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:
卡若
2026-03-27 17:18:25 +08:00
parent 9601b6955b
commit aca2e263bb
188 changed files with 41937 additions and 27625 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View 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

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

View File

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

View File

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

View File

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

View 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

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

View File

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

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

View 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

View File

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

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

View 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

View File

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

View File

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