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

@@ -0,0 +1,11 @@
---
description: MBTI王 飞书里程碑须读卡若AI F01e Skill勿在项目内复制 Skill 正文)
globs: "**/*"
alwaysApply: false
---
在本仓库mbti王涉及「五角色 / 飞书里程碑 / 完整功能通报」时:
1. **必读**卡若AI`/Users/karuo/Documents/个人/卡若AI/04_卡火/火炬_全栈消息/开发五角色与飞书里程碑/SKILL.md`**F01e**,间名五方演岗)。**不要**在本仓库 `.cursor/skills/` 维护该 Skill 正文。
2. 推送:环境变量 `FEISHU_WEBHOOK_MBTI` + 薄封装 `scripts/feishu_mbti_milestone_notify.py`内部调用卡若AI `feishu_milestone_notify.py`)。
3. 仅在「完整功能可验收」时推送,禁止每次小改动都推。

View File

@@ -19,8 +19,32 @@ git commit -m "描述"
git push origin main
```
## 产品与后台边界
-`开发文档/1、需求/管理后台与产品目标对齐.md`(概览 / 用户运营 / 订单运营 / 系统设置 与小程序全链路文档对齐说明)
## 项目结构
- `admin/` - Vue 管理后台
- `api/` - ThinkPHP API
- `miniprogram/` - 微信小程序
## 本地开发(管理后台 + API
1. 启动 API需 PHP 8+,示例用 Homebrew 的 8.4
```bash
cd api/public
/usr/local/opt/php@8.4/bin/php -S 127.0.0.1:8787 router.php
```
2. 启动管理后台(`admin/.env.development` 已默认走代理,无需改 hosts
```bash
cd admin
npm install
npm run dev
```
浏览器打开 `http://localhost:5173` ,接口会经 Vite 转发到 `http://127.0.0.1:8787`。若 API 端口不是 8787可执行
`VITE_DEV_API_PROXY=http://127.0.0.1:端口 npm run dev`

View File

@@ -1,7 +1,8 @@
# 开发环境配置
# 测试环境API地址
VITE_API_BASE_URL=http://mbti.com
# 环境标识
VITE_APP_ENV=development
# 开发环境配置
# 留空:请求走当前站点相对路径 /api/v1由 vite.config.ts 代理到本机 PHP默认 127.0.0.1:8787避免依赖 hosts。
# 若直连远程 API再改为完整地址例如https://mbtiapi.quwanzhi.com
VITE_API_BASE_URL=
# 环境标识
VITE_APP_ENV=development
VITE_APP_TITLE=MBTI管理后台-测试环境

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

View File

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

View File

@@ -0,0 +1,325 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 飞书自定义机器人获客推送(对齐 Soul 存客宝卡片:标题/来源/对接人/姓名手机/时间/最近行为)
* 配置system_config key=feishu_lead_webhook enterprise_id=0 JSON
*/
class FeishuLeadWebhookService
{
public const CONFIG_KEY = 'feishu_lead_webhook';
public static function getConfig(): array
{
$def = [
'enabled' => false,
'webhookUrl' => '',
'contactPerson' => '运营',
];
$row = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->find();
if (!$row || empty($row['value'])) {
return $def;
}
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (!is_array($v)) {
return $def;
}
return array_merge($def, $v);
}
/**
* 支付成功(含测试付费、充值等):每订单仅推一次
*/
public static function onOrderPaid(int $orderDbId, int $userId): void
{
if ($orderDbId <= 0 || $userId <= 0) {
return;
}
$dedupKey = 'order_paid:' . $orderDbId;
$order = Db::name('orders')->where('id', $orderDbId)->find();
if (!$order) {
return;
}
$productType = (string) ($order['productType'] ?? '');
$amountFen = (int) ($order['amount'] ?? 0);
$title = (string) ($order['productTitle'] ?? '');
$source = self::sourceLabelForOrder($productType, $title, $amountFen);
self::pushLead([
'dedupKey' => $dedupKey,
'userId' => $userId,
'source' => $source,
'extraLine' => '订单号: ' . ($order['orderNo'] ?? '') . ' · 金额: ¥' . number_format($amountFen / 100, 2),
]);
}
/**
* 首次绑定手机号(测试完成留资)
*/
public static function onPhoneBound(int $userId, string $phone): void
{
if ($userId <= 0 || trim($phone) === '') {
return;
}
self::pushLead([
'dedupKey' => 'phone_bind:' . $userId,
'userId' => $userId,
'source' => '测试完成·授权手机号',
'phone' => $phone,
]);
}
/**
* @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
*/
public static function pushLead(array $p): void
{
$cfg = self::getConfig();
if (empty($cfg['enabled'])) {
return;
}
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
if ($url === '' || stripos($url, 'http') !== 0) {
return;
}
$dedupKey = $p['dedupKey'] ?? '';
if ($dedupKey === '') {
return;
}
if (!self::beginDedup($dedupKey)) {
return;
}
$userId = (int) ($p['userId'] ?? 0);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone')->find()
: null;
$nickname = trim((string) ($wu['nickname'] ?? ''));
if ($nickname === '') {
$nickname = '微信用户';
}
$phone = trim((string) ($p['phone'] ?? ($wu['phone'] ?? '')));
$contact = trim((string) ($cfg['contactPerson'] ?? '运营'));
if ($contact === '') {
$contact = '运营';
}
$source = (string) ($p['source'] ?? '小程序');
$now = date('Y-m-d H:i');
$text = "📋 新获客\n来源: {$source}\n对接人: {$contact}\n━━━━━━━━━━";
$text .= "\n姓名: {$nickname}";
if ($phone !== '') {
$text .= "\n手机: {$phone}";
}
$text .= "\n时间: {$now}";
if (!empty($p['extraLine'])) {
$text .= "\n" . $p['extraLine'];
}
$lines = self::recentBehaviorLines($userId, 8);
if (count($lines) > 0) {
$text .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
foreach ($lines as $line) {
$text .= "\n {$i}. {$line}";
$i++;
}
}
$ok = self::postWebhook($url, $text);
if (!$ok) {
self::rollbackDedup($dedupKey);
}
}
private static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string
{
if ($productType === 'recharge') {
return '企业余额·充值支付成功';
}
$map = [
'face' => '面相测试',
'mbti' => 'MBTI测试',
'disc' => 'DISC测试',
'pdp' => 'PDP测试',
'resume' => '简历分析',
'report' => '完整报告',
'team_analysis' => '团队分析',
'deep_personal' => '个人深度服务',
'deep_team' => '团队深度服务',
'vip' => 'VIP',
];
$label = $map[$productType] ?? strtoupper($productType);
$suffix = '·支付成功';
if ($title !== '') {
return $label . '·「' . self::oneLine($title, 40) . '」' . $suffix;
}
if ($amountFen === 100) {
return $label . '·1元支付' . $suffix;
}
return $label . $suffix;
}
private static function oneLine(string $s, int $max): string
{
$s = preg_replace('/\s+/u', ' ', trim($s));
if (mb_strlen($s) > $max) {
return mb_substr($s, 0, $max) . '…';
}
return $s;
}
private static function recentBehaviorLines(int $userId, int $limit): array
{
if ($userId <= 0) {
return [];
}
try {
$rows = Db::name('analytics_events')
->where('userId', $userId)
->order('id', 'desc')
->limit($limit)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($rows as $r) {
$out[] = self::formatAnalyticsLine($r);
}
return $out;
}
private static function formatAnalyticsLine(array $r): string
{
$name = (string) ($r['eventName'] ?? '');
$path = trim((string) ($r['pagePath'] ?? ''));
$props = [];
if (!empty($r['propsJson'])) {
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
$props = is_array($decoded) ? $decoded : [];
}
$labelMap = [
'page_view' => '浏览页面',
'button_click' => '按钮点击',
'click_pay' => '发起支付',
'click_recharge'=> '点击充值',
];
$label = $labelMap[$name] ?? $name;
$detail = '';
if ($name === 'page_view' && $path !== '') {
$detail = $path;
}
if (isset($props['action']) && (string) $props['action'] !== '') {
$detail = (string) $props['action'];
if (!empty($props['productType'])) {
$detail .= ' · ' . (string) $props['productType'];
}
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
$detail = (string) $props['label'];
} elseif ($path !== '' && $detail === '') {
$detail = $path;
}
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
if (!$ts && !empty($r['createdAt'])) {
$ts = strtotime((string) $r['createdAt']) * 1000;
}
if ($ts) {
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
}
return $line;
}
private static function humanTimeAgoCn(int $clientTsMs): string
{
$now = (int) (microtime(true) * 1000);
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
if ($sec < 60) {
return '刚刚';
}
if ($sec < 3600) {
return (int) floor($sec / 60) . '分钟前';
}
if ($sec < 86400) {
return (int) floor($sec / 3600) . '小时前';
}
return (int) floor($sec / 86400) . '天前';
}
private static function beginDedup(string $dedupKey): bool
{
try {
Db::name('feishu_lead_dedup')->insert([
'dedupKey' => $dedupKey,
'createdAt' => date('Y-m-d H:i:s'),
]);
return true;
} catch (\Throwable $e) {
return false;
}
}
private static function rollbackDedup(string $dedupKey): void
{
try {
Db::name('feishu_lead_dedup')->where('dedupKey', $dedupKey)->delete();
} catch (\Throwable $e) {
}
}
private static function postWebhook(string $url, string $text): bool
{
$payload = [];
if (stripos($url, 'qyapi.weixin.qq.com') !== false) {
$payload = [
'msgtype' => 'text',
'text' => ['content' => $text],
];
} else {
$payload = [
'msg_type' => 'text',
'content' => ['text' => $text],
];
}
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
if ($ch === false) {
return false;
}
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) {
return false;
}
if ($body !== false && $body !== '') {
$resp = json_decode($body, true);
if (is_array($resp)) {
if (isset($resp['code']) && (int) $resp['code'] !== 0) {
return false;
}
if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) {
return false;
}
}
}
return true;
}
}

View File

@@ -1,358 +1,261 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 测试用户(小程序用户)管理 - 只读列表与详情
* 数据来源wechat_users测试记录来自 test_resultsuserId 关联 wechat_users.id
*/
class AppUser extends BaseController
{
/**
* 测试用户列表:分页、关键词搜索
* GET /api/v1/admin/app-users?page=1&pageSize=20&keyword=
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
// admin / enterprise_admin 均只能看本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
// JWT 未含 enterpriseId 时回退查库(兼容旧 token
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
// 若有企业ID先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
$profileUserIds = [];
if ($enterpriseId) {
$profileUserIds = Db::name('user_profile')
->where('enterpriseId', $enterpriseId)
->column('userId');
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
if (empty($profileUserIds)) {
return paginate_response([], 0, $page, $pageSize);
}
}
// 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
if (!empty($profileUserIds)) {
$baseQuery->whereIn('id', $profileUserIds);
}
if ($where) {
$baseQuery->where($where);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 为每条用户附加测试统计test_results.userId 对应 wechat_users.id
$ids = array_column($list, 'id');
$testCounts = [];
$lastTestAt = [];
$testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
$payStats = [];
$enterpriseName = null;
if ($enterpriseId) {
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
}
if (!empty($ids)) {
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$trBase->where('enterpriseId', $enterpriseId);
}
$counts = (clone $trBase)
->group('userId')
->column('COUNT(*) as cnt', 'userId');
$testCounts = $counts ?: [];
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->select();
foreach ($lastRows as $row) {
$uid = $row['userId'];
if (!isset($lastTestAt[$uid])) {
$lastTestAt[$uid] = $row['createdAt'];
}
if (!isset($testTypes[$uid])) {
$testTypes[$uid] = [];
}
$testTypes[$uid][] = [
'testType' => $row['testType'],
'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
'createdAt' => $row['createdAt'],
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
];
}
// 付款统计user_profile按当前企业过滤
try {
$profilesQuery = Db::name('user_profile')
->where('userId', 'in', $ids);
if ($enterpriseId) {
$profilesQuery->where('enterpriseId', $enterpriseId);
}
$profiles = $profilesQuery
->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
->group('userId')
->select()
->toArray();
foreach ($profiles as $p) {
$uid = (int) ($p['userId'] ?? 0);
if ($uid > 0) {
$payStats[$uid] = [
'paidOrders' => (int) ($p['paidOrders'] ?? 0),
'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
];
}
}
} catch (\Throwable $e) {
$payStats = [];
}
}
foreach ($list as &$row) {
$id = $row['id'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
$row['lastTestAt'] = $lastTestAt[$id] ?? null;
$row['tests'] = $testsForUser;
$row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
$row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
$row['discType'] = $this->extractResultType($testsForUser, 'disc');
$row['faceType'] = $this->extractResultType($testsForUser, 'face');
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
$row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
$row['enterprise'] = $enterpriseName !== null ? $enterpriseName : '全部';
$pay = $payStats[$id] ?? null;
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* 测试用户详情:基本信息 + 测试记录列表
* GET /api/v1/admin/app-users/:id
*/
public function detail($id)
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// admin / enterprise_admin 均只能查看本企业的用户
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
if ($enterpriseId) {
// 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
$has = Db::name('user_profile')
->where('userId', $id)
->where('enterpriseId', $enterpriseId)
->find();
if (!$has) {
return error('无权限查看该用户', 403);
}
}
$row = Db::name('wechat_users')->where('id', $id)->find();
if (!$row) {
return error('用户不存在', 404);
}
$data = [
'id' => (int) $row['id'],
'username' => $row['nickname'] ?? ('用户' . $row['id']),
'nickname' => $row['nickname'] ?? '',
'avatar' => $row['avatar'] ?? '',
'phone' => $row['phone'] ?? '',
'email' => '',
'gender' => (int) ($row['gender'] ?? 0),
'country' => $row['country'] ?? '',
'province' => $row['province'] ?? '',
'city' => $row['city'] ?? '',
'status' => (int) ($row['status'] ?? 1),
'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
];
// 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
$testQuery = Db::name('test_results')->where('userId', $id);
if ($enterpriseId) {
$testQuery->where('enterpriseId', $enterpriseId);
}
$tests = $testQuery
->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
->order('createdAt', 'desc')
->select()
->toArray();
foreach ($tests as &$t) {
$raw = $t['resultData'] ?? '';
$t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
unset($t['testEnterpriseId']);
}
$data['testCount'] = count($tests);
$data['testList'] = $tests;
$data['mbtiType'] = $this->extractResultType($tests, 'mbti');
$data['pdpType'] = $this->extractResultType($tests, 'pdp');
$data['discType'] = $this->extractResultType($tests, 'disc');
$data['faceType'] = $this->extractResultType($tests, 'face');
$data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
return success($data);
}
/**
* 从测试记录中取出某类型的最近结果result 可能是 JSON 字符串,取 type 或 result 字段)
*/
private function extractResultType(array $tests, string $type): string
{
$targetType = strtolower($type);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== $targetType) {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
return $targetType === 'face' ? '人脸分析' : trim($result);
}
if ($targetType === 'face') {
return '人脸分析';
}
if ($targetType === 'mbti') {
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
}
if ($targetType === 'disc') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['disc'] ?? '');
}
if ($targetType === 'pdp') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['pdp'] ?? '');
}
return (string) ($dec['type'] ?? $dec['result'] ?? '');
}
return '';
}
/**
* 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
*/
private function extractFaceSubType(array $tests, string $subType): string
{
$target = strtolower($subType);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== 'face') {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
continue;
}
if ($target === 'mbti') {
if (!empty($dec['mbti']['type'])) {
return (string) $dec['mbti']['type'];
}
if (!empty($dec['mbtiType'])) {
return (string) $dec['mbtiType'];
}
} elseif ($target === 'disc') {
if (!empty($dec['disc']['primary'])) {
return (string) $dec['disc']['primary'];
}
if (!empty($dec['disc'])) {
return (string) $dec['disc'];
}
} elseif ($target === 'pdp') {
if (!empty($dec['pdp']['primary'])) {
return (string) $dec['pdp']['primary'];
}
if (!empty($dec['pdp'])) {
return (string) $dec['pdp'];
}
}
}
return '';
}
}
<?php
namespace app\controller\admin;
use app\BaseController;
use app\controller\admin\concern\ExtractsTestResults;
use think\facade\Db;
use think\facade\Request;
/**
* 测试用户(小程序用户)管理 - 只读列表与详情
* 数据来源wechat_users测试记录来自 test_resultsuserId 关联 wechat_users.id
*/
class AppUser extends BaseController
{
use ExtractsTestResults;
/**
* 测试用户列表:分页、关键词搜索
* GET /api/v1/admin/app-users?page=1&pageSize=20&keyword=
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
// admin / enterprise_admin 均只能看本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
// JWT 未含 enterpriseId 时回退查库(兼容旧 token
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
// 若有企业ID先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
$profileUserIds = [];
if ($enterpriseId) {
$profileUserIds = Db::name('user_profile')
->where('enterpriseId', $enterpriseId)
->column('userId');
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
if (empty($profileUserIds)) {
return paginate_response([], 0, $page, $pageSize);
}
}
// 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
if (!empty($profileUserIds)) {
$baseQuery->whereIn('id', $profileUserIds);
}
if ($where) {
$baseQuery->where($where);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 为每条用户附加测试统计test_results.userId 对应 wechat_users.id
$ids = array_column($list, 'id');
$testCounts = [];
$lastTestAt = [];
$testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
$payStats = [];
$enterpriseName = null;
if ($enterpriseId) {
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
}
if (!empty($ids)) {
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$trBase->where('enterpriseId', $enterpriseId);
}
$counts = (clone $trBase)
->group('userId')
->column('COUNT(*) as cnt', 'userId');
$testCounts = $counts ?: [];
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->select();
foreach ($lastRows as $row) {
$uid = $row['userId'];
if (!isset($lastTestAt[$uid])) {
$lastTestAt[$uid] = $row['createdAt'];
}
if (!isset($testTypes[$uid])) {
$testTypes[$uid] = [];
}
$testTypes[$uid][] = [
'testType' => $row['testType'],
'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
'createdAt' => $row['createdAt'],
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
];
}
// 付款统计:user_profile(按当前企业过滤)
try {
$profilesQuery = Db::name('user_profile')
->where('userId', 'in', $ids);
if ($enterpriseId) {
$profilesQuery->where('enterpriseId', $enterpriseId);
}
$profiles = $profilesQuery
->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
->group('userId')
->select()
->toArray();
foreach ($profiles as $p) {
$uid = (int) ($p['userId'] ?? 0);
if ($uid > 0) {
$payStats[$uid] = [
'paidOrders' => (int) ($p['paidOrders'] ?? 0),
'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
];
}
}
} catch (\Throwable $e) {
$payStats = [];
}
}
foreach ($list as &$row) {
$id = $row['id'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
$row['lastTestAt'] = $lastTestAt[$id] ?? null;
$row['tests'] = $testsForUser;
$row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
$row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
$row['discType'] = $this->extractResultType($testsForUser, 'disc');
$row['faceType'] = $this->extractResultType($testsForUser, 'face');
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
$row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
$row['enterprise'] = $enterpriseName !== null ? $enterpriseName : '全部';
$pay = $payStats[$id] ?? null;
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* 测试用户详情:基本信息 + 测试记录列表
* GET /api/v1/admin/app-users/:id
*/
public function detail($id)
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// admin / enterprise_admin 均只能查看本企业的用户
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
if ($enterpriseId) {
// 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
$has = Db::name('user_profile')
->where('userId', $id)
->where('enterpriseId', $enterpriseId)
->find();
if (!$has) {
return error('无权限查看该用户', 403);
}
}
$row = Db::name('wechat_users')->where('id', $id)->find();
if (!$row) {
return error('用户不存在', 404);
}
$data = [
'id' => (int) $row['id'],
'username' => $row['nickname'] ?? ('用户' . $row['id']),
'nickname' => $row['nickname'] ?? '',
'avatar' => $row['avatar'] ?? '',
'phone' => $row['phone'] ?? '',
'email' => '',
'gender' => (int) ($row['gender'] ?? 0),
'country' => $row['country'] ?? '',
'province' => $row['province'] ?? '',
'city' => $row['city'] ?? '',
'status' => (int) ($row['status'] ?? 1),
'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
];
// 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
$testQuery = Db::name('test_results')->where('userId', $id);
if ($enterpriseId) {
$testQuery->where('enterpriseId', $enterpriseId);
}
$tests = $testQuery
->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
->order('createdAt', 'desc')
->select()
->toArray();
foreach ($tests as &$t) {
$raw = $t['resultData'] ?? '';
$t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
unset($t['testEnterpriseId']);
}
$data['testCount'] = count($tests);
$data['testList'] = $tests;
$data['mbtiType'] = $this->extractResultType($tests, 'mbti');
$data['pdpType'] = $this->extractResultType($tests, 'pdp');
$data['discType'] = $this->extractResultType($tests, 'disc');
$data['faceType'] = $this->extractResultType($tests, 'face');
$data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
return success($data);
}
}

View File

@@ -1,179 +1,277 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 数据概览控制器(普通管理员)
*/
class Dashboard extends BaseController
{
/**
* 获取统计数据
* @return \think\response\Json
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
// admin / enterprise_admin 均只统计本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
// 企业用户 ID 集合(用于后续统计个人版测试)
$enterpriseUserIds = [];
if ($enterpriseId) {
$enterpriseUserIds = Db::name('wechat_users')
->where('enterpriseId', $enterpriseId)
->column('id');
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
}
// 总用户数wechat_users.enterpriseId = 本企业
if ($enterpriseId) {
$totalUsers = count($enterpriseUserIds);
} else {
try {
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
} catch (\Throwable $e) {
$totalUsers = (int) Db::name('wechat_users')->count();
}
}
// 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
if ($enterpriseId) {
$testsCompleted = (int) Db::name('test_results')
->where('enterpriseId', $enterpriseId)
->count();
} else {
$testsCompleted = (int) Db::name('test_results')->count();
}
// 今日活跃用户数
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$activeQuery = Db::name('test_results')
->where('createdAt', '>=', $todayStart)
->where('createdAt', '<=', $todayEnd);
if ($enterpriseId) {
$activeQuery->where('enterpriseId', $enterpriseId);
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
} else {
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
}
// 待审核暂返回0
$pendingReviews = 0;
// 最近 14 天测试趋势
$days = 14;
$startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
$trendQuery = Db::name('test_results')
->where('createdAt', '>=', $startDate)
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
if ($enterpriseId) {
$trendQuery->where('enterpriseId', $enterpriseId);
}
$trendRows = $trendQuery
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
->group('d,testType')
->order('d', 'asc')
->select()
->toArray();
// 组装为按日期汇总的数组
$trendMap = [];
foreach ($trendRows as $row) {
$d = $row['d'];
$type = $row['testType'];
$cnt = (int) ($row['c'] ?? 0);
if (!isset($trendMap[$d])) {
$trendMap[$d] = [
'date' => $d,
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'total' => 0,
];
}
if (in_array($type, ['face', 'mbti', 'disc', 'pdp'], true)) {
$trendMap[$d][$type] += $cnt;
$trendMap[$d]['total'] += $cnt;
}
}
// 补齐没有数据的日期
$trendData = [];
for ($i = 0; $i < $days; $i++) {
$d = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days'));
if (isset($trendMap[$d])) {
$trendData[] = $trendMap[$d];
} else {
$trendData[] = [
'date' => $d,
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'total' => 0,
];
}
}
return success([
'totalUsers' => $totalUsers,
'testsCompleted' => $testsCompleted,
'activeToday' => $activeToday,
'pendingReviews' => $pendingReviews,
'testTrends' => $trendData,
]);
} catch (\Exception $e) {
return error('获取统计数据失败:' . $e->getMessage(), 500);
}
}
/**
* 格式化时间
* @param int $timestamp
* @return string
*/
private function formatTime($timestamp)
{
if (!$timestamp) {
return '';
}
$now = time();
$diff = $now - $timestamp;
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} elseif ($diff < 604800) {
return floor($diff / 86400) . '天前';
} else {
return date('Y-m-d H:i', $timestamp);
}
}
}
<?php
namespace app\controller\admin;
use app\BaseController;
use app\controller\admin\concern\ExtractsTestResults;
use think\facade\Db;
use think\facade\Request;
/**
* 数据概览控制器(普通管理员)
*/
class Dashboard extends BaseController
{
use ExtractsTestResults;
/**
* 获取统计数据
* @return \think\response\Json
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
// admin / enterprise_admin 均只统计本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
// 企业用户 ID 集合(用于后续统计个人版测试)
$enterpriseUserIds = [];
if ($enterpriseId) {
$enterpriseUserIds = Db::name('wechat_users')
->where('enterpriseId', $enterpriseId)
->column('id');
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
}
// 总用户数wechat_users.enterpriseId = 本企业
if ($enterpriseId) {
$totalUsers = count($enterpriseUserIds);
} else {
try {
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
} catch (\Throwable $e) {
$totalUsers = (int) Db::name('wechat_users')->count();
}
}
// 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
if ($enterpriseId) {
$testsCompleted = (int) Db::name('test_results')
->where('enterpriseId', $enterpriseId)
->count();
} else {
$testsCompleted = (int) Db::name('test_results')->count();
}
// 今日活跃用户数
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$activeQuery = Db::name('test_results')
->where('createdAt', '>=', $todayStart)
->where('createdAt', '<=', $todayEnd);
if ($enterpriseId) {
$activeQuery->where('enterpriseId', $enterpriseId);
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
} else {
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
}
// 待审核暂返回0
$pendingReviews = 0;
// 最近 14 天测试趋势
$days = 14;
$startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
$trendQuery = Db::name('test_results')
->where('createdAt', '>=', $startDate)
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
if ($enterpriseId) {
$trendQuery->where('enterpriseId', $enterpriseId);
}
$trendRows = $trendQuery
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
->group('d,testType')
->order('d', 'asc')
->select()
->toArray();
// 组装为按日期汇总的数组
$trendMap = [];
foreach ($trendRows as $row) {
$d = $row['d'];
$type = $row['testType'];
$cnt = (int) ($row['c'] ?? 0);
if (!isset($trendMap[$d])) {
$trendMap[$d] = [
'date' => $d,
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'total' => 0,
];
}
if (in_array($type, ['face', 'mbti', 'disc', 'pdp'], true)) {
$trendMap[$d][$type] += $cnt;
$trendMap[$d]['total'] += $cnt;
}
}
// 补齐没有数据的日期
$trendData = [];
for ($i = 0; $i < $days; $i++) {
$d = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days'));
if (isset($trendMap[$d])) {
$trendData[] = $trendMap[$d];
} else {
$trendData[] = [
'date' => $d,
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'total' => 0,
];
}
}
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
return success([
'totalUsers' => $totalUsers,
'testsCompleted' => $testsCompleted,
'activeToday' => $activeToday,
'pendingReviews' => $pendingReviews,
'testTrends' => $trendData,
'topTestUsers' => $topTestUsers,
]);
} catch (\Exception $e) {
return error('获取统计数据失败:' . $e->getMessage(), 500);
}
}
/**
* 按测试完成次数排序,取前 N 名小程序用户与列表页口径一致test_results 按企业过滤)
*/
private function buildTopTestUsers(?int $enterpriseId, int $limit = 10): array
{
$limit = min(max($limit, 1), 50);
$q = Db::name('test_results')->field('userId, COUNT(*) as cnt')->group('userId')->order('cnt', 'desc')->limit($limit);
if ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
}
$rankRows = $q->select()->toArray();
if (empty($rankRows)) {
return [];
}
$uids = array_values(array_filter(array_map(static function ($r) {
return (int) ($r['userId'] ?? 0);
}, $rankRows)));
$countMap = [];
foreach ($rankRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$countMap[$uid] = (int) ($r['cnt'] ?? 0);
}
}
if (empty($uids)) {
return [];
}
$users = Db::name('wechat_users')
->whereIn('id', $uids)
->field('id,nickname,phone,avatar,createdAt')
->select()
->toArray();
$userMap = [];
foreach ($users as $u) {
$userMap[(int) $u['id']] = $u;
}
$trQuery = Db::name('test_results')->whereIn('userId', $uids);
if ($enterpriseId) {
$trQuery->where('enterpriseId', $enterpriseId);
}
$testRows = $trQuery
->field('userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->select()
->toArray();
$testsByUser = [];
foreach ($testRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) {
continue;
}
if (!isset($testsByUser[$uid])) {
$testsByUser[$uid] = [];
}
$raw = $row['resultData'] ?? '';
$testsByUser[$uid][] = [
'testType' => $row['testType'] ?? '',
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
'createdAt' => (int) ($row['createdAt'] ?? 0),
];
}
$out = [];
foreach ($uids as $uid) {
$wu = $userMap[$uid] ?? null;
$tests = $testsByUser[$uid] ?? [];
$lastAt = 0;
foreach ($tests as $t) {
$lastAt = max($lastAt, (int) ($t['createdAt'] ?? 0));
}
$out[] = [
'id' => $uid,
'username' => $wu ? ($wu['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid),
'nickname' => $wu ? ($wu['nickname'] ?? '') : '',
'phone' => $wu ? ($wu['phone'] ?? '') : '',
'avatar' => $wu ? ($wu['avatar'] ?? '') : '',
'testCount' => $countMap[$uid] ?? 0,
'lastTestAt' => $lastAt > 0 ? $lastAt : null,
'mbtiType' => $this->extractResultType($tests, 'mbti'),
'pdpType' => $this->extractResultType($tests, 'pdp'),
'discType' => $this->extractResultType($tests, 'disc'),
'faceMbtiType' => $this->extractFaceSubType($tests, 'mbti'),
'faceDiscType' => $this->extractFaceSubType($tests, 'disc'),
'facePdpType' => $this->extractFaceSubType($tests, 'pdp'),
];
}
return $out;
}
/**
* 格式化时间
* @param int $timestamp
* @return string
*/
private function formatTime($timestamp)
{
if (!$timestamp) {
return '';
}
$now = time();
$diff = $now - $timestamp;
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} elseif ($diff < 604800) {
return floor($diff / 86400) . '天前';
} else {
return date('Y-m-d H:i', $timestamp);
}
}
}

View File

@@ -1,168 +1,171 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 管理端订单列表(只读),包含用户信息与关联的测试数据
*/
class Order extends BaseController
{
/**
* 订单列表:分页、关键词、状态/产品筛选;企业管理员仅本企业订单
* GET /api/v1/admin/orders?page=1&pageSize=20&keyword=&status=&productType=
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$status = trim(Request::param('status', ''));
$productType = trim(Request::param('productType', ''));
// admin / enterprise_admin 均只能看本企业订单
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
$query = Db::name('orders');
if ($enterpriseId !== null) {
$query->where('enterpriseId', $enterpriseId);
}
if ($status !== '') {
$query->where('status', $status);
}
if ($productType !== '') {
$query->where('productType', $productType);
}
if ($keyword !== '') {
if (is_numeric($keyword)) {
$query->where(function ($q) use ($keyword) {
$q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
});
} else {
$userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
$userIdsMatch = array_values(array_filter($userIdsMatch));
$query->where(function ($q) use ($keyword, $userIdsMatch) {
$q->whereLike('orderNo', '%' . $keyword . '%');
if (!empty($userIdsMatch)) {
$q->whereOr('userId', 'in', $userIdsMatch);
}
});
}
}
$query->order('createdAt', 'desc');
$total = (int) (clone $query)->count();
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
$userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
$usersMap = [];
if (!empty($userIds)) {
$users = Db::name('wechat_users')
->where('id', 'in', $userIds)
->field('id, nickname, phone')
->select()
->toArray();
foreach ($users as $u) {
$usersMap[(int) $u['id']] = $u;
}
}
$orderIds = array_column($list, 'id');
$testsByOrder = [];
if (!empty($orderIds)) {
$tests = Db::name('test_results')
->where('orderId', 'in', $orderIds)
->field('id, orderId, userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->select()
->toArray();
foreach ($tests as $t) {
$oid = (int) ($t['orderId'] ?? 0);
if ($oid <= 0) {
continue;
}
if (!isset($testsByOrder[$oid])) {
$testsByOrder[$oid] = [];
}
$raw = $t['resultData'] ?? '';
$resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$testsByOrder[$oid][] = [
'id' => (int) $t['id'],
'testType' => $t['testType'] ?? '',
'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
];
}
}
foreach ($list as &$row) {
$uid = (int) ($row['userId'] ?? 0);
$u = $usersMap[$uid] ?? null;
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
$row['testData'] = $testsByOrder[$row['id']] ?? [];
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* 从 resultData 字符串中提取简要结果(用于列表展示)
*/
private function extractResultSummary(string $testType, string $resultStr): string
{
if ($resultStr === '') {
return '-';
}
$data = json_decode($resultStr, true);
if (!is_array($data)) {
return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
}
$type = strtolower($testType);
if ($type === 'mbti') {
return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
}
if ($type === 'disc') {
$desc = $data['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($data['dominantType'])) {
return (string) $data['dominantType'] . '型';
}
return (string) ($data['disc'] ?? '');
}
if ($type === 'pdp') {
$desc = $data['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($data['dominantType'])) {
return (string) $data['dominantType'];
}
return (string) ($data['pdp'] ?? '');
}
if ($type === 'face' || $type === 'ai') {
return '人脸分析';
}
return (string) ($data['type'] ?? $data['result'] ?? '');
}
}
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 管理端订单列表(只读),包含用户信息与关联的测试数据
*/
class Order extends BaseController
{
/**
* 订单列表:分页、关键词、状态/产品筛选;企业管理员仅本企业订单
* GET /api/v1/admin/orders?page=1&pageSize=20&keyword=&status=&productType=
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin', 'superadmin'])) {
return error('无权限访问', 403);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$status = trim(Request::param('status', ''));
$productType = trim(Request::param('productType', ''));
// 超管:全平台订单;其余管理员仅本企业
$enterpriseId = null;
if (($user['role'] ?? '') !== 'superadmin') {
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
}
$query = Db::name('orders');
if ($enterpriseId !== null) {
$query->where('enterpriseId', $enterpriseId);
}
if ($status !== '') {
$query->where('status', $status);
}
if ($productType !== '') {
$query->where('productType', $productType);
}
if ($keyword !== '') {
if (is_numeric($keyword)) {
$query->where(function ($q) use ($keyword) {
$q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
});
} else {
$userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
$userIdsMatch = array_values(array_filter($userIdsMatch));
$query->where(function ($q) use ($keyword, $userIdsMatch) {
$q->whereLike('orderNo', '%' . $keyword . '%');
if (!empty($userIdsMatch)) {
$q->whereOr('userId', 'in', $userIdsMatch);
}
});
}
}
$query->order('createdAt', 'desc');
$total = (int) (clone $query)->count();
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
$userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
$usersMap = [];
if (!empty($userIds)) {
$users = Db::name('wechat_users')
->where('id', 'in', $userIds)
->field('id, nickname, phone')
->select()
->toArray();
foreach ($users as $u) {
$usersMap[(int) $u['id']] = $u;
}
}
$orderIds = array_column($list, 'id');
$testsByOrder = [];
if (!empty($orderIds)) {
$tests = Db::name('test_results')
->where('orderId', 'in', $orderIds)
->field('id, orderId, userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->select()
->toArray();
foreach ($tests as $t) {
$oid = (int) ($t['orderId'] ?? 0);
if ($oid <= 0) {
continue;
}
if (!isset($testsByOrder[$oid])) {
$testsByOrder[$oid] = [];
}
$raw = $t['resultData'] ?? '';
$resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$testsByOrder[$oid][] = [
'id' => (int) $t['id'],
'testType' => $t['testType'] ?? '',
'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
];
}
}
foreach ($list as &$row) {
$uid = (int) ($row['userId'] ?? 0);
$u = $usersMap[$uid] ?? null;
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
$row['testData'] = $testsByOrder[$row['id']] ?? [];
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* 从 resultData 字符串中提取简要结果(用于列表展示)
*/
private function extractResultSummary(string $testType, string $resultStr): string
{
if ($resultStr === '') {
return '-';
}
$data = json_decode($resultStr, true);
if (!is_array($data)) {
return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
}
$type = strtolower($testType);
if ($type === 'mbti') {
return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
}
if ($type === 'disc') {
$desc = $data['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($data['dominantType'])) {
return (string) $data['dominantType'] . '型';
}
return (string) ($data['disc'] ?? '');
}
if ($type === 'pdp') {
$desc = $data['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($data['dominantType'])) {
return (string) $data['dominantType'];
}
return (string) ($data['pdp'] ?? '');
}
if ($type === 'face' || $type === 'ai') {
return '人脸分析';
}
return (string) ($data['type'] ?? $data['result'] ?? '');
}
}

View File

@@ -48,10 +48,14 @@ class Question extends BaseController
// 如果指定了企业ID优先查询企业题库
// 如果没有企业题库则查询超管题库enterpriseId = NULL
if ($enterpriseId !== null) {
// 先检查企业是否有自己的题库
$enterpriseQuestionCount = QuestionModel::where('enterpriseId', $enterpriseId)
->where('type', $type ?: ['mbti', 'disc', 'pdp'])
->count();
// 先检查企业是否有自己的题库(未指定 type 时需统计 mbti/disc/pdp 三类)
$countQuery = QuestionModel::where('enterpriseId', $enterpriseId);
if ($type !== '') {
$countQuery->where('type', $type);
} else {
$countQuery->whereIn('type', ['mbti', 'disc', 'pdp']);
}
$enterpriseQuestionCount = $countQuery->count();
if ($enterpriseQuestionCount > 0) {
// 使用企业题库

View File

@@ -1,418 +1,486 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use think\facade\Request;
use think\facade\Db;
/**
* 系统设置控制器(普通管理员)
*/
class Settings extends BaseController
{
/**
* 获取系统配置
* @return \think\response\Json
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
// 获取当前管理员用户名
$jwtUsername = $user['username'] ?? null;
$username = 'admin';
if ($jwtUsername) {
$currentUser = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if ($currentUser) {
$username = $currentUser->username;
} else {
$username = $jwtUsername;
}
}
return success([
'username' => $username
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 获取可用字体列表
* GET /api/v1/admin/settings/fonts
*/
public function getFonts()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$fonts = \app\common\service\PosterService::getAvailableFonts();
return success([
'fonts' => $fonts,
'fontDir' => root_path() . 'public/fonts/',
'dirExist' => is_dir(root_path() . 'public/fonts/'),
]);
}
/**
* 获取海报配置
* GET /api/v1/admin/settings/poster
* 有 enterpriseId 则读企业专属行否则读全局enterprise_id=0
*/
public function getPosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = (int)($user['enterpriseId'] ?? 0);
$row = self::getConfig('poster_config', $eid);
$poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
return success(['poster' => $poster]);
}
/**
* 保存海报配置
* PUT /api/v1/admin/settings/poster
*/
public function updatePosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$input = json_decode($this->request->getContent(), true);
if (!is_array($input)) {
$input = [];
}
$data = [
'bgColor' => $input['bgColor'] ?? '#ffffff',
'bgImage' => $input['bgImage'] ?? '',
'elements' => $input['elements'] ?? []
];
$eid = (int)($user['enterpriseId'] ?? 0);
try {
self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
return success(null, '海报配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 读取配置key + enterprise_id有企业专属则取否则降级到 enterprise_id=0
*/
private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
{
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
if ($fallbackGlobal && $enterpriseId > 0) {
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
}
return null;
}
/**
* 保存配置key + enterprise_id存在则 update否则 insert
*/
private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
{
$now = time();
$json = json_encode($value, JSON_UNESCAPED_UNICODE);
$exists = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => $enterpriseId,
'value' => $json,
'description' => $description,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
}
/**
* 安全解码 JSON处理可能的多重编码
*/
private static function decodeJsonSafe($raw): ?array
{
if (!$raw) return null;
$val = $raw;
for ($i = 0; $i < 5 && is_string($val); $i++) {
$decoded = json_decode($val, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break;
$val = $decoded;
}
return is_array($val) ? $val : null;
}
/**
* 获取小程序配置
* 读取全局 text_configenterprise_id=0作为默认值再用企业专属行覆盖
* GET /api/v1/admin/settings/miniprogram
*/
public function getMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
$eid = (int)($user['enterpriseId'] ?? 0);
// 全局小程序名称(仅超管可改,此处只读)
$miniprogramName = '神仙团队AI性格测试';
$siteInfo = Db::name('system_config')
->where('key', 'site_info')
->where('enterprise_id', 0)
->find();
if ($siteInfo && !empty($siteInfo['value'])) {
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
$miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
}
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonEnterprise' => '开始面测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
// 全局文案enterprise_id=0作为基础
$globalTc = self::getConfig('text_config', 0);
$textConfigData = $globalTc
? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
: $tcDefaults;
// 企业专属文案 + 小程序名称 覆盖
if ($eid > 0) {
$eidTc = self::getConfig('text_config', $eid);
if ($eidTc) {
$textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
if (!empty($eidTc['miniprogramName'])) {
$miniprogramName = (string) $eidTc['miniprogramName'];
}
}
}
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $textConfigData,
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 更新小程序配置
* 写入 text_config 行enterprise_id={eid}(有企业)或 0无企业
* PUT /api/v1/admin/settings/miniprogram
*/
public function updateMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [
'miniprogramName' => Request::param('miniprogramName', ''),
'textConfig' => Request::param('textConfig', []),
];
}
$miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
$textConfig = $input['textConfig'] ?? [];
if ($miniprogramName === '') {
return error('小程序名称不能为空', 400);
}
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
$tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
$tcMerge = array_merge($tcDefaults, $tcData);
$eid = (int)($user['enterpriseId'] ?? 0);
try {
// eid=0更新 site_info 的小程序名称(全局)
if ($eid === 0) {
$siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
$siteInfo = $siteRow && !empty($siteRow['value'])
? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
: [];
$siteInfo = is_array($siteInfo) ? $siteInfo : [];
$siteInfo['miniprogramName'] = $miniprogramName;
$siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
$siteInfo['updatedAt'] = time();
self::saveConfig('site_info', $siteInfo, 0, '站点信息');
} else {
// 企业专属:把 miniprogramName 一并写入 text_config
$tcMerge['miniprogramName'] = $miniprogramName;
}
// 统一写到 text_config企业行已含 miniprogramName全局行不含
self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid}" : '小程序文案配置(全局)');
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $tcMerge,
], '小程序配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新管理员账户信息
* @return \think\response\Json
*/
public function updateCredentials()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// 兼容 axios JSON PUT 与表单提交
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [];
}
$username = trim((string)($input['username'] ?? Request::param('username', '')));
$currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', ''));
$newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', ''));
$confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', ''));
if (empty($username)) {
return error('用户名不能为空', 400);
}
try {
// 优先使用JWT中的username来查找用户
$jwtUsername = $user['username'] ?? null;
if (empty($jwtUsername)) {
return error('无法获取用户信息,请重新登录', 400);
}
// 直接通过username查找用户
$userModel = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if (!$userModel) {
return error('用户不存在,请检查登录状态', 404);
}
// 如果要修改密码,需要验证当前密码
if (!empty($newPassword)) {
if (empty($currentPassword)) {
return error('请输入当前密码', 400);
}
if ($newPassword !== $confirmPassword) {
return error('两次输入的密码不一致', 400);
}
// 验证当前密码User 模型已有原始加密密码)
if (!password_verify($currentPassword, $userModel->password)) {
return error('当前密码错误', 400);
}
// 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密
$userModel->password = $newPassword;
}
// 更新用户名
if ($username !== $userModel->username) {
// 检查用户名是否已存在(排除当前用户)
$exists = UserModel::where('username', $username)
->where('id', '<>', $userModel->id)
->find();
if ($exists) {
return error('用户名已存在', 400);
}
$userModel->username = $username;
}
$userModel->save();
return success([
'username' => $userModel->username
], '账户信息已更新');
} catch (\Exception $e) {
return error('更新失败:' . $e->getMessage(), 500);
}
}
}
<?php
namespace app\controller\admin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use think\facade\Request;
use think\facade\Db;
/**
* 系统设置控制器(普通管理员)
*/
class Settings extends BaseController
{
/**
* 获取系统配置
* @return \think\response\Json
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
// 获取当前管理员用户名
$jwtUsername = $user['username'] ?? null;
$username = 'admin';
if ($jwtUsername) {
$currentUser = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if ($currentUser) {
$username = $currentUser->username;
} else {
$username = $jwtUsername;
}
}
return success([
'username' => $username
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 获取可用字体列表
* GET /api/v1/admin/settings/fonts
*/
public function getFonts()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$fonts = \app\common\service\PosterService::getAvailableFonts();
return success([
'fonts' => $fonts,
'fontDir' => root_path() . 'public/fonts/',
'dirExist' => is_dir(root_path() . 'public/fonts/'),
]);
}
/**
* 获取海报配置
* GET /api/v1/admin/settings/poster
* 有 enterpriseId 则读企业专属行否则读全局enterprise_id=0
*/
public function getPosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$eid = (int)($user['enterpriseId'] ?? 0);
$row = self::getConfig('poster_config', $eid);
$poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
return success(['poster' => $poster]);
}
/**
* 保存海报配置
* PUT /api/v1/admin/settings/poster
*/
public function updatePosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$input = json_decode($this->request->getContent(), true);
if (!is_array($input)) {
$input = [];
}
$data = [
'bgColor' => $input['bgColor'] ?? '#ffffff',
'bgImage' => $input['bgImage'] ?? '',
'elements' => $input['elements'] ?? []
];
$eid = (int)($user['enterpriseId'] ?? 0);
try {
self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
return success(null, '海报配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 读取配置key + enterprise_id有企业专属则取否则降级到 enterprise_id=0
*/
private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
{
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
if ($fallbackGlobal && $enterpriseId > 0) {
$row = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->find();
if ($row && !empty($row['value'])) {
$val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($val)) return $val;
}
}
return null;
}
/**
* 保存配置key + enterprise_id存在则 update否则 insert
*/
private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
{
$now = time();
$json = json_encode($value, JSON_UNESCAPED_UNICODE);
$exists = Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', $enterpriseId)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => $enterpriseId,
'value' => $json,
'description' => $description,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
}
/**
* 安全解码 JSON处理可能的多重编码
*/
private static function decodeJsonSafe($raw): ?array
{
if (!$raw) return null;
$val = $raw;
for ($i = 0; $i < 5 && is_string($val); $i++) {
$decoded = json_decode($val, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break;
$val = $decoded;
}
return is_array($val) ? $val : null;
}
/**
* 获取小程序配置
* 读取全局 text_configenterprise_id=0作为默认值再用企业专属行覆盖
* GET /api/v1/admin/settings/miniprogram
*/
public function getMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
try {
$eid = (int)($user['enterpriseId'] ?? 0);
// 全局小程序名称(仅超管可改,此处只读)
$miniprogramName = '神仙团队AI性格测试';
$siteInfo = Db::name('system_config')
->where('key', 'site_info')
->where('enterprise_id', 0)
->find();
if ($siteInfo && !empty($siteInfo['value'])) {
$val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
$miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
}
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面测试',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
// 全局文案enterprise_id=0作为基础
$globalTc = self::getConfig('text_config', 0);
$textConfigData = $globalTc
? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
: $tcDefaults;
// 企业专属文案 + 小程序名称 覆盖
if ($eid > 0) {
$eidTc = self::getConfig('text_config', $eid);
if ($eidTc) {
$textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
if (!empty($eidTc['miniprogramName'])) {
$miniprogramName = (string) $eidTc['miniprogramName'];
}
}
}
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $textConfigData,
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 飞书获客 Webhook全局 enterprise_id=0
* GET /api/v1/admin/settings/feishu-lead
*/
public function getFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$cfg = FeishuLeadWebhookService::getConfig();
return success([
'enabled' => !empty($cfg['enabled']),
'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
]);
}
/**
* PUT /api/v1/admin/settings/feishu-lead
*/
public function updateFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
$contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
if ($contactPerson === '') {
$contactPerson = '运营';
}
if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
return error('Webhook 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'webhookUrl' => $webhookUrl,
'contactPerson' => $contactPerson,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = FeishuLeadWebhookService::CONFIG_KEY;
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => 0,
'value' => $json,
'description' => '飞书获客 Webhook',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* 更新小程序配置
* 写入 text_config 行enterprise_id={eid}(有企业)或 0无企业
* PUT /api/v1/admin/settings/miniprogram
*/
public function updateMiniprogramConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [
'miniprogramName' => Request::param('miniprogramName', ''),
'textConfig' => Request::param('textConfig', []),
];
}
$miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
$textConfig = $input['textConfig'] ?? [];
if ($miniprogramName === '') {
return error('小程序名称不能为空', 400);
}
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
];
$tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
$tcMerge = array_merge($tcDefaults, $tcData);
$eid = (int)($user['enterpriseId'] ?? 0);
try {
// eid=0更新 site_info 的小程序名称(全局)
if ($eid === 0) {
$siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
$siteInfo = $siteRow && !empty($siteRow['value'])
? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
: [];
$siteInfo = is_array($siteInfo) ? $siteInfo : [];
$siteInfo['miniprogramName'] = $miniprogramName;
$siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
$siteInfo['updatedAt'] = time();
self::saveConfig('site_info', $siteInfo, 0, '站点信息');
} else {
// 企业专属:把 miniprogramName 一并写入 text_config
$tcMerge['miniprogramName'] = $miniprogramName;
}
// 统一写到 text_config企业行已含 miniprogramName全局行不含
self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid}" : '小程序文案配置(全局)');
return success([
'miniprogramName' => $miniprogramName,
'textConfig' => $tcMerge,
], '小程序配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新管理员账户信息
* @return \think\response\Json
*/
public function updateCredentials()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为管理员
if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// 兼容 axios JSON PUT 与表单提交
$rawBody = $this->request->getContent();
if (empty($rawBody)) {
$rawBody = file_get_contents('php://input');
}
$input = $rawBody ? json_decode($rawBody, true) : null;
if (!is_array($input)) {
$input = [];
}
$username = trim((string)($input['username'] ?? Request::param('username', '')));
$currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', ''));
$newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', ''));
$confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', ''));
if (empty($username)) {
return error('用户名不能为空', 400);
}
try {
// 优先使用JWT中的username来查找用户
$jwtUsername = $user['username'] ?? null;
if (empty($jwtUsername)) {
return error('无法获取用户信息,请重新登录', 400);
}
// 直接通过username查找用户
$userModel = UserModel::where('username', $jwtUsername)
->whereIn('role', ['admin', 'enterprise_admin'])
->find();
if (!$userModel) {
return error('用户不存在,请检查登录状态', 404);
}
// 如果要修改密码,需要验证当前密码
if (!empty($newPassword)) {
if (empty($currentPassword)) {
return error('请输入当前密码', 400);
}
if ($newPassword !== $confirmPassword) {
return error('两次输入的密码不一致', 400);
}
// 验证当前密码User 模型已有原始加密密码)
if (!password_verify($currentPassword, $userModel->password)) {
return error('当前密码错误', 400);
}
// 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密
$userModel->password = $newPassword;
}
// 更新用户名
if ($username !== $userModel->username) {
// 检查用户名是否已存在(排除当前用户)
$exists = UserModel::where('username', $username)
->where('id', '<>', $userModel->id)
->find();
if ($exists) {
return error('用户名已存在', 400);
}
$userModel->username = $username;
}
$userModel->save();
return success([
'username' => $userModel->username
], '账户信息已更新');
} catch (\Exception $e) {
return error('更新失败:' . $e->getMessage(), 500);
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace app\controller\admin\concern;
/**
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
*/
trait ExtractsTestResults
{
private function extractResultType(array $tests, string $type): string
{
$targetType = strtolower($type);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== $targetType) {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
return $targetType === 'face' ? '人脸分析' : trim($result);
}
if ($targetType === 'face') {
return '人脸分析';
}
if ($targetType === 'mbti') {
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
}
if ($targetType === 'disc') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['disc'] ?? '');
}
if ($targetType === 'pdp') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['pdp'] ?? '');
}
return (string) ($dec['type'] ?? $dec['result'] ?? '');
}
return '';
}
private function extractFaceSubType(array $tests, string $subType): string
{
$target = strtolower($subType);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== 'face') {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
continue;
}
if ($target === 'mbti') {
if (!empty($dec['mbti']['type'])) {
return (string) $dec['mbti']['type'];
}
if (!empty($dec['mbtiType'])) {
return (string) $dec['mbtiType'];
}
} elseif ($target === 'disc') {
if (!empty($dec['disc']['primary'])) {
return (string) $dec['disc']['primary'];
}
if (!empty($dec['disc'])) {
return (string) $dec['disc'];
}
} elseif ($target === 'pdp') {
if (!empty($dec['pdp']['primary'])) {
return (string) $dec['pdp']['primary'];
}
if (!empty($dec['pdp'])) {
return (string) $dec['pdp'];
}
}
}
return '';
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\JwtService;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序埋点上报(可匿名;带 token 时关联用户)
* POST /api/analytics/events
*/
class Analytics extends BaseController
{
public function batch()
{
$body = Request::post();
$events = $body['events'] ?? [];
if (!is_array($events) || count($events) === 0) {
return success(['accepted' => 0], 'ok');
}
if (count($events) > 50) {
return error('单次最多 50 条', 400);
}
$userId = null;
$openid = null;
$token = JwtService::getTokenFromRequest($this->request);
if ($token) {
$payload = JwtService::verifyToken($token);
if ($payload && ($payload['source'] ?? '') === 'wechat') {
$userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
}
}
$now = date('Y-m-d H:i:s');
$rows = [];
foreach ($events as $ev) {
if (!is_array($ev)) {
continue;
}
$name = isset($ev['event_name']) ? trim((string) $ev['event_name']) : '';
if ($name === '' || strlen($name) > 128) {
continue;
}
$pagePath = isset($ev['page_path']) ? mb_substr(trim((string) $ev['page_path']), 0, 255) : '';
$props = $ev['props'] ?? null;
$propsJson = null;
if ($props !== null && $props !== []) {
$propsJson = json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if (strlen($propsJson) > 8000) {
$propsJson = mb_substr($propsJson, 0, 8000);
}
}
$clientTs = isset($ev['client_ts']) ? (int) $ev['client_ts'] : null;
$rowOpenid = null;
if (!$userId && isset($ev['openid'])) {
$rowOpenid = mb_substr(trim((string) $ev['openid']), 0, 64) ?: null;
}
$rows[] = [
'userId' => $userId,
'openid' => $rowOpenid,
'eventName' => $name,
'pagePath' => $pagePath ?: null,
'propsJson' => $propsJson,
'clientTs' => $clientTs ?: null,
'createdAt' => $now,
];
}
if (count($rows) === 0) {
return success(['accepted' => 0], 'ok');
}
try {
Db::name('analytics_events')->insertAll($rows);
} catch (\Throwable $e) {
// 表未创建时不抛 500避免小程序端刷屏超管端「小程序埋点」会提示建表 SQL
return success(['accepted' => 0, 'skipped' => true], 'ok');
}
return success(['accepted' => count($rows)], 'ok');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
use app\common\service\FeishuLeadWebhookService;
use think\facade\Request;
use think\facade\Db;
@@ -313,6 +314,8 @@ class Payment extends BaseController
return error('订单不存在', 404);
}
$prevStatus = (string) ($order['status'] ?? '');
// 仅允许从 pending → 其他状态,避免重复更新已完成订单
if ($order['status'] !== 'pending' && $order['status'] !== 'paid') {
return success(null, '订单状态已更新,无需重复通知');
@@ -339,6 +342,12 @@ class Payment extends BaseController
// 支付成功时:将关联该订单的测试结果标记为已付款,并记录当时付款金额(分)
if ($status === 'success') {
if ($prevStatus === 'pending') {
try {
FeishuLeadWebhookService::onOrderPaid((int) $order['id'], (int) ($order['userId'] ?? 0));
} catch (\Throwable $e) {
}
}
$paidAmountFen = isset($order['amount']) ? (int) $order['amount'] : 0;
Db::name('test_results')
@@ -458,6 +467,11 @@ class Payment extends BaseController
// 佣金结算失败不影响主流程
}
}
try {
FeishuLeadWebhookService::onOrderPaid((int) $localOrder['id'], (int) ($localOrder['userId'] ?? 0));
} catch (\Throwable $e) {
}
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序埋点统计(仅超级管理员)
*/
class Analytics extends BaseController
{
/**
* GET /api/v1/superadmin/analytics/summary?days=7
*/
public function summary()
{
$days = min(90, max(1, (int) Request::param('days', 7)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
try {
$list = Db::name('analytics_events')
->field('eventName, COUNT(*) AS cnt')
->where('createdAt', '>=', $since)
->group('eventName')
->order('cnt', 'desc')
->select()
->toArray();
$total = Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
return success([
'days' => $days,
'total' => (int) $total,
'list' => $list,
'tableMissing' => false,
]);
} catch (\Throwable $e) {
return success([
'days' => $days,
'total' => 0,
'list' => [],
'tableMissing' => true,
]);
}
}
/**
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
*/
public function events()
{
$days = min(90, max(1, (int) Request::param('days', 7)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(10, (int) Request::param('pageSize', 50)));
try {
$total = (int) Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
$offset = ($page - 1) * $pageSize;
$rows = Db::name('analytics_events')
->where('createdAt', '>=', $since)
->order('id', 'desc')
->limit($offset, $pageSize)
->select()
->toArray();
foreach ($rows as &$r) {
if (!empty($r['propsJson'])) {
$decoded = json_decode($r['propsJson'], true);
$r['props'] = is_array($decoded) ? $decoded : null;
} else {
$r['props'] = null;
}
unset($r['propsJson']);
}
unset($r);
return success([
'list' => $rows,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => false,
]);
} catch (\Throwable $e) {
return success([
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => true,
]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,393 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 将「平台侧」无企业归属的订单/测试/用户画像归并到指定企业,供企业后台与概览统计一致展示。
* 仅超级管理员可调用;默认 dryRun 预览,正式执行需 confirm=true。
*/
class DataMigration extends BaseController
{
/**
* POST /api/v1/superadmin/data-migration/attach-orphan-orders
*
* Body JSON:
* - targetEnterpriseId (int, 必填) 目标企业 ID
* - dryRun (bool, 默认 true) true 只统计不写入
* - confirm (bool, 默认 false) 与 dryRun=false 同时为真时才写入
* - orderIds (int[], 可选) 仅处理这些订单 id仍须满足当前无企业归属
* - userIds (int[], 可选) 仅处理这些小程序用户 id 名下的无归属订单
* - syncPersonalTestResults (bool, 默认 true) 是否把同用户下 enterpriseId 为空的 personal 测试记录一并标到目标企业
* - syncWechatUsers (bool, 默认 true) 是否将 wechat_users.enterpriseId 为空的用户标到目标企业
* - clonePersonalProfile (bool, 默认 true) 若无 (userId,enterprise,enterprise) 画像行,则从 personal 行复制一条 enterprise 画像(便于「用户运营」列表出现)
*/
public function attachOrphanOrders()
{
$actor = $this->request->user ?? null;
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
return error('仅超级管理员可操作', 403);
}
$body = Request::post();
if (!is_array($body)) {
$body = [];
}
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
if ($targetEnterpriseId <= 0) {
return error('targetEnterpriseId 无效', 400);
}
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
if (!$ent) {
return error('目标企业不存在', 404);
}
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
$confirm = !empty($body['confirm']);
$syncPersonalTestResults = array_key_exists('syncPersonalTestResults', $body) ? (bool) $body['syncPersonalTestResults'] : true;
$syncWechatUsers = array_key_exists('syncWechatUsers', $body) ? (bool) $body['syncWechatUsers'] : true;
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
$orderIdsFilter = $this->normalizeIdList($body['orderIds'] ?? null);
$userIdsFilter = $this->normalizeIdList($body['userIds'] ?? null);
$orderQuery = Db::name('orders')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
});
if (!empty($orderIdsFilter)) {
$orderQuery->whereIn('id', $orderIdsFilter);
}
if (!empty($userIdsFilter)) {
$orderQuery->whereIn('userId', $userIdsFilter);
}
$orderRows = $orderQuery->field('id,userId,orderNo,enterpriseId,status,amount')->select()->toArray();
$affectedOrderIds = array_values(array_unique(array_filter(array_column($orderRows, 'id'))));
$userIdsFromOrders = array_values(array_unique(array_filter(array_column($orderRows, 'userId'))));
$testByOrderCount = 0;
if (!empty($affectedOrderIds)) {
$testByOrderCount = (int) Db::name('test_results')
->whereIn('orderId', $affectedOrderIds)
->count();
}
$personalTestExtraCount = 0;
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
$personalTestExtraCount = (int) Db::name('test_results')
->whereIn('userId', $userIdsFromOrders)
->where('testScope', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
}
$wechatPatchCount = 0;
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
$wechatPatchCount = (int) Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
}
$profileCloneCount = 0;
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
foreach ($userIdsFromOrders as $uid) {
$hasEnt = Db::name('user_profile')
->where('userId', $uid)
->where('userType', 'enterprise')
->where('enterpriseId', $targetEnterpriseId)
->find();
if (!$hasEnt) {
$profileCloneCount++;
}
}
}
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'ordersMatched' => count($orderRows),
'orderIds' => $affectedOrderIds,
'distinctUserIds' => $userIdsFromOrders,
'testResultsByOrder' => $testByOrderCount,
'testResultsPersonalExtra' => $personalTestExtraCount,
'wechatUsersToPatch' => $wechatPatchCount,
'userProfilesToClone' => $profileCloneCount,
'dryRun' => $dryRun,
];
if ($dryRun || !$confirm) {
$preview['hint'] = $dryRun
? '当前为预览dryRun=true。若要执行写入请传 dryRun=false 且 confirm=true。'
: '未执行写入:请同时传 dryRun=false 与 confirm=true。';
return success($preview);
}
$now = time();
Db::startTrans();
try {
if (!empty($affectedOrderIds)) {
Db::name('orders')
->whereIn('id', $affectedOrderIds)
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
Db::name('test_results')
->whereIn('orderId', $affectedOrderIds)
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
}
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
Db::name('test_results')
->whereIn('userId', $userIdsFromOrders)
->where('testScope', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
}
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
}
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
foreach ($userIdsFromOrders as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('迁移失败:' . $e->getMessage(), 500);
}
$preview['executed'] = true;
$preview['hint'] = '已写入。企业管理员刷新「订单运营 / 概览 / 用户运营」即可看到归属数据。超管仍可见全平台订单。';
return success($preview, '迁移完成');
}
/**
* @param mixed $raw
* @return int[]
*/
private function normalizeIdList($raw): array
{
if (!is_array($raw) || $raw === []) {
return [];
}
$out = [];
foreach ($raw as $v) {
$n = (int) $v;
if ($n > 0) {
$out[] = $n;
}
}
return array_values(array_unique($out));
}
private function ensureEnterpriseProfileFromPersonal(int $userId, int $enterpriseId, int $now): void
{
if ($userId <= 0 || $enterpriseId <= 0) {
return;
}
$exists = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'enterprise')
->where('enterpriseId', $enterpriseId)
->find();
if ($exists) {
return;
}
$personal = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->order('id', 'desc')
->find();
$base = [
'userId' => $userId,
'userType' => 'enterprise',
'enterpriseId' => $enterpriseId,
'testsTotal' => 0,
'testsMbti' => 0,
'testsDisc' => 0,
'testsPdp' => 0,
'testsFace' => 0,
'ordersTotal' => 0,
'paidOrders' => 0,
'totalPaidAmount' => 0,
'lastTestResultId' => null,
'lastTestType' => null,
'lastTestAt' => null,
'lastMbtiResultId' => null,
'lastDiscResultId' => null,
'lastPdpResultId' => null,
'lastFaceResultId' => null,
'createdAt' => $now,
'updatedAt' => $now,
];
if ($personal) {
$copyFields = [
'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
'ordersTotal', 'paidOrders', 'totalPaidAmount',
'lastTestResultId', 'lastTestType', 'lastTestAt',
'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId',
];
foreach ($copyFields as $f) {
if (array_key_exists($f, $personal) && $personal[$f] !== null) {
$base[$f] = $personal[$f];
}
}
}
Db::name('user_profile')->insert($base);
}
/**
* 将全平台「无 enterpriseId」的 test_results 与 wechat_users 归属到存客宝(或指定企业)
* POST /api/v1/superadmin/data-migration/attach-orphans-to-cunkbao
*
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm、clonePersonalProfile (默认 true)
*/
public function attachOrphansToCunkbao()
{
$actor = $this->request->user ?? null;
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
return error('仅超级管理员可操作', 403);
}
$body = Request::post();
if (!is_array($body)) {
$body = [];
}
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
$confirm = !empty($body['confirm']);
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
if ($targetEnterpriseId <= 0) {
$row = Db::name('enterprises')->where('name', 'like', '%存客宝%')->order('id', 'asc')->find();
if (!$row) {
return error('未找到名称包含「存客宝」的企业,请先在企业管理中创建或传入 targetEnterpriseId', 404);
}
$targetEnterpriseId = (int) $row['id'];
}
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
if (!$ent) {
return error('目标企业不存在', 404);
}
$testAffected = (int) Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->count();
$wechatAffected = (int) Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
$userIdsFromTests = Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->distinct(true)
->column('userId');
$userIdsFromWechat = Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->column('id');
$distinctUserIds = array_values(array_unique(array_filter(array_merge($userIdsFromTests, $userIdsFromWechat))));
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'testResultsRows' => $testAffected,
'wechatUsersRows' => $wechatAffected,
'distinctUserIds' => $distinctUserIds,
'dryRun' => $dryRun,
];
if ($dryRun || !$confirm) {
$preview['hint'] = $dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。';
return success($preview);
}
$now = time();
Db::startTrans();
try {
Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
if ($clonePersonalProfile && !empty($distinctUserIds)) {
foreach ($distinctUserIds as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('归并失败:' . $e->getMessage(), 500);
}
$preview['executed'] = true;
$preview['hint'] = '已写入。无企业归属的测试与用户已归属到目标企业。';
return success($preview, '归并完成');
}
}

View File

@@ -1,437 +1,551 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Request;
use think\facade\Db;
/**
* 企业管理控制器(超管专用)
*/
class Enterprise extends BaseController
{
/**
* 获取企业列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
$keyword = Request::param('keyword', '');
$status = Request::param('status', '');
$where = [];
// 搜索条件
if ($keyword) {
$where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
}
// 状态筛选
if ($status !== '') {
$where['status'] = $status;
}
// 查询企业列表
$list = EnterpriseModel::where($where)
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 统计每个企业的用户数和测试用量
foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户)
$item['userCount'] = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->count();
// 统计测试用量(测试结果数)- 通过企业下的用户ID统计只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$item['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$item['testUsage'] = 0;
}
}
$total = EnterpriseModel::where($where)->count();
// 统计活跃企业数status为operating
$activeCount = EnterpriseModel::where('status', 'operating')->count();
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'activeCount' => $activeCount
]);
}
/**
* 获取企业详情
* @param int $id
* @return \think\response\Json
*/
public function detail($id = null)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 如果路由参数没有传递,尝试从请求参数获取
if (empty($id)) {
$id = Request::param('id');
}
if (empty($id)) {
return error('企业ID不能为空', 400);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
// 统计用户数
$data['userCount'] = count($userIds);
// 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users')
->where('enterpriseId', $id)
->where('role', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
->select()
->toArray();
$data['adminAccounts'] = $adminAccounts;
// 获取用户列表(排除管理员,只获取未删除的)
$users = Db::name('users')
->where('enterpriseId', $id)
->where('role', '<>', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,mbtiType,status,createdAt')
->limit(50) // 限制返回数量
->select()
->toArray();
$data['users'] = $users;
// 获取测试结果列表
$testResults = [];
if (!empty($userIds)) {
$testResults = Db::name('test_results')
->alias('tr')
->leftJoin('users u', 'tr.userId = u.id')
->where('tr.userId', 'in', $userIds)
->field('tr.id,tr.testType,tr.createdAt,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
// 统计测试用量
if (!empty($userIds)) {
$data['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$data['testUsage'] = 0;
}
return success($data);
}
/**
* 创建企业
* @return \think\response\Json
*/
public function create()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::post();
// 验证必填字段
if (empty($data['name'])) {
return error('企业名称不能为空', 400);
}
// 验证管理员账号信息
if (empty($data['adminUsername'])) {
return error('管理员用户名不能为空', 400);
}
if (empty($data['adminPassword'])) {
return error('管理员密码不能为空', 400);
}
if (strlen($data['adminPassword']) < 6) {
return error('密码长度至少6位', 400);
}
// 检查企业代码是否重复(如果提供了代码)
if (!empty($data['code'])) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 检查管理员用户名是否已存在
if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
return error('管理员用户名已存在', 400);
}
// 状态映射前端使用operating/trial/disabled
$status = $data['status'] ?? 'operating';
if (!in_array($status, ['operating', 'trial', 'disabled'])) {
$status = 'operating';
}
// 验证试用到期时间
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
}
// 开启事务
Db::startTrans();
try {
// 创建企业
$enterprise = new EnterpriseModel();
$enterprise->name = $data['name'];
$enterprise->code = $data['code'] ?? null;
$enterprise->contactName = $data['contactName'] ?? null;
$enterprise->contactPhone = $data['contactPhone'] ?? null;
$enterprise->contactEmail = $data['contactEmail'] ?? null;
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
$enterprise->save();
$enterpriseId = $enterprise->id;
// 创建企业管理员账号
$adminUser = [
'username' => $data['adminUsername'],
'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
'email' => $data['contactEmail'] ?? null,
'phone' => $data['contactPhone'] ?? null,
'role' => 'enterprise_admin',
'enterpriseId' => $enterpriseId,
'status' => 1,
'createdAt' => time(),
'updatedAt' => time()
];
Db::name('users')->insert($adminUser);
// 提交事务
Db::commit();
$enterpriseData = $enterprise->toArray();
$enterpriseData['userCount'] = 1; // 刚创建的企业管理员
$enterpriseData['testUsage'] = 0;
return success($enterpriseData, '企业创建成功,管理员账号已创建');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return error('创建失败:' . $e->getMessage(), 500);
}
}
/**
* 更新企业
* @param int $id
* @return \think\response\Json
*/
public function update($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = Request::put();
$oldBalance = (float) ($enterprise->balance ?? 0);
// 如果更新企业代码,检查是否重复
if (isset($data['code']) && $data['code'] != $enterprise->code) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 状态验证
if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
return error('状态值无效', 400);
}
// 验证试用到期时间
$status = $data['status'] ?? $enterprise->status;
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
$enterprise->trialExpireAt = $data['trialExpireAt'];
} else {
// 如果不是试用状态,清空到期时间
$enterprise->trialExpireAt = null;
}
$enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0);
if ($newBalance > $oldBalance) {
try {
\app\controller\api\Distribution::unfreezeCommissions((int) $id);
} catch (\Throwable $e) {
// 余额已更新成功,解冻失败不阻断主流程
}
}
$enterpriseData = $enterprise->toArray();
// 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$enterpriseData['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$enterpriseData['testUsage'] = 0;
}
return success($enterpriseData, '更新成功');
}
/**
* 删除企业(软删除)
* @param int $id
* @return \think\response\Json
*/
public function delete($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 检查是否已删除
if ($enterprise->deletedAt) {
return error('企业已被删除', 400);
}
// 检查是否有用户关联(只检查未删除的用户)
$userCount = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
if ($userCount > 0) {
return error('该企业下还有用户,无法删除', 400);
}
// 软删除(设置 deletedAt 时间戳)
$enterprise->delete();
return success(null, '删除成功');
}
/**
* 启用/禁用企业
* @param int $id
* @return \think\response\Json
*/
public function toggleStatus($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 切换状态operating <-> disabled
if ($enterprise->status === 'operating') {
$enterprise->status = 'disabled';
} else {
$enterprise->status = 'operating';
}
$enterprise->save();
return success($enterprise, '操作成功');
}
}
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\SystemConfig as SystemConfigModel;
use think\facade\Request;
use think\facade\Db;
/**
* 企业管理控制器(超管专用)
*/
class Enterprise extends BaseController
{
/**
* 获取企业列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
$keyword = Request::param('keyword', '');
$status = Request::param('status', '');
$where = [];
// 搜索条件
if ($keyword) {
$where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
}
// 状态筛选
if ($status !== '') {
$where['status'] = $status;
}
// 查询企业列表
$list = EnterpriseModel::where($where)
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 统计每个企业的用户数和测试用量
foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户)
$item['userCount'] = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->count();
// 统计测试用量(测试结果数)- 通过企业下的用户ID统计只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$item['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$item['testUsage'] = 0;
}
}
$total = EnterpriseModel::where($where)->count();
// 统计活跃企业数status为operating
$activeCount = EnterpriseModel::where('status', 'operating')->count();
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'activeCount' => $activeCount
]);
}
/**
* 获取企业详情
* @param int $id
* @return \think\response\Json
*/
public function detail($id = null)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 如果路由参数没有传递,尝试从请求参数获取
if (empty($id)) {
$id = Request::param('id');
}
if (empty($id)) {
return error('企业ID不能为空', 400);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
// 统计用户数
$data['userCount'] = count($userIds);
// 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users')
->where('enterpriseId', $id)
->where('role', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
->select()
->toArray();
$data['adminAccounts'] = $adminAccounts;
// 获取用户列表(排除管理员,只获取未删除的)
$users = Db::name('users')
->where('enterpriseId', $id)
->where('role', '<>', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,mbtiType,status,createdAt')
->limit(50) // 限制返回数量
->select()
->toArray();
$data['users'] = $users;
// 获取测试结果列表
$testResults = [];
if (!empty($userIds)) {
$testResults = Db::name('test_results')
->alias('tr')
->leftJoin('users u', 'tr.userId = u.id')
->where('tr.userId', 'in', $userIds)
->field('tr.id,tr.testType,tr.createdAt,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
// 统计测试用量
if (!empty($userIds)) {
$data['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$data['testUsage'] = 0;
}
// —— 小程序侧用户wechat_users.enterpriseId——
$wechatIds = [];
try {
$wechatIds = Db::name('wechat_users')->where('enterpriseId', $id)->column('id');
$wechatIds = array_values(array_filter($wechatIds));
} catch (\Throwable $e) {
$wechatIds = [];
}
$data['wechatUserCount'] = count($wechatIds);
$data['wechatUsers'] = [];
if (!empty($wechatIds)) {
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('id', 'in', $wechatIds)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->limit(120)
->select()
->toArray();
} catch (\Throwable $e) {
$data['wechatUsers'] = [];
}
}
// 该企业下、带 enterpriseId 的小程序测试记录
$data['miniprogramTestResults'] = [];
try {
$data['miniprogramTestResults'] = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users w', 'tr.userId = w.id')
->where('tr.enterpriseId', $id)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,w.nickname as wechatNickname')
->order('tr.createdAt', 'desc')
->limit(60)
->select()
->toArray();
} catch (\Throwable $e) {
$data['miniprogramTestResults'] = [];
}
// 订单与消耗(金额分)
$paidStatuses = ['paid', 'completed'];
try {
$data['orderStats'] = [
'totalCount' => (int) Db::name('orders')->where('enterpriseId', $id)->count(),
'paidCount' => (int) Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->count(),
'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
];
$data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id)
->order('createdAt', 'desc')
->limit(25)
->field('id,orderNo,status,amount,productType,userId,createdAt')
->select()
->toArray();
} catch (\Throwable $e) {
$data['orderStats'] = [
'totalCount' => 0,
'paidCount' => 0,
'paidAmountFen' => 0,
];
$data['recentOrders'] = [];
}
// 埋点:近 30 天,归属该企业的小程序用户
$data['analyticsStats'] = [
'eventTotal' => 0,
'pageViewCount' => 0,
'byEvent' => [],
'hint' => null,
'windowDays' => 30,
];
if (empty($wechatIds)) {
$data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点';
} else {
try {
$since = date('Y-m-d H:i:s', time() - 30 * 86400);
$data['analyticsStats']['eventTotal'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->count();
$data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->where('eventName', 'page_view')
->count();
$byEvent = Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->field('eventName, COUNT(*) AS cnt')
->group('eventName')
->order('cnt', 'desc')
->limit(20)
->select()
->toArray();
$data['analyticsStats']['byEvent'] = $byEvent ?: [];
} catch (\Throwable $e) {
$data['analyticsStats']['hint'] = '埋点表未就绪或查询失败(请确认已建 analytics_events 表)';
}
}
// 全局通知策略(超管在系统设置中配置,影响余额类提醒等)
$data['notificationPolicy'] = null;
try {
$nc = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find();
if ($nc) {
$val = $nc->getAttr('value');
$data['notificationPolicy'] = is_array($val) ? $val : null;
}
} catch (\Throwable $e) {
$data['notificationPolicy'] = null;
}
return success($data);
}
/**
* 创建企业
* @return \think\response\Json
*/
public function create()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::post();
// 验证必填字段
if (empty($data['name'])) {
return error('企业名称不能为空', 400);
}
// 验证管理员账号信息
if (empty($data['adminUsername'])) {
return error('管理员用户名不能为空', 400);
}
if (empty($data['adminPassword'])) {
return error('管理员密码不能为空', 400);
}
if (strlen($data['adminPassword']) < 6) {
return error('密码长度至少6位', 400);
}
// 检查企业代码是否重复(如果提供了代码)
if (!empty($data['code'])) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 检查管理员用户名是否已存在
if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
return error('管理员用户名已存在', 400);
}
// 状态映射前端使用operating/trial/disabled
$status = $data['status'] ?? 'operating';
if (!in_array($status, ['operating', 'trial', 'disabled'])) {
$status = 'operating';
}
// 验证试用到期时间
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
}
// 开启事务
Db::startTrans();
try {
// 创建企业
$enterprise = new EnterpriseModel();
$enterprise->name = $data['name'];
$enterprise->code = $data['code'] ?? null;
$enterprise->contactName = $data['contactName'] ?? null;
$enterprise->contactPhone = $data['contactPhone'] ?? null;
$enterprise->contactEmail = $data['contactEmail'] ?? null;
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
$enterprise->save();
$enterpriseId = $enterprise->id;
// 创建企业管理员账号
$adminUser = [
'username' => $data['adminUsername'],
'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
'email' => $data['contactEmail'] ?? null,
'phone' => $data['contactPhone'] ?? null,
'role' => 'enterprise_admin',
'enterpriseId' => $enterpriseId,
'status' => 1,
'createdAt' => time(),
'updatedAt' => time()
];
Db::name('users')->insert($adminUser);
// 提交事务
Db::commit();
$enterpriseData = $enterprise->toArray();
$enterpriseData['userCount'] = 1; // 刚创建的企业管理员
$enterpriseData['testUsage'] = 0;
return success($enterpriseData, '企业创建成功,管理员账号已创建');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return error('创建失败:' . $e->getMessage(), 500);
}
}
/**
* 更新企业
* @param int $id
* @return \think\response\Json
*/
public function update($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = Request::put();
$oldBalance = (float) ($enterprise->balance ?? 0);
// 如果更新企业代码,检查是否重复
if (isset($data['code']) && $data['code'] != $enterprise->code) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 状态验证
if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
return error('状态值无效', 400);
}
// 验证试用到期时间
$status = $data['status'] ?? $enterprise->status;
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
$enterprise->trialExpireAt = $data['trialExpireAt'];
} else {
// 如果不是试用状态,清空到期时间
$enterprise->trialExpireAt = null;
}
$enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0);
if ($newBalance > $oldBalance) {
try {
\app\controller\api\Distribution::unfreezeCommissions((int) $id);
} catch (\Throwable $e) {
// 余额已更新成功,解冻失败不阻断主流程
}
}
$enterpriseData = $enterprise->toArray();
// 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$enterpriseData['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$enterpriseData['testUsage'] = 0;
}
return success($enterpriseData, '更新成功');
}
/**
* 删除企业(软删除)
* @param int $id
* @return \think\response\Json
*/
public function delete($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 检查是否已删除
if ($enterprise->deletedAt) {
return error('企业已被删除', 400);
}
// 检查是否有用户关联(只检查未删除的用户)
$userCount = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
if ($userCount > 0) {
return error('该企业下还有用户,无法删除', 400);
}
// 软删除(设置 deletedAt 时间戳)
$enterprise->delete();
return success(null, '删除成功');
}
/**
* 启用/禁用企业
* @param int $id
* @return \think\response\Json
*/
public function toggleStatus($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 切换状态operating <-> disabled
if ($enterprise->status === 'operating') {
$enterprise->status = 'disabled';
} else {
$enterprise->status = 'operating';
}
$enterprise->save();
return success($enterprise, '操作成功');
}
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use app\model\Enterprise as EnterpriseModel;
@@ -276,6 +277,69 @@ class Settings extends BaseController
}
}
/**
* 飞书获客 Webhook与 admin 共用配置)
*/
public function getFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$cfg = FeishuLeadWebhookService::getConfig();
return success([
'enabled' => !empty($cfg['enabled']),
'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
]);
}
public function updateFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
$contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
if ($contactPerson === '') {
$contactPerson = '运营';
}
if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
return error('Webhook 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'webhookUrl' => $webhookUrl,
'contactPerson' => $contactPerson,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = FeishuLeadWebhookService::CONFIG_KEY;
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => 0,
'value' => $json,
'description' => '飞书获客 Webhook',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* 更新超管账户信息
* @return \think\response\Json

View File

@@ -0,0 +1,11 @@
-- 飞书获客 Webhook 去重表(前缀 mbti_ 与 .env DATABASE_PREFIX 一致)
CREATE TABLE IF NOT EXISTS `mbti_feishu_lead_dedup` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`dedupKey` varchar(160) NOT NULL COMMENT '如 order_paid:123、phone_bind:456',
`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dedup_key` (`dedupKey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='飞书获客推送幂等';
-- 可选:加速按用户查最近行为(若 idx_user_id 已存在会报错,忽略即可)
-- ALTER TABLE `mbti_analytics_events` ADD INDEX `idx_user_id` (`userId`);

View File

@@ -0,0 +1,15 @@
-- 小程序埋点表(与 mbti_ 表前缀一致;若 .env 中 DATABASE_PREFIX 不同,请同步改表名)
-- ThinkPHPDb::name('analytics_events') + 前缀 mbti_ => mbti_analytics_events
CREATE TABLE IF NOT EXISTS `mbti_analytics_events` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`userId` int(10) unsigned DEFAULT NULL COMMENT 'wechat_users.id',
`openid` varchar(64) DEFAULT NULL,
`eventName` varchar(128) NOT NULL DEFAULT '',
`pagePath` varchar(255) DEFAULT NULL,
`propsJson` text COMMENT 'JSON 字符串',
`clientTs` bigint(20) DEFAULT NULL COMMENT '客户端毫秒时间戳',
`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event_created` (`eventName`,`createdAt`),
KEY `idx_created` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='小程序行为埋点';

7
api/public/router.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
// PHP 内置服务器用:非真实文件请求一律进 ThinkPHP 入口
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
if ($uri !== '/' && $uri !== '' && file_exists(__DIR__ . $uri) && !is_dir(__DIR__ . $uri)) {
return false;
}
require __DIR__ . '/index.php';

View File

@@ -21,6 +21,8 @@ Route::group('api', function () {
Route::get('config/runtime', 'api.AppConfig/runtime');
Route::get('config/deep-pricing', 'api.AppConfig/deepPricing');
Route::post('analyze', 'api.Analyze/index');
// 小程序埋点批量上报(无需登录;带 token 时关联 user_id
Route::post('analytics/events', 'api.Analytics/batch');
})->middleware('cors');
// 前端需要认证的API路由
@@ -166,6 +168,14 @@ Route::group('api/v1/superadmin', function () {
// 超级管理员认证
Route::get('auth/me', 'superadmin.Auth/me');
Route::post('auth/logout', 'superadmin.Auth/logout');
// 全平台订单列表(与 admin.Order 共用逻辑,超管不限企业)
Route::get('orders', 'admin.Order/index');
// 将无 enterpriseId 的订单/测试等归并到指定企业(仅超管;默认预览)
Route::post('data-migration/attach-orphan-orders', 'superadmin.DataMigration/attachOrphanOrders');
// 将全平台无归属测试记录/小程序用户并入「存客宝」或指定企业(仅超管)
Route::post('data-migration/attach-orphans-to-cunkbao', 'superadmin.DataMigration/attachOrphansToCunkbao');
// 企业管理(超管专用)
// 注意:带参数的路由要放在不带参数的路由之前,避免路由匹配冲突
@@ -253,6 +263,10 @@ Route::group('api/v1/superadmin', function () {
Route::post('distribution/withdrawals/:id/reject', 'superadmin.Distribution/rejectWithdrawal');
Route::get('distribution/settings', 'superadmin.Distribution/settings');
Route::put('distribution/settings', 'superadmin.Distribution/updateSettings');
// 小程序埋点统计(仅超管)
Route::get('analytics/summary', 'superadmin.Analytics/summary');
Route::get('analytics/events', 'superadmin.Analytics/events');
})->middleware(['cors', 'auth', 'superadmin']);
// ==================== 兼容旧版路由(保留,逐步废弃)====================

View File

@@ -28,10 +28,27 @@ App({
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle
if (typeof cfg.reviewMode === 'boolean') {
this.globalData.reviewMode = cfg.reviewMode
}
}
}).catch(() => {})
},
onShow() {
try {
const { reportPageView } = require('./utils/analytics.js')
reportPageView()
} catch (e) {}
},
onHide() {
try {
const { flush } = require('./utils/analytics.js')
flush()
} catch (e) {}
},
loadStoredData() {
const token = tt.getStorageSync('token')
if (token) {

View File

@@ -39,7 +39,7 @@
},
{
"pagePath": "pages/index/camera",
"text": "查看报告",
"text": "拍摄",
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},

View File

@@ -2,33 +2,23 @@
Component({
data: {
selected: 0,
reviewMode: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
{ pagePath: '/pages/index/camera', text: '查看报告', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
]
},
lifetimes: {
attached() {
this.updateSelected()
this.checkReviewMode()
}
},
pageLifetimes: {
show() {
this.updateSelected()
this.checkReviewMode()
}
},
methods: {
checkReviewMode() {
try {
const app = getApp()
const rm = !!(app && app.globalData && app.globalData.reviewMode)
this.setData({ reviewMode: rm })
} catch (e) {}
},
updateSelected() {
try {
const pages = getCurrentPages()
@@ -66,13 +56,6 @@ Component({
} catch (e) {}
}
// 审核模式:中间按钮跳转到测试选择页而非相机
if (index === 1 && this.data.reviewMode) {
tt.navigateTo({ url: '/pages/test-select/index' })
this.setData({ selected: index })
return
}
tt.switchTab({ url })
this.setData({ selected: index })
}

View File

@@ -1,4 +1,4 @@
<!-- 自定义 tabBar顶部分割线 + 三栏,中间浮起圆钮 -->
<!-- 自定义 tabBar与微信端一致,中间固定「拍摄」 -->
<view class="tab-bar">
<view class="tab-bar-line"></view>
<view class="tab-bar-inner">
@@ -11,30 +11,21 @@
<image class="tab-icon" src="{{selected === 0 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">首页</text>
</view>
<!-- 审核模式:中间按钮显示"测试"文字 -->
<view
tt:if="{{reviewMode}}"
class="tab-item {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/test-select/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 1 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">测试</text>
</view>
<!-- 正常模式:中间浮起拍照按钮 -->
<view
tt:else
class="tab-item-center {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
bindtap="switchTab"
>
<view class="center-circle">
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
<view class="tab-slot-middle">
<view
class="middle-fab {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
bindtap="switchTab"
>
<view class="center-circle">
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
</view>
<text class="tab-text tab-text-fab">拍摄</text>
</view>
<view class="center-text-placeholder"></view>
</view>
<view
class="tab-item {{selected === 2 ? 'active' : ''}}"
data-index="2"

View File

@@ -1,104 +1,114 @@
/* 自定义 tabBar白底、顶部分割线、中间浮起圆钮 */
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 9999;
}
.tab-bar-line {
height: 1rpx;
background: #e5e7eb;
width: 100%;
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100rpx;
padding: 0 20rpx 8rpx;
position: relative;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 8rpx;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 20rpx;
color: #999999;
}
.tab-item.active .tab-text {
color: #7c3aed;
}
/* 中间项:浮起彩色圆钮 + 相机图标 */
.tab-item-center {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
margin-top: -56rpx;
padding-bottom: 8rpx;
}
.center-circle {
width: 96rpx;
height: 96rpx;
min-width: 96rpx;
min-height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4rpx;
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.4);
border: 4rpx solid #ffffff;
flex-shrink: 0;
overflow: hidden;
}
.tab-item-center.active .center-circle {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
}
.center-icon {
width: 44rpx;
height: 44rpx;
}
/* 占位:与「首页」「我的」文字等高,保证中间圆钮与两侧图标对齐 */
.center-text-placeholder {
height: 24rpx;
width: 1rpx;
visibility: hidden;
}
.tab-item-center .center-text {
color: #7c3aed;
font-size: 20rpx;
font-weight: 600;
}
.tab-item-center.active .center-text {
color: #6d28d9;
}
/* 自定义 tabBar与微信端一致 */
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 9999;
}
.tab-bar-line {
height: 1rpx;
background: #e5e7eb;
width: 100%;
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-between;
min-height: 120rpx;
padding: 12rpx 12rpx 10rpx;
position: relative;
box-sizing: border-box;
}
.tab-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
z-index: 1;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 20rpx;
color: #999999;
}
.tab-item.active .tab-text {
color: #7c3aed;
}
.tab-slot-middle {
flex: 1;
min-width: 0;
position: relative;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
}
.middle-fab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 100%;
transform: translateY(-10rpx);
margin-bottom: -6rpx;
}
.center-circle {
width: 84rpx;
height: 84rpx;
min-width: 84rpx;
min-height: 84rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.4);
border: 4rpx solid #ffffff;
flex-shrink: 0;
overflow: hidden;
}
.middle-fab.active .center-circle {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
}
.center-icon {
width: 40rpx;
height: 40rpx;
}
.tab-text-fab {
font-size: 18rpx;
color: #999999;
line-height: 1.25;
text-align: center;
max-width: 168rpx;
}
.middle-fab.active .tab-text-fab {
color: #7c3aed;
font-weight: 600;
}

View File

@@ -15,29 +15,66 @@ Page({
},
onLoad() {
this.cameraContext = tt.createCameraContext()
this.setData({ reviewMode: !!app.globalData.reviewMode })
const tc = app.globalData.textConfig
if (tc && tc.aiAnalysisText) {
this.setData({ aiAnalysisText: tc.aiAnalysisText })
} else {
app.getRuntimeConfig().then((cfg) => {
if (cfg && cfg.textConfig) {
app.globalData.textConfig = cfg.textConfig
this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析' })
}
app.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (typeof cfg.reviewMode === 'boolean') {
app.globalData.reviewMode = cfg.reviewMode
}
}).catch(() => {})
if (cfg.textConfig) {
app.globalData.textConfig = cfg.textConfig
this.setData({
aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析',
reviewMode: !!app.globalData.reviewMode
})
return
}
}
this.setData({ reviewMode: !!app.globalData.reviewMode })
}).catch(() => {
this.setData({ reviewMode: !!app.globalData.reviewMode })
})
},
onReady() {
if (!app.globalData.reviewMode) {
this.initCameraContext()
}
},
goToQuestionnaire() {
tt.navigateTo({ url: '/pages/test-select/index' })
},
initCameraContext() {
try {
if (typeof tt.createCameraContext === 'function') {
this.cameraContext = tt.createCameraContext()
}
} catch (e) {
console.error('initCameraContext', e)
this.cameraContext = null
}
},
onShow() {
// 审核模式下重定向到测试选择页
if (app.globalData.reviewMode) {
tt.navigateTo({ url: '/pages/test-select/index' })
const rm = !!app.globalData.reviewMode
this.setData({ reviewMode: rm })
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
}
if (rm) {
return
}
if (!ensureProfileCompleteAndRedirect()) return
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
if (this.data.photos.length < 3) {
this.initCameraContext()
}
this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig
@@ -77,6 +114,14 @@ Page({
return
}
if (!this.cameraContext) {
this.initCameraContext()
}
if (!this.cameraContext || typeof this.cameraContext.takePhoto !== 'function') {
tt.showToast({ title: '相机未就绪,请稍候再试', icon: 'none' })
return
}
this.cameraContext.takePhoto({
quality: 'high',
success: (res) => {
@@ -95,7 +140,17 @@ Page({
}
},
fail: (err) => {
tt.showToast({ title: '拍照失败', icon: 'none' })
const msg = (err && (err.errMsg || err.message)) ? String(err.errMsg || err.message) : ''
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
tt.showModal({
title: '需要相机权限',
content: '请在设置中允许使用摄像头',
confirmText: '去设置',
success: (r) => { if (r.confirm) tt.openSetting() }
})
} else {
tt.showToast({ title: '拍照失败,请重试', icon: 'none' })
}
console.error('拍照失败:', err)
}
})
@@ -116,6 +171,7 @@ Page({
guideText: '请正对镜头'
})
tt.showToast({ title: '已清空,请重新拍摄', icon: 'success' })
setTimeout(() => this.initCameraContext(), 200)
}
}
})

View File

@@ -1,6 +1,6 @@
{
"navigationBarTitleText": "拍",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}
{
"navigationBarTitleText": "拍",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -1,69 +1,77 @@
<!--pages/index/camera.wxml - 拍照页面-->
<view class="container" tt:if="{{!reviewMode}}">
<view class="progress-section">
<view class="progress-info">
<text class="step-text">步骤 {{photoIndex + 1}}/3</text>
<text class="photo-count">{{photos.length}}/3 张照片已完成</text>
</view>
<view class="progress-bars">
<view class="progress-bar {{photos.length >= 1 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 2 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 3 ? 'completed' : 'pending'}}"></view>
</view>
<view class="instruction-card">
<view class="instruction-content">
<view class="step-number">{{photoIndex + 1}}</view>
<view class="instruction-text">
<text class="angle-text">{{guideText}}</text>
<text class="tip-text">请保持自然表情,确保光线充足</text>
</view>
</view>
</view>
</view>
<view class="camera-container">
<view tt:if="{{photos.length < 3}}" class="camera-preview">
<camera
class="camera"
device-position="front"
flash="off"
binderror="onCameraError"
></camera>
</view>
<view tt:else class="photos-preview">
<view class="photo-item" tt:for="{{photos}}" tt:key="index">
<image class="photo-image" src="{{item}}" mode="aspectFill"></image>
<view class="photo-label">{{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}</view>
</view>
</view>
</view>
<view class="button-container">
<view tt:if="{{photos.length < 3}}" class="capture-actions">
<view class="capture-button" bindtap="takePhoto">
<text class="button-text">拍摄{{guideText}}照片</text>
</view>
<view class="album-button" bindtap="goToUpload">
<text class="album-button-text">从相册选择</text>
</view>
</view>
<view tt:else class="action-buttons">
<view class="action-button secondary" bindtap="retakeAll">
<text class="action-button-text">重新拍摄</text>
</view>
<view class="action-button primary" bindtap="completeCapture">
<text class="action-button-text">立即{{aiAnalysisText || '分析'}}</text>
</view>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示,就地弹出微信系统手机号授权 -->
<view class="phone-auth-section" tt:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后开始查看报告
</button>
</view>
</view>
<!--pages/index/camera.ttml - 拍照页面-->
<view class="camera-page-root">
<view class="container review-mode-panel" tt:if="{{reviewMode}}">
<view class="review-mode-card">
<text class="review-mode-title">问卷审核模式</text>
<text class="review-mode-desc">当前未开放实时拍摄面相,请先做 MBTI / DISC / PDP 等问卷测试;后台关闭「审核模式」后即可使用拍摄报告。</text>
<view class="review-mode-btn" bindtap="goToQuestionnaire">去做性格测试</view>
</view>
</view>
<view class="container" tt:else>
<view class="progress-section">
<view class="progress-info">
<text class="step-text">步骤 {{photoIndex + 1}}/3</text>
<text class="photo-count">{{photos.length}}/3 张照片已完成</text>
</view>
<view class="progress-bars">
<view class="progress-bar {{photos.length >= 1 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 2 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 3 ? 'completed' : 'pending'}}"></view>
</view>
<view class="instruction-card">
<view class="instruction-content">
<view class="step-number">{{photoIndex + 1}}</view>
<view class="instruction-text">
<text class="angle-text">{{guideText}}</text>
<text class="tip-text">请保持自然表情,确保光线充足</text>
</view>
</view>
</view>
</view>
<view class="camera-container">
<view tt:if="{{photos.length < 3}}" class="camera-preview">
<camera
class="camera"
device-position="front"
flash="off"
binderror="onCameraError"
></camera>
</view>
<view tt:else class="photos-preview">
<view class="photo-item" tt:for="{{photos}}" tt:key="index">
<image class="photo-image" src="{{item}}" mode="aspectFill"></image>
<view class="photo-label">{{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}</view>
</view>
</view>
</view>
<view class="button-container">
<view tt:if="{{photos.length < 3}}" class="capture-actions">
<view class="capture-button" bindtap="takePhoto">
<text class="button-text">拍摄{{guideText}}照片</text>
</view>
<view class="album-button" bindtap="goToUpload">
<text class="album-button-text">从相册选择</text>
</view>
</view>
<view tt:else class="action-buttons">
<view class="action-button secondary" bindtap="retakeAll">
<text class="action-button-text">重新拍摄</text>
</view>
<view class="action-button primary" bindtap="completeCapture">
<text class="action-button-text">立即{{aiAnalysisText || '分析'}}</text>
</view>
</view>
</view>
<view class="phone-auth-section" tt:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后继续拍摄
</button>
</view>
</view>
</view>

View File

@@ -1,312 +1,366 @@
/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar含中间浮起圆钮预留空间 */
.container {
width: 100%;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
/* 预留底部空间,避免与自定义 tabBar约 100rpx 高 + 中间圆钮上浮约 56rpx重叠 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.progress-section {
flex-shrink: 0;
margin: 16rpx 24rpx 0;
padding: 20rpx 32rpx 16rpx;
border-radius: 10rpx;
background: linear-gradient(to right, #fff5f5, #ffe5e8);
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.step-text {
font-size: 26rpx;
font-weight: 600;
color: #e63946;
}
.photo-count {
font-size: 22rpx;
color: #999;
}
.progress-bars {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.progress-bar {
flex: 1;
height: 6rpx;
border-radius: 6rpx;
}
.progress-bar.completed {
background-color: #52c41a;
}
.progress-bar.pending {
background-color: #e5e5e5;
}
.instruction-card {
background: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(230, 57, 70, 0.1);
}
.instruction-content {
display: flex;
align-items: center;
gap: 12rpx;
}
.step-number {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
border-radius: 50%;
background-color: #e63946;
color: #fff;
font-size: 24rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.instruction-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rpx;
}
.angle-text {
font-size: 26rpx;
font-weight: 600;
color: #c41d2a;
}
.tip-text {
font-size: 22rpx;
color: #666;
line-height: 1.3;
}
.camera-container {
flex: 1;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.camera-preview {
width: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
border-radius: 32rpx;
overflow: hidden;
border: 6rpx solid #e5e5e5;
background-color: #000;
position: relative;
}
.camera {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photos-preview {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.photo-item {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
max-height: 100%;
border-radius: 16rpx;
overflow: hidden;
border: 4rpx solid #e5e5e5;
}
.photo-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-label {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(230, 57, 70, 0.9);
color: #fff;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.button-container {
flex-shrink: 0;
padding: 16rpx 24rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.capture-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.capture-actions .capture-button {
width: 100%;
}
.album-button {
width: 100%;
padding: 20rpx;
border-radius: 50rpx;
text-align: center;
background: #fff;
border: 2rpx solid #e63946;
box-sizing: border-box;
}
.album-button:active {
background: #fff5f5;
}
.album-button-text {
font-size: 28rpx;
font-weight: 600;
color: #e63946;
letter-spacing: 1rpx;
}
.phone-auth-section {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.phone-auth-tip {
font-size: 26rpx;
color: #4b5563;
}
.phone-auth-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #ffffff;
font-size: 28rpx;
}
.phone-auth-btn::after {
border: none;
}
.capture-button {
width: 100%;
max-width: 100%;
padding: 22rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
text-align: center;
box-sizing: border-box;
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
}
.button-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
width: 100%;
}
.action-button {
flex: 1;
padding: 28rpx 24rpx;
border-radius: 50rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.action-button.secondary {
background: #fff;
border: 2rpx solid #e63946;
}
.action-button.secondary:active {
background: #fff5f5;
transform: scale(0.98);
}
.action-button.primary {
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
}
.action-button.primary:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
}
.action-button-text {
font-size: 28rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.action-button.secondary .action-button-text {
color: #e63946;
}
.action-button.primary .action-button-text {
color: #fff;
}
/* pages/index/camera.ttss */
.camera-page-root {
min-height: 100vh;
min-height: 100dvh;
position: relative;
}
.container {
width: 100%;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
/* 预留底部空间,避免与自定义 tabBar约 100rpx 高 + 中间圆钮上浮约 56rpx重叠 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.progress-section {
flex-shrink: 0;
margin: 16rpx 24rpx 0;
padding: 20rpx 32rpx 16rpx;
border-radius: 10rpx;
background: linear-gradient(to right, #fff5f5, #ffe5e8);
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.step-text {
font-size: 26rpx;
font-weight: 600;
color: #e63946;
}
.photo-count {
font-size: 22rpx;
color: #999;
}
.progress-bars {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.progress-bar {
flex: 1;
height: 6rpx;
border-radius: 6rpx;
}
.progress-bar.completed {
background-color: #52c41a;
}
.progress-bar.pending {
background-color: #e5e5e5;
}
.instruction-card {
background: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(230, 57, 70, 0.1);
}
.instruction-content {
display: flex;
align-items: center;
gap: 12rpx;
}
.step-number {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
border-radius: 50%;
background-color: #e63946;
color: #fff;
font-size: 24rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.instruction-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rpx;
}
.angle-text {
font-size: 26rpx;
font-weight: 600;
color: #c41d2a;
}
.tip-text {
font-size: 22rpx;
color: #666;
line-height: 1.3;
}
.camera-container {
flex: 1;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.camera-preview {
width: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
border-radius: 32rpx;
overflow: hidden;
border: 6rpx solid #e5e5e5;
background-color: #000;
position: relative;
}
.camera {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photos-preview {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.photo-item {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
max-height: 100%;
border-radius: 16rpx;
overflow: hidden;
border: 4rpx solid #e5e5e5;
}
.photo-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-label {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(230, 57, 70, 0.9);
color: #fff;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.button-container {
flex-shrink: 0;
padding: 16rpx 24rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.capture-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.capture-actions .capture-button {
width: 100%;
}
.album-button {
width: 100%;
padding: 20rpx;
border-radius: 50rpx;
text-align: center;
background: #fff;
border: 2rpx solid #e63946;
box-sizing: border-box;
}
.album-button:active {
background: #fff5f5;
}
.album-button-text {
font-size: 28rpx;
font-weight: 600;
color: #e63946;
letter-spacing: 1rpx;
}
.phone-auth-section {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.phone-auth-tip {
font-size: 26rpx;
color: #4b5563;
}
.phone-auth-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #ffffff;
font-size: 28rpx;
}
.phone-auth-btn::after {
border: none;
}
.capture-button {
width: 100%;
max-width: 100%;
padding: 22rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
text-align: center;
box-sizing: border-box;
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
}
.button-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
width: 100%;
}
.action-button {
flex: 1;
padding: 28rpx 24rpx;
border-radius: 50rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.action-button.secondary {
background: #fff;
border: 2rpx solid #e63946;
}
.action-button.secondary:active {
background: #fff5f5;
transform: scale(0.98);
}
.action-button.primary {
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
}
.action-button.primary:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
}
.action-button-text {
font-size: 28rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.action-button.secondary .action-button-text {
color: #e63946;
}
.action-button.primary .action-button-text {
color: #fff;
}
.review-mode-panel {
justify-content: center;
align-items: center;
padding: 48rpx 40rpx;
background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
}
.review-mode-card {
width: 100%;
max-width: 620rpx;
padding: 48rpx 40rpx;
background: #fff;
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.06);
border: 1rpx solid rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.review-mode-title {
font-size: 34rpx;
font-weight: 700;
color: #1e293b;
}
.review-mode-desc {
font-size: 28rpx;
color: #64748b;
line-height: 1.65;
text-align: center;
}
.review-mode-btn {
margin-top: 16rpx;
padding: 24rpx 56rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
color: #fff;
font-size: 30rpx;
font-weight: 600;
}
.review-mode-btn:active {
opacity: 0.9;
}

View File

@@ -29,7 +29,7 @@ Page({
navbarHeight: navbarHeightRpx,
showEnterpriseEntry: userInfo.hasEnterprise === true,
siteTitle: gd.reviewMode ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: gd.reviewMode ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: !!gd.reviewMode
})
@@ -45,7 +45,7 @@ Page({
if (cfg.textConfig) {
getApp().globalData.textConfig = cfg.textConfig
this.setData({
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '开始面相测试'),
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '拍摄'),
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
})
}
@@ -100,7 +100,7 @@ Page({
const rm = !!gd.reviewMode
this.setData({
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm
})
@@ -124,14 +124,10 @@ Page({
}
},
// 开始测试(审核模式跳问卷,正常模式跳拍照
// 与底栏中间一致:始终进入拍摄 Tab审核态在 camera 页内展示问卷引导
startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {}
if (this.data.reviewMode) {
tt.navigateTo({ url: '/pages/test-select/index' })
} else {
tt.switchTab({ url: '/pages/index/camera' })
}
tt.switchTab({ url: '/pages/index/camera' })
},
// 上传照片(个人版入口:强制本次链路为个人定价)

View File

@@ -1,113 +1,113 @@
<!--pages/index/upload.wxml - 上传照片引导页-->
<view class="page">
<!-- 主体内容 -->
<view class="content">
<!-- 引导文案 -->
<view class="intro">
<text class="intro-title">多角度拍摄更精准</text>
<view class="intro-desc-wrap">
<text class="intro-desc">为了更准确地分析您的性格特征,</text>
<text class="intro-desc">请上传以下三个维度的照片。</text>
</view>
</view>
<!-- 三个角度上传卡片 -->
<view class="steps">
<!-- 正面 -->
<view class="step-item">
<view class="step-index step-index-primary">
<text class="step-index-text step-index-text-primary">1</text>
</view>
<view class="step-body">
<text class="step-title">正面</text>
<text class="step-subtitle">目视前方,五官端正,不遮挡额头</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[0]}}"></image>
<view class="upload-card" bindtap="onUploadFront">
<image tt:if="{{photos[0]}}" class="upload-preview" mode="aspectFill" src="{{photos[0]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 左侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">2</text>
</view>
<view class="step-body">
<text class="step-title">左侧 45度</text>
<text class="step-subtitle">展示左侧面颊,用于分析面部轮廓</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[1]}}"></image>
<view class="upload-card" bindtap="onUploadLeft">
<image tt:if="{{photos[1]}}" class="upload-preview" mode="aspectFill" src="{{photos[1]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 右侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">3</text>
</view>
<view class="step-body">
<text class="step-title">右侧 45度</text>
<text class="step-subtitle">展示右侧面颊,完整捕捉面部信息</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[2]}}"></image>
<view class="upload-card" bindtap="onUploadRight">
<image tt:if="{{photos[2]}}" class="upload-preview" mode="aspectFill" src="{{photos[2]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-card">
<view class="tips-title-row">
<view class="tips-icon"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<text class="tips-item">· 光线充足,背景整洁,避免过曝或过暗</text>
<text class="tips-item">· 保持表情自然,无需刻意微笑</text>
<text class="tips-item">· 如有佩戴眼镜,请确保镜片不反光</text>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示 -->
<view class="phone-auth-section" tt:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
授权手机号后开始查看报告
</button>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer">
<button class="primary-btn" bindtap="completeCapture">开始分析</button>
</view>
<!-- 自定义底部导航,与首页保持一致 -->
<custom-tab-bar />
</view>
<!--pages/index/upload.wxml - 上传照片引导页-->
<view class="page">
<!-- 主体内容 -->
<view class="content">
<!-- 引导文案 -->
<view class="intro">
<text class="intro-title">多角度拍摄更精准</text>
<view class="intro-desc-wrap">
<text class="intro-desc">为了更准确地分析您的性格特征,</text>
<text class="intro-desc">请上传以下三个维度的照片。</text>
</view>
</view>
<!-- 三个角度上传卡片 -->
<view class="steps">
<!-- 正面 -->
<view class="step-item">
<view class="step-index step-index-primary">
<text class="step-index-text step-index-text-primary">1</text>
</view>
<view class="step-body">
<text class="step-title">正面</text>
<text class="step-subtitle">目视前方,五官端正,不遮挡额头</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[0]}}"></image>
<view class="upload-card" bindtap="onUploadFront">
<image tt:if="{{photos[0]}}" class="upload-preview" mode="aspectFill" src="{{photos[0]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 左侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">2</text>
</view>
<view class="step-body">
<text class="step-title">左侧 45度</text>
<text class="step-subtitle">展示左侧面颊,用于分析面部轮廓</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[1]}}"></image>
<view class="upload-card" bindtap="onUploadLeft">
<image tt:if="{{photos[1]}}" class="upload-preview" mode="aspectFill" src="{{photos[1]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 右侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">3</text>
</view>
<view class="step-body">
<text class="step-title">右侧 45度</text>
<text class="step-subtitle">展示右侧面颊,完整捕捉面部信息</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[2]}}"></image>
<view class="upload-card" bindtap="onUploadRight">
<image tt:if="{{photos[2]}}" class="upload-preview" mode="aspectFill" src="{{photos[2]}}"></image>
<block tt:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-card">
<view class="tips-title-row">
<view class="tips-icon"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<text class="tips-item">· 光线充足,背景整洁,避免过曝或过暗</text>
<text class="tips-item">· 保持表情自然,无需刻意微笑</text>
<text class="tips-item">· 如有佩戴眼镜,请确保镜片不反光</text>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示 -->
<view class="phone-auth-section" tt:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
授权手机号后继续上传
</button>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer">
<button class="primary-btn" bindtap="completeCapture">开始分析</button>
</view>
<!-- 自定义底部导航,与首页保持一致 -->
<custom-tab-bar />
</view>

View File

@@ -1,5 +1,5 @@
<view class="container">
<view class="tip">为保障服务与联系需要,请授权您的手机号。</view>
<view class="tip sub">授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。</view>
<button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">授权手机号</button>
</view>
<view class="container">
<view class="tip">为保障服务与联系需要,请授权您的手机号。</view>
<view class="tip sub">授权后可在「拍摄」「支付」「企业咨询」等场景使用,仅需授权一次。</view>
<button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">授权手机号</button>
</view>

View File

@@ -258,10 +258,16 @@ Page({
},
goToIndex() { tt.switchTab({ url: '/pages/index/index' }) },
goToCamera() { tt.switchTab({ url: '/pages/index/camera' }) },
goToHistory() { tt.navigateTo({ url: '/pages/history/index' }) },
goToHistory() {
try { require('../../utils/analytics').track('tap_test_history', {}) } catch (e) {}
tt.navigateTo({ url: '/pages/history/index' })
},
goToUserProfile() { tt.navigateTo({ url: '/pages/user-profile/index' }) },
goToPurchase() { tt.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToDeepService() {
try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
tt.navigateTo({ url: '/pages/purchase/index' })
},
goToPurchase() { tt.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { tt.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { tt.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToEnterprise() { tt.navigateTo({ url: '/pages/enterprise/index' }) },

View File

@@ -68,58 +68,84 @@
<text class="chevron"></text>
</view>
<!-- 最新测试卡片(横向滚动) -->
<view class="section" tt:if="{{hasLogin && hasResults}}">
<view class="section-header">
<text class="section-title">最新测试</text>
<view class="section-link" bindtap="goToHistory">
<text class="section-link-text">查看全部</text>
<view class="section px-section" tt:if="{{hasLogin}}">
<text class="section-title depth-parse-title">深度解析</text>
<view class="depth-unified-card">
<view class="depth-unified-header">
<text class="depth-unified-subtitle">最新测试</text>
<view class="depth-header-link" bindtap="goToHistory">
<text class="depth-header-link-text">查看全部<text tt:if="{{testCount > 0}}"> · {{testCount}}条</text></text>
<text class="depth-header-chevron"></text>
</view>
</view>
<block tt:if="{{hasResults}}">
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card">
<view class="result-card card-purple" tt:if="{{mbtiType}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiTime}}</text>
</view>
<view class="result-card card-blue" tt:if="{{discType}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType}}型</text>
<text class="card-time">{{discTime}}</text>
</view>
<view class="result-card card-orange" tt:if="{{pdpType}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpTime}}</text>
</view>
<view class="result-card card-rose" tt:if="{{aiType && !reviewMode}}" bindtap="viewAI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiTime}}</text>
</view>
</view>
</scroll-view>
</block>
<view tt:else class="depth-empty-hint">
<text>完成任一测评后,此处展示最近一次结果;右上角可查看全部历史。</text>
</view>
<view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToDeepService">
<view class="menu-icon-wrap menu-icon-purple">
<text class="menu-icon">✨</text>
</view>
<view class="menu-content">
<text class="menu-title">深度解读与方案</text>
<text class="menu-sub">个人报告与团队/企业服务,进入后按需选择</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider menu-divider--in-card" tt:if="{{hasEnterprise}}"></view>
<view class="menu-item menu-item--flat" tt:if="{{hasEnterprise}}" bindtap="goToMyResume">
<view class="menu-icon-wrap menu-icon-indigo">
<text class="menu-icon">📋</text>
</view>
<view class="menu-content">
<text class="menu-title">我的简历</text>
<text class="menu-sub">查看与设置默认简历</text>
</view>
<text class="menu-chevron"></text>
</view>
</view>
<scroll-view scroll-x class="cards-scroll" enhanced show-scrollbar="{{false}}">
<view class="cards-row">
<!-- MBTI -->
<view class="result-card card-purple" tt:if="{{mbtiType}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiTime}}</text>
</view>
<!-- DISC -->
<view class="result-card card-blue" tt:if="{{discType}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType}}型</text>
<text class="card-time">{{discTime}}</text>
</view>
<!-- PDP -->
<view class="result-card card-orange" tt:if="{{pdpType}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpTime}}</text>
</view>
<!-- AI 面相(审核模式下隐藏) -->
<view class="result-card card-rose" tt:if="{{aiType && !reviewMode}}" bindtap="viewAI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiTime}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 推广中心(根据管理端开关显示/隐藏,标题可配置) -->
@@ -150,63 +176,5 @@
</view>
</view>
<!-- 服务菜单 -->
<view class="section px-section">
<text class="section-title">深度服务</text>
<view class="menu-card">
<!-- 专业报告:跳到个人版开通页面 -->
<view class="menu-item" bindtap="goToPurchasePersonal">
<view class="menu-icon-wrap menu-icon-red">
<text class="menu-icon">📄</text>
</view>
<view class="menu-content">
<text class="menu-title">专业报告</text>
<text class="menu-sub">解锁完整的深度性格解析</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider"></view>
<!-- 企业版服务:跳到企业版开通页面 -->
<view class="menu-item" bindtap="goToPurchaseEnterprise">
<view class="menu-icon-wrap menu-icon-indigo">
<text class="menu-icon">🏢</text>
</view>
<view class="menu-content">
<text class="menu-title">企业版服务</text>
<text class="menu-sub">团队测评与人才管理方案</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider"></view>
<!-- 测试历史:仍然进入历史记录页面 -->
<view class="menu-item" bindtap="goToHistory">
<view class="menu-icon-wrap menu-icon-amber">
<text class="menu-icon">🕒</text>
</view>
<view class="menu-content">
<text class="menu-title">测试历史</text>
<text class="menu-sub">{{testCount > 0 ? testCount + '条记录' : '查看过往所有测试记录'}}</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider" tt:if="{{hasEnterprise}}"></view>
<!-- 我的简历:仅绑定企业的用户可见,展示当前企业下的简历并可设默认 -->
<view class="menu-item" tt:if="{{hasEnterprise}}" bindtap="goToMyResume">
<view class="menu-icon-wrap menu-icon-indigo">
<text class="menu-icon">📋</text>
</view>
<view class="menu-content">
<text class="menu-title">我的简历</text>
<text class="menu-sub">查看与设置默认简历</text>
</view>
<text class="menu-chevron"></text>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -1,247 +1,251 @@
// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
Page({
data: {
activeTab: 'personal',
personalCategories: [],
enterpriseCategories: [],
loading: true,
purchasing: false,
hasPhone: false,
successModal: {
visible: false,
title: '',
content: '',
wechat: ''
}
},
onLoad(options) {
const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal'
this.setData({ activeTab: tab })
tt.setNavigationBarTitle({ title: tab === 'enterprise' ? '开通企业版' : '开通个人版' })
this.loadDeepPricing()
},
onShow() {
if (!ensureProfileCompleteAndRedirect()) return
this.setData({ hasPhone: hasPhone() })
},
loadDeepPricing() {
const apiBase = app.globalData.apiBase || ''
if (!apiBase) {
this.setData({ loading: false })
return
}
this.setData({ loading: true })
Promise.all([
this.requestDeepPricing('personal'),
this.requestDeepPricing('enterprise')
]).then(([personal, enterprise]) => {
this.setData({
personalCategories: personal || [],
enterpriseCategories: enterprise || [],
loading: false
})
}).catch(() => {
this.setData({ loading: false })
})
},
requestDeepPricing(scope) {
return new Promise((resolve) => {
tt.request({
url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`,
method: 'GET',
data: { scope },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
resolve(res.data.data.categories)
} else {
resolve([])
}
},
fail: () => resolve([])
})
})
},
switchTab(e) {
const tab = e.currentTarget.dataset.tab
this.setData({ activeTab: tab })
},
// 无需再次授权时,直接点击按钮执行购买/咨询
handlePurchaseTap(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
this.handlePurchase(tab, index)
},
// 实际执行购买/咨询逻辑(已确保有手机号)
handlePurchase(tab, index) {
if (!ensureProfileCompleteAndRedirect()) return
if (index === undefined || index === null) return
const list = tab === 'enterprise' ? this.data.enterpriseCategories : this.data.personalCategories
const category = list[index]
if (!category) return
if (category.actionType === 'buy' && category.productKey) {
this.purchasePersonal(category)
} else {
this.applyConsult(category)
}
},
// 购买/企业咨询按钮:就地触发微信系统手机号授权,然后执行 handlePurchase
onGetPhoneNumberForPurchase(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
// 用户拒绝但之前已授权过,本地已有手机号,则直接继续
this.handlePurchase(tab, index)
return
}
if (!code) {
if (hasPhone()) {
this.handlePurchase(tab, index)
} else {
tt.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.handlePurchase(tab, index)
})
.catch(() => {
// 失败时只提示,不阻塞后续再次点击
})
},
purchasePersonal(category) {
if (this.data.purchasing) return
this.setData({ purchasing: true })
tt.showLoading({ title: '处理中...', mask: true })
const deepProductId = category.id || category.productKey || ''
const title = category.title || '个人深度服务1v1深度解读'
payment.purchasePersonalDeepService({
deepProductId,
description: title,
success: () => {
tt.hideLoading()
this.setData({ purchasing: false })
this._reportCrmLead(category, 'buy')
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
const wechat = (category.serviceWechat || '').trim()
this._showSuccessModal('购买成功', successMsg, wechat)
},
fail: () => {
tt.hideLoading()
this.setData({ purchasing: false })
}
})
},
applyConsult(category) {
// serviceWechat 展示给用户consultWechat 是存客宝 API key
const wechat = (category.serviceWechat || '').trim()
const apiKey = (category.consultWechat || '').trim()
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
tt.showLoading({ title: '提交中...', mask: true })
if (apiKey) {
this._reportCrmLead(category, 'consult')
}
setTimeout(() => {
tt.hideLoading()
this._showSuccessModal('申请成功', successMsg, wechat)
}, 600)
},
_showSuccessModal(title, content, wechat) {
this.setData({
successModal: {
visible: true,
title: title || '成功',
content: content || '',
wechat: wechat || ''
}
})
},
closeSuccessModal() {
this.setData({ 'successModal.visible': false })
},
copyWechat() {
const wechat = this.data.successModal.wechat
if (!wechat) return
tt.setClipboardData({
data: wechat,
success: () => tt.showToast({ title: '已复制微信号', icon: 'success' })
})
},
/**
* 向后端上报存客宝线索,后端负责签名和调用存客宝 API
* @param {Object} category 深度服务类目对象(需含 consultWechat / title
* @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
*/
_reportCrmLead(category, actionType) {
const apiKey = category.consultWechat || ''
if (!apiKey) return
const apiBase = app.globalData.apiBase || ''
if (!apiBase) return
const isEnterprise = this.data.activeTab === 'enterprise'
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
const remark = actionType === 'buy' ? '完成付款' : '申请咨询'
tt.request({
url: `${apiBase.replace(/\/$/, '')}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${tt.getStorageSync('token') || ''}`,
'Content-Type': 'application/json',
},
data: {
apiKey,
source,
remark,
siteTags: category.title || '',
},
success(res) {
console.log('[CRM] 线索上报结果', res.data)
},
fail(err) {
console.warn('[CRM] 线索上报请求失败', err)
},
})
},
onShareAppMessage() {
const { getSharePath } = require('../../utils/share')
return { title: '神仙团队性格测试 - 发现你的内在潜能', path: getSharePath('/pages/purchase/index') }
},
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
return {
title: '神仙团队性格测试 - 发现你的内在潜能',
query: buildShareQuery()
}
}
})
// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
Page({
data: {
activeTab: 'personal',
personalCategories: [],
enterpriseCategories: [],
loading: true,
purchasing: false,
hasPhone: false,
successModal: {
visible: false,
title: '',
content: '',
wechat: ''
}
},
onLoad(options) {
const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal'
this.setData({ activeTab: tab })
tt.setNavigationBarTitle({ title: '深度服务' })
this.loadDeepPricing()
},
onShow() {
if (!ensureProfileCompleteAndRedirect()) return
this.setData({ hasPhone: hasPhone() })
},
loadDeepPricing() {
const apiBase = app.globalData.apiBase || ''
if (!apiBase) {
this.setData({ loading: false })
return
}
this.setData({ loading: true })
Promise.all([
this.requestDeepPricing('personal'),
this.requestDeepPricing('enterprise')
]).then(([personal, enterprise]) => {
this.setData({
personalCategories: personal || [],
enterpriseCategories: enterprise || [],
loading: false
})
}).catch(() => {
this.setData({ loading: false })
})
},
requestDeepPricing(scope) {
return new Promise((resolve) => {
tt.request({
url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`,
method: 'GET',
data: { scope },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
resolve(res.data.data.categories)
} else {
resolve([])
}
},
fail: () => resolve([])
})
})
},
switchTab(e) {
const tab = e.currentTarget.dataset.tab
if (tab !== 'personal' && tab !== 'enterprise') return
this.setData({ activeTab: tab })
tt.setNavigationBarTitle({ title: '深度服务' })
},
// 无需再次授权时,直接点击按钮执行购买/咨询
handlePurchaseTap(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
this.handlePurchase(tab, index)
},
// 实际执行购买/咨询逻辑(已确保有手机号)
handlePurchase(tab, index) {
if (!ensureProfileCompleteAndRedirect()) return
if (index === undefined || index === null) return
const list = tab === 'enterprise' ? this.data.enterpriseCategories : this.data.personalCategories
const category = list[index]
if (!category) return
if (category.actionType === 'buy' && category.productKey) {
this.purchasePersonal(category)
} else {
this.applyConsult(category)
}
},
// 购买/企业咨询按钮:就地触发微信系统手机号授权,然后执行 handlePurchase
onGetPhoneNumberForPurchase(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
// 用户拒绝但之前已授权过,本地已有手机号,则直接继续
this.handlePurchase(tab, index)
return
}
if (!code) {
if (hasPhone()) {
this.handlePurchase(tab, index)
} else {
tt.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.handlePurchase(tab, index)
})
.catch(() => {
// 失败时只提示,不阻塞后续再次点击
})
},
purchasePersonal(category) {
if (this.data.purchasing) return
this.setData({ purchasing: true })
tt.showLoading({ title: '处理中...', mask: true })
const deepProductId = category.id || category.productKey || ''
const title = category.title || '个人深度服务1v1深度解读'
payment.purchasePersonalDeepService({
deepProductId,
description: title,
success: () => {
tt.hideLoading()
this.setData({ purchasing: false })
this._reportCrmLead(category, 'buy')
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
const wechat = (category.serviceWechat || '').trim()
this._showSuccessModal('购买成功', successMsg, wechat)
},
fail: () => {
tt.hideLoading()
this.setData({ purchasing: false })
}
})
},
applyConsult(category) {
// serviceWechat 展示给用户consultWechat 是存客宝 API key
const wechat = (category.serviceWechat || '').trim()
const apiKey = (category.consultWechat || '').trim()
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
tt.showLoading({ title: '提交中...', mask: true })
if (apiKey) {
this._reportCrmLead(category, 'consult')
}
setTimeout(() => {
tt.hideLoading()
this._showSuccessModal('申请成功', successMsg, wechat)
}, 600)
},
_showSuccessModal(title, content, wechat) {
this.setData({
successModal: {
visible: true,
title: title || '成功',
content: content || '',
wechat: wechat || ''
}
})
},
catchTap() {},
closeSuccessModal() {
this.setData({ 'successModal.visible': false })
},
copyWechat() {
const wechat = this.data.successModal.wechat
if (!wechat) return
tt.setClipboardData({
data: wechat,
success: () => tt.showToast({ title: '已复制微信号', icon: 'success' })
})
},
/**
* 向后端上报存客宝线索,后端负责签名和调用存客宝 API
* @param {Object} category 深度服务类目对象(需含 consultWechat / title
* @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
*/
_reportCrmLead(category, actionType) {
const apiKey = category.consultWechat || ''
if (!apiKey) return
const apiBase = app.globalData.apiBase || ''
if (!apiBase) return
const isEnterprise = this.data.activeTab === 'enterprise'
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
const remark = actionType === 'buy' ? '完成付款' : '申请咨询'
tt.request({
url: `${apiBase.replace(/\/$/, '')}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${tt.getStorageSync('token') || ''}`,
'Content-Type': 'application/json',
},
data: {
apiKey,
source,
remark,
siteTags: category.title || '',
},
success(res) {
console.log('[CRM] 线索上报结果', res.data)
},
fail(err) {
console.warn('[CRM] 线索上报请求失败', err)
},
})
},
onShareAppMessage() {
const { getSharePath } = require('../../utils/share')
return { title: '神仙团队性格测试 - 发现你的内在潜能', path: getSharePath('/pages/purchase/index') }
},
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
return {
title: '神仙团队性格测试 - 发现你的内在潜能',
query: buildShareQuery()
}
}
})

View File

@@ -1,135 +1,146 @@
<!--pages/purchase/index.wxml - 开通会员(深度服务类目由接口拉取,个人/企业区分-->
<!-- 成功弹窗 -->
<view class="success-mask" tt:if="{{successModal.visible}}" bindtap="closeSuccessModal">
<view class="success-dialog" catchtap="catchTap">
<view class="success-icon-wrap">
<view class="success-icon-circle">
<text class="success-icon-check">✓</text>
</view>
</view>
<text class="success-dialog-title">{{successModal.title}}</text>
<text class="success-dialog-content">{{successModal.content}}</text>
<view tt:if="{{successModal.wechat}}" class="success-wechat-wrap">
<text class="success-wechat-label">客服微信</text>
<text class="success-wechat-val">{{successModal.wechat}}</text>
</view>
<view class="success-dialog-btns">
<button tt:if="{{successModal.wechat}}" class="success-btn-copy" bindtap="copyWechat">复制微信号</button>
<button class="success-btn-close" bindtap="closeSuccessModal">我知道了</button>
</view>
</view>
</view>
<view class="container">
<view tt:if="{{loading}}" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<!-- 个人版:按类目列表渲染 -->
<view tt:elif="{{activeTab === 'personal'}}">
<view tt:for="{{personalCategories}}" tt:key="id" class="pricing-card {{index === 0 ? 'featured' : ''}}" style="margin: 24rpx;">
<view class="card-header">
<text class="plan-title">{{item.title}}</text>
<view class="price-section" tt:if="{{item.price !== undefined}}">
<text class="price-symbol">¥</text>
<text class="price-amount">{{item.price}}</text>
<text class="price-unit">{{item.priceUnit || '/次'}}</text>
</view>
<text class="plan-subtitle" tt:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="features-list" tt:if="{{item.features && item.features.length}}">
<view class="feature-item" tt:for="{{item.features}}" tt:for-item="f" tt:key="*this">
<view class="check-icon">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<!-- 未有手机号时:使用微信系统手机号授权组件 -->
<button
class="purchase-button primary"
tt:if="{{item.actionType === 'buy' && !hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">立即购买</text>
</button>
<!-- 已有手机号时:普通按钮,直接走支付 -->
<button
class="purchase-button primary"
tt:elif="{{item.actionType === 'buy' && hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">立即购买</text>
</button>
</view>
<view tt:if="{{!loading && activeTab === 'personal' && personalCategories.length === 0}}" class="empty-tip">
<text>暂无可用的个人版套餐</text>
</view>
</view>
<!-- 企业版:按类目列表渲染 -->
<view tt:elif="{{activeTab === 'enterprise'}}">
<view tt:for="{{enterpriseCategories}}" tt:key="id" class="pricing-card {{index === 1 ? 'featured-blue' : ''}}" style="margin: 24rpx;">
<view class="card-header-flex">
<view class="header-left">
<text class="plan-title-sm">{{item.title}}</text>
<text class="plan-desc" tt:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="header-right" tt:if="{{item.priceDisplay || item.price}}">
<text class="price-amount-small">{{item.priceDisplay || '¥' + item.price}}</text>
<text class="price-limit" tt:if="{{item.userLimit}}">{{item.userLimit}}</text>
</view>
</view>
<view class="features-list" tt:if="{{item.features && item.features.length}}">
<view class="feature-item" tt:for="{{item.features}}" tt:for-item="f" tt:key="*this">
<view class="check-icon blue">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<!-- 未有手机号时:使用微信系统手机号授权组件 -->
<button
class="purchase-button secondary"
tt:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '申请咨询'}}</text>
</button>
<!-- 已有手机号时:普通按钮,直接触发咨询逻辑 -->
<button
class="purchase-button secondary"
tt:elif="{{hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '申请咨询'}}</text>
</button>
</view>
<view tt:if="{{!loading && activeTab === 'enterprise' && enterpriseCategories.length === 0}}" class="empty-tip">
<text>暂无可用的企业版套餐</text>
</view>
</view>
<view class="safety-tips">
<view class="safety-item">
<text class="safety-icon">🔒</text>
<text class="safety-text">微信安全支付</text>
</view>
<view class="safety-item">
<text class="safety-icon">✅</text>
<text class="safety-text">即时到账开通</text>
</view>
<view class="safety-item">
<text class="safety-icon">📞</text>
<text class="safety-text">7×24客服</text>
</view>
</view>
</view>
<!--pages/purchase/index.ttml - 深度服务(个人/企业 Tab类目由接口拉取-->
<!-- 成功弹窗 -->
<view class="success-mask" tt:if="{{successModal.visible}}" bindtap="closeSuccessModal">
<view class="success-dialog" catchtap="catchTap">
<view class="success-icon-wrap">
<view class="success-icon-circle">
<text class="success-icon-check">✓</text>
</view>
</view>
<text class="success-dialog-title">{{successModal.title}}</text>
<text class="success-dialog-content">{{successModal.content}}</text>
<view tt:if="{{successModal.wechat}}" class="success-wechat-wrap">
<text class="success-wechat-label">客服微信</text>
<text class="success-wechat-val">{{successModal.wechat}}</text>
</view>
<view class="success-dialog-btns">
<button tt:if="{{successModal.wechat}}" class="success-btn-copy" bindtap="copyWechat">复制微信号</button>
<button class="success-btn-close" bindtap="closeSuccessModal">我知道了</button>
</view>
</view>
</view>
<view class="container">
<view tt:if="{{loading}}" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<block tt:else>
<view class="deep-intro">
<text class="deep-intro-text">先选服务类型,查看说明与权益;具体安排可在下一步与顾问沟通确认。</text>
</view>
<view class="tabs-container deep-tabs">
<view class="tabs-list">
<view class="tab-item {{activeTab === 'personal' ? 'active' : ''}}" data-tab="personal" bindtap="switchTab">
<text class="tab-text">个人解读</text>
</view>
<view class="tab-item {{activeTab === 'enterprise' ? 'active' : ''}}" data-tab="enterprise" bindtap="switchTab">
<text class="tab-text">团队与企业</text>
</view>
</view>
</view>
<view tt:if="{{activeTab === 'personal'}}">
<view tt:for="{{personalCategories}}" tt:key="id" class="pricing-card {{index === 0 ? 'featured' : ''}}" style="margin: 24rpx;">
<view class="card-header">
<text class="plan-title">{{item.title}}</text>
<view class="price-section" tt:if="{{item.price !== undefined}}">
<text class="price-symbol">¥</text>
<text class="price-amount">{{item.price}}</text>
<text class="price-unit">{{item.priceUnit || '/次'}}</text>
</view>
<text class="plan-subtitle" tt:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="features-list" tt:if="{{item.features && item.features.length}}">
<view class="feature-item" tt:for="{{item.features}}" tt:for-item="f" tt:key="*this">
<view class="check-icon">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<button
class="purchase-button primary"
tt:if="{{item.actionType === 'buy' && !hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">下一步</text>
</button>
<button
class="purchase-button primary"
tt:elif="{{item.actionType === 'buy' && hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">下一步</text>
</button>
</view>
<view tt:if="{{!loading && activeTab === 'personal' && personalCategories.length === 0}}" class="empty-tip">
<text>暂无可用的个人版套餐</text>
</view>
</view>
<view tt:elif="{{activeTab === 'enterprise'}}">
<view tt:for="{{enterpriseCategories}}" tt:key="id" class="pricing-card {{index === 1 ? 'featured-blue' : ''}}" style="margin: 24rpx;">
<view class="card-header-flex">
<view class="header-left">
<text class="plan-title-sm">{{item.title}}</text>
<text class="plan-desc" tt:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="header-right" tt:if="{{item.priceDisplay || item.price}}">
<text class="price-amount-small">{{item.priceDisplay || '¥' + item.price}}</text>
<text class="price-limit" tt:if="{{item.userLimit}}">{{item.userLimit}}</text>
</view>
</view>
<view class="features-list" tt:if="{{item.features && item.features.length}}">
<view class="feature-item" tt:for="{{item.features}}" tt:for-item="f" tt:key="*this">
<view class="check-icon blue">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<button
class="purchase-button secondary"
tt:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '预约沟通'}}</text>
</button>
<button
class="purchase-button secondary"
tt:elif="{{hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '预约沟通'}}</text>
</button>
</view>
<view tt:if="{{!loading && activeTab === 'enterprise' && enterpriseCategories.length === 0}}" class="empty-tip">
<text>暂无可用的企业版套餐</text>
</view>
</view>
<view class="safety-tips">
<view class="safety-item">
<text class="safety-icon">🔒</text>
<text class="safety-text">流程在平台内完成</text>
</view>
<view class="safety-item">
<text class="safety-icon">✅</text>
<text class="safety-text">顾问跟进确认</text>
</view>
<view class="safety-item">
<text class="safety-icon">📞</text>
<text class="safety-text">支持咨询与售后</text>
</view>
</view>
</block>
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +1,97 @@
const app = getApp()
const payment = require('../../utils/payment')
const { request } = require('../../utils/request')
Page({
data: {
enterpriseId: 0,
enterpriseName: '',
amountFen: 0,
amountYuan: '0.00',
paying: false
},
onLoad(options) {
const rawScene = options && options.scene ? decodeURIComponent(options.scene) : ''
const sceneParams = {}
if (rawScene) {
rawScene.split('&').forEach(pair => {
const [k, v] = pair.split('=')
if (k) sceneParams[k] = v || ''
})
}
const enterpriseId = parseInt(sceneParams.eid || options.eid || 0, 10) || 0
const amountFen = parseInt(sceneParams.a || options.amountFen || 0, 10) || 0
const amountYuan = (amountFen / 100).toFixed(2)
if (enterpriseId > 0) {
app.globalData.enterpriseIdFromScene = enterpriseId
}
this.setData({
enterpriseId,
enterpriseName: (app.globalData.userInfo && app.globalData.userInfo.enterpriseName) || '',
amountFen,
amountYuan
})
app.ensureLogin()
.then(() => {
if (enterpriseId > 0) {
request({
url: '/api/enterprise/bind',
method: 'POST',
data: { enterpriseId },
success: (res) => {
const payload = res && res.data && res.data.data ? res.data.data : {}
const enterpriseName = payload.enterpriseName || this.data.enterpriseName || ''
this.setData({ enterpriseName })
},
fail: () => {}
})
}
})
.catch(() => {
tt.showToast({ title: '请先登录', icon: 'none' })
})
},
submitRecharge() {
if (this.data.paying) return
if (!this.data.enterpriseId || !this.data.amountFen) {
tt.showToast({ title: '充值参数无效', icon: 'none' })
return
}
this.setData({ paying: true })
payment.recharge({
amountYuan: Number(this.data.amountYuan),
enterpriseId: this.data.enterpriseId,
success: () => {
this.setData({ paying: false })
tt.showToast({ title: '充值成功', icon: 'success' })
setTimeout(() => {
tt.navigateTo({ url: `/pages/enterprise/index?eid=${this.data.enterpriseId}` })
}, 1200)
},
fail: () => {
this.setData({ paying: false })
}
})
}
})
const app = getApp()
const payment = require('../../utils/payment')
const { request } = require('../../utils/request')
let analyticsMod = null
try {
analyticsMod = require('../../utils/analytics')
} catch (e) {}
Page({
data: {
enterpriseId: 0,
enterpriseName: '',
amountFen: 0,
amountYuan: '0.00',
paying: false
},
onLoad(options) {
const rawScene = options && options.scene ? decodeURIComponent(options.scene) : ''
const sceneParams = {}
if (rawScene) {
rawScene.split('&').forEach(pair => {
const [k, v] = pair.split('=')
if (k) sceneParams[k] = v || ''
})
}
const enterpriseId = parseInt(sceneParams.eid || options.eid || 0, 10) || 0
const amountFen = parseInt(sceneParams.a || options.amountFen || 0, 10) || 0
const amountYuan = (amountFen / 100).toFixed(2)
if (enterpriseId > 0) {
app.globalData.enterpriseIdFromScene = enterpriseId
}
this.setData({
enterpriseId,
enterpriseName: (app.globalData.userInfo && app.globalData.userInfo.enterpriseName) || '',
amountFen,
amountYuan
})
app.ensureLogin()
.then(() => {
if (enterpriseId > 0) {
request({
url: '/api/enterprise/bind',
method: 'POST',
data: { enterpriseId },
success: (res) => {
const payload = res && res.data && res.data.data ? res.data.data : {}
const enterpriseName = payload.enterpriseName || this.data.enterpriseName || ''
this.setData({ enterpriseName })
},
fail: () => {}
})
}
})
.catch(() => {
tt.showToast({ title: '请先登录', icon: 'none' })
})
},
submitRecharge() {
if (this.data.paying) return
if (!this.data.enterpriseId || !this.data.amountFen) {
tt.showToast({ title: '充值参数无效', icon: 'none' })
return
}
this.setData({ paying: true })
if (analyticsMod && typeof analyticsMod.track === 'function') {
analyticsMod.track('click_recharge', {
action: '充值页确认充值',
enterpriseId: this.data.enterpriseId,
amountFen: this.data.amountFen
})
if (typeof analyticsMod.flush === 'function') {
analyticsMod.flush()
}
}
payment.recharge({
amountYuan: Number(this.data.amountYuan),
enterpriseId: this.data.enterpriseId,
success: () => {
this.setData({ paying: false })
tt.showToast({ title: '充值成功', icon: 'success' })
setTimeout(() => {
tt.navigateTo({ url: `/pages/enterprise/index?eid=${this.data.enterpriseId}` })
}, 1200)
},
fail: () => {
this.setData({ paying: false })
}
})
}
})

View File

@@ -1,180 +1,223 @@
// pages/test/mbti.js - MBTI测试页面逻辑
const { mbtiQuestions, shuffleQuestions } = require('../../utils/questions')
const { mbtiDescriptions } = require('../../utils/descriptions')
const payment = require('../../utils/payment')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: mbtiQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 30 * 60, // 30分钟
formatTime: '30:00',
isSubmitting: false,
canAccess: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(mbtiQuestions)
this.setData({
questions,
currentQuestion: questions[0],
canAccess: true
})
this.startTimer()
},
// 检查访问权限
checkAccess() {
// 当前策略:所有测试免费开放,直接允许访问
// 若后续恢复收费,可重新启用 payment.canTakeTest 等校验逻辑
return true
},
onUnload() {
if (this.timer) {
clearInterval(this.timer)
}
},
// 启动计时器
startTimer() {
this.timer = setInterval(() => {
let time = this.data.timeRemaining - 1
if (time <= 0) {
clearInterval(this.timer)
this.submitTest()
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
// 选择答案
selectAnswer(e) {
const value = e.currentTarget.dataset.value
const questionId = this.data.currentQuestion.id
let answers = { ...this.data.answers }
answers[questionId] = value
this.setData({
selectedAnswer: value,
answers: answers,
answeredCount: Object.keys(answers).length,
progress: (Object.keys(answers).length / this.data.total) * 100
})
// 自动跳转下一题
setTimeout(() => {
if (this.data.currentIndex < this.data.total - 1) {
this.nextQuestion()
}
}, 300)
},
// 上一题
prevQuestion() {
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
// 下一题
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null
})
}
},
// 提交测试
submitTest() {
if (this.data.isSubmitting) return
this.setData({ isSubmitting: true })
const result = this.calculateResult()
// 保存结果
const resultData = {
...result,
testDuration: 30 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
timestamp: new Date().toISOString()
}
tt.setStorageSync('mbtiResult', resultData)
app.saveTestResult('mbti', resultData)
// 跳转到结果页
tt.redirectTo({
url: '/pages/result/mbti'
})
},
// 计算MBTI结果
calculateResult() {
const answers = this.data.answers
const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
// 统计各维度得分
Object.values(answers).forEach(value => {
if (scores.hasOwnProperty(value)) {
scores[value]++
}
})
// 确定MBTI类型
const mbtiType = [
scores.E >= scores.I ? 'E' : 'I',
scores.S >= scores.N ? 'S' : 'N',
scores.T >= scores.F ? 'T' : 'F',
scores.J >= scores.P ? 'J' : 'P'
].join('')
// 计算各维度百分比
const dimensionScores = {
EI: { E: scores.E, I: scores.I, dominant: scores.E >= scores.I ? 'E' : 'I', percentage: Math.round((Math.max(scores.E, scores.I) / (scores.E + scores.I)) * 100) },
SN: { S: scores.S, N: scores.N, dominant: scores.S >= scores.N ? 'S' : 'N', percentage: Math.round((Math.max(scores.S, scores.N) / (scores.S + scores.N)) * 100) },
TF: { T: scores.T, F: scores.F, dominant: scores.T >= scores.F ? 'T' : 'F', percentage: Math.round((Math.max(scores.T, scores.F) / (scores.T + scores.F)) * 100) },
JP: { J: scores.J, P: scores.P, dominant: scores.J >= scores.P ? 'J' : 'P', percentage: Math.round((Math.max(scores.J, scores.P) / (scores.J + scores.P)) * 100) }
}
// 计算置信度
const confidence = Math.round(
(dimensionScores.EI.percentage + dimensionScores.SN.percentage +
dimensionScores.TF.percentage + dimensionScores.JP.percentage) / 4
)
return {
mbtiType,
scores,
dimensionScores,
confidence,
description: mbtiDescriptions[mbtiType] || {}
}
}
})
// pages/test/mbti.js - MBTI测试页面逻辑(与微信端对齐:最后一题必提交结果)
const { mbtiQuestions, shuffleQuestions } = require('../../utils/questions')
const { mbtiDescriptions } = require('../../utils/descriptions')
const payment = require('../../utils/payment')
const app = getApp()
Page({
data: {
questions: [],
currentIndex: 0,
currentQuestion: null,
answers: {},
selectedAnswer: null,
total: mbtiQuestions.length,
answeredCount: 0,
progress: 0,
timeRemaining: 30 * 60,
formatTime: '30:00',
isSubmitting: false,
canAccess: false
},
timer: null,
onLoad() {
const questions = shuffleQuestions(mbtiQuestions)
const total = questions.length
this.setData({
questions,
currentQuestion: questions[0],
canAccess: true,
total,
progress: total ? Math.round((1 / total) * 100) : 0
})
this.startTimer()
},
checkAccess() {
return true
},
onUnload() {
if (this.timer) {
clearInterval(this.timer)
}
},
startTimer() {
this.timer = setInterval(() => {
let time = this.data.timeRemaining - 1
if (time <= 0) {
clearInterval(this.timer)
this.submitTest({ allowIncomplete: true })
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
this.setData({
timeRemaining: time,
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
})
}, 1000)
},
selectAnswer(e) {
const value = e.currentTarget.dataset.value
const questionId = this.data.currentQuestion.id
const idx = this.data.currentIndex
const tot = this.data.total
let answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
const progress = tot ? Math.round(((idx + 1) / tot) * 100) : 0
this.setData({
selectedAnswer: value,
answers,
answeredCount,
progress
})
setTimeout(() => {
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 320)
},
prevQuestion() {
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
const tot = this.data.total
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null,
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
})
}
},
nextQuestion() {
if (this.data.currentIndex < this.data.total - 1) {
const newIndex = this.data.currentIndex + 1
const newQuestion = this.data.questions[newIndex]
const tot = this.data.total
this.setData({
currentIndex: newIndex,
currentQuestion: newQuestion,
selectedAnswer: this.data.answers[newQuestion.id] || null,
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
})
}
},
finishTest() {
const q = this.data.currentQuestion
if (!q) return
if (this.data.answers[q.id] == null && this.data.selectedAnswer == null) {
tt.showToast({ title: '请先选择一项', icon: 'none' })
return
}
const tot = this.data.total
if (Object.keys(this.data.answers).length < tot) {
tt.showToast({ title: '还有题目未作答,请返回补答', icon: 'none' })
return
}
this.submitTest()
},
submitTest(opt = {}) {
if (this.data.isSubmitting) return
const allowIncomplete = !!opt.allowIncomplete
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
const tot = this.data.total
const n = Object.keys(this.data.answers).length
if (!allowIncomplete && n < tot) {
tt.showToast({ title: `还有 ${tot - n} 题未作答`, icon: 'none' })
this.startTimer()
return
}
this.setData({ isSubmitting: true })
let result
try {
result = this.calculateResult()
} catch (err) {
console.error('calculateResult', err)
tt.showToast({ title: '计算结果失败,请重试', icon: 'none' })
this.setData({ isSubmitting: false })
this.startTimer()
return
}
const resultData = {
...result,
answers: this.data.answers,
testDuration: 30 * 60 - this.data.timeRemaining,
completedAt: new Date().toISOString(),
timestamp: new Date().toISOString()
}
tt.setStorageSync('mbtiResult', resultData)
app.saveTestResult('mbti', resultData)
tt.redirectTo({
url: '/pages/result/mbti'
})
},
calculateResult() {
const answers = this.data.answers
const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
Object.values(answers).forEach(value => {
if (scores.hasOwnProperty(value)) {
scores[value]++
}
})
const mbtiType = [
scores.E >= scores.I ? 'E' : 'I',
scores.S >= scores.N ? 'S' : 'N',
scores.T >= scores.F ? 'T' : 'F',
scores.J >= scores.P ? 'J' : 'P'
].join('')
const pct = (a, b) => {
const s = a + b
if (!s) return 50
return Math.round((Math.max(a, b) / s) * 100)
}
const dimensionScores = {
EI: { E: scores.E, I: scores.I, dominant: scores.E >= scores.I ? 'E' : 'I', percentage: pct(scores.E, scores.I) },
SN: { S: scores.S, N: scores.N, dominant: scores.S >= scores.N ? 'S' : 'N', percentage: pct(scores.S, scores.N) },
TF: { T: scores.T, F: scores.F, dominant: scores.T >= scores.F ? 'T' : 'F', percentage: pct(scores.T, scores.F) },
JP: { J: scores.J, P: scores.P, dominant: scores.J >= scores.P ? 'J' : 'P', percentage: pct(scores.J, scores.P) }
}
let confidence = Math.round(
(dimensionScores.EI.percentage + dimensionScores.SN.percentage +
dimensionScores.TF.percentage + dimensionScores.JP.percentage) / 4
)
if (!Number.isFinite(confidence)) confidence = 0
return {
mbtiType,
scores,
dimensionScores,
confidence,
description: mbtiDescriptions[mbtiType] || {}
}
}
})

View File

@@ -1,48 +1,48 @@
<!--pages/test/mbti.wxml - MBTI测试页面按旧版模板重构-->
<view class="test-page">
<view class="progress-section">
<view class="progress-info">
<text class="question-count">问题 {{currentIndex + 1}}/{{total}}</text>
<text class="time-remaining">剩余时间: {{formatTime}}</text>
</view>
<view class="progress-bar-container">
<view class="progress-bar" style="width: {{progress}}%"></view>
</view>
</view>
<view class="content-area">
<view class="question-card">
<text class="question-text">{{currentQuestion.question}}</text>
<view class="options-container">
<view
class="option-item {{selectedAnswer === option.value ? 'selected' : ''}}"
tt:for="{{currentQuestion.options}}"
tt:for-item="option"
tt:key="value"
bindtap="selectAnswer"
data-value="{{option.value}}"
>
<view class="radio-button {{selectedAnswer === option.value ? 'checked' : ''}}">
<view tt:if="{{selectedAnswer === option.value}}" class="radio-inner"></view>
</view>
<text class="option-text">{{option.text}}</text>
</view>
</view>
</view>
</view>
<view class="footer-buttons">
<view class="nav-button secondary {{currentIndex === 0 ? 'disabled' : ''}}" bindtap="prevQuestion">
<text class="button-text">上一题</text>
</view>
<view class="nav-button secondary {{currentIndex === total - 1 ? 'disabled' : ''}}" bindtap="nextQuestion">
<text class="button-text">跳过</text>
</view>
</view>
<view class="submit-wrap" tt:if="{{answeredCount === total}}">
<view class="submit-button" bindtap="submitTest">
<text class="submit-text">{{isSubmitting ? '计算中...' : '完成测试,查看结果'}}</text>
</view>
</view>
</view>
<!--pages/test/mbti.wxml - MBTI测试页面按旧版模板重构-->
<view class="test-page">
<view class="progress-section">
<view class="progress-info">
<text class="question-count">问题 {{currentIndex + 1}}/{{total}}</text>
<text class="time-remaining">剩余时间: {{formatTime}}</text>
</view>
<view class="progress-bar-container">
<view class="progress-bar" style="width: {{progress}}%"></view>
</view>
</view>
<view class="content-area">
<view class="question-card">
<text class="question-text">{{currentQuestion.question}}</text>
<view class="options-container">
<view
class="option-item {{selectedAnswer === option.value ? 'selected' : ''}}"
tt:for="{{currentQuestion.options}}"
tt:for-item="option"
tt:key="value"
bindtap="selectAnswer"
data-value="{{option.value}}"
>
<view class="radio-button {{selectedAnswer === option.value ? 'checked' : ''}}">
<view tt:if="{{selectedAnswer === option.value}}" class="radio-inner"></view>
</view>
<text class="option-text">{{option.text}}</text>
</view>
</view>
</view>
<view tt:if="{{currentIndex === total - 1}}" class="last-hint">
<text class="last-hint-text">最后一题:选择后约 0.3 秒自动跳转结果页;若未跳转,请点右下角「查看结果」。</text>
</view>
</view>
<view class="footer-buttons">
<view class="nav-button secondary {{currentIndex === 0 ? 'disabled' : ''}}" bindtap="prevQuestion">
<text class="button-text">上一题</text>
</view>
<view tt:if="{{currentIndex < total - 1}}" class="nav-button secondary" bindtap="nextQuestion">
<text class="button-text">跳过</text>
</view>
<view tt:else class="nav-button primary {{isSubmitting ? 'disabled' : ''}}" bindtap="finishTest">
<text class="button-text button-text-on-primary">{{isSubmitting ? '正在生成…' : '查看结果'}}</text>
</view>
</view>
</view>

View File

@@ -1,172 +1,181 @@
/* pages/test/mbti.wxss - 按旧版模板重构 */
.test-page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fff;
}
.progress-section {
padding: 32rpx;
border-bottom: 1rpx solid #e5e5e5;
flex-shrink: 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.question-count {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.time-remaining {
font-size: 28rpx;
color: #999;
}
.progress-bar-container {
width: 100%;
height: 8rpx;
background-color: #e5e5e5;
border-radius: 8rpx;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
border-radius: 8rpx;
transition: width 0.3s ease;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 32rpx;
}
.question-card {
background-color: #fff;
border-radius: 24rpx;
padding: 48rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.question-text {
display: block;
font-size: 40rpx;
font-weight: 500;
color: #333;
line-height: 1.6;
margin-bottom: 48rpx;
}
.options-container {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.option-item {
display: flex;
align-items: center;
padding: 32rpx;
border: 2rpx solid #e5e5e5;
border-radius: 16rpx;
transition: all 0.3s ease;
}
.option-item.selected {
background-color: rgba(255, 107, 138, 0.12);
border-color: #FF6B8A;
}
.radio-button {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
transition: all 0.3s ease;
}
.radio-button.checked {
background-color: #FF6B8A;
border-color: #FF6B8A;
}
.radio-inner {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: #fff;
}
.option-text {
flex: 1;
font-size: 32rpx;
color: #333;
line-height: 1.5;
}
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 32rpx;
border-top: 1rpx solid #e5e5e5;
flex-shrink: 0;
}
.nav-button {
flex: 1;
padding: 28rpx;
border-radius: 16rpx;
text-align: center;
}
.nav-button.secondary {
background-color: #fff;
border: 2rpx solid #FF6B8A;
}
.nav-button.secondary .button-text {
color: #FF6B8A;
}
.nav-button.disabled {
opacity: 0.4;
pointer-events: none;
}
.button-text {
font-size: 32rpx;
font-weight: 500;
}
.submit-wrap {
padding: 0 32rpx 32rpx;
}
.submit-button {
background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
border-radius: 16rpx;
padding: 28rpx;
text-align: center;
}
.submit-text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
/* pages/test/mbti.wxss - 按旧版模板重构 */
.test-page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fff;
}
.progress-section {
padding: 32rpx;
border-bottom: 1rpx solid #e5e5e5;
flex-shrink: 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.question-count {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.time-remaining {
font-size: 28rpx;
color: #999;
}
.progress-bar-container {
width: 100%;
height: 8rpx;
background-color: #e5e5e5;
border-radius: 8rpx;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
border-radius: 8rpx;
transition: width 0.3s ease;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 32rpx;
}
.question-card {
background-color: #fff;
border-radius: 24rpx;
padding: 48rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.question-text {
display: block;
font-size: 40rpx;
font-weight: 500;
color: #333;
line-height: 1.6;
margin-bottom: 48rpx;
}
.options-container {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.option-item {
display: flex;
align-items: center;
padding: 32rpx;
border: 2rpx solid #e5e5e5;
border-radius: 16rpx;
transition: all 0.3s ease;
}
.option-item.selected {
background-color: rgba(255, 107, 138, 0.12);
border-color: #FF6B8A;
}
.radio-button {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
transition: all 0.3s ease;
}
.radio-button.checked {
background-color: #FF6B8A;
border-color: #FF6B8A;
}
.radio-inner {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: #fff;
}
.option-text {
flex: 1;
font-size: 32rpx;
color: #333;
line-height: 1.5;
}
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 32rpx;
border-top: 1rpx solid #e5e5e5;
flex-shrink: 0;
}
.nav-button {
flex: 1;
padding: 28rpx;
border-radius: 16rpx;
text-align: center;
}
.nav-button.secondary {
background-color: #fff;
border: 2rpx solid #FF6B8A;
}
.nav-button.secondary .button-text {
color: #FF6B8A;
}
.nav-button.primary {
background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
border: none;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 138, 0.35);
}
.button-text-on-primary {
color: #ffffff !important;
font-weight: 600;
}
.nav-button.disabled {
opacity: 0.4;
pointer-events: none;
}
.last-hint {
margin-top: 24rpx;
padding: 20rpx 24rpx;
background: rgba(255, 107, 138, 0.08);
border-radius: 16rpx;
border: 1rpx solid rgba(255, 107, 138, 0.2);
}
.last-hint-text {
font-size: 26rpx;
color: #be185d;
line-height: 1.5;
}
.button-text {
font-size: 32rpx;
font-weight: 500;
}

View File

@@ -0,0 +1,73 @@
/**
* 抖音小程序埋点(同微信接口)
*/
const { request } = require('./request.js')
const MAX_BATCH = 30
const queue = []
let lastPageReport = { path: '', t: 0 }
function getAppSafe() {
try {
return getApp()
} catch (e) {
return null
}
}
function getCurrentRoute() {
const pages = getCurrentPages()
const p = pages[pages.length - 1]
return p && p.route ? p.route : ''
}
function track(eventName, props) {
if (!eventName || typeof eventName !== 'string') return
const app = getAppSafe()
const openId =
app && app.globalData
? (app.globalData.openId || (app.globalData.userInfo && (app.globalData.userInfo.openid || app.globalData.userInfo.openId)) || '')
: ''
queue.push({
event_name: eventName,
page_path: getCurrentRoute(),
props: props && typeof props === 'object' ? props : {},
client_ts: Date.now(),
openid: openId || undefined
})
if (queue.length >= MAX_BATCH) {
flush()
}
}
function reportPageView() {
const path = getCurrentRoute()
if (!path) return
const now = Date.now()
if (path === lastPageReport.path && now - lastPageReport.t < 2000) {
return
}
lastPageReport = { path, t: now }
track('page_view', { path })
}
function flush() {
if (queue.length === 0) return
const events = queue.splice(0, queue.length)
request({
url: '/api/analytics/events',
method: 'POST',
needAuth: true,
data: { events },
allow401: false,
success() {},
fail() {}
})
}
module.exports = {
track,
flush,
reportPageView,
getCurrentRoute
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ App({
siteTitle: '神仙团队AI性格测试',
textConfig: null, // 从 /api/config/runtime 动态加载analyzingTitle, startButtonText, reportTitle, aiAnalysisText 等
maintenanceMode: undefined, // 审核模式undefined=未加载,等 getRuntimeConfig 后再决定,避免 tabBar 闪烁
reviewMode: undefined, // 面相审核开关,与 camera/index 一致,由 runtime.reviewMode 写入
// 当前使用范围personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入)
appScope: 'personal',
// 扫码进入企业页时 scene 解析出的企业IDe_123提交测试/分析时优先使用
@@ -38,11 +39,14 @@ App({
// 静默登录获取openId
this.silentLogin()
// 预加载站点/小程序名称、审核模式(供导航栏展示
// 预加载站点/小程序名称、维护模式与面相审核模式camera/首页文案
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle
if (cfg.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!cfg.maintenanceMode
if (typeof cfg.reviewMode === 'boolean') {
this.globalData.reviewMode = cfg.reviewMode
}
if (cfg.defaultEnterpriseId != null && Number(cfg.defaultEnterpriseId) > 0) {
this.globalData.defaultEnterpriseId = Number(cfg.defaultEnterpriseId)
} else {
@@ -52,6 +56,20 @@ App({
}).catch(() => {})
},
onShow() {
try {
const { reportPageView } = require('./utils/analytics.js')
reportPageView()
} catch (e) {}
},
onHide() {
try {
const { flush } = require('./utils/analytics.js')
flush()
} catch (e) {}
},
// 加载本地存储数据
loadStoredData() {
const token = wx.getStorageSync('token')
@@ -338,6 +356,9 @@ App({
if (data.siteTitle) this.globalData.siteTitle = data.siteTitle
if (data.textConfig) this.globalData.textConfig = data.textConfig
if (data.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!data.maintenanceMode
if (typeof data.reviewMode === 'boolean') {
this.globalData.reviewMode = data.reviewMode
}
if (data.defaultEnterpriseId != null && Number(data.defaultEnterpriseId) > 0) {
this.globalData.defaultEnterpriseId = Number(data.defaultEnterpriseId)
} else {

View File

@@ -39,7 +39,7 @@
},
{
"pagePath": "pages/index/camera",
"text": "查看报告",
"text": "拍摄",
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},

View File

@@ -2,33 +2,23 @@
Component({
data: {
selected: 0,
reviewMode: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
{ pagePath: '/pages/index/camera', text: '查看报告', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
]
},
lifetimes: {
attached() {
this.updateSelected()
this.checkReviewMode()
}
},
pageLifetimes: {
show() {
this.updateSelected()
this.checkReviewMode()
}
},
methods: {
checkReviewMode() {
try {
const app = getApp()
const rm = !!(app && app.globalData && app.globalData.reviewMode)
this.setData({ reviewMode: rm })
} catch (e) {}
},
updateSelected() {
try {
const pages = getCurrentPages()
@@ -66,13 +56,6 @@ Component({
} catch (e) {}
}
// 审核模式:中间按钮跳转到测试选择页而非相机
if (index === 1 && this.data.reviewMode) {
wx.navigateTo({ url: '/pages/test-select/index' })
this.setData({ selected: index })
return
}
wx.switchTab({ url })
this.setData({ selected: index })
}

View File

@@ -1,4 +1,4 @@
<!-- 自定义 tabBar顶部分割线 + 三栏中间浮起圆钮 -->
<!-- 自定义 tabBar顶部分割线 + 三栏中间单独一列避免浮钮与左右叠层 -->
<view class="tab-bar">
<view class="tab-bar-line"></view>
<view class="tab-bar-inner">
@@ -11,30 +11,22 @@
<image class="tab-icon" src="{{selected === 0 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">首页</text>
</view>
<!-- 审核模式:中间按钮显示"测试"文字 -->
<view
wx:if="{{reviewMode}}"
class="tab-item {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/test-select/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 1 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">测试</text>
</view>
<!-- 正常模式:中间浮起拍照按钮 -->
<view
wx:else
class="tab-item-center {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
bindtap="switchTab"
>
<view class="center-circle">
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
<!-- 中间列:与审核态无关,固定「拍摄」+ 始终进 camera -->
<view class="tab-slot-middle">
<view
class="middle-fab {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
bindtap="switchTab"
>
<view class="center-circle">
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
</view>
<text class="tab-text tab-text-fab">拍摄</text>
</view>
<view class="center-text-placeholder"></view>
</view>
<view
class="tab-item {{selected === 2 ? 'active' : ''}}"
data-index="2"

View File

@@ -1,104 +1,116 @@
/* 自定义 tabBar白底、顶部分割线、中间浮起圆钮 */
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 9999;
}
.tab-bar-line {
height: 1rpx;
background: #e5e7eb;
width: 100%;
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100rpx;
padding: 0 20rpx 8rpx;
position: relative;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 8rpx;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 20rpx;
color: #999999;
}
.tab-item.active .tab-text {
color: #7c3aed;
}
/* 中间项:浮起彩色圆钮 + 相机图标 */
.tab-item-center {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
margin-top: -56rpx;
padding-bottom: 8rpx;
}
.center-circle {
width: 96rpx;
height: 96rpx;
min-width: 96rpx;
min-height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4rpx;
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.4);
border: 4rpx solid #ffffff;
flex-shrink: 0;
overflow: hidden;
}
.tab-item-center.active .center-circle {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
}
.center-icon {
width: 44rpx;
height: 44rpx;
}
/* 占位:与「首页」「我的」文字等高,保证中间圆钮与两侧图标对齐 */
.center-text-placeholder {
height: 24rpx;
width: 1rpx;
visibility: hidden;
}
.tab-item-center .center-text {
color: #7c3aed;
font-size: 20rpx;
font-weight: 600;
}
.tab-item-center.active .center-text {
color: #6d28d9;
}
/* 自定义 tabBar白底、顶部分割线、中间浮起圆钮 */
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #ffffff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 9999;
}
.tab-bar-line {
height: 1rpx;
background: #e5e7eb;
width: 100%;
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-between;
min-height: 120rpx;
padding: 12rpx 12rpx 10rpx;
position: relative;
box-sizing: border-box;
}
.tab-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
z-index: 1;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
}
.tab-text {
font-size: 20rpx;
color: #999999;
}
.tab-item.active .tab-text {
color: #7c3aed;
}
/* 中间列:独立层叠上下文,浮钮不盖住左右 tab */
.tab-slot-middle {
flex: 1;
min-width: 0;
position: relative;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
}
/* 浮钮:略上移即可,避免压住首页主按钮 */
.middle-fab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
width: 100%;
transform: translateY(-10rpx);
margin-bottom: -6rpx;
}
.center-circle {
width: 84rpx;
height: 84rpx;
min-width: 84rpx;
min-height: 84rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.4);
border: 4rpx solid #ffffff;
flex-shrink: 0;
overflow: hidden;
}
.middle-fab.active .center-circle {
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
}
.center-icon {
width: 40rpx;
height: 40rpx;
}
.tab-text-fab {
font-size: 18rpx;
color: #999999;
line-height: 1.25;
text-align: center;
max-width: 168rpx;
}
.middle-fab.active .tab-text-fab {
color: #7c3aed;
font-weight: 600;
}

View File

@@ -1,283 +1,280 @@
/* pages/enterprise/index.wxss - 企业版首页样式(与个人版相同,仅背景不同) */
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx);
}
/* 背景装饰圆形 */
.bg-decoration {
position: absolute;
width: 600rpx;
height: 600rpx;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: -200rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
/* bottom: -200rpx;
left: -200rpx; */
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 10000;
width: 100%;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
position: relative;
background: #ffffff;
min-height: 88rpx;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
font-weight: 700;
color: #333;
text-align: center;
flex: 1;
}
.navbar-placeholder {
width: 140rpx;
flex-shrink: 0;
}
.switch-personal-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: rgba(99, 102, 241, 0.1);
border-radius: 30rpx;
border: 1rpx solid rgba(99, 102, 241, 0.2);
flex-shrink: 0;
z-index: 10;
}
.personal-icon {
font-size: 28rpx;
}
.personal-text {
font-size: 24rpx;
color: #6366f1;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
}
.image-container {
position: relative;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
display: block;
}
.float-tag {
position: absolute;
padding: 12rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: 10rpx;
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #e63946;
text-align: center;
margin-bottom: 25rpx;
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
background: #b8b9bc;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 12rpx;
}
.step-circle.active {
background: #e63946;
}
.step-label {
font-size: 20rpx;
color: #666;
margin-bottom: 4rpx;
}
.step-text {
font-size: 24rpx;
color: #333;
font-weight: 600;
}
.step-line {
width: 50rpx;
height: 4rpx;
background: #e0e0e0;
margin: 0 6rpx 50rpx;
}
.start-button {
/* 顶部与中间按钮的间距:上 20下 10 */
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 50rpx;
padding: 24rpx;
text-align: center;
box-shadow: 0 8rpx 30rpx rgba(99, 102, 241, 0.3);
position: relative;
z-index: 1;
}
.button-text {
font-size: 30rpx;
color: #fff;
font-weight: 700;
}
.upload-button {
margin: 10rpx 40rpx 0;
border: 2rpx solid #8b5cf6;
border-radius: 50rpx;
padding: 20rpx;
text-align: center;
background: #fff;
position: relative;
z-index: 1;
}
.upload-button-primary {
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.upload-button-primary .upload-text {
color: #fff;
}
.upload-text {
font-size: 26rpx;
color: #8b5cf6;
font-weight: 600;
}
/* 确保底部导航显示 */
custom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
}
/* pages/enterprise/index.wxss - 企业版首页样式(与个人版相同,仅背景不同) */
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx);
}
/* 背景装饰圆形 */
.bg-decoration {
position: absolute;
width: 600rpx;
height: 600rpx;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: -200rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
/* bottom: -200rpx;
left: -200rpx; */
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 10000;
width: 100%;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
position: relative;
background: #ffffff;
min-height: 88rpx;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
font-weight: 700;
color: #333;
text-align: center;
flex: 1;
}
.navbar-placeholder {
width: 140rpx;
flex-shrink: 0;
}
.switch-personal-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: rgba(99, 102, 241, 0.1);
border-radius: 30rpx;
border: 1rpx solid rgba(99, 102, 241, 0.2);
flex-shrink: 0;
z-index: 10;
}
.personal-icon {
font-size: 28rpx;
}
.personal-text {
font-size: 24rpx;
color: #6366f1;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
}
.image-container {
position: relative;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
display: block;
}
.float-tag {
position: absolute;
padding: 12rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: 10rpx;
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #e63946;
text-align: center;
margin-bottom: 25rpx;
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
background: #b8b9bc;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 12rpx;
}
.step-circle.active {
background: #e63946;
}
.step-label {
font-size: 20rpx;
color: #666;
margin-bottom: 4rpx;
}
.step-text {
font-size: 24rpx;
color: #333;
font-weight: 600;
}
.step-line {
width: 50rpx;
height: 4rpx;
background: #e0e0e0;
margin: 0 6rpx 50rpx;
}
.start-button {
/* 顶部与中间按钮的间距:上 20下 10 */
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 50rpx;
padding: 24rpx;
text-align: center;
box-shadow: 0 8rpx 30rpx rgba(99, 102, 241, 0.3);
position: relative;
z-index: 1;
}
.button-text {
font-size: 30rpx;
color: #fff;
font-weight: 700;
}
.upload-button {
margin: 10rpx 40rpx 0;
border: 2rpx solid #8b5cf6;
border-radius: 50rpx;
padding: 20rpx;
text-align: center;
background: #fff;
position: relative;
z-index: 1;
}
.upload-button-primary {
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.upload-button-primary .upload-text {
color: #fff;
}
.upload-text {
font-size: 26rpx;
color: #8b5cf6;
font-weight: 600;
}
custom-tab-bar {
display: block;
height: 0;
overflow: visible;
}

View File

@@ -15,30 +15,74 @@ Page({
},
onLoad() {
this.cameraContext = wx.createCameraContext()
this.setData({ reviewMode: !!app.globalData.reviewMode })
const tc = app.globalData.textConfig
if (tc && tc.aiAnalysisText) {
this.setData({ aiAnalysisText: tc.aiAnalysisText })
} else {
app.getRuntimeConfig().then((cfg) => {
if (cfg && cfg.textConfig) {
app.globalData.textConfig = cfg.textConfig
this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析' })
}
app.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (typeof cfg.reviewMode === 'boolean') {
app.globalData.reviewMode = cfg.reviewMode
}
}).catch(() => {})
if (cfg.textConfig) {
app.globalData.textConfig = cfg.textConfig
this.setData({
aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析',
reviewMode: !!app.globalData.reviewMode
})
return
}
}
this.setData({ reviewMode: !!app.globalData.reviewMode })
}).catch(() => {
this.setData({ reviewMode: !!app.globalData.reviewMode })
})
},
/** 非审核模式且相机在页上时再创建上下文 */
onReady() {
if (!app.globalData.reviewMode) {
this.initCameraContext()
}
},
goToQuestionnaire() {
wx.navigateTo({ url: '/pages/test-select/index' })
},
initCameraContext() {
try {
if (typeof wx.createCameraContext === 'function') {
this.cameraContext = wx.createCameraContext()
}
} catch (e) {
console.error('initCameraContext', e)
this.cameraContext = null
}
},
onShow() {
// 审核模式下重定向到测试选择页
if (app.globalData.reviewMode) {
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
if (!ensureProfileCompleteAndRedirect()) return
const rm = !!app.globalData.reviewMode
this.setData({ reviewMode: rm })
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
}
// 审核模式:只展示本页引导,不再强跳 navigateTo失败时曾导致白屏且无拍摄区
if (rm) {
return
}
if (!ensureProfileCompleteAndRedirect()) {
return
}
if (this.data.photos.length < 3) {
this.initCameraContext()
}
this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig
if (tc && tc.aiAnalysisText) {
@@ -77,6 +121,14 @@ Page({
return
}
if (!this.cameraContext) {
this.initCameraContext()
}
if (!this.cameraContext || typeof this.cameraContext.takePhoto !== 'function') {
wx.showToast({ title: '相机未就绪,请稍候再试', icon: 'none' })
return
}
this.cameraContext.takePhoto({
quality: 'high',
success: (res) => {
@@ -95,7 +147,17 @@ Page({
}
},
fail: (err) => {
wx.showToast({ title: '拍照失败', icon: 'none' })
const msg = (err && (err.errMsg || err.message)) ? String(err.errMsg || err.message) : ''
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相机权限',
content: '请在设置中允许使用摄像头',
confirmText: '去设置',
success: (r) => { if (r.confirm) wx.openSetting() }
})
} else {
wx.showToast({ title: '拍照失败,请重试', icon: 'none' })
}
console.error('拍照失败:', err)
}
})
@@ -116,6 +178,7 @@ Page({
guideText: '请正对镜头'
})
wx.showToast({ title: '已清空,请重新拍摄', icon: 'success' })
setTimeout(() => this.initCameraContext(), 200)
}
}
})
@@ -123,7 +186,6 @@ Page({
// 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页
completeCapture() {
if (!ensureProfileCompleteAndRedirect()) return
if (!hasPhone()) {
wx.showToast({ title: '请先授权手机号', icon: 'none' })
this.setData({ needPhoneAuth: true })

View File

@@ -1,6 +1,8 @@
{
"navigationBarTitleText": "拍",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}
{
"navigationBarTitleText": "拍",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
}
}

View File

@@ -1,69 +1,80 @@
<!--pages/index/camera.wxml - 拍照页面-->
<view class="container" wx:if="{{!reviewMode}}">
<view class="progress-section">
<view class="progress-info">
<text class="step-text">步骤 {{photoIndex + 1}}/3</text>
<text class="photo-count">{{photos.length}}/3 张照片已完成</text>
</view>
<view class="progress-bars">
<view class="progress-bar {{photos.length >= 1 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 2 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 3 ? 'completed' : 'pending'}}"></view>
</view>
<view class="instruction-card">
<view class="instruction-content">
<view class="step-number">{{photoIndex + 1}}</view>
<view class="instruction-text">
<text class="angle-text">{{guideText}}</text>
<text class="tip-text">请保持自然表情,确保光线充足</text>
</view>
</view>
</view>
</view>
<view class="camera-container">
<view wx:if="{{photos.length < 3}}" class="camera-preview">
<camera
class="camera"
device-position="front"
flash="off"
binderror="onCameraError"
></camera>
</view>
<view wx:else class="photos-preview">
<view class="photo-item" wx:for="{{photos}}" wx:key="index">
<image class="photo-image" src="{{item}}" mode="aspectFill"></image>
<view class="photo-label">{{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}</view>
</view>
</view>
</view>
<view class="button-container">
<view wx:if="{{photos.length < 3}}" class="capture-actions">
<view class="capture-button" bindtap="takePhoto">
<text class="button-text">拍摄{{guideText}}照片</text>
</view>
<view class="album-button" bindtap="goToUpload">
<text class="album-button-text">从相册选择</text>
</view>
</view>
<view wx:else class="action-buttons">
<view class="action-button secondary" bindtap="retakeAll">
<text class="action-button-text">重新拍摄</text>
</view>
<view class="action-button primary" bindtap="completeCapture">
<text class="action-button-text">立即{{aiAnalysisText || '分析'}}</text>
</view>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示,就地弹出微信系统手机号授权 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后开始查看报告
</button>
</view>
</view>
<!--pages/index/camera.wxml - 拍照页面(与首页等统一使用自定义 tabBar避免系统 tab 与浮钮叠层) -->
<view class="camera-page-root">
<!-- 审核模式:展示引导,避免整块 wx:if 隐藏导致白屏、拍摄按钮不存在 -->
<view class="container review-mode-panel" wx:if="{{reviewMode}}">
<view class="review-mode-card">
<text class="review-mode-title">问卷审核模式</text>
<text class="review-mode-desc">当前未开放实时拍摄面相,请先做 MBTI / DISC / PDP 等问卷测试;后台关闭「审核模式」后即可使用拍摄报告。</text>
<view class="review-mode-btn" bindtap="goToQuestionnaire">去做性格测试</view>
</view>
</view>
<view class="container" wx:else>
<view class="progress-section">
<view class="progress-info">
<text class="step-text">步骤 {{photoIndex + 1}}/3</text>
<text class="photo-count">{{photos.length}}/3 张照片已完成</text>
</view>
<view class="progress-bars">
<view class="progress-bar {{photos.length >= 1 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 2 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 3 ? 'completed' : 'pending'}}"></view>
</view>
<view class="instruction-card">
<view class="instruction-content">
<view class="step-number">{{photoIndex + 1}}</view>
<view class="instruction-text">
<text class="angle-text">{{guideText}}</text>
<text class="tip-text">请保持自然表情,确保光线充足</text>
</view>
</view>
</view>
</view>
<view class="camera-container">
<view wx:if="{{photos.length < 3}}" class="camera-preview">
<camera
class="camera"
device-position="front"
flash="off"
binderror="onCameraError"
></camera>
</view>
<view wx:else class="photos-preview">
<view class="photo-item" wx:for="{{photos}}" wx:key="index">
<image class="photo-image" src="{{item}}" mode="aspectFill"></image>
<view class="photo-label">{{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}</view>
</view>
</view>
</view>
<view class="button-container">
<view wx:if="{{photos.length < 3}}" class="capture-actions">
<view class="capture-button" bindtap="takePhoto">
<text class="button-text">拍摄{{guideText}}照片</text>
</view>
<view class="album-button" bindtap="goToUpload">
<text class="album-button-text">从相册选择</text>
</view>
</view>
<view wx:else class="action-buttons">
<view class="action-button secondary" bindtap="retakeAll">
<text class="action-button-text">重新拍摄</text>
</view>
<view class="action-button primary" bindtap="completeCapture">
<text class="action-button-text">立即{{aiAnalysisText || '分析'}}</text>
</view>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示,就地弹出微信系统手机号授权 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后继续拍摄
</button>
</view>
</view>
<custom-tab-bar />
</view>

View File

@@ -1,312 +1,367 @@
/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar含中间浮起圆钮预留空间 */
.container {
width: 100%;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
/* 预留底部空间,避免与自定义 tabBar约 100rpx 高 + 中间圆钮上浮约 56rpx重叠 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.progress-section {
flex-shrink: 0;
margin: 16rpx 24rpx 0;
padding: 20rpx 32rpx 16rpx;
border-radius: 10rpx;
background: linear-gradient(to right, #fff5f5, #ffe5e8);
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.step-text {
font-size: 26rpx;
font-weight: 600;
color: #e63946;
}
.photo-count {
font-size: 22rpx;
color: #999;
}
.progress-bars {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.progress-bar {
flex: 1;
height: 6rpx;
border-radius: 6rpx;
}
.progress-bar.completed {
background-color: #52c41a;
}
.progress-bar.pending {
background-color: #e5e5e5;
}
.instruction-card {
background: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(230, 57, 70, 0.1);
}
.instruction-content {
display: flex;
align-items: center;
gap: 12rpx;
}
.step-number {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
border-radius: 50%;
background-color: #e63946;
color: #fff;
font-size: 24rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.instruction-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rpx;
}
.angle-text {
font-size: 26rpx;
font-weight: 600;
color: #c41d2a;
}
.tip-text {
font-size: 22rpx;
color: #666;
line-height: 1.3;
}
.camera-container {
flex: 1;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.camera-preview {
width: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
border-radius: 32rpx;
overflow: hidden;
border: 6rpx solid #e5e5e5;
background-color: #000;
position: relative;
}
.camera {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photos-preview {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.photo-item {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
max-height: 100%;
border-radius: 16rpx;
overflow: hidden;
border: 4rpx solid #e5e5e5;
}
.photo-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-label {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(230, 57, 70, 0.9);
color: #fff;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.button-container {
flex-shrink: 0;
padding: 16rpx 24rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.capture-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.capture-actions .capture-button {
width: 100%;
}
.album-button {
width: 100%;
padding: 20rpx;
border-radius: 50rpx;
text-align: center;
background: #fff;
border: 2rpx solid #e63946;
box-sizing: border-box;
}
.album-button:active {
background: #fff5f5;
}
.album-button-text {
font-size: 28rpx;
font-weight: 600;
color: #e63946;
letter-spacing: 1rpx;
}
.phone-auth-section {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.phone-auth-tip {
font-size: 26rpx;
color: #4b5563;
}
.phone-auth-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #ffffff;
font-size: 28rpx;
}
.phone-auth-btn::after {
border: none;
}
.capture-button {
width: 100%;
max-width: 100%;
padding: 22rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
text-align: center;
box-sizing: border-box;
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
}
.button-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
width: 100%;
}
.action-button {
flex: 1;
padding: 28rpx 24rpx;
border-radius: 50rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.action-button.secondary {
background: #fff;
border: 2rpx solid #e63946;
}
.action-button.secondary:active {
background: #fff5f5;
transform: scale(0.98);
}
.action-button.primary {
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
}
.action-button.primary:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
}
.action-button-text {
font-size: 28rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.action-button.secondary .action-button-text {
color: #e63946;
}
.action-button.primary .action-button-text {
color: #fff;
}
/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar含中间浮起圆钮预留空间 */
.camera-page-root {
min-height: 100vh;
min-height: 100dvh;
position: relative;
}
.container {
width: 100%;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
/* 预留底部空间,避免与自定义 tabBar约 100rpx 高 + 中间圆钮上浮约 56rpx重叠 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.progress-section {
flex-shrink: 0;
margin: 16rpx 24rpx 0;
padding: 20rpx 32rpx 16rpx;
border-radius: 10rpx;
background: linear-gradient(to right, #fff5f5, #ffe5e8);
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.step-text {
font-size: 26rpx;
font-weight: 600;
color: #e63946;
}
.photo-count {
font-size: 22rpx;
color: #999;
}
.progress-bars {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.progress-bar {
flex: 1;
height: 6rpx;
border-radius: 6rpx;
}
.progress-bar.completed {
background-color: #52c41a;
}
.progress-bar.pending {
background-color: #e5e5e5;
}
.instruction-card {
background: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(230, 57, 70, 0.1);
}
.instruction-content {
display: flex;
align-items: center;
gap: 12rpx;
}
.step-number {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
border-radius: 50%;
background-color: #e63946;
color: #fff;
font-size: 24rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.instruction-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rpx;
}
.angle-text {
font-size: 26rpx;
font-weight: 600;
color: #c41d2a;
}
.tip-text {
font-size: 22rpx;
color: #666;
line-height: 1.3;
}
.camera-container {
flex: 1;
min-height: 0;
padding: 16rpx 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.camera-preview {
width: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
border-radius: 32rpx;
overflow: hidden;
border: 6rpx solid #e5e5e5;
background-color: #000;
position: relative;
}
.camera {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photos-preview {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.photo-item {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
max-height: 100%;
border-radius: 16rpx;
overflow: hidden;
border: 4rpx solid #e5e5e5;
}
.photo-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-label {
position: absolute;
top: 8rpx;
left: 8rpx;
background: rgba(230, 57, 70, 0.9);
color: #fff;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.button-container {
flex-shrink: 0;
padding: 16rpx 24rpx 0;
display: flex;
justify-content: center;
align-items: center;
}
.capture-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.capture-actions .capture-button {
width: 100%;
}
.album-button {
width: 100%;
padding: 20rpx;
border-radius: 50rpx;
text-align: center;
background: #fff;
border: 2rpx solid #e63946;
box-sizing: border-box;
}
.album-button:active {
background: #fff5f5;
}
.album-button-text {
font-size: 28rpx;
font-weight: 600;
color: #e63946;
letter-spacing: 1rpx;
}
.phone-auth-section {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.phone-auth-tip {
font-size: 26rpx;
color: #4b5563;
}
.phone-auth-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #ffffff;
font-size: 28rpx;
}
.phone-auth-btn::after {
border: none;
}
.capture-button {
width: 100%;
max-width: 100%;
padding: 22rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
text-align: center;
box-sizing: border-box;
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
}
.button-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
width: 100%;
}
.action-button {
flex: 1;
padding: 28rpx 24rpx;
border-radius: 50rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.action-button.secondary {
background: #fff;
border: 2rpx solid #e63946;
}
.action-button.secondary:active {
background: #fff5f5;
transform: scale(0.98);
}
.action-button.primary {
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
}
.action-button.primary:active {
transform: scale(0.98);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
}
.action-button-text {
font-size: 28rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.action-button.secondary .action-button-text {
color: #e63946;
}
.action-button.primary .action-button-text {
color: #fff;
}
/* 审核模式引导(替代白屏) */
.review-mode-panel {
justify-content: center;
align-items: center;
padding: 48rpx 40rpx;
background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
}
.review-mode-card {
width: 100%;
max-width: 620rpx;
padding: 48rpx 40rpx;
background: #fff;
border-radius: 24rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.06);
border: 1rpx solid rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.review-mode-title {
font-size: 34rpx;
font-weight: 700;
color: #1e293b;
}
.review-mode-desc {
font-size: 28rpx;
color: #64748b;
line-height: 1.65;
text-align: center;
}
.review-mode-btn {
margin-top: 16rpx;
padding: 24rpx 56rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
color: #fff;
font-size: 30rpx;
font-weight: 600;
}
.review-mode-btn:active {
opacity: 0.9;
}

View File

@@ -29,7 +29,7 @@ Page({
navbarHeight: navbarHeightRpx,
showEnterpriseEntry: userInfo.hasEnterprise === true,
siteTitle: gd.reviewMode ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: gd.reviewMode ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: !!gd.reviewMode
})
@@ -45,7 +45,7 @@ Page({
if (cfg.textConfig) {
getApp().globalData.textConfig = cfg.textConfig
this.setData({
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '开始面相测试'),
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '拍摄'),
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
})
}
@@ -100,7 +100,7 @@ Page({
const rm = !!gd.reviewMode
this.setData({
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm
})
@@ -124,14 +124,10 @@ Page({
}
},
// 开始测试(审核模式跳问卷,正常模式跳拍照
// 与底栏中间一致:始终进入拍摄 Tab审核态在 camera 页内展示问卷引导
startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {}
if (this.data.reviewMode) {
wx.navigateTo({ url: '/pages/test-select/index' })
} else {
wx.switchTab({ url: '/pages/index/camera' })
}
wx.switchTab({ url: '/pages/index/camera' })
},
// 上传照片(个人版入口:强制本次链路为个人定价)

View File

@@ -1,305 +1,304 @@
/* pages/index/index.wxss - 个人版首页,样式与企业版一致 */
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx);
}
/* 背景装饰圆形(与企业版一致) */
.bg-decoration {
position: absolute;
width: 600rpx;
height: 600rpx;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: -200rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 10000;
width: 100%;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
position: relative;
background: #ffffff;
min-height: 88rpx;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
font-weight: 700;
color: #e63946;
text-align: center;
flex: 1;
}
.navbar-placeholder {
width: 140rpx;
flex-shrink: 0;
}
/* 与企业版切换按钮同款样式(红主色) */
.switch-enterprise-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: rgba(230, 57, 70, 0.1);
border-radius: 30rpx;
border: 1rpx solid rgba(230, 57, 70, 0.2);
flex-shrink: 0;
z-index: 10;
}
.enterprise-icon {
font-size: 28rpx;
}
.enterprise-text {
font-size: 24rpx;
color: #e63946;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
}
.image-container {
position: relative;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
display: block;
}
.float-tag {
position: absolute;
padding: 12rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: 10rpx;
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #e63946;
text-align: center;
margin-bottom: 25rpx;
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
background: #b8b9bc;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 12rpx;
}
.step-circle.active {
background: #e63946;
}
.step-label {
font-size: 20rpx;
color: #666;
margin-bottom: 4rpx;
}
.step-text {
font-size: 24rpx;
color: #333;
font-weight: 600;
}
.step-line {
width: 50rpx;
height: 4rpx;
background: #e0e0e0;
margin: 0 6rpx 50rpx;
}
.start-button {
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
padding: 24rpx;
text-align: center;
box-shadow: 0 8rpx 30rpx rgba(230, 57, 70, 0.3);
position: relative;
z-index: 1;
}
.button-text {
font-size: 30rpx;
color: #fff;
font-weight: 700;
}
.upload-button {
margin: 0 40rpx 16rpx;
border: 2rpx solid #8b5cf6;
border-radius: 50rpx;
padding: 18rpx;
text-align: center;
background: #fff;
flex-shrink: 0;
}
.upload-text {
font-size: 26rpx;
color: #8b5cf6;
font-weight: 600;
}
/* AI 生成内容标识横幅(监管合规,始终显示) */
.ai-disclosure-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin: 24rpx 40rpx 16rpx;
padding: 16rpx 24rpx;
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 100%);
border: 1rpx solid #c7d2fe;
border-radius: 12rpx;
position: relative;
z-index: 1;
}
.ai-disclosure-icon {
width: 44rpx;
height: 44rpx;
border-radius: 8rpx;
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
color: #fff;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-disclosure-text {
font-size: 22rpx;
color: #4338ca;
line-height: 1.4;
}
/* 确保底部导航显示 */
custom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
}
/* pages/index/index.wxss - 个人版首页,样式与企业版一致 */
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* 底栏 + 中间浮钮留白,避免与主按钮叠在一起 */
padding-bottom: calc(168rpx + env(safe-area-inset-bottom) + 48rpx);
}
/* 背景装饰圆形(与企业版一致) */
.bg-decoration {
position: absolute;
width: 600rpx;
height: 600rpx;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: -200rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 10000;
width: 100%;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
position: relative;
background: #ffffff;
min-height: 88rpx;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
font-weight: 700;
color: #e63946;
text-align: center;
flex: 1;
}
.navbar-placeholder {
width: 140rpx;
flex-shrink: 0;
}
/* 与企业版切换按钮同款样式(红主色) */
.switch-enterprise-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: rgba(230, 57, 70, 0.1);
border-radius: 30rpx;
border: 1rpx solid rgba(230, 57, 70, 0.2);
flex-shrink: 0;
z-index: 10;
}
.enterprise-icon {
font-size: 28rpx;
}
.enterprise-text {
font-size: 24rpx;
color: #e63946;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
}
.image-container {
position: relative;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
display: block;
}
.float-tag {
position: absolute;
padding: 12rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: 10rpx;
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #e63946;
text-align: center;
margin-bottom: 25rpx;
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
background: #b8b9bc;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 12rpx;
}
.step-circle.active {
background: #e63946;
}
.step-label {
font-size: 20rpx;
color: #666;
margin-bottom: 4rpx;
}
.step-text {
font-size: 24rpx;
color: #333;
font-weight: 600;
}
.step-line {
width: 50rpx;
height: 4rpx;
background: #e0e0e0;
margin: 0 6rpx 50rpx;
}
.start-button {
margin: 20rpx 40rpx 0;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
padding: 24rpx;
text-align: center;
box-shadow: 0 8rpx 30rpx rgba(230, 57, 70, 0.3);
position: relative;
z-index: 1;
}
.button-text {
font-size: 30rpx;
color: #fff;
font-weight: 700;
}
.upload-button {
margin: 0 40rpx 16rpx;
border: 2rpx solid #8b5cf6;
border-radius: 50rpx;
padding: 18rpx;
text-align: center;
background: #fff;
flex-shrink: 0;
}
.upload-text {
font-size: 26rpx;
color: #8b5cf6;
font-weight: 600;
}
/* AI 生成内容标识横幅(监管合规,始终显示) */
.ai-disclosure-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin: 24rpx 40rpx 16rpx;
padding: 16rpx 24rpx;
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 100%);
border: 1rpx solid #c7d2fe;
border-radius: 12rpx;
position: relative;
z-index: 1;
}
.ai-disclosure-icon {
width: 44rpx;
height: 44rpx;
border-radius: 8rpx;
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
color: #fff;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-disclosure-text {
font-size: 22rpx;
color: #4338ca;
line-height: 1.4;
}
/* 自定义 tab 由内层 .tab-bar fixed避免与组件双重 fixed 导致错位 */
custom-tab-bar {
display: block;
height: 0;
overflow: visible;
}

View File

@@ -39,7 +39,9 @@ Page({
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
if (!ensureProfileCompleteAndRedirect()) return
if (!ensureProfileCompleteAndRedirect()) {
return
}
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
}
@@ -170,7 +172,11 @@ Page({
// 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页
completeCapture() {
if (!ensureProfileCompleteAndRedirect()) return
if (!hasPhone()) {
wx.showToast({ title: '请先授权手机号', icon: 'none' })
this.setData({ needPhoneAuth: true })
return
}
const urls = (this.data.uploadedUrls || []).filter(Boolean)
if (!urls.length) {
wx.showToast({ title: '请先上传至少一张照片', icon: 'none' })

View File

@@ -1,113 +1,113 @@
<!--pages/index/upload.wxml - 上传照片引导页-->
<view class="page">
<!-- 主体内容 -->
<view class="content">
<!-- 引导文案 -->
<view class="intro">
<text class="intro-title">多角度拍摄更精准</text>
<view class="intro-desc-wrap">
<text class="intro-desc">为了更准确地分析您的性格特征,</text>
<text class="intro-desc">请上传以下三个维度的照片。</text>
</view>
</view>
<!-- 三个角度上传卡片 -->
<view class="steps">
<!-- 正面 -->
<view class="step-item">
<view class="step-index step-index-primary">
<text class="step-index-text step-index-text-primary">1</text>
</view>
<view class="step-body">
<text class="step-title">正面</text>
<text class="step-subtitle">目视前方,五官端正,不遮挡额头</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[0]}}"></image>
<view class="upload-card" bindtap="onUploadFront">
<image wx:if="{{photos[0]}}" class="upload-preview" mode="aspectFill" src="{{photos[0]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 左侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">2</text>
</view>
<view class="step-body">
<text class="step-title">左侧 45度</text>
<text class="step-subtitle">展示左侧面颊,用于分析面部轮廓</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[1]}}"></image>
<view class="upload-card" bindtap="onUploadLeft">
<image wx:if="{{photos[1]}}" class="upload-preview" mode="aspectFill" src="{{photos[1]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 右侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">3</text>
</view>
<view class="step-body">
<text class="step-title">右侧 45度</text>
<text class="step-subtitle">展示右侧面颊,完整捕捉面部信息</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[2]}}"></image>
<view class="upload-card" bindtap="onUploadRight">
<image wx:if="{{photos[2]}}" class="upload-preview" mode="aspectFill" src="{{photos[2]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-card">
<view class="tips-title-row">
<view class="tips-icon"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<text class="tips-item">· 光线充足,背景整洁,避免过曝或过暗</text>
<text class="tips-item">· 保持表情自然,无需刻意微笑</text>
<text class="tips-item">· 如有佩戴眼镜,请确保镜片不反光</text>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
授权手机号后开始查看报告
</button>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer">
<button class="primary-btn" bindtap="completeCapture">开始分析</button>
</view>
<!-- 自定义底部导航,与首页保持一致 -->
<custom-tab-bar />
</view>
<!--pages/index/upload.wxml - 上传照片引导页-->
<view class="page">
<!-- 主体内容 -->
<view class="content">
<!-- 引导文案 -->
<view class="intro">
<text class="intro-title">多角度拍摄更精准</text>
<view class="intro-desc-wrap">
<text class="intro-desc">为了更准确地分析您的性格特征,</text>
<text class="intro-desc">请上传以下三个维度的照片。</text>
</view>
</view>
<!-- 三个角度上传卡片 -->
<view class="steps">
<!-- 正面 -->
<view class="step-item">
<view class="step-index step-index-primary">
<text class="step-index-text step-index-text-primary">1</text>
</view>
<view class="step-body">
<text class="step-title">正面</text>
<text class="step-subtitle">目视前方,五官端正,不遮挡额头</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[0]}}"></image>
<view class="upload-card" bindtap="onUploadFront">
<image wx:if="{{photos[0]}}" class="upload-preview" mode="aspectFill" src="{{photos[0]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 左侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">2</text>
</view>
<view class="step-body">
<text class="step-title">左侧 45度</text>
<text class="step-subtitle">展示左侧面颊,用于分析面部轮廓</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[1]}}"></image>
<view class="upload-card" bindtap="onUploadLeft">
<image wx:if="{{photos[1]}}" class="upload-preview" mode="aspectFill" src="{{photos[1]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 右侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">3</text>
</view>
<view class="step-body">
<text class="step-title">右侧 45度</text>
<text class="step-subtitle">展示右侧面颊,完整捕捉面部信息</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[2]}}"></image>
<view class="upload-card" bindtap="onUploadRight">
<image wx:if="{{photos[2]}}" class="upload-preview" mode="aspectFill" src="{{photos[2]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-card">
<view class="tips-title-row">
<view class="tips-icon"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<text class="tips-item">· 光线充足,背景整洁,避免过曝或过暗</text>
<text class="tips-item">· 保持表情自然,无需刻意微笑</text>
<text class="tips-item">· 如有佩戴眼镜,请确保镜片不反光</text>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
授权手机号后继续上传
</button>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer">
<button class="primary-btn" bindtap="completeCapture">开始分析</button>
</view>
<!-- 自定义底部导航,与首页保持一致 -->
<custom-tab-bar />
</view>

View File

@@ -1,5 +1,5 @@
<view class="container">
<view class="tip">为保障服务与联系需要,请授权您的手机号。</view>
<view class="tip sub">授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。</view>
<button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">授权手机号</button>
</view>
<view class="container">
<view class="tip">为保障服务与联系需要,请授权您的手机号。</view>
<view class="tip sub">授权后可在「拍摄」「支付」「企业咨询」等场景使用,仅需授权一次。</view>
<button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">授权手机号</button>
</view>

View File

@@ -258,10 +258,17 @@ Page({
},
goToIndex() { wx.switchTab({ url: '/pages/index/index' }) },
goToCamera() { wx.switchTab({ url: '/pages/index/camera' }) },
goToHistory() { wx.navigateTo({ url: '/pages/history/index' }) },
goToHistory() {
try { require('../../utils/analytics').track('tap_test_history', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/history/index' })
},
goToUserProfile() { wx.navigateTo({ url: '/pages/user-profile/index' }) },
goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
/** 深度服务统一入口(页内 Tab个人 / 团队与企业) */
goToDeepService() {
try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/purchase/index' })
},
goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { wx.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToEnterprise() { wx.navigateTo({ url: '/pages/enterprise/index' }) },

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