feat: 管理端聚合页、小程序/抖音埋点与统计、飞书线索 webhook、API 迁移与路由
- admin:OrdersHub/UsersHub、Commerce/Ops/Enterprise Hub、MpAnalytics、Feishu/小程序配置面板、鉴权存储 - api:Analytics、DataMigration、FeishuLeadWebhook、mp 事件迁移 SQL - 微信/抖音小程序:analytics 上报与相关页面调整 - 开发文档与 scripts 补充 Made-with: Cursor
This commit is contained in:
@@ -1,357 +1,338 @@
|
||||
<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>
|
||||
|
||||
<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 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,
|
||||
SwitchButton,
|
||||
DataLine,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Share,
|
||||
Setting
|
||||
} 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/settings', icon: Setting, label: '系统设置' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/admin/settings') {
|
||||
return route.path === '/admin/settings'
|
||||
}
|
||||
if (path === '/admin/users') {
|
||||
return route.path === '/admin/users'
|
||||
}
|
||||
if (path === '/admin/orders') {
|
||||
return route.path === '/admin/orders'
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
&.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>
|
||||
|
||||
|
||||
@@ -1,330 +1,387 @@
|
||||
<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>
|
||||
|
||||
<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 admin-entry" @click="goAdminConsole">
|
||||
<el-icon><Monitor /></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">
|
||||
<div class="nav-main">
|
||||
<el-button
|
||||
v-for="item in navMainItems"
|
||||
: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>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer-block">
|
||||
<el-button
|
||||
v-for="item in navFooterItems"
|
||||
: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>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div v-if="sidebarOpen" class="sidebar-overlay" @click="toggleSidebar" />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<div class="mission-strip">
|
||||
<div class="mission-inner">
|
||||
<span class="mission-text">
|
||||
<strong>核心职责</strong>:配置与监督各企业使用的
|
||||
<strong>普通管理后台</strong>
|
||||
(用户运营、订单、分销等由企业管理员在日常后台完成)。
|
||||
</span>
|
||||
<el-button type="danger" size="small" plain @click="goAdminConsole">
|
||||
进入管理后台
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<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,
|
||||
SwitchButton,
|
||||
TrendCharts,
|
||||
ShoppingCart,
|
||||
Cpu,
|
||||
Setting,
|
||||
OfficeBuilding,
|
||||
Share,
|
||||
Monitor
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navMainItems: { path: string; icon: typeof TrendCharts; label: string }[] = [
|
||||
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
|
||||
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
|
||||
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
|
||||
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }
|
||||
]
|
||||
|
||||
const navFooterItems: { path: string; icon: typeof Setting; label: string }[] = [
|
||||
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' }
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path || route.path.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
const goAdminConsole = () => {
|
||||
const url = router.resolve({ path: '/admin/dashboard' }).href
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
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)
|
||||
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: 4px;
|
||||
|
||||
.nav-link {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.admin-entry {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.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: 12px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 64px - 32px);
|
||||
}
|
||||
|
||||
.nav-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-footer-block {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.mission-strip {
|
||||
background: linear-gradient(90deg, #fef2f2 0%, #fff7ed 100%);
|
||||
border-bottom: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.mission-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mission-text {
|
||||
font-size: 13px;
|
||||
color: #44403c;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.scss'
|
||||
import { migrateLegacyAuthStorage } from '@/utils/authStorage'
|
||||
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -1,203 +1,223 @@
|
||||
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
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import {
|
||||
migrateLegacyAuthStorage,
|
||||
getAdminToken,
|
||||
getAdminRole,
|
||||
getSuperadminToken,
|
||||
getSuperadminRole,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
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/UsersHub.vue'),
|
||||
meta: { title: '用户运营' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
component: () => import('@/views/admin/OrdersHub.vue'),
|
||||
meta: { title: '订单运营' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'AdminDistribution',
|
||||
component: () => import('@/views/admin/Distribution.vue'),
|
||||
meta: { title: '分销推广' }
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
redirect: { path: '/admin/orders', query: { tab: 'questions' } }
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
redirect: { path: '/admin/orders', query: { tab: 'pricing' } }
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
redirect: { path: '/admin/settings', query: { tab: 'finance' } }
|
||||
},
|
||||
{
|
||||
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/ops',
|
||||
children: [
|
||||
{
|
||||
path: 'ops',
|
||||
name: 'SuperAdminOps',
|
||||
component: () => import('@/views/superadmin/OpsHub.vue'),
|
||||
meta: { title: '总览' },
|
||||
beforeEnter: (to, _from, next) => {
|
||||
const t = to.query.tab
|
||||
if (t === 'distribution') {
|
||||
next({ path: '/superadmin/distribution' })
|
||||
return
|
||||
}
|
||||
if (t === 'analytics') {
|
||||
next({ path: '/superadmin/distribution', query: { tab: 'analytics' } })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'SuperAdminUsers',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'users' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'enterprises',
|
||||
name: 'SuperAdminEnterprises',
|
||||
component: () => import('@/views/superadmin/EnterpriseHub.vue'),
|
||||
meta: { title: '企业管理' }
|
||||
},
|
||||
{
|
||||
path: 'commerce',
|
||||
name: 'SuperAdminCommerce',
|
||||
component: () => import('@/views/superadmin/CommerceHub.vue'),
|
||||
meta: { title: '订单和财务' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'SuperAdminDistribution',
|
||||
component: () => import('@/views/superadmin/DistributionStandalone.vue'),
|
||||
meta: { title: '分销管理' }
|
||||
},
|
||||
{
|
||||
path: 'analytics',
|
||||
name: 'SuperAdminMpAnalytics',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/distribution',
|
||||
query: { ...to.query, tab: 'analytics' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'SuperAdminAIConfig',
|
||||
component: () => import('@/views/superadmin/AIConfig.vue'),
|
||||
meta: { title: '智能算力' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SuperAdminSettings',
|
||||
component: () => import('@/views/superadmin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
redirect: to => ({ path: '/superadmin/ops', query: { ...to.query, tab: 'overview' } })
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
redirect: to => ({ path: '/superadmin/settings', query: { ...to.query, tab: 'questions' } })
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
redirect: to => ({ path: '/superadmin/commerce', query: { ...to.query, tab: 'pricing' } })
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
redirect: to => ({ path: '/superadmin/settings', query: { ...to.query, tab: 'database' } })
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
redirect: to => ({ path: '/superadmin/commerce', query: { ...to.query, tab: 'finance' } })
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 默认重定向
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/admin/login'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫:管理端仅 admin / enterprise_admin;超管端仅 superadmin;两套 Token 分存
|
||||
router.beforeEach((to, _from, next) => {
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
document.title = to.meta.title ? `${to.meta.title} - MBTI 管理后台` : 'MBTI 管理后台'
|
||||
|
||||
const adminToken = getAdminToken()
|
||||
const adminRole = getAdminRole()
|
||||
const saToken = getSuperadminToken()
|
||||
const saRole = getSuperadminRole()
|
||||
|
||||
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
|
||||
if (!adminToken || !adminRole || !['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
clearAdminAuthKeys()
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.path.startsWith('/superadmin') && to.path !== '/superadmin/login') {
|
||||
if (!saToken || saRole !== 'superadmin') {
|
||||
clearSuperadminAuthKeys()
|
||||
next('/superadmin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.path === '/admin/login' && adminToken && adminRole && ['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/superadmin/login' && saToken && saRole === 'superadmin') {
|
||||
next('/superadmin/ops')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import {
|
||||
migrateLegacyAuthStorage,
|
||||
ADMIN_TOKEN_KEY,
|
||||
ADMIN_ROLE_KEY,
|
||||
ADMIN_USER_ID_KEY,
|
||||
SUPERADMIN_TOKEN_KEY,
|
||||
SUPERADMIN_ROLE_KEY,
|
||||
SUPERADMIN_USER_ID_KEY,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
interface LoginResponse {
|
||||
code: number
|
||||
@@ -18,31 +29,29 @@ interface LoginResponse {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
const adminRole = localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
if (adminToken && adminRole && ['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
adminLoggedIn.value = true
|
||||
}
|
||||
|
||||
const saToken = localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
const saRole = localStorage.getItem(SUPERADMIN_ROLE_KEY)
|
||||
if (saToken && saRole === 'superadmin') {
|
||||
superAdminLoggedIn.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 自动初始化
|
||||
initAuth()
|
||||
|
||||
// 管理员登录
|
||||
async function adminLogin(username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await request.post<LoginResponse>('/auth/admin/login', {
|
||||
@@ -51,15 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
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(ADMIN_TOKEN_KEY, response.data.token)
|
||||
localStorage.setItem(ADMIN_ROLE_KEY, response.data.user.role)
|
||||
localStorage.setItem(ADMIN_USER_ID_KEY, String(response.data.user.id))
|
||||
localStorage.setItem('adminLoggedIn', 'true')
|
||||
|
||||
|
||||
currentUser.value = response.data.user
|
||||
adminLoggedIn.value = true
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -69,7 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 超级管理员登录
|
||||
async function superAdminLogin(username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await request.post<LoginResponse>('/auth/superadmin/login', {
|
||||
@@ -78,15 +85,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
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(SUPERADMIN_TOKEN_KEY, response.data.token)
|
||||
localStorage.setItem(SUPERADMIN_ROLE_KEY, response.data.user.role)
|
||||
localStorage.setItem(SUPERADMIN_USER_ID_KEY, String(response.data.user.id))
|
||||
localStorage.setItem('superAdminLoggedIn', 'true')
|
||||
|
||||
|
||||
currentUser.value = response.data.user
|
||||
superAdminLoggedIn.value = true
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -96,39 +102,25 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员登出
|
||||
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')
|
||||
|
||||
clearAdminAuthKeys()
|
||||
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')
|
||||
|
||||
clearSuperadminAuthKeys()
|
||||
superAdminLoggedIn.value = false
|
||||
currentUser.value = null
|
||||
}
|
||||
@@ -145,4 +137,3 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
superAdminLogout
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
88
admin/src/utils/authStorage.ts
Normal file
88
admin/src/utils/authStorage.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 管理端与超管端使用独立 localStorage,避免互相覆盖 Token。
|
||||
* 兼容旧键 authToken / userRole:首次加载时迁移到新键后删除旧键。
|
||||
*/
|
||||
|
||||
export const ADMIN_TOKEN_KEY = 'adminAuthToken'
|
||||
export const ADMIN_ROLE_KEY = 'adminUserRole'
|
||||
export const ADMIN_USER_ID_KEY = 'adminUserId'
|
||||
|
||||
export const SUPERADMIN_TOKEN_KEY = 'superadminAuthToken'
|
||||
export const SUPERADMIN_ROLE_KEY = 'superadminUserRole'
|
||||
export const SUPERADMIN_USER_ID_KEY = 'superadminUserId'
|
||||
|
||||
const LEGACY_TOKEN = 'authToken'
|
||||
const LEGACY_ROLE = 'userRole'
|
||||
const LEGACY_USER_ID = 'userId'
|
||||
|
||||
/** 应用启动时调用一次 */
|
||||
export function migrateLegacyAuthStorage(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
const legacyToken = localStorage.getItem(LEGACY_TOKEN)
|
||||
const legacyRole = localStorage.getItem(LEGACY_ROLE)
|
||||
if (!legacyToken || !legacyRole) return
|
||||
|
||||
const hasSplit =
|
||||
localStorage.getItem(ADMIN_TOKEN_KEY) || localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
if (hasSplit) {
|
||||
localStorage.removeItem(LEGACY_TOKEN)
|
||||
localStorage.removeItem(LEGACY_ROLE)
|
||||
localStorage.removeItem(LEGACY_USER_ID)
|
||||
return
|
||||
}
|
||||
|
||||
const legacyUserId = localStorage.getItem(LEGACY_USER_ID)
|
||||
if (legacyRole === 'superadmin') {
|
||||
localStorage.setItem(SUPERADMIN_TOKEN_KEY, legacyToken)
|
||||
localStorage.setItem(SUPERADMIN_ROLE_KEY, legacyRole)
|
||||
if (legacyUserId) localStorage.setItem(SUPERADMIN_USER_ID_KEY, legacyUserId)
|
||||
} else if (['admin', 'enterprise_admin'].includes(legacyRole)) {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, legacyToken)
|
||||
localStorage.setItem(ADMIN_ROLE_KEY, legacyRole)
|
||||
if (legacyUserId) localStorage.setItem(ADMIN_USER_ID_KEY, legacyUserId)
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_TOKEN)
|
||||
localStorage.removeItem(LEGACY_ROLE)
|
||||
localStorage.removeItem(LEGACY_USER_ID)
|
||||
}
|
||||
|
||||
export function getAdminToken(): string | null {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getAdminRole(): string | null {
|
||||
return localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
}
|
||||
|
||||
export function getSuperadminToken(): string | null {
|
||||
return localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getSuperadminRole(): string | null {
|
||||
return localStorage.getItem(SUPERADMIN_ROLE_KEY)
|
||||
}
|
||||
|
||||
/** axios / 上传:按当前页面路径选择 Bearer */
|
||||
export function getBearerTokenForCurrentApp(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (window.location.pathname.startsWith('/superadmin')) {
|
||||
return getSuperadminToken()
|
||||
}
|
||||
return getAdminToken()
|
||||
}
|
||||
|
||||
export function clearAdminAuthKeys(): void {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||
localStorage.removeItem(ADMIN_ROLE_KEY)
|
||||
localStorage.removeItem(ADMIN_USER_ID_KEY)
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
}
|
||||
|
||||
export function clearSuperadminAuthKeys(): void {
|
||||
localStorage.removeItem(SUPERADMIN_TOKEN_KEY)
|
||||
localStorage.removeItem(SUPERADMIN_ROLE_KEY)
|
||||
localStorage.removeItem(SUPERADMIN_USER_ID_KEY)
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
}
|
||||
@@ -1,118 +1,127 @@
|
||||
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
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getBearerTokenForCurrentApp,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
let lastBizErrorKey = ''
|
||||
let lastBizErrorAt = 0
|
||||
function showBizErrorOnce(message: string) {
|
||||
const key = message || '请求失败'
|
||||
const now = Date.now()
|
||||
if (key === lastBizErrorKey && now - lastBizErrorAt < 1800) return
|
||||
lastBizErrorKey = key
|
||||
lastBizErrorAt = now
|
||||
ElMessage.error(key)
|
||||
}
|
||||
|
||||
// 获取API基础URL(开发环境留空 VITE_API_BASE_URL 时走同源 /api/v1,由 Vite 代理到本机后端)
|
||||
const getBaseURL = (): string => {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||
const envURL = typeof raw === 'string' ? raw.trim() : ''
|
||||
if (envURL) {
|
||||
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 = getBearerTokenForCurrentApp()
|
||||
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) {
|
||||
showBizErrorOnce(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('未授权,请重新登录')
|
||||
if (window.location.pathname.startsWith('/superadmin')) {
|
||||
clearSuperadminAuthKeys()
|
||||
window.location.href = '/superadmin/login'
|
||||
} else {
|
||||
clearAdminAuthKeys()
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
} else if (status === 403) {
|
||||
showBizErrorOnce(data?.message || '拒绝访问')
|
||||
} 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
|
||||
|
||||
|
||||
@@ -1,392 +1,528 @@
|
||||
<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 天人脸分析、MBTI、PDP、DISC 等关键测试的完成情况</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>
|
||||
<template>
|
||||
<div class="dashboard-viewport" v-loading="loading">
|
||||
<header class="dash-head">
|
||||
<h1 class="dash-title">数据概览</h1>
|
||||
<p class="dash-tagline">
|
||||
企业侧测评闭环:小程序面相与 MBTI / DISC / PDP 答题 → MySQL 落库 → 本页看用户与测试趋势、拉新邀请码(全链路见开发文档《小程序全链路功能与接口》)。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="dash-kpis">
|
||||
<div v-for="(card, i) in kpiCards" :key="card.key" class="stat-card" :style="{ animationDelay: `${i * 45}ms` }">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-value">{{ card.value }}</div>
|
||||
</div>
|
||||
<div :class="['stat-icon', card.tone]">
|
||||
<el-icon><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-main">
|
||||
<section class="panel panel-chart">
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title">近 14 日测试趋势</h2>
|
||||
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量</p>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<VChart v-if="testTrends.length" class="trend-chart" :option="chartOption" autoresize />
|
||||
<div v-else class="panel-empty">暂无趋势数据</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel panel-side">
|
||||
<div class="side-block side-users">
|
||||
<div class="panel-head row">
|
||||
<div>
|
||||
<h2 class="panel-title">测试 Top 10</h2>
|
||||
<p class="panel-desc">按完成次数 · 本企业口径</p>
|
||||
</div>
|
||||
<el-button type="primary" link size="small" @click="router.push('/admin/users')">全部用户</el-button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-if="topTestUsers.length"
|
||||
:data="topTestUsers"
|
||||
size="small"
|
||||
stripe
|
||||
class="compact-table"
|
||||
:max-height="tableMaxH"
|
||||
>
|
||||
<el-table-column label="#" width="42">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" min-width="88" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.username || '未命名' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="testCount" label="次数" width="52" align="center" />
|
||||
<el-table-column label="摘要" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ summarizeTypes(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="panel-empty tight">暂无测试记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-block side-invite">
|
||||
<div class="panel-head row">
|
||||
<div>
|
||||
<h2 class="panel-title">邀请小程序码</h2>
|
||||
<p class="panel-desc">员工 / 客户扫码进入企业测评</p>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
{{ inviteQrcode ? '刷新' : '生成' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="invite-body">
|
||||
<img v-if="inviteQrcode" :src="inviteQrcode" alt="邀请码" class="invite-img" />
|
||||
<span v-else class="invite-placeholder">点击生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 router = useRouter()
|
||||
|
||||
interface TopUserRow {
|
||||
id: number
|
||||
username: string
|
||||
phone: string
|
||||
testCount: number
|
||||
lastTestAt: number | null
|
||||
mbtiType: string
|
||||
pdpType: string
|
||||
discType: string
|
||||
faceMbtiType: string
|
||||
faceDiscType: string
|
||||
facePdpType: string
|
||||
}
|
||||
|
||||
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 topTestUsers = ref<TopUserRow[]>([])
|
||||
const loading = ref(false)
|
||||
const inviteLoading = ref(false)
|
||||
const inviteQrcode = ref<string>('')
|
||||
|
||||
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
|
||||
const tableMaxH = 220
|
||||
|
||||
const kpiCards = computed(() => [
|
||||
{ key: 'u', label: '总用户数', value: stats.totalUsers, icon: User, tone: 'blue' },
|
||||
{ key: 't', label: '已完成测试', value: stats.testsCompleted, icon: Document, tone: 'green' },
|
||||
{ key: 'a', label: '今日活跃', value: stats.activeToday, icon: TrendCharts, tone: 'purple' },
|
||||
{ key: 'p', label: '待审核', value: stats.pendingReviews, icon: User, tone: 'orange' }
|
||||
])
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const dates = testTrends.value.map(d => d.date.slice(5))
|
||||
return {
|
||||
animationDuration: 480,
|
||||
animationEasing: 'cubicOut',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['人脸', 'MBTI', 'PDP', 'DISC'],
|
||||
top: 0,
|
||||
textStyle: { fontSize: 11, color: '#6b7280' }
|
||||
},
|
||||
grid: { left: 36, right: 12, top: 36, bottom: 24 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#9ca3af', fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#9ca3af', fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '人脸',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#22c55e' },
|
||||
data: testTrends.value.map(d => d.face)
|
||||
},
|
||||
{
|
||||
name: 'MBTI',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
data: testTrends.value.map(d => d.mbti)
|
||||
},
|
||||
{
|
||||
name: 'PDP',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: testTrends.value.map(d => d.pdp)
|
||||
},
|
||||
{
|
||||
name: 'DISC',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#6366f1' },
|
||||
data: testTrends.value.map(d => d.disc)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
function summarizeTypes(row: TopUserRow) {
|
||||
const parts: string[] = []
|
||||
if (row.mbtiType) parts.push(row.mbtiType)
|
||||
if (row.pdpType) parts.push(row.pdpType)
|
||||
if (row.discType) parts.push(row.discType)
|
||||
const faceBits = [row.faceMbtiType, row.facePdpType, row.faceDiscType].filter(Boolean)
|
||||
if (faceBits.length) parts.push('面:' + faceBits.join('/'))
|
||||
return parts.length ? parts.join(' · ') : '—'
|
||||
}
|
||||
|
||||
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 || []
|
||||
topTestUsers.value = Array.isArray(response.data.topTestUsers) ? response.data.topTestUsers : []
|
||||
}
|
||||
} 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 qrcode = res?.data?.qrcode
|
||||
if (qrcode && typeof qrcode === 'string') {
|
||||
inviteQrcode.value = qrcode
|
||||
} else {
|
||||
ElMessage.error(res?.message || res?.msg || '生成失败,请确认企业绑定')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '生成失败')
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@keyframes dashFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-viewport {
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 18px 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 10px;
|
||||
animation: dashFadeUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.dash-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dash-tagline {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #6b7280;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.dash-kpis {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
animation: dashFadeUp 0.45s ease-out both;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
|
||||
&.blue {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
&.green {
|
||||
background: #f0fdf4;
|
||||
color: #22c55e;
|
||||
}
|
||||
&.purple {
|
||||
background: #faf5ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
&.orange {
|
||||
background: #fffbeb;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dash-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(280px, 30%);
|
||||
gap: 10px;
|
||||
animation: dashFadeUp 0.5s ease-out 0.08s both;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-chart {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-side {
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.panel-desc {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
|
||||
&.tight {
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.side-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side-users {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.side-invite {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.invite-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.invite-img {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.invite-placeholder {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dash-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-viewport {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 56px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-chart .chart-box {
|
||||
min-height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-kpis {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
133
admin/src/views/admin/FeishuLeadConfigPanel.vue
Normal file
133
admin/src/views/admin/FeishuLeadConfigPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="tab-content" v-loading="loading">
|
||||
<div class="content-header">
|
||||
<h3>飞书获客推送</h3>
|
||||
<p class="content-description">
|
||||
参考 Soul 创业派对:支付成功(含 1 元测试、充值)、用户首次授权手机号时,向飞书群机器人推送「新获客」卡片文案;含最近行为(依赖小程序埋点)。
|
||||
在飞书群添加自定义机器人,复制 Webhook 地址填入下方。
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-item row-line">
|
||||
<label>启用推送</label>
|
||||
<el-switch v-model="form.enabled" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>飞书 Webhook 地址</label>
|
||||
<el-input
|
||||
v-model="form.webhookUrl"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/……"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>对接人(展示在卡片上)</label>
|
||||
<el-input v-model="form.contactPerson" placeholder="如:卡若" class="w-full" />
|
||||
</div>
|
||||
<p class="hint">
|
||||
幂等:同一订单仅推一次;同一用户首次绑定手机号仅推一次。支付结果可能经「前端 notify」与「查单」两条路径,后台以订单维度去重。
|
||||
</p>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="loading" @click="save">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
webhookUrl: '',
|
||||
contactPerson: '运营'
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/settings/feishu-lead')
|
||||
if (res.code === 200 && res.data) {
|
||||
form.enabled = !!res.data.enabled
|
||||
form.webhookUrl = res.data.webhookUrl || ''
|
||||
form.contactPerson = res.data.contactPerson || '运营'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/settings/feishu-lead', { ...form })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-header h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.content-description {
|
||||
margin: 0 0 20px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-section {
|
||||
max-width: 640px;
|
||||
}
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-item label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
.row-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-line label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.save-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,379 +1,386 @@
|
||||
<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>
|
||||
<template>
|
||||
<div class="finance-page" :class="{ 'is-embedded': embedded }">
|
||||
<div v-if="!embedded" 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'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.finance-page.is-embedded {
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
204
admin/src/views/admin/MiniprogramConfigPanel.vue
Normal file
204
admin/src/views/admin/MiniprogramConfigPanel.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMiniprogramConfig()
|
||||
})
|
||||
|
||||
defineExpose({ loadMiniprogramConfig })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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;
|
||||
}
|
||||
|
||||
:deep(.el-input .el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
159
admin/src/views/admin/OrdersHub.vue
Normal file
159
admin/src/views/admin/OrdersHub.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="orders-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单运营</h2>
|
||||
<p class="hub-subtitle">订单成交、测试定价与题库在同一入口完成</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body flat">
|
||||
<Orders
|
||||
v-if="activeTab === 'orders'"
|
||||
embedded
|
||||
orders-api-path="/admin/orders"
|
||||
/>
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded />
|
||||
<Questions v-if="activeTab === 'questions'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from './Orders.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
import Questions from './Questions.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'pricing', 'questions'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('orders')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '订单列表', value: 'orders' },
|
||||
{ label: '价格设置', value: 'pricing' },
|
||||
{ label: '题库管理', value: 'questions' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/orders', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(() => route.query.tab, () => applyRouteTab())
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
padding: 8px 18px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body.flat {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -325,6 +325,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
@@ -531,7 +532,7 @@ const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
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`
|
||||
|
||||
@@ -1,363 +1,369 @@
|
||||
<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>
|
||||
<template>
|
||||
<div class="page-container" :class="{ 'is-embedded': embedded }" v-loading="loading">
|
||||
<div v-if="!embedded" 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'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,543 +1,346 @@
|
||||
<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>
|
||||
<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="selectTab(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }">
|
||||
<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 v-if="activeTab === 'finance'" class="embed-wrap">
|
||||
<Finance embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Finance from './Finance.vue'
|
||||
|
||||
const TAB_IDS = ['account', 'finance'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('account')
|
||||
const loading = ref(false)
|
||||
|
||||
const tabs: { label: string; value: TabId }[] = [
|
||||
{ label: '账号设置', value: 'account' },
|
||||
{ label: '企业余额', value: 'finance' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'account'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'account') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/settings', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
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(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
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);
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
:deep(.el-input .el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
200
admin/src/views/admin/UsersHub.vue
Normal file
200
admin/src/views/admin/UsersHub.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="users-hub">
|
||||
<div class="hub-header">
|
||||
<h2>用户运营</h2>
|
||||
<p class="hub-subtitle">测试用户数据、小程序展示文案与分销海报一站式维护</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hub-card"
|
||||
:class="{ 'no-pad': activeTab === 'poster', 'flat-embed': activeTab === 'users' }"
|
||||
>
|
||||
<Users v-if="activeTab === 'users'" embedded />
|
||||
<MiniprogramConfigPanel v-if="activeTab === 'miniprogram'" ref="miniRef" />
|
||||
<div v-if="activeTab === 'poster'" class="poster-wrap">
|
||||
<PosterEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Users from './Users.vue'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
import MiniprogramConfigPanel from './MiniprogramConfigPanel.vue'
|
||||
|
||||
const TAB_IDS = ['users', 'miniprogram', 'poster'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('users')
|
||||
const miniRef = ref<InstanceType<typeof MiniprogramConfigPanel> | null>(null)
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '用户列表', value: 'users' },
|
||||
{ label: '小程序配置', value: 'miniprogram' },
|
||||
{ label: '海报配置', value: 'poster' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'users'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'users') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/users', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
applyRouteTab()
|
||||
if (activeTab.value === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
if (activeTab.value === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.no-pad {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-wrap {
|
||||
min-height: 720px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
163
admin/src/views/superadmin/CommerceHub.vue
Normal file
163
admin/src/views/superadmin/CommerceHub.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="commerce-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单和财务</h2>
|
||||
<p class="hub-subtitle">
|
||||
监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body">
|
||||
<Orders
|
||||
v-if="activeTab === 'orders'"
|
||||
embedded
|
||||
orders-api-path="/superadmin/orders"
|
||||
/>
|
||||
<Finance v-if="activeTab === 'finance'" embedded />
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded enterprise-procurement-only />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from '@/views/admin/Orders.vue'
|
||||
import Finance from './Finance.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'finance', 'pricing'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('orders')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '订单列表', value: 'orders' },
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '全局定价', value: 'pricing' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/commerce', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.commerce-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,483 +1,493 @@
|
||||
<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>
|
||||
<template>
|
||||
<div class="page-container" :class="{ 'is-embedded': embedded }">
|
||||
<div class="page-header" :class="{ 'header-embedded': embedded }">
|
||||
<div v-if="!embedded" 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'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.page-header.header-embedded {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
160
admin/src/views/superadmin/DistributionStandalone.vue
Normal file
160
admin/src/views/superadmin/DistributionStandalone.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="distribution-hub">
|
||||
<div class="hub-header">
|
||||
<h2>分销管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body">
|
||||
<Distribution v-if="activeTab === 'distribution'" embedded />
|
||||
<MpAnalytics v-if="activeTab === 'analytics'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Distribution from './Distribution.vue'
|
||||
import MpAnalytics from './MpAnalytics.vue'
|
||||
|
||||
const TAB_IDS = ['distribution', 'analytics'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('distribution')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '分销推广', value: 'distribution' },
|
||||
{ label: '小程序埋点', value: 'analytics' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'distribution'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'distribution') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({
|
||||
path: '/superadmin/distribution',
|
||||
query: Object.keys(q).length ? q : {}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.distribution-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
220
admin/src/views/superadmin/EnterpriseHub.vue
Normal file
220
admin/src/views/superadmin/EnterpriseHub.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="enterprise-hub">
|
||||
<div class="hub-header">
|
||||
<h2>企业管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="activeTab === 'users'"
|
||||
type="info"
|
||||
class="migrate-alert"
|
||||
show-icon
|
||||
:closable="false"
|
||||
>
|
||||
<template #title>数据库归并(可选)</template>
|
||||
<p class="migrate-text">
|
||||
若需把库里的「无 enterpriseId」记录<strong>永久写入</strong>到「存客宝」企业,可点下方按钮(先预览条数,再二次确认)。仅统计展示时不必执行,接口已把个人池并入存客宝卡片。
|
||||
</p>
|
||||
<el-button type="primary" plain size="small" :loading="migrateLoading" @click="runOrphanMigrate">
|
||||
预览并归并到存客宝
|
||||
</el-button>
|
||||
</el-alert>
|
||||
|
||||
<div class="hub-body">
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('companies')
|
||||
const migrateLoading = ref(false)
|
||||
const usersRefreshKey = ref(0)
|
||||
|
||||
async function runOrphanMigrate() {
|
||||
migrateLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
|
||||
dryRun: true
|
||||
})
|
||||
const d = res.data ?? res
|
||||
const name = d.enterpriseName || '存客宝'
|
||||
const tr = d.testResultsRows ?? 0
|
||||
const wu = d.wechatUsersRows ?? 0
|
||||
await ElMessageBox.confirm(
|
||||
`将把无企业归属的数据写入「${name}」:测试记录约 ${tr} 条,小程序用户表约 ${wu} 行。此操作会修改数据库,是否继续?`,
|
||||
'确认归并',
|
||||
{ type: 'warning', confirmButtonText: '写入', cancelButtonText: '取消' }
|
||||
)
|
||||
await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
|
||||
dryRun: false,
|
||||
confirm: true
|
||||
})
|
||||
ElMessage.success('归并完成,已刷新用户总览')
|
||||
usersRefreshKey.value += 1
|
||||
} catch (e: unknown) {
|
||||
if (e === 'cancel') return
|
||||
const msg = e instanceof Error ? e.message : '操作失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
migrateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'companies'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'companies') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/enterprises', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.enterprise-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.migrate-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.migrate-text {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,254 +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>
|
||||
<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/ops')
|
||||
} 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>
|
||||
|
||||
230
admin/src/views/superadmin/MpAnalytics.vue
Normal file
230
admin/src/views/superadmin/MpAnalytics.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="mp-analytics" :class="{ 'is-embedded': embedded }">
|
||||
<el-alert
|
||||
v-if="tableMissing"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="setup-alert"
|
||||
title="埋点数据表未就绪"
|
||||
description="请在数据库执行 api/database/migrations/add_mp_analytics_events.sql(表前缀需与 .env 中 DATABASE_PREFIX 一致),执行后刷新本页。"
|
||||
/>
|
||||
<div class="page-head" v-if="!embedded">
|
||||
<h2>小程序埋点</h2>
|
||||
<p class="sub">仅超级管理员可见;统计来自小程序上报的 page_view 与业务点击事件。</p>
|
||||
<div class="toolbar">
|
||||
<span class="label">统计范围</span>
|
||||
<el-select v-model="days" style="width: 120px" @change="loadAll">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="14" label="近 14 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="embedded-toolbar">
|
||||
<span class="hint">监督各端上报行为;page_view 与业务点击</span>
|
||||
<div class="toolbar">
|
||||
<span class="label">统计范围</span>
|
||||
<el-select v-model="days" style="width: 120px" @change="loadAll">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="14" label="近 14 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>事件汇总(共 {{ summaryTotal }} 条记录)</span>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="summaryList"
|
||||
stripe
|
||||
:empty-text="tableMissing ? '表未创建,见上方说明' : '暂无数据(小程序有访问后会出现统计)'"
|
||||
>
|
||||
<el-table-column prop="eventName" label="事件名" min-width="200" />
|
||||
<el-table-column prop="cnt" label="次数" width="120" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>最近明细</span>
|
||||
</template>
|
||||
<el-table v-loading="loadingEvents" :data="eventRows" stripe empty-text="暂无明细">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column prop="eventName" label="事件" min-width="140" />
|
||||
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column label="附加" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="props-preview">{{ formatProps(row.props) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="eventTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadEvents"
|
||||
@size-change="loadEvents"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const days = ref(7)
|
||||
const tableMissing = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingEvents = ref(false)
|
||||
const summaryList = ref<{ eventName: string; cnt: number }[]>([])
|
||||
const summaryTotal = ref(0)
|
||||
const eventRows = ref<any[]>([])
|
||||
const eventTotal = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
function formatProps(p: unknown): string {
|
||||
if (p == null) return '—'
|
||||
try {
|
||||
const s = JSON.stringify(p)
|
||||
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/summary', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
summaryList.value = res.data?.list || []
|
||||
summaryTotal.value = res.data?.total ?? 0
|
||||
tableMissing.value = !!res.data?.tableMissing
|
||||
} catch {
|
||||
summaryList.value = []
|
||||
summaryTotal.value = 0
|
||||
tableMissing.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/events', {
|
||||
params: {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
eventRows.value = res.data?.list || []
|
||||
eventTotal.value = res.data?.total ?? 0
|
||||
if (res.data?.tableMissing) {
|
||||
tableMissing.value = true
|
||||
}
|
||||
} catch {
|
||||
eventRows.value = []
|
||||
eventTotal.value = 0
|
||||
tableMissing.value = true
|
||||
} finally {
|
||||
loadingEvents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
page.value = 1
|
||||
await loadSummary()
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setup-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mp-analytics {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.mp-analytics.is-embedded {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 20px;
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.sub {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
.card-block {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.props-preview {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
46
admin/src/views/superadmin/OpsHub.vue
Normal file
46
admin/src/views/superadmin/OpsHub.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="ops-hub">
|
||||
<div class="hub-header">
|
||||
<h2>总览</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级数据总览,用于对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hub-body">
|
||||
<Overview />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Overview from './Overview.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ops-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
@@ -534,7 +535,7 @@ const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
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`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理系统配置、API密钥和管理员账户</p>
|
||||
<p class="subtitle">
|
||||
审核与站点、题库、数据库及超管账户等平台级配置(全局定价在「订单和财务」)。此处变更会间接影响各企业管理后台行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,13 +22,13 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
@click="selectTab(tab.value)"
|
||||
>
|
||||
<el-icon class="tab-icon"><component :is="tab.icon" /></el-icon>
|
||||
{{ tab.label }}
|
||||
@@ -34,7 +36,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card">
|
||||
<div
|
||||
class="tab-content-card"
|
||||
:class="{ 'flat-embed': isFlatEmbed }"
|
||||
>
|
||||
<!-- 审核模式 -->
|
||||
<div v-if="activeTab === 'review'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
@@ -464,30 +469,106 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'questions'" class="embed-wrap">
|
||||
<Questions embedded />
|
||||
</div>
|
||||
<div v-if="activeTab === 'database'" class="embed-wrap">
|
||||
<Database embedded />
|
||||
</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 { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Setting,
|
||||
Bell,
|
||||
Lock,
|
||||
Document,
|
||||
ChatDotRound,
|
||||
Postcard,
|
||||
DataLine,
|
||||
Reading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
import Questions from './Questions.vue'
|
||||
import Database from './Database.vue'
|
||||
|
||||
const activeTab = ref('review')
|
||||
const TAB_IDS = [
|
||||
'review',
|
||||
'system',
|
||||
'notification',
|
||||
'prompts',
|
||||
'poster',
|
||||
'security',
|
||||
'questions',
|
||||
'database'
|
||||
] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<TabId>('review')
|
||||
const saveSuccess = ref<string | null>(null)
|
||||
|
||||
const tabs = [
|
||||
const tabs: { label: string; value: TabId; icon: any }[] = [
|
||||
{ label: '审核模式', value: 'review', icon: Document },
|
||||
{ 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 }
|
||||
{ label: '账户安全', value: 'security', icon: Lock },
|
||||
{ label: '题库管理', value: 'questions', icon: Reading },
|
||||
{ label: '数据库', value: 'database', icon: DataLine }
|
||||
]
|
||||
|
||||
const isFlatEmbed = computed(
|
||||
() => activeTab.value === 'questions' || activeTab.value === 'database'
|
||||
)
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (t === 'pricing') {
|
||||
router.replace({ path: '/superadmin/commerce', query: { tab: 'pricing' } })
|
||||
return
|
||||
}
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'review'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'review') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/settings', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
// 审核模式
|
||||
const reviewMode = reactive({
|
||||
enabled: false
|
||||
@@ -603,6 +684,7 @@ async function loadEnterpriseOptions() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
loadSettings()
|
||||
loadEnterpriseOptions()
|
||||
})
|
||||
@@ -739,10 +821,22 @@ const handleSave = async (section: string) => {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
@@ -783,6 +877,17 @@ const handleSave = async (section: string) => {
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user