chore: 初始化前端仓库并迁移 admin-ui

- 创建 rui-frontend 前端仓库
- 迁移 admin-ui 管理后台
- 创建 cashier-mobile 和 customer-mobile 占位项目
- 配置 pnpm workspace
This commit is contained in:
2026-06-04 05:14:11 +08:00
commit 82a19101a8
153 changed files with 21561 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# 管理后台默认租户编号
# 该值会在每个 HTTP 请求的 X-Tenant-Id 请求头中透传至后端
VITE_TENANT_ID=1
# OAuth2 客户端密钥(client_id:client_secret 的 base64 编码)
# 用于 /oauth2/token 接口的 Basic 认证
VITE_OAUTH2_CLIENT_SECRET=cnVpLWNsaWVudDpydWktc2VjcmV0
+18
View File
@@ -0,0 +1,18 @@
# 管理后台 PC
Vue 3 + TypeScript + Vite + Element Plus + UnoCSS
## 启动
```bash
pnpm install
pnpm dev
```
访问 `http://localhost:3000`
## 构建
```bash
pnpm build
```
+20
View File
@@ -0,0 +1,20 @@
{
"key": "cashier",
"name": "收银系统",
"description": "面向收银场景的管理后台",
"modules": ["system", "user", "cms", "cashier"],
"login": {
"component": "Cashier",
"showTenantInput": true,
"title": "睿核收银",
"subtitle": "门店管理系统"
},
"dashboard": {
"component": "Cashier",
"title": "收银数据概览"
},
"theme": {
"primaryColor": "#1677ff",
"title": "睿核收银"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"key": "default",
"name": "默认系统",
"description": "开发测试用,包含所有模块",
"modules": ["system", "user", "order", "cms", "marketing", "demo", "cashier"],
"login": {
"component": "Default",
"showTenantInput": true,
"title": "睿核通用平台",
"subtitle": "管理后台登录"
},
"dashboard": {
"component": "Default",
"title": "数据概览"
},
"theme": {
"primaryColor": "#1677ff",
"title": "睿核通用平台"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"key": "super",
"name": "超级管理后台",
"description": "超级租户专用,包含租户管理",
"modules": ["system", "user"],
"login": {
"component": "Super",
"showTenantInput": false,
"title": "睿核平台管理",
"subtitle": "超级管理员登录"
},
"dashboard": {
"component": "Super",
"title": "平台运营概览"
},
"theme": {
"primaryColor": "#722ed1",
"title": "睿核平台管理"
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>睿核通用平台 — 管理后台</title>
<style>
* { margin: 0; padding: 0; }
html, body, #app { height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{
"name": "admin-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:cashier": "vite --port 3000 -- --system=cashier",
"dev:super": "vite --port 3000 -- --system=super",
"build": "vite build",
"build:cashier": "vite build -- --system=cashier",
"build:super": "vite build -- --system=super",
"build:admin": "vite build -- --system=admin",
"build:all": "pnpm build:cashier && pnpm build:super && pnpm build:admin",
"type-check": "vue-tsc",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3",
"@iconify-json/tabler": "^1.2.35",
"@iconify/vue": "^5.0.1",
"axios": "^1.7",
"element-plus": "^2.9",
"pinia": "^2.2",
"vue": "^3.5",
"vue-i18n": "^10.0",
"vue-router": "^4.4"
},
"devDependencies": {
"@antfu/eslint-config": "^3.11",
"@unocss/preset-attributify": "^0.65",
"@unocss/preset-icons": "^0.65",
"@unocss/preset-uno": "^0.65",
"@vitejs/plugin-vue": "^5.2",
"@vue/tsconfig": "^0.7",
"eslint": "^9.15",
"sass": "^1.81",
"typescript": "~5.6",
"unocss": "^0.65",
"unplugin-auto-import": "^0.18",
"unplugin-vue-components": "^0.28",
"vite": "^6.0",
"vue-tsc": "^2.1"
}
}
+5948
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
unrs-resolver: true
vue-demi: true
@@ -0,0 +1,125 @@
import type { Plugin, ViteDevServer } from 'vite'
import fs from 'fs'
import path from 'path'
import type { BuildConfig } from '../src/types/system-config'
/**
* 模块路由映射表
*/
const moduleRouteMap: Record<string, string> = {
system: "import { systemRoutes } from '@/router/modules/system'",
user: "import { userRoutes } from '@/router/modules/user'",
order: "import { orderRoutes } from '@/router/modules/order'",
cms: "import { cmsRoutes } from '@/router/modules/cms'",
marketing: "import { marketingRoutes } from '@/router/modules/marketing'",
demo: "import { demoRoutes } from '@/router/modules/demo'",
cashier: "import { cashierRoutes } from '@/router/modules/cashier'",
}
/**
* 生成路由代码
*/
function generateRoutesCode(config: BuildConfig): string {
const imports = config.modules
.map((module) => moduleRouteMap[module])
.filter(Boolean)
.join('\n')
const routeArrays = config.modules
.map((module) => {
const varName = '...' + module + 'Routes'
return varName
})
.join(', ')
return `
import { coreRoutes } from '@/router/modules/core'
${imports}
const routes = [
...coreRoutes.slice(0, 1), // login route
{
path: '/',
component: () => import('@/layout/DefaultLayout.vue'),
redirect: '/dashboard',
children: [
...coreRoutes[1].children, // dashboard, profile, settings
${routeArrays},
],
},
]
export default routes
`
}
/**
* 生成系统配置代码
*/
function generateConfigCode(config: BuildConfig): string {
return `export default ${JSON.stringify(config, null, 2)}`
}
/**
* Vite 模块构建插件
*/
export default function moduleBuildPlugin(): Plugin {
let config: BuildConfig | null = null
let systemKey = 'default'
return {
name: 'vite-plugin-module-build',
enforce: 'pre',
config(_config, { command }) {
// 从命令行参数读取系统标识
const args = process.argv
const systemArg = args.find((arg) => arg.startsWith('--system='))
if (systemArg) {
systemKey = systemArg.replace('--system=', '')
}
// 读取配置文件
const configPath = path.resolve(process.cwd(), 'build-config', `${systemKey}.json`)
if (!fs.existsSync(configPath)) {
throw new Error(`System config not found: ${configPath}`)
}
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
// 修改构建输出目录
return {
build: {
outDir: `dist/${config.key}`,
},
}
},
resolveId(id) {
if (id === 'virtual:generated-routes' || id === 'virtual:system-config') {
return id
}
return null
},
load(id) {
if (!config) return null
if (id === 'virtual:generated-routes') {
return generateRoutesCode(config)
}
if (id === 'virtual:system-config') {
return generateConfigCode(config)
}
return null
},
configureServer(server: ViteDevServer) {
// 开发模式下,当配置文件变更时重启服务
const configPath = path.resolve(server.config.root, 'build-config', `${systemKey}.json`)
server.watcher.add(configPath)
},
}
}
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import systemConfig from 'virtual:system-config'
const app = useAppStore()
const locale = computed(() => app.lang === 'zh-CN' ? zhCn : en)
onMounted(() => {
// 设置页面标题
document.title = systemConfig.theme.title
// 设置主题色
const el = document.documentElement
el.style.setProperty('--el-color-primary', systemConfig.theme.primaryColor)
})
</script>
<template>
<el-config-provider :locale="locale">
<router-view />
<!-- 全屏退出角标 -->
<div v-if="app.pageFullscreen" class="fs-exit" @click="app.togglePageFullscreen()">
<el-icon :size="18"><Close /></el-icon>
</div>
</el-config-provider>
</template>
<style>
:root { --el-bg-color-page: #f5f6fa; }
html.dark { --el-bg-color-page: #111; color-scheme: dark; }
html.dark body { background: #111; color: #e5e7eb; }
.fullscreen-main {
position: fixed !important; inset: 0 !important; z-index: 100 !important;
background: #f5f6fa !important; padding: 20px !important; overflow: auto !important;
}
html.dark .fullscreen-main { background: #111 !important; }
.fs-exit {
position: fixed; top: 16px; right: 16px; z-index: 200;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(0,0,0,0.4); color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.2s;
}
.fs-exit:hover { background: rgba(0,0,0,0.6); }
/* ======================== rui-dialog 全局样式规则 ======================== */
/**
* 所有使用 el-dialog 的组件必须添加 class="rui-dialog"
* 规则说明:
* 1. 内容区域最大高度 60vh,超出滚动
* 2. 底部 footer 固定不参与滚动
* 3. 用法:<el-dialog class="rui-dialog" ...>
*/
.rui-dialog .el-dialog__body {
max-height: 60vh;
overflow-y: auto;
padding: 20px;
}
.rui-dialog .el-dialog__footer {
border-top: 1px solid var(--el-border-color-lighter);
padding: 12px 20px;
background: var(--el-bg-color);
}
</style>
+88
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, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+77
View File
@@ -0,0 +1,77 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
CashierTablePage: typeof import('./components/CashierTablePage.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
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']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
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']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
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']
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']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
IconPicker: typeof import('./components/IconPicker.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
RuiIcon: typeof import('./components/RuiIcon.vue')['default']
RuiTable: typeof import('./components/RuiTable.vue')['default']
TagsBar: typeof import('./components/TagsBar.vue')['default']
ThemeDrawer: typeof import('./components/ThemeDrawer.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
title: string
columns: any[]
loading?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'add'): void
(e: 'refresh'): void
}>()
const searchForm = ref({})
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">{{ title }}</h2>
<div class="toolbar mb-4">
<el-button type="primary" @click="emit('add')">
新增
</el-button>
<el-button @click="emit('refresh')">
刷新
</el-button>
</div>
<el-table
v-loading="loading"
:data="[]"
stripe
border
style="width: 100%"
>
<el-table-column
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
/>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
.toolbar {
display: flex;
gap: 8px;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Icon } from '@iconify/vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import RuiIcon from './RuiIcon.vue'
const visible = defineModel<boolean>()
const emit = defineEmits<{ select: [icon: string] }>()
const search = ref('')
const tab = ref<'ep' | 'tabler' | 'svg' | 'other'>('ep')
// ==================== Element Plus 图标 ====================
const epIcons = Object.keys(ElementPlusIconsVue)
const epFiltered = computed(() => {
if (!search.value) return epIcons
return epIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
})
// ==================== Tabler Icons (通过 Iconify) ====================
// Tabler 图标集前缀为 "tabler:"
// 这里列出常用的 Tabler 图标,实际使用时可以从 @iconify-json/tabler 获取完整列表
const tablerIcons = [
'tabler:home', 'tabler:settings', 'tabler:user', 'tabler:users',
'tabler:file', 'tabler:folder', 'tabler:mail', 'tabler:bell',
'tabler:search', 'tabler:plus', 'tabler:edit', 'tabler:trash',
'tabler:check', 'tabler:x', 'tabler:arrow-left', 'tabler:arrow-right',
'tabler:arrow-up', 'tabler:arrow-down', 'tabler:chevron-left',
'tabler:chevron-right', 'tabler:chevron-up', 'tabler:chevron-down',
'tabler:menu', 'tabler:dots', 'tabler:dots-vertical',
'tabler:refresh', 'tabler:reload', 'tabler:download', 'tabler:upload',
'tabler:share', 'tabler:link', 'tabler:external-link',
'tabler:lock', 'tabler:lock-open', 'tabler:key', 'tabler:shield',
'tabler:eye', 'tabler:eye-off', 'tabler:copy', 'tabler:clipboard',
'tabler:calendar', 'tabler:clock', 'tabler:timer', 'tabler:history',
'tabler:chart-bar', 'tabler:chart-line', 'tabler:chart-pie',
'tabler:trending-up', 'tabler:trending-down', 'tabler:activity',
'tabler:dashboard', 'tabler:gauge', 'tabler:speedboat',
'tabler:building', 'tabler:building-store', 'tabler:building-bank',
'tabler:briefcase', 'tabler:certificate', 'tabler:award',
'tabler:star', 'tabler:star-off', 'tabler:heart', 'tabler:thumb-up',
'tabler:message', 'tabler:messages', 'tabler:notification',
'tabler:device-desktop', 'tabler:device-mobile', 'tabler:devices',
'tabler:wifi', 'tabler:bluetooth', 'tabler:cloud', 'tabler:database',
'tabler:server', 'tabler:box', 'tabler:package', 'tabler:truck',
'tabler:map', 'tabler:map-pin', 'tabler:location',
'tabler:phone', 'tabler:video', 'tabler:camera', 'tabler:photo',
'tabler:music', 'tabler:volume', 'tabler:microphone',
'tabler:book', 'tabler:bookmark', 'tabler:flag', 'tabler:tag',
'tabler:tags', 'tabler:label', 'tabler:pin',
'tabler:brush', 'tabler:palette', 'tabler:color-swatch',
'tabler:tools', 'tabler:tool', 'tabler:hammer', 'tabler:wrench',
'tabler:bug', 'tabler:code', 'tabler:terminal', 'tabler:prompt',
'tabler:git-branch', 'tabler:git-commit', 'tabler:git-merge',
'tabler:layout', 'tabler:layout-grid', 'tabler:layout-list',
'tabler:template', 'tabler:components', 'tabler:puzzle',
'tabler:adjustments', 'tabler:slider', 'tabler:toggle-left',
'tabler:filter', 'tabler:sort-ascending', 'tabler:sort-descending',
'tabler:zoom-in', 'tabler:zoom-out', 'tabler:maximize', 'tabler:minimize',
'tabler:login', 'tabler:logout', 'tabler:user-plus', 'tabler:user-minus',
'tabler:user-check', 'tabler:user-x', 'tabler:user-circle',
'tabler:id', 'tabler:passport', 'tabler:fingerprint',
'tabler:credit-card', 'tabler:wallet', 'tabler:cash', 'tabler:coin',
'tabler:receipt', 'tabler:file-invoice', 'tabler:report',
'tabler:printer', 'tabler:scan', 'tabler:qrcode',
'tabler:shopping-cart', 'tabler:basket', 'tabler:bag',
'tabler:rocket', 'tabler:flame', 'tabler:bolt', 'tabler:bulb',
'tabler:help', 'tabler:info-circle', 'tabler:alert-circle',
'tabler:alert-triangle', 'tabler:circle-check', 'tabler:circle-x',
'tabler:ban', 'tabler:plus-circle', 'tabler:minus-circle',
'tabler:exchange', 'tabler:transfer', 'tabler:send', 'tabler:mail-forward',
'tabler:inbox', 'tabler:archive', 'tabler:trash-off',
'tabler:undo', 'tabler:redo', 'tabler:rotate', 'tabler:rotate-clockwise',
'tabler:layers', 'tabler:layer', 'tabler:stack',
'tabler:affiliate', 'tabler:apps', 'tabler:grid',
'tabler:world', 'tabler:globe', 'tabler:language',
'tabler:sun', 'tabler:moon', 'tabler:brightness',
'tabler: Temperature', 'tabler:wind', 'tabler:droplet',
'tabler:leaf', 'tabler:plant', 'tabler:tree',
'tabler:anchor', 'tabler:ship', 'tabler:plane',
'tabler:train', 'tabler:bus', 'tabler:car',
'tabler:bike', 'tabler:walk', 'tabler:run',
'tabler:school', 'tabler:book-2', 'tabler:notebook',
'tabler:notes', 'tabler:clipboard-list', 'tabler:clipboard-check',
'tabler:list', 'tabler:list-check', 'tabler:list-details',
'tabler:checklist', 'tabler:task', 'tabler:tasks',
'tabler:kanban', 'tabler:gantt', 'tabler:timeline',
'tabler:chart-arcs', 'tabler:chart-dots', 'tabler:chart-circles',
'tabler: pollution', 'tabler:recycle', 'tabler:eco',
'tabler:binary', 'tabler:cpu', 'tabler:memory',
'tabler:network', 'tabler:router', 'tabler:firewall',
'tabler:shield-check', 'tabler:shield-lock', 'tabler:shield-off',
'tabler:key-off', 'tabler:lock-access',
'tabler:subtask', 'tabler:parentheses', 'tabler:braces',
'tabler:quote', 'tabler:blockquote',
'tabler:math', 'tabler:calculator', 'tabler:abacus',
'tabler:clock-hour-1', 'tabler:clock-hour-2', 'tabler:clock-hour-3',
'tabler:calendar-event', 'tabler:calendar-plus', 'tabler:calendar-minus',
'tabler:clock-play', 'tabler:clock-pause', 'tabler:clock-stop',
'tabler:alarm', 'tabler:stopwatch', 'tabler:hourglass',
]
const tablerFiltered = computed(() => {
if (!search.value) return tablerIcons
return tablerIcons.filter(i => i.toLowerCase().includes(search.value.toLowerCase()))
})
// ==================== SVG 示例 ====================
const svgIcons = [
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/></svg>',
'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>',
]
function select(icon: string) {
emit('select', icon)
visible.value = false
}
</script>
<template>
<el-dialog class="rui-dialog" v-model="visible" title="选择图标" width="800px">
<el-tabs v-model="tab">
<!-- Element Plus 图标 -->
<el-tab-pane label="Element Plus" name="ep">
<el-input v-model.trim="search" placeholder="搜索图标名..." clearable class="mb-3">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="icon-grid">
<div v-for="icon in epFiltered" :key="icon" class="icon-cell" @click="select(icon)">
<RuiIcon :icon="icon" :size="24" />
<span class="text-xs mt-1 truncate w-full text-center">{{ icon }}</span>
</div>
</div>
<div v-if="epFiltered.length === 0" class="text-center text-gray-400 py-8">
未找到匹配的图标
</div>
</el-tab-pane>
<!-- Tabler Icons -->
<el-tab-pane :label="`Tabler (${tablerIcons.length})`" name="tabler">
<el-input v-model.trim="search" placeholder="搜索 Tabler 图标..." clearable class="mb-3">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="icon-grid">
<div v-for="icon in tablerFiltered" :key="icon" class="icon-cell" @click="select(icon)">
<Icon :icon="icon" width="24" height="24" />
<span class="text-xs mt-1 truncate w-full text-center">{{ icon.replace('tabler:', '') }}</span>
</div>
</div>
<div v-if="tablerFiltered.length === 0" class="text-center text-gray-400 py-8">
未找到匹配的图标
</div>
</el-tab-pane>
<!-- SVG -->
<el-tab-pane label="SVG" name="svg">
<div class="icon-grid">
<div v-for="(svg, i) in svgIcons" :key="i" class="icon-cell" @click="select(svg)">
<RuiIcon :icon="svg" :size="24" />
<span class="text-xs mt-1">SVG {{ i + 1 }}</span>
</div>
</div>
</el-tab-pane>
<!-- 图片/URL -->
<el-tab-pane label="图片/URL" name="other">
<div class="p-4 text-center text-gray-400 text-sm">
粘贴图片 URL 到输入框即可使用
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<style scoped>
.icon-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 8px;
max-height: 50vh;
overflow: auto;
}
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
aspect-ratio: 1;
}
.icon-cell:hover {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
@media (max-width: 768px) {
.icon-grid {
grid-template-columns: repeat(6, 1fr);
}
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Icon } from '@iconify/vue'
const props = defineProps<{
icon?: string // ep icon name, iconify name, svg string, font class, or image url
size?: number
color?: string
}>()
const type = computed<'ep' | 'iconify' | 'svg' | 'font' | 'img' | 'none'>(() => {
if (!props.icon) return 'none'
if (props.icon.startsWith('<svg') || props.icon.startsWith('<?xml')) return 'svg'
if (props.icon.startsWith('http') || props.icon.startsWith('/')) return 'img'
if (props.icon.includes(' ')) return 'font'
// Iconify 格式:包含冒号,如 tabler:settings
if (props.icon.includes(':')) return 'iconify'
return 'ep'
})
const style = computed(() => ({
fontSize: (props.size || 16) + 'px',
color: props.color,
width: (props.size || 16) + 'px',
height: (props.size || 16) + 'px',
}))
</script>
<template>
<span v-if="type === 'ep'" class="rui-icon ep"><el-icon :size="size" :color="color"><component :is="icon" /></el-icon></span>
<span v-else-if="type === 'iconify' && icon" class="rui-icon iconify" :style="style"><Icon :icon="icon" :width="size || 16" :height="size || 16" :color="color" /></span>
<span v-else-if="type === 'svg'" class="rui-icon svg" :style="style" v-html="icon" />
<span v-else-if="type === 'font'" class="rui-icon font" :style="style" :class="icon" />
<img v-else-if="type === 'img'" class="rui-icon img" :src="icon" :style="style" />
</template>
<style scoped>
.rui-icon { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; }
.rui-icon.svg :deep(svg) { width: 100%; height: 100%; }
.rui-icon.img { object-fit: contain; }
</style>
+752
View File
@@ -0,0 +1,752 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { RefreshRight, ArrowDown, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
/**
* 数据类型,用于自动格式化
*/
export type DataType =
| 'dateTime' // 日期时间:yyyy-MM-dd HH:mm:ss
| 'date' // 日期:yyyy-MM-dd
| 'time' // 时间:HH:mm:ss
| 'number' // 数字:千分位
| 'money' // 金额:保留2位小数
| 'percent' // 百分比
| 'enum' // 枚举:配合 enumMap 使用
/**
* 表格列配置
*/
export interface TableColumn {
/** 字段名 */
prop: string
/** 列标题 */
label: string
/** 列宽度 */
width?: string | number
/** 最小宽度 */
minWidth?: string | number
/** 固定位置 */
fixed?: 'left' | 'right'
/** 是否可排序 */
sortable?: boolean | 'custom'
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
/** 数据类型,用于自动格式化(优先级高于 formatter) */
dataType?: DataType
/** 枚举映射(dataType='enum' 时使用) */
enumMap?: Record<string | number, string>
/** 格式化函数(dataType 无法满足时使用) */
formatter?: (row: any, column: any, cellValue: any, index: number) => string
/** 是否使用自定义 slotslot 名为 column-{prop} */
slot?: boolean
/** 是否隐藏 */
hide?: boolean
/** 是否显示提示(show-overflow-tooltip */
tooltip?: boolean
/** 是否可导出 */
exportable?: boolean
/** 是否可编辑 */
editable?: boolean
/** 编辑类型 input/select/switch/number */
editType?: 'input' | 'select' | 'switch' | 'number'
/** 编辑选项(editType='select' 时使用) */
editOptions?: { label: string; value: any }[]
}
/**
* 分页查询结果
*/
export interface PageResult<T = any> {
/** 列表数据 */
list: T[]
/** 总记录数 */
total: number
}
/**
* 分页参数
*/
export interface PageParams {
page: number
size: number
}
/**
* 排序参数
*/
export interface SortParams {
sortField?: string
sortOrder?: 'asc' | 'desc'
}
const props = withDefaults(defineProps<{
/** 列配置 */
columns: TableColumn[]
/** 数据加载函数,接收分页参数,返回 Promise<PageResult> */
loadData: (params: PageParams & Record<string, any>) => Promise<PageResult>
/** 是否立即加载 */
immediate?: boolean
/** 行 key */
rowKey?: string
/** 显示序号列 */
showIndex?: boolean
/** 显示选择列 */
showSelection?: boolean
/** 显示工具栏 */
showToolbar?: boolean
/** 默认分页大小 */
defaultPageSize?: number
/** 分页大小选项 */
pageSizes?: number[]
/** 是否支持导出 */
exportable?: boolean
/** 导出文件名 */
exportFilename?: string
/** 是否支持导入 */
importable?: boolean
/** 导入接口地址 */
importUrl?: string
}>(), {
immediate: true,
rowKey: 'id',
showIndex: true,
showSelection: false,
showToolbar: true,
defaultPageSize: 10,
pageSizes: () => [10, 20, 50, 100],
exportable: false,
exportFilename: '数据导出',
importable: false,
})
const emit = defineEmits<{
/** 刷新后触发 */
(e: 'refresh', data: any[]): void
/** 选择变化 */
(e: 'selection-change', rows: any[]): void
/** 行内保存 */
(e: 'row-save', row: any): void
}>()
// ==================== 导入 ====================
/** 导入弹窗显示状态 */
const importVisible = ref(false)
/** 导入文件 */
const importFile = ref<File | null>(null)
/** 导入加载状态 */
const importLoading = ref(false)
/**
* 处理导入
*/
async function handleImport() {
if (!importFile.value) {
ElMessage.warning('请选择文件')
return
}
importLoading.value = true
try {
const formData = new FormData()
formData.append('file', importFile.value)
const res: any = await request({
url: props.importUrl || '',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.error === 0) {
ElMessage.success(`导入成功,共 ${res.data} 条记录`)
importVisible.value = false
importFile.value = null
load()
} else {
ElMessage.error(res.message || '导入失败')
}
} catch {
// 错误已由请求拦截器统一提示
} finally {
importLoading.value = false
}
}
// 加载状态
const loading = ref(false)
// 数据列表
const tableData = ref<any[]>([])
// 分页参数
const pagination = reactive({
page: 1,
size: props.defaultPageSize,
total: 0,
})
// 查询参数(由外部通过 search slot 填充)
const queryParams = reactive<Record<string, any>>({})
// 排序参数
const sortParams = reactive<SortParams>({
sortField: undefined,
sortOrder: undefined,
})
// 列显示控制
const columnVisibility = reactive<Record<string, boolean>>({})
// 初始化列显示状态
function initColumnVisibility() {
props.columns.forEach(col => {
if (columnVisibility[col.prop] === undefined) {
columnVisibility[col.prop] = !col.hide
}
})
}
initColumnVisibility()
// ==================== 行内编辑 ====================
/** 当前正在编辑的行 ID */
const editingRowId = ref<string | number | null>(null)
/** 编辑中的行数据(深拷贝) */
const editingRowData = ref<any>({})
/**
* 进入编辑模式
*/
function startEdit(row: any) {
editingRowId.value = row[props.rowKey]
editingRowData.value = JSON.parse(JSON.stringify(row))
}
/**
* 保存编辑
*/
function saveEdit(row: any) {
emit('row-save', editingRowData.value)
editingRowId.value = null
editingRowData.value = {}
}
/**
* 取消编辑
*/
function cancelEdit() {
editingRowId.value = null
editingRowData.value = {}
}
// 可见列(考虑用户自定义显示/隐藏)
const visibleColumns = computed(() => {
return props.columns.filter(c => columnVisibility[c.prop] !== false)
})
/**
* 加载数据
*/
async function load() {
loading.value = true
try {
const params: any = {
page: pagination.page,
size: pagination.size,
...queryParams,
}
// 添加排序参数
if (sortParams.sortField && sortParams.sortOrder) {
params.sortField = sortParams.sortField
params.sortOrder = sortParams.sortOrder
}
const res = await props.loadData(params)
tableData.value = res.list || []
pagination.total = res.total || 0
emit('refresh', tableData.value)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
/**
* 搜索(重置到第一页)
*/
function search() {
pagination.page = 1
load()
}
/**
* 重置(清空查询参数并加载)
*/
function reset() {
Object.keys(queryParams).forEach(key => delete queryParams[key])
// 重置排序
sortParams.sortField = undefined
sortParams.sortOrder = undefined
pagination.page = 1
load()
}
/**
* 刷新(保持当前页)
*/
function refresh() {
load()
}
/**
* 分页大小变化
*/
function handleSizeChange(val: number) {
pagination.size = val
pagination.page = 1
load()
}
/**
* 页码变化
*/
function handleCurrentChange(val: number) {
pagination.page = val
load()
}
/**
* 排序变化
*/
function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
if (order) {
sortParams.sortField = prop
sortParams.sortOrder = order === 'ascending' ? 'asc' : 'desc'
} else {
sortParams.sortField = undefined
sortParams.sortOrder = undefined
}
load()
}
/**
* 选择变化
*/
function handleSelectionChange(rows: any[]) {
emit('selection-change', rows)
}
/**
* 设置查询参数(供外部调用)
*/
function setQueryParams(params: Record<string, any>) {
Object.assign(queryParams, params)
}
/**
* 切换列显示/隐藏
*/
function toggleColumn(prop: string, visible: boolean) {
columnVisibility[prop] = visible
}
/**
* 导出 CSV
*/
function exportData() {
if (!tableData.value.length) {
ElMessage.warning('暂无数据可导出')
return
}
// 表头
const headers = visibleColumns.value
.filter(col => col.exportable !== false)
.map(col => col.label)
// 数据行
const rows = tableData.value.map(row => {
return visibleColumns.value
.filter(col => col.exportable !== false)
.map(col => {
const val = row[col.prop]
const formatter = getFormatter(col)
if (formatter) {
return formatter(row, col, val, 0)
}
return val ?? ''
})
})
// 构建 CSV 内容
const csvContent = [
headers.join(','),
...rows.map(row =>
row.map(cell => {
const str = String(cell).replace(/"/g, '""')
return `"${str}"`
}).join(',')
),
].join('\n')
// 下载文件
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${props.exportFilename}_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(link.href)
ElMessage.success('导出成功')
}
/**
* 根据 dataType 获取 formatter 函数
*/
function getFormatter(col: TableColumn): ((row: any, column: any, cellValue: any, index: number) => string) | undefined {
// 如果已定义 formatter,优先使用
if (col.formatter) {
return col.formatter
}
// 根据 dataType 自动格式化
switch (col.dataType) {
case 'dateTime':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleString()
}
case 'date':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleDateString()
}
case 'time':
return (_row: any, _column: any, val: any) => {
if (!val) return '-'
const d = new Date(val)
return isNaN(d.getTime()) ? String(val) : d.toLocaleTimeString()
}
case 'number':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : num.toLocaleString()
}
case 'money':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : '¥' + num.toFixed(2)
}
case 'percent':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
const num = Number(val)
return isNaN(num) ? String(val) : (num * 100).toFixed(2) + '%'
}
case 'enum':
return (_row: any, _column: any, val: any) => {
if (val == null) return '-'
return col.enumMap?.[val] ?? String(val)
}
default:
return undefined
}
}
// 暴露方法
defineExpose({
load,
search,
reset,
refresh,
setQueryParams,
startEdit,
saveEdit,
cancelEdit,
})
onMounted(() => {
if (props.immediate) {
load()
}
})
</script>
<template>
<div class="rui-table">
<!-- 查询区域 -->
<div v-if="$slots.search" class="search-area">
<el-form :model="queryParams" inline>
<slot name="search" :query="queryParams" :search="search" :reset="reset" />
</el-form>
</div>
<!-- 工具栏 -->
<div v-if="showToolbar" class="toolbar">
<div class="toolbar-left">
<slot name="toolbar-left" />
</div>
<div class="toolbar-right">
<slot name="toolbar-right" />
<!-- 列显示控制 -->
<el-dropdown v-if="columns.length > 0" trigger="click">
<el-button :icon="ArrowDown" circle size="small" title="列设置" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="col in columns"
:key="col.prop"
:divided="false"
>
<el-checkbox
:model-value="columnVisibility[col.prop]"
@update:model-value="(val) => toggleColumn(col.prop, val as boolean)"
>
{{ col.label }}
</el-checkbox>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 导入按钮 -->
<el-button
v-if="importable"
type="warning"
circle
size="small"
title="导入"
@click="importVisible = true"
>
<el-icon><Upload /></el-icon>
</el-button>
<!-- 导出按钮 -->
<el-button
v-if="exportable"
type="success"
circle
size="small"
title="导出"
@click="exportData"
>
<el-icon><ArrowDown /></el-icon>
</el-button>
<el-button :icon="RefreshRight" circle size="small" title="刷新" @click="refresh" />
</div>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="tableData"
:row-key="rowKey"
stripe
border
style="width: 100%"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<!-- 选择列 -->
<el-table-column v-if="showSelection" type="selection" width="50" align="center" />
<!-- 序号列 -->
<el-table-column v-if="showIndex" type="index" label="序号" width="60" align="center" />
<!-- 动态列 -->
<template v-for="col in visibleColumns" :key="col.prop">
<!-- 自定义 slot 列 -->
<el-table-column
v-if="col.slot"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
:sortable="col.sortable"
:align="col.align || 'left'"
:show-overflow-tooltip="col.tooltip"
>
<template #default="scope">
<slot :name="`column-${col.prop}`" v-bind="scope" />
</template>
</el-table-column>
<!-- 普通列(支持行内编辑) -->
<el-table-column
v-else
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
:sortable="col.sortable"
:align="col.align || 'left'"
:show-overflow-tooltip="col.tooltip"
>
<template #default="scope">
<!-- 编辑模式 -->
<template v-if="col.editable && editingRowId === scope.row[rowKey]">
<el-input
v-if="!col.editType || col.editType === 'input'"
v-model="editingRowData[col.prop]"
size="small"
/>
<el-input-number
v-else-if="col.editType === 'number'"
v-model="editingRowData[col.prop]"
size="small"
style="width: 100%"
/>
<el-select
v-else-if="col.editType === 'select'"
v-model="editingRowData[col.prop]"
size="small"
style="width: 100%"
>
<el-option
v-for="opt in col.editOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-switch
v-else-if="col.editType === 'switch'"
v-model="editingRowData[col.prop]"
size="small"
/>
</template>
<!-- 查看模式 -->
<template v-else>
{{ getFormatter(col)?.(scope.row, col, scope.row[col.prop], scope.$index) ?? scope.row[col.prop] }}
</template>
</template>
</el-table-column>
</template>
<!-- 操作列 -->
<el-table-column label="操作" fixed="right" min-width="120" align="center">
<template #default="scope">
<!-- 行内编辑操作 -->
<template v-if="columns.some(c => c.editable)">
<template v-if="editingRowId === scope.row[rowKey]">
<el-button link type="primary" size="small" @click="saveEdit(scope.row)">
保存
</el-button>
<el-button link size="small" @click="cancelEdit">
取消
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" @click="startEdit(scope.row)">
编辑
</el-button>
<slot name="action" v-bind="scope" />
</template>
</template>
<template v-else>
<slot name="action" v-bind="scope" />
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-area">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="pageSizes"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 导入弹窗 -->
<el-dialog
v-model="importVisible"
title="导入数据"
width="500px"
class="rui-dialog"
>
<el-upload
drag
action="#"
:auto-upload="false"
:on-change="(file: any) => importFile = file.raw"
accept=".xlsx,.xls"
:limit="1"
>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
仅支持 .xlsx / .xls 格式
</div>
</template>
</el-upload>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="handleImport">
确认导入
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.rui-table {
background: var(--el-bg-color-overlay);
border-radius: 8px;
padding: 20px;
}
.search-area {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.toolbar-left {
display: flex;
gap: 8px;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.pagination-area {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.el-dropdown-menu__item) {
padding: 0 16px;
}
</style>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import {watch, ref, nextTick} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {useTagsStore} from '@/stores/tags'
import {useAppStore} from '@/stores/app'
import {storeToRefs} from 'pinia'
const route = useRoute()
const router = useRouter()
const tags = useTagsStore()
const app = useAppStore()
const { t } = useI18n()
const {visited} = storeToRefs(tags)
watch(() => route.path, () => tags.addTag(route), {immediate: true})
function handleClose(path: string, index: number) {
tags.removeTag(path)
if (route.path === path) {
const prev = visited.value[index - 1] || visited.value[index]
if (prev) router.push(prev.path)
}
}
function handleCommand(cmd: string, path: string, index: number) {
if (cmd === 'close') handleClose(path, index)
if (cmd === 'refresh') {
// 通过 query 参数触发重新渲染
router.replace({ path, query: { _t: String(Date.now()) } })
}
if (cmd === 'fullscreen') {
if (route.path !== path) router.push(path) // 先切换到目标页面
nextTick(() => app.togglePageFullscreen()) // 等页面切换后再全屏
}
if (cmd === 'closeOther') tags.closeOther(path)
if (cmd === 'closeAll') { tags.closeAll(); router.push('/dashboard') }
hideMenu()
}
const contextMenu = ref({ visible: false, x: 0, y: 0, path: '', index: 0 })
function showContextMenu(e: MouseEvent, path: string, index: number) {
e.preventDefault()
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, path, index }
}
function hideMenu() {
contextMenu.value.visible = false
}
function scrollTags(dir: number) {
const wrap = document.querySelector('.tags-bar .el-scrollbar__wrap') as HTMLElement
if (wrap) wrap.scrollBy({ left: dir * 200, behavior: 'smooth' })
}
</script>
<template>
<div class="tags-bar">
<span class="tag-arrow" @click="scrollTags(-1)"><el-icon :size="16"><ArrowLeft /></el-icon></span>
<el-scrollbar>
<div class="tags-list">
<div
v-for="(tag, i) in visited" :key="tag.path"
:class="['tag', { active: route.path === tag.path }]"
@click="router.push(tag.path)"
@contextmenu.prevent="showContextMenu($event, tag.path, i)"
>
<div class="tag-wrap">
<span class="tag-text">{{ t(tag.title) || tag.title }}</span>
<span
v-if="tag.path !== '/dashboard'"
class="tag-close"
@click.stop="handleClose(tag.path, i)"
><el-icon :size="12"><Close/></el-icon></span>
</div>
</div>
</div>
</el-scrollbar>
<span class="tag-arrow" @click="scrollTags(1)"><el-icon :size="16"><ArrowRight /></el-icon></span>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<div class="context-item" @click="handleCommand('close', contextMenu.path, contextMenu.index)">{{ t('context.close') }}</div>
<div class="context-item" @click="handleCommand('refresh', contextMenu.path, contextMenu.index)">{{ t('context.refresh') }}</div>
<div class="context-item" @click="handleCommand('fullscreen', contextMenu.path, contextMenu.index)">{{ t('context.fullscreen') }}</div>
<div class="context-divider" />
<div class="context-item" @click="handleCommand('closeOther', contextMenu.path, contextMenu.index)">{{ t('context.closeOther') }}</div>
<div class="context-item" @click="handleCommand('closeAll', contextMenu.path, contextMenu.index)">{{ t('context.closeAll') }}</div>
</div>
<div v-if="contextMenu.visible" class="context-overlay" @click="hideMenu" />
</teleport>
</div>
</template>
<style scoped>
.tags-bar {
background-color: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
position: relative; z-index: 4; display: flex; align-items: center;
padding-top: 22px;
}
html.dark .tags-bar { border-color: #2a2a2a; }
.tags-bar .el-scrollbar__wrap {
overflow-x:auto !important
}
.tags-list {
margin: 0;
display: flex;
align-items: center;
color: var(--el-text-color-regular);
font-size: 12px;
white-space: nowrap;
padding:0 15px;
}
.tag {
display: flex;
align-items: center;
padding: 0 7px;
margin-right: 5px;
border-radius: 2px;
position: relative;
z-index: 0;
cursor: pointer;
justify-content: space-between;
height: 26px;
border-width: 1px 27px 1px;
border-style: solid;
border-color: transparent;
margin: 0 -15px;
}
.tag:hover {
border-color:var(--el-color-primary-light-5);
background-color: var(--el-color-primary-light-5);
color:unset;
}
.active, .tag:hover {
-webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
-webkit-mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
-webkit-mask-position: right bottom, left bottom, center top;
-webkit-mask-repeat: no-repeat;
mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+), url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg==), url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
mask-position: right bottom, left bottom, center top;
mask-repeat: no-repeat;
}
.tag-close {
border-radius: 100%;
width: 14px; height: 14px; text-align: center; line-height: 14px;
display: flex; align-items: center; justify-content: center;
opacity: 0.6; transition: all 0.15s;
}
.tag.active .tag-close { color: rgba(255,255,255,0.7); }
.tag-close:hover { opacity: 1; background-color: var(--el-color-primary-light-3); color: #fff; }
.tag-wrap{
display: flex;
align-items: center;
justify-items: center ;
}
.tag:hover {
color:unset;
z-index: 2;
}
.tags-list .active {
color: var(--el-color-white);
border-color: var(--el-color-primary);
z-index: 1;
background-color: var(--el-color-primary);
}
.tag-arrow {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 4px; cursor: pointer;
color: #999; flex-shrink: 0; transition: all 0.15s;
}
.tag-arrow:hover { color: #333; background: rgba(0,0,0,0.06); }
html.dark .tag-arrow:hover { color: #ccc; background: rgba(255,255,255,0.06); }
/* 右键菜单 */
.context-menu {
position: fixed; z-index: 9999;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
padding: 4px 0; min-width: 100px;
}
.context-item {
padding: 8px 16px; font-size: 13px; cursor: pointer; color: #333;
transition: background 0.15s;
}
.context-item:hover { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
.context-divider { height: 1px; margin: 4px 8px; background: var(--el-border-color-light); }
.context-overlay { position: fixed; inset: 0; z-index: 9998; }
html.dark .context-item { color: #ccc; }
html.dark .context-item:hover { background: rgba(64,158,255,0.1); }
</style>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { useI18n } from 'vue-i18n'
const visible = defineModel<boolean>()
const app = useAppStore()
const { t, locale } = useI18n()
const colors = ['#1677ff', '#409eff', '#52c41a', '#fa8c16', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96']
function onLangChange(v: string) {
locale.value = v // 立即响应,无需刷新
app.setLang(v)
}
function onLayoutChange(v: string) {
app.setLayout(v)
}
</script>
<template>
<el-drawer v-model="visible" :title="t('theme.title')" size="280px">
<!-- 暗黑模式 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">{{ t('theme.dark') }}</p>
<el-switch :model-value="app.dark" @change="app.toggleDark()" :active-text="app.dark ? '🌙' : ''" />
</div>
<!-- 语言 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">语言 / Language</p>
<el-radio-group :model-value="locale" @change="onLangChange">
<el-radio-button value="zh-CN">中文</el-radio-button>
<el-radio-button value="en-US">English</el-radio-button>
</el-radio-group>
</div>
<!-- 布局 -->
<div class="mb-5">
<p class="text-sm mb-2 text-gray-500">{{ t('theme.layout') }}</p>
<div class="grid grid-cols-2 gap-2">
<div
v-for="l in (['side', 'top', 'mix', 'double'] as const)"
:key="l"
:class="['p-2 rounded text-center text-xs cursor-pointer border-2', app.layout === l ? 'border-blue-500 bg-blue-50' : 'border-gray-200']"
@click="onLayoutChange(l)"
>
{{ t(`theme.layout${l.charAt(0).toUpperCase() + l.slice(1)}` as any) }}
</div>
</div>
</div>
<!-- 主题色 -->
<div>
<p class="text-sm mb-2 text-gray-500">{{ t('theme.color') }}</p>
<div class="flex gap-2 flex-wrap">
<div
v-for="c in colors"
:key="c"
:style="{ background: c }"
:class="['w-6 h-6 rounded-full cursor-pointer', app.primaryColor === c ? 'ring-2 ring-offset-2 ring-blue-500' : '']"
@click="app.setPrimaryColor(c)"
/>
</div>
</div>
</el-drawer>
</template>
+147
View File
@@ -0,0 +1,147 @@
import { ref, computed } from 'vue'
import { menuService } from '@/service/system/menuService'
/**
* 图标名称映射
* - 如果图标以 "tabler:" 开头,直接透传(使用 Iconify 渲染)
* - 否则映射为 Element Plus 图标组件名
*/
function getIconName(icon?: string): string {
if (!icon) return 'FolderOpened'
// Tabler 图标直接透传
if (icon.startsWith('tabler:')) return icon
// Element Plus 图标映射
const iconMap: Record<string, string> = {
'setting': 'Setting',
'user': 'UserFilled',
'monitor': 'Monitor',
'document': 'Document',
'edit': 'Edit',
'present': 'Present',
'tools': 'Tools',
'home': 'HomeFilled',
}
const normalized = icon.charAt(0).toUpperCase() + icon.slice(1).toLowerCase()
return iconMap[normalized.toLowerCase()] || normalized || 'FolderOpened'
}
/**
* 将后端菜单转换为前端格式
*/
function transformMenu(menu: any): any {
const item: any = {
id: menu.id,
title: menu.menuName,
icon: getIconName(menu.icon),
path: menu.path,
}
if (menu.children && menu.children.length > 0) {
const validChildren = menu.children.filter((child: any) => child.menuType !== 3)
if (validChildren.length > 0) {
item.children = validChildren.map(transformMenu)
}
}
return item
}
/**
* 递归获取菜单下的第一个可跳转路径
*/
function getFirstPath(menu: any): string | undefined {
if (menu.path) return menu.path
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
const path = getFirstPath(child)
if (path) return path
}
}
return undefined
}
/**
* 根据路径在菜单树中查找对应的面包屑路径
*/
function findBreadcrumbPath(menus: any[], targetPath: string): any[] {
for (const menu of menus) {
if (menu.path === targetPath) {
return [{ id: menu.id, title: menu.menuName, path: menu.path }]
}
if (menu.children && menu.children.length > 0) {
const childPath = findBreadcrumbPath(menu.children, targetPath)
if (childPath.length > 0) {
return [{ id: menu.id, title: menu.menuName, path: menu.path }, ...childPath]
}
}
}
return []
}
/**
* 菜单管理 Composable
*/
export function useMenu() {
const rawMenus = ref<any[]>([])
const loading = ref(false)
const menuItems = computed(() => {
const filtered = rawMenus.value.filter((menu: any) => menu.menuType !== 3)
return filtered.map(transformMenu)
})
/**
* 一级菜单列表(用于 MixLayout/DoubleLayout 的顶栏/图标栏)
*/
const topMenus = computed(() => {
return menuItems.value.map((item: any) => ({
id: item.id,
key: String(item.id),
title: item.title,
icon: item.icon,
path: item.path || getFirstPath(item),
children: item.children,
}))
})
/**
* 根据一级菜单 ID 获取对应的二级菜单列表
*/
function getSubMenus(topMenuId: string | number): any[] {
const topMenu = rawMenus.value.find((m: any) => String(m.id) === String(topMenuId))
if (!topMenu || !topMenu.children) return []
return topMenu.children
.filter((child: any) => child.menuType !== 3)
.map(transformMenu)
}
/**
* 根据当前路径生成面包屑数据
*/
function getBreadcrumbs(currentPath: string): any[] {
const paths = findBreadcrumbPath(rawMenus.value, currentPath)
return paths.map(p => ({ id: p.id, title: p.title, path: p.path }))
}
async function loadMenus(platform?: string) {
loading.value = true
try {
rawMenus.value = await menuService.userTree(platform)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
return {
menuItems,
topMenus,
rawMenus,
loading,
loadMenus,
getSubMenus,
getBreadcrumbs,
getFirstPath,
}
}
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import SideLayout from './SideLayout.vue'
import TopNavLayout from './TopNavLayout.vue'
import MixLayout from './MixLayout.vue'
import DoubleLayout from './DoubleLayout.vue'
import ThemeDrawer from '@/components/ThemeDrawer.vue'
const app = useAppStore()
const Component = computed(() => {
return { side: SideLayout, top: TopNavLayout, mix: MixLayout, double: DoubleLayout }[app.layout] || SideLayout
})
</script>
<template>
<component :is="Component" :key="app.layout" />
<ThemeDrawer v-model="app.themeVisible" />
</template>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
const activeTop = ref('')
const subCollapsed = ref(false)
const subMenus = computed(() => {
if (!activeTop.value) return []
return getSubMenus(activeTop.value)
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
function onTopClick(item: any) {
activeTop.value = item.key
if (item.path) { router.push(item.path); return }
const first = getFirstPath(item)
if (first) router.push(first)
}
onMounted(() => {
loadMenus('admin').then(() => {
if (topMenus.value.length > 0 && !activeTop.value) {
activeTop.value = topMenus.value[0].key
}
})
})
</script>
<template>
<el-container class="h-screen">
<!-- 图标栏 -->
<div class="icon-col">
<el-icon :size="22" class="my-3 icon-logo"><Setting /></el-icon>
<el-tooltip v-for="item in topMenus" :key="item.key" :content="item.title" placement="right">
<div :class="['icon-btn', { active: activeTop === item.key }]" @click="onTopClick(item)">
<RuiIcon :icon="item.icon" :size="20" />
</div>
</el-tooltip>
</div>
<!-- 二级菜单可收起 -->
<div v-if="subMenus.length" class="sub-col" :class="{ 'sub-collapsed': subCollapsed }">
<div class="sub-col-header">
<span v-show="!subCollapsed" class="sub-title">{{ topMenus.find(m => m.key === activeTop)?.title }}</span>
<el-icon :size="16" class="cursor-pointer sub-toggle" @click="subCollapsed = !subCollapsed">
<Fold v-if="!subCollapsed" /><Expand v-else />
</el-icon>
</div>
<el-scrollbar>
<el-menu v-if="!subCollapsed" :default-active="route.path" router class="sub-menu" :key="activeTop">
<template v-for="s in subMenus" :key="s.id">
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
<template #title>
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</template>
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="s.path">
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
<!-- 内容区 -->
<el-container class="flex-1">
<el-header class="top-bar">
<div class="top-bar-inner">
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3 ml-auto">
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<TagsBar />
</el-header>
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style>
/* 图标栏 */
.icon-col {
width: 56px; background: #001529;
display: flex; flex-direction: column; align-items: center;
padding-top: 4px; flex-shrink: 0;
}
.icon-logo { color: var(--el-color-primary); }
.icon-btn {
width: 40px; height: 40px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #ffffff73; cursor: pointer; transition: all 0.2s;
margin-bottom: 2px;
}
.icon-btn:hover { color: #fff; background: rgba(255,255,255,0.08); }
.icon-btn.active { color: #fff; background: rgba(255,255,255,0.15); }
/* 二级菜单栏 */
.sub-col {
width: 200px; background: #fff;
border-right: 1px solid #eee;
flex-shrink: 0; transition: width 0.2s;
overflow: hidden; display: flex; flex-direction: column;
}
.sub-col.sub-collapsed { width: 48px; }
.sub-col-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; flex-shrink: 0;
}
.sub-col.sub-collapsed .sub-col-header { justify-content: center; padding: 12px 0; }
.sub-toggle { flex-shrink: 0; color: #999; }
.sub-toggle:hover { color: #333; }
.sub-title { font-size: 12px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }
/* 二级菜单样式 */
.sub-menu { border-right: none !important; }
.sub-menu .el-menu-item.is-active {
background: rgba(0,0,0,0.04);
border-right: 3px solid var(--el-color-primary);
color: var(--el-color-primary); font-weight: 500;
}
.menu-icon { margin-right: 5px; }
/* 顶部栏 */
.top-bar { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-bar-inner {
display: flex; align-items: center; justify-content: space-between;
height: 52px; background: #fff; padding: 0 20px;
}
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; }
/* 暗色模式适配 */
html.dark .sub-col { background: #1d1d1d; border-color: #333; }
html.dark .sub-toggle:hover { color: #ccc; }
html.dark .top-bar-inner { background: #1d1d1d; }
html.dark .main { background: #111; }
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { topMenus, loadMenus, getSubMenus, getBreadcrumbs, getFirstPath } = useMenu()
const activeTop = ref('')
const sideMenus = computed(() => {
if (!activeTop.value) return []
return getSubMenus(activeTop.value)
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
function onTopChange(key: string) {
activeTop.value = key
const subItems = getSubMenus(key)
if (subItems.length > 0) {
const firstPath = getFirstPath(subItems[0])
if (firstPath) router.push(firstPath)
} else {
const topItem = topMenus.value.find((m: any) => m.key === key)
if (topItem?.path) router.push(topItem.path)
}
}
onMounted(() => {
loadMenus('admin').then(() => {
if (topMenus.value.length > 0 && !activeTop.value) {
activeTop.value = topMenus.value[0].key
}
})
})
</script>
<template>
<el-container class="h-screen">
<div class="flex flex-col w-full">
<!-- 顶部一级菜单 + 工具栏 -->
<el-header class="top-header">
<div class="top-header-nav">
<div class="flex items-center gap-6 h-full">
<!-- Logo -->
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
<!-- 一级菜单 -->
<div class="flex gap-0 flex-1">
<div
v-for="item in topMenus"
:key="item.key"
:class="[
'px-3 py-2 text-sm cursor-pointer rounded transition-colors flex items-center gap-1',
activeTop === item.key ? 'text-white' : 'hover:text-blue-500'
]"
:style="activeTop === item.key ? { background: app.primaryColor } : {}"
@click="onTopChange(item.key)"
>
<RuiIcon v-if="item.icon" :icon="item.icon" :size="14" />
<span>{{ item.title }}</span>
</div>
</div>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3">
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 面包屑 + 标签栏 -->
<div class="top-header-bottom">
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<TagsBar />
</div>
</el-header>
<!-- 二级侧边 + 内容 -->
<div class="flex flex-1 overflow-hidden">
<div v-if="sideMenus.length" class="side-sub">
<el-scrollbar>
<el-menu :default-active="route.path" router class="sub-menu">
<template v-for="s in sideMenus" :key="s.id">
<el-sub-menu v-if="s.children?.length" :index="String(s.id)">
<template #title>
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</template>
<el-menu-item v-for="c in s.children" :key="c.id" :index="c.path">
<RuiIcon v-if="c.icon" :icon="c.icon" :size="16" class="menu-icon" />
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="s.path">
<RuiIcon v-if="s.icon" :icon="s.icon" :size="16" class="menu-icon" />
<span>{{ s.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</div>
</div>
</el-container>
</template>
<style scoped>
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; }
html.dark .top-header-nav { background: #1d1d1d; }
/* 面包屑区域 */
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
.breadcrumb-wrap {
display: flex; align-items: center;
height: 28px; padding: 0 20px;
font-size: 13px;
}
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
/* 二级侧边栏 */
.side-sub { width: 180px; background: #fff; border-right: 1px solid #eee; flex-shrink: 0; }
.sub-menu { border-right: none !important; }
.sub-menu .el-menu-item.is-active {
background: rgba(0,0,0,0.04);
border-right: 3px solid var(--el-color-primary);
color: var(--el-color-primary); font-weight: 500;
}
.menu-icon { margin-right: 5px; }
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; padding: 20px; overflow: auto; }
/* 暗色模式适配 */
html.dark .side-sub { background: #1d1d1d; border-color: #333; }
html.dark .main { background: #111; }
html.dark .sub-menu .el-menu-item.is-active { background: rgba(255,255,255,0.06); }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
html.dark .el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
const isCollapse = ref(false)
/**
* 加载菜单
*/
async function loadMenuData() {
try {
await loadMenus('admin')
} catch {
// 错误已由请求拦截器统一提示
}
}
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
onMounted(() => {
loadMenuData()
})
</script>
<template>
<el-container class="h-screen">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="side-bar">
<div class="side-logo" :class="{ collapsed: isCollapse }">
<el-icon :size="22"><Setting /></el-icon>
<span v-show="!isCollapse" class="side-logo-text">{{ t('app.title') }}</span>
</div>
<el-scrollbar>
<el-menu :default-active="route.path" :collapse="isCollapse" router class="side-menu" :collapse-transition="false">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
<template #title>
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
</el-tooltip>
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.id">
<!-- 子菜单还有子菜单三级菜单 -->
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
<template #title>
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</template>
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">
<RuiIcon v-if="sub.icon" :icon="sub.icon" :size="16" class="menu-icon" />
<span>{{ sub.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 二级菜单 -->
<el-menu-item v-else :index="child.path">
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.path">
<el-tooltip v-if="isCollapse" :content="item.title" placement="right">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
</el-tooltip>
<RuiIcon v-else-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</el-aside>
<!-- 主内容区 -->
<el-container class="flex-1">
<!-- 顶部栏 -->
<el-header class="top-bar">
<div class="top-bar-inner">
<div class="flex items-center gap-3">
<el-icon :size="20" class="cursor-pointer hover:text-blue-500 transition-colors" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" /><Expand v-else />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="flex items-center gap-4">
<!-- 暗黑模式切换 -->
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<!-- 主题配置 -->
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<!-- 通知 -->
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 用户信息 -->
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<TagsBar />
</el-header>
<!-- 主内容 -->
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style>
/* 侧边栏暗色调 — 不受亮/暗模式影响 */
.side-bar {
background: #001529 !important;
--el-menu-bg-color: #001529;
--el-menu-text-color: #ffffffb3;
--el-menu-hover-bg-color: #ffffff0d;
--el-menu-active-color: #fff;
--el-menu-border-color: transparent;
--el-sub-menu-title-font-size: 14px;
transition: width 0.3s;
}
.side-logo {
display: flex; align-items: center; gap: 10px; padding: 0 18px; height: 56px;
border-bottom: 1px solid rgba(255,255,255,0.06); color: #fff;
transition: all 0.3s;
}
.side-logo.collapsed { justify-content: center; padding: 0; }
.side-logo-text { font-size: 16px; font-weight: 700; white-space: nowrap; }
.side-menu { border-right: none !important; }
.menu-icon { margin-right: 5px; }
/* 当前激活菜单项加背景色 */
.side-menu .el-menu-item.is-active { background: rgba(255,255,255,0.08) !important; }
.side-menu .el-sub-menu.is-active > .el-sub-menu__title { background: rgba(255,255,255,0.06); }
/* 顶部栏 */
.top-bar {
padding: 0; height: auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.top-bar-inner {
display: flex; align-items: center; justify-content: space-between;
height: 52px; background: #fff; padding: 0 20px;
transition: background 0.3s;
}
html.dark .top-bar-inner { background: #1d1d1d; }
/* 主内容区 */
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
html.dark .main { background: #111; }
/* 面包屑样式优化 */
.el-breadcrumb { font-size: 13px; }
html.dark .el-breadcrumb__inner { color: #ccc; }
html.dark .el-breadcrumb__inner.is-link:hover { color: var(--el-color-primary); }
/* 下拉菜单图标对齐 */
.el-dropdown-menu .el-icon { margin-right: 6px; vertical-align: middle; }
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useMenu } from '@/composables/useMenu'
import TagsBar from '@/components/TagsBar.vue'
import RuiIcon from '@/components/RuiIcon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const app = useAppStore()
const user = useUserStore()
const { menuItems, loadMenus, getBreadcrumbs } = useMenu()
onMounted(() => {
loadMenus('admin')
})
/**
* 面包屑数据
*/
const breadcrumbs = computed(() => {
const crumbs = getBreadcrumbs(route.path)
if (crumbs.length === 0) {
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }]
}
return [{ title: t('common.breadcrumb.home'), path: '/dashboard' }, ...crumbs]
})
</script>
<template>
<el-container class="h-screen" :class="{ dark: app.dark }">
<!-- 顶部导航 -->
<el-header class="top-header">
<div class="top-header-nav">
<div class="flex items-center gap-6 h-full">
<!-- Logo -->
<span class="text-lg font-bold whitespace-nowrap" :style="{ color: app.primaryColor }">{{ t('app.title') }}</span>
<!-- 水平菜单 -->
<el-menu mode="horizontal" :default-active="route.path" router class="top-menu">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children?.length" :index="String(item.id)">
<template #title>
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.id">
<!-- 三级菜单 -->
<el-sub-menu v-if="child.children?.length" :index="String(child.id)">
<template #title>
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</template>
<el-menu-item v-for="sub in child.children" :key="sub.id" :index="sub.path">{{ sub.title }}</el-menu-item>
</el-sub-menu>
<!-- 二级菜单 -->
<el-menu-item v-else :index="child.path">
<RuiIcon v-if="child.icon" :icon="child.icon" :size="16" class="menu-icon" />
<span>{{ child.title }}</span>
</el-menu-item>
</template>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.path">
<RuiIcon v-if="item.icon" :icon="item.icon" :size="16" class="menu-icon" />
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
<!-- 右侧工具栏 -->
<div class="flex items-center gap-3 ml-auto">
<!-- 暗黑模式 -->
<el-tooltip :content="app.dark ? '切换亮色模式' : '切换暗黑模式'" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.toggleDark()">
<Moon v-if="!app.dark" /><Sunny v-else />
</el-icon>
</el-tooltip>
<!-- 主题配置 -->
<el-tooltip content="主题配置" placement="bottom">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors" @click="app.themeVisible = true"><Brush /></el-icon>
</el-tooltip>
<!-- 通知 -->
<el-dropdown>
<el-badge :value="3" :offset="[-2, 2]">
<el-icon :size="18" class="cursor-pointer hover:text-blue-500 transition-colors"><Bell /></el-icon>
</el-badge>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item><el-text size="small" type="info">暂无通知</el-text></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 用户 -->
<el-dropdown>
<span class="flex items-center gap-2 cursor-pointer hover:text-blue-500 transition-colors">
<el-avatar :size="28" :src="user.avatar" :icon="!user.avatar ? 'UserFilled' : undefined" />
<span class="text-sm">{{ user.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>{{ t('common.personal') }}
</el-dropdown-item>
<el-dropdown-item divided @click="app.logout()">
<el-icon><SwitchButton /></el-icon>{{ t('common.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 面包屑 + 标签栏 -->
<div class="top-header-bottom">
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(crumb, index) in breadcrumbs" :key="index" :to="crumb.path && index < breadcrumbs.length - 1 ? crumb.path : undefined">
{{ crumb.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<TagsBar />
</div>
</el-header>
<!-- 主内容 -->
<el-main :class="['main', { 'fullscreen-main': app.pageFullscreen }]">
<router-view />
</el-main>
</el-container>
</template>
<style scoped>
.top-header { padding: 0; height: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.top-header-nav { height: 52px; background: #fff; padding: 0 20px; display: flex; align-items: center; }
html.dark .top-header-nav { background: #1d1d1d; }
/* 面包屑区域 */
.top-header-bottom { background: #fff; border-top: 1px solid var(--el-border-color-light); }
html.dark .top-header-bottom { background: #1d1d1d; border-color: #2a2a2a; }
.breadcrumb-wrap {
display: flex; align-items: center;
height: 28px; padding: 0 20px;
font-size: 13px;
}
.breadcrumb-wrap .el-breadcrumb { line-height: 1; }
.top-menu { border-bottom: none; height: 52px; flex: 1; }
.menu-icon { margin-right: 5px; }
.main { background: #f5f6fa; flex: 1; overflow: auto; padding: 20px; transition: background 0.3s; }
html.dark .main { background: #111; }
/* 暗色模式下的菜单适配 */
html.dark .top-menu {
--el-menu-bg-color: #1d1d1d;
--el-menu-text-color: #ccc;
--el-menu-hover-text-color: var(--el-color-primary);
--el-menu-active-color: var(--el-color-primary);
}
html.dark .el-menu--popup {
--el-menu-bg-color: #2a2a2a;
--el-menu-text-color: #ccc;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
export default {
app: {
title: 'Rui Platform',
titleShort: 'Rui',
},
menu: {
home: 'Dashboard',
user: 'User',
userInfo: 'User Info',
level: 'Level',
levelList: 'Level List',
levelLog: 'Level Log',
address: 'Address',
account: 'Account',
system: 'System',
systemAuth: 'Auth',
systemOrg: 'Organization',
systemMenu: 'Menu',
systemRole: 'Role',
systemDept: 'Department',
systemDict: 'Dictionary',
systemConfig: 'Config',
systemLog: 'Log',
order: 'Order',
orderList: 'Order List',
orderRefund: 'Refund',
cms: 'Content',
cmsArticle: 'Article',
cmsCategory: 'Category',
cmsBanner: 'Banner',
marketing: 'Marketing',
marketingCoupon: 'Coupon',
marketingActivity: 'Activity',
demo: 'Demo',
demoIcons: 'Icons',
demoList: 'List',
settings: 'Settings',
},
common: {
search: 'Search',
reset: 'Reset',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
enable: 'Enable',
disable: 'Disable',
status: 'Status',
operation: 'Operation',
remark: 'Remark',
personal: 'Profile',
logout: 'Logout',
fullscreen: 'Fullscreen',
breadcrumb: { home: 'Home' },
},
theme: {
title: 'Theme',
dark: 'Dark',
light: 'Light',
layout: 'Layout',
layoutSide: 'Sidebar',
layoutTop: 'Top Menu',
layoutMix: 'Mixed',
layoutDouble: 'Double',
color: 'Primary Color',
fontSize: 'Font Size',
},
dashboard: {
title: 'Overview',
users: 'Total Users',
today: 'New Today',
active: 'Active Users',
orders: 'Total Orders',
recent: 'Recent Activity',
vsLastWeek: 'vs last week',
action: { register: 'registered', login: 'logged in', order: 'purchased', comment: 'commented' },
},
userInfo: {
title: 'User Info',
username: 'Username',
nickname: 'Nickname',
phone: 'Phone',
email: 'Email',
add: 'Add User',
edit: 'Edit User',
deleteConfirm: 'Delete user "{name}"?',
},
userLevel: {
title: 'Level Management',
name: 'Level Name',
code: 'Code',
minScore: 'Min Score',
maxScore: 'Max Score',
add: 'Add Level',
edit: 'Edit Level',
deleteConfirm: 'Delete "{name}"?',
},
context: {
close: 'Close',
refresh: 'Refresh',
fullscreen: 'Fullscreen',
closeOther: 'Close Others',
closeAll: 'Close All',
},
}
+12
View File
@@ -0,0 +1,12 @@
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN'
import enUS from './en-US'
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('lang') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, 'en-US': enUS },
})
export default i18n
+121
View File
@@ -0,0 +1,121 @@
export default {
app: {
title: '睿核通用平台',
titleShort: '睿核',
},
menu: {
home: '首页',
user: '用户管理',
userInfo: '用户信息',
userDetail: '用户详情',
level: '等级管理',
levelList: '等级列表',
levelLog: '等级日志',
address: '收货地址',
account: '账户流水',
system: '系统管理',
systemAuth: '权限管理',
systemOrg: '组织架构',
systemMenu: '菜单管理',
systemRole: '角色管理',
systemDept: '部门管理',
systemDict: '字典管理',
systemConfig: '参数配置',
systemLog: '操作日志',
systemLoginLog: '登录日志',
systemTenant: '租户管理',
systemOAuth2Client: 'OAuth2客户端',
order: '订单管理',
orderList: '订单列表',
orderRefund: '退款记录',
cms: '内容管理',
cmsArticle: '文章管理',
cmsCategory: '分类管理',
cmsBanner: '轮播图',
marketing: '营销中心',
marketingCoupon: '优惠券',
marketingActivity: '活动管理',
demo: '演示中心',
demoIcons: '图标演示',
demoList: '列表演示',
settings: '系统设置',
},
common: {
search: '查询',
reset: '重置',
add: '新增',
edit: '编辑',
delete: '删除',
confirm: '确定',
cancel: '取消',
save: '保存',
enable: '启用',
disable: '禁用',
status: '状态',
operation: '操作',
remark: '备注',
personal: '个人中心',
logout: '退出登录',
fullscreen: '全屏',
all: '全部',
query: '查询',
batchDelete: '批量删除',
success: '成功',
failed: '失败',
breadcrumb: { home: '首页' },
},
theme: {
title: '主题配置',
dark: '暗黑模式',
light: '明亮模式',
layout: '布局风格',
layoutSide: '侧边栏',
layoutTop: '顶栏',
layoutMix: '混合',
layoutDouble: '双栏',
color: '主题色',
fontSize: '字号',
},
dashboard: {
title: '数据概览',
users: '用户总数',
today: '今日新增',
active: '活跃用户',
orders: '订单总数',
recent: '最近动态',
vsLastWeek: '较上周',
action: { register: '注册', login: '登录', order: '购买', comment: '评论' },
},
userInfo: {
title: '用户信息',
username: '用户名',
nickname: '昵称',
phone: '手机号',
email: '邮箱',
status: '状态',
userType: '用户类型',
createdAt: '创建时间',
enabled: '启用',
disabled: '禁用',
add: '新增用户',
edit: '编辑用户',
deleteConfirm: '确定删除用户「{name}」?',
},
userLevel: {
title: '等级管理',
name: '等级名称',
code: '编码',
minScore: '最低分值',
maxScore: '最高分值',
add: '新增等级',
edit: '编辑等级',
deleteConfirm: '确定删除「{name}」?',
},
context: {
close: '关闭当前',
refresh: '刷新当前',
fullscreen: '全屏当前页',
closeOther: '关闭其它',
closeAll: '关闭所有',
},
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './locales'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'uno.css'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue))
app.component(key, component)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.mount('#app')
+36
View File
@@ -0,0 +1,36 @@
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
/**
* 全局路由守卫
*/
export function setupRouterGuards(router: any) {
// 白名单路由(无需登录)
const whiteList = ['/login']
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
const authStore = useAuthStore()
// 白名单直接放行
if (whiteList.includes(to.path)) {
// 已登录用户访问登录页,重定向到首页
if (authStore.isLoggedIn && to.path === '/login') {
next('/')
return
}
next()
return
}
// 需要登录的页面
if (!authStore.isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath },
})
return
}
next()
})
}
+10
View File
@@ -0,0 +1,10 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import routes from 'virtual:generated-routes'
import { setupRouterGuards } from './guards'
const router = createRouter({ history: createWebHashHistory(), routes })
// 设置路由守卫
setupRouterGuards(router)
export default router
+19
View File
@@ -0,0 +1,19 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
cashierStore: 'menu.cashierStore',
cashierRoom: 'menu.cashierRoom',
cashierPricing: 'menu.cashierPricing',
cashierOrder: 'menu.cashierOrder',
cashierProduct: 'menu.cashierProduct',
cashierReport: 'menu.cashierReport',
}
export const cashierRoutes: RouteRecordRaw[] = [
{ path: 'cashier/store', name: 'CashierStore', component: () => import('@/views/cashier/store/Index.vue'), meta: { i18n: M.cashierStore } },
{ path: 'cashier/room', name: 'CashierRoom', component: () => import('@/views/cashier/room/Index.vue'), meta: { i18n: M.cashierRoom } },
{ path: 'cashier/pricing', name: 'CashierPricing', component: () => import('@/views/cashier/pricing/Index.vue'), meta: { i18n: M.cashierPricing } },
{ path: 'cashier/order', name: 'CashierOrder', component: () => import('@/views/cashier/order/Index.vue'), meta: { i18n: M.cashierOrder } },
{ path: 'cashier/product', name: 'CashierProduct', component: () => import('@/views/cashier/product/Index.vue'), meta: { i18n: M.cashierProduct } },
{ path: 'cashier/report', name: 'CashierReport', component: () => import('@/views/cashier/report/Index.vue'), meta: { i18n: M.cashierReport } },
]
+13
View File
@@ -0,0 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
cmsArticle: 'menu.cmsArticle',
cmsCategory: 'menu.cmsCategory',
cmsBanner: 'menu.cmsBanner',
}
export const cmsRoutes: RouteRecordRaw[] = [
{ path: 'cms/article', name: 'CmsArticle', component: () => import('@/views/cms/article/Index.vue'), meta: { i18n: M.cmsArticle } },
{ path: 'cms/category', name: 'CmsCategory', component: () => import('@/views/cms/category/Index.vue'), meta: { i18n: M.cmsCategory } },
{ path: 'cms/banner', name: 'CmsBanner', component: () => import('@/views/cms/banner/Index.vue'), meta: { i18n: M.cmsBanner } },
]
+38
View File
@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* 核心路由 - 所有系统默认包含
*/
export const coreRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/Index.vue'),
meta: { hidden: true },
},
{
path: '/',
component: () => import('@/layout/DefaultLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Index.vue'),
meta: { i18n: 'menu.home' },
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/profile/Index.vue'),
meta: { i18n: 'common.personal', hidden: true },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/Index.vue'),
meta: { i18n: 'menu.settings' },
},
],
},
]
+11
View File
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
demoIcons: 'menu.demoIcons',
demoList: 'menu.demoList',
}
export const demoRoutes: RouteRecordRaw[] = [
{ path: 'demo/icons', name: 'DemoIcons', component: () => import('@/views/demo/Icons.vue'), meta: { i18n: M.demoIcons } },
{ path: 'demo/list', name: 'DemoList', component: () => import('@/views/demo/list/Index.vue'), meta: { i18n: M.demoList } },
]
+11
View File
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
coupon: 'menu.marketingCoupon',
activity: 'menu.marketingActivity',
}
export const marketingRoutes: RouteRecordRaw[] = [
{ path: 'marketing/coupon', name: 'MarketingCoupon', component: () => import('@/views/marketing/coupon/Index.vue'), meta: { i18n: M.coupon } },
{ path: 'marketing/activity', name: 'MarketingActivity', component: () => import('@/views/marketing/activity/Index.vue'), meta: { i18n: M.activity } },
]
+11
View File
@@ -0,0 +1,11 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
orderList: 'menu.orderList',
orderRefund: 'menu.orderRefund',
}
export const orderRoutes: RouteRecordRaw[] = [
{ path: 'order/list', name: 'OrderList', component: () => import('@/views/order/list/Index.vue'), meta: { i18n: M.orderList } },
{ path: 'order/refund', name: 'OrderRefund', component: () => import('@/views/order/refund/Index.vue'), meta: { i18n: M.orderRefund } },
]
+31
View File
@@ -0,0 +1,31 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
systemMenu: 'menu.systemMenu',
systemRole: 'menu.systemRole',
systemDept: 'menu.systemDept',
systemPost: 'menu.systemPost',
systemDict: 'menu.systemDict',
systemConfig: 'menu.systemConfig',
systemLog: 'menu.systemLog',
systemLoginLog: 'menu.systemLoginLog',
systemTenant: 'menu.systemTenant',
systemTenantPackage: 'menu.systemTenantPackage',
systemDataScope: 'menu.systemDataScope',
systemOAuth2Client: 'menu.systemOAuth2Client',
}
export const systemRoutes: RouteRecordRaw[] = [
{ path: 'system/menu', name: 'SystemMenu', component: () => import('@/views/system/menu/Index.vue'), meta: { i18n: M.systemMenu } },
{ path: 'system/role', name: 'SystemRole', component: () => import('@/views/system/role/Index.vue'), meta: { i18n: M.systemRole } },
{ path: 'system/dept', name: 'SystemDept', component: () => import('@/views/system/dept/Index.vue'), meta: { i18n: M.systemDept } },
{ path: 'system/post', name: 'SystemPost', component: () => import('@/views/system/post/Index.vue'), meta: { i18n: M.systemPost } },
{ path: 'system/dict', name: 'SystemDict', component: () => import('@/views/system/dict/Index.vue'), meta: { i18n: M.systemDict } },
{ path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/config/Index.vue'), meta: { i18n: M.systemConfig } },
{ path: 'system/log', name: 'SystemLog', component: () => import('@/views/system/log/Index.vue'), meta: { i18n: M.systemLog } },
{ path: 'system/login-log', name: 'SystemLoginLog', component: () => import('@/views/system/login-log/Index.vue'), meta: { i18n: M.systemLoginLog } },
{ path: 'system/tenant', name: 'SystemTenant', component: () => import('@/views/system/tenant/Index.vue'), meta: { i18n: M.systemTenant } },
{ path: 'system/tenant-package', name: 'SystemTenantPackage', component: () => import('@/views/system/tenant-package/Index.vue'), meta: { i18n: M.systemTenantPackage } },
{ path: 'system/data-scope', name: 'SystemDataScope', component: () => import('@/views/system/data-scope/Index.vue'), meta: { i18n: M.systemDataScope } },
{ path: 'system/oauth2-client', name: 'SystemOAuth2Client', component: () => import('@/views/system/oauth2-client/Index.vue'), meta: { i18n: M.systemOAuth2Client } },
]
+19
View File
@@ -0,0 +1,19 @@
import type { RouteRecordRaw } from 'vue-router'
const M = {
userInfo: 'menu.userInfo',
userDetail: 'menu.userDetail',
level: 'menu.levelList',
levelLog: 'menu.levelLog',
address: 'menu.address',
account: 'menu.account',
}
export const userRoutes: RouteRecordRaw[] = [
{ path: 'user/info', name: 'UserInfo', component: () => import('@/views/user/info/Index.vue'), meta: { i18n: M.userInfo } },
{ path: 'user/detail', name: 'UserDetail', component: () => import('@/views/user/detail/Index.vue'), meta: { i18n: M.userDetail } },
{ path: 'user/level', name: 'UserLevel', component: () => import('@/views/user/level/Index.vue'), meta: { i18n: M.level } },
{ path: 'user/level-log', name: 'UserLevelLog', component: () => import('@/views/user/level-log/Index.vue'), meta: { i18n: M.levelLog } },
{ path: 'user/address', name: 'UserAddress', component: () => import('@/views/user/address/Index.vue'), meta: { i18n: M.address } },
{ path: 'user/account', name: 'UserAccount', component: () => import('@/views/user/account/Index.vue'), meta: { i18n: M.account } },
]
+163
View File
@@ -0,0 +1,163 @@
import { request } from '@/utils/request'
/**
* 分页查询结果
*/
export interface PageResult<T = any> {
/** 列表数据 */
list: T[]
/** 总记录数 */
total: number
}
/**
* 分页参数
*/
export interface PageParams {
page: number
size: number
}
/**
* 通用 Service 基类
*
* <p>封装标准 CRUD 操作,子类只需传入 baseUrl 即可使用:</p>
*
* <pre>
* class UserService extends BaseService {
* constructor() {
* super('/user/admin/user')
* }
* }
* </pre>
*/
export class BaseService<T = any> {
/** 接口基础路径 */
protected baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
/**
* 分页查询
*
* @param params 分页参数 + 查询条件
* @returns 分页结果
*/
async page(params: PageParams & Record<string, any>): Promise<PageResult<T>> {
const res: any = await request({
url: `${this.baseUrl}/page`,
method: 'get',
params,
})
return {
list: res.data?.records || [],
total: res.data?.total || 0,
}
}
/**
* 列表查询
*
* @param params 查询条件
* @returns 数据列表
*/
async list(params?: Record<string, any>): Promise<T[]> {
const res: any = await request({
url: `${this.baseUrl}/list`,
method: 'get',
params,
})
return res.data || []
}
/**
* 根据 ID 查询详情
*
* @param id 主键 ID
* @returns 实体数据
*/
async getById(id: number | string): Promise<T> {
const res: any = await request({
url: `${this.baseUrl}/${id}`,
method: 'get',
})
return res.data
}
/**
* 新增
*
* @param data 实体数据
* @returns 新增后的实体(包含生成的ID)
*/
async add(data: Partial<T>): Promise<T> {
const res: any = await request({
url: this.baseUrl,
method: 'post',
data,
})
return res.data
}
/**
* 修改
*
* @param data 实体数据(必须包含 id)
* @returns 是否成功
*/
async update(data: Partial<T> & { id: number | string }): Promise<boolean> {
const res: any = await request({
url: this.baseUrl,
method: 'put',
data,
})
return res.data === true
}
/**
* 删除
*
* @param id 主键 ID
* @returns 是否成功
*/
async remove(id: number | string): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/${id}`,
method: 'delete',
})
return res.data === true
}
/**
* 批量删除(调用后端批量删除接口)
*
* @param ids 主键 ID 列表
* @returns 是否成功
*/
async batchRemove(ids: (number | string)[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/batch`,
method: 'delete',
data: ids,
})
return res.data === true
}
/**
* 状态切换(启用/禁用)
*
* @param id 主键 ID
* @param status 状态值(0禁用 1启用)
* @returns 是否成功
*/
async changeStatus(id: number | string, status: number): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/status`,
method: 'put',
params: { id, status },
})
return res.data === true
}
}
+100
View File
@@ -0,0 +1,100 @@
import { request } from '@/utils/request'
/**
* OAuth2 Token 响应
*/
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
scope?: string
tenantId?: number
}
/**
* 登录参数
*/
export interface LoginParams {
username: string
password: string
tenantId?: string
}
/**
* 认证服务
*/
class AuthService {
/**
* OAuth2 密码模式登录
*/
async login(params: LoginParams): Promise<TokenResponse> {
const formData = new URLSearchParams()
formData.append('grant_type', 'password')
formData.append('username', params.username)
formData.append('password', params.password)
if (params.tenantId) {
formData.append('tenantId', params.tenantId)
}
const res: any = await request({
url: '/oauth2/token',
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
},
data: formData,
})
return res.data
}
/**
* 刷新 Token
*/
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const formData = new URLSearchParams()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)
const res: any = await request({
url: '/oauth2/token',
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + import.meta.env.VITE_OAUTH2_CLIENT_SECRET,
},
data: formData,
})
return res.data
}
/**
* 获取当前登录用户信息
*/
async getUserInfo(): Promise<any> {
const res: any = await request({
url: '/user/admin/user/current',
method: 'get',
})
return res.data
}
/**
* 登出
*/
async logout(): Promise<void> {
try {
await request({
url: '/oauth2/revoke',
method: 'post',
})
} catch {
// 即使后端登出失败,前端也要清理本地状态
}
}
}
export const authService = new AuthService()
@@ -0,0 +1,68 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 订单服务
*/
class OrderService extends BaseService {
constructor() {
super('/cashier/admin/order')
}
/**
* 开台
*/
async openRoom(data: {
storeId: number
roomId: number
customerName?: string
customerPhone?: string
orderType?: number
remark?: string
}): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/open`,
method: 'post',
data,
})
return res.data
}
/**
* 结账
*/
async checkout(id: number): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/checkout`,
method: 'post',
})
return res.data
}
/**
* 支付
*/
async pay(id: number, data: any): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/pay`,
method: 'post',
data,
})
return res.data
}
/**
* 退款
*/
async refund(id: number, data: { amount: number; reason: string }): Promise<any> {
const res: any = await request({
url: `${this.baseUrl}/${id}/refund`,
method: 'post',
data,
})
return res.data
}
}
/** 订单服务单例 */
export const orderService = new OrderService()
@@ -0,0 +1,61 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 定价策略服务
*/
class PricingService extends BaseService {
constructor() {
super('/cashier/admin/pricing-strategy')
}
/**
* 查询策略下的套餐列表
*/
async getPackages(strategyId: number): Promise<any[]> {
const res: any = await request({
url: `/cashier/admin/pricing-package/list`,
method: 'get',
params: { strategyId },
})
return res.data || []
}
/**
* 新增套餐
*/
async addPackage(data: any) {
const res: any = await request({
url: '/cashier/admin/pricing-package',
method: 'post',
data,
})
return res.data
}
/**
* 修改套餐
*/
async updatePackage(data: any) {
const res: any = await request({
url: '/cashier/admin/pricing-package',
method: 'put',
data,
})
return res.data
}
/**
* 删除套餐
*/
async deletePackage(id: number) {
const res: any = await request({
url: `/cashier/admin/pricing-package/${id}`,
method: 'delete',
})
return res.data
}
}
/** 定价策略服务单例 */
export const pricingService = new PricingService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 商品服务
*/
class ProductService extends BaseService {
constructor() {
super('/cashier/admin/product')
}
}
/** 商品服务单例 */
export const productService = new ProductService()
@@ -0,0 +1,33 @@
import { request } from '@/utils/request'
/**
* 报表服务
*/
class ReportService {
/**
* 营业日报
*/
async getDailyReport(storeId: number, date: string): Promise<any> {
const res: any = await request({
url: '/cashier/admin/report/daily',
method: 'get',
params: { storeId, date },
})
return res.data
}
/**
* 包间利用率
*/
async getRoomUsage(storeId: number, date: string): Promise<any[]> {
const res: any = await request({
url: '/cashier/admin/report/room-usage',
method: 'get',
params: { storeId, date },
})
return res.data || []
}
}
/** 报表服务单例 */
export const reportService = new ReportService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 包间服务
*/
class RoomService extends BaseService {
constructor() {
super('/cashier/admin/room')
}
}
/** 包间服务单例 */
export const roomService = new RoomService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 门店服务
*/
class StoreService extends BaseService {
constructor() {
super('/cashier/admin/store')
}
}
/** 门店服务单例 */
export const storeService = new StoreService()
@@ -0,0 +1,12 @@
import { BaseService } from '../BaseService'
/**
* 参数配置 Service
*/
class ConfigService extends BaseService {
constructor() {
super('/system/admin/config')
}
}
export const configService = new ConfigService()
@@ -0,0 +1,36 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 数据权限服务
*/
class DataScopeService extends BaseService {
constructor() {
super('/system/admin/data-scope')
}
/**
* 查询角色已分配的部门ID列表
*/
async listDeptIdsByRoleId(roleId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/role/${roleId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配角色数据权限(自定义部门范围)
*/
async assignDataScope(roleId: number | string, deptIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/role/${roleId}`,
method: 'post',
data: deptIds,
})
return res.data === true
}
}
export const dataScopeService = new DataScopeService()
@@ -0,0 +1,25 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 部门服务
*/
class DeptService extends BaseService {
constructor() {
super('/system/admin/dept')
}
/**
* 查询部门树
*/
async tree(): Promise<any[]> {
const res: any = await request({
url: '/system/admin/dept/list',
method: 'get',
})
return res.data || []
}
}
/** 部门服务单例 */
export const deptService = new DeptService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 字典项服务
*/
class DictItemService extends BaseService {
constructor() {
super('/system/admin/dict/item')
}
}
/** 字典项服务单例 */
export const dictItemService = new DictItemService()
@@ -0,0 +1,12 @@
import { BaseService } from '../BaseService'
/**
* 字典类型 Service
*/
class DictService extends BaseService {
constructor() {
super('/system/admin/dict/type')
}
}
export const dictService = new DictService()
+8
View File
@@ -0,0 +1,8 @@
export { menuService } from './menuService'
export { tenantService } from './tenantService'
export { roleService } from './roleService'
export { deptService } from './deptService'
export { dictService } from './dictService'
export { dictItemService } from './dictItemService'
export { configService } from './configService'
export { oauth2ClientService } from './oauth2ClientService'
@@ -0,0 +1,25 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 登录日志服务
*/
class LoginLogService extends BaseService {
constructor() {
super('/system/admin/login-log')
}
/**
* 清空日志
*/
async clear(): Promise<boolean> {
const res: any = await request({
url: '/system/admin/login-log/clear',
method: 'delete',
})
return res.data === true
}
}
/** 登录日志服务单例 */
export const loginLogService = new LoginLogService()
@@ -0,0 +1,40 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 菜单服务
*/
class MenuService extends BaseService {
constructor() {
super('/system/admin/menu')
}
/**
* 查询菜单树(菜单管理用,返回全部菜单)
* @param platform 所属平台 admin|app
*/
async tree(platform?: string): Promise<any[]> {
const res: any = await request({
url: '/system/admin/menu/tree',
method: 'get',
params: platform ? { platform } : undefined,
})
return res.data || []
}
/**
* 查询当前用户菜单树(框架侧边栏用,按角色权限过滤)
* @param platform 所属平台 admin|app
*/
async userTree(platform?: string): Promise<any[]> {
const res: any = await request({
url: '/system/admin/menu/user-tree',
method: 'get',
params: platform ? { platform } : undefined,
})
return res.data || []
}
}
/** 菜单服务单例 */
export const menuService = new MenuService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* OAuth2 客户端服务
*/
class OAuth2ClientService extends BaseService {
constructor() {
super('/system/admin/oauth2-client')
}
}
/** OAuth2 客户端服务单例 */
export const oauth2ClientService = new OAuth2ClientService()
@@ -0,0 +1,25 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 操作日志服务
*/
class OperLogService extends BaseService {
constructor() {
super('/system/admin/log')
}
/**
* 清空日志
*/
async clear(): Promise<boolean> {
const res: any = await request({
url: '/system/admin/log/clear',
method: 'delete',
})
return res.data === true
}
}
/** 操作日志服务单例 */
export const operLogService = new OperLogService()
@@ -0,0 +1,12 @@
import { BaseService } from '../BaseService'
/**
* 岗位服务
*/
class PostService extends BaseService {
constructor() {
super('/system/admin/post')
}
}
export const postService = new PostService()
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 角色服务
*/
class RoleService extends BaseService {
constructor() {
super('/system/admin/role')
}
}
/** 角色服务单例 */
export const roleService = new RoleService()
@@ -0,0 +1,12 @@
import { BaseService } from '../BaseService'
/**
* 租户套餐服务
*/
class TenantPackageService extends BaseService {
constructor() {
super('/system/admin/tenant-package')
}
}
export const tenantPackageService = new TenantPackageService()
@@ -0,0 +1,60 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 租户服务
*/
class TenantService extends BaseService {
constructor() {
super('/system/admin/tenant')
}
/**
* 初始化租户
*/
async initTenant(tenantId: number | string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/init`,
method: 'post',
})
return res.error === 0
}
/**
* 修改租户管理员密码
*/
async updateAdminPassword(tenantId: number | string, newPassword: string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/admin/password`,
method: 'post',
data: { newPassword },
})
return res.error === 0
}
/**
* 获取租户已启用模块
*/
async getModules(tenantId: number | string): Promise<string> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/modules`,
method: 'get',
})
return res.data || ''
}
/**
* 同步租户模块
*/
async syncModules(tenantId: number | string, modules: string): Promise<boolean> {
const res: any = await request({
url: `/system/admin/tenant/${tenantId}/modules`,
method: 'post',
data: { modules },
})
return res.error === 0
}
}
/** 租户服务单例 */
export const tenantService = new TenantService()
+6
View File
@@ -0,0 +1,6 @@
export { userService } from './userService'
export { userDetailService } from './userDetailService'
export { userDeptService } from './userDeptService'
export { userPostService } from './userPostService'
export { levelService } from './levelService'
export { levelLogService } from './levelLogService'
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 用户等级日志服务
*/
class LevelLogService extends BaseService {
constructor() {
super('/user/admin/level-log')
}
}
/** 用户等级日志服务单例 */
export const levelLogService = new LevelLogService()
+13
View File
@@ -0,0 +1,13 @@
import { BaseService } from '../BaseService'
/**
* 用户等级服务
*/
class LevelService extends BaseService {
constructor() {
super('/user/admin/level')
}
}
/** 用户等级服务单例 */
export const levelService = new LevelService()
@@ -0,0 +1,34 @@
import { request } from '@/utils/request'
/**
* 用户部门关联服务
*/
class UserDeptService {
private baseUrl = '/user/admin/user-dept'
/**
* 查询用户已分配的部门ID列表
*/
async listDeptIdsByUserId(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配用户部门
*/
async assignDepts(userId: number | string, deptIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'post',
data: deptIds,
})
return res.data === true
}
}
/** 用户部门关联服务单例 */
export const userDeptService = new UserDeptService()
@@ -0,0 +1,12 @@
import { BaseService } from '@/service/BaseService'
/**
* 用户详情服务
*/
class UserDetailService extends BaseService {
constructor() {
super('/user/admin/detail')
}
}
export const userDetailService = new UserDetailService()
@@ -0,0 +1,34 @@
import { request } from '@/utils/request'
/**
* 用户岗位关联服务
*/
class UserPostService {
private baseUrl = '/user/admin/user-post'
/**
* 查询用户已分配的岗位ID列表
*/
async listPostIdsByUserId(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'get',
})
return res.data || []
}
/**
* 分配用户岗位
*/
async assignPosts(userId: number | string, postIds: number[]): Promise<boolean> {
const res: any = await request({
url: `${this.baseUrl}/user/${userId}`,
method: 'post',
data: postIds,
})
return res.data === true
}
}
/** 用户岗位关联服务单例 */
export const userPostService = new UserPostService()
+39
View File
@@ -0,0 +1,39 @@
import { request } from '@/utils/request'
import { BaseService } from '../BaseService'
/**
* 用户服务
*
* <p>封装用户相关 API 调用,继承 BaseService 获得标准 CRUD 能力</p>
*/
class UserService extends BaseService {
constructor() {
super('/user/admin/user')
}
/**
* 获取用户已分配的角色列表
*/
async getRoles(userId: number | string): Promise<number[]> {
const res: any = await request({
url: `/user/admin/user/${userId}/roles`,
method: 'get',
})
return res.data || []
}
/**
* 分配角色
*/
async assignRoles(userId: number | string, roleIds: number[]): Promise<boolean> {
const res: any = await request({
url: `/user/admin/user/${userId}/roles`,
method: 'post',
data: roleIds,
})
return res.error === 0
}
}
/** 用户服务单例 */
export const userService = new UserService()
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const dark = ref(localStorage.getItem('dark') === 'true')
const lang = ref(localStorage.getItem('lang') || 'zh-CN')
const layout = ref(localStorage.getItem('layout') || 'double')
const primaryColor = ref(localStorage.getItem('primaryColor') || '#1677ff')
const themeVisible = ref(false)
const pageFullscreen = ref(false)
function toggleDark() { dark.value = !dark.value; localStorage.setItem('dark', String(dark.value)); applyDark() }
function setLang(l: string) { lang.value = l; localStorage.setItem('lang', l) }
function setLayout(l: string) { layout.value = l; localStorage.setItem('layout', l) }
function setPrimaryColor(c: string) { primaryColor.value = c; localStorage.setItem('primaryColor', c); document.documentElement.style.setProperty('--el-color-primary', c) }
function togglePageFullscreen() { pageFullscreen.value = !pageFullscreen.value }
function logout() { localStorage.removeItem('token'); window.location.hash = '#/login' }
function applyDark() { dark.value ? document.documentElement.classList.add('dark') : document.documentElement.classList.remove('dark') }
applyDark()
document.documentElement.style.setProperty('--el-color-primary', primaryColor.value)
return { dark, lang, layout, primaryColor, themeVisible, pageFullscreen, toggleDark, setLang, setLayout, setPrimaryColor, togglePageFullscreen, logout, applyDark }
})
+158
View File
@@ -0,0 +1,158 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/service/authService'
import type { TokenResponse, LoginParams } from '@/service/authService'
import router from '@/router'
export interface UserInfo {
userId?: number
username?: string
nickname?: string
avatar?: string
tenantId?: number
[key: string]: any
}
export const useAuthStore = defineStore('auth', () => {
// ============ State ============
const token = ref<TokenResponse | null>(null)
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value?.access_token)
// ============ Getters ============
const accessToken = computed(() => token.value?.access_token || '')
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || '')
const avatar = computed(() => userInfo.value?.avatar || '')
const tenantId = computed(() => userInfo.value?.tenantId || token.value?.tenantId || 0)
// ============ Actions ============
/**
* 从 localStorage 加载认证状态
*/
function loadAuth() {
try {
const tokenStr = localStorage.getItem('token')
if (tokenStr) {
token.value = JSON.parse(tokenStr)
}
const userStr = localStorage.getItem('user')
if (userStr) {
userInfo.value = JSON.parse(userStr)
}
} catch {
clearAuth()
}
}
/**
* 保存认证状态到 localStorage
*/
function saveAuth(tokenData: TokenResponse, user?: UserInfo) {
token.value = tokenData
localStorage.setItem('token', JSON.stringify(tokenData))
if (user) {
userInfo.value = user
localStorage.setItem('user', JSON.stringify(user))
}
}
/**
* 清除认证状态
*/
function clearAuth() {
token.value = null
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('tenantId')
}
/**
* 登录
*/
async function login(params: LoginParams): Promise<boolean> {
try {
const tokenData = await authService.login(params)
saveAuth(tokenData)
// 如果有租户ID,保存到 localStorage
if (params.tenantId) {
localStorage.setItem('tenantId', params.tenantId)
}
// 获取用户信息
await fetchUserInfo()
return true
} catch {
return false
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo(): Promise<boolean> {
try {
const user = await authService.getUserInfo()
userInfo.value = {
userId: user.id,
username: user.username,
nickname: user.nickName || user.nickname || user.username,
avatar: user.avatar,
tenantId: user.tenantId,
...user,
}
localStorage.setItem('user', JSON.stringify(userInfo.value))
return true
} catch {
return false
}
}
/**
* 登出
*/
async function logout(): Promise<void> {
await authService.logout()
clearAuth()
router.push('/login')
}
/**
* 刷新 Token
*/
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = token.value?.refresh_token
if (!refreshToken) return false
try {
const tokenData = await authService.refreshToken(refreshToken)
saveAuth(tokenData)
return true
} catch {
// 刷新失败,需要重新登录
clearAuth()
return false
}
}
// ============ 初始化加载 ============
loadAuth()
return {
token,
userInfo,
isLoggedIn,
accessToken,
username,
avatar,
tenantId,
login,
logout,
fetchUserInfo,
refreshAccessToken,
clearAuth,
}
})
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
export interface TagItem { path: string; title: string }
export const useTagsStore = defineStore('tags', () => {
const visited = ref<TagItem[]>(load())
function load() {
const saved = JSON.parse(localStorage.getItem('tags') || '[]') as TagItem[]
const hasHome = saved.some(t => t.path === '/dashboard')
if (!hasHome) saved.unshift({ path: '/dashboard', title: 'menu.home' })
return saved
}
function addTag(route: RouteLocationNormalized) {
if (route.meta.hidden) return
const title = (route.meta.i18n as string) || route.name as string || ''
if (!title) return
const existIdx = visited.value.findIndex(t => t.path === route.path)
if (existIdx >= 0) return
visited.value.push({ path: route.path, title })
save()
}
function removeTag(path: string) {
visited.value = visited.value.filter(t => t.path !== path)
save()
}
function closeOther(path: string) {
visited.value = visited.value.filter(t => t.path === path || t.path === '/dashboard')
save()
}
function closeAll() {
visited.value = visited.value.filter(t => t.path === '/dashboard')
save()
}
function save() {
localStorage.setItem('tags', JSON.stringify(visited.value))
}
return { visited, addTag, removeTag, closeOther, closeAll }
})
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface UserInfo {
username?: string
nickname?: string
avatar?: string
[key: string]: any
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
/**
* 从 localStorage 加载用户信息
*/
function loadUserInfo() {
try {
const userStr = localStorage.getItem('user')
if (userStr) {
const data = JSON.parse(userStr)
// 兼容不同后端返回格式:可能直接是用户对象,也可能是 token 对象
userInfo.value = {
username: data.username || data.user_name || data.name || 'Admin',
nickname: data.nickname || data.nickName || data.username || data.user_name || 'Admin',
avatar: data.avatar || data.headImgUrl || '',
...data,
}
}
} catch {
userInfo.value = null
}
}
const username = computed(() => userInfo.value?.nickname || userInfo.value?.username || 'Admin')
const avatar = computed(() => userInfo.value?.avatar || '')
function clearUser() {
userInfo.value = null
}
// 初始化时加载
loadUserInfo()
return { userInfo, username, avatar, loadUserInfo, clearUser }
})
+75
View File
@@ -0,0 +1,75 @@
/**
* 系统构建配置
*/
export interface BuildConfig {
/** 系统唯一标识,产物目录名 */
key: string
/** 系统显示名称 */
name: string
/** 系统描述 */
description?: string
/** 包含的模块列表 */
modules: string[]
/** 登录页配置 */
login: LoginConfig
/** Dashboard配置 */
dashboard: DashboardConfig
/** 主题配置 */
theme: ThemeConfig
}
/**
* 登录页配置
*/
export interface LoginConfig {
/** 登录组件名(对应 views/login/systems/ 下的组件) */
component: string
/** 是否显示租户ID输入 */
showTenantInput: boolean
/** 页面标题 */
title: string
/** 副标题 */
subtitle?: string
/** 背景图路径 */
background?: string
/** Logo路径 */
logo?: string
}
/**
* Dashboard配置
*/
export interface DashboardConfig {
/** Dashboard组件名(对应 views/dashboard/systems/ 下的组件) */
component: string
/** 页面标题 */
title: string
}
/**
* 主题配置
*/
export interface ThemeConfig {
/** 主题色 */
primaryColor: string
/** 页面标题 */
title: string
}
/**
* 虚拟模块:生成的路由配置
*/
declare module 'virtual:generated-routes' {
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[]
export default routes
}
/**
* 虚拟模块:系统配置
*/
declare module 'virtual:system-config' {
import type { BuildConfig } from './system-config'
const config: BuildConfig
export default config
}
+150
View File
@@ -0,0 +1,150 @@
import axios from 'axios'
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import type { LoadingInstance } from 'element-plus/es/components/loading/src/loading'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 全局 loading 实例
let loadingInstance: LoadingInstance | null = null
let requestCount = 0
/**
* 显示 loading
*/
function showLoading() {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.1)',
})
}
requestCount++
}
/**
* 隐藏 loading
*/
function hideLoading() {
requestCount--
if (requestCount <= 0) {
requestCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}
// 不需要显示 loading 的接口白名单
const noLoadingUrls = ['/oauth2/token']
// 请求拦截
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 显示 loading(白名单接口除外)
if (!noLoadingUrls.some(url => config.url?.includes(url))) {
showLoading()
}
// 透传租户编号至后端(优先级:Token中 > localStorage > 环境变量)
let tenantId = localStorage.getItem('tenantId') || import.meta.env.VITE_TENANT_ID
const tokenStr = localStorage.getItem('token')
if (tokenStr) {
try {
const token = JSON.parse(tokenStr)
config.headers.Authorization = `Bearer ${token.access_token}`
// Token中如有租户编号则使用token中的值,用于一键登录
if (token.tenantId) {
tenantId = token.tenantId
}
} catch (e) {
console.warn('Token解析失败', e)
}
}
if (tenantId) {
config.headers['X-Tenant-Id'] = tenantId
}
return config
},
(error) => {
hideLoading()
return Promise.reject(error)
},
)
// 响应拦截
request.interceptors.response.use(
(response: AxiosResponse) => {
hideLoading()
const { data } = response
// 业务错误处理
if (data.error !== 0) {
// 特殊错误码处理
if (data.code === 403) {
ElMessage.error('无权访问该资源')
} else {
ElMessage.error(data.message || '请求失败')
}
return Promise.reject(data)
}
return data
},
(error) => {
hideLoading()
// 网络错误处理
if (!error.response) {
if (error.message?.includes('timeout')) {
ElMessage.error('请求超时,请稍后重试')
} else if (error.message?.includes('Network Error')) {
ElMessage.error('网络连接失败,请检查网络')
} else {
ElMessage.error('服务器连接失败')
}
return Promise.reject(error)
}
// HTTP 状态码处理
const status = error.response.status
switch (status) {
case 400:
ElMessage.error('请求参数错误')
break
case 401:
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('tenantId')
window.location.hash = '#/login'
break
case 403:
ElMessage.error('无权访问该资源')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务暂不可用')
break
default:
ElMessage.error(`请求失败: ${status}`)
}
return Promise.reject(error)
},
)
export { request }
export default request
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderService } from '@/service/cashier/orderService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import OpenRoomDialog from './OpenRoomDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'orderNo', label: '订单编号', width: 150 },
{ prop: 'storeId', label: '门店ID', width: 100 },
{ prop: 'roomId', label: '包间ID', width: 100 },
{ prop: 'customerName', label: '顾客姓名', width: 100 },
{ prop: 'customerPhone', label: '顾客电话', width: 130 },
{ prop: 'totalAmount', label: '总金额', width: 100, align: 'right', dataType: 'money' },
{ prop: 'payAmount', label: '实付金额', width: 100, align: 'right', dataType: 'money' },
{
prop: 'payStatus',
label: '支付状态',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 0: '未支付', 1: '部分支付', 2: '已支付' },
},
{ prop: 'payType', label: '支付方式', width: 100 },
{
prop: 'status',
label: '订单状态',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 0: '开台中', 1: '已挂单', 2: '待支付', 3: '已完成', 4: '已退款' },
},
{ prop: 'createTime', label: '创建时间', width: 160, dataType: 'dateTime' },
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
const openRoomVisible = ref(false)
/**
* 查询参数
*/
const queryParams = ref({
orderNo: '',
status: undefined as number | undefined,
payStatus: undefined as number | undefined,
})
/**
* 加载数据
*/
async function loadData(params: any) {
return await orderService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
orderNo: '',
status: undefined,
payStatus: undefined,
}
tableRef.value?.reset()
}
/**
* 开台
*/
function handleOpen() {
openRoomVisible.value = true
}
/**
* 结账
*/
function handleCheckout(row: any) {
ElMessageBox.confirm(`确认为订单 "${row.orderNo}" 结账吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await orderService.checkout(row.id)
ElMessage.success('结账成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 退款
*/
function handleRefund(row: any) {
ElMessageBox.prompt('请输入退款金额', '退款', {
inputType: 'number',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async ({ value }: any) => {
try {
await orderService.refund(row.id, { amount: Number(value), reason: '用户申请退款' })
ElMessage.success('退款成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">订单管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="订单编号">
<el-input v-model="queryParams.orderNo" placeholder="请输入订单编号" clearable />
</el-form-item>
<el-form-item label="订单状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="开台中" :value="0" />
<el-option label="已挂单" :value="1" />
<el-option label="待支付" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已退款" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="queryParams.payStatus" placeholder="请选择状态" clearable>
<el-option label="未支付" :value="0" />
<el-option label="部分支付" :value="1" />
<el-option label="已支付" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleOpen">开台</el-button>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button
v-if="row.status === 0 || row.status === 1"
link
type="primary"
size="small"
@click="handleCheckout(row)"
>
结账
</el-button>
<el-button
v-if="row.status === 3 && row.payStatus === 2"
link
type="warning"
size="small"
@click="handleRefund(row)"
>
退款
</el-button>
</template>
</RuiTable>
<OpenRoomDialog
v-model:visible="openRoomVisible"
@success="tableRef?.refresh()"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { orderService } from '@/service/cashier/orderService'
import { storeService } from '@/service/cashier/storeService'
import { roomService } from '@/service/cashier/roomService'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
storeId: undefined as number | undefined,
roomId: undefined as number | undefined,
customerName: '',
customerPhone: '',
orderType: 1,
remark: '',
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 门店列表
const storeList = ref<any[]>([])
// 包间列表
const roomList = ref<any[]>([])
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
loadStores()
Object.assign(form, {
storeId: undefined,
roomId: undefined,
customerName: '',
customerPhone: '',
orderType: 1,
remark: '',
})
roomList.value = []
}
})
// 加载门店列表
async function loadStores() {
try {
const res = await storeService.list({ status: 1 })
storeList.value = res || []
} catch {
storeList.value = []
}
}
// 门店变化时加载包间列表
async function handleStoreChange(storeId: number) {
form.roomId = undefined
if (!storeId) {
roomList.value = []
return
}
try {
const res = await roomService.list({ storeId, enabled: 1 })
roomList.value = res || []
} catch {
roomList.value = []
}
}
// 表单校验规则
const rules = {
storeId: [
{ required: true, message: '请选择门店', trigger: 'change' },
],
roomId: [
{ required: true, message: '请选择包间', trigger: 'change' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await orderService.openRoom({
storeId: form.storeId!,
roomId: form.roomId!,
customerName: form.customerName || undefined,
customerPhone: form.customerPhone || undefined,
orderType: form.orderType,
remark: form.remark || undefined,
})
ElMessage.success('开台成功')
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
title="开台"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="门店" prop="storeId">
<el-select
v-model="form.storeId"
placeholder="请选择门店"
clearable
@change="handleStoreChange"
>
<el-option
v-for="item in storeList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="包间" prop="roomId">
<el-select v-model="form.roomId" placeholder="请选择包间" clearable>
<el-option
v-for="item in roomList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="顾客姓名">
<el-input v-model.trim="form.customerName" placeholder="请输入顾客姓名" />
</el-form-item>
<el-form-item label="顾客电话">
<el-input v-model.trim="form.customerPhone" placeholder="请输入顾客电话" />
</el-form-item>
<el-form-item label="订单类型">
<el-radio-group v-model="form.orderType">
<el-radio-button :label="1">正常订单</el-radio-button>
<el-radio-button :label="2">预订订单</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model.trim="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pricingService } from '@/service/cashier/pricingService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import PricingStrategyFormDialog from './PricingStrategyFormDialog.vue'
import PricingPackageDialog from './PricingPackageDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'strategyName', label: '策略名称', minWidth: 150 },
{ prop: 'roomTypeId', label: '包间类型ID', width: 120 },
{
prop: 'status',
label: '状态',
width: 80,
align: 'center',
dataType: 'enum',
enumMap: { 0: '禁用', 1: '启用' },
slot: true,
},
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
/**
* 查询参数
*/
const queryParams = ref({
strategyName: '',
status: undefined as number | undefined,
})
/**
* 策略表单弹窗状态
*/
const strategyDialogVisible = ref(false)
const strategyRow = ref<any>(undefined)
/**
* 套餐管理弹窗状态
*/
const packageDialogVisible = ref(false)
const packageStrategyId = ref<number | undefined>(undefined)
/**
* 加载数据
*/
async function loadData(params: any) {
return await pricingService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
strategyName: '',
status: undefined,
}
tableRef.value?.reset()
}
/**
* 新增
*/
function handleAdd() {
strategyRow.value = undefined
strategyDialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
strategyRow.value = row
strategyDialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除策略 "${row.strategyName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await pricingService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await pricingService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1
}
}
/**
* 查看套餐
*/
function handleViewPackages(row: any) {
packageStrategyId.value = row.id
packageDialogVisible.value = true
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">定价策略管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="策略名称">
<el-input v-model="queryParams.strategyName" placeholder="请输入策略名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">新增策略</el-button>
</template>
<!-- 状态列 -->
<template #column-status="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val as number)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="info" size="small" @click="handleViewPackages(row)">查看套餐</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</RuiTable>
<PricingStrategyFormDialog
v-model:visible="strategyDialogVisible"
:row="strategyRow"
@success="tableRef?.refresh()"
/>
<PricingPackageDialog
v-model:visible="packageDialogVisible"
:strategy-id="packageStrategyId"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pricingService } from '@/service/cashier/pricingService'
const props = defineProps<{
visible: boolean
strategyId?: number
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
// 套餐列表
const packages = ref<any[]>([])
const loading = ref(false)
// 内层表单弹窗
const formDialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
const formLoading = ref(false)
const form = reactive({
id: undefined as number | undefined,
name: '',
price: undefined as number | undefined,
duration: undefined as number | undefined,
durationUnit: 'hour',
billingType: 1,
minDuration: undefined as number | undefined,
isDefault: 0,
sort: 0,
status: 1,
description: '',
strategyId: undefined as number | undefined,
})
// 监听 visible 变化,加载数据
watch(() => props.visible, async (val) => {
if (val && props.strategyId) {
await loadPackages()
}
})
// 加载套餐列表
async function loadPackages() {
if (!props.strategyId) return
loading.value = true
try {
const list = await pricingService.getPackages(props.strategyId)
packages.value = list
} catch {
packages.value = []
} finally {
loading.value = false
}
}
// 打开新增弹窗
function handleAdd() {
isEdit.value = false
Object.assign(form, {
id: undefined,
name: '',
price: undefined,
duration: undefined,
durationUnit: 'hour',
billingType: 1,
minDuration: undefined,
isDefault: 0,
sort: 0,
status: 1,
description: '',
strategyId: props.strategyId,
})
formDialogVisible.value = true
}
// 打开编辑弹窗
function handleEdit(row: any) {
isEdit.value = true
Object.assign(form, row)
formDialogVisible.value = true
}
// 删除套餐
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除套餐 "${row.name}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await pricingService.deletePackage(row.id)
ElMessage.success('删除成功')
await loadPackages()
} catch {}
}).catch(() => {})
}
// 表单校验规则
const rules = {
name: [
{ required: true, message: '请输入套餐名称', trigger: 'blur' },
],
price: [
{ required: true, message: '请输入价格', trigger: 'blur' },
],
duration: [
{ required: true, message: '请输入时长', trigger: 'blur' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
formLoading.value = true
try {
if (isEdit.value) {
await pricingService.updatePackage(form as any)
ElMessage.success('修改成功')
} else {
await pricingService.addPackage(form)
ElMessage.success('新增成功')
}
formDialogVisible.value = false
await loadPackages()
} catch {
// 错误已由请求拦截器统一提示
} finally {
formLoading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
// 关闭内层弹窗
function handleFormClose() {
formDialogVisible.value = false
}
</script>
<template>
<el-dialog
class="rui-dialog"
title="套餐管理"
:model-value="visible"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="mb-4">
<el-button type="primary" @click="handleAdd">新增套餐</el-button>
</div>
<el-table :data="packages" v-loading="loading" border>
<el-table-column prop="name" label="套餐名称" min-width="150" />
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">
{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="duration" label="时长" width="100">
<template #default="{ row }">
{{ row.duration }}{{ row.durationUnit === 'hour' ? '小时' : '天' }}
</template>
</el-table-column>
<el-table-column prop="billingType" label="计费类型" width="100">
<template #default="{ row }">
<el-tag v-if="row.billingType === 1" size="small">按时计费</el-tag>
<el-tag v-else-if="row.billingType === 2" size="small" type="success">按局计费</el-tag>
<el-tag v-else-if="row.billingType === 3" size="small" type="warning">包时段</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="isDefault" label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.isDefault === 1" size="small" type="success"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 1" size="small" type="success">启用</el-tag>
<el-tag v-else size="small" type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 内层表单弹窗 -->
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑套餐' : '新增套餐'"
width="600px"
append-to-body
:close-on-click-modal="false"
@close="handleFormClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="套餐名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入套餐名称" />
</el-form-item>
<el-form-item label="价格" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" placeholder="请输入价格" />
</el-form-item>
<el-form-item label="时长" prop="duration">
<el-input-number v-model="form.duration" :min="1" placeholder="请输入时长" />
</el-form-item>
<el-form-item label="时长单位" prop="durationUnit">
<el-radio-group v-model="form.durationUnit">
<el-radio-button label="hour">小时</el-radio-button>
<el-radio-button label="day"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="计费类型" prop="billingType">
<el-radio-group v-model="form.billingType">
<el-radio-button :label="1">按时计费</el-radio-button>
<el-radio-button :label="2">按局计费</el-radio-button>
<el-radio-button :label="3">包时段</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="最小时长" prop="minDuration">
<el-input-number v-model="form.minDuration" :min="0" placeholder="请输入最小时长" />
</el-form-item>
<el-form-item label="是否默认" prop="isDefault">
<el-radio-group v-model="form.isDefault">
<el-radio-button :label="1"></el-radio-button>
<el-radio-button :label="0"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button :label="1">启用</el-radio-button>
<el-radio-button :label="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model.trim="form.description"
type="textarea"
:rows="3"
placeholder="请输入描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleFormClose">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { pricingService } from '@/service/cashier/pricingService'
const props = defineProps<{
visible: boolean
row?: any
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
id: undefined as number | undefined,
strategyName: '',
roomTypeId: undefined as number | undefined,
status: 1,
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 是否编辑模式
const isEdit = ref(false)
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
if (props.row) {
isEdit.value = true
Object.assign(form, props.row)
} else {
isEdit.value = false
Object.assign(form, {
id: undefined,
strategyName: '',
roomTypeId: undefined,
status: 1,
})
}
}
})
// 表单校验规则
const rules = {
strategyName: [
{ required: true, message: '请输入策略名称', trigger: 'blur' },
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
await pricingService.update(form as any)
ElMessage.success('修改成功')
} else {
await pricingService.add(form)
ElMessage.success('新增成功')
}
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
:title="isEdit ? '编辑定价策略' : '新增定价策略'"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="策略名称" prop="strategyName">
<el-input v-model.trim="form.strategyName" placeholder="请输入策略名称" />
</el-form-item>
<el-form-item label="包间类型ID" prop="roomTypeId">
<el-input-number v-model="form.roomTypeId" :min="0" placeholder="请输入包间类型ID" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button :label="1">启用</el-radio-button>
<el-radio-button :label="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { productService } from '@/service/cashier/productService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import ProductFormDialog from './ProductFormDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'productName', label: '商品名称', minWidth: 150 },
{ prop: 'productCode', label: '商品编码', width: 120 },
{ prop: 'categoryId', label: '分类ID', width: 100 },
{
prop: 'productType',
label: '类型',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 1: '普通商品', 2: '服务商品' },
},
{ prop: 'unit', label: '单位', width: 80 },
{ prop: 'salePrice', label: '售价', width: 100, align: 'right', dataType: 'money' },
{ prop: 'costPrice', label: '成本价', width: 100, align: 'right', dataType: 'money' },
{ prop: 'sort', label: '排序', width: 80, align: 'center' },
{
prop: 'status',
label: '状态',
width: 80,
align: 'center',
dataType: 'enum',
enumMap: { 0: '禁用', 1: '启用' },
slot: true,
},
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
/**
* 弹窗状态
*/
const dialogVisible = ref(false)
const currentRow = ref<any>()
/**
* 查询参数
*/
const queryParams = ref({
productName: '',
status: undefined as number | undefined,
})
/**
* 加载数据
*/
async function loadData(params: any) {
return await productService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
productName: '',
status: undefined,
}
tableRef.value?.reset()
}
/**
* 新增
*/
function handleAdd() {
currentRow.value = undefined
dialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
currentRow.value = row
dialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除商品 "${row.productName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await productService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await productService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1
}
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">商品管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="商品名称">
<el-input v-model="queryParams.productName" placeholder="请输入商品名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">新增商品</el-button>
</template>
<!-- 状态列 -->
<template #column-status="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val as number)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</RuiTable>
<ProductFormDialog
v-model:visible="dialogVisible"
:row="currentRow"
@success="tableRef?.refresh()"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { productService } from '@/service/cashier/productService'
const props = defineProps<{
visible: boolean
row?: any
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 是否编辑模式
const isEdit = ref(false)
// 表单数据
const form = reactive({
id: undefined as number | undefined,
name: '',
price: undefined as number | undefined,
productType: 1,
category: '',
unit: '',
stock: undefined as number | undefined,
status: 1,
description: '',
storeId: undefined as number | undefined,
})
// 是否显示库存字段(仅实物商品)
const showStock = computed(() => form.productType === 1)
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
if (props.row) {
isEdit.value = true
Object.assign(form, props.row)
} else {
isEdit.value = false
Object.assign(form, {
id: undefined,
name: '',
price: undefined,
productType: 1,
category: '',
unit: '',
stock: undefined,
status: 1,
description: '',
storeId: undefined,
})
}
}
})
// 表单校验规则
const rules = {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' },
],
price: [
{ required: true, message: '请输入商品价格', trigger: 'blur' },
],
productType: [
{ required: true, message: '请选择商品类型', trigger: 'change' },
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
await productService.update(form as any)
ElMessage.success('修改成功')
} else {
await productService.add(form)
ElMessage.success('新增成功')
}
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
:title="isEdit ? '编辑商品' : '新增商品'"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="商品名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入商品名称" />
</el-form-item>
<el-form-item label="商品类型" prop="productType">
<el-radio-group v-model="form.productType">
<el-radio-button :label="1">实物商品</el-radio-button>
<el-radio-button :label="2">服务商品</el-radio-button>
<el-radio-button :label="3">虚拟商品</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="售价" prop="price">
<el-input-number
v-model="form.price"
:precision="2"
:min="0"
placeholder="请输入售价"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="分类" prop="category">
<el-input v-model.trim="form.category" placeholder="请输入分类" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model.trim="form.unit" placeholder="如:个、杯、小时" />
</el-form-item>
<el-form-item v-if="showStock" label="库存" prop="stock">
<el-input-number
v-model="form.stock"
:min="0"
:precision="0"
placeholder="请输入库存数量"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button :label="1">启用</el-radio-button>
<el-radio-button :label="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model.trim="form.description"
type="textarea"
:rows="3"
placeholder="请输入商品描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
+496
View File
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { reportService } from '@/service/cashier/reportService'
/**
* 查询参数
*/
const queryParams = ref({
storeId: 1,
dateRange: [new Date().toISOString().split('T')[0], new Date().toISOString().split('T')[0]] as string[],
})
/**
* 日报数据
*/
const dailyReport = ref<any>(null)
/**
* 包间利用率
*/
const roomUsageList = ref<any[]>([])
/**
* 加载状态
*/
const loadingDaily = ref(false)
const loadingRoom = ref(false)
/**
* 计算当前查询日期
*/
const currentDate = computed(() => {
if (Array.isArray(queryParams.value.dateRange) && queryParams.value.dateRange.length > 0) {
return queryParams.value.dateRange[0]
}
return new Date().toISOString().split('T')[0]
})
/**
* 格式化金额
*/
function formatMoney(value: number | undefined): string {
if (value === undefined || value === null) return '0.00'
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/**
* 获取趋势数据(模拟)
*/
function getTrend(field: string): number {
// 实际应从昨日数据计算,此处为演示返回模拟值
const mockTrends: Record<string, number> = {
totalAmount: 12.5,
payAmount: 8.3,
orderCount: -2.1,
completedOrderCount: 5.0,
avgAmount: 15.2,
refundAmount: -5.8,
}
return mockTrends[field] || 0
}
/**
* 加载日报
*/
async function loadDailyReport() {
loadingDaily.value = true
try {
dailyReport.value = await reportService.getDailyReport(
queryParams.value.storeId,
currentDate.value
)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loadingDaily.value = false
}
}
/**
* 加载包间利用率
*/
async function loadRoomUsage() {
loadingRoom.value = true
try {
roomUsageList.value = await reportService.getRoomUsage(
queryParams.value.storeId,
currentDate.value
)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loadingRoom.value = false
}
}
/**
* 查询
*/
function handleSearch() {
loadDailyReport()
loadRoomUsage()
}
/**
* 刷新数据
*/
function handleRefresh() {
handleSearch()
ElMessage.success('数据已刷新')
}
/**
* 导出报表
*/
function handleExport() {
ElMessage.info('导出功能开发中')
}
onMounted(() => {
handleSearch()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<h2 class="text-xl font-bold">营业报表</h2>
<div class="header-actions">
<el-button icon="Refresh" @click="handleRefresh">
刷新
</el-button>
<el-button type="success" icon="Download" @click="handleExport">
导出
</el-button>
</div>
</div>
<!-- 查询表单 -->
<el-form :model="queryParams" inline class="search-form">
<el-form-item label="门店ID">
<el-input-number v-model="queryParams.storeId" :min="1" />
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="queryParams.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="[
{ text: '今天', value: [new Date(), new Date()] },
{ text: '昨天', value: () => { const d = new Date(); d.setDate(d.getDate() - 1); return [d, d] } },
{ text: '最近7天', value: () => { const d = new Date(); d.setDate(d.getDate() - 7); return [d, new Date()] } },
{ text: '最近30天', value: () => { const d = new Date(); d.setDate(d.getDate() - 30); return [d, new Date()] } },
]"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
<!-- 日报数据卡片 -->
<el-row :gutter="16" class="data-cards">
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
<span>订单总数</span>
</div>
</template>
<div class="card-body">
<div class="card-value">{{ dailyReport?.orderCount || 0 }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('orderCount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
<span :class="getTrend('orderCount') >= 0 ? 'text-success' : 'text-danger'">
{{ Math.abs(getTrend('orderCount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><CircleCheck /></el-icon>
<span>已完成订单</span>
</div>
</template>
<div class="card-body">
<div class="card-value">{{ dailyReport?.completedOrderCount || 0 }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('completedOrderCount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
<span :class="getTrend('completedOrderCount') >= 0 ? 'text-success' : 'text-danger'">
{{ Math.abs(getTrend('completedOrderCount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><Money /></el-icon>
<span>总营业额</span>
</div>
</template>
<div class="card-body">
<div class="card-value text-success">¥{{ formatMoney(dailyReport?.totalAmount) }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('totalAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
<span :class="getTrend('totalAmount') >= 0 ? 'text-success' : 'text-danger'">
{{ Math.abs(getTrend('totalAmount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><Wallet /></el-icon>
<span>实付金额</span>
</div>
</template>
<div class="card-body">
<div class="card-value text-success">¥{{ formatMoney(dailyReport?.payAmount) }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('payAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
<span :class="getTrend('payAmount') >= 0 ? 'text-success' : 'text-danger'">
{{ Math.abs(getTrend('payAmount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><TrendCharts /></el-icon>
<span>客单价</span>
</div>
</template>
<div class="card-body">
<div class="card-value">¥{{ formatMoney(dailyReport?.avgAmount) }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('avgAmount') >= 0" color="#67c23a"><ArrowUp /></el-icon>
<el-icon v-else color="#f56c6c"><ArrowDown /></el-icon>
<span :class="getTrend('avgAmount') >= 0 ? 'text-success' : 'text-danger'">
{{ Math.abs(getTrend('avgAmount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4" class="mb-4">
<el-card v-loading="loadingDaily" class="stat-card">
<template #header>
<div class="card-header">
<el-icon><Refund /></el-icon>
<span>退款金额</span>
</div>
</template>
<div class="card-body">
<div class="card-value text-danger">¥{{ formatMoney(dailyReport?.refundAmount) }}</div>
<div v-if="dailyReport" class="card-trend">
<el-icon v-if="getTrend('refundAmount') >= 0" color="#f56c6c"><ArrowUp /></el-icon>
<el-icon v-else color="#67c23a"><ArrowDown /></el-icon>
<span :class="getTrend('refundAmount') >= 0 ? 'text-danger' : 'text-success'">
{{ Math.abs(getTrend('refundAmount')) }}% 较昨日
</span>
</div>
</div>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" :image-size="60" />
</el-card>
</el-col>
</el-row>
<!-- 收入明细 -->
<el-card class="mt-4" v-loading="loadingDaily">
<template #header>
<div class="card-header">
<el-icon><Coin /></el-icon>
<span>收入明细</span>
</div>
</template>
<el-empty v-if="!dailyReport && !loadingDaily" description="暂无数据" />
<el-row :gutter="16" v-if="dailyReport">
<el-col :xs="24" :sm="8">
<div class="income-item">
<span class="label">
<el-icon><House /></el-icon> 包间收入
</span>
<span class="value text-success">¥{{ formatMoney(dailyReport.roomAmount) }}</span>
</div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="income-item">
<span class="label">
<el-icon><Goods /></el-icon> 商品收入
</span>
<span class="value text-success">¥{{ formatMoney(dailyReport.productAmount) }}</span>
</div>
</el-col>
<el-col :xs="24" :sm="8">
<div class="income-item">
<span class="label">
<el-icon><Discount /></el-icon> 优惠金额
</span>
<span class="value text-danger">-¥{{ formatMoney(dailyReport.discountAmount) }}</span>
</div>
</el-col>
</el-row>
</el-card>
<!-- 支付方式统计 -->
<el-card class="mt-4" v-loading="loadingDaily">
<template #header>
<div class="card-header">
<el-icon><CreditCard /></el-icon>
<span>支付方式统计</span>
</div>
</template>
<el-empty v-if="!dailyReport?.payTypeStats?.length && !loadingDaily" description="暂无数据" />
<el-table
v-if="dailyReport?.payTypeStats?.length"
:data="dailyReport.payTypeStats"
border
stripe
highlight-current-row
>
<el-table-column prop="payType" label="支付方式" min-width="120" />
<el-table-column prop="count" label="笔数" align="center" width="100" />
<el-table-column prop="amount" label="金额" align="right" min-width="120">
<template #default="{ row }">
<span :class="(row.amount || 0) >= 0 ? 'text-success' : 'text-danger'">
¥{{ formatMoney(row.amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="占比" align="center" width="120">
<template #default="{ row }">
<el-progress
:percentage="dailyReport.payAmount ? Math.round((row.amount / dailyReport.payAmount) * 100) : 0"
:stroke-width="8"
:show-text="true"
/>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 包间利用率 -->
<el-card class="mt-4" v-loading="loadingRoom">
<template #header>
<div class="card-header">
<el-icon><OfficeBuilding /></el-icon>
<span>包间利用率</span>
</div>
</template>
<el-empty v-if="!roomUsageList.length && !loadingRoom" description="暂无数据" />
<el-table
v-if="roomUsageList.length"
:data="roomUsageList"
border
stripe
highlight-current-row
>
<el-table-column prop="roomId" label="包间ID" width="100" />
<el-table-column prop="orderCount" label="订单数" width="100" align="center" />
<el-table-column prop="totalAmount" label="营业额" align="right" min-width="120">
<template #default="{ row }">
<span class="text-success">¥{{ formatMoney(row.totalAmount) }}</span>
</template>
</el-table-column>
<el-table-column prop="usageMinutes" label="使用时长(分钟)" width="140" align="center" />
<el-table-column prop="idleMinutes" label="空闲时长(分钟)" width="140" align="center" />
<el-table-column prop="usageRate" label="利用率" width="160" align="center">
<template #default="{ row }">
<el-progress
:percentage="Math.min(Math.round(row.usageRate || 0), 100)"
:stroke-width="10"
:status="(row.usageRate || 0) >= 80 ? 'success' : (row.usageRate || 0) >= 50 ? '' : 'exception'"
/>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 8px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 4px;
}
.data-cards {
margin-bottom: 0;
}
.stat-card {
height: 100%;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.card-body {
text-align: center;
padding: 8px 0;
}
.card-value {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.card-trend {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 13px;
}
.text-success {
color: #67c23a;
}
.text-danger {
color: #f56c6c;
}
.income-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 8px;
}
.income-item:last-child {
margin-bottom: 0;
}
.income-item .label {
display: flex;
align-items: center;
gap: 6px;
color: #666;
}
.income-item .value {
font-size: 18px;
font-weight: bold;
}
.mt-4 {
margin-top: 16px;
}
.mb-4 {
margin-bottom: 16px;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { roomService } from '@/service/cashier/roomService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import RoomFormDialog from './RoomFormDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'roomName', label: '包间名称', minWidth: 150 },
{ prop: 'roomNo', label: '包间编号', width: 120 },
{ prop: 'storeId', label: '门店ID', width: 100 },
{ prop: 'roomTypeId', label: '包间类型ID', width: 120 },
{
prop: 'roomStatus',
label: '状态',
width: 100,
align: 'center',
dataType: 'enum',
enumMap: { 0: '空闲', 1: '使用中', 2: '待清洁', 3: '已挂单', 4: '已预约' },
},
{ prop: 'currentOrderId', label: '当前订单ID', width: 120 },
{ prop: 'sort', label: '排序', width: 80, align: 'center' },
{
prop: 'enabled',
label: '启用',
width: 80,
align: 'center',
dataType: 'enum',
enumMap: { 0: '禁用', 1: '启用' },
slot: true,
},
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
/**
* 弹窗相关
*/
const dialogVisible = ref(false)
const currentRow = ref<any>()
/**
* 查询参数
*/
const queryParams = ref({
roomName: '',
roomStatus: undefined as number | undefined,
})
/**
* 加载数据
*/
async function loadData(params: any) {
return await roomService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
roomName: '',
roomStatus: undefined,
}
tableRef.value?.reset()
}
/**
* 新增
*/
function handleAdd() {
currentRow.value = undefined
dialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
currentRow.value = row
dialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除包间 "${row.roomName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await roomService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await roomService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.enabled = status === 1 ? 0 : 1
}
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">包间管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="包间名称">
<el-input v-model="queryParams.roomName" placeholder="请输入包间名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.roomStatus" placeholder="请选择状态" clearable>
<el-option label="空闲" :value="0" />
<el-option label="使用中" :value="1" />
<el-option label="待清洁" :value="2" />
<el-option label="已挂单" :value="3" />
<el-option label="已预约" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">新增包间</el-button>
</template>
<!-- 启用列 -->
<template #column-enabled="{ row }">
<el-switch
v-model="row.enabled"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val as number)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</RuiTable>
<RoomFormDialog
v-model:visible="dialogVisible"
:row="currentRow"
@success="tableRef?.refresh()"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { roomService } from '@/service/cashier/roomService'
import { storeService } from '@/service/cashier/storeService'
const props = defineProps<{
visible: boolean
row?: any
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
id: undefined as number | undefined,
storeId: undefined as number | undefined,
name: '',
roomNo: '',
sort: 0,
enabled: 1,
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 是否编辑模式
const isEdit = ref(false)
// 门店列表
const storeList = ref<any[]>([])
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
loadStoreList()
if (props.row) {
isEdit.value = true
Object.assign(form, props.row)
} else {
isEdit.value = false
Object.assign(form, {
id: undefined,
storeId: undefined,
name: '',
roomNo: '',
sort: 0,
enabled: 1,
})
}
}
})
// 加载门店列表
async function loadStoreList() {
try {
const res = await storeService.list({ status: 1 })
storeList.value = res || []
} catch {
storeList.value = []
}
}
// 表单校验规则
const rules = {
storeId: [
{ required: true, message: '请选择所属门店', trigger: 'change' },
],
name: [
{ required: true, message: '请输入包间名称', trigger: 'blur' },
],
roomNo: [
{ required: true, message: '请输入包间编号', trigger: 'blur' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
await roomService.update(form as any)
ElMessage.success('修改成功')
} else {
await roomService.add(form)
ElMessage.success('新增成功')
}
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
:title="isEdit ? '编辑包间' : '新增包间'"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="所属门店" prop="storeId">
<el-select v-model="form.storeId" placeholder="请选择所属门店" clearable>
<el-option
v-for="item in storeList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="包间名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入包间名称" />
</el-form-item>
<el-form-item label="包间编号" prop="roomNo">
<el-input v-model.trim="form.roomNo" placeholder="请输入包间编号" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-radio-group v-model="form.enabled">
<el-radio-button :label="1">启用</el-radio-button>
<el-radio-button :label="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { storeService } from '@/service/cashier/storeService'
import RuiTable from '@/components/RuiTable.vue'
import type { TableColumn } from '@/components/RuiTable.vue'
import StoreFormDialog from './StoreFormDialog.vue'
/**
* 表格列配置
*/
const columns: TableColumn[] = [
{ prop: 'storeName', label: '门店名称', minWidth: 150 },
{ prop: 'storeCode', label: '门店编码', width: 120 },
{ prop: 'contactName', label: '联系人', width: 100 },
{ prop: 'contactPhone', label: '联系电话', width: 130 },
{ prop: 'address', label: '地址', minWidth: 200, tooltip: true },
{ prop: 'businessHours', label: '营业时间', width: 120 },
{
prop: 'status',
label: '状态',
width: 80,
align: 'center',
dataType: 'enum',
enumMap: { 0: '禁用', 1: '启用' },
slot: true,
},
]
/**
* 表格引用
*/
const tableRef = ref<InstanceType<typeof RuiTable>>()
/**
* 查询参数
*/
const queryParams = ref({
storeName: '',
status: undefined as number | undefined,
})
/**
* 弹窗显示状态
*/
const dialogVisible = ref(false)
/**
* 当前编辑行
*/
const currentRow = ref<any>()
/**
* 加载数据
*/
async function loadData(params: any) {
return await storeService.page(params)
}
/**
* 查询
*/
function handleSearch() {
tableRef.value?.setQueryParams(queryParams.value)
tableRef.value?.search()
}
/**
* 重置
*/
function handleReset() {
queryParams.value = {
storeName: '',
status: undefined,
}
tableRef.value?.reset()
}
/**
* 新增
*/
function handleAdd() {
currentRow.value = undefined
dialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
currentRow.value = row
dialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除门店 "${row.storeName}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await storeService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {
// 错误已由请求拦截器统一提示
}
}).catch(() => {})
}
/**
* 状态切换
*/
async function handleStatusChange(row: any, status: number) {
if (!row?.id) return
try {
await storeService.changeStatus(row.id, status)
ElMessage.success(status === 1 ? '启用成功' : '禁用成功')
} catch {
row.status = status === 1 ? 0 : 1
}
}
</script>
<template>
<div class="page-container">
<h2 class="text-xl font-bold mb-4">门店管理</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
>
<!-- 查询区域 -->
<template #search>
<el-form-item label="门店名称">
<el-input v-model="queryParams.storeName" placeholder="请输入门店名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</template>
<!-- 工具栏 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">新增门店</el-button>
</template>
<!-- 状态列 -->
<template #column-status="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val: any) => handleStatusChange(row, val as number)"
/>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</RuiTable>
<StoreFormDialog
v-model:visible="dialogVisible"
:row="currentRow"
@success="tableRef?.refresh()"
/>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { storeService } from '@/service/cashier/storeService'
const props = defineProps<{
visible: boolean
row?: any
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
id: undefined as number | undefined,
name: '',
address: '',
phone: '',
contactName: '',
status: 1,
remark: '',
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 是否编辑模式
const isEdit = ref(false)
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
if (props.row) {
isEdit.value = true
Object.assign(form, props.row)
} else {
isEdit.value = false
Object.assign(form, {
id: undefined,
name: '',
address: '',
phone: '',
contactName: '',
status: 1,
remark: '',
})
}
}
})
// 表单校验规则
const rules = {
name: [
{ required: true, message: '请输入门店名称', trigger: 'blur' },
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' },
],
}
// 提交表单
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
await storeService.update(form as any)
ElMessage.success('修改成功')
} else {
await storeService.add(form)
ElMessage.success('新增成功')
}
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
// 关闭弹窗
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
:title="isEdit ? '编辑门店' : '新增门店'"
:model-value="visible"
width="600px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="门店名称" prop="name">
<el-input v-model.trim="form.name" placeholder="请输入门店名称" />
</el-form-item>
<el-form-item label="门店地址" prop="address">
<el-input v-model.trim="form.address" placeholder="请输入门店地址" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="联系人" prop="contactName">
<el-input v-model.trim="form.contactName" placeholder="请输入联系人姓名" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button :label="1">启用</el-radio-button>
<el-radio-button :label="0">禁用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model.trim="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const title = route.meta.title || route.name || ''
</script>
<template>
<div class="stat-card">
<h2 class="text-xl font-bold mb-3">{{ title }}</h2>
<el-empty description="页面开发中..." />
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; }
</style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const title = route.meta.title || route.name || ''
</script>
<template>
<div class="stat-card">
<h2 class="text-xl font-bold mb-3">{{ title }}</h2>
<el-empty description="页面开发中..." />
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; }
</style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const title = route.meta.title || route.name || ''
</script>
<template>
<div class="stat-card">
<h2 class="text-xl font-bold mb-3">{{ title }}</h2>
<el-empty description="页面开发中..." />
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; }
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import systemConfig from 'virtual:system-config'
// 预加载所有 Dashboard 组件变体
const dashboardVariants: Record<string, any> = {
Default: defineAsyncComponent(() => import('./variants/Default.vue')),
Super: defineAsyncComponent(() => import('./variants/Super.vue')),
Cashier: defineAsyncComponent(() => import('./variants/Cashier.vue')),
}
const dashboardComponent = dashboardVariants[systemConfig.dashboard.component] || dashboardVariants.Default
</script>
<template>
<component :is="dashboardComponent" />
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
const stats = [
{ key: 'todayRevenue', label: '今日营收', value: 25800, growth: '+15%', icon: 'Money', color: '#1677ff' },
{ key: 'orderCount', label: '今日订单', value: 186, growth: '+8%', icon: 'ShoppingCart', color: '#52c41a' },
{ key: 'activeRooms', label: '开台数', value: 42, growth: '+12%', icon: 'HomeFilled', color: '#722ed1' },
{ key: 'memberCount', label: '会员数', value: 3560, growth: '+20%', icon: 'UserFilled', color: '#fa8c16' },
]
const recentOrders = [
{ name: '大厅-A1', action: '开台', time: '5m' },
{ name: '包厢-B2', action: '结账', time: '15m' },
{ name: '大厅-A3', action: '点单', time: '30m' },
]
</script>
<template>
<div>
<h2 class="text-xl font-bold mb-5">收银数据概览</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-5">
<div v-for="s in stats" :key="s.key" class="stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">{{ s.label }}</p>
<p class="text-3xl font-bold mb-1">{{ s.value.toLocaleString() }}</p>
<span class="text-xs" :style="{ color: s.color }">{{ s.growth }} 较昨日</span>
</div>
<div class="stat-icon" :style="{ background: s.color + '15', color: s.color }">
<el-icon :size="24"><component :is="s.icon" /></el-icon>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-bold mb-4">最近订单</h3>
<div class="stat-card">
<el-timeline>
<el-timeline-item v-for="(r, i) in recentOrders" :key="i" :timestamp="r.time" placement="top">
<span class="font-medium">{{ r.name }}</span>
<span class="text-gray-500 ml-2">{{ r.action }}</span>
</el-timeline-item>
</el-timeline>
</div>
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: box-shadow 0.2s; }
.stat-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const stats = [
{ key: 'users', label: '', value: 1280, growth: '+12%', icon: 'UserFilled', color: '#1677ff' },
{ key: 'today', label: '', value: 23, growth: '+8%', icon: 'TrendCharts', color: '#52c41a' },
{ key: 'active', label: '', value: 856, growth: '+23%', icon: 'DataLine', color: '#722ed1' },
{ key: 'orders', label: '', value: 5680, growth: '+15%', icon: 'ShoppingCart', color: '#fa8c16' },
].map(s => ({ ...s, label: t(`dashboard.${s.key}`) }))
const recent = [
{ name: '张三', actionKey: 'register', time: '10m' },
{ name: '李四', actionKey: 'login', time: '30m' },
{ name: '王五', actionKey: 'order', time: '1h' },
{ name: '赵六', actionKey: 'comment', time: '2h' },
].map(r => ({ ...r, action: t(`dashboard.action.${r.actionKey}`) }))
</script>
<template>
<div>
<h2 class="text-xl font-bold mb-5">{{ t('dashboard.title') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-5">
<div v-for="s in stats" :key="s.key" class="stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">{{ s.label }}</p>
<p class="text-3xl font-bold mb-1">{{ s.value.toLocaleString() }}</p>
<span class="text-xs" :style="{ color: s.color }">{{ s.growth }} {{ t('dashboard.vsLastWeek') }}</span>
</div>
<div class="stat-icon" :style="{ background: s.color + '15', color: s.color }">
<el-icon :size="24"><component :is="s.icon" /></el-icon>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-bold mb-4">{{ t('dashboard.recent') }}</h3>
<div class="stat-card">
<el-timeline>
<el-timeline-item v-for="(r, i) in recent" :key="i" :timestamp="r.time" placement="top">
<span class="font-medium">{{ r.name }}</span>
<span class="text-gray-500 ml-2">{{ r.action }}</span>
</el-timeline-item>
</el-timeline>
</div>
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: box-shadow 0.2s; }
.stat-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
const stats = [
{ key: 'tenants', label: '租户总数', value: 156, growth: '+5%', icon: 'OfficeBuilding', color: '#722ed1' },
{ key: 'users', label: '用户总数', value: 12800, growth: '+12%', icon: 'UserFilled', color: '#1677ff' },
{ key: 'active', label: '活跃租户', value: 142, growth: '+8%', icon: 'TrendCharts', color: '#52c41a' },
{ key: 'orders', label: '平台订单', value: 56800, growth: '+15%', icon: 'ShoppingCart', color: '#fa8c16' },
]
const recentTenants = [
{ name: '企业A', action: '新增租户', time: '10m' },
{ name: '企业B', action: '升级套餐', time: '30m' },
{ name: '企业C', action: '续费', time: '1h' },
]
</script>
<template>
<div>
<h2 class="text-xl font-bold mb-5">平台运营概览</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-5">
<div v-for="s in stats" :key="s.key" class="stat-card">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">{{ s.label }}</p>
<p class="text-3xl font-bold mb-1">{{ s.value.toLocaleString() }}</p>
<span class="text-xs" :style="{ color: s.color }">{{ s.growth }} 较上周</span>
</div>
<div class="stat-icon" :style="{ background: s.color + '15', color: s.color }">
<el-icon :size="24"><component :is="s.icon" /></el-icon>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-bold mb-4">租户动态</h3>
<div class="stat-card">
<el-timeline>
<el-timeline-item v-for="(r, i) in recentTenants" :key="i" :timestamp="r.time" placement="top">
<span class="font-medium">{{ r.name }}</span>
<span class="text-gray-500 ml-2">{{ r.action }}</span>
</el-timeline-item>
</el-timeline>
</div>
</div>
</template>
<style scoped>
.stat-card { background: var(--el-bg-color-overlay); border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: box-shadow 0.2s; }
.stat-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
</style>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import RuiIcon from '@/components/RuiIcon.vue'
import IconPicker from '@/components/IconPicker.vue'
const pickerVisible = ref(false)
const selected = ref('')
function onSelect(icon: string) {
selected.value = icon
}
</script>
<template>
<div class="p-4">
<h2 class="text-xl font-bold mb-4">图标组件演示</h2>
<!-- 选择器 -->
<div class="stat-card mb-4">
<p class="text-sm text-gray-500 mb-2">图标选择器</p>
<div class="flex items-center gap-3">
<el-button @click="pickerVisible = true">打开图标选择器</el-button>
<span class="text-sm text-gray-500">已选</span>
<RuiIcon v-if="selected" :icon="selected" size="24" color="#1677ff" />
<span class="text-sm">{{ selected || '未选择' }}</span>
</div>
</div>
<!-- 四种类型演示 -->
<div class="grid grid-cols-2 gap-4">
<div class="stat-card">
<p class="text-sm font-bold mb-3">Element Plus 图标</p>
<div class="flex gap-4 flex-wrap">
<RuiIcon v-for="i in ['HomeFilled', 'Setting', 'UserFilled', 'Document', 'Edit']" :key="i" :icon="i" size="24" :color="'#1677ff'" />
</div>
</div>
<div class="stat-card">
<p class="text-sm font-bold mb-3">SVG 图标</p>
<div class="flex gap-4 flex-wrap">
<RuiIcon icon='<svg viewBox="0 0 24 24" fill="#52c41a"><circle cx="12" cy="12" r="10"/></svg>' size="24" />
<RuiIcon icon='<svg viewBox="0 0 24 24" fill="#fa8c16"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>' size="24" />
<RuiIcon icon='<svg viewBox="0 0 24 24" fill="#722ed1"><polygon points="12,2 22,22 2,22"/></svg>' size="24" />
</div>
</div>
<div class="stat-card">
<p class="text-sm font-bold mb-3">图片图标</p>
<div class="flex gap-4 flex-wrap">
<RuiIcon icon="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" size="32" />
<RuiIcon icon="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" size="32" />
</div>
</div>
<div class="stat-card">
<p class="text-sm font-bold mb-3">字体图标</p>
<div class="text-gray-400 text-sm">引入字体库后通过 class 名使用 el-icon-xxx</div>
</div>
</div>
<IconPicker v-model="pickerVisible" @select="onSelect" />
</div>
</template>
+226
View File
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { userService } from '@/service/user'
import type { TableColumn, PageResult, PageParams } from '@/components/RuiTable.vue'
import UserFormDialog from './UserFormDialog.vue'
/**
* 查询参数
*/
const query = ref({
username: '',
status: '',
})
/**
* 表格列配置(与后端 User 实体对齐)
*/
const columns: TableColumn[] = [
{ prop: 'username', label: '用户名', minWidth: 120, sortable: 'custom' },
{
prop: 'userType',
label: '用户类型',
width: 120,
align: 'center',
slot: true,
sortable: 'custom',
},
{
prop: 'status',
label: '状态',
width: 100,
align: 'center',
slot: true,
sortable: 'custom',
},
{
prop: 'createdAt',
label: '创建时间',
minWidth: 180,
sortable: 'custom',
dataType: 'dateTime',
},
]
/**
* 加载数据(通过 Service 层调用后端分页接口)
*/
async function loadData(params: PageParams & Record<string, any>): Promise<PageResult> {
return userService.page(params)
}
/**
* 表格组件引用
*/
const tableRef = ref<InstanceType<typeof import('@/components/RuiTable.vue').default>>()
/**
* 当前选中的行
*/
const selectedRows = ref<any[]>([])
/**
* 弹窗显示状态
*/
const dialogVisible = ref(false)
/**
* 当前编辑的行数据
*/
const currentRow = ref<any>(null)
/**
* 新增
*/
function handleAdd() {
currentRow.value = null
dialogVisible.value = true
}
/**
* 编辑
*/
function handleEdit(row: any) {
currentRow.value = row
dialogVisible.value = true
}
/**
* 删除
*/
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除用户 "${row.username}" 吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
await userService.remove(row.id)
ElMessage.success('删除成功')
tableRef.value?.refresh()
} catch {
// 错误已由请求拦截器统一提示
}
}).catch(() => {})
}
/**
* 批量删除
*/
function handleBatchDelete() {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '提示', {
type: 'warning',
}).then(async () => {
try {
const ids = selectedRows.value.map(row => row.id)
await userService.batchRemove(ids)
ElMessage.success('批量删除成功')
tableRef.value?.refresh()
} catch {
// 错误已由请求拦截器统一提示
}
}).catch(() => {})
}
/**
* 表单操作成功回调
*/
function handleFormSuccess() {
tableRef.value?.refresh()
}
</script>
<template>
<div>
<h2 class="text-xl font-bold mb-4">
用户列表通用表格组件 + 后端接口对接示例
</h2>
<RuiTable
ref="tableRef"
:columns="columns"
:load-data="loadData"
:show-selection="true"
:exportable="true"
export-filename="用户列表"
@selection-change="(rows: any[]) => selectedRows = rows"
>
<!-- 查询区域 -->
<template #search="{ query: q, search, reset }">
<el-form-item label="用户名">
<el-input v-model.trim="q.username" placeholder="请输入用户名" clearable @keyup.enter="search" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width: 120px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">
查询
</el-button>
<el-button @click="reset">
重置
</el-button>
</el-form-item>
</template>
<!-- 工具栏左侧 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">
新增
</el-button>
<el-button type="danger" @click="handleBatchDelete">
批量删除
</el-button>
</template>
<!-- 自定义列:用户类型 -->
<template #column-userType="{ row }">
<el-tag v-if="row.userType === 1" type="info">
普通用户
</el-tag>
<el-tag v-else-if="row.userType === 2" type="warning">
管理员
</el-tag>
<el-tag v-else-if="row.userType === 3" type="danger">
系统用户
</el-tag>
<el-tag v-else>
未知
</el-tag>
</template>
<!-- 自定义列:状态 -->
<template #column-status="{ row }">
<el-tag v-if="row.status === 1" type="success">
启用
</el-tag>
<el-tag v-else type="danger">
禁用
</el-tag>
</template>
<!-- 操作列 -->
<template #action="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</RuiTable>
<!-- 新增/编辑弹窗 -->
<UserFormDialog
v-model:visible="dialogVisible"
:row="currentRow"
@success="handleFormSuccess"
/>
</div>
</template>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { userService } from '@/service/user'
/**
* 用户类型选项
*/
const userTypeOptions = [
{ label: '普通用户', value: 1 },
{ label: '管理员', value: 2 },
{ label: '系统用户', value: 3 },
]
/**
* 状态选项
*/
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]
const props = defineProps<{
visible: boolean
/** 编辑时传入的用户数据,新增时为 null */
row?: any
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'success'): void
}>()
// 表单数据
const form = reactive({
id: undefined as number | undefined,
username: '',
password: '',
userType: 1,
status: 1,
})
// 表单引用
const formRef = ref()
// 加载状态
const loading = ref(false)
// 是否编辑模式
const isEdit = ref(false)
// 监听 visible 变化
watch(() => props.visible, (val) => {
if (val) {
if (props.row) {
// 编辑模式
isEdit.value = true
form.id = props.row.id
form.username = props.row.username
form.password = '' // 编辑时不显示密码
form.userType = props.row.userType ?? 1
form.status = props.row.status ?? 1
} else {
// 新增模式
isEdit.value = false
form.id = undefined
form.username = ''
form.password = ''
form.userType = 1
form.status = 1
}
}
})
/**
* 表单校验规则
*/
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
],
password: [
{ required: !isEdit.value, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 50, message: '长度在 6 到 50 个字符', trigger: 'blur' },
],
userType: [
{ required: true, message: '请选择用户类型', trigger: 'change' },
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' },
],
}
/**
* 提交表单
*/
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
// 编辑
await userService.update({
id: form.id!,
username: form.username,
userType: form.userType,
status: form.status,
})
ElMessage.success('修改成功')
} else {
// 新增
await userService.add({
username: form.username,
password: form.password,
userType: form.userType,
status: form.status,
})
ElMessage.success('新增成功')
}
emit('success')
emit('update:visible', false)
} catch {
// 错误已由请求拦截器统一提示
} finally {
loading.value = false
}
}
/**
* 关闭弹窗
*/
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<el-dialog
class="rui-dialog"
:title="isEdit ? '编辑用户' : '新增用户'"
:model-value="visible"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model.trim="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input v-model.trim="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item label="用户类型" prop="userType">
<el-select v-model="form.userType" placeholder="请选择用户类型" style="width: 100%">
<el-option
v-for="item in userTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button
v-for="item in statusOptions"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import systemConfig from 'virtual:system-config'
// 预加载所有登录组件变体
const loginVariants: Record<string, any> = {
Default: defineAsyncComponent(() => import('./variants/Default.vue')),
Super: defineAsyncComponent(() => import('./variants/Super.vue')),
Cashier: defineAsyncComponent(() => import('./variants/Cashier.vue')),
}
const loginComponent = loginVariants[systemConfig.login.component] || loginVariants.Default
</script>
<template>
<component :is="loginComponent" />
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = ref({
tenantId: '',
username: '',
password: '',
})
async function handleLogin() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const success = await authStore.login({
username: form.value.username,
password: form.value.password,
tenantId: form.value.tenantId || undefined,
})
if (success) {
ElMessage.success('登录成功')
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
} else {
ElMessage.error('登录失败,请检查用户名和密码')
}
} catch {
ElMessage.error('登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<h2 class="text-2xl font-bold mb-2 text-blue-600">睿核收银</h2>
<p class="text-gray-500 mb-6">门店管理系统</p>
<el-form :model="form" label-position="top">
<el-form-item label="门店ID">
<el-input v-model="form.tenantId" placeholder="请输入门店ID" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
</el-form-item>
<el-button type="primary" class="w-full" :loading="loading" @click="handleLogin">登录</el-button>
</el-form>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = ref({
tenantId: '',
username: '',
password: '',
})
async function handleLogin() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const success = await authStore.login({
username: form.value.username,
password: form.value.password,
tenantId: form.value.tenantId || undefined,
})
if (success) {
ElMessage.success('登录成功')
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
} else {
ElMessage.error('登录失败,请检查用户名和密码')
}
} catch {
ElMessage.error('登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<h2 class="text-2xl font-bold mb-2">睿核通用平台</h2>
<p class="text-gray-500 mb-6">管理后台登录</p>
<el-form :model="form" label-position="top">
<el-form-item label="租户ID">
<el-input v-model="form.tenantId" placeholder="请输入租户ID" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
</el-form-item>
<el-button type="primary" class="w-full" :loading="loading" @click="handleLogin">登录</el-button>
</el-form>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = ref({
username: '',
password: '',
})
async function handleLogin() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const success = await authStore.login({
username: form.value.username,
password: form.value.password,
})
if (success) {
ElMessage.success('登录成功')
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
} else {
ElMessage.error('登录失败,请检查用户名和密码')
}
} catch {
ElMessage.error('登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<h2 class="text-2xl font-bold mb-2 text-purple-700">睿核平台管理</h2>
<p class="text-gray-500 mb-6">超级管理员登录</p>
<el-form :model="form" label-position="top">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入管理员用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
</el-form-item>
<el-button type="primary" class="w-full" color="#722ed1" :loading="loading" @click="handleLogin">登录</el-button>
</el-form>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #722ed1 0%, #531dab 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
}
</style>

Some files were not shown because too many files have changed in this diff Show More