chore: 首次提交 - 关联 GitHub fnvtk/MBTI_wang

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 12:39:38 +08:00
commit eb510304c0
259 changed files with 60464 additions and 0 deletions

7
admin/.env.development Normal file
View File

@@ -0,0 +1,7 @@
# 开发环境配置
# 测试环境API地址
VITE_API_BASE_URL=http://mbti.com
# 环境标识
VITE_APP_ENV=development
VITE_APP_TITLE=MBTI管理后台-测试环境

7
admin/.env.production Normal file
View File

@@ -0,0 +1,7 @@
# 生产环境配置
# 正式环境API地址
VITE_API_BASE_URL=https://mbtiapi.quwanzhi.com
# 环境标识
VITE_APP_ENV=production
VITE_APP_TITLE=MBTI管理后台

32
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
# 环境文件(.env.local 不提交,但 .env.development 和 .env.production 需要提交)
.env.local
.env.local.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Auto-generated
src/auto-imports.d.ts
src/components.d.ts

14
admin/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MBTI 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3211
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
admin/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "mbti-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"echarts": "^5.6.0",
"element-plus": "^2.6.2",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-echarts": "^6.6.9",
"vue-router": "^4.3.0",
"vue3-draggable-resizable": "^1.6.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"sass": "^1.71.1",
"typescript": "~5.4.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.6",
"vue-tsc": "^2.0.6"
}
}

12
admin/src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App 根组件
</script>
<style scoped>
/* 全局样式在 assets/main.scss 中 */
</style>

107
admin/src/assets/main.scss Normal file
View File

@@ -0,0 +1,107 @@
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Element Plus 自定义样式 */
.el-card {
border-radius: 8px;
}
.el-button {
border-radius: 6px;
}
/* 页面容器 */
.page-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h2 {
font-size: 24px;
font-weight: 600;
color: #303133;
}
p {
margin-top: 8px;
font-size: 14px;
color: #909399;
}
}
/* 统计卡片 */
.stat-card {
padding: 24px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
&.blue {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.green {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.orange {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&.purple {
background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);
}
.stat-value {
font-size: 32px;
font-weight: bold;
margin: 12px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
}
/* 响应式 */
@media (max-width: 768px) {
.page-container {
padding: 16px;
}
}

View File

@@ -0,0 +1,357 @@
<template>
<div class="admin-layout">
<!-- 顶部导航栏 -->
<header class="layout-header">
<div class="header-content">
<div class="header-left">
<el-button
class="menu-toggle"
:icon="sidebarOpen ? Close : Menu"
circle
@click="toggleSidebar"
/>
<div class="logo">
<div class="logo-icon">
<el-icon><Lock /></el-icon>
</div>
<span class="logo-text">管理后台</span>
</div>
</div>
<div class="header-right">
<el-button
text
class="nav-link super-admin"
@click="router.push('/superadmin')"
>
超管端
</el-button>
<el-button
text
class="nav-link"
@click="router.push('/')"
>
<el-icon><HomeFilled /></el-icon>
<span>前台</span>
</el-button>
<el-button
text
class="nav-link logout"
@click="handleLogout"
>
<el-icon><SwitchButton /></el-icon>
<span>退出</span>
</el-button>
</div>
</div>
</header>
<!-- 侧边栏 -->
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
<nav class="sidebar-nav">
<el-button
v-for="item in navItems"
:key="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
text
@click="navigateTo(item.path)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-label">{{ item.label }}</span>
</el-button>
</nav>
</aside>
<!-- 遮罩层移动端 -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="toggleSidebar"
/>
<!-- 主内容区 -->
<main class="layout-main">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Menu,
Close,
Lock,
HomeFilled,
SwitchButton,
DataLine,
User,
ShoppingCart,
Share,
Document,
Setting,
PriceTag,
WalletFilled
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const sidebarOpen = ref(false)
const navItems = [
{ path: '/admin/dashboard', icon: DataLine, label: '数据概览' },
{ path: '/admin/users', icon: User, label: '用户管理' },
{ path: '/admin/orders', icon: ShoppingCart, label: '订单管理' },
{ path: '/admin/distribution', icon: Share, label: '分销管理' },
{ path: '/admin/questions', icon: Document, label: '题库管理' },
{ path: '/admin/pricing', icon: PriceTag, label: '价格设置' },
{ path: '/admin/finance', icon: WalletFilled, label: '企业余额' },
{ path: '/admin/settings', icon: Setting, label: '系统设置' },
]
const isActive = (path: string) => {
return route.path === path
}
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const navigateTo = (path: string) => {
router.push(path)
// 移动端导航后关闭侧边栏
if (window.innerWidth < 1024) {
sidebarOpen.value = false
}
}
const handleLogout = async () => {
try {
await authStore.adminLogout()
router.push('/admin/login')
} catch (error) {
console.error('退出登录失败:', error)
// 即使API调用失败也清除本地状态并跳转
router.push('/admin/login')
}
}
</script>
<style scoped lang="scss">
.admin-layout {
min-height: 100vh;
background-color: #f9fafb;
}
.layout-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background-color: #ffffff;
border-bottom: 1px solid #f3f4f6;
z-index: 1000;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
.menu-toggle {
@media (min-width: 1024px) {
display: none;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background-color: #7c3aed;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.logo-text {
font-weight: 600;
font-size: 17px;
color: #111827;
@media (max-width: 640px) {
display: none;
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
.nav-link {
font-size: 13px;
color: #6b7280;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
height: auto;
&:hover {
background-color: transparent;
color: #111827;
}
&.super-admin {
color: #7c3aed;
font-weight: 500;
padding-right: 14px;
}
&.logout {
color: #ef4444;
}
.el-icon {
font-size: 15px;
}
}
}
}
.layout-sidebar {
position: fixed;
left: 0;
top: 56px;
bottom: 0;
width: 210px;
background-color: #ffffff;
border-right: 1px solid #f3f4f6;
z-index: 999;
overflow-y: auto;
transition: transform 0.3s ease;
@media (max-width: 1023px) {
transform: translateX(-100%);
&.sidebar-open {
transform: translateX(0);
}
}
.sidebar-nav {
padding: 12px 0;
display: flex;
flex-direction: column;
.nav-item {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 14px;
padding: 0 20px;
border-radius: 0;
font-size: 14px;
color: #374151;
transition: all 0.2s;
position: relative;
border: none;
background: transparent;
cursor: pointer;
margin-left: 0!important;
&:hover {
background-color: #f9fafb;
color: #111827;
}
&.active {
background-color: #f5f3ff;
color: #7c3aed;
font-weight: 500;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #7c3aed;
}
.nav-icon {
color: #7c3aed;
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #6b7280;
}
.nav-label {
flex: 1;
text-align: left;
margin-left: 0;
line-height: 1;
display: flex;
align-items: center;
}
}
}
}
.sidebar-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.2);
z-index: 998;
@media (min-width: 1024px) {
display: none;
}
}
.layout-main {
padding-top: 56px;
min-height: 100vh;
background-color: #f9fafb;
@media (min-width: 1024px) {
padding-left: 210px;
}
}
</style>

View File

@@ -0,0 +1,330 @@
<template>
<div class="superadmin-layout">
<!-- 顶部导航栏 -->
<header class="layout-header">
<div class="header-content">
<div class="header-left">
<el-button
class="menu-toggle"
:icon="sidebarOpen ? Close : Menu"
circle
@click="toggleSidebar"
/>
<div class="logo">
<div class="logo-icon">
<el-icon><Lock /></el-icon>
</div>
<span class="logo-text">超级管理后台</span>
</div>
</div>
<div class="header-right">
<el-button
text
class="nav-link"
@click="router.push('/admin/dashboard')"
>
<el-icon><HomeFilled /></el-icon>
<span>管理台</span>
</el-button>
<el-button
text
class="nav-link logout"
@click="handleLogout"
>
<el-icon><SwitchButton /></el-icon>
<span>退出</span>
</el-button>
</div>
</div>
</header>
<!-- 侧边栏 -->
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
<nav class="sidebar-nav">
<el-button
v-for="item in navItems"
:key="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
text
@click="navigateTo(item.path)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-label">{{ item.label }}</span>
</el-button>
</nav>
</aside>
<!-- 遮罩层移动端 -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="toggleSidebar"
/>
<!-- 主内容区 -->
<main class="layout-main">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Menu,
Close,
Lock,
HomeFilled,
SwitchButton,
TrendCharts,
OfficeBuilding,
User,
Document,
Cpu,
Money,
DataLine,
Setting,
Share
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const sidebarOpen = ref(false)
const navItems = [
{ path: '/superadmin/overview', icon: TrendCharts, label: '概览' },
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
{ path: '/superadmin/users', icon: User, label: '用户总览' },
{ path: '/superadmin/questions', icon: Document, label: '题库管理' },
{ path: '/superadmin/ai-config', icon: Cpu, label: 'AI 服务配置' },
{ path: '/superadmin/pricing', icon: Money, label: '全局定价' },
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
{ path: '/superadmin/finance', icon: Money, label: '财务数据' },
{ path: '/superadmin/database', icon: DataLine, label: '数据库管理' },
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' },
]
const isActive = (path: string) => {
return route.path === path
}
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const navigateTo = (path: string) => {
router.push(path)
// 移动端导航后关闭侧边栏
if (window.innerWidth < 1024) {
sidebarOpen.value = false
}
}
const handleLogout = async () => {
try {
await authStore.superAdminLogout()
router.push('/superadmin/login')
} catch (error) {
console.error('退出登录失败:', error)
// 即使API调用失败也清除本地状态并跳转
router.push('/superadmin/login')
}
}
</script>
<style scoped lang="scss">
.superadmin-layout {
min-height: 100vh;
background-color: #f9fafb;
}
.layout-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
background-color: #ffffff;
border-bottom: 1px solid #f3f4f6;
z-index: 1000;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
.menu-toggle {
@media (min-width: 1024px) {
display: none;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #ef4444;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.logo-text {
font-weight: 700;
font-size: 16px;
color: #111827;
@media (max-width: 640px) {
display: none;
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
.nav-link {
font-size: 13px;
color: #6b7280;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
height: auto;
&:hover {
background-color: transparent;
color: #111827;
}
&.logout {
color: #ef4444;
}
.el-icon {
font-size: 16px;
}
}
}
}
.layout-sidebar {
position: fixed;
left: 0;
top: 64px;
bottom: 0;
width: 240px;
background-color: #ffffff;
border-right: 1px solid #f3f4f6;
z-index: 999;
overflow-y: auto;
transition: transform 0.3s ease;
@media (max-width: 1023px) {
transform: translateX(-100%);
&.sidebar-open {
transform: translateX(0);
}
}
.sidebar-nav {
padding: 16px 0;
display: flex;
flex-direction: column;
.nav-item {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 24px;
border-radius: 0;
font-size: 14px;
color: #4b5563;
transition: all 0.2s;
position: relative;
margin-left: 0!important;
&:hover {
background-color: #f9fafb;
color: #111827;
}
&.active {
background-color: #fef2f2;
color: #ef4444;
font-weight: 600;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background-color: #ef4444;
}
}
.nav-icon {
font-size: 18px;
}
.nav-label {
flex: 1;
text-align: left;
}
}
}
}
.sidebar-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.2);
z-index: 998;
@media (min-width: 1024px) {
display: none;
}
}
.layout-main {
padding-top: 64px;
min-height: 100vh;
background-color: #f9fafb;
@media (min-width: 1024px) {
padding-left: 240px;
}
}
</style>

27
admin/src/main.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import './assets/main.scss'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

203
admin/src/router/index.ts Normal file
View File

@@ -0,0 +1,203 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
// 普通管理后台路由
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('@/views/admin/Login.vue'),
meta: { title: '管理员登录' }
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/admin/dashboard',
children: [
{
path: 'dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue'),
meta: { title: '数据概览' }
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/Users.vue'),
meta: { title: '用户管理' }
},
{
path: 'orders',
name: 'AdminOrders',
component: () => import('@/views/admin/Orders.vue'),
meta: { title: '订单管理' }
},
{
path: 'distribution',
name: 'AdminDistribution',
component: () => import('@/views/admin/Distribution.vue'),
meta: { title: '分销管理' }
},
{
path: 'questions',
name: 'AdminQuestions',
component: () => import('@/views/admin/Questions.vue'),
meta: { title: '题库管理' }
},
{
path: 'pricing',
name: 'AdminPricing',
component: () => import('@/views/admin/Pricing.vue'),
meta: { title: '价格设置' }
},
{
path: 'finance',
name: 'AdminFinance',
component: () => import('@/views/admin/Finance.vue'),
meta: { title: '企业余额' }
},
{
path: 'settings',
name: 'AdminSettings',
component: () => import('@/views/admin/Settings.vue'),
meta: { title: '系统设置' }
},
]
},
// 超级管理后台路由
{
path: '/superadmin/login',
name: 'SuperAdminLogin',
component: () => import('@/views/superadmin/Login.vue'),
meta: { title: '超级管理员登录' }
},
{
path: '/superadmin',
component: () => import('@/layouts/SuperAdminLayout.vue'),
redirect: '/superadmin/overview',
children: [
{
path: 'overview',
name: 'SuperAdminOverview',
component: () => import('@/views/superadmin/Overview.vue'),
meta: { title: '概览' }
},
{
path: 'enterprises',
name: 'SuperAdminEnterprises',
component: () => import('@/views/superadmin/Enterprises.vue'),
meta: { title: '企业管理' }
},
{
path: 'users',
name: 'SuperAdminUsers',
component: () => import('@/views/superadmin/Users.vue'),
meta: { title: '用户总览' }
},
{
path: 'questions',
name: 'SuperAdminQuestions',
component: () => import('@/views/superadmin/Questions.vue'),
meta: { title: '题库管理' }
},
{
path: 'ai-config',
name: 'SuperAdminAIConfig',
component: () => import('@/views/superadmin/AIConfig.vue'),
meta: { title: 'AI 服务配置' }
},
{
path: 'pricing',
name: 'SuperAdminPricing',
component: () => import('@/views/superadmin/Pricing.vue'),
meta: { title: '全局定价' }
},
{
path: 'distribution',
name: 'SuperAdminDistribution',
component: () => import('@/views/superadmin/Distribution.vue'),
meta: { title: '分销管理' }
},
{
path: 'finance',
name: 'SuperAdminFinance',
component: () => import('@/views/superadmin/Finance.vue'),
meta: { title: '财务数据' }
},
{
path: 'database',
name: 'SuperAdminDatabase',
component: () => import('@/views/superadmin/Database.vue'),
meta: { title: '数据库管理' }
},
{
path: 'settings',
name: 'SuperAdminSettings',
component: () => import('@/views/superadmin/Settings.vue'),
meta: { title: '系统设置' }
},
]
},
// 默认重定向
{
path: '/',
redirect: '/admin/login'
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - MBTI 管理后台` : 'MBTI 管理后台'
// 检查登录状态使用Token
const token = localStorage.getItem('authToken')
const userRole = localStorage.getItem('userRole')
// 管理后台路由守卫
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
if (!token || !userRole || !['admin', 'enterprise_admin', 'superadmin'].includes(userRole)) {
// 清除登录状态
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('adminLoggedIn')
next('/admin/login')
return
}
}
// 超级管理后台路由守卫
if (to.path.startsWith('/superadmin') && to.path !== '/superadmin/login') {
if (!token || userRole !== 'superadmin') {
// 清除登录状态
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('superAdminLoggedIn')
next('/superadmin/login')
return
}
}
// 如果已登录,访问登录页则跳转到对应首页
if (to.path === '/admin/login' && token && userRole && ['admin', 'enterprise_admin', 'superadmin'].includes(userRole)) {
next('/admin/dashboard')
return
}
if (to.path === '/superadmin/login' && token && userRole === 'superadmin') {
next('/superadmin/overview')
return
}
next()
})
export default router

148
admin/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,148 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/utils/request'
interface LoginResponse {
code: number
message: string
data: {
token: string
expires_in: number
user: {
id: number
username: string
role: string
enterprise_id?: number | null
}
}
}
export const useAuthStore = defineStore('auth', () => {
// 管理员登录状态
const adminLoggedIn = ref(false)
const superAdminLoggedIn = ref(false)
const currentUser = ref<any>(null)
// 初始化登录状态(自动执行)
function initAuth() {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
const userRole = localStorage.getItem('userRole')
if (token && userRole) {
if (userRole === 'superadmin') {
superAdminLoggedIn.value = true
} else if (['admin', 'enterprise_admin'].includes(userRole)) {
adminLoggedIn.value = true
}
}
}
}
// 自动初始化
initAuth()
// 管理员登录
async function adminLogin(username: string, password: string): Promise<boolean> {
try {
const response = await request.post<LoginResponse>('/auth/admin/login', {
username,
password
})
if (response.code === 200 && response.data) {
// 存储Token和用户信息
localStorage.setItem('authToken', response.data.token)
localStorage.setItem('userRole', response.data.user.role)
localStorage.setItem('userId', String(response.data.user.id))
localStorage.setItem('adminLoggedIn', 'true')
currentUser.value = response.data.user
adminLoggedIn.value = true
return true
}
return false
} catch (error: any) {
console.error('登录失败:', error)
throw error
}
}
// 超级管理员登录
async function superAdminLogin(username: string, password: string): Promise<boolean> {
try {
const response = await request.post<LoginResponse>('/auth/superadmin/login', {
username,
password
})
if (response.code === 200 && response.data) {
// 存储Token和用户信息
localStorage.setItem('authToken', response.data.token)
localStorage.setItem('userRole', response.data.user.role)
localStorage.setItem('userId', String(response.data.user.id))
localStorage.setItem('superAdminLoggedIn', 'true')
currentUser.value = response.data.user
superAdminLoggedIn.value = true
return true
}
return false
} catch (error: any) {
console.error('登录失败:', error)
throw error
}
}
// 管理员登出
async function adminLogout() {
try {
await request.post('/auth/logout')
} catch (error) {
console.error('退出登录失败:', error)
} finally {
// 清除本地存储
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('userId')
localStorage.removeItem('adminLoggedIn')
localStorage.removeItem('superAdminLoggedIn')
adminLoggedIn.value = false
currentUser.value = null
}
}
// 超级管理员登出
async function superAdminLogout() {
try {
await request.post('/auth/logout')
} catch (error) {
console.error('退出登录失败:', error)
} finally {
// 清除本地存储
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('userId')
localStorage.removeItem('adminLoggedIn')
localStorage.removeItem('superAdminLoggedIn')
superAdminLoggedIn.value = false
currentUser.value = null
}
}
return {
adminLoggedIn,
superAdminLoggedIn,
currentUser,
initAuth,
adminLogin,
superAdminLogin,
adminLogout,
superAdminLogout
}
})

67
admin/src/utils/format.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* 格式化金额,自动转换为万或亿单位
* @param amount 金额(元)
* @param decimals 保留小数位数默认1位仅用于万和亿单位
* @returns 格式化后的金额字符串,如 "¥1000"、"¥1.2万" 或 "¥3.5亿"
*/
export function formatCurrency(amount: number, decimals: number = 1): string {
if (amount === null || amount === undefined || isNaN(amount)) {
return '¥0'
}
const absAmount = Math.abs(amount)
// 大于等于1亿使用亿单位
if (absAmount >= 100000000) {
return `¥${(amount / 100000000).toFixed(decimals)}亿`
}
// 大于等于1万使用万单位
if (absAmount >= 10000) {
return `¥${(amount / 10000).toFixed(decimals)}`
}
// 小于1万直接显示整数不带小数
return `¥${Math.round(amount).toLocaleString()}`
}
/**
* 格式化金额(不带符号),自动转换为万或亿单位
* @param amount 金额(元)
* @param decimals 保留小数位数默认1位仅用于万和亿单位
* @returns 格式化后的金额字符串,如 "1000"、"1.2万" 或 "3.5亿"
*/
export function formatCurrencyWithoutSymbol(amount: number, decimals: number = 1): string {
if (amount === null || amount === undefined || isNaN(amount)) {
return '0'
}
const absAmount = Math.abs(amount)
// 大于等于1亿使用亿单位
if (absAmount >= 100000000) {
return `${(amount / 100000000).toFixed(decimals)}亿`
}
// 大于等于1万使用万单位
if (absAmount >= 10000) {
return `${(amount / 10000).toFixed(decimals)}`
}
// 小于1万直接显示整数不带小数
return `¥${Math.round(amount).toLocaleString()}`
}
/**
* 格式化金额(元),始终保留两位小数,用于订单/财务等精确展示
* 避免 0.23 元被 formatCurrency 四舍五入成 ¥0
* @param amount 金额(元)
*/
export function formatMoneyYuan(amount: number): string {
if (amount === null || amount === undefined || isNaN(amount)) {
return '¥0.00'
}
const n = Number(amount)
return '¥' + (Number.isFinite(n) ? n.toFixed(2) : '0.00')
}

118
admin/src/utils/request.ts Normal file
View File

@@ -0,0 +1,118 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// 获取API基础URL
const getBaseURL = (): string => {
const envURL = import.meta.env.VITE_API_BASE_URL
if (envURL) {
// 如果环境变量是完整URL拼接 /api/v1
return envURL.endsWith('/') ? `${envURL}api/v1` : `${envURL}/api/v1`
}
// 默认使用相对路径
return '/api/v1'
}
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 可以在这里添加 token
const token = localStorage.getItem('authToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 如果返回的状态码不是 200则认为是错误
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
if (error.response) {
const { status, data } = error.response
if (status === 401) {
ElMessage.error('未授权,请重新登录')
// 清除所有登录状态和Token
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('userId')
localStorage.removeItem('adminLoggedIn')
localStorage.removeItem('superAdminLoggedIn')
// 根据当前路径跳转到对应登录页
if (window.location.pathname.startsWith('/superadmin')) {
window.location.href = '/superadmin/login'
} else {
window.location.href = '/admin/login'
}
} else if (status === 403) {
ElMessage.error('拒绝访问')
} else if (status === 404) {
ElMessage.error('请求地址不存在')
} else if (status === 500) {
ElMessage.error('服务器错误')
} else {
ElMessage.error(data?.message || '请求失败')
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 导出请求方法
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
},
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config)
}
}
export default service

View File

@@ -0,0 +1,392 @@
<template>
<div class="dashboard-container" v-loading="loading">
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总用户数</div>
<div class="stat-value">{{ stats.totalUsers }}</div>
</div>
<div class="stat-icon blue">
<el-icon><User /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">已完成测试</div>
<div class="stat-value">{{ stats.testsCompleted }}</div>
</div>
<div class="stat-icon green">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">今日活跃</div>
<div class="stat-value">{{ stats.activeToday }}</div>
</div>
<div class="stat-icon purple">
<el-icon><TrendCharts /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">待审核</div>
<div class="stat-value">{{ stats.pendingReviews }}</div>
</div>
<div class="stat-icon orange">
<el-icon><User /></el-icon>
</div>
</div>
</div>
<!-- 测试趋势折线图 -->
<div class="activity-section">
<div class="section-header">
<h2 class="section-title">测试趋势</h2>
<p class="section-subtitle">最近 14 天人脸分析MBTIPDPDISC 等关键测试的完成情况</p>
</div>
<div class="trend-chart-wrapper" v-if="testTrends.length">
<VChart class="trend-chart-echarts" :option="chartOption" autoresize />
</div>
<div class="empty-state" v-else>
<span>暂无测试趋势数据</span>
</div>
</div>
<!-- 邀请二维码 -->
<div class="activity-section invite-section">
<div class="section-header">
<h2 class="section-title">专属邀请小程序码</h2>
<p class="section-subtitle">生成专属邀请二维码员工/客户扫码即可进入小程序完成测试</p>
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
重新生成
</el-button>
</div>
<div class="invite-content">
<div v-if="inviteQrcode">
<img :src="inviteQrcode" alt="邀请小程序码" class="invite-qrcode" />
<div class="invite-tip">右键/长按保存二维码用于宣传物料或群内邀请</div>
</div>
<div v-else class="empty-state">
<el-button type="primary" size="small" @click="loadInviteQrcode" :loading="inviteLoading">
生成邀请二维码
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
User,
Document,
TrendCharts
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
const stats = reactive({
totalUsers: 0,
testsCompleted: 0,
activeToday: 0,
pendingReviews: 0
})
const testTrends = ref<
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
>([])
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
const chartOption = computed(() => {
const dates = testTrends.value.map(d => d.date.slice(5))
return {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['人脸分析', 'MBTI', 'PDP', 'DISC'],
bottom: 0
},
grid: {
left: 40,
right: 20,
top: 30,
bottom: 40
},
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280' }
},
yAxis: {
type: 'value',
minInterval: 1,
axisLine: { lineStyle: { color: '#e5e7eb' } },
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#6b7280' }
},
series: [
{
name: '人脸分析',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#22c55e' },
data: testTrends.value.map(d => d.face)
},
{
name: 'MBTI',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#3b82f6' },
data: testTrends.value.map(d => d.mbti)
},
{
name: 'PDP',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#f97316' },
data: testTrends.value.map(d => d.pdp)
},
{
name: 'DISC',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#6366f1' },
data: testTrends.value.map(d => d.disc)
}
]
}
})
// 加载数据
const loadData = async () => {
loading.value = true
try {
const response: any = await request.get('/admin/dashboard')
if (response.code === 200 && response.data) {
stats.totalUsers = response.data.totalUsers || 0
stats.testsCompleted = response.data.testsCompleted || 0
stats.activeToday = response.data.activeToday || 0
stats.pendingReviews = response.data.pendingReviews || 0
testTrends.value = response.data.testTrends || []
}
} catch (error: any) {
console.error('加载数据失败:', error)
ElMessage.error(error.message || '加载数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadData()
})
// 加载邀请小程序码
const loadInviteQrcode = async () => {
if (inviteLoading.value) return
inviteLoading.value = true
try {
const res: any = await request.get('/admin/invite/qrcode')
const data = res.data ?? res
if (data && data.qrcode) {
inviteQrcode.value = data.qrcode
} else if (data.code === 200 && data.data?.qrcode) {
inviteQrcode.value = data.data.qrcode
} else {
ElMessage.error('生成邀请二维码失败')
}
} catch (error: any) {
console.error('生成邀请二维码失败:', error)
ElMessage.error(error?.message || '生成邀请二维码失败')
} finally {
inviteLoading.value = false
}
}
</script>
<style scoped lang="scss">
.dashboard-container {
padding: 24px;
background-color: #f9fafb;
min-height: 100vh;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 28px;
}
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
}
.stat-info {
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 6px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #111827;
line-height: 1;
}
}
.stat-icon {
width: 42px;
height: 42px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
&.blue {
background-color: #eff6ff;
color: #3b82f6;
}
&.green {
background-color: #f0fdf4;
color: #22c55e;
}
&.purple {
background-color: #faf5ff;
color: #a855f7;
}
&.orange {
background-color: #fffbeb;
color: #f59e0b;
}
}
}
.activity-section {
background: #fff;
border-radius: 10px;
padding: 28px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
.section-header {
margin-bottom: 24px;
.section-title {
font-size: 20px;
font-weight: 700;
color: #111827;
margin: 0 0 6px 0;
}
.section-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.trend-chart-wrapper {
margin-top: 12px;
}
.trend-chart-echarts {
width: 100%;
height: 260px;
}
}
.invite-section {
margin-top: 24px;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
.section-title {
margin-bottom: 0;
}
}
.invite-content {
display: flex;
align-items: center;
gap: 24px;
margin-top: 8px;
}
.invite-qrcode {
width: 160px;
height: 160px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.invite-tip {
font-size: 13px;
color: #6b7280;
margin-top: 8px;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.activity-section {
padding: 20px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
<template>
<div class="finance-page">
<div class="page-header">
<div>
<h2>企业余额</h2>
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额测试收入和充值流水</p>
</div>
<div class="header-actions">
<el-button @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
</div>
</div>
<div class="stats-grid" v-loading="loading">
<div class="stat-card">
<div class="stat-label">当前余额</div>
<div class="stat-value">¥{{ fenToYuan(overview.balanceFen) }}</div>
<div class="stat-desc">可用于企业佣金结算</div>
</div>
<div class="stat-card">
<div class="stat-label">今日测试收入</div>
<div class="stat-value">¥{{ fenToYuan(overview.todayIncomeFen) }}</div>
<div class="stat-desc">仅统计 face / MBTI / DISC / PDP</div>
</div>
<div class="stat-card">
<div class="stat-label">本月测试收入</div>
<div class="stat-value">¥{{ fenToYuan(overview.monthIncomeFen) }}</div>
<div class="stat-desc">企业用户支付后自动入账</div>
</div>
<div class="stat-card">
<div class="stat-label">冻结佣金</div>
<div class="stat-value">¥{{ fenToYuan(overview.frozenCommissionFen) }}</div>
<div class="stat-desc">补余额后会自动解冻</div>
</div>
</div>
<div class="content-card">
<div class="content-header">
<h3>财务流水</h3>
<span class="content-tip">测试收入会自动进入企业余额手动充值也会记录在这里</span>
</div>
<el-table :data="records" v-loading="recordsLoading" class="custom-table">
<el-table-column prop="typeLabel" label="类型" width="120" />
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }">
<span :class="row.direction === 'out' ? 'amount-out' : 'amount-in'">
{{ row.direction === 'out' ? '-' : '+' }}¥{{ fenToYuan(row.amountFen) }}
</span>
</template>
</el-table-column>
<el-table-column label="变动前余额" width="140" align="right">
<template #default="{ row }">¥{{ fenToYuan(row.balanceBeforeFen) }}</template>
</el-table-column>
<el-table-column label="变动后余额" width="140" align="right">
<template #default="{ row }">¥{{ fenToYuan(row.balanceAfterFen) }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="220" />
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="empty-state" v-if="!recordsLoading && records.length === 0">暂无财务流水</div>
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
@current-change="loadRecords"
/>
</div>
</div>
<el-dialog v-model="rechargeDialogVisible" title="企业余额充值" width="460px">
<el-form label-position="top">
<el-form-item label="充值金额(元)" required>
<el-input-number v-model="rechargeForm.amountYuan" :min="0.01" :step="100" :precision="2" class="w-full" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="rechargeForm.remark" type="textarea" :rows="2" placeholder="可选,仅用于当前页提示,不会直接入账" />
</el-form-item>
<div v-if="rechargeQrcode" class="recharge-qrcode">
<img :src="rechargeQrcode" alt="充值二维码" class="qrcode-image" />
<p class="qrcode-tip">请使用微信扫码在小程序内完成支付充值</p>
<p class="qrcode-amount">本次充值¥{{ rechargeForm.amountYuan.toFixed(2) }}</p>
</div>
</el-form>
<template #footer>
<el-button @click="rechargeDialogVisible = false">取消</el-button>
<el-button type="primary" color="#7c3aed" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
const loading = ref(false)
const recordsLoading = ref(false)
const rechargeLoading = ref(false)
const rechargeDialogVisible = ref(false)
const rechargeQrcode = ref('')
const overview = reactive({
enterpriseId: 0,
enterpriseName: '',
balanceFen: 0,
todayIncomeFen: 0,
monthIncomeFen: 0,
totalIncomeFen: 0,
manualRechargeFen: 0,
frozenCommissionFen: 0,
paidOrderCount: 0
})
const records = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const rechargeForm = reactive({
amountYuan: 100,
remark: ''
})
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
const formatTime = (timestamp: number) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
const loadOverview = async () => {
const res: any = await request.get('/admin/finance/overview')
Object.assign(overview, res.data || {})
}
const loadRecords = async () => {
recordsLoading.value = true
try {
const res: any = await request.get('/admin/finance/records', {
params: {
page: page.value,
pageSize: pageSize.value
}
})
records.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
recordsLoading.value = false
}
}
const loadAll = async () => {
loading.value = true
try {
await loadOverview()
await loadRecords()
} catch (error: any) {
ElMessage.error(error?.message || '加载企业财务数据失败')
} finally {
loading.value = false
}
}
const openRechargeDialog = () => {
rechargeQrcode.value = ''
rechargeDialogVisible.value = true
}
const generateRechargeQrcode = async () => {
if (!rechargeForm.amountYuan || rechargeForm.amountYuan <= 0) {
ElMessage.warning('请输入正确的充值金额')
return
}
const amountFen = Math.round(Number(rechargeForm.amountYuan) * 100)
if (amountFen <= 0) {
ElMessage.warning('请输入正确的充值金额')
return
}
rechargeLoading.value = true
try {
const res: any = await request.post('/admin/finance/recharge-qrcode', {
amountFen,
remark: rechargeForm.remark
})
rechargeQrcode.value = res.data?.qrcode || ''
ElMessage.success('充值码已生成')
} catch (error: any) {
ElMessage.error(error?.message || '生成充值码失败')
} finally {
rechargeLoading.value = false
}
}
onMounted(() => {
loadAll()
})
</script>
<style scoped lang="scss">
.finance-page {
min-height: 100vh;
padding: 24px;
background: #f9fafb;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
h2 {
margin: 0 0 6px;
font-size: 26px;
color: #111827;
}
.subtitle {
margin: 0;
color: #6b7280;
font-size: 14px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card,
.content-card {
background: #fff;
border-radius: 18px;
border: 1px solid #eef2f7;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.stat-card {
padding: 20px;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 30px;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.stat-desc {
margin-top: 10px;
font-size: 13px;
color: #94a3b8;
}
.content-card {
padding: 20px;
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
h3 {
margin: 0;
color: #111827;
}
}
.content-tip {
color: #6b7280;
font-size: 13px;
}
.amount-in {
color: #16a34a;
font-weight: 600;
}
.amount-out {
color: #dc2626;
font-weight: 600;
}
.empty-state {
padding: 24px 0 8px;
text-align: center;
color: #94a3b8;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
.recharge-qrcode {
margin-top: 12px;
padding: 18px;
border-radius: 16px;
background: #f8fafc;
text-align: center;
}
.qrcode-image {
width: 220px;
height: 220px;
object-fit: contain;
border-radius: 12px;
background: #fff;
padding: 10px;
}
.qrcode-tip {
margin: 12px 0 4px;
color: #6b7280;
font-size: 13px;
}
.qrcode-amount {
margin: 0;
color: #111827;
font-size: 14px;
font-weight: 600;
}
.w-full {
width: 100%;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.finance-page {
padding: 16px;
}
.page-header,
.content-header {
flex-direction: column;
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="card-header">
<h2 class="title">管理员登录</h2>
<p class="description">请输入您的管理员凭据以访问后台</p>
</div>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
show-icon
class="error-alert"
>
{{ errorMessage }}
</el-alert>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@submit.prevent="handleLogin"
class="login-form"
>
<el-form-item prop="username">
<label class="form-label">用户名</label>
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="password">
<label class="form-label">密码</label>
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
class="form-input"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-button"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="security-info">
<el-icon class="security-icon"><Lock /></el-icon>
<span>安全连接 | 仅限授权人员访问</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref<FormInstance>()
const loading = ref(false)
const errorMessage = ref('')
const loginForm = reactive({
username: '',
password: ''
})
const loginRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
errorMessage.value = ''
try {
const success = await authStore.adminLogin(loginForm.username, loginForm.password)
if (success) {
ElMessage.success('登录成功')
router.push('/admin/dashboard')
} else {
errorMessage.value = '用户名或密码错误'
}
} catch (error: any) {
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
} finally {
loading.value = false
}
} catch (error) {
console.log('表单验证失败:', error)
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f3f4f6;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 448px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
padding: 32px;
}
.card-header {
text-align: center;
margin-bottom: 24px;
.title {
font-size: 28px;
font-weight: 700;
color: #111827;
margin: 0 0 8px;
}
.description {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.error-alert {
margin-bottom: 20px;
}
.login-form {
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item__content) {
display: block;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-input {
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 12px 16px;
background-color: #f9fafb;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
background-color: #fff;
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
:deep(.el-input__inner) {
font-size: 15px;
color: #111827;
&::placeholder {
color: #9ca3af;
}
}
}
}
.login-button {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
background-color: #7c3aed;
border-color: #7c3aed;
margin-top: 8px;
&:hover {
background-color: #6d28d9;
border-color: #6d28d9;
}
&:active {
background-color: #5b21b6;
border-color: #5b21b6;
}
}
.security-info {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
font-size: 12px;
color: #9ca3af;
gap: 6px;
.security-icon {
font-size: 14px;
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,611 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>订单管理</h2>
<p class="subtitle">管理所有支付订单含用户与关联测试数据</p>
</div>
<div class="header-actions">
<el-button @click="loadOrders" class="refresh-btn">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总订单数</div>
<div class="stat-value">{{ total }}</div>
</div>
<div class="stat-icon blue">
<el-icon><ShoppingCart /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">已完成/已支付</div>
<div class="stat-value">{{ paidCount }}</div>
</div>
<div class="stat-icon green">
<el-icon><Box /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总收入</div>
<div class="stat-value">{{ totalIncome }}</div>
</div>
<div class="stat-icon purple">
<el-icon><Money /></el-icon>
</div>
</div>
</div>
<div class="content-card">
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="searchQuery"
placeholder="搜索订单号/用户ID/昵称/手机号..."
clearable
class="search-input"
@clear="loadOrders"
@keyup.enter="loadOrders"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="loadOrders">搜索</el-button>
</div>
<div class="toolbar-right">
<div class="filter-group">
<div
v-for="item in statusOptions"
:key="item.value"
:class="['filter-item', { active: statusFilter === item.value }]"
@click="statusFilter = item.value; currentPage = 1; loadOrders()"
>
{{ item.label }}
</div>
</div>
<div class="filter-divider"></div>
<div class="filter-group">
<div
v-for="item in productOptions"
:key="item.value"
:class="['filter-item', { active: productFilter === item.value }]"
@click="productFilter = item.value; currentPage = 1; loadOrders()"
>
{{ item.label }}
</div>
</div>
</div>
</div>
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table">
<el-table-column label="订单号" width="180">
<template #default="{ row }">
<span class="order-id">{{ row.orderNo }}</span>
</template>
</el-table-column>
<el-table-column label="用户" min-width="140">
<template #default="{ row }">
<div class="user-cell">
<div class="user-name">{{ row.userName || '—' }}</div>
<div class="user-phone">{{ formatPhone(row.userPhone) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="产品" min-width="180">
<template #default="{ row }">
<div class="product-cell">
<div class="product-main">{{ row.productTitle || productTypeLabel(row.productType) }}</div>
<div class="product-sub" v-if="row.productTitle">
{{ productTypeLabel(row.productType) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="金额" width="100" align="right">
<template #default="{ row }">
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="支付方式" width="100" align="center">
<template #default="{ row }">
{{ payMethodLabel(row.payMethod) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="关联测试" min-width="200">
<template #default="{ row }">
<div class="test-data-cell">
<template v-if="row.testData && row.testData.length">
<div
v-for="(t, i) in row.testData"
:key="t.id || i"
class="test-item"
>
<span class="test-type">{{ testTypeLabel(t.testType) }}</span>
<span class="test-summary">{{ t.resultSummary || '—' }}</span>
</div>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="支付时间" width="160">
<template #default="{ row }">
{{ row.payTime ? formatDate(row.payTime) : '—' }}
</template>
</el-table-column>
</el-table>
<div class="empty-state" v-if="orders.length === 0 && !loading">
<span>暂无订单数据</span>
</div>
<div class="empty-state" v-if="orders.length === 0 && loading">
<div class="loading-spinner"></div>
<p class="loading-text">加载中...</p>
</div>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
@current-change="loadOrders"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Refresh, ShoppingCart, Box, Money, Search } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
const loading = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
const productFilter = ref('')
const orders = ref<any[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 20
const statusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
{ label: '已退款', value: 'refunded' },
{ label: '失败', value: 'failed' }
]
const productOptions = [
{ label: '全部产品', value: '' },
{ label: 'AI人脸分析', value: 'face' },
{ label: 'MBTI', value: 'mbti' },
{ label: 'DISC', value: 'disc' },
{ label: 'PDP', value: 'pdp' },
{ label: '完整报告', value: 'report' }
]
const paidCount = computed(() =>
orders.value.filter((o: any) => ['paid', 'completed'].includes(o.status || '')).length
)
const totalIncome = computed(() => {
const sum = orders.value
.filter((o: any) => ['paid', 'completed'].includes(o.status || ''))
.reduce((acc: number, o: any) => acc + (Number(o.amount) || 0), 0)
return '¥' + (sum / 100).toFixed(2)
})
function formatPhone(phone: string) {
if (!phone) return '—'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
return phone
}
function formatDate(ts: number | null | undefined) {
if (ts == null) return '—'
const d = new Date(ts * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
function formatAmount(amountFen: number | string | null | undefined) {
if (amountFen == null) return '¥0.00'
const n = typeof amountFen === 'number' ? amountFen : Number(amountFen)
if (!Number.isFinite(n)) return '¥0.00'
return '¥' + (n / 100).toFixed(2)
}
function productTypeLabel(type: string) {
const map: Record<string, string> = {
face: 'AI人脸分析',
mbti: 'MBTI',
disc: 'DISC',
pdp: 'PDP',
report: '完整报告'
}
return map[type || ''] || type || '—'
}
function testTypeLabel(type: string) {
const map: Record<string, string> = {
face: '人脸',
ai: '人脸',
mbti: 'MBTI',
disc: 'DISC',
pdp: 'PDP'
}
return map[(type || '').toLowerCase()] || type || '—'
}
function payMethodLabel(m: string) {
if (!m) return '—'
if (m === 'wechat') return '微信支付'
return m
}
function statusLabel(s: string) {
const map: Record<string, string> = {
pending: '待支付',
paid: '已支付',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
failed: '失败'
}
return map[s || ''] || s || '—'
}
function statusTagType(
s: string
): 'primary' | 'success' | 'warning' | 'info' | 'danger' {
const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
pending: 'warning',
paid: 'success',
completed: 'success',
cancelled: 'info',
refunded: 'info',
failed: 'danger'
}
return map[s || ''] || 'info'
}
async function loadOrders() {
loading.value = true
try {
const res: any = await request.get('/admin/orders', {
params: {
page: currentPage.value,
pageSize,
keyword: searchQuery.value,
status: statusFilter.value,
productType: productFilter.value
}
})
const list = res.data?.list ?? res?.list ?? []
orders.value = list
total.value = res.data?.total ?? res?.total ?? 0
} catch {
orders.value = []
total.value = 0
} finally {
loading.value = false
}
}
onMounted(() => loadOrders())
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
h2 {
font-size: 22px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.refresh-btn {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
color: #374151;
height: 34px;
display: flex;
align-items: center;
gap: 6px;
&:hover {
background-color: #f9fafb;
border-color: #d1d5db;
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.stat-info {
.stat-label {
font-size: 13px;
color: #6b7280;
margin-bottom: 6px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #111827;
line-height: 1;
}
.realtime-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #22c55e;
margin-top: 8px;
font-weight: 500;
}
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
&.blue { background-color: #eff6ff; color: #3b82f6; }
&.green { background-color: #f0fdf4; color: #22c55e; }
&.purple { background-color: #faf5ff; color: #a855f7; }
&.orange { background-color: #fffbeb; color: #f59e0b; }
}
}
.content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.toolbar {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
gap: 20px;
.toolbar-left {
flex: 1;
.search-input {
max-width: 320px;
:deep(.el-input__wrapper) {
border-radius: 6px;
background-color: #f9fafb;
box-shadow: none;
border: 1px solid #e5e7eb;
&.is-focus {
border-color: #7c3aed;
background-color: #fff;
}
}
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 16px;
.filter-divider {
width: 1px;
height: 20px;
background-color: #e5e7eb;
}
.filter-group {
display: flex;
background-color: #f3f4f6;
padding: 3px;
border-radius: 6px;
gap: 2px;
.filter-item {
padding: 4px 12px;
font-size: 12px;
color: #6b7280;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
white-space: nowrap;
&:hover {
color: #111827;
}
&.active {
background-color: #7c3aed;
color: #fff;
font-weight: 500;
}
}
}
}
}
.custom-table {
:deep(.el-table__header) {
th {
background-color: #f9fafb;
color: #6b7280;
font-weight: 500;
font-size: 13px;
padding: 12px 0;
}
}
.order-id {
font-size: 13px;
color: #374151;
}
.user-cell {
.user-name {
font-size: 13px;
color: #111827;
font-weight: 500;
}
.user-phone {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
}
.amount-cell {
font-size: 13px;
color: #111827;
font-weight: 500;
}
.test-data-cell {
font-size: 12px;
.test-item {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
.test-type {
flex-shrink: 0;
color: #7c3aed;
font-weight: 500;
}
.test-summary {
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.no-test {
color: #9ca3af;
}
}
}
.pagination-wrap {
padding: 16px 20px;
display: flex;
justify-content: flex-end;
}
.empty-state {
padding: 80px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 14px;
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #f3f4f6;
border-top: 2px solid #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
.loading-text {
margin: 0;
font-size: 13px;
color: #6b7280;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.toolbar {
flex-direction: column;
align-items: flex-start;
.toolbar-right {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
}
}
}
</style>

View File

@@ -0,0 +1,981 @@
<template>
<div class="poster-editor-root">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-group">
<el-button size="small" @click="addElement('text')">
<el-icon><EditPen /></el-icon> 添加文字
</el-button>
<el-button size="small" @click="addElement('nickname')">
<el-icon><User /></el-icon> 用户昵称
</el-button>
<el-button size="small" @click="addElement('avatar')">
<el-icon><Avatar /></el-icon> 用户头像
</el-button>
<el-button size="small" @click="addElement('qrcode')">
<el-icon><Grid /></el-icon> 小程序码
</el-button>
<el-button size="small" @click="triggerImageAdd">
<el-icon><Picture /></el-icon> 上传图片
</el-button>
<el-divider direction="vertical" />
<el-button size="small" type="warning" plain @click="addElement('mbti')">MBTI</el-button>
<el-button size="small" type="success" plain @click="addElement('pdp')">PDP</el-button>
<el-button size="small" type="danger" plain @click="addElement('disc')">DISC</el-button>
</div>
<div class="toolbar-right">
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
<el-button size="small" @click="loadConfig">重置</el-button>
<el-button size="small" type="primary" color="#7c3aed" :loading="saving" @click="saveConfig">
保存配置
</el-button>
</div>
</div>
<!-- 主体图层 | 画布 | 属性 -->
<div class="editor-body">
<!-- 左侧图层面板 -->
<div class="editor-layers">
<div class="panel-title">图层</div>
<div class="layers-list">
<div
v-for="el in [...elements].reverse()"
:key="el.id"
:class="['layer-item', { active: selectedId === el.id }]"
@click="selectedId = el.id"
>
<el-icon class="layer-icon">
<component :is="typeIconMap[el.type]" />
</el-icon>
<span class="layer-name">{{ typeLabel(el) }}</span>
<div class="layer-actions">
<el-icon class="action-icon" @click.stop="moveLayer(el.id, -1)" title="上移"><ArrowUp /></el-icon>
<el-icon class="action-icon" @click.stop="moveLayer(el.id, 1)" title="下移"><ArrowDown /></el-icon>
<el-icon class="action-icon danger" @click.stop="removeElement(el.id)"><Delete /></el-icon>
</div>
</div>
<div v-if="elements.length === 0" class="layers-empty">暂无元素</div>
</div>
</div>
<!-- 中间画布 -->
<div class="editor-canvas-area" @click.self="selectedId = null">
<div class="canvas-scale-wrap">
<div
class="poster-canvas"
:style="posterCanvasStyle"
@click.self="selectedId = null"
>
<!-- 背景图点击空白处取消选择 -->
<img v-if="config.bgImage" :src="config.bgImage" class="canvas-bg-img" @click="selectedId = null" />
<!-- 无背景图时的可点击底衬 -->
<div
v-else
class="canvas-bg-click"
@click="selectedId = null"
/>
<!-- 各元素configReady 后才渲染避免组件初始化覆盖数据 -->
<Vue3DraggableResizable
v-for="el in elements"
v-if="configReady"
:key="`${el.id}_${configVersion}`"
:initW="el.w"
:initH="el.h"
:x="el.x"
:y="el.y"
:w="el.w"
:h="el.h"
:active="selectedId === el.id"
:parent="true"
:draggable="true"
:resizable="true"
:min-w="20"
:min-h="20"
class-name-active="vdr-active"
@update:x="el.x = Math.round($event)"
@update:y="el.y = Math.round($event)"
@update:w="el.w = Math.round($event)"
@update:h="el.h = Math.round($event)"
@activated="selectedId = el.id"
>
<!-- 文字 -->
<div v-if="el.type === 'text'" class="el-text" :style="textElemStyle(el)">
{{ el.content || '文字内容' }}
</div>
<!-- 昵称占位 -->
<div v-else-if="el.type === 'nickname'" class="el-text el-nickname" :style="textElemStyle(el)">
用户昵称
</div>
<!-- 头像占位 -->
<div v-else-if="el.type === 'avatar'" class="el-placeholder" :style="shapeElemStyle(el)">
<el-icon><Avatar /></el-icon>
<span>头像</span>
</div>
<!-- 小程序码占位 -->
<div v-else-if="el.type === 'qrcode'" class="el-placeholder el-qrcode" :style="shapeElemStyle(el)">
<el-icon><Grid /></el-icon>
<span>小程序码</span>
</div>
<!-- 上传图片 -->
<div v-else-if="el.type === 'image'" class="el-placeholder el-image" :style="shapeElemStyle(el)">
<img v-if="el.url" :src="el.url" :style="{ borderRadius: el.shape === 'circle' ? '50%' : '0', width: '100%', height: '100%', objectFit: 'cover' }" />
<template v-else>
<el-icon><Picture /></el-icon>
<span>图片</span>
</template>
</div>
<!-- MBTI 结果 -->
<div v-else-if="el.type === 'mbti'" class="el-text el-test-result mbti-result" :style="textElemStyle(el)">
{{ el.content || 'INTJ' }}
</div>
<!-- PDP 结果 -->
<div v-else-if="el.type === 'pdp'" class="el-text el-test-result pdp-result" :style="textElemStyle(el)">
{{ el.content || '猫头鹰' }}
</div>
<!-- DISC 结果 -->
<div v-else-if="el.type === 'disc'" class="el-text el-test-result disc-result" :style="textElemStyle(el)">
{{ el.content || 'C型' }}
</div>
</Vue3DraggableResizable>
</div>
</div>
<div class="canvas-hint">画布尺寸375 × 667 px标准小程序屏幕</div>
</div>
<!-- 右侧属性面板 -->
<div class="editor-props">
<!-- 无选中显示画布设置 -->
<template v-if="!selectedElement">
<div class="panel-title">画布设置</div>
<div class="prop-item">
<label class="prop-label">背景颜色</label>
<el-color-picker v-model="config.bgColor" show-alpha />
</div>
<div class="prop-item">
<label class="prop-label">背景图片</label>
<div v-if="config.bgImage" class="img-preview-row">
<img :src="config.bgImage" class="img-preview" />
<el-button size="small" type="danger" plain @click="config.bgImage = ''">清除</el-button>
</div>
<el-button size="small" :loading="uploadingBg" @click="triggerBgUpload">
<el-icon><Upload /></el-icon>
{{ config.bgImage ? '更换背景' : '上传背景' }}
</el-button>
</div>
<div class="props-tip">点击画布上的元素以编辑属性</div>
</template>
<!-- 有选中显示元素属性 -->
<template v-else>
<div class="panel-title">
{{ typeLabel(selectedElement) }}
<el-icon class="delete-icon" @click="removeElement(selectedElement.id)" title="删除"><Delete /></el-icon>
</div>
<!-- 文字内容text 类型 -->
<div v-if="selectedElement.type === 'text'" class="prop-item">
<label class="prop-label">文字内容</label>
<el-input v-model="selectedElement.content" type="textarea" :rows="2" placeholder="请输入文字" />
</div>
<!-- 占位文字mbti / pdp / disc -->
<div v-if="['mbti','pdp','disc'].includes(selectedElement.type)" class="prop-item">
<label class="prop-label">预览占位文字</label>
<el-input v-model="selectedElement.content" placeholder="如INTJ" size="small" />
<span class="form-hint">实际生成时自动读取用户最近测试结果</span>
</div>
<!-- 字体/字号/颜色/加粗/对齐text / nickname / mbti / pdp / disc -->
<template v-if="['text','nickname','mbti','pdp','disc'].includes(selectedElement.type)">
<div class="prop-item">
<label class="prop-label">字体</label>
<el-select
v-model="selectedElement.fontFamily"
placeholder="默认字体"
size="small"
clearable
>
<el-option
v-for="f in fontList"
:key="f.key"
:label="f.name"
:value="f.key"
/>
</el-select>
<span v-if="serverFontsLoaded && fontList.length === 0" class="form-hint" style="color:#ef4444">
服务器未安装字体请上传字体到 public/fonts/
</span>
<span v-else-if="selectedElement?.fontFamily && !fontList.find(f => f.key === selectedElement?.fontFamily)" class="form-hint" style="color:#f59e0b">
当前字体在服务器上不可用将使用默认字体
</span>
</div>
<div class="prop-item">
<label class="prop-label">字号 (px)</label>
<div class="font-size-row">
<el-slider
:model-value="selectedElement.fontSize ?? 16"
:min="10"
:max="120"
:show-tooltip="false"
@input="onFontSizeChange"
style="flex:1"
/>
<el-input-number
:model-value="selectedElement.fontSize ?? 16"
:min="10"
:max="200"
:step="1"
size="small"
controls-position="right"
@change="onFontSizeChange"
style="width:80px;margin-left:8px"
/>
</div>
</div>
<div class="prop-item">
<label class="prop-label">颜色</label>
<el-color-picker v-model="selectedElement.color" show-alpha />
</div>
<div class="prop-item inline">
<label class="prop-label">加粗</label>
<el-switch v-model="selectedElement.bold" />
</div>
<div class="prop-item">
<label class="prop-label">对齐</label>
<el-radio-group v-model="selectedElement.align" size="small">
<el-radio-button value="left">左对齐</el-radio-button>
<el-radio-button value="center">居中</el-radio-button>
<el-radio-button value="right">右对齐</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 形状avatar / image -->
<template v-if="['avatar','image'].includes(selectedElement.type)">
<div class="prop-item">
<label class="prop-label">形状</label>
<el-radio-group v-model="selectedElement.shape" size="small">
<el-radio-button label="circle">圆形</el-radio-button>
<el-radio-button label="square">正方形</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 图片上传image -->
<div v-if="selectedElement.type === 'image'" class="prop-item">
<label class="prop-label">图片</label>
<div v-if="selectedElement.url" class="img-preview-row">
<img :src="selectedElement.url" class="img-preview" />
<el-button size="small" type="danger" plain @click="selectedElement.url = ''">清除</el-button>
</div>
<el-button size="small" :loading="uploadingImg" @click="triggerElementImageUpload">
<el-icon><Upload /></el-icon>
{{ selectedElement.url ? '更换图片' : '上传图片' }}
</el-button>
</div>
<!-- 位置与大小 -->
<div class="props-section-title">位置与大小</div>
<div class="prop-row">
<div class="prop-item half">
<label class="prop-label">X</label>
<el-input-number v-model="selectedElement.x" :min="0" size="small" controls-position="right" />
</div>
<div class="prop-item half">
<label class="prop-label">Y</label>
<el-input-number v-model="selectedElement.y" :min="0" size="small" controls-position="right" />
</div>
</div>
<div class="prop-row">
<div class="prop-item half">
<label class="prop-label"></label>
<el-input-number v-model="selectedElement.w" :min="20" size="small" controls-position="right" />
</div>
<div class="prop-item half">
<label class="prop-label"></label>
<el-input-number v-model="selectedElement.h" :min="20" size="small" controls-position="right" />
</div>
</div>
</template>
</div>
</div>
<!-- 隐藏文件输入 -->
<input ref="bgInputRef" type="file" accept="image/*" style="display:none" @change="onBgFileChange" />
<input ref="imgInputRef" type="file" accept="image/*" style="display:none" @change="onImgFileChange" />
<input ref="addImgInputRef" type="file" accept="image/*" style="display:none" @change="onAddImgFileChange" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import Vue3DraggableResizable from 'vue3-draggable-resizable'
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
import {
EditPen, User, Avatar, Grid, Picture, Delete, Upload,
ArrowUp, ArrowDown, TrophyBase, Odometer, DataAnalysis
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import axios from 'axios'
// ────── 类型 ──────
type ElemType = 'text' | 'nickname' | 'avatar' | 'qrcode' | 'image' | 'mbti' | 'pdp' | 'disc'
type Shape = 'circle' | 'square'
type TextAlign = 'left' | 'center' | 'right'
interface PosterElement {
id: string
type: ElemType
x: number
y: number
w: number
h: number
content?: string
fontSize?: number
color?: string
bold?: boolean
align?: TextAlign
fontFamily?: string
shape?: Shape
url?: string
}
interface FontItem {
key: string
name: string
available?: boolean
}
interface PosterConfig {
bgColor: string
bgImage: string
elements: PosterElement[]
}
// ────── 状态 ──────
const saving = ref(false)
const uploadingBg = ref(false)
const uploadingImg = ref(false)
const selectedId = ref<string | null>(null)
const configReady = ref(false)
const configVersion = ref(0)
const fontList = ref<FontItem[]>([])
const serverFontsLoaded = ref(false)
const bgInputRef = ref<HTMLInputElement | null>(null)
const imgInputRef = ref<HTMLInputElement | null>(null)
const addImgInputRef = ref<HTMLInputElement | null>(null)
const config = reactive<PosterConfig>({
bgColor: '#ffffff',
bgImage: '',
elements: []
})
const elements = computed(() => config.elements)
const selectedElement = computed(() =>
selectedId.value ? config.elements.find(e => e.id === selectedId.value) ?? null : null
)
// ────── 工具映射 ──────
const typeIconMap: Record<ElemType, any> = {
text: EditPen,
nickname: User,
avatar: Avatar,
qrcode: Grid,
image: Picture,
mbti: TrophyBase,
pdp: Odometer,
disc: DataAnalysis
}
const typeLabel = (el: PosterElement) => {
const labels: Record<ElemType, string> = {
text: `文字:${el.content?.slice(0, 6) || '文字内容'}`,
nickname: '用户昵称',
avatar: '用户头像',
qrcode: '小程序码',
image: '图片',
mbti: `MBTI${el.content || 'INTJ'}`,
pdp: `PDP${el.content || '猫头鹰'}`,
disc: `DISC${el.content || 'C型'}`
}
return labels[el.type]
}
// ────── 画布样式 ──────
const posterCanvasStyle = computed(() => ({
width: '375px',
height: '667px',
backgroundColor: config.bgColor,
position: 'relative' as const,
overflow: 'hidden',
flexShrink: 0
}))
// ────── 字体 CSS 映射(用于画布预览近似渲染) ──────
const fontCssMap: Record<string, string> = {
'noto-sans': '"Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
'noto-serif': '"Noto Serif SC", "Source Han Serif SC", "STSong", "SimSun", serif',
'alimama': '"AlimamaFangYuanTiVF", "PingFang SC", "Microsoft YaHei", sans-serif',
'wqy-microhei': '"WenQuanYi Micro Hei", "Noto Sans SC", "Microsoft YaHei", sans-serif',
'simhei': '"SimHei", "Heiti SC", sans-serif',
'msyh': '"Microsoft YaHei", "PingFang SC", sans-serif',
}
const getFontCss = (fontKey?: string) => {
if (!fontKey) return undefined
return fontCssMap[fontKey] ?? undefined
}
// ────── 元素样式 ──────
const alignToJustify: Record<TextAlign, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end'
}
const textElemStyle = (el: PosterElement) => {
const align = el.align ?? 'left'
return {
fontSize: `${el.fontSize ?? 16}px`,
color: el.color ?? '#333333',
fontWeight: el.bold ? 'bold' : 'normal',
textAlign: align,
justifyContent: alignToJustify[align],
fontFamily: getFontCss(el.fontFamily),
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
boxSizing: 'border-box' as const,
wordBreak: 'break-all' as const,
lineHeight: 1.4,
userSelect: 'none' as const
}
}
const shapeElemStyle = (el: PosterElement) => ({
width: '100%',
height: '100%',
borderRadius: el.shape === 'circle' ? '50%' : '4px',
overflow: 'hidden'
})
// ────── 添加元素 ──────
let idCounter = 1
const genId = () => `el_${Date.now()}_${idCounter++}`
const addElement = (type: ElemType) => {
const defaults: Record<ElemType, Partial<PosterElement>> = {
text: { w: 160, h: 40, content: '文字内容', fontSize: 18, color: '#333333', bold: false, align: 'left' },
nickname: { w: 150, h: 36, fontSize: 16, color: '#333333', bold: false, align: 'left' },
avatar: { w: 80, h: 80, shape: 'circle' },
qrcode: { w: 120, h: 120 },
image: { w: 100, h: 100, shape: 'square', url: '' },
mbti: { w: 80, h: 32, content: 'INTJ', fontSize: 16, color: '#6366f1', bold: true, align: 'center' },
pdp: { w: 80, h: 32, content: '猫头鹰', fontSize: 16, color: '#10b981', bold: true, align: 'center' },
disc: { w: 80, h: 32, content: 'C型', fontSize: 16, color: '#f43f5e', bold: true, align: 'center' }
}
const el: PosterElement = {
id: genId(),
type,
x: 100,
y: 100,
w: 100,
h: 100,
...defaults[type]
}
config.elements.push(el)
selectedId.value = el.id
}
// ────── 属性修改 ──────
const onFontSizeChange = (val: number | number[] | undefined) => {
const el = selectedElement.value
const v = Array.isArray(val) ? val[0] : val
if (el && typeof v === 'number' && v >= 10) {
el.fontSize = v
}
}
// ────── 删除/移动图层 ──────
const removeElement = (id: string) => {
const idx = config.elements.findIndex(e => e.id === id)
if (idx !== -1) config.elements.splice(idx, 1)
if (selectedId.value === id) selectedId.value = null
}
const moveLayer = (id: string, dir: -1 | 1) => {
const arr = config.elements
const idx = arr.findIndex(e => e.id === id)
const newIdx = idx + dir
if (newIdx < 0 || newIdx >= arr.length) return
const tmp = arr[idx]
arr[idx] = arr[newIdx]
arr[newIdx] = tmp
}
// ────── 上传工具(使用 admin 上传接口) ──────
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('file', file)
const token = localStorage.getItem('authToken')
const baseURL = import.meta.env.VITE_API_BASE_URL
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
: `${import.meta.env.VITE_API_BASE_URL}/api/v1`)
: '/api/v1'
const res = await axios.post(`${baseURL}/admin/upload/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(token ? { Authorization: `Bearer ${token}` } : {})
}
})
if (res.data?.data?.url) return res.data.data.url
throw new Error(res.data?.message || '上传失败')
}
// 背景上传
const triggerBgUpload = () => bgInputRef.value?.click()
const onBgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
uploadingBg.value = true
try {
config.bgImage = await uploadFile(file)
ElMessage.success('背景图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingBg.value = false
if (bgInputRef.value) bgInputRef.value.value = ''
}
}
// 图片元素上传(更换已有图片元素)
const triggerElementImageUpload = () => imgInputRef.value?.click()
const onImgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !selectedElement.value) return
uploadingImg.value = true
try {
selectedElement.value.url = await uploadFile(file)
ElMessage.success('图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingImg.value = false
if (imgInputRef.value) imgInputRef.value.value = ''
}
}
// 添加图片元素时先上传
const triggerImageAdd = () => addImgInputRef.value?.click()
const onAddImgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
uploadingImg.value = true
try {
const url = await uploadFile(file)
const el: PosterElement = {
id: genId(),
type: 'image',
x: 100,
y: 100,
w: 120,
h: 120,
shape: 'square',
url
}
config.elements.push(el)
selectedId.value = el.id
ElMessage.success('图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingImg.value = false
if (addImgInputRef.value) addImgInputRef.value.value = ''
}
}
// ────── 读取 / 保存配置(使用 admin 接口) ──────
const loadConfig = async () => {
configReady.value = false
selectedId.value = null
try {
const res = await request.get<any>('/admin/settings/poster')
const data = res?.data?.poster
if (data) {
config.bgColor = data.bgColor ?? '#ffffff'
config.bgImage = data.bgImage ?? ''
config.elements = (data.elements ?? []).map((el: any) => {
const align = el.align ?? (el.center ? 'center' : 'left')
const { center: _center, ...rest } = el
return {
...rest,
align,
x: Number(el.x) || 0,
y: Number(el.y) || 0,
w: Math.max(20, Number(el.w) || 100),
h: Math.max(20, Number(el.h) || 100),
}
})
}
} catch {
// 未配置时静默失败
}
configVersion.value++
await nextTick()
configReady.value = true
}
const saveConfig = async () => {
saving.value = true
try {
await request.put('/admin/settings/poster', {
bgColor: config.bgColor,
bgImage: config.bgImage,
elements: config.elements
})
ElMessage.success('海报配置已保存')
} catch (err: any) {
ElMessage.error(err.message || '保存失败')
} finally {
saving.value = false
}
}
const loadFonts = async () => {
try {
const res = await request.get<any>('/admin/settings/fonts')
const remote = res?.data?.fonts
serverFontsLoaded.value = true
if (Array.isArray(remote) && remote.length > 0) {
fontList.value = remote
}
} catch {
serverFontsLoaded.value = false
fontList.value = [
{ key: 'noto-sans', name: '思源黑体' },
{ key: 'noto-serif', name: '思源宋体' },
{ key: 'alimama', name: '阿里妈妈方圆体' },
{ key: 'wqy-microhei', name: '文泉驿微米黑' },
]
}
}
onMounted(async () => {
await Promise.all([loadConfig(), loadFonts()])
})
</script>
<style scoped lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap');
.poster-editor-root {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
/* 工具栏 */
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
gap: 8px;
flex-wrap: wrap;
.toolbar-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
gap: 6px;
}
}
/* 主体三栏 */
.editor-body {
display: grid;
grid-template-columns: 180px 1fr 260px;
gap: 12px;
min-height: 700px;
}
/* 通用面板 */
.editor-layers,
.editor-props {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
.delete-icon {
cursor: pointer;
color: #ef4444;
font-size: 14px;
&:hover { color: #dc2626; }
}
}
/* 图层列表 */
.layers-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.layer-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
font-size: 12px;
color: #6b7280;
&:hover { background: #f3f4f6; border-color: #d1d5db; }
&.active { background: #f3e8ff; border-color: #a855f7; color: #7c3aed; }
.layer-icon { font-size: 13px; flex-shrink: 0; }
.layer-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.layer-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
.action-icon {
font-size: 12px;
padding: 2px;
border-radius: 3px;
cursor: pointer;
&:hover { background: #e5e7eb; }
&.danger { color: #ef4444; }
}
}
&:hover .layer-actions { opacity: 1; }
}
.layers-empty {
text-align: center;
color: #9ca3af;
font-size: 12px;
padding: 20px 0;
}
/* 画布区域 */
.editor-canvas-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: #e5e7eb;
border-radius: 8px;
overflow: auto;
}
.canvas-scale-wrap {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.poster-canvas {
position: relative;
width: 375px;
height: 667px;
overflow: hidden;
user-select: none;
}
.canvas-bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
cursor: default;
}
.canvas-bg-click {
position: absolute;
inset: 0;
z-index: 0;
cursor: default;
}
.canvas-hint {
font-size: 11px;
color: #9ca3af;
}
/* 元素内容 */
.el-text,
.el-nickname {
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.el-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: rgba(124, 58, 237, 0.08);
border: 1.5px dashed rgba(124, 58, 237, 0.4);
color: #7c3aed;
font-size: 12px;
pointer-events: none;
.el-icon { font-size: 20px; }
&.el-qrcode {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.4);
color: #10b981;
}
}
/* MBTI / PDP / DISC 测试结果占位 */
.el-test-result {
border: 1.5px dashed rgba(124, 58, 237, 0.3);
border-radius: 4px;
justify-content: center;
&.mbti-result { background: rgba(99, 102, 241, 0.06); }
&.pdp-result { background: rgba(16, 185, 129, 0.06); border-color: rgba(16, 185, 129, 0.3); }
&.disc-result { background: rgba(244, 63, 94, 0.06); border-color: rgba(244, 63, 94, 0.3); }
}
/* vue3-draggable-resizable 激活样式覆盖 */
:deep(.vdr-active) {
outline: 2px solid #7c3aed !important;
outline-offset: 1px;
}
:deep(.vdr) {
z-index: 1;
}
/* 字号行 */
.font-size-row {
display: flex;
align-items: center;
}
/* 属性面板 */
.prop-item {
margin-bottom: 12px;
&.inline {
display: flex;
align-items: center;
justify-content: space-between;
}
&.half {
flex: 1;
:deep(.el-input-number) {
width: 100%;
}
}
}
.prop-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.prop-label {
display: block;
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.props-section-title {
font-size: 12px;
font-weight: 600;
color: #374151;
margin: 14px 0 8px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
}
.props-tip {
font-size: 12px;
color: #9ca3af;
text-align: center;
padding: 20px 0;
}
.img-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.img-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.form-hint {
display: block;
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="header-left">
<h2>价格设置</h2>
<p class="subtitle">分别配置个人版和企业版的测试价格</p>
</div>
</div>
<!-- Tab 切换 -->
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', activeTab === tab.value ? 'active' : '']"
@click="activeTab = tab.value"
>{{ tab.label }}</div>
</div>
</div>
<div class="pricing-content">
<div class="tab-content-card">
<!-- 个人版价格 -->
<div v-if="activeTab === 'personal'" class="tab-content">
<div class="form-section">
<div class="form-grid">
<div class="form-item">
<label>人脸测试价格 (/)</label>
<el-input-number v-model="personal.face" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>MBTI测试价格 (/)</label>
<el-input-number v-model="personal.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>DISC测试价格 (/)</label>
<el-input-number v-model="personal.disc" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>PDP测试价格 (/)</label>
<el-input-number v-model="personal.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
<span>当前使用超管默认定价保存后将创建您的个人版专属配置</span>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="savePersonal" :loading="loading">
保存个人版价格
</el-button>
</div>
</div>
</div>
<!-- 企业版价格 -->
<div v-if="activeTab === 'enterprise'" class="tab-content">
<div class="form-section">
<div class="form-grid">
<div class="form-item">
<label>人脸测试价格 (/)</label>
<el-input-number v-model="enterprise.face" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>MBTI测试价格 (/)</label>
<el-input-number v-model="enterprise.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>DISC测试价格 (/)</label>
<el-input-number v-model="enterprise.disc" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>PDP测试价格 (/)</label>
<el-input-number v-model="enterprise.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
<span>当前使用超管默认企业定价保存后将创建您的企业版专属配置</span>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveEnterprise" :loading="loading">
保存企业版价格
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
const tabs = [
{ label: '个人版价格', value: 'personal' },
{ label: '企业版价格', value: 'enterprise' },
]
const activeTab = ref('personal')
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
const loading = ref(false)
const isUsingSuperAdminPersonalConfig = ref(false)
const isUsingSuperAdminEnterpriseConfig = ref(false)
const loadPricing = async () => {
loading.value = true
try {
const response: any = await request.get('/admin/pricing')
if (response.code === 200 && response.data) {
if (response.data.personal) {
Object.assign(personal, response.data.personal)
}
if (response.data.enterprise) {
Object.assign(enterprise, response.data.enterprise)
}
isUsingSuperAdminPersonalConfig.value = response.data.isUsingSuperAdminPersonalConfig
?? response.data.isUsingSuperAdminConfig
?? false
isUsingSuperAdminEnterpriseConfig.value = response.data.isUsingSuperAdminEnterpriseConfig ?? false
}
} catch (error: any) {
console.error('加载定价配置失败:', error)
} finally {
loading.value = false
}
}
const savePersonal = async () => {
loading.value = true
try {
const response: any = await request.put('/admin/pricing', { personalConfig: personal })
if (response.code === 200) {
ElMessage.success('个人版价格已保存')
isUsingSuperAdminPersonalConfig.value = false
await loadPricing()
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
loading.value = false
}
}
const saveEnterprise = async () => {
loading.value = true
try {
const response: any = await request.put('/admin/pricing', {
enterpriseConfig: {
face: enterprise.face,
mbti: enterprise.mbti,
disc: enterprise.disc,
pdp: enterprise.pdp
}
})
if (response.code === 200) {
ElMessage.success('企业版价格已保存')
isUsingSuperAdminEnterpriseConfig.value = false
await loadPricing()
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadPricing()
})
</script>
<style scoped lang="scss">
.page-header {
margin-bottom: 24px;
.header-left {
h2 {
font-size: 22px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 16px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover { color: #111827; }
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
.pricing-content {
display: flex;
flex-direction: column;
}
.tab-content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 32px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.tab-content {
.form-section {
display: flex;
flex-direction: column;
gap: 24px;
margin-bottom: 24px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-bottom: 32px;
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 13px;
font-weight: 600;
color: #374151;
}
}
}
.notice-box {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background-color: #eff6ff;
border-radius: 8px;
margin-bottom: 24px;
border: 1px solid #bfdbfe;
.notice-icon {
color: #3b82f6;
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
}
span {
font-size: 13px;
color: #1e40af;
line-height: 1.5;
}
}
.form-item {
:deep(.el-input-number) {
width: 100%;
.el-input__wrapper {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 8px 12px;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
.el-input__inner {
font-size: 14px;
color: #111827;
text-align: left;
}
}
}
.save-actions {
padding-top: 24px;
border-top: 1px solid #f3f4f6;
.save-btn {
height: 42px;
padding: 0 32px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
.w-full { width: 100%; }
@media (max-width: 768px) {
.tab-content .form-grid {
grid-template-columns: 1fr;
}
.tab-content-card {
padding: 24px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,543 @@
<template>
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="header-left">
<h2>系统设置</h2>
<p class="subtitle">管理管理员账号</p>
</div>
</div>
<div class="settings-content">
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="activeTab = tab.value"
>
{{ tab.label }}
</div>
</div>
</div>
<div class="tab-content-card" :class="{ 'no-pad': activeTab === 'poster' }">
<!-- 小程序配置 -->
<div v-if="activeTab === 'miniprogram'" class="tab-content" v-loading="miniprogramLoading">
<div class="content-header">
<h3>小程序配置</h3>
<p class="content-description">配置小程序名称及展示文案与超管共用全局配置将显示在小程序导航栏等位置</p>
</div>
<div class="form-section">
<div class="form-item">
<label>小程序名称</label>
<el-input
v-model="miniprogramConfig.miniprogramName"
placeholder="用于小程序导航栏等展示"
class="w-full"
/>
</div>
<div class="text-config-section">
<div class="section-label">小程序文案配置</div>
<p class="section-desc">以下文案将显示在小程序对应位置留空则使用默认值</p>
<div class="form-grid">
<div class="form-item">
<label>分析中提示</label>
<el-input v-model="miniprogramConfig.textConfig.analyzingTitle" placeholder="默认:正在分析中" class="w-full" />
</div>
<div class="form-item">
<label>开始按钮个人版</label>
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:开始面相测试" class="w-full" />
</div>
<div class="form-item">
<label>开始按钮企业版</label>
<el-input v-model="miniprogramConfig.textConfig.startButtonEnterprise" placeholder="默认:开始面部测试" class="w-full" />
</div>
<div class="form-item">
<label>报告页标题</label>
<el-input v-model="miniprogramConfig.textConfig.reportTitle" placeholder="默认:分析报告" class="w-full" />
</div>
<div class="form-item">
<label>智能分析文案</label>
<el-input v-model="miniprogramConfig.textConfig.aiAnalysisText" placeholder="默认:智能分析" class="w-full" />
</div>
</div>
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
保存小程序配置
</el-button>
</div>
</div>
<!-- 海报配置 -->
<div v-if="activeTab === 'poster'" class="tab-content poster-tab">
<PosterEditor />
</div>
<!-- 账号设置 -->
<div v-if="activeTab === 'account'" class="tab-content">
<div class="content-header">
<h3>管理员账号设置</h3>
<p class="content-description">修改管理员账号和密码</p>
</div>
<div class="form-section">
<div class="form-item">
<label>管理员用户名</label>
<el-input
v-model="accountConfig.username"
placeholder="输入管理员用户名"
class="w-full"
/>
</div>
<div class="form-item">
<label>当前密码</label>
<el-input
v-model="accountConfig.currentPassword"
type="password"
placeholder="输入当前密码(修改密码时必填)"
show-password
class="w-full"
/>
</div>
<div class="form-item">
<label>新密码</label>
<el-input
v-model="accountConfig.password"
type="password"
placeholder="输入新密码(不修改则留空)"
show-password
class="w-full"
/>
</div>
<div class="form-item">
<label>确认新密码</label>
<el-input
v-model="accountConfig.confirmPassword"
type="password"
placeholder="再次输入新密码"
show-password
class="w-full"
/>
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAccountSettings" :loading="loading">
<el-icon><DocumentCopy /></el-icon>
<span>保存凭据</span>
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { DocumentCopy } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import PosterEditor from './PosterEditor.vue'
const activeTab = ref('account')
const loading = ref(false)
const tabs = [
{ label: '账号设置', value: 'account' },
{ label: '小程序配置', value: 'miniprogram' },
{ label: '海报配置', value: 'poster' }
]
// ── 小程序配置 ──
const miniprogramLoading = ref(false)
const miniprogramConfig = reactive({
miniprogramName: '神仙团队AI性格测试',
textConfig: {
analyzingTitle: '正在分析中',
startButtonText: '开始面相测试',
startButtonEnterprise: '开始面部测试',
reportTitle: '分析报告',
aiAnalysisText: '智能分析'
}
})
const loadMiniprogramConfig = async () => {
miniprogramLoading.value = true
try {
const res: any = await request.get('/admin/settings/miniprogram')
if (res.code === 200 && res.data) {
miniprogramConfig.miniprogramName = res.data.miniprogramName ?? '神仙团队AI性格测试'
if (res.data.textConfig && typeof res.data.textConfig === 'object') {
Object.assign(miniprogramConfig.textConfig, res.data.textConfig)
}
}
} catch (e) {
console.error('加载小程序配置失败:', e)
} finally {
miniprogramLoading.value = false
}
}
const saveMiniprogramConfig = async () => {
if (!miniprogramConfig.miniprogramName?.trim()) {
ElMessage.error('小程序名称不能为空')
return
}
miniprogramLoading.value = true
try {
const res: any = await request.put('/admin/settings/miniprogram', {
miniprogramName: miniprogramConfig.miniprogramName.trim(),
textConfig: miniprogramConfig.textConfig
})
if (res.code === 200) {
ElMessage.success('小程序配置已保存')
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e: any) {
ElMessage.error(e.message || '保存失败')
} finally {
miniprogramLoading.value = false
}
}
// 账号配置
const accountConfig = reactive({
username: '',
password: '',
currentPassword: '',
confirmPassword: ''
})
// 加载当前用户信息
const loadSettings = async () => {
loading.value = true
try {
const response: any = await request.get('/admin/settings')
if (response.code === 200 && response.data) {
accountConfig.username = response.data.username || ''
}
} catch (error: any) {
console.error('加载设置失败:', error)
} finally {
loading.value = false
}
}
// 保存账号设置
const saveAccountSettings = async () => {
if (!accountConfig.username) {
ElMessage.error('用户名不能为空')
return
}
if (accountConfig.password && accountConfig.password !== accountConfig.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
loading.value = true
try {
const response: any = await request.put('/admin/settings/credentials', {
username: accountConfig.username,
currentPassword: accountConfig.currentPassword,
newPassword: accountConfig.password,
confirmPassword: accountConfig.confirmPassword
})
if (response.code === 200) {
ElMessage.success('账号设置已保存')
accountConfig.password = ''
accountConfig.currentPassword = ''
accountConfig.confirmPassword = ''
await loadSettings()
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
loading.value = false
}
}
watch(activeTab, (tab) => {
if (tab === 'miniprogram') loadMiniprogramConfig()
})
onMounted(() => {
loadSettings()
})
</script>
<style scoped lang="scss">
.page-header {
margin-bottom: 24px;
.header-left {
h2 {
font-size: 22px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
.tab-content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 32px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&.no-pad {
padding: 16px;
}
}
.poster-tab {
min-height: 780px;
}
.tab-content {
.content-header {
margin-bottom: 32px;
h3 {
font-size: 20px;
font-weight: 700;
color: #111827;
margin: 0 0 8px 0;
}
.content-description {
font-size: 13px;
color: #6b7280;
margin: 0;
line-height: 1.5;
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 24px;
margin-bottom: 32px;
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 0;
}
.form-hint {
font-size: 12px;
color: #9ca3af;
margin: 0;
line-height: 1.4;
}
:deep(.el-input) {
.el-input__wrapper {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 8px 12px;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
.el-input__inner {
font-size: 14px;
color: #111827;
&::placeholder {
color: #9ca3af;
}
}
}
}
}
.save-actions {
padding-top: 24px;
border-top: 1px solid #f3f4f6;
.save-btn {
height: 42px;
padding: 0 32px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.text-config-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
.section-label {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.section-desc {
font-size: 13px;
color: #6b7280;
margin: 0 0 16px 0;
line-height: 1.5;
}
}
.notice-box {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background-color: #eff6ff;
border-radius: 8px;
margin-bottom: 24px;
border: 1px solid #bfdbfe;
.notice-icon {
color: #3b82f6;
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
}
span {
font-size: 13px;
color: #1e40af;
line-height: 1.5;
}
}
:deep(.el-input-number) {
width: 100%;
.el-input__wrapper {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 8px 12px;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
.el-input__inner {
font-size: 14px;
color: #111827;
text-align: left;
}
}
.w-full {
width: 100%;
}
@media (max-width: 768px) {
.tab-content-card {
padding: 24px;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>数据库管理</h2>
<p class="subtitle">管理数据库连接备份和恢复</p>
</div>
<div class="header-actions">
<el-button type="primary" color="#ef4444" @click="handleBackup">
<el-icon class="mr-1"><DocumentCopy /></el-icon>备份数据库
</el-button>
</div>
</div>
<!-- 数据库信息 -->
<div class="info-grid">
<div class="info-card">
<div class="info-label">数据库类型</div>
<div class="info-value">{{ dbInfo.databaseType }}</div>
</div>
<div class="info-card">
<div class="info-label">连接状态</div>
<div class="info-value">
<el-tag :type="dbInfo.connected ? 'success' : 'danger'" size="small">
{{ dbInfo.connected ? '已连接' : '未连接' }}
</el-tag>
</div>
</div>
<div class="info-card">
<div class="info-label">数据库大小</div>
<div class="info-value">{{ formatSize(dbInfo.databaseSize * 1024 * 1024) }}</div>
</div>
<div class="info-card">
<div class="info-label">表数量</div>
<div class="info-value">{{ dbInfo.tableCount }}</div>
</div>
</div>
<!-- 集合列表 -->
<div class="content-card">
<div class="card-header">
<h3>数据库集合</h3>
<el-input
v-model="searchTerm"
placeholder="搜索集合名称..."
clearable
class="search-input"
style="max-width: 300px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-table
:data="filteredCollections"
style="width: 100%"
v-loading="loading"
class="custom-table"
v-if="filteredCollections.length > 0 && !loading"
>
<el-table-column label="集合名称" min-width="200">
<template #default="{ row }">
<span class="collection-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="文档数量" width="120" align="right">
<template #default="{ row }">
<span class="doc-count">{{ row.docCount.toLocaleString() }}</span>
</template>
</el-table-column>
<el-table-column label="大小" width="120" align="right">
<template #default="{ row }">
<span class="size">{{ formatSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="索引数" width="100" align="right">
<template #default="{ row }">
<span class="index-count">{{ row.indexCount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
<el-button link @click="handleExport(row)"><el-icon><Download /></el-icon></el-button>
<el-button link type="danger" @click="handleClear(row)"><el-icon><Delete /></el-icon></el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 空数据占位图 -->
<div v-else-if="!loading" class="empty-placeholder">
<el-icon class="empty-icon"><DocumentCopy /></el-icon>
<p class="empty-text">暂无数据表</p>
</div>
</div>
<!-- 备份记录 -->
<div class="content-card">
<div class="card-header">
<h3>备份记录</h3>
</div>
<el-table
:data="backups"
style="width: 100%"
class="custom-table"
v-if="backups.length > 0"
>
<el-table-column label="备份时间" width="180">
<template #default="{ row }">
<span class="backup-time">{{ formatTime(row.time) }}</span>
</template>
</el-table-column>
<el-table-column label="备份大小" width="120" align="right">
<template #default="{ row }">
<span class="backup-size">{{ formatSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button link @click="handleRestore(row)"><el-icon><RefreshLeft /></el-icon>恢复</el-button>
<el-button link @click="handleDownload(row)"><el-icon><Download /></el-icon>下载</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { DocumentCopy, Search, View, Download, Delete, RefreshLeft } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/utils/request'
const loading = ref(false)
const searchTerm = ref('')
const dbInfo = ref({
databaseType: 'MySQL',
databaseName: '',
connected: false,
databaseSize: 0,
tableCount: 0
})
const collections = ref([])
const backups = ref([])
const filteredCollections = computed(() => {
if (!searchTerm.value) {
return collections.value
}
return collections.value.filter((item: any) =>
item.name.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}
const formatTime = (time: string) => {
const date = new Date(time)
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 加载数据库信息
const loadDbInfo = async () => {
try {
const response: any = await request.get('/superadmin/database/info')
if (response.code === 200 && response.data) {
dbInfo.value = response.data
}
} catch (error: any) {
console.error('加载数据库信息失败:', error)
}
}
// 加载表列表
const loadTables = async () => {
loading.value = true
try {
const response: any = await request.get('/superadmin/database/tables')
if (response.code === 200 && response.data) {
collections.value = response.data
}
} catch (error: any) {
ElMessage.error(error.message || '加载表列表失败')
} finally {
loading.value = false
}
}
// 加载备份记录
const loadBackups = async () => {
try {
const response: any = await request.get('/superadmin/database/backups')
if (response.code === 200 && response.data) {
backups.value = response.data
}
} catch (error: any) {
console.error('加载备份记录失败:', error)
}
}
const handleBackup = async () => {
try {
await ElMessageBox.confirm('确定要备份数据库吗?', '备份数据库', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
loading.value = true
const response: any = await request.post('/superadmin/database/backup')
if (response.code === 200) {
ElMessage.success('数据库备份成功')
await loadBackups()
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '备份失败')
}
} finally {
loading.value = false
}
}
const handleView = async (row: any) => {
ElMessage.info('查看表: ' + row.name + ' (功能开发中)')
}
const handleExport = async (row: any) => {
try {
loading.value = true
const response: any = await request.post('/superadmin/database/export-table', {
table: row.name
})
if (response.code === 200 && response.data) {
// 下载文件
window.open(response.data.downloadUrl, '_blank')
ElMessage.success('导出成功')
}
} catch (error: any) {
ElMessage.error(error.message || '导出失败')
} finally {
loading.value = false
}
}
const handleClear = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要清空表 "${row.name}" 吗?此操作不可恢复!`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
loading.value = true
const response: any = await request.post('/superadmin/database/clear-table', {
table: row.name
})
if (response.code === 200) {
ElMessage.success('表数据已清空')
await loadTables()
await loadDbInfo()
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '清空失败')
}
} finally {
loading.value = false
}
}
const handleRestore = async (row: any) => {
try {
await ElMessageBox.confirm('确定要恢复此备份吗?当前数据将被覆盖!', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
loading.value = true
const response: any = await request.post('/superadmin/database/restore', {
file: row.filename
})
if (response.code === 200) {
ElMessage.success('数据库恢复成功')
await loadTables()
await loadDbInfo()
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '恢复失败')
}
} finally {
loading.value = false
}
}
const handleDownload = (row: any) => {
window.open(`/api/v1/superadmin/database/download?file=${encodeURIComponent(row.filename)}`, '_blank')
}
onMounted(() => {
loadDbInfo()
loadTables()
loadBackups()
})
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
h2 {
font-size: 22px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.info-card {
background: #fff;
border-radius: 10px;
padding: 20px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.info-label {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.info-value {
font-size: 18px;
font-weight: 600;
color: #111827;
}
}
.content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
margin-bottom: 24px;
.card-header {
padding: 20px;
border-bottom: 1px solid #f3f4f6;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
}
}
}
.custom-table {
:deep(.el-table__header) {
th {
background-color: #f9fafb;
color: #6b7280;
font-weight: 500;
font-size: 13px;
padding: 12px 0;
}
}
.collection-name, .doc-count, .size, .index-count, .backup-time, .backup-size {
font-size: 13px;
color: #374151;
}
.action-buttons {
display: flex;
gap: 4px;
.el-button {
padding: 4px;
font-size: 16px;
color: #6b7280;
&:hover {
color: #ef4444;
}
&.el-button--danger:hover {
color: #ef4444;
}
}
}
}
.mr-1 {
margin-right: 4px;
}
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #9ca3af;
.empty-icon {
font-size: 64px;
color: #d1d5db;
margin-bottom: 16px;
}
.empty-text {
font-size: 14px;
color: #9ca3af;
margin: 0;
}
}
@media (max-width: 1200px) {
.info-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,694 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>分销管理</h2>
<p class="subtitle">全平台分销商佣金提现审核与分销设置</p>
</div>
<div class="header-actions">
<el-button @click="refresh" class="refresh-btn">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
</div>
</div>
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="activeTab = tab.value"
>
{{ tab.label }}
</div>
</div>
</div>
<div class="tab-content">
<!-- 数据概览 -->
<div v-if="activeTab === 'overview'" class="overview-section">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">累计佣金全平台</div>
<div class="stat-value">¥{{ overview.totalCommission }}</div>
</div>
<div class="stat-icon purple"><el-icon><Money /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">已结算佣金</div>
<div class="stat-value">¥{{ overview.paidCommission }}</div>
</div>
<div class="stat-icon green"><el-icon><Money /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">待审核提现</div>
<div class="stat-value">¥{{ overview.pendingWithdraw }}</div>
</div>
<div class="stat-icon orange"><el-icon><Clock /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">有效绑定人数</div>
<div class="stat-value">{{ overview.bindingCount }}</div>
</div>
<div class="stat-icon blue"><el-icon><User /></el-icon></div>
</div>
</div>
<div class="two-cols">
<div class="content-card small-card">
<div class="card-title">佣金来源分布</div>
<div class="dist-row">
<span>个人版</span><strong>¥{{ overview.personalCommission }}</strong>
</div>
<div class="dist-row">
<span>企业版</span><strong>¥{{ overview.enterpriseCommission }}</strong>
</div>
</div>
<div class="content-card small-card">
<div class="card-title">今日数据</div>
<div class="dist-row">
<span>今日结算佣金</span><strong>¥{{ overview.todayCommission }}</strong>
</div>
<div class="dist-row">
<span>全部订单数</span><strong>{{ overview.totalOrders }}</strong>
</div>
</div>
</div>
</div>
<!-- 提现管理 -->
<div v-else-if="activeTab === 'withdrawals'" class="table-section">
<div class="content-card">
<div class="toolbar">
<div class="filter-group">
<div
v-for="item in withOptions"
:key="item.value"
:class="['filter-item', { active: withFilter === item.value }]"
@click="withFilter = item.value"
>{{ item.label }}</div>
</div>
</div>
<el-table :data="withdrawals" style="width:100%" class="custom-table" v-loading="loading">
<el-table-column label="用户" min-width="140">
<template #default="{ row }">
<div class="agent-cell">
<el-avatar :size="32" :src="row.avatar">{{ row.nickname ? row.nickname[0] : '?' }}</el-avatar>
<span class="agent-name">{{ row.nickname }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="申请金额" align="right">
<template #default="{ row }">¥{{ row.amountYuan }}</template>
</el-table-column>
<el-table-column label="状态" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" />
<el-table-column label="申请时间" min-width="160">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="160">
<template #default="{ row }">
<template v-if="Number(row.status) === 0">
<el-button size="small" type="success" @click="approve(row)">通过</el-button>
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
</template>
<span v-else style="color:#9ca3af;font-size:12px">已处理</span>
</template>
</el-table-column>
</el-table>
<div v-if="withdrawals.length === 0 && !loading" class="empty-placeholder">暂无提现申请</div>
</div>
</div>
<!-- 佣金记录 -->
<div v-else-if="activeTab === 'commissions'" class="table-section">
<div class="content-card">
<div class="toolbar">
<div class="filter-group">
<div
v-for="item in commOptions"
:key="item.value"
:class="['filter-item', { active: commFilter === item.value }]"
@click="commFilter = item.value"
>{{ item.label }}</div>
</div>
</div>
<el-table :data="commissions" style="width:100%" class="custom-table" v-loading="loading">
<el-table-column label="推荐人" prop="inviterName" />
<el-table-column label="购买者" prop="inviteeName" />
<el-table-column label="企业" prop="enterpriseName" />
<el-table-column label="订单金额" align="right">
<template #default="{ row }">¥{{ row.orderYuan }}</template>
</el-table-column>
<el-table-column label="比例" align="center">
<template #default="{ row }">{{ row.commissionRate || 0 }}%</template>
</el-table-column>
<el-table-column label="佣金" align="right">
<template #default="{ row }">¥{{ row.commissionYuan }}</template>
</el-table-column>
<el-table-column label="状态" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'pending' ? 'warning' : 'info'" size="small">
{{ row.status === 'paid' ? '已结算' : row.status === 'pending' ? '待结算' : '已取消' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" min-width="160">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div v-if="commissions.length === 0 && !loading" class="empty-placeholder">暂无佣金记录</div>
</div>
</div>
<!-- 分销设置 -->
<div v-else-if="activeTab === 'settings'" class="settings-section">
<div class="settings-grid">
<div class="settings-card">
<div class="card-header">基本设置</div>
<div class="setting-list">
<div class="setting-row">
<div class="info">
<p class="title">启用分销功能个人版全局</p>
<p class="desc">关闭后新订单不再产生佣金</p>
</div>
<el-switch v-model="distEnabled" />
</div>
<div class="setting-row">
<div class="info">
<p class="title">推广中心标题</p>
<p class="desc">小程序个人中心推广卡片的显示文字</p>
</div>
<el-input v-model="promoCenterTitle" placeholder="推广中心" maxlength="20" show-word-limit class="promo-title-input" />
</div>
</div>
</div>
<div class="settings-card">
<div class="card-header">提现设置</div>
<div class="setting-list" style="margin-bottom:16px">
<div class="setting-row">
<div class="info">
<p class="title">开启提现审核</p>
<p class="desc">开启后每笔提现申请需管理员人工审核才可打款</p>
</div>
<el-switch v-model="requireAudit" />
</div>
</div>
<div class="form-grid">
<div class="form-item">
<label>最低提现金额 ()</label>
<el-input-number v-model="minWithdraw" :min="1" :max="200" :precision="2" class="w-full" />
</div>
<div class="form-item">
<label>最高提现金额 ()</label>
<el-input-number v-model="maxWithdraw" :min="0" :max="200" :precision="2" class="w-full" />
</div>
<div class="form-item">
<label>提现手续费 (%)</label>
<el-input-number v-model="withdrawFee" :min="0" :max="100" class="w-full" />
</div>
</div>
</div>
<!-- 测试佣金配置 -->
<div class="settings-card full-width-card">
<div class="card-header">测试佣金配置</div>
<p class="card-desc">为每种测试类型独立设置佣金比例或固定金额并可开启无需付款让用户完成测试即触发佣金</p>
<div class="ts-grid">
<div v-for="item in testTypeItems" :key="item.key" class="ts-card">
<div class="ts-head">
<span class="ts-name">{{ item.label }}</span>
<el-switch v-model="testSettings[item.key].enabled" size="small" />
</div>
<template v-if="testSettings[item.key].enabled">
<div class="ts-row">
<label>佣金类型</label>
<el-radio-group v-model="testSettings[item.key].commissionType" size="small">
<el-radio-button value="ratio">比例</el-radio-button>
<el-radio-button value="amount">固定金额</el-radio-button>
</el-radio-group>
</div>
<div class="ts-row">
<label>{{ testSettings[item.key].commissionType === 'ratio' ? '佣金比例 (%)' : '固定金额 (元)' }}</label>
<el-input-number
v-if="testSettings[item.key].commissionType === 'ratio'"
v-model="testSettings[item.key].commissionRate"
:min="0" :max="100" class="w-full"
/>
<el-input-number
v-else
v-model="testSettings[item.key].commissionAmount"
:min="0" :precision="2" :step="0.1" class="w-full"
/>
</div>
<div class="ts-nopay">
<div class="ts-nopay-info">
<p class="ts-nopay-title">无需付款触发</p>
<p class="ts-nopay-desc">用户完成测试即发放佣金无需付款</p>
</div>
<el-switch v-model="testSettings[item.key].noPayment" size="small" />
</div>
</template>
<div v-else class="ts-disabled">已关闭该类型不产生佣金</div>
</div>
</div>
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
</div>
</div>
</div>
</div>
<!-- 拒绝理由弹窗 -->
<el-dialog v-model="rejectVisible" title="拒绝提现" width="400px" destroy-on-close>
<el-input v-model="rejectNote" type="textarea" :rows="3" placeholder="请输入拒绝理由(可选)" />
<template #footer>
<el-button @click="rejectVisible = false">取消</el-button>
<el-button type="danger" @click="confirmReject" :loading="loading">确认拒绝</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { Refresh, User, Money, Clock } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
const activeTab = ref('overview')
const loading = ref(false)
const commFilter = ref('')
const withFilter = ref('')
const tabs = [
{ label: '数据概览', value: 'overview' },
{ label: '提现管理', value: 'withdrawals' },
{ label: '佣金记录', value: 'commissions' },
{ label: '分销设置', value: 'settings' },
]
const commOptions = [
{ label: '全部', value: '' },
{ label: '待结算', value: 'pending' },
{ label: '已结算', value: 'paid' },
{ label: '已取消', value: 'cancelled' },
]
const withOptions = [
{ label: '全部', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已完成', value: 'completed' },
]
const overview = reactive({
totalCommission: '0.00',
paidCommission: '0.00',
frozenCommission: '0.00',
personalCommission: '0.00',
enterpriseCommission: '0.00',
totalOrders: 0,
bindingCount: 0,
pendingWithdraw: '0.00',
todayCommission: '0.00',
})
const withdrawals = ref<any[]>([])
const commissions = ref<any[]>([])
const distEnabled = ref(true)
const promoCenterTitle = ref('推广中心')
const minWithdraw = ref(1)
const maxWithdraw = ref(0)
const requireAudit = ref(true)
const withdrawFee = ref(0)
const testTypeItems = [
{ key: 'face', label: '人脸分析' },
{ key: 'mbti', label: 'MBTI 测试' },
{ key: 'disc', label: 'DISC 测试' },
{ key: 'pdp', label: 'PDP 测试' },
]
type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean }
const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false })
const testSettings = reactive<Record<string, TestSetting>>({
face: makeDefaultTs(),
mbti: makeDefaultTs(),
disc: makeDefaultTs(),
pdp: makeDefaultTs(),
})
// 拒绝弹窗
const rejectVisible = ref(false)
const rejectNote = ref('')
const rejectTarget = ref<any>(null)
const fmtTime = (ts: number) => ts ? new Date(ts * 1000).toLocaleString() : '-'
// 提现状态0审核中、1已驳回、2待收款、3已收款、4已过期
const statusLabel = (s: any) => {
const code = Number(s)
switch (code) {
case 0: return '审核中'
case 1: return '已驳回'
case 2: return '待收款'
case 3: return '已收款'
case 4: return '已过期'
default: return '未知'
}
}
const statusTagType = (s: any) => {
const code = Number(s)
switch (code) {
case 0: return 'warning' // 审核中
case 1: return 'danger' // 已驳回
case 2: return 'info' // 待收款
case 3: return 'success' // 已收款
case 4: return 'info' // 已过期
default: return 'info'
}
}
// ── 数据概览
const loadOverview = async () => {
try {
const res: any = await request.get('/superadmin/distribution/overview')
if (res.code === 200 && res.data) Object.assign(overview, res.data)
} catch (e) {}
}
// ── 提现列表
const loadWithdrawals = async () => {
loading.value = true
try {
const res: any = await request.get('/superadmin/distribution/withdrawals', {
params: { status: withFilter.value, pageSize: 100 }
})
if (res.code === 200 && res.data) withdrawals.value = res.data.list || []
} catch (e) {}
loading.value = false
}
// ── 审核通过
const approve = async (row: any) => {
loading.value = true
try {
const res: any = await request.post(`/superadmin/distribution/withdrawals/${row.id}/approve`)
if (res.code === 200) {
ElMessage.success('已通过')
loadWithdrawals()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
loading.value = false
}
// ── 打开拒绝弹窗
const openReject = (row: any) => {
rejectTarget.value = row
rejectNote.value = ''
rejectVisible.value = true
}
// ── 确认拒绝
const confirmReject = async () => {
if (!rejectTarget.value) return
loading.value = true
try {
const res: any = await request.post(
`/superadmin/distribution/withdrawals/${rejectTarget.value.id}/reject`,
{ note: rejectNote.value }
)
if (res.code === 200) {
ElMessage.success('已拒绝')
rejectVisible.value = false
loadWithdrawals()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
loading.value = false
}
// ── 佣金记录
const loadCommissions = async () => {
loading.value = true
try {
const res: any = await request.get('/superadmin/distribution/commissions', {
params: { status: commFilter.value, pageSize: 100 }
})
if (res.code === 200 && res.data) commissions.value = res.data.list || []
} catch (e) {}
loading.value = false
}
// ── 加载设置
const loadSettings = async () => {
try {
const res: any = await request.get('/superadmin/distribution/settings')
if (res.code === 200 && res.data) {
const d = res.data
distEnabled.value = d.enabled ?? true
promoCenterTitle.value = d.promoCenterTitle ?? '推广中心'
minWithdraw.value = d.minWithdraw ?? 1
maxWithdraw.value = d.maxWithdraw ?? 0
requireAudit.value = d.requireAudit !== false
withdrawFee.value = d.withdrawFee ?? 0
const ts = d.testSettings ?? {}
testTypeItems.forEach(({ key }) => {
const s = ts[key] ?? {}
testSettings[key] = {
enabled: s.enabled !== false,
commissionType: s.commissionType ?? 'ratio',
commissionRate: s.commissionRate ?? 90,
commissionAmount: s.commissionAmount ?? 0,
noPayment: s.noPayment ?? false,
}
})
}
} catch (e) {}
}
// ── 保存设置
const saveSettings = async () => {
loading.value = true
try {
const res: any = await request.put('/superadmin/distribution/settings', {
enabled: distEnabled.value,
promoCenterTitle: promoCenterTitle.value || '推广中心',
minWithdraw: minWithdraw.value,
maxWithdraw: maxWithdraw.value,
requireAudit: requireAudit.value,
withdrawFee: withdrawFee.value,
testSettings: Object.fromEntries(
testTypeItems.map(({ key }) => [key, testSettings[key]])
),
})
if (res.code === 200) ElMessage.success('配置已保存')
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
loading.value = false
}
watch(activeTab, (t) => {
if (t === 'overview') loadOverview()
else if (t === 'withdrawals') loadWithdrawals()
else if (t === 'commissions') loadCommissions()
else if (t === 'settings') loadSettings()
})
watch(withFilter, () => { if (activeTab.value === 'withdrawals') loadWithdrawals() })
watch(commFilter, () => { if (activeTab.value === 'commissions') loadCommissions() })
const refresh = async () => {
if (activeTab.value === 'overview') await loadOverview()
else if (activeTab.value === 'withdrawals') await loadWithdrawals()
else if (activeTab.value === 'commissions') await loadCommissions()
ElMessage.success('数据已刷新')
}
onMounted(() => { loadOverview() })
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
h2 { font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 4px 0; }
.subtitle { font-size: 13px; color: #6b7280; margin: 0; }
}
.refresh-btn {
border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px;
font-size: 13px; color: #374151; height: 34px;
display: flex; align-items: center; gap: 6px;
&:hover { background-color: #f9fafb; }
}
}
.custom-tabs-container {
background-color: #f3f4f6; padding: 4px;
border-radius: 8px; display: flex; margin-bottom: 24px; width: 100%;
.custom-tabs {
display: flex; gap: 4px; width: 100%;
.tab-item {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 8px 0; font-size: 13px; color: #6b7280;
cursor: pointer; border-radius: 6px; transition: all 0.2s;
white-space: nowrap;
&:hover { color: #111827; }
&.active { background: #fff; color: #111827; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
}
}
}
.overview-section {
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: #fff; border-radius: 10px; padding: 20px;
display: flex; justify-content: space-between; align-items: center;
border: 1px solid #f3f4f6; box-shadow: 0 1px 2px rgba(0,0,0,.05);
.stat-label { font-size: 13px; color: #6b7280; margin-bottom: 6px; }
.stat-value { font-size: 24px; font-weight: 700; color: #111827; }
.stat-icon {
width: 48px; height: 48px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
&.purple { background: #ede9fe; color: #7c3aed; }
&.green { background: #d1fae5; color: #059669; }
&.orange { background: #fef3c7; color: #d97706; }
&.blue { background: #dbeafe; color: #2563eb; }
}
}
.two-cols {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
}
.small-card {
padding: 20px;
.card-title { font-size: 14px; font-weight: 600; color: #374151; margin-bottom: 16px; }
.dist-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid #f3f4f6;
span { font-size: 13px; color: #6b7280; }
strong { font-size: 15px; font-weight: 700; color: #111827; }
&:last-child { border-bottom: none; }
}
}
}
.table-section { .content-card { overflow: hidden; } }
.content-card {
background: #fff; border-radius: 10px;
border: 1px solid #f3f4f6; box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
.toolbar {
padding: 16px 20px;
display: flex; align-items: center; gap: 16px;
border-bottom: 1px solid #f3f4f6;
.filter-group {
display: flex; background: #f3f4f6; padding: 3px; border-radius: 6px; gap: 2px;
.filter-item {
padding: 4px 12px; font-size: 12px; color: #6b7280;
cursor: pointer; border-radius: 4px; transition: all .2s;
&.active { background: #7c3aed; color: #fff; font-weight: 500; }
}
}
}
.custom-table {
:deep(.el-table__header) th {
background: #f9fafb; color: #6b7280; font-weight: 500; font-size: 13px; padding: 12px 0;
}
}
.agent-cell {
display: flex; align-items: center; gap: 10px;
.agent-name { font-size: 13px; font-weight: 500; color: #111827; }
}
.empty-placeholder { padding: 60px; text-align: center; color: #9ca3af; font-size: 14px; }
/* 设置区 */
.settings-section { }
.settings-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
.settings-card {
background: #fff; border-radius: 10px; border: 1px solid #f3f4f6; padding: 24px;
.card-header { font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 20px; }
.card-header-row {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;
.card-header { margin-bottom: 0; }
}
.card-desc { font-size: 12px; color: #6b7280; margin: 0 0 20px; }
&.full-width-card { grid-column: 1 / -1; }
.setting-list { display: flex; flex-direction: column; gap: 12px; }
.ts-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
.ts-card {
background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
.ts-head {
display: flex; justify-content: space-between; align-items: center;
.ts-name { font-size: 13px; font-weight: 600; color: #374151; }
}
.ts-row {
display: flex; flex-direction: column; gap: 6px;
label { font-size: 12px; color: #6b7280; }
}
.ts-nopay {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; background: #ede9fe; border-radius: 6px; margin-top: 4px;
.ts-nopay-info {
.ts-nopay-title { font-size: 12px; font-weight: 600; color: #5b21b6; margin: 0; }
.ts-nopay-desc { font-size: 11px; color: #7c3aed; margin: 2px 0 0; }
}
}
.ts-disabled { font-size: 12px; color: #9ca3af; text-align: center; padding: 16px 0; }
}
}
.setting-row {
display: flex; justify-content: space-between; align-items: center;
padding: 16px; background: #f9fafb; border-radius: 8px;
.info {
.title { font-size: 14px; font-weight: 600; color: #111827; margin: 0; }
.desc { font-size: 12px; color: #6b7280; margin: 4px 0 0; }
}
.promo-title-input { width: 180px; flex-shrink: 0; }
}
.form-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px;
.form-item {
display: flex; flex-direction: column; gap: 8px;
label { font-size: 13px; font-weight: 500; color: #374151; }
}
.form-hint { font-size: 12px; color: #9ca3af; margin: 8px 0 0; }
}
}
.save-actions {
margin-top: 24px;
.save-btn { height: 42px; padding: 0 40px; border-radius: 8px; font-weight: 600; }
}
.w-full { width: 100%; }
@media (max-width: 1200px) {
.overview-section .stats-grid { grid-template-columns: repeat(2, 1fr); }
.overview-section .two-cols { grid-template-columns: 1fr; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,933 @@
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<div class="header-left">
<h2>财务管理</h2>
<p class="subtitle">收入成本及利润分析</p>
</div>
<div class="header-actions">
<el-button variant="outline" size="small" @click="exportData">
<el-icon class="mr-1"><Download /></el-icon>导出报表
</el-button>
</div>
</div>
<!-- 无已支付订单时的说明 -->
<el-alert
v-if="!overviewLoading && financialOverview.paidOrderCount === 0"
type="info"
show-icon
class="zero-hint"
title="当前暂无已支付订单,因此收入/成本/利润均为 0。"
>
<template #default>
请确认1小程序用户是否已完成支付2支付成功后是否调用了支付通知接口/api/payment/notify以将订单状态更新为已支付只有状态为已支付已完成的订单才会计入收入
</template>
</el-alert>
<!-- 核心财务指标金额接口返回分需 /100 -->
<div class="stats-grid" v-loading="overviewLoading">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总收入</div>
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalRevenue)) }}</div>
<div class="stat-trend">
<el-icon class="trend-icon"><ArrowUp /></el-icon>
<span class="trend-text positive">本月 {{ formatMoneyYuan(fenToYuan(financialOverview.monthRevenue)) }}</span>
</div>
</div>
<div class="stat-icon green">
<el-icon><Money /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总成本估算</div>
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalCost)) }}</div>
<div class="stat-trend">
<span class="trend-text">本月 {{ formatMoneyYuan(fenToYuan(financialOverview.monthCost)) }}</span>
</div>
</div>
<div class="stat-icon orange">
<el-icon><TrendCharts /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">净利润</div>
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(financialOverview.netProfit)) }}</div>
<div class="stat-trend">
<el-icon v-if="financialOverview.monthGrowth >= 0" class="trend-icon"><ArrowUp /></el-icon>
<el-icon v-else class="trend-icon down"><Bottom /></el-icon>
<span :class="['trend-text', financialOverview.monthGrowth >= 0 ? 'positive' : 'negative']">
{{ financialOverview.monthGrowth }}% 环比
</span>
</div>
</div>
<div class="stat-icon green">
<el-icon><Money /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">已支付订单数</div>
<div class="stat-value">{{ financialOverview.paidOrderCount }}</div>
<div class="stat-trend">
<span class="trend-text"></span>
</div>
</div>
<div class="stat-icon blue">
<el-icon><TrendCharts /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">利润率</div>
<div class="stat-value">{{ financialOverview.profitRate }}%</div>
<div class="profit-bar-container">
<div class="profit-bar-bg">
<div class="profit-bar-fill" :style="{ width: `${financialOverview.profitRate}%` }"></div>
</div>
</div>
</div>
<div class="stat-icon purple">
<el-icon><TrendCharts /></el-icon>
</div>
</div>
</div>
<!-- 收入和成本明细 Tabs -->
<div class="finance-content">
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="activeTab = tab.value"
>
{{ tab.label }}
</div>
</div>
</div>
<div class="tab-content-card">
<!-- 收入明细 -->
<div v-if="activeTab === 'revenue'" class="tab-content">
<div class="detail-card">
<div class="card-title">收入构成</div>
<div class="detail-list">
<div v-for="(item, index) in revenueDetails" :key="index" class="detail-item">
<div class="detail-content">
<div class="detail-header">
<span class="detail-name">{{ item.type }}</span>
<div class="detail-amounts">
<span class="detail-amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
<span class="detail-percent">{{ item.percent }}%</span>
</div>
</div>
<div class="progress-bar-bg">
<div
:class="['progress-bar-fill', `color-${index}`]"
:style="{ width: `${item.percent}%` }"
></div>
</div>
</div>
</div>
</div>
<!-- 总计 -->
<div class="detail-total">
<span class="total-label">总收入</span>
<span class="total-value">
{{ formatMoneyYuan(fenToYuan(revenueDetails.reduce((s: number, i: any) => s + i.amount, 0))) }}
</span>
</div>
</div>
</div>
<!-- 成本明细 -->
<div v-if="activeTab === 'cost'" class="tab-content">
<div class="detail-card">
<div class="card-title">成本构成估算</div>
<div class="detail-list">
<div v-for="(item, index) in costDetails" :key="index" class="detail-item">
<div class="detail-content">
<div class="detail-header">
<span class="detail-name">{{ item.type }}</span>
<div class="detail-amounts">
<span class="detail-amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
<span class="detail-percent">{{ item.percent }}%</span>
</div>
</div>
<div class="progress-bar-bg">
<div
class="progress-bar-fill color-cost"
:style="{ width: `${item.percent}%` }"
></div>
</div>
</div>
</div>
</div>
<!-- 总计 -->
<div class="detail-total">
<span class="total-label">总成本估算</span>
<span class="total-value">
{{ formatMoneyYuan(fenToYuan(costDetails.reduce((s: number, i: any) => s + i.amount, 0))) }}
</span>
</div>
<!-- 利润分析卡片 -->
<div class="profit-analysis">
<div class="profit-card">
<p class="profit-label">总收入</p>
<p class="profit-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalRevenue)) }}</p>
</div>
<div class="profit-card">
<p class="profit-label">总成本</p>
<p class="profit-value">{{ formatMoneyYuan(fenToYuan(financialOverview.totalCost)) }}</p>
</div>
<div class="profit-card highlight">
<p class="profit-label highlight-text">净利润</p>
<p class="profit-value highlight-value">{{ formatMoneyYuan(fenToYuan(financialOverview.netProfit)) }}</p>
</div>
</div>
</div>
</div>
<!-- 企业支付记录 -->
<div v-if="activeTab === 'records'" class="tab-content">
<div class="detail-card">
<div class="card-title">企业支付记录</div>
<div class="table-container" v-if="rechargeRecords.length > 0">
<table class="recharge-table">
<thead>
<tr>
<th class="text-left">订单号</th>
<th class="text-left">企业名称</th>
<th class="text-right">金额</th>
<th class="text-left">支付方式</th>
<th class="text-left">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="(record, index) in rechargeRecords" :key="index">
<td><span class="order-no">{{ record.orderNo || '—' }}</span></td>
<td>
<div class="enterprise-cell">
<el-icon class="enterprise-icon"><OfficeBuilding /></el-icon>
<span class="enterprise-name">{{ record.enterprise }}</span>
</div>
</td>
<td class="text-right">
<span class="amount-positive">+{{ formatMoneyYuan(fenToYuan(record.amount)) }}</span>
</td>
<td><span class="method-text">{{ record.method }}</span></td>
<td><span class="date-text">{{ record.date }}</span></td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">暂无企业支付记录</p>
</div>
<div class="pagination-wrap" v-if="rechargeTotal > rechargePageSize">
<el-pagination
v-model:current-page="rechargePage"
:page-size="rechargePageSize"
:total="rechargeTotal"
layout="prev, pager, next, total"
@current-change="loadRechargeRecords"
/>
</div>
</div>
</div>
<!-- 支付记录全部已支付订单 -->
<div v-if="activeTab === 'payments'" class="tab-content">
<div class="detail-card">
<div class="card-title">支付记录</div>
<div class="toolbar-row">
<el-input
v-model="paymentKeyword"
placeholder="订单号/用户ID"
clearable
class="search-input"
@keyup.enter="loadPaymentRecords"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="loadPaymentRecords">搜索</el-button>
</div>
<div class="table-container" v-if="paymentRecords.length > 0">
<table class="recharge-table">
<thead>
<tr>
<th class="text-left">订单号</th>
<th class="text-left">用户</th>
<th class="text-left">企业</th>
<th class="text-left">产品</th>
<th class="text-right">金额</th>
<th class="text-left">支付方式</th>
<th class="text-left">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="(record, index) in paymentRecords" :key="index">
<td><span class="order-no">{{ record.orderNo }}</span></td>
<td><span class="user-name">{{ record.userName }}</span></td>
<td>
<div class="enterprise-cell">
<el-icon v-if="record.enterprise && record.enterprise !== '个人'" class="enterprise-icon"><OfficeBuilding /></el-icon>
<span class="enterprise-name">{{ record.enterprise || '个人' }}</span>
</div>
</td>
<td><span class="product-type">{{ record.productType }}</span></td>
<td class="text-right">
<span class="amount-positive">+{{ formatMoneyYuan(fenToYuan(record.amount)) }}</span>
</td>
<td><span class="method-text">{{ record.method }}</span></td>
<td><span class="date-text">{{ record.date }}</span></td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">暂无支付记录</p>
</div>
<div class="pagination-wrap" v-if="paymentTotal > 0">
<el-pagination
v-model:current-page="paymentPage"
:page-size="paymentPageSize"
:total="paymentTotal"
layout="prev, pager, next, total"
@current-change="loadPaymentRecords"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Download, ArrowUp, Bottom, OfficeBuilding, Money, TrendCharts, Document, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import { formatMoneyYuan } from '@/utils/format'
const activeTab = ref('revenue')
const overviewLoading = ref(false)
const tabs = [
{ label: '收入明细', value: 'revenue' },
{ label: '成本明细', value: 'cost' },
{ label: '企业支付', value: 'records' },
{ label: '支付记录', value: 'payments' }
]
// 财务概览数据(金额为分,前端展示时 fenToYuan 转元)
const financialOverview = reactive({
totalRevenue: 0,
totalCost: 0,
netProfit: 0,
profitRate: 0,
monthRevenue: 0,
monthCost: 0,
monthProfit: 0,
monthGrowth: 0,
paidOrderCount: 0,
})
function fenToYuan(fen: number | undefined | null): number {
if (fen == null || !Number.isFinite(fen)) return 0
return Number(fen) / 100
}
interface DetailItem { type: string; amount: number; percent: number }
interface RechargeRecord { orderNo?: string; enterprise: string; amount: number; method: string; date: string }
interface PaymentRecord { orderNo: string; userName: string; enterprise: string; productType: string; amount: number; method: string; date: string }
// 收入明细
const revenueDetails = ref<DetailItem[]>([])
// 成本明细
const costDetails = ref<DetailItem[]>([])
// 企业支付记录
const rechargeRecords = ref<RechargeRecord[]>([])
const rechargePage = ref(1)
const rechargePageSize = ref(20)
const rechargeTotal = ref(0)
// 支付记录(全部)
const paymentRecords = ref<PaymentRecord[]>([])
const paymentPage = ref(1)
const paymentPageSize = ref(20)
const paymentTotal = ref(0)
const paymentKeyword = ref('')
// 加载财务概览
const loadFinancialOverview = async () => {
overviewLoading.value = true
try {
const response: any = await request.get('/superadmin/finance/overview')
if (response.code === 200 && response.data) {
Object.assign(financialOverview, response.data)
}
} catch (error: any) {
ElMessage.error('加载财务概览失败')
} finally {
overviewLoading.value = false
}
}
// 加载收入明细
const loadRevenueDetails = async () => {
try {
const response: any = await request.get('/superadmin/finance/revenue-details')
if (response.code === 200 && response.data) {
revenueDetails.value = response.data
}
} catch {
ElMessage.error('加载收入明细失败')
}
}
// 加载成本明细
const loadCostDetails = async () => {
try {
const response: any = await request.get('/superadmin/finance/cost-details')
if (response.code === 200 && response.data) {
costDetails.value = response.data
}
} catch {
ElMessage.error('加载成本明细失败')
}
}
// 加载企业支付记录
const loadRechargeRecords = async () => {
try {
const response: any = await request.get('/superadmin/finance/recharge-records', {
params: { page: rechargePage.value, pageSize: rechargePageSize.value }
})
if (response.code === 200 && response.data) {
rechargeRecords.value = response.data.list || []
rechargeTotal.value = response.data.total || 0
}
} catch {
ElMessage.error('加载企业支付记录失败')
}
}
// 加载支付记录(全部)
const loadPaymentRecords = async () => {
try {
const response: any = await request.get('/superadmin/finance/payment-records', {
params: {
page: paymentPage.value,
pageSize: paymentPageSize.value,
keyword: paymentKeyword.value
}
})
if (response.code === 200 && response.data) {
paymentRecords.value = response.data.list || []
paymentTotal.value = response.data.total || 0
}
} catch {
ElMessage.error('加载支付记录失败')
}
}
const exportData = async () => {
try {
const response: any = await request.post('/superadmin/finance/export')
if (response.code === 200) {
ElMessage.success('财务报表导出成功')
}
} catch (error: any) {
ElMessage.error(error.message || '导出失败')
}
}
onMounted(() => {
loadFinancialOverview()
loadRevenueDetails()
loadCostDetails()
loadRechargeRecords()
})
watch(activeTab, (tab) => {
if (tab === 'payments') loadPaymentRecords()
})
</script>
<style scoped lang="scss">
.page-container {
padding: 24px;
min-height: calc(100vh - 64px);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
h2 {
font-size: 24px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.zero-hint {
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.stat-info {
flex: 1;
.stat-label {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #111827;
line-height: 1;
margin-bottom: 8px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
.trend-icon {
font-size: 14px;
color: #22c55e;
}
.trend-text {
&.positive {
color: #22c55e;
}
&.negative {
color: #ef4444;
}
}
.trend-icon.down {
color: #ef4444;
}
}
.profit-bar-container {
margin-top: 8px;
.profit-bar-bg {
width: 100%;
height: 6px;
background-color: #e5e7eb;
border-radius: 9999px;
overflow: hidden;
.profit-bar-fill {
height: 100%;
background-color: #22c55e;
border-radius: 9999px;
transition: width 0.3s;
}
}
}
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
&.purple {
background-color: #f3e8ff;
color: #a855f7;
}
&.green {
background-color: #dcfce7;
color: #22c55e;
}
&.orange {
background-color: #fed7aa;
color: #f97316;
}
&.blue {
background-color: #dbeafe;
color: #3b82f6;
}
}
}
.finance-content {
display: flex;
flex-direction: column;
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
.tab-content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 32px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.tab-content {
.detail-card {
.card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 20px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.search-input {
max-width: 280px;
}
}
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.order-no {
font-size: 12px;
color: #374151;
font-family: ui-monospace, monospace;
}
.user-name {
font-size: 13px;
color: #111827;
}
.product-type {
font-size: 13px;
color: #6b7280;
}
}
}
.detail-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.detail-item {
.detail-content {
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.detail-name {
font-size: 14px;
font-weight: 500;
color: #111827;
}
.detail-amounts {
display: flex;
align-items: center;
gap: 12px;
.detail-amount {
font-size: 14px;
font-weight: 500;
color: #111827;
}
.detail-percent {
font-size: 12px;
color: #9ca3af;
width: 48px;
text-align: right;
}
}
}
.progress-bar-bg {
width: 100%;
height: 8px;
background-color: #f3f4f6;
border-radius: 9999px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.3s;
&.color-0 {
background-color: #6366f1;
}
&.color-1 {
background-color: #3b82f6;
}
&.color-2 {
background-color: #22c55e;
}
&.color-3 {
background-color: #f59e0b;
}
&.color-cost {
background-color: #f87171;
}
}
}
}
}
.detail-total {
padding-top: 16px;
border-top: 1px solid #f3f4f6;
display: flex;
align-items: center;
justify-content: space-between;
.total-label {
font-weight: 500;
color: #111827;
}
.total-value {
font-size: 18px;
font-weight: 700;
color: #111827;
}
}
.profit-analysis {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.profit-card {
padding: 16px;
background-color: #f9fafb;
border-radius: 12px;
text-align: center;
&.highlight {
background-color: #dcfce7;
}
.profit-label {
font-size: 12px;
color: #6b7280;
margin: 0 0 4px 0;
&.highlight-text {
color: #22c55e;
}
}
.profit-value {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
&.highlight-value {
color: #22c55e;
}
}
}
.table-container {
overflow-x: auto;
}
.recharge-table {
width: 100%;
border-collapse: collapse;
thead {
background-color: rgba(249, 250, 251, 0.8);
tr {
th {
padding: 12px 16px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
text-align: left;
&.text-left {
text-align: left;
}
&.text-right {
text-align: right;
}
}
}
}
tbody {
tr {
border-bottom: 1px solid #f3f4f6;
&:hover {
background-color: rgba(249, 250, 251, 0.5);
}
td {
padding: 12px 16px;
&.text-left {
text-align: left;
}
&.text-right {
text-align: right;
}
.enterprise-cell {
display: flex;
align-items: center;
gap: 8px;
.enterprise-icon {
font-size: 16px;
color: #9ca3af;
}
.enterprise-name {
font-size: 14px;
font-weight: 500;
color: #111827;
}
}
.amount-positive {
font-size: 14px;
font-weight: 500;
color: #22c55e;
}
.method-text,
.date-text {
font-size: 14px;
color: #6b7280;
}
}
}
}
}
.mr-1 {
margin-right: 4px;
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="card-header">
<h2 class="title">超级管理员登录</h2>
<p class="description">请输入您的超级管理员凭据</p>
</div>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
show-icon
class="error-alert"
>
{{ errorMessage }}
</el-alert>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@submit.prevent="handleLogin"
class="login-form"
>
<el-form-item prop="username">
<label class="form-label">用户名</label>
<el-input
v-model="loginForm.username"
placeholder="输入超级管理员用户名"
size="large"
clearable
class="form-input"
/>
</el-form-item>
<el-form-item prop="password">
<label class="form-label">密码</label>
<el-input
v-model="loginForm.password"
type="password"
placeholder="输入超级管理员密码"
size="large"
show-password
@keyup.enter="handleLogin"
class="form-input"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="login-button"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-form-item>
</el-form>
<div class="security-info">
<el-icon class="security-icon"><Lock /></el-icon>
<span>安全连接 | 仅限授权人员访问</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loginFormRef = ref<FormInstance>()
const loading = ref(false)
const errorMessage = ref('')
const loginForm = reactive({
username: '',
password: ''
})
const loginRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
errorMessage.value = ''
try {
const success = await authStore.superAdminLogin(loginForm.username, loginForm.password)
if (success) {
ElMessage.success('登录成功')
router.push('/superadmin/overview')
} else {
errorMessage.value = '用户名或密码错误'
}
} catch (error: any) {
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
} finally {
loading.value = false
}
} catch (error) {
console.log('表单验证失败:', error)
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f3f4f6;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 448px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
padding: 32px;
}
.card-header {
text-align: center;
margin-bottom: 24px;
.title {
font-size: 28px;
font-weight: 700;
color: #111827;
margin: 0 0 8px;
}
.description {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.error-alert {
margin-bottom: 20px;
}
.login-form {
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item__content) {
display: block;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-input {
:deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 12px 16px;
background-color: #f9fafb;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
background-color: #fff;
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
:deep(.el-input__inner) {
font-size: 15px;
color: #111827;
&::placeholder {
color: #9ca3af;
}
}
}
}
.login-button {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
background-color: #7c3aed;
border-color: #7c3aed;
margin-top: 8px;
&:hover {
background-color: #6d28d9;
border-color: #6d28d9;
}
&:active {
background-color: #5b21b6;
border-color: #5b21b6;
}
}
.security-info {
display: flex;
align-items: center;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
font-size: 12px;
color: #9ca3af;
gap: 6px;
.security-icon {
font-size: 14px;
color: #9ca3af;
}
}
</style>

View File

@@ -0,0 +1,824 @@
<template>
<div class="dashboard-container" v-loading="loading">
<!-- 数据概览 -->
<div class="overview-section">
<div class="section-header">
<h2 class="section-title">数据概览</h2>
<p class="section-subtitle">超管端全局数据一览</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">企业总数</div>
<div class="stat-value">{{ stats.totalEnterprises }}</div>
<div class="stat-trend">
<span class="trend-text">+{{ stats.newEnterprises }} 本月新增</span>
</div>
</div>
<div class="stat-icon purple">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总用户数</div>
<div class="stat-value">{{ stats.totalUsers.toLocaleString() }}</div>
<div class="stat-trend">
<span class="trend-text">+{{ stats.newUsers }} 本月新增 · 注册 {{ stats.totalRegisteredUsers?.toLocaleString() ?? 0 }}</span>
</div>
</div>
<div class="stat-icon purple">
<el-icon><User /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">总收入</div>
<div class="stat-value">{{ formatMoneyYuan(fenToYuan(stats.totalRevenue)) }}</div>
<div class="stat-trend">
<span class="trend-text">本月 {{ formatMoneyYuan(fenToYuan(stats.monthRevenue)) }}</span>
<el-icon v-if="stats.revenueGrowth > 0" class="trend-icon"><ArrowUp /></el-icon>
<span class="trend-text" :class="{ positive: stats.revenueGrowth > 0 }">
{{ stats.revenueGrowth > 0 ? '+' : '' }}{{ stats.revenueGrowth }}% 环比
</span>
</div>
</div>
<div class="stat-icon green">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">已支付订单</div>
<div class="stat-value">{{ (stats.paidOrderCount ?? 0).toLocaleString() }}</div>
<div class="stat-trend">
<span class="trend-text">累计已支付笔数</span>
</div>
</div>
<div class="stat-icon green">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">测试总量</div>
<div class="stat-value">{{ stats.totalTests.toLocaleString() }}</div>
<div class="stat-trend">
<span class="trend-text">+{{ stats.newTests }} 本月</span>
</div>
</div>
<div class="stat-icon orange">
<el-icon><TrendCharts /></el-icon>
</div>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div class="quick-actions-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><Lightning /></el-icon>
快捷操作
</h2>
</div>
<div class="actions-grid">
<div class="action-card blue" @click="handleAction('create-enterprise')">
<div class="action-icon">
<el-icon><Plus /></el-icon>
</div>
<div class="action-label">新建企业</div>
</div>
<div class="action-card blue" @click="handleAction('enterprise-management')">
<div class="action-icon">
<el-icon><Document /></el-icon>
</div>
<div class="action-label">企业管理</div>
</div>
<div class="action-card green" @click="handleAction('finance')">
<div class="action-icon">
<el-icon><Document /></el-icon>
</div>
<div class="action-label">财务管理</div>
</div>
<div class="action-card orange" @click="handleAction('pricing')">
<div class="action-icon">
<el-icon><PriceTag /></el-icon>
</div>
<div class="action-label">价格管理</div>
</div>
<div class="action-card purple" @click="handleAction('users')">
<div class="action-icon">
<el-icon><User /></el-icon>
</div>
<div class="action-label">用户数据</div>
</div>
<div class="action-card gray" @click="handleAction('settings')">
<div class="action-icon">
<el-icon><Setting /></el-icon>
</div>
<div class="action-label">系统设置</div>
</div>
</div>
</div>
<!-- 测试趋势折线图 -->
<div class="trend-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
测试趋势
</h2>
<p class="section-subtitle">最近 14 天人脸分析MBTIPDPDISC 等关键测试的完成情况</p>
</div>
<div class="trend-chart-wrapper" v-if="testTrends.length">
<VChart class="trend-chart-echarts" :option="chartOption" autoresize />
</div>
<div class="empty-placeholder" v-else>
<el-icon class="empty-icon"><TrendCharts /></el-icon>
<p class="empty-text">暂无测试趋势数据</p>
</div>
</div>
<!-- 底部两列布局 -->
<div class="bottom-section">
<!-- 最近动态 -->
<div class="recent-dynamics-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
最近动态
</h2>
</div>
<div class="dynamics-list" v-if="recentDynamics.length > 0">
<div
v-for="(item, index) in recentDynamics"
:key="index"
class="dynamics-item"
>
<div class="dynamics-icon">
<el-icon><component :is="iconMap[item.icon] || Document" /></el-icon>
</div>
<div class="dynamics-content">
<div class="dynamics-text">{{ item.text }}</div>
</div>
<div class="dynamics-time">{{ item.time }}</div>
</div>
</div>
<!-- 空数据占位图 -->
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">暂无最近动态</p>
</div>
</div>
<!-- 企业活跃排行 -->
<div class="ranking-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
企业活跃排行
</h2>
<el-button link class="view-all-btn" @click="handleViewAll">查看全部</el-button>
</div>
<div class="ranking-list" v-if="enterpriseRanking.length > 0">
<div
v-for="(item, index) in enterpriseRanking"
:key="index"
class="ranking-item"
>
<div class="ranking-number" :class="{ 'top-one': index === 0 }">
{{ index + 1 }}
</div>
<div class="ranking-content">
<div class="ranking-name">{{ item.name }}</div>
<div class="ranking-stats">
<span class="test-count">{{ item.tests }}次测试</span>
<span class="amount">{{ formatMoneyYuan(fenToYuan(item.amount)) }}</span>
</div>
</div>
</div>
</div>
<!-- 空数据占位图 -->
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><TrendCharts /></el-icon>
<p class="empty-text">暂无企业排行数据</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
User,
Document,
TrendCharts,
ArrowUp,
Lightning,
Plus,
PriceTag,
Setting
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { formatMoneyYuan } from '@/utils/format'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
const router = useRouter()
const loading = ref(false)
// 金额为分,转元展示
function fenToYuan(fen: number | undefined | null): number {
if (fen == null || Number.isNaN(Number(fen))) return 0
return Number(fen) / 100
}
const stats = reactive({
totalEnterprises: 0,
newEnterprises: 0,
totalRegisteredUsers: 0,
totalUsers: 0,
newUsers: 0,
totalRevenue: 0,
monthRevenue: 0,
revenueGrowth: 0,
paidOrderCount: 0,
totalTests: 0,
newTests: 0
})
interface DynamicItem { icon: string; text: string; time: string }
interface RankingItem { name: string; tests: number; amount: number }
const recentDynamics = ref<DynamicItem[]>([])
const enterpriseRanking = ref<RankingItem[]>([])
type TrendPoint = { date: string; face: number; mbti: number; pdp: number; disc: number; total: number }
const testTrends = ref<TrendPoint[]>([])
const chartOption = computed(() => {
const dates = testTrends.value.map(d => d.date.slice(5))
return {
tooltip: { trigger: 'axis' },
legend: {
data: ['人脸分析', 'MBTI', 'PDP', 'DISC'],
bottom: 0
},
grid: {
left: 40,
right: 20,
top: 30,
bottom: 40
},
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280' }
},
yAxis: {
type: 'value',
minInterval: 1,
axisLine: { lineStyle: { color: '#e5e7eb' } },
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#6b7280' }
},
series: [
{
name: '人脸分析',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#22c55e' },
data: testTrends.value.map(d => d.face)
},
{
name: 'MBTI',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#3b82f6' },
data: testTrends.value.map(d => d.mbti)
},
{
name: 'PDP',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#f97316' },
data: testTrends.value.map(d => d.pdp)
},
{
name: 'DISC',
type: 'line',
smooth: true,
showSymbol: false,
itemStyle: { color: '#6366f1' },
data: testTrends.value.map(d => d.disc)
}
]
}
})
// 图标映射
const iconMap: Record<string, any> = {
Document,
User,
TrendCharts
}
// 加载数据概览
const loadOverview = async () => {
loading.value = true
try {
const response: any = await request.get('/superadmin/overview')
if (response.code === 200 && response.data) {
Object.assign(stats, response.data)
}
} catch (error: any) {
console.error('加载数据概览失败:', error)
} finally {
loading.value = false
}
}
// 加载最近动态
const loadRecentDynamics = async () => {
try {
const response: any = await request.get('/superadmin/overview/recent-dynamics', {
params: { limit: 10 }
})
if (response.code === 200 && response.data) {
recentDynamics.value = response.data
}
} catch (error: any) {
console.error('加载最近动态失败:', error)
}
}
// 加载企业排行
const loadEnterpriseRanking = async () => {
try {
const response: any = await request.get('/superadmin/overview/enterprise-ranking', {
params: { limit: 5 }
})
if (response.code === 200 && response.data) {
enterpriseRanking.value = response.data.map((item: any) => ({
name: item.name,
tests: item.tests,
amount: item.amount
}))
}
} catch (error: any) {
console.error('加载企业排行失败:', error)
}
}
// 加载测试趋势
const loadTestTrends = async () => {
try {
const response: any = await request.get('/superadmin/overview/test-trends', {
params: { days: 14 }
})
if (response.code === 200 && response.data) {
testTrends.value = response.data
}
} catch (error: any) {
console.error('加载测试趋势失败:', error)
}
}
const handleAction = (action: string) => {
const routes: Record<string, string> = {
'create-enterprise': '/superadmin/enterprises',
'enterprise-management': '/superadmin/enterprises',
'finance': '/superadmin/finance',
'pricing': '/superadmin/pricing',
'users': '/superadmin/users',
'settings': '/superadmin/settings'
}
if (routes[action]) {
router.push(routes[action])
}
}
const handleViewAll = () => {
router.push('/superadmin/enterprises')
}
onMounted(() => {
loadOverview()
loadRecentDynamics()
loadEnterpriseRanking()
loadTestTrends()
})
</script>
<style scoped lang="scss">
.dashboard-container {
padding: 24px;
background-color: #f9fafb;
min-height: 100vh;
}
.section-header {
margin-bottom: 20px;
.section-title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
.title-icon {
font-size: 20px;
color: #6b7280;
}
}
.section-subtitle {
font-size: 13px;
color: #6b7280;
margin: 4px 0 0 0;
}
}
.overview-section {
margin-bottom: 28px;
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.stat-info {
flex: 1;
.stat-label {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #111827;
line-height: 1;
margin-bottom: 8px;
}
.stat-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
.trend-icon {
font-size: 14px;
color: #22c55e;
}
.trend-text {
&.positive {
color: #22c55e;
}
}
}
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
&.purple {
background-color: #f3e8ff;
color: #a855f7;
}
&.green {
background-color: #dcfce7;
color: #22c55e;
}
&.orange {
background-color: #fed7aa;
color: #f97316;
}
}
}
}
.quick-actions-section {
margin-bottom: 28px;
.actions-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
.action-card {
background: #fff;
border-radius: 10px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
}
.action-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #fff;
}
.action-label {
font-size: 13px;
font-weight: 500;
color: #111827;
text-align: center;
}
&.blue .action-icon {
background-color: #3b82f6;
}
&.green .action-icon {
background-color: #22c55e;
}
&.orange .action-icon {
background-color: #f97316;
}
&.purple .action-icon {
background-color: #a855f7;
}
&.gray .action-icon {
background-color: #6b7280;
}
}
}
.trend-section {
margin-bottom: 28px;
background: #fff;
border-radius: 10px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
.trend-chart-wrapper {
margin-top: 8px;
}
.trend-chart-echarts {
width: 100%;
height: 260px;
}
}
.bottom-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.recent-dynamics-section,
.ranking-section {
background: #fff;
border-radius: 10px;
padding: 20px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f3f4f6;
.view-all-btn {
font-size: 13px;
color: #6b7280;
padding: 0;
&:hover {
color: #ef4444;
}
}
}
}
.dynamics-list {
.dynamics-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
.dynamics-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 16px;
flex-shrink: 0;
}
.dynamics-content {
flex: 1;
.dynamics-text {
font-size: 14px;
color: #111827;
line-height: 1.5;
}
}
.dynamics-time {
font-size: 12px;
color: #9ca3af;
flex-shrink: 0;
}
}
}
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #9ca3af;
.empty-icon {
font-size: 64px;
color: #d1d5db;
margin-bottom: 16px;
}
.empty-text {
font-size: 14px;
color: #9ca3af;
margin: 0;
}
}
.ranking-list {
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
.ranking-number {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
&.top-one {
background-color: #fef3c7;
color: #f59e0b;
}
}
.ranking-content {
flex: 1;
.ranking-name {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.ranking-stats {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #6b7280;
.test-count {
color: #6b7280;
}
.amount {
color: #111827;
font-weight: 600;
}
}
}
}
}
@media (max-width: 1400px) {
.quick-actions-section .actions-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1200px) {
.overview-section .stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.bottom-section {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.overview-section .stats-grid,
.quick-actions-section .actions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,985 @@
<template>
<div class="poster-editor-root">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-group">
<el-button size="small" @click="addElement('text')">
<el-icon><EditPen /></el-icon> 添加文字
</el-button>
<el-button size="small" @click="addElement('nickname')">
<el-icon><User /></el-icon> 用户昵称
</el-button>
<el-button size="small" @click="addElement('avatar')">
<el-icon><Avatar /></el-icon> 用户头像
</el-button>
<el-button size="small" @click="addElement('qrcode')">
<el-icon><Grid /></el-icon> 小程序码
</el-button>
<el-button size="small" @click="triggerImageAdd">
<el-icon><Picture /></el-icon> 上传图片
</el-button>
<el-divider direction="vertical" />
<el-button size="small" type="warning" plain @click="addElement('mbti')">MBTI</el-button>
<el-button size="small" type="success" plain @click="addElement('pdp')">PDP</el-button>
<el-button size="small" type="danger" plain @click="addElement('disc')">DISC</el-button>
</div>
<div class="toolbar-right">
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
<el-button size="small" @click="loadConfig">重置</el-button>
<el-button size="small" type="primary" color="#6366f1" :loading="saving" @click="saveConfig">
保存配置
</el-button>
</div>
</div>
<!-- 主体图层 | 画布 | 属性 -->
<div class="editor-body">
<!-- 左侧图层面板 -->
<div class="editor-layers">
<div class="panel-title">图层</div>
<div class="layers-list">
<div
v-for="el in [...elements].reverse()"
:key="el.id"
:class="['layer-item', { active: selectedId === el.id }]"
@click="selectedId = el.id"
>
<el-icon class="layer-icon">
<component :is="typeIconMap[el.type]" />
</el-icon>
<span class="layer-name">{{ typeLabel(el) }}</span>
<div class="layer-actions">
<el-icon class="action-icon" @click.stop="moveLayer(el.id, -1)" title="上移"><ArrowUp /></el-icon>
<el-icon class="action-icon" @click.stop="moveLayer(el.id, 1)" title="下移"><ArrowDown /></el-icon>
<el-icon class="action-icon danger" @click.stop="removeElement(el.id)"><Delete /></el-icon>
</div>
</div>
<div v-if="elements.length === 0" class="layers-empty">暂无元素</div>
</div>
</div>
<!-- 中间画布 -->
<div class="editor-canvas-area" @click.self="selectedId = null">
<div class="canvas-scale-wrap">
<div
class="poster-canvas"
:style="posterCanvasStyle"
@click.self="selectedId = null"
>
<!-- 背景图点击空白处取消选择 -->
<img v-if="config.bgImage" :src="config.bgImage" class="canvas-bg-img" @click="selectedId = null" />
<!-- 无背景图时的可点击底衬 -->
<div
v-else
class="canvas-bg-click"
@click="selectedId = null"
/>
<!-- 各元素configReady 后才渲染避免组件初始化覆盖数据 -->
<Vue3DraggableResizable
v-for="el in elements"
v-if="configReady"
:key="`${el.id}_${configVersion}`"
:initW="el.w"
:initH="el.h"
:x="el.x"
:y="el.y"
:w="el.w"
:h="el.h"
:active="selectedId === el.id"
:parent="true"
:draggable="true"
:resizable="true"
:min-w="20"
:min-h="20"
class-name-active="vdr-active"
@update:x="el.x = Math.round($event)"
@update:y="el.y = Math.round($event)"
@update:w="el.w = Math.round($event)"
@update:h="el.h = Math.round($event)"
@activated="selectedId = el.id"
>
<!-- 文字 -->
<div v-if="el.type === 'text'" class="el-text" :style="textElemStyle(el)">
{{ el.content || '文字内容' }}
</div>
<!-- 昵称占位 -->
<div v-else-if="el.type === 'nickname'" class="el-text el-nickname" :style="textElemStyle(el)">
用户昵称
</div>
<!-- 头像占位 -->
<div v-else-if="el.type === 'avatar'" class="el-placeholder" :style="shapeElemStyle(el)">
<el-icon><Avatar /></el-icon>
<span>头像</span>
</div>
<!-- 小程序码占位 -->
<div v-else-if="el.type === 'qrcode'" class="el-placeholder el-qrcode" :style="shapeElemStyle(el)">
<el-icon><Grid /></el-icon>
<span>小程序码</span>
</div>
<!-- 上传图片 -->
<div v-else-if="el.type === 'image'" class="el-placeholder el-image" :style="shapeElemStyle(el)">
<img v-if="el.url" :src="el.url" :style="{ borderRadius: el.shape === 'circle' ? '50%' : '0', width: '100%', height: '100%', objectFit: 'cover' }" />
<template v-else>
<el-icon><Picture /></el-icon>
<span>图片</span>
</template>
</div>
<!-- MBTI 结果 -->
<div v-else-if="el.type === 'mbti'" class="el-text el-test-result mbti-result" :style="textElemStyle(el)">
{{ el.content || 'INTJ' }}
</div>
<!-- PDP 结果 -->
<div v-else-if="el.type === 'pdp'" class="el-text el-test-result pdp-result" :style="textElemStyle(el)">
{{ el.content || '猫头鹰' }}
</div>
<!-- DISC 结果 -->
<div v-else-if="el.type === 'disc'" class="el-text el-test-result disc-result" :style="textElemStyle(el)">
{{ el.content || 'C型' }}
</div>
</Vue3DraggableResizable>
</div>
</div>
<div class="canvas-hint">画布尺寸375 × 667 px标准小程序屏幕</div>
</div>
<!-- 右侧属性面板 -->
<div class="editor-props">
<!-- 无选中显示画布设置 -->
<template v-if="!selectedElement">
<div class="panel-title">画布设置</div>
<div class="prop-item">
<label class="prop-label">背景颜色</label>
<el-color-picker v-model="config.bgColor" show-alpha />
</div>
<div class="prop-item">
<label class="prop-label">背景图片</label>
<div v-if="config.bgImage" class="img-preview-row">
<img :src="config.bgImage" class="img-preview" />
<el-button size="small" type="danger" plain @click="config.bgImage = ''">清除</el-button>
</div>
<el-button size="small" :loading="uploadingBg" @click="triggerBgUpload">
<el-icon><Upload /></el-icon>
{{ config.bgImage ? '更换背景' : '上传背景' }}
</el-button>
</div>
<div class="props-tip">点击画布上的元素以编辑属性</div>
</template>
<!-- 有选中显示元素属性 -->
<template v-else>
<div class="panel-title">
{{ typeLabel(selectedElement) }}
<el-icon class="delete-icon" @click="removeElement(selectedElement.id)" title="删除"><Delete /></el-icon>
</div>
<!-- 文字内容text 类型 -->
<div v-if="selectedElement.type === 'text'" class="prop-item">
<label class="prop-label">文字内容</label>
<el-input v-model="selectedElement.content" type="textarea" :rows="2" placeholder="请输入文字" />
</div>
<!-- 占位文字mbti / pdp / disc -->
<div v-if="['mbti','pdp','disc'].includes(selectedElement.type)" class="prop-item">
<label class="prop-label">预览占位文字</label>
<el-input v-model="selectedElement.content" placeholder="如INTJ" size="small" />
<span class="form-hint">实际生成时自动读取用户最近测试结果</span>
</div>
<!-- 字体/字号/颜色/加粗/居中text / nickname / mbti / pdp / disc -->
<template v-if="['text','nickname','mbti','pdp','disc'].includes(selectedElement.type)">
<div class="prop-item">
<label class="prop-label">字体</label>
<el-select
v-model="selectedElement.fontFamily"
placeholder="默认字体"
size="small"
clearable
>
<el-option
v-for="f in fontList"
:key="f.key"
:label="f.name"
:value="f.key"
/>
</el-select>
<span v-if="serverFontsLoaded && fontList.length === 0" class="form-hint" style="color:#ef4444">
服务器未安装字体请上传字体到 public/fonts/
</span>
<span v-else-if="selectedElement?.fontFamily && !fontList.find(f => f.key === selectedElement?.fontFamily)" class="form-hint" style="color:#f59e0b">
当前字体在服务器上不可用将使用默认字体
</span>
</div>
<div class="prop-item">
<label class="prop-label">字号 (px)</label>
<div class="font-size-row">
<el-slider
:model-value="selectedElement.fontSize ?? 16"
:min="10"
:max="120"
:show-tooltip="false"
@input="onFontSizeChange"
style="flex:1"
/>
<el-input-number
:model-value="selectedElement.fontSize ?? 16"
:min="10"
:max="200"
:step="1"
size="small"
controls-position="right"
@change="onFontSizeChange"
style="width:80px;margin-left:8px"
/>
</div>
</div>
<div class="prop-item">
<label class="prop-label">颜色</label>
<el-color-picker v-model="selectedElement.color" show-alpha />
</div>
<div class="prop-item inline">
<label class="prop-label">加粗</label>
<el-switch v-model="selectedElement.bold" />
</div>
<div class="prop-item">
<label class="prop-label">对齐</label>
<el-radio-group v-model="selectedElement.align" size="small">
<el-radio-button value="left">左对齐</el-radio-button>
<el-radio-button value="center">居中</el-radio-button>
<el-radio-button value="right">右对齐</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 形状avatar / image -->
<template v-if="['avatar','image'].includes(selectedElement.type)">
<div class="prop-item">
<label class="prop-label">形状</label>
<el-radio-group v-model="selectedElement.shape" size="small">
<el-radio-button label="circle">圆形</el-radio-button>
<el-radio-button label="square">正方形</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 图片上传image -->
<div v-if="selectedElement.type === 'image'" class="prop-item">
<label class="prop-label">图片</label>
<div v-if="selectedElement.url" class="img-preview-row">
<img :src="selectedElement.url" class="img-preview" />
<el-button size="small" type="danger" plain @click="selectedElement.url = ''">清除</el-button>
</div>
<el-button size="small" :loading="uploadingImg" @click="triggerElementImageUpload">
<el-icon><Upload /></el-icon>
{{ selectedElement.url ? '更换图片' : '上传图片' }}
</el-button>
</div>
<!-- 位置与大小 -->
<div class="props-section-title">位置与大小</div>
<div class="prop-row">
<div class="prop-item half">
<label class="prop-label">X</label>
<el-input-number v-model="selectedElement.x" :min="0" size="small" controls-position="right" />
</div>
<div class="prop-item half">
<label class="prop-label">Y</label>
<el-input-number v-model="selectedElement.y" :min="0" size="small" controls-position="right" />
</div>
</div>
<div class="prop-row">
<div class="prop-item half">
<label class="prop-label"></label>
<el-input-number v-model="selectedElement.w" :min="20" size="small" controls-position="right" />
</div>
<div class="prop-item half">
<label class="prop-label"></label>
<el-input-number v-model="selectedElement.h" :min="20" size="small" controls-position="right" />
</div>
</div>
</template>
</div>
</div>
<!-- 隐藏文件输入 -->
<input ref="bgInputRef" type="file" accept="image/*" style="display:none" @change="onBgFileChange" />
<input ref="imgInputRef" type="file" accept="image/*" style="display:none" @change="onImgFileChange" />
<input ref="addImgInputRef" type="file" accept="image/*" style="display:none" @change="onAddImgFileChange" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import Vue3DraggableResizable from 'vue3-draggable-resizable'
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css'
import {
EditPen, User, Avatar, Grid, Picture, Delete, Upload,
ArrowUp, ArrowDown, TrophyBase, Odometer, DataAnalysis
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import axios from 'axios'
// ────── 类型 ──────
type ElemType = 'text' | 'nickname' | 'avatar' | 'qrcode' | 'image' | 'mbti' | 'pdp' | 'disc'
type Shape = 'circle' | 'square'
type TextAlign = 'left' | 'center' | 'right'
interface PosterElement {
id: string
type: ElemType
x: number
y: number
w: number
h: number
// text / nickname
content?: string
fontSize?: number
color?: string
bold?: boolean
align?: TextAlign
fontFamily?: string
// avatar / image
shape?: Shape
// image only
url?: string
}
interface FontItem {
key: string
name: string
available?: boolean
}
interface PosterConfig {
bgColor: string
bgImage: string
elements: PosterElement[]
}
// ────── 状态 ──────
const saving = ref(false)
const uploadingBg = ref(false)
const uploadingImg = ref(false)
const selectedId = ref<string | null>(null)
const configReady = ref(false)
const configVersion = ref(0)
const fontList = ref<FontItem[]>([])
const serverFontsLoaded = ref(false)
const bgInputRef = ref<HTMLInputElement | null>(null)
const imgInputRef = ref<HTMLInputElement | null>(null)
const addImgInputRef = ref<HTMLInputElement | null>(null)
const config = reactive<PosterConfig>({
bgColor: '#ffffff',
bgImage: '',
elements: []
})
const elements = computed(() => config.elements)
const selectedElement = computed(() =>
selectedId.value ? config.elements.find(e => e.id === selectedId.value) ?? null : null
)
// ────── 工具映射 ──────
const typeIconMap: Record<ElemType, any> = {
text: EditPen,
nickname: User,
avatar: Avatar,
qrcode: Grid,
image: Picture,
mbti: TrophyBase,
pdp: Odometer,
disc: DataAnalysis
}
const typeLabel = (el: PosterElement) => {
const labels: Record<ElemType, string> = {
text: `文字:${el.content?.slice(0, 6) || '文字内容'}`,
nickname: '用户昵称',
avatar: '用户头像',
qrcode: '小程序码',
image: '图片',
mbti: `MBTI${el.content || 'INTJ'}`,
pdp: `PDP${el.content || '猫头鹰'}`,
disc: `DISC${el.content || 'C型'}`
}
return labels[el.type]
}
// ────── 画布样式 ──────
const posterCanvasStyle = computed(() => ({
width: '375px',
height: '667px',
backgroundColor: config.bgColor,
position: 'relative' as const,
overflow: 'hidden',
flexShrink: 0
}))
// ────── 字体 CSS 映射(用于画布预览近似渲染) ──────
const fontCssMap: Record<string, string> = {
'noto-sans': '"Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif',
'noto-serif': '"Noto Serif SC", "Source Han Serif SC", "STSong", "SimSun", serif',
'alimama': '"AlimamaFangYuanTiVF", "PingFang SC", "Microsoft YaHei", sans-serif',
'wqy-microhei': '"WenQuanYi Micro Hei", "Noto Sans SC", "Microsoft YaHei", sans-serif',
'simhei': '"SimHei", "Heiti SC", sans-serif',
'msyh': '"Microsoft YaHei", "PingFang SC", sans-serif',
}
const getFontCss = (fontKey?: string) => {
if (!fontKey) return undefined
return fontCssMap[fontKey] ?? undefined
}
// ────── 元素样式 ──────
const alignToJustify: Record<TextAlign, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end'
}
const textElemStyle = (el: PosterElement) => {
const align = el.align ?? 'left'
return {
fontSize: `${el.fontSize ?? 16}px`,
color: el.color ?? '#333333',
fontWeight: el.bold ? 'bold' : 'normal',
textAlign: align,
justifyContent: alignToJustify[align],
fontFamily: getFontCss(el.fontFamily),
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
boxSizing: 'border-box' as const,
wordBreak: 'break-all' as const,
lineHeight: 1.4,
userSelect: 'none' as const
}
}
const shapeElemStyle = (el: PosterElement) => ({
width: '100%',
height: '100%',
borderRadius: el.shape === 'circle' ? '50%' : '4px',
overflow: 'hidden'
})
// ────── 添加元素 ──────
let idCounter = 1
const genId = () => `el_${Date.now()}_${idCounter++}`
const addElement = (type: ElemType) => {
const defaults: Record<ElemType, Partial<PosterElement>> = {
text: { w: 160, h: 40, content: '文字内容', fontSize: 18, color: '#333333', bold: false, align: 'left' },
nickname: { w: 150, h: 36, fontSize: 16, color: '#333333', bold: false, align: 'left' },
avatar: { w: 80, h: 80, shape: 'circle' },
qrcode: { w: 120, h: 120 },
image: { w: 100, h: 100, shape: 'square', url: '' },
mbti: { w: 80, h: 32, content: 'INTJ', fontSize: 16, color: '#6366f1', bold: true, align: 'center' },
pdp: { w: 80, h: 32, content: '猫头鹰', fontSize: 16, color: '#10b981', bold: true, align: 'center' },
disc: { w: 80, h: 32, content: 'C型', fontSize: 16, color: '#f43f5e', bold: true, align: 'center' }
}
const el: PosterElement = {
id: genId(),
type,
x: 100,
y: 100,
w: 100,
h: 100,
...defaults[type]
}
config.elements.push(el)
selectedId.value = el.id
}
// ────── 属性修改 ──────
const onFontSizeChange = (val: number | number[] | undefined) => {
const el = selectedElement.value
const v = Array.isArray(val) ? val[0] : val
if (el && typeof v === 'number' && v >= 10) {
el.fontSize = v
}
}
// ────── 删除/移动图层 ──────
const removeElement = (id: string) => {
const idx = config.elements.findIndex(e => e.id === id)
if (idx !== -1) config.elements.splice(idx, 1)
if (selectedId.value === id) selectedId.value = null
}
const moveLayer = (id: string, dir: -1 | 1) => {
const arr = config.elements
const idx = arr.findIndex(e => e.id === id)
const newIdx = idx + dir
if (newIdx < 0 || newIdx >= arr.length) return
const tmp = arr[idx]
arr[idx] = arr[newIdx]
arr[newIdx] = tmp
}
// ────── 上传工具 ──────
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('file', file)
const token = localStorage.getItem('authToken')
const baseURL = import.meta.env.VITE_API_BASE_URL
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
: `${import.meta.env.VITE_API_BASE_URL}/api/v1`)
: '/api/v1'
const res = await axios.post(`${baseURL}/superadmin/upload/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(token ? { Authorization: `Bearer ${token}` } : {})
}
})
if (res.data?.data?.url) return res.data.data.url
throw new Error(res.data?.message || '上传失败')
}
// 背景上传
const triggerBgUpload = () => bgInputRef.value?.click()
const onBgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
uploadingBg.value = true
try {
config.bgImage = await uploadFile(file)
ElMessage.success('背景图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingBg.value = false
if (bgInputRef.value) bgInputRef.value.value = ''
}
}
// 图片元素上传(更换已有图片元素)
const triggerElementImageUpload = () => imgInputRef.value?.click()
const onImgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !selectedElement.value) return
uploadingImg.value = true
try {
selectedElement.value.url = await uploadFile(file)
ElMessage.success('图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingImg.value = false
if (imgInputRef.value) imgInputRef.value.value = ''
}
}
// 添加图片元素时先上传
const triggerImageAdd = () => addImgInputRef.value?.click()
const onAddImgFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
uploadingImg.value = true
try {
const url = await uploadFile(file)
const el: PosterElement = {
id: genId(),
type: 'image',
x: 100,
y: 100,
w: 120,
h: 120,
shape: 'square',
url
}
config.elements.push(el)
selectedId.value = el.id
ElMessage.success('图片上传成功')
} catch (err: any) {
ElMessage.error(err.message || '上传失败')
} finally {
uploadingImg.value = false
if (addImgInputRef.value) addImgInputRef.value.value = ''
}
}
// ────── 读取 / 保存配置 ──────
const loadConfig = async () => {
configReady.value = false
selectedId.value = null
try {
const res = await request.get<any>('/superadmin/settings/poster')
const data = res?.data?.poster
if (data) {
config.bgColor = data.bgColor ?? '#ffffff'
config.bgImage = data.bgImage ?? ''
config.elements = (data.elements ?? []).map((el: any) => {
// 兼容旧数据center: true → align: 'center'
const align = el.align ?? (el.center ? 'center' : 'left')
const { center: _center, ...rest } = el
return {
...rest,
align,
x: Number(el.x) || 0,
y: Number(el.y) || 0,
w: Math.max(20, Number(el.w) || 100),
h: Math.max(20, Number(el.h) || 100),
}
})
}
} catch {
// 未配置时静默失败
}
configVersion.value++
await nextTick()
configReady.value = true
}
const saveConfig = async () => {
saving.value = true
try {
await request.put('/superadmin/settings/poster', {
bgColor: config.bgColor,
bgImage: config.bgImage,
elements: config.elements
})
ElMessage.success('海报配置已保存')
} catch (err: any) {
ElMessage.error(err.message || '保存失败')
} finally {
saving.value = false
}
}
const loadFonts = async () => {
try {
const res = await request.get<any>('/superadmin/settings/fonts')
const remote = res?.data?.fonts
serverFontsLoaded.value = true
if (Array.isArray(remote)) {
fontList.value = remote
}
} catch {
serverFontsLoaded.value = false
fontList.value = [
{ key: 'noto-sans', name: '思源黑体' },
{ key: 'noto-serif', name: '思源宋体' },
{ key: 'alimama', name: '阿里妈妈方圆体' },
{ key: 'wqy-microhei', name: '文泉驿微米黑' },
]
}
}
onMounted(async () => {
await Promise.all([loadConfig(), loadFonts()])
})
</script>
<style scoped lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@400;700&display=swap');
.poster-editor-root {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
/* 工具栏 */
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
gap: 8px;
flex-wrap: wrap;
.toolbar-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
gap: 6px;
}
}
/* 主体三栏 */
.editor-body {
display: grid;
grid-template-columns: 180px 1fr 260px;
gap: 12px;
min-height: 700px;
}
/* 通用面板 */
.editor-layers,
.editor-props {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
overflow-y: auto;
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
.delete-icon {
cursor: pointer;
color: #ef4444;
font-size: 14px;
&:hover { color: #dc2626; }
}
}
/* 图层列表 */
.layers-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.layer-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
font-size: 12px;
color: #6b7280;
&:hover { background: #f3f4f6; border-color: #d1d5db; }
&.active { background: #ede9fe; border-color: #8b5cf6; color: #6d28d9; }
.layer-icon { font-size: 13px; flex-shrink: 0; }
.layer-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.layer-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
.action-icon {
font-size: 12px;
padding: 2px;
border-radius: 3px;
cursor: pointer;
&:hover { background: #e5e7eb; }
&.danger { color: #ef4444; }
}
}
&:hover .layer-actions { opacity: 1; }
}
.layers-empty {
text-align: center;
color: #9ca3af;
font-size: 12px;
padding: 20px 0;
}
/* 画布区域 */
.editor-canvas-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: #e5e7eb;
border-radius: 8px;
overflow: auto;
}
.canvas-scale-wrap {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.poster-canvas {
position: relative;
width: 375px;
height: 667px;
overflow: hidden;
user-select: none;
}
.canvas-bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
cursor: default;
}
.canvas-bg-click {
position: absolute;
inset: 0;
z-index: 0;
cursor: default;
}
.canvas-hint {
font-size: 11px;
color: #9ca3af;
}
/* 元素内容 */
.el-text,
.el-nickname {
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.el-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: rgba(99, 102, 241, 0.08);
border: 1.5px dashed rgba(99, 102, 241, 0.4);
color: #6366f1;
font-size: 12px;
pointer-events: none;
.el-icon { font-size: 20px; }
&.el-qrcode {
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.4);
color: #10b981;
}
}
/* MBTI / PDP / DISC 测试结果占位 */
.el-test-result {
border: 1.5px dashed rgba(99, 102, 241, 0.3);
border-radius: 4px;
justify-content: center;
&.mbti-result { background: rgba(99, 102, 241, 0.06); }
&.pdp-result { background: rgba(16, 185, 129, 0.06); border-color: rgba(16, 185, 129, 0.3); }
&.disc-result { background: rgba(244, 63, 94, 0.06); border-color: rgba(244, 63, 94, 0.3); }
}
/* vue3-draggable-resizable 激活样式覆盖 */
:deep(.vdr-active) {
outline: 2px solid #6366f1 !important;
outline-offset: 1px;
}
:deep(.vdr) {
z-index: 1;
}
/* 字号行 */
.font-size-row {
display: flex;
align-items: center;
}
/* 属性面板 */
.prop-item {
margin-bottom: 12px;
&.inline {
display: flex;
align-items: center;
justify-content: space-between;
}
&.half {
flex: 1;
:deep(.el-input-number) {
width: 100%;
}
}
}
.prop-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.prop-label {
display: block;
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.props-section-title {
font-size: 12px;
font-weight: 600;
color: #374151;
margin: 14px 0 8px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
}
.props-tip {
font-size: 12px;
color: #9ca3af;
text-align: center;
padding: 20px 0;
}
.img-preview-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.img-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.form-hint {
display: block;
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,871 @@
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<div>
<h2>系统设置</h2>
<p class="subtitle">管理系统配置API密钥和管理员账户</p>
</div>
</div>
<!-- 保存成功提示 -->
<el-alert
v-if="saveSuccess"
type="success"
:closable="true"
@close="saveSuccess = null"
class="success-alert"
>
设置已保存成功
</el-alert>
<div class="settings-content">
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="activeTab = tab.value"
>
<el-icon class="tab-icon"><component :is="tab.icon" /></el-icon>
{{ tab.label }}
</div>
</div>
</div>
<div class="tab-content-card">
<!-- 系统配置 -->
<div v-if="activeTab === 'system'" class="tab-content">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="header-icon"><Setting /></el-icon>
<span>系统基础配置</span>
</div>
<p class="header-description">管理网站名称维护模式等基础设置</p>
</div>
</template>
<div class="card-content">
<div class="form-grid">
<div class="form-item">
<label class="form-label">网站名称</label>
<el-input
v-model="systemConfig.siteName"
class="form-input"
placeholder="用于管理后台、Web 端展示"
/>
</div>
<div class="form-item">
<label class="form-label">小程序名称</label>
<el-input
v-model="systemConfig.miniprogramName"
class="form-input"
placeholder="用于小程序导航栏等展示,未填则用网站名称"
/>
</div>
<div class="form-item">
<label class="form-label">网站描述</label>
<el-input
v-model="systemConfig.siteDescription"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">每日最大测试数</label>
<el-input-number
v-model="systemConfig.maxTestsPerDay"
:min="1"
:controls="false"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">企业试用测试次数</label>
<el-input-number
v-model="systemConfig.trialTestCount"
:min="1"
:controls="false"
class="form-input"
/>
</div>
</div>
<!-- 小程序文案配置 -->
<div class="form-section text-config-section">
<div class="section-label">小程序文案配置</div>
<p class="section-desc">以下文案将显示在小程序对应位置留空则使用默认值</p>
<div class="form-grid">
<div class="form-item">
<label class="form-label">分析中提示</label>
<el-input
v-model="textConfig.analyzingTitle"
class="form-input"
placeholder="默认:正在分析中"
/>
<span class="form-hint">AI正在分析中</span>
</div>
<div class="form-item">
<label class="form-label">开始按钮个人版</label>
<el-input
v-model="textConfig.startButtonText"
class="form-input"
placeholder="默认:开始面相测试"
/>
<span class="form-hint">开始AI面相测试</span>
</div>
<div class="form-item">
<label class="form-label">开始按钮企业版</label>
<el-input
v-model="textConfig.startButtonEnterprise"
class="form-input"
placeholder="默认:开始面部测试"
/>
<span class="form-hint">开始AI面部测试</span>
</div>
<div class="form-item">
<label class="form-label">报告页标题</label>
<el-input
v-model="textConfig.reportTitle"
class="form-input"
placeholder="默认:分析报告"
/>
<span class="form-hint">AI分析报告</span>
</div>
<div class="form-item">
<label class="form-label">智能分析文案</label>
<el-input
v-model="textConfig.aiAnalysisText"
class="form-input"
placeholder="默认:智能分析"
/>
<span class="form-hint">用于步骤立即分析按钮等AI分析</span>
</div>
</div>
</div>
<!-- 开关项 -->
<div class="switch-section">
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">维护模式</p>
<p class="switch-desc">开启后前台将显示维护页面</p>
</div>
<el-switch v-model="systemConfig.maintenanceMode" />
</div>
</div>
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('system')"
>
<el-icon class="mr-1"><Document /></el-icon>保存系统配置
</el-button>
</div>
</el-card>
</div>
<!-- 通知设置 -->
<div v-if="activeTab === 'notification'" class="tab-content">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="header-icon"><Bell /></el-icon>
<span>通知与告警设置</span>
</div>
<p class="header-description">管理系统通知和企业告警规则</p>
</div>
</template>
<div class="card-content">
<div class="switch-section">
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">邮件通知</p>
<p class="switch-desc">发送重要事件邮件通知</p>
</div>
<el-switch v-model="notificationConfig.emailNotification" />
</div>
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">余额不足告警</p>
<p class="switch-desc">企业余额低于阈值时提醒</p>
</div>
<el-switch v-model="notificationConfig.lowBalanceAlert" />
</div>
<div v-if="notificationConfig.lowBalanceAlert" class="threshold-input">
<label class="form-label">余额告警阈值</label>
<el-input-number
v-model="notificationConfig.lowBalanceThreshold"
:min="100"
:step="100"
:controls="false"
class="form-input threshold-input-field"
/>
</div>
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">新企业入驻通知</p>
<p class="switch-desc">有新企业入驻时发送通知</p>
</div>
<el-switch v-model="notificationConfig.newEnterpriseNotify" />
</div>
</div>
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('notification')"
>
<el-icon class="mr-1"><Document /></el-icon>保存通知设置
</el-button>
</div>
</el-card>
</div>
<!-- 提示词配置 -->
<div v-if="activeTab === 'prompts'" class="tab-content">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="header-icon"><ChatDotRound /></el-icon>
<span>提示词配置</span>
</div>
<p class="header-description">配置 AI 面相分析场景使用的系统提示词保存后将在对应接口中生效</p>
</div>
</template>
<div class="card-content">
<div class="prompt-two-col">
<div class="form-item prompt-col">
<label class="form-label">个人版面相分析提示词</label>
<el-input
v-model="promptsConfig.faceAnalyze"
type="textarea"
:rows="20"
placeholder='仅填写 JSON 返回模板,例如:{"mbti":"四字母如INTJ",...},接口会自动在前面拼接固定中文说明'
class="form-input prompt-textarea"
/>
</div>
<div class="form-item prompt-col">
<label class="form-label">企业版面相分析提示词</label>
<el-input
v-model="promptsConfig.reportSummary"
type="textarea"
:rows="20"
placeholder="企业版面相分析场景使用的提示词,可按需扩展"
class="form-input prompt-textarea"
/>
</div>
</div>
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('prompts')"
>
<el-icon class="mr-1"><Document /></el-icon>保存提示词配置
</el-button>
</div>
</el-card>
</div>
<!-- 海报配置 -->
<div v-if="activeTab === 'poster'" class="tab-content">
<el-card shadow="never" class="settings-card poster-card">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="header-icon"><Postcard /></el-icon>
<span>分销海报配置</span>
</div>
<p class="header-description">可视化设计分销推广海报配置背景文字昵称头像小程序码等元素的位置与样式</p>
</div>
</template>
<div class="card-content" style="padding: 0;">
<PosterEditor />
</div>
</el-card>
</div>
<!-- 账户安全 -->
<div v-if="activeTab === 'security'" class="tab-content">
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<div class="header-title">
<el-icon class="header-icon"><Lock /></el-icon>
<span>超管账户安全</span>
</div>
<p class="header-description">修改超级管理员用户名和密码</p>
</div>
</template>
<div class="card-content">
<div class="form-section">
<div class="form-item">
<label class="form-label">用户名</label>
<el-input
v-model="credentials.username"
class="form-input credentials-input"
/>
</div>
<div class="form-item">
<label class="form-label">当前密码</label>
<el-input
v-model="credentials.currentPassword"
type="password"
placeholder="输入当前密码"
show-password
class="form-input credentials-input"
/>
</div>
<div class="form-item">
<label class="form-label">新密码</label>
<el-input
v-model="credentials.newPassword"
type="password"
placeholder="输入新密码"
show-password
class="form-input credentials-input"
/>
</div>
<div class="form-item">
<label class="form-label">确认新密码</label>
<el-input
v-model="credentials.confirmPassword"
type="password"
placeholder="再次输入新密码"
show-password
class="form-input credentials-input"
/>
</div>
</div>
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('credentials')"
>
<el-icon class="mr-1"><Document /></el-icon>更新账户信息
</el-button>
</div>
</el-card>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Setting, Bell, Lock, Document, ChatDotRound, Postcard } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import PosterEditor from './PosterEditor.vue'
const activeTab = ref('system')
const saveSuccess = ref<string | null>(null)
const tabs = [
{ label: '系统配置', value: 'system', icon: Setting },
{ label: '通知设置', value: 'notification', icon: Bell },
{ label: '提示词配置', value: 'prompts', icon: ChatDotRound },
{ label: '海报配置', value: 'poster', icon: Postcard },
{ label: '账户安全', value: 'security', icon: Lock }
]
// 系统配置
const systemConfig = reactive({
siteName: '神仙团队AI性格测试',
siteDescription: '专业的AI性格测试平台',
miniprogramName: '神仙团队AI性格测试',
maintenanceMode: false,
maxTestsPerDay: 100,
trialTestCount: 10,
})
// 超管凭据
const credentials = reactive({
username: 'admin',
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
// 通知配置
const notificationConfig = reactive({
emailNotification: true,
lowBalanceAlert: true,
lowBalanceThreshold: 1000,
newEnterpriseNotify: true,
})
// 小程序文案配置(分析中提示、按钮、报告标题等)
const textConfig = reactive({
analyzingTitle: '正在分析中',
startButtonText: '开始面相测试',
startButtonEnterprise: '开始面部测试',
reportTitle: '分析报告',
aiAnalysisText: '智能分析',
})
// 提示词配置faceAnalyze 只存 JSON 返回模板,由后端与固定前缀拼接)
const promptsConfig = reactive<Record<string, string>>({
faceAnalyze: '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
reportSummary: '',
})
// 加载配置
const loadSettings = async () => {
try {
const response: any = await request.get('/superadmin/settings')
if (response.code === 200 && response.data) {
// 加载系统配置
if (response.data.system) {
Object.assign(systemConfig, response.data.system)
}
// 加载小程序文案配置
if (response.data.textConfig && typeof response.data.textConfig === 'object') {
Object.assign(textConfig, response.data.textConfig)
}
// 加载通知配置
if (response.data.notification) {
Object.assign(notificationConfig, response.data.notification)
}
// 加载提示词配置
if (response.data.prompts && typeof response.data.prompts === 'object') {
Object.keys(promptsConfig).forEach(k => {
if (response.data.prompts[k] !== undefined) {
promptsConfig[k] = response.data.prompts[k] || ''
}
})
Object.keys(response.data.prompts).forEach(k => {
if (!(k in promptsConfig)) {
promptsConfig[k] = response.data.prompts[k] || ''
}
})
}
// 加载用户名
if (response.data.username) {
credentials.username = response.data.username
}
}
} catch (error: any) {
console.error('加载设置失败:', error)
ElMessage.error(error.message || '加载设置失败')
}
}
onMounted(() => {
loadSettings()
})
// 保存配置
const handleSave = async (section: string) => {
try {
let response: any
switch (section) {
case 'system':
response = await request.put('/superadmin/settings/system', {
...systemConfig,
textConfig
})
if (response.code === 200) {
ElMessage.success('系统配置已保存')
saveSuccess.value = section
setTimeout(() => {
saveSuccess.value = null
}, 3000)
}
break
case 'notification':
response = await request.put('/superadmin/settings/notification', notificationConfig)
if (response.code === 200) {
ElMessage.success('通知配置已保存')
saveSuccess.value = section
setTimeout(() => {
saveSuccess.value = null
}, 3000)
}
break
case 'prompts':
response = await request.put('/superadmin/settings/prompts', { prompts: promptsConfig })
if (response.code === 200) {
ElMessage.success('提示词配置已保存')
saveSuccess.value = section
setTimeout(() => {
saveSuccess.value = null
}, 3000)
}
break
case 'credentials':
if (credentials.newPassword && credentials.newPassword !== credentials.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
response = await request.put('/superadmin/settings/credentials', {
username: credentials.username,
currentPassword: credentials.currentPassword,
newPassword: credentials.newPassword,
confirmPassword: credentials.confirmPassword
})
if (response.code === 200) {
ElMessage.success('账户信息已更新')
credentials.currentPassword = ''
credentials.newPassword = ''
credentials.confirmPassword = ''
saveSuccess.value = section
setTimeout(() => {
saveSuccess.value = null
}, 3000)
}
break
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
}
}
</script>
<style scoped lang="scss">
.page-container {
padding: 24px;
min-height: calc(100vh - 64px);
}
.page-header {
margin-bottom: 24px;
h2 {
font-size: 24px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.success-alert {
margin-bottom: 24px;
background-color: #dcfce7;
border-color: #bbf7d0;
color: #166534;
:deep(.el-alert__content) {
color: #166534;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
.tab-icon {
font-size: 14px;
}
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
.tab-content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 32px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.settings-card {
border-radius: 8px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
:deep(.el-card__header) {
padding: 20px;
border-bottom: 1px solid #f3f4f6;
}
:deep(.el-card__body) {
padding: 20px;
}
.card-header {
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
.header-icon {
font-size: 16px;
color: #6366f1;
}
}
.header-description {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 20px;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 16px;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
&.text-config-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
.section-label {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.section-desc {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px 0;
}
}
}
.form-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
.form-label {
font-size: 14px;
font-weight: 500;
color: #374151;
}
.form-input {
:deep(.el-input__wrapper) {
border-radius: 6px;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 8px 12px;
height: 36px;
&.is-focus {
box-shadow: 0 0 0 1px #6366f1 inset, 0 0 0 3px rgba(99, 102, 241, 0.1);
}
}
:deep(.el-input__inner) {
font-size: 14px;
}
}
&.threshold-input {
margin-left: 16px;
margin-top: -8px;
.threshold-input-field {
max-width: 320px;
}
}
}
.credentials-input {
max-width: 384px;
}
.prompt-two-col {
display: flex;
gap: 20px;
align-items: flex-start;
}
.prompt-col {
flex: 1;
min-width: 0;
}
.prompt-textarea {
max-width: 100%;
:deep(.el-textarea__inner) {
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
}
}
.switch-section {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.switch-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
.switch-info {
flex: 1;
.switch-title {
font-size: 14px;
font-weight: 500;
color: #111827;
margin: 0 0 2px 0;
}
.switch-desc {
font-size: 12px;
color: #6b7280;
margin: 0;
}
}
}
.warning-box {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px;
background-color: #fffbeb;
border: 1px solid #fef3c7;
border-radius: 8px;
font-size: 12px;
color: #92400e;
.warning-icon {
font-size: 16px;
color: #f59e0b;
flex-shrink: 0;
margin-top: 2px;
}
}
.save-button {
background-color: #6366f1;
border-color: #6366f1;
color: #fff;
height: 36px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
&:hover {
background-color: #4f46e5;
border-color: #4f46e5;
}
:deep(.el-icon) {
color: #fff;
}
}
.mr-1 {
margin-right: 4px;
}
.poster-card {
:deep(.el-card__body) {
padding: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

1
admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
admin/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

52
admin/vite.config.ts Normal file
View File

@@ -0,0 +1,52 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
css: {
preprocessorOptions: {
scss: { silenceDeprecations: ['legacy-js-api'] },
sass: { silenceDeprecations: ['legacy-js-api'] }
}
},
build: {
chunkSizeWarningLimit: 1200
},
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0', // 允许局域网访问
port: 5173, // 首选端口
strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
proxy: {
'/api': {
// 如果 VITE_API_BASE_URL 是完整URL则不使用代理
// 否则代理到测试服务器
// 注意在vite.config.ts中无法直接访问import.meta.env
// 这里使用默认代理配置实际请求会根据VITE_API_BASE_URL决定
target: 'http://test.mbti.com', // 本地开发代理到测试环境
changeOrigin: true,
rewrite: (path) => path, // 保持路径不变
}
}
}
})