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:
11
.cursor/rules/mbti-milestone-feishu.mdc
Normal file
11
.cursor/rules/mbti-milestone-feishu.mdc
Normal 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. 仅在「完整功能可验收」时推送,禁止每次小改动都推。
|
||||
24
README.md
24
README.md
@@ -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`。
|
||||
|
||||
@@ -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管理后台-测试环境
|
||||
@@ -1,357 +1,338 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="layout-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
class="menu-toggle"
|
||||
:icon="sidebarOpen ? Close : Menu"
|
||||
circle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<el-icon><Lock /></el-icon>
|
||||
</div>
|
||||
<span class="logo-text">管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-button
|
||||
text
|
||||
class="nav-link super-admin"
|
||||
@click="router.push('/superadmin')"
|
||||
>
|
||||
超管端
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
class="nav-link"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>前台</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
class="nav-link logout"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
|
||||
<nav class="sidebar-nav">
|
||||
<el-button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Menu,
|
||||
Close,
|
||||
Lock,
|
||||
HomeFilled,
|
||||
SwitchButton,
|
||||
DataLine,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Share,
|
||||
Document,
|
||||
Setting,
|
||||
PriceTag,
|
||||
WalletFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin/dashboard', icon: DataLine, label: '数据概览' },
|
||||
{ path: '/admin/users', icon: User, label: '用户管理' },
|
||||
{ path: '/admin/orders', icon: ShoppingCart, label: '订单管理' },
|
||||
{ path: '/admin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/admin/questions', icon: Document, label: '题库管理' },
|
||||
{ path: '/admin/pricing', icon: PriceTag, label: '价格设置' },
|
||||
{ path: '/admin/finance', icon: WalletFilled, label: '企业余额' },
|
||||
{ path: '/admin/settings', icon: Setting, label: '系统设置' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
// 移动端导航后关闭侧边栏
|
||||
if (window.innerWidth < 1024) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.adminLogout()
|
||||
router.push('/admin/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
// 即使API调用失败,也清除本地状态并跳转
|
||||
router.push('/admin/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
z-index: 1000;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.menu-toggle {
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
color: #111827;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.nav-link {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.super-admin {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
&.logout {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
width: 210px;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #f3f4f6;
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
margin-left: 0!important;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #f5f3ff;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: #7c3aed;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
margin-left: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
z-index: 998;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding-top: 56px;
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding-left: 210px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="layout-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
class="menu-toggle"
|
||||
:icon="sidebarOpen ? Close : Menu"
|
||||
circle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<el-icon><Lock /></el-icon>
|
||||
</div>
|
||||
<span class="logo-text">管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-button
|
||||
text
|
||||
class="nav-link logout"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
|
||||
<nav class="sidebar-nav">
|
||||
<el-button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Menu,
|
||||
Close,
|
||||
Lock,
|
||||
SwitchButton,
|
||||
DataLine,
|
||||
User,
|
||||
ShoppingCart,
|
||||
Share,
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin/dashboard', icon: DataLine, label: '概览' },
|
||||
{ path: '/admin/users', icon: User, label: '用户运营' },
|
||||
{ path: '/admin/orders', icon: ShoppingCart, label: '订单运营' },
|
||||
{ path: '/admin/distribution', icon: Share, label: '分销推广' },
|
||||
{ path: '/admin/settings', icon: Setting, label: '系统设置' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/admin/settings') {
|
||||
return route.path === '/admin/settings'
|
||||
}
|
||||
if (path === '/admin/users') {
|
||||
return route.path === '/admin/users'
|
||||
}
|
||||
if (path === '/admin/orders') {
|
||||
return route.path === '/admin/orders'
|
||||
}
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
// 移动端导航后关闭侧边栏
|
||||
if (window.innerWidth < 1024) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.adminLogout()
|
||||
router.push('/admin/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
// 即使API调用失败,也清除本地状态并跳转
|
||||
router.push('/admin/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
z-index: 1000;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.menu-toggle {
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
color: #111827;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.nav-link {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.logout {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
width: 210px;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #f3f4f6;
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
padding: 0 20px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
margin-left: 0!important;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #f5f3ff;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: #7c3aed;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
margin-left: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
z-index: 998;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding-top: 56px;
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding-left: 210px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,330 +1,387 @@
|
||||
<template>
|
||||
<div class="superadmin-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="layout-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
class="menu-toggle"
|
||||
:icon="sidebarOpen ? Close : Menu"
|
||||
circle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<el-icon><Lock /></el-icon>
|
||||
</div>
|
||||
<span class="logo-text">超级管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-button
|
||||
text
|
||||
class="nav-link"
|
||||
@click="router.push('/admin/dashboard')"
|
||||
>
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>管理台</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
class="nav-link logout"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
|
||||
<nav class="sidebar-nav">
|
||||
<el-button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Menu,
|
||||
Close,
|
||||
Lock,
|
||||
HomeFilled,
|
||||
SwitchButton,
|
||||
TrendCharts,
|
||||
OfficeBuilding,
|
||||
User,
|
||||
Document,
|
||||
Cpu,
|
||||
Money,
|
||||
DataLine,
|
||||
Setting,
|
||||
Share
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navItems = [
|
||||
{ path: '/superadmin/overview', icon: TrendCharts, label: '概览' },
|
||||
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
|
||||
{ path: '/superadmin/users', icon: User, label: '用户总览' },
|
||||
{ path: '/superadmin/questions', icon: Document, label: '题库管理' },
|
||||
{ path: '/superadmin/ai-config', icon: Cpu, label: 'AI 服务配置' },
|
||||
{ path: '/superadmin/pricing', icon: Money, label: '全局定价' },
|
||||
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/superadmin/finance', icon: Money, label: '财务数据' },
|
||||
{ path: '/superadmin/database', icon: DataLine, label: '数据库管理' },
|
||||
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
// 移动端导航后关闭侧边栏
|
||||
if (window.innerWidth < 1024) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.superAdminLogout()
|
||||
router.push('/superadmin/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
// 即使API调用失败,也清除本地状态并跳转
|
||||
router.push('/superadmin/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.superadmin-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
z-index: 1000;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.menu-toggle {
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #ef4444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: #111827;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.nav-link {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.logout {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 64px;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #f3f4f6;
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
margin-left: 0!important;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fef2f2;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
z-index: 998;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding-top: 64px;
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding-left: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="superadmin-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="layout-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
class="menu-toggle"
|
||||
:icon="sidebarOpen ? Close : Menu"
|
||||
circle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<el-icon><Lock /></el-icon>
|
||||
</div>
|
||||
<span class="logo-text">超级管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-button text class="nav-link admin-entry" @click="goAdminConsole">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>管理后台</span>
|
||||
</el-button>
|
||||
<el-button text class="nav-link logout" @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-main">
|
||||
<el-button
|
||||
v-for="item in navMainItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer-block">
|
||||
<el-button
|
||||
v-for="item in navFooterItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: isActive(item.path) }]"
|
||||
text
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<el-icon class="nav-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 遮罩层(移动端) -->
|
||||
<div v-if="sidebarOpen" class="sidebar-overlay" @click="toggleSidebar" />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<div class="mission-strip">
|
||||
<div class="mission-inner">
|
||||
<span class="mission-text">
|
||||
<strong>核心职责</strong>:配置与监督各企业使用的
|
||||
<strong>普通管理后台</strong>
|
||||
(用户运营、订单、分销等由企业管理员在日常后台完成)。
|
||||
</span>
|
||||
<el-button type="danger" size="small" plain @click="goAdminConsole">
|
||||
进入管理后台
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Menu,
|
||||
Close,
|
||||
Lock,
|
||||
SwitchButton,
|
||||
TrendCharts,
|
||||
ShoppingCart,
|
||||
Cpu,
|
||||
Setting,
|
||||
OfficeBuilding,
|
||||
Share,
|
||||
Monitor
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const navMainItems: { path: string; icon: typeof TrendCharts; label: string }[] = [
|
||||
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
|
||||
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
|
||||
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
|
||||
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }
|
||||
]
|
||||
|
||||
const navFooterItems: { path: string; icon: typeof Setting; label: string }[] = [
|
||||
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' }
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path || route.path.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
const goAdminConsole = () => {
|
||||
const url = router.resolve({ path: '/admin/dashboard' }).href
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
if (window.innerWidth < 1024) {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.superAdminLogout()
|
||||
router.push('/superadmin/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
router.push('/superadmin/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.superadmin-layout {
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
z-index: 1000;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.menu-toggle {
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #ef4444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: #111827;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.nav-link {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.admin-entry {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.logout {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 64px;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #f3f4f6;
|
||||
z-index: 999;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 64px - 32px);
|
||||
}
|
||||
|
||||
.nav-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-footer-block {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
margin-left: 0 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fef2f2;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
z-index: 998;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding-top: 64px;
|
||||
min-height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding-left: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.mission-strip {
|
||||
background: linear-gradient(90deg, #fef2f2 0%, #fff7ed 100%);
|
||||
border-bottom: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.mission-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mission-text {
|
||||
font-size: 13px;
|
||||
color: #44403c;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.scss'
|
||||
import { migrateLegacyAuthStorage } from '@/utils/authStorage'
|
||||
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -1,203 +1,223 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// 普通管理后台路由
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: () => import('@/views/admin/Login.vue'),
|
||||
meta: { title: '管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue'),
|
||||
meta: { title: '数据概览' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/Users.vue'),
|
||||
meta: { title: '用户管理' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
component: () => import('@/views/admin/Orders.vue'),
|
||||
meta: { title: '订单管理' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'AdminDistribution',
|
||||
component: () => import('@/views/admin/Distribution.vue'),
|
||||
meta: { title: '分销管理' }
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
name: 'AdminQuestions',
|
||||
component: () => import('@/views/admin/Questions.vue'),
|
||||
meta: { title: '题库管理' }
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'AdminPricing',
|
||||
component: () => import('@/views/admin/Pricing.vue'),
|
||||
meta: { title: '价格设置' }
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'AdminFinance',
|
||||
component: () => import('@/views/admin/Finance.vue'),
|
||||
meta: { title: '企业余额' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// 超级管理后台路由
|
||||
{
|
||||
path: '/superadmin/login',
|
||||
name: 'SuperAdminLogin',
|
||||
component: () => import('@/views/superadmin/Login.vue'),
|
||||
meta: { title: '超级管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/superadmin',
|
||||
component: () => import('@/layouts/SuperAdminLayout.vue'),
|
||||
redirect: '/superadmin/overview',
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'SuperAdminOverview',
|
||||
component: () => import('@/views/superadmin/Overview.vue'),
|
||||
meta: { title: '概览' }
|
||||
},
|
||||
{
|
||||
path: 'enterprises',
|
||||
name: 'SuperAdminEnterprises',
|
||||
component: () => import('@/views/superadmin/Enterprises.vue'),
|
||||
meta: { title: '企业管理' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'SuperAdminUsers',
|
||||
component: () => import('@/views/superadmin/Users.vue'),
|
||||
meta: { title: '用户总览' }
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
name: 'SuperAdminQuestions',
|
||||
component: () => import('@/views/superadmin/Questions.vue'),
|
||||
meta: { title: '题库管理' }
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'SuperAdminAIConfig',
|
||||
component: () => import('@/views/superadmin/AIConfig.vue'),
|
||||
meta: { title: 'AI 服务配置' }
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'SuperAdminPricing',
|
||||
component: () => import('@/views/superadmin/Pricing.vue'),
|
||||
meta: { title: '全局定价' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'SuperAdminDistribution',
|
||||
component: () => import('@/views/superadmin/Distribution.vue'),
|
||||
meta: { title: '分销管理' }
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'SuperAdminFinance',
|
||||
component: () => import('@/views/superadmin/Finance.vue'),
|
||||
meta: { title: '财务数据' }
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
name: 'SuperAdminDatabase',
|
||||
component: () => import('@/views/superadmin/Database.vue'),
|
||||
meta: { title: '数据库管理' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SuperAdminSettings',
|
||||
component: () => import('@/views/superadmin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// 默认重定向
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/admin/login'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title ? `${to.meta.title} - MBTI 管理后台` : 'MBTI 管理后台'
|
||||
|
||||
// 检查登录状态(使用Token)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const userRole = localStorage.getItem('userRole')
|
||||
|
||||
// 管理后台路由守卫
|
||||
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
|
||||
if (!token || !userRole || !['admin', 'enterprise_admin', 'superadmin'].includes(userRole)) {
|
||||
// 清除登录状态
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userRole')
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 超级管理后台路由守卫
|
||||
if (to.path.startsWith('/superadmin') && to.path !== '/superadmin/login') {
|
||||
if (!token || userRole !== 'superadmin') {
|
||||
// 清除登录状态
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userRole')
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
next('/superadmin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录,访问登录页则跳转到对应首页
|
||||
if (to.path === '/admin/login' && token && userRole && ['admin', 'enterprise_admin', 'superadmin'].includes(userRole)) {
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/superadmin/login' && token && userRole === 'superadmin') {
|
||||
next('/superadmin/overview')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import {
|
||||
migrateLegacyAuthStorage,
|
||||
getAdminToken,
|
||||
getAdminRole,
|
||||
getSuperadminToken,
|
||||
getSuperadminRole,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// 普通管理后台路由
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: () => import('@/views/admin/Login.vue'),
|
||||
meta: { title: '管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue'),
|
||||
meta: { title: '概览' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UsersHub.vue'),
|
||||
meta: { title: '用户运营' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
component: () => import('@/views/admin/OrdersHub.vue'),
|
||||
meta: { title: '订单运营' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'AdminDistribution',
|
||||
component: () => import('@/views/admin/Distribution.vue'),
|
||||
meta: { title: '分销推广' }
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
redirect: { path: '/admin/orders', query: { tab: 'questions' } }
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
redirect: { path: '/admin/orders', query: { tab: 'pricing' } }
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
redirect: { path: '/admin/settings', query: { tab: 'finance' } }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// 超级管理后台路由
|
||||
{
|
||||
path: '/superadmin/login',
|
||||
name: 'SuperAdminLogin',
|
||||
component: () => import('@/views/superadmin/Login.vue'),
|
||||
meta: { title: '超级管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/superadmin',
|
||||
component: () => import('@/layouts/SuperAdminLayout.vue'),
|
||||
redirect: '/superadmin/ops',
|
||||
children: [
|
||||
{
|
||||
path: 'ops',
|
||||
name: 'SuperAdminOps',
|
||||
component: () => import('@/views/superadmin/OpsHub.vue'),
|
||||
meta: { title: '总览' },
|
||||
beforeEnter: (to, _from, next) => {
|
||||
const t = to.query.tab
|
||||
if (t === 'distribution') {
|
||||
next({ path: '/superadmin/distribution' })
|
||||
return
|
||||
}
|
||||
if (t === 'analytics') {
|
||||
next({ path: '/superadmin/distribution', query: { tab: 'analytics' } })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'SuperAdminUsers',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'users' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'enterprises',
|
||||
name: 'SuperAdminEnterprises',
|
||||
component: () => import('@/views/superadmin/EnterpriseHub.vue'),
|
||||
meta: { title: '企业管理' }
|
||||
},
|
||||
{
|
||||
path: 'commerce',
|
||||
name: 'SuperAdminCommerce',
|
||||
component: () => import('@/views/superadmin/CommerceHub.vue'),
|
||||
meta: { title: '订单和财务' }
|
||||
},
|
||||
{
|
||||
path: 'distribution',
|
||||
name: 'SuperAdminDistribution',
|
||||
component: () => import('@/views/superadmin/DistributionStandalone.vue'),
|
||||
meta: { title: '分销管理' }
|
||||
},
|
||||
{
|
||||
path: 'analytics',
|
||||
name: 'SuperAdminMpAnalytics',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/distribution',
|
||||
query: { ...to.query, tab: 'analytics' }
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'SuperAdminAIConfig',
|
||||
component: () => import('@/views/superadmin/AIConfig.vue'),
|
||||
meta: { title: '智能算力' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SuperAdminSettings',
|
||||
component: () => import('@/views/superadmin/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
redirect: to => ({ path: '/superadmin/ops', query: { ...to.query, tab: 'overview' } })
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
redirect: to => ({ path: '/superadmin/settings', query: { ...to.query, tab: 'questions' } })
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
redirect: to => ({ path: '/superadmin/commerce', query: { ...to.query, tab: 'pricing' } })
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
redirect: to => ({ path: '/superadmin/settings', query: { ...to.query, tab: 'database' } })
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
redirect: to => ({ path: '/superadmin/commerce', query: { ...to.query, tab: 'finance' } })
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 默认重定向
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/admin/login'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫:管理端仅 admin / enterprise_admin;超管端仅 superadmin;两套 Token 分存
|
||||
router.beforeEach((to, _from, next) => {
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
document.title = to.meta.title ? `${to.meta.title} - MBTI 管理后台` : 'MBTI 管理后台'
|
||||
|
||||
const adminToken = getAdminToken()
|
||||
const adminRole = getAdminRole()
|
||||
const saToken = getSuperadminToken()
|
||||
const saRole = getSuperadminRole()
|
||||
|
||||
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
|
||||
if (!adminToken || !adminRole || !['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
clearAdminAuthKeys()
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.path.startsWith('/superadmin') && to.path !== '/superadmin/login') {
|
||||
if (!saToken || saRole !== 'superadmin') {
|
||||
clearSuperadminAuthKeys()
|
||||
next('/superadmin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.path === '/admin/login' && adminToken && adminRole && ['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
next('/admin/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/superadmin/login' && saToken && saRole === 'superadmin') {
|
||||
next('/superadmin/ops')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import {
|
||||
migrateLegacyAuthStorage,
|
||||
ADMIN_TOKEN_KEY,
|
||||
ADMIN_ROLE_KEY,
|
||||
ADMIN_USER_ID_KEY,
|
||||
SUPERADMIN_TOKEN_KEY,
|
||||
SUPERADMIN_ROLE_KEY,
|
||||
SUPERADMIN_USER_ID_KEY,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
interface LoginResponse {
|
||||
code: number
|
||||
@@ -18,31 +29,29 @@ interface LoginResponse {
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 管理员登录状态
|
||||
const adminLoggedIn = ref(false)
|
||||
const superAdminLoggedIn = ref(false)
|
||||
const currentUser = ref<any>(null)
|
||||
|
||||
// 初始化登录状态(自动执行)
|
||||
function initAuth() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const userRole = localStorage.getItem('userRole')
|
||||
|
||||
if (token && userRole) {
|
||||
if (userRole === 'superadmin') {
|
||||
superAdminLoggedIn.value = true
|
||||
} else if (['admin', 'enterprise_admin'].includes(userRole)) {
|
||||
adminLoggedIn.value = true
|
||||
}
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
migrateLegacyAuthStorage()
|
||||
|
||||
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
const adminRole = localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
if (adminToken && adminRole && ['admin', 'enterprise_admin'].includes(adminRole)) {
|
||||
adminLoggedIn.value = true
|
||||
}
|
||||
|
||||
const saToken = localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
const saRole = localStorage.getItem(SUPERADMIN_ROLE_KEY)
|
||||
if (saToken && saRole === 'superadmin') {
|
||||
superAdminLoggedIn.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 自动初始化
|
||||
initAuth()
|
||||
|
||||
// 管理员登录
|
||||
async function adminLogin(username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await request.post<LoginResponse>('/auth/admin/login', {
|
||||
@@ -51,15 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 存储Token和用户信息
|
||||
localStorage.setItem('authToken', response.data.token)
|
||||
localStorage.setItem('userRole', response.data.user.role)
|
||||
localStorage.setItem('userId', String(response.data.user.id))
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, response.data.token)
|
||||
localStorage.setItem(ADMIN_ROLE_KEY, response.data.user.role)
|
||||
localStorage.setItem(ADMIN_USER_ID_KEY, String(response.data.user.id))
|
||||
localStorage.setItem('adminLoggedIn', 'true')
|
||||
|
||||
|
||||
currentUser.value = response.data.user
|
||||
adminLoggedIn.value = true
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -69,7 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 超级管理员登录
|
||||
async function superAdminLogin(username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await request.post<LoginResponse>('/auth/superadmin/login', {
|
||||
@@ -78,15 +85,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 存储Token和用户信息
|
||||
localStorage.setItem('authToken', response.data.token)
|
||||
localStorage.setItem('userRole', response.data.user.role)
|
||||
localStorage.setItem('userId', String(response.data.user.id))
|
||||
localStorage.setItem(SUPERADMIN_TOKEN_KEY, response.data.token)
|
||||
localStorage.setItem(SUPERADMIN_ROLE_KEY, response.data.user.role)
|
||||
localStorage.setItem(SUPERADMIN_USER_ID_KEY, String(response.data.user.id))
|
||||
localStorage.setItem('superAdminLoggedIn', 'true')
|
||||
|
||||
|
||||
currentUser.value = response.data.user
|
||||
superAdminLoggedIn.value = true
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -96,39 +102,25 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员登出
|
||||
async function adminLogout() {
|
||||
try {
|
||||
await request.post('/auth/logout')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
} finally {
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userRole')
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
|
||||
clearAdminAuthKeys()
|
||||
adminLoggedIn.value = false
|
||||
currentUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 超级管理员登出
|
||||
async function superAdminLogout() {
|
||||
try {
|
||||
await request.post('/auth/logout')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
} finally {
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userRole')
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
|
||||
clearSuperadminAuthKeys()
|
||||
superAdminLoggedIn.value = false
|
||||
currentUser.value = null
|
||||
}
|
||||
@@ -145,4 +137,3 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
superAdminLogout
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
88
admin/src/utils/authStorage.ts
Normal file
88
admin/src/utils/authStorage.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 管理端与超管端使用独立 localStorage,避免互相覆盖 Token。
|
||||
* 兼容旧键 authToken / userRole:首次加载时迁移到新键后删除旧键。
|
||||
*/
|
||||
|
||||
export const ADMIN_TOKEN_KEY = 'adminAuthToken'
|
||||
export const ADMIN_ROLE_KEY = 'adminUserRole'
|
||||
export const ADMIN_USER_ID_KEY = 'adminUserId'
|
||||
|
||||
export const SUPERADMIN_TOKEN_KEY = 'superadminAuthToken'
|
||||
export const SUPERADMIN_ROLE_KEY = 'superadminUserRole'
|
||||
export const SUPERADMIN_USER_ID_KEY = 'superadminUserId'
|
||||
|
||||
const LEGACY_TOKEN = 'authToken'
|
||||
const LEGACY_ROLE = 'userRole'
|
||||
const LEGACY_USER_ID = 'userId'
|
||||
|
||||
/** 应用启动时调用一次 */
|
||||
export function migrateLegacyAuthStorage(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
const legacyToken = localStorage.getItem(LEGACY_TOKEN)
|
||||
const legacyRole = localStorage.getItem(LEGACY_ROLE)
|
||||
if (!legacyToken || !legacyRole) return
|
||||
|
||||
const hasSplit =
|
||||
localStorage.getItem(ADMIN_TOKEN_KEY) || localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
if (hasSplit) {
|
||||
localStorage.removeItem(LEGACY_TOKEN)
|
||||
localStorage.removeItem(LEGACY_ROLE)
|
||||
localStorage.removeItem(LEGACY_USER_ID)
|
||||
return
|
||||
}
|
||||
|
||||
const legacyUserId = localStorage.getItem(LEGACY_USER_ID)
|
||||
if (legacyRole === 'superadmin') {
|
||||
localStorage.setItem(SUPERADMIN_TOKEN_KEY, legacyToken)
|
||||
localStorage.setItem(SUPERADMIN_ROLE_KEY, legacyRole)
|
||||
if (legacyUserId) localStorage.setItem(SUPERADMIN_USER_ID_KEY, legacyUserId)
|
||||
} else if (['admin', 'enterprise_admin'].includes(legacyRole)) {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, legacyToken)
|
||||
localStorage.setItem(ADMIN_ROLE_KEY, legacyRole)
|
||||
if (legacyUserId) localStorage.setItem(ADMIN_USER_ID_KEY, legacyUserId)
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_TOKEN)
|
||||
localStorage.removeItem(LEGACY_ROLE)
|
||||
localStorage.removeItem(LEGACY_USER_ID)
|
||||
}
|
||||
|
||||
export function getAdminToken(): string | null {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getAdminRole(): string | null {
|
||||
return localStorage.getItem(ADMIN_ROLE_KEY)
|
||||
}
|
||||
|
||||
export function getSuperadminToken(): string | null {
|
||||
return localStorage.getItem(SUPERADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getSuperadminRole(): string | null {
|
||||
return localStorage.getItem(SUPERADMIN_ROLE_KEY)
|
||||
}
|
||||
|
||||
/** axios / 上传:按当前页面路径选择 Bearer */
|
||||
export function getBearerTokenForCurrentApp(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (window.location.pathname.startsWith('/superadmin')) {
|
||||
return getSuperadminToken()
|
||||
}
|
||||
return getAdminToken()
|
||||
}
|
||||
|
||||
export function clearAdminAuthKeys(): void {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||
localStorage.removeItem(ADMIN_ROLE_KEY)
|
||||
localStorage.removeItem(ADMIN_USER_ID_KEY)
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
}
|
||||
|
||||
export function clearSuperadminAuthKeys(): void {
|
||||
localStorage.removeItem(SUPERADMIN_TOKEN_KEY)
|
||||
localStorage.removeItem(SUPERADMIN_ROLE_KEY)
|
||||
localStorage.removeItem(SUPERADMIN_USER_ID_KEY)
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
}
|
||||
@@ -1,118 +1,127 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 获取API基础URL
|
||||
const getBaseURL = (): string => {
|
||||
const envURL = import.meta.env.VITE_API_BASE_URL
|
||||
if (envURL) {
|
||||
// 如果环境变量是完整URL,拼接 /api/v1
|
||||
return envURL.endsWith('/') ? `${envURL}api/v1` : `${envURL}/api/v1`
|
||||
}
|
||||
// 默认使用相对路径
|
||||
return '/api/v1'
|
||||
}
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加 token
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response.data
|
||||
|
||||
// 如果返回的状态码不是 200,则认为是错误
|
||||
if (res.code && res.code !== 200) {
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
(error) => {
|
||||
console.error('响应错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
if (status === 401) {
|
||||
ElMessage.error('未授权,请重新登录')
|
||||
// 清除所有登录状态和Token
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('userRole')
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem('adminLoggedIn')
|
||||
localStorage.removeItem('superAdminLoggedIn')
|
||||
|
||||
// 根据当前路径跳转到对应登录页
|
||||
if (window.location.pathname.startsWith('/superadmin')) {
|
||||
window.location.href = '/superadmin/login'
|
||||
} else {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('拒绝访问')
|
||||
} else if (status === 404) {
|
||||
ElMessage.error('请求地址不存在')
|
||||
} else if (status === 500) {
|
||||
ElMessage.error('服务器错误')
|
||||
} else {
|
||||
ElMessage.error(data?.message || '请求失败')
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
ElMessage.error('请求配置错误')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 导出请求方法
|
||||
export const request = {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, config)
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, config)
|
||||
},
|
||||
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.patch(url, data, config)
|
||||
}
|
||||
}
|
||||
|
||||
export default service
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getBearerTokenForCurrentApp,
|
||||
clearAdminAuthKeys,
|
||||
clearSuperadminAuthKeys
|
||||
} from '@/utils/authStorage'
|
||||
|
||||
let lastBizErrorKey = ''
|
||||
let lastBizErrorAt = 0
|
||||
function showBizErrorOnce(message: string) {
|
||||
const key = message || '请求失败'
|
||||
const now = Date.now()
|
||||
if (key === lastBizErrorKey && now - lastBizErrorAt < 1800) return
|
||||
lastBizErrorKey = key
|
||||
lastBizErrorAt = now
|
||||
ElMessage.error(key)
|
||||
}
|
||||
|
||||
// 获取API基础URL(开发环境留空 VITE_API_BASE_URL 时走同源 /api/v1,由 Vite 代理到本机后端)
|
||||
const getBaseURL = (): string => {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||
const envURL = typeof raw === 'string' ? raw.trim() : ''
|
||||
if (envURL) {
|
||||
return envURL.endsWith('/') ? `${envURL}api/v1` : `${envURL}/api/v1`
|
||||
}
|
||||
return '/api/v1'
|
||||
}
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: getBaseURL(),
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加 token
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response.data
|
||||
|
||||
// 如果返回的状态码不是 200,则认为是错误
|
||||
if (res.code && res.code !== 200) {
|
||||
showBizErrorOnce(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
(error) => {
|
||||
console.error('响应错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
if (status === 401) {
|
||||
ElMessage.error('未授权,请重新登录')
|
||||
if (window.location.pathname.startsWith('/superadmin')) {
|
||||
clearSuperadminAuthKeys()
|
||||
window.location.href = '/superadmin/login'
|
||||
} else {
|
||||
clearAdminAuthKeys()
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
} else if (status === 403) {
|
||||
showBizErrorOnce(data?.message || '拒绝访问')
|
||||
} else if (status === 404) {
|
||||
ElMessage.error('请求地址不存在')
|
||||
} else if (status === 500) {
|
||||
ElMessage.error('服务器错误')
|
||||
} else {
|
||||
ElMessage.error(data?.message || '请求失败')
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
} else {
|
||||
ElMessage.error('请求配置错误')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 导出请求方法
|
||||
export const request = {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, config)
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, config)
|
||||
},
|
||||
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.patch(url, data, config)
|
||||
}
|
||||
}
|
||||
|
||||
export default service
|
||||
|
||||
|
||||
@@ -1,392 +1,528 @@
|
||||
<template>
|
||||
<div class="dashboard-container" v-loading="loading">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-value">{{ stats.totalUsers }}</div>
|
||||
</div>
|
||||
<div class="stat-icon blue">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已完成测试</div>
|
||||
<div class="stat-value">{{ stats.testsCompleted }}</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">今日活跃</div>
|
||||
<div class="stat-value">{{ stats.activeToday }}</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">待审核</div>
|
||||
<div class="stat-value">{{ stats.pendingReviews }}</div>
|
||||
</div>
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试趋势折线图 -->
|
||||
<div class="activity-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">测试趋势</h2>
|
||||
<p class="section-subtitle">最近 14 天人脸分析、MBTI、PDP、DISC 等关键测试的完成情况</p>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart-wrapper" v-if="testTrends.length">
|
||||
<VChart class="trend-chart-echarts" :option="chartOption" autoresize />
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-else>
|
||||
<span>暂无测试趋势数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请二维码 -->
|
||||
<div class="activity-section invite-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">专属邀请小程序码</h2>
|
||||
<p class="section-subtitle">生成专属邀请二维码,员工/客户扫码即可进入小程序完成测试</p>
|
||||
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="invite-content">
|
||||
<div v-if="inviteQrcode">
|
||||
<img :src="inviteQrcode" alt="邀请小程序码" class="invite-qrcode" />
|
||||
<div class="invite-tip">右键/长按保存二维码,用于宣传物料或群内邀请</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<el-button type="primary" size="small" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
生成邀请二维码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import {
|
||||
User,
|
||||
Document,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const stats = reactive({
|
||||
totalUsers: 0,
|
||||
testsCompleted: 0,
|
||||
activeToday: 0,
|
||||
pendingReviews: 0
|
||||
})
|
||||
|
||||
const testTrends = ref<
|
||||
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
|
||||
>([])
|
||||
const loading = ref(false)
|
||||
const inviteLoading = ref(false)
|
||||
const inviteQrcode = ref<string>('')
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const dates = testTrends.value.map(d => d.date.slice(5))
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['人脸分析', 'MBTI', 'PDP', 'DISC'],
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 30,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '人脸分析',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#22c55e' },
|
||||
data: testTrends.value.map(d => d.face)
|
||||
},
|
||||
{
|
||||
name: 'MBTI',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
data: testTrends.value.map(d => d.mbti)
|
||||
},
|
||||
{
|
||||
name: 'PDP',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: testTrends.value.map(d => d.pdp)
|
||||
},
|
||||
{
|
||||
name: 'DISC',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#6366f1' },
|
||||
data: testTrends.value.map(d => d.disc)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/dashboard')
|
||||
if (response.code === 200 && response.data) {
|
||||
stats.totalUsers = response.data.totalUsers || 0
|
||||
stats.testsCompleted = response.data.testsCompleted || 0
|
||||
stats.activeToday = response.data.activeToday || 0
|
||||
stats.pendingReviews = response.data.pendingReviews || 0
|
||||
testTrends.value = response.data.testTrends || []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据失败:', error)
|
||||
ElMessage.error(error.message || '加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// 加载邀请小程序码
|
||||
const loadInviteQrcode = async () => {
|
||||
if (inviteLoading.value) return
|
||||
inviteLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/invite/qrcode')
|
||||
const data = res.data ?? res
|
||||
if (data && data.qrcode) {
|
||||
inviteQrcode.value = data.qrcode
|
||||
} else if (data.code === 200 && data.data?.qrcode) {
|
||||
inviteQrcode.value = data.data.qrcode
|
||||
} else {
|
||||
ElMessage.error('生成邀请二维码失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('生成邀请二维码失败:', error)
|
||||
ElMessage.error(error?.message || '生成邀请二维码失败')
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
background-color: #f9fafb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
|
||||
&.blue {
|
||||
background-color: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #f0fdf4;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.purple {
|
||||
background-color: #faf5ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fffbeb;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-section {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.trend-chart-echarts {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-section {
|
||||
margin-top: 24px;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-qrcode {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.invite-tip {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.activity-section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="dashboard-viewport" v-loading="loading">
|
||||
<header class="dash-head">
|
||||
<h1 class="dash-title">数据概览</h1>
|
||||
<p class="dash-tagline">
|
||||
企业侧测评闭环:小程序面相与 MBTI / DISC / PDP 答题 → MySQL 落库 → 本页看用户与测试趋势、拉新邀请码(全链路见开发文档《小程序全链路功能与接口》)。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="dash-kpis">
|
||||
<div v-for="(card, i) in kpiCards" :key="card.key" class="stat-card" :style="{ animationDelay: `${i * 45}ms` }">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-value">{{ card.value }}</div>
|
||||
</div>
|
||||
<div :class="['stat-icon', card.tone]">
|
||||
<el-icon><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-main">
|
||||
<section class="panel panel-chart">
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title">近 14 日测试趋势</h2>
|
||||
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量</p>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<VChart v-if="testTrends.length" class="trend-chart" :option="chartOption" autoresize />
|
||||
<div v-else class="panel-empty">暂无趋势数据</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel panel-side">
|
||||
<div class="side-block side-users">
|
||||
<div class="panel-head row">
|
||||
<div>
|
||||
<h2 class="panel-title">测试 Top 10</h2>
|
||||
<p class="panel-desc">按完成次数 · 本企业口径</p>
|
||||
</div>
|
||||
<el-button type="primary" link size="small" @click="router.push('/admin/users')">全部用户</el-button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-if="topTestUsers.length"
|
||||
:data="topTestUsers"
|
||||
size="small"
|
||||
stripe
|
||||
class="compact-table"
|
||||
:max-height="tableMaxH"
|
||||
>
|
||||
<el-table-column label="#" width="42">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" min-width="88" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.username || '未命名' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="testCount" label="次数" width="52" align="center" />
|
||||
<el-table-column label="摘要" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ summarizeTypes(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="panel-empty tight">暂无测试记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-block side-invite">
|
||||
<div class="panel-head row">
|
||||
<div>
|
||||
<h2 class="panel-title">邀请小程序码</h2>
|
||||
<p class="panel-desc">员工 / 客户扫码进入企业测评</p>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
|
||||
{{ inviteQrcode ? '刷新' : '生成' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="invite-body">
|
||||
<img v-if="inviteQrcode" :src="inviteQrcode" alt="邀请码" class="invite-img" />
|
||||
<span v-else class="invite-placeholder">点击生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Document, TrendCharts } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface TopUserRow {
|
||||
id: number
|
||||
username: string
|
||||
phone: string
|
||||
testCount: number
|
||||
lastTestAt: number | null
|
||||
mbtiType: string
|
||||
pdpType: string
|
||||
discType: string
|
||||
faceMbtiType: string
|
||||
faceDiscType: string
|
||||
facePdpType: string
|
||||
}
|
||||
|
||||
const stats = reactive({
|
||||
totalUsers: 0,
|
||||
testsCompleted: 0,
|
||||
activeToday: 0,
|
||||
pendingReviews: 0
|
||||
})
|
||||
|
||||
const testTrends = ref<
|
||||
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
|
||||
>([])
|
||||
const topTestUsers = ref<TopUserRow[]>([])
|
||||
const loading = ref(false)
|
||||
const inviteLoading = ref(false)
|
||||
const inviteQrcode = ref<string>('')
|
||||
|
||||
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
|
||||
const tableMaxH = 220
|
||||
|
||||
const kpiCards = computed(() => [
|
||||
{ key: 'u', label: '总用户数', value: stats.totalUsers, icon: User, tone: 'blue' },
|
||||
{ key: 't', label: '已完成测试', value: stats.testsCompleted, icon: Document, tone: 'green' },
|
||||
{ key: 'a', label: '今日活跃', value: stats.activeToday, icon: TrendCharts, tone: 'purple' },
|
||||
{ key: 'p', label: '待审核', value: stats.pendingReviews, icon: User, tone: 'orange' }
|
||||
])
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const dates = testTrends.value.map(d => d.date.slice(5))
|
||||
return {
|
||||
animationDuration: 480,
|
||||
animationEasing: 'cubicOut',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['人脸', 'MBTI', 'PDP', 'DISC'],
|
||||
top: 0,
|
||||
textStyle: { fontSize: 11, color: '#6b7280' }
|
||||
},
|
||||
grid: { left: 36, right: 12, top: 36, bottom: 24 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#9ca3af', fontSize: 10 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#9ca3af', fontSize: 10 }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '人脸',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#22c55e' },
|
||||
data: testTrends.value.map(d => d.face)
|
||||
},
|
||||
{
|
||||
name: 'MBTI',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
data: testTrends.value.map(d => d.mbti)
|
||||
},
|
||||
{
|
||||
name: 'PDP',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#f97316' },
|
||||
data: testTrends.value.map(d => d.pdp)
|
||||
},
|
||||
{
|
||||
name: 'DISC',
|
||||
type: 'line',
|
||||
smooth: 0.35,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 2 },
|
||||
itemStyle: { color: '#6366f1' },
|
||||
data: testTrends.value.map(d => d.disc)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
function summarizeTypes(row: TopUserRow) {
|
||||
const parts: string[] = []
|
||||
if (row.mbtiType) parts.push(row.mbtiType)
|
||||
if (row.pdpType) parts.push(row.pdpType)
|
||||
if (row.discType) parts.push(row.discType)
|
||||
const faceBits = [row.faceMbtiType, row.facePdpType, row.faceDiscType].filter(Boolean)
|
||||
if (faceBits.length) parts.push('面:' + faceBits.join('/'))
|
||||
return parts.length ? parts.join(' · ') : '—'
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/dashboard')
|
||||
if (response.code === 200 && response.data) {
|
||||
stats.totalUsers = response.data.totalUsers || 0
|
||||
stats.testsCompleted = response.data.testsCompleted || 0
|
||||
stats.activeToday = response.data.activeToday || 0
|
||||
stats.pendingReviews = response.data.pendingReviews || 0
|
||||
testTrends.value = response.data.testTrends || []
|
||||
topTestUsers.value = Array.isArray(response.data.topTestUsers) ? response.data.topTestUsers : []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据失败:', error)
|
||||
ElMessage.error(error.message || '加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const loadInviteQrcode = async () => {
|
||||
if (inviteLoading.value) return
|
||||
inviteLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/invite/qrcode')
|
||||
const qrcode = res?.data?.qrcode
|
||||
if (qrcode && typeof qrcode === 'string') {
|
||||
inviteQrcode.value = qrcode
|
||||
} else {
|
||||
ElMessage.error(res?.message || res?.msg || '生成失败,请确认企业绑定')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '生成失败')
|
||||
} finally {
|
||||
inviteLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@keyframes dashFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-viewport {
|
||||
height: calc(100vh - 56px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px 18px 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 10px;
|
||||
animation: dashFadeUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.dash-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dash-tagline {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #6b7280;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.dash-kpis {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
animation: dashFadeUp 0.45s ease-out both;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
|
||||
&.blue {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
&.green {
|
||||
background: #f0fdf4;
|
||||
color: #22c55e;
|
||||
}
|
||||
&.purple {
|
||||
background: #faf5ff;
|
||||
color: #a855f7;
|
||||
}
|
||||
&.orange {
|
||||
background: #fffbeb;
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dash-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(280px, 30%);
|
||||
gap: 10px;
|
||||
animation: dashFadeUp 0.5s ease-out 0.08s both;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-chart {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-side {
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.panel-desc {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
|
||||
&.tight {
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.side-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side-users {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.side-invite {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.invite-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.invite-img {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.invite-placeholder {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dash-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-viewport {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 56px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-chart .chart-box {
|
||||
min-height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-kpis {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
133
admin/src/views/admin/FeishuLeadConfigPanel.vue
Normal file
133
admin/src/views/admin/FeishuLeadConfigPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="tab-content" v-loading="loading">
|
||||
<div class="content-header">
|
||||
<h3>飞书获客推送</h3>
|
||||
<p class="content-description">
|
||||
参考 Soul 创业派对:支付成功(含 1 元测试、充值)、用户首次授权手机号时,向飞书群机器人推送「新获客」卡片文案;含最近行为(依赖小程序埋点)。
|
||||
在飞书群添加自定义机器人,复制 Webhook 地址填入下方。
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-item row-line">
|
||||
<label>启用推送</label>
|
||||
<el-switch v-model="form.enabled" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>飞书 Webhook 地址</label>
|
||||
<el-input
|
||||
v-model="form.webhookUrl"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/……"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>对接人(展示在卡片上)</label>
|
||||
<el-input v-model="form.contactPerson" placeholder="如:卡若" class="w-full" />
|
||||
</div>
|
||||
<p class="hint">
|
||||
幂等:同一订单仅推一次;同一用户首次绑定手机号仅推一次。支付结果可能经「前端 notify」与「查单」两条路径,后台以订单维度去重。
|
||||
</p>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="loading" @click="save">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
webhookUrl: '',
|
||||
contactPerson: '运营'
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/settings/feishu-lead')
|
||||
if (res.code === 200 && res.data) {
|
||||
form.enabled = !!res.data.enabled
|
||||
form.webhookUrl = res.data.webhookUrl || ''
|
||||
form.contactPerson = res.data.contactPerson || '运营'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/settings/feishu-lead', { ...form })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-header h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.content-description {
|
||||
margin: 0 0 20px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-section {
|
||||
max-width: 640px;
|
||||
}
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-item label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
.row-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-line label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.save-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,379 +1,386 @@
|
||||
<template>
|
||||
<div class="finance-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>企业余额</h2>
|
||||
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额、测试收入和充值流水</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="loadAll" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前余额</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.balanceFen) }}</div>
|
||||
<div class="stat-desc">可用于企业佣金结算</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.todayIncomeFen) }}</div>
|
||||
<div class="stat-desc">仅统计 face / MBTI / DISC / PDP</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.monthIncomeFen) }}</div>
|
||||
<div class="stat-desc">企业用户支付后自动入账</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">冻结佣金</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.frozenCommissionFen) }}</div>
|
||||
<div class="stat-desc">补余额后会自动解冻</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<div class="content-header">
|
||||
<h3>财务流水</h3>
|
||||
<span class="content-tip">测试收入会自动进入企业余额,手动充值也会记录在这里</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" v-loading="recordsLoading" class="custom-table">
|
||||
<el-table-column prop="typeLabel" label="类型" width="120" />
|
||||
<el-table-column label="金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.direction === 'out' ? 'amount-out' : 'amount-in'">
|
||||
{{ row.direction === 'out' ? '-' : '+' }}¥{{ fenToYuan(row.amountFen) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动前余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceBeforeFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动后余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceAfterFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="220" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="empty-state" v-if="!recordsLoading && records.length === 0">暂无财务流水</div>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeDialogVisible" title="企业余额充值" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="充值金额(元)" required>
|
||||
<el-input-number v-model="rechargeForm.amountYuan" :min="0.01" :step="100" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="rechargeForm.remark" type="textarea" :rows="2" placeholder="可选,仅用于当前页提示,不会直接入账" />
|
||||
</el-form-item>
|
||||
<div v-if="rechargeQrcode" class="recharge-qrcode">
|
||||
<img :src="rechargeQrcode" alt="充值二维码" class="qrcode-image" />
|
||||
<p class="qrcode-tip">请使用微信扫码,在小程序内完成支付充值</p>
|
||||
<p class="qrcode-amount">本次充值:¥{{ rechargeForm.amountYuan.toFixed(2) }}</p>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rechargeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" color="#7c3aed" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const recordsLoading = ref(false)
|
||||
const rechargeLoading = ref(false)
|
||||
const rechargeDialogVisible = ref(false)
|
||||
const rechargeQrcode = ref('')
|
||||
|
||||
const overview = reactive({
|
||||
enterpriseId: 0,
|
||||
enterpriseName: '',
|
||||
balanceFen: 0,
|
||||
todayIncomeFen: 0,
|
||||
monthIncomeFen: 0,
|
||||
totalIncomeFen: 0,
|
||||
manualRechargeFen: 0,
|
||||
frozenCommissionFen: 0,
|
||||
paidOrderCount: 0
|
||||
})
|
||||
|
||||
const records = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amountYuan: 100,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
const res: any = await request.get('/admin/finance/overview')
|
||||
Object.assign(overview, res.data || {})
|
||||
}
|
||||
|
||||
const loadRecords = async () => {
|
||||
recordsLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/finance/records', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
records.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
recordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadOverview()
|
||||
await loadRecords()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载企业财务数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRechargeDialog = () => {
|
||||
rechargeQrcode.value = ''
|
||||
rechargeDialogVisible.value = true
|
||||
}
|
||||
|
||||
const generateRechargeQrcode = async () => {
|
||||
if (!rechargeForm.amountYuan || rechargeForm.amountYuan <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
const amountFen = Math.round(Number(rechargeForm.amountYuan) * 100)
|
||||
if (amountFen <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/admin/finance/recharge-qrcode', {
|
||||
amountFen,
|
||||
remark: rechargeForm.remark
|
||||
})
|
||||
rechargeQrcode.value = res.data?.qrcode || ''
|
||||
ElMessage.success('充值码已生成')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '生成充值码失败')
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.finance-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #eef2f7;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-tip {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-in {
|
||||
color: #16a34a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-out {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px 0 8px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.recharge-qrcode {
|
||||
margin-top: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
margin: 12px 0 4px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qrcode-amount {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.finance-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="finance-page" :class="{ 'is-embedded': embedded }">
|
||||
<div v-if="!embedded" class="page-header">
|
||||
<div>
|
||||
<h2>企业余额</h2>
|
||||
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额、测试收入和充值流水</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="loadAll" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" v-loading="loading">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">当前余额</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.balanceFen) }}</div>
|
||||
<div class="stat-desc">可用于企业佣金结算</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.todayIncomeFen) }}</div>
|
||||
<div class="stat-desc">仅统计 face / MBTI / DISC / PDP</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月测试收入</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.monthIncomeFen) }}</div>
|
||||
<div class="stat-desc">企业用户支付后自动入账</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">冻结佣金</div>
|
||||
<div class="stat-value">¥{{ fenToYuan(overview.frozenCommissionFen) }}</div>
|
||||
<div class="stat-desc">补余额后会自动解冻</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<div class="content-header">
|
||||
<h3>财务流水</h3>
|
||||
<span class="content-tip">测试收入会自动进入企业余额,手动充值也会记录在这里</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" v-loading="recordsLoading" class="custom-table">
|
||||
<el-table-column prop="typeLabel" label="类型" width="120" />
|
||||
<el-table-column label="金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.direction === 'out' ? 'amount-out' : 'amount-in'">
|
||||
{{ row.direction === 'out' ? '-' : '+' }}¥{{ fenToYuan(row.amountFen) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动前余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceBeforeFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动后余额" width="140" align="right">
|
||||
<template #default="{ row }">¥{{ fenToYuan(row.balanceAfterFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="220" />
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="empty-state" v-if="!recordsLoading && records.length === 0">暂无财务流水</div>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > pageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, total"
|
||||
@current-change="loadRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeDialogVisible" title="企业余额充值" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="充值金额(元)" required>
|
||||
<el-input-number v-model="rechargeForm.amountYuan" :min="0.01" :step="100" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="rechargeForm.remark" type="textarea" :rows="2" placeholder="可选,仅用于当前页提示,不会直接入账" />
|
||||
</el-form-item>
|
||||
<div v-if="rechargeQrcode" class="recharge-qrcode">
|
||||
<img :src="rechargeQrcode" alt="充值二维码" class="qrcode-image" />
|
||||
<p class="qrcode-tip">请使用微信扫码,在小程序内完成支付充值</p>
|
||||
<p class="qrcode-amount">本次充值:¥{{ rechargeForm.amountYuan.toFixed(2) }}</p>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rechargeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" color="#7c3aed" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const loading = ref(false)
|
||||
const recordsLoading = ref(false)
|
||||
const rechargeLoading = ref(false)
|
||||
const rechargeDialogVisible = ref(false)
|
||||
const rechargeQrcode = ref('')
|
||||
|
||||
const overview = reactive({
|
||||
enterpriseId: 0,
|
||||
enterpriseName: '',
|
||||
balanceFen: 0,
|
||||
todayIncomeFen: 0,
|
||||
monthIncomeFen: 0,
|
||||
totalIncomeFen: 0,
|
||||
manualRechargeFen: 0,
|
||||
frozenCommissionFen: 0,
|
||||
paidOrderCount: 0
|
||||
})
|
||||
|
||||
const records = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amountYuan: 100,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
const res: any = await request.get('/admin/finance/overview')
|
||||
Object.assign(overview, res.data || {})
|
||||
}
|
||||
|
||||
const loadRecords = async () => {
|
||||
recordsLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/finance/records', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
records.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
recordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadOverview()
|
||||
await loadRecords()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载企业财务数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRechargeDialog = () => {
|
||||
rechargeQrcode.value = ''
|
||||
rechargeDialogVisible.value = true
|
||||
}
|
||||
|
||||
const generateRechargeQrcode = async () => {
|
||||
if (!rechargeForm.amountYuan || rechargeForm.amountYuan <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
const amountFen = Math.round(Number(rechargeForm.amountYuan) * 100)
|
||||
if (amountFen <= 0) {
|
||||
ElMessage.warning('请输入正确的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/admin/finance/recharge-qrcode', {
|
||||
amountFen,
|
||||
remark: rechargeForm.remark
|
||||
})
|
||||
rechargeQrcode.value = res.data?.qrcode || ''
|
||||
ElMessage.success('充值码已生成')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '生成充值码失败')
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.finance-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #eef2f7;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-tip {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-in {
|
||||
color: #16a34a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-out {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px 0 8px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.recharge-qrcode {
|
||||
margin-top: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
margin: 12px 0 4px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qrcode-amount {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.finance-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.finance-page.is-embedded {
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
204
admin/src/views/admin/MiniprogramConfigPanel.vue
Normal file
204
admin/src/views/admin/MiniprogramConfigPanel.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="tab-content" v-loading="miniprogramLoading">
|
||||
<div class="content-header">
|
||||
<h3>小程序配置</h3>
|
||||
<p class="content-description">配置小程序名称及展示文案,将显示在小程序导航栏等位置</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>小程序名称</label>
|
||||
<el-input
|
||||
v-model="miniprogramConfig.miniprogramName"
|
||||
placeholder="用于小程序导航栏等展示"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-config-section">
|
||||
<div class="section-label">小程序文案配置</div>
|
||||
<p class="section-desc">以下文案将显示在小程序对应位置,留空则使用默认值</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>分析中提示</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.analyzingTitle" placeholder="默认:正在分析中" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(个人版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:开始面相测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(企业版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonEnterprise" placeholder="默认:开始面部测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>报告页标题</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.reportTitle" placeholder="默认:分析报告" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>智能分析文案</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.aiAnalysisText" placeholder="默认:智能分析" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
|
||||
保存小程序配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const miniprogramLoading = ref(false)
|
||||
const miniprogramConfig = reactive({
|
||||
miniprogramName: '神仙团队AI性格测试',
|
||||
textConfig: {
|
||||
analyzingTitle: '正在分析中',
|
||||
startButtonText: '开始面相测试',
|
||||
startButtonEnterprise: '开始面部测试',
|
||||
reportTitle: '分析报告',
|
||||
aiAnalysisText: '智能分析'
|
||||
}
|
||||
})
|
||||
|
||||
const loadMiniprogramConfig = async () => {
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/settings/miniprogram')
|
||||
if (res.code === 200 && res.data) {
|
||||
miniprogramConfig.miniprogramName = res.data.miniprogramName ?? '神仙团队AI性格测试'
|
||||
if (res.data.textConfig && typeof res.data.textConfig === 'object') {
|
||||
Object.assign(miniprogramConfig.textConfig, res.data.textConfig)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载小程序配置失败:', e)
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveMiniprogramConfig = async () => {
|
||||
if (!miniprogramConfig.miniprogramName?.trim()) {
|
||||
ElMessage.error('小程序名称不能为空')
|
||||
return
|
||||
}
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/settings/miniprogram', {
|
||||
miniprogramName: miniprogramConfig.miniprogramName.trim(),
|
||||
textConfig: miniprogramConfig.textConfig
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('小程序配置已保存')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMiniprogramConfig()
|
||||
})
|
||||
|
||||
defineExpose({ loadMiniprogramConfig })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.content-description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:deep(.el-input .el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-config-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.section-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
159
admin/src/views/admin/OrdersHub.vue
Normal file
159
admin/src/views/admin/OrdersHub.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="orders-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单运营</h2>
|
||||
<p class="hub-subtitle">订单成交、测试定价与题库在同一入口完成</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body flat">
|
||||
<Orders
|
||||
v-if="activeTab === 'orders'"
|
||||
embedded
|
||||
orders-api-path="/admin/orders"
|
||||
/>
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded />
|
||||
<Questions v-if="activeTab === 'questions'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from './Orders.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
import Questions from './Questions.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'pricing', 'questions'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('orders')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '订单列表', value: 'orders' },
|
||||
{ label: '价格设置', value: 'pricing' },
|
||||
{ label: '题库管理', value: 'questions' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/orders', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(() => route.query.tab, () => applyRouteTab())
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
padding: 8px 18px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body.flat {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -325,6 +325,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
@@ -531,7 +532,7 @@ const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL
|
||||
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
|
||||
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
|
||||
|
||||
@@ -1,363 +1,369 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>价格设置</h2>
|
||||
<p class="subtitle">分别配置个人版和企业版的测试价格</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', activeTab === tab.value ? 'active' : '']"
|
||||
@click="activeTab = tab.value"
|
||||
>{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-content">
|
||||
<div class="tab-content-card">
|
||||
|
||||
<!-- 个人版价格 -->
|
||||
<div v-if="activeTab === 'personal'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认定价,保存后将创建您的个人版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="savePersonal" :loading="loading">
|
||||
保存个人版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业版价格 -->
|
||||
<div v-if="activeTab === 'enterprise'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认企业定价,保存后将创建您的企业版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveEnterprise" :loading="loading">
|
||||
保存企业版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const tabs = [
|
||||
{ label: '个人版价格', value: 'personal' },
|
||||
{ label: '企业版价格', value: 'enterprise' },
|
||||
]
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
|
||||
const loading = ref(false)
|
||||
const isUsingSuperAdminPersonalConfig = ref(false)
|
||||
const isUsingSuperAdminEnterpriseConfig = ref(false)
|
||||
|
||||
const loadPricing = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/pricing')
|
||||
if (response.code === 200 && response.data) {
|
||||
if (response.data.personal) {
|
||||
Object.assign(personal, response.data.personal)
|
||||
}
|
||||
if (response.data.enterprise) {
|
||||
Object.assign(enterprise, response.data.enterprise)
|
||||
}
|
||||
isUsingSuperAdminPersonalConfig.value = response.data.isUsingSuperAdminPersonalConfig
|
||||
?? response.data.isUsingSuperAdminConfig
|
||||
?? false
|
||||
isUsingSuperAdminEnterpriseConfig.value = response.data.isUsingSuperAdminEnterpriseConfig ?? false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载定价配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const savePersonal = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', { personalConfig: personal })
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('个人版价格已保存')
|
||||
isUsingSuperAdminPersonalConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEnterprise = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', {
|
||||
enterpriseConfig: {
|
||||
face: enterprise.face,
|
||||
mbti: enterprise.mbti,
|
||||
disc: enterprise.disc,
|
||||
pdp: enterprise.pdp
|
||||
}
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('企业版价格已保存')
|
||||
isUsingSuperAdminEnterpriseConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPricing()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover { color: #111827; }
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.notice-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full { width: 100%; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content .form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="page-container" :class="{ 'is-embedded': embedded }" v-loading="loading">
|
||||
<div v-if="!embedded" class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>价格设置</h2>
|
||||
<p class="subtitle">分别配置个人版和企业版的测试价格</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', activeTab === tab.value ? 'active' : '']"
|
||||
@click="activeTab = tab.value"
|
||||
>{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-content">
|
||||
<div class="tab-content-card">
|
||||
|
||||
<!-- 个人版价格 -->
|
||||
<div v-if="activeTab === 'personal'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认定价,保存后将创建您的个人版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="savePersonal" :loading="loading">
|
||||
保存个人版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业版价格 -->
|
||||
<div v-if="activeTab === 'enterprise'" class="tab-content">
|
||||
<div class="form-section">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>人脸测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.face" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>MBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>DISC测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.disc" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>PDP测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
<span>当前使用超管默认企业定价,保存后将创建您的企业版专属配置</span>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveEnterprise" :loading="loading">
|
||||
保存企业版价格
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const tabs = [
|
||||
{ label: '个人版价格', value: 'personal' },
|
||||
{ label: '企业版价格', value: 'enterprise' },
|
||||
]
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0 })
|
||||
|
||||
const loading = ref(false)
|
||||
const isUsingSuperAdminPersonalConfig = ref(false)
|
||||
const isUsingSuperAdminEnterpriseConfig = ref(false)
|
||||
|
||||
const loadPricing = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/pricing')
|
||||
if (response.code === 200 && response.data) {
|
||||
if (response.data.personal) {
|
||||
Object.assign(personal, response.data.personal)
|
||||
}
|
||||
if (response.data.enterprise) {
|
||||
Object.assign(enterprise, response.data.enterprise)
|
||||
}
|
||||
isUsingSuperAdminPersonalConfig.value = response.data.isUsingSuperAdminPersonalConfig
|
||||
?? response.data.isUsingSuperAdminConfig
|
||||
?? false
|
||||
isUsingSuperAdminEnterpriseConfig.value = response.data.isUsingSuperAdminEnterpriseConfig ?? false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载定价配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const savePersonal = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', { personalConfig: personal })
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('个人版价格已保存')
|
||||
isUsingSuperAdminPersonalConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEnterprise = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/pricing', {
|
||||
enterpriseConfig: {
|
||||
face: enterprise.face,
|
||||
mbti: enterprise.mbti,
|
||||
disc: enterprise.disc,
|
||||
pdp: enterprise.pdp
|
||||
}
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('企业版价格已保存')
|
||||
isUsingSuperAdminEnterpriseConfig.value = false
|
||||
await loadPricing()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPricing()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover { color: #111827; }
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.notice-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full { width: 100%; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content .form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,543 +1,346 @@
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理管理员账号</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card" :class="{ 'no-pad': activeTab === 'poster' }">
|
||||
<!-- 小程序配置 -->
|
||||
<div v-if="activeTab === 'miniprogram'" class="tab-content" v-loading="miniprogramLoading">
|
||||
<div class="content-header">
|
||||
<h3>小程序配置</h3>
|
||||
<p class="content-description">配置小程序名称及展示文案,与超管共用全局配置,将显示在小程序导航栏等位置</p>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>小程序名称</label>
|
||||
<el-input
|
||||
v-model="miniprogramConfig.miniprogramName"
|
||||
placeholder="用于小程序导航栏等展示"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-config-section">
|
||||
<div class="section-label">小程序文案配置</div>
|
||||
<p class="section-desc">以下文案将显示在小程序对应位置,留空则使用默认值</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>分析中提示</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.analyzingTitle" placeholder="默认:正在分析中" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(个人版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:开始面相测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>开始按钮(企业版)</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.startButtonEnterprise" placeholder="默认:开始面部测试" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>报告页标题</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.reportTitle" placeholder="默认:分析报告" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>智能分析文案</label>
|
||||
<el-input v-model="miniprogramConfig.textConfig.aiAnalysisText" placeholder="默认:智能分析" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
|
||||
保存小程序配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 海报配置 -->
|
||||
<div v-if="activeTab === 'poster'" class="tab-content poster-tab">
|
||||
<PosterEditor />
|
||||
</div>
|
||||
|
||||
<!-- 账号设置 -->
|
||||
<div v-if="activeTab === 'account'" class="tab-content">
|
||||
<div class="content-header">
|
||||
<h3>管理员账号设置</h3>
|
||||
<p class="content-description">修改管理员账号和密码</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>管理员用户名</label>
|
||||
<el-input
|
||||
v-model="accountConfig.username"
|
||||
placeholder="输入管理员用户名"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label>当前密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.currentPassword"
|
||||
type="password"
|
||||
placeholder="输入当前密码(修改密码时必填)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.password"
|
||||
type="password"
|
||||
placeholder="输入新密码(不修改则留空)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>确认新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAccountSettings" :loading="loading">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>保存凭据</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
|
||||
const activeTab = ref('account')
|
||||
const loading = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ label: '账号设置', value: 'account' },
|
||||
{ label: '小程序配置', value: 'miniprogram' },
|
||||
{ label: '海报配置', value: 'poster' }
|
||||
]
|
||||
|
||||
// ── 小程序配置 ──
|
||||
const miniprogramLoading = ref(false)
|
||||
const miniprogramConfig = reactive({
|
||||
miniprogramName: '神仙团队AI性格测试',
|
||||
textConfig: {
|
||||
analyzingTitle: '正在分析中',
|
||||
startButtonText: '开始面相测试',
|
||||
startButtonEnterprise: '开始面部测试',
|
||||
reportTitle: '分析报告',
|
||||
aiAnalysisText: '智能分析'
|
||||
}
|
||||
})
|
||||
|
||||
const loadMiniprogramConfig = async () => {
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/settings/miniprogram')
|
||||
if (res.code === 200 && res.data) {
|
||||
miniprogramConfig.miniprogramName = res.data.miniprogramName ?? '神仙团队AI性格测试'
|
||||
if (res.data.textConfig && typeof res.data.textConfig === 'object') {
|
||||
Object.assign(miniprogramConfig.textConfig, res.data.textConfig)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载小程序配置失败:', e)
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveMiniprogramConfig = async () => {
|
||||
if (!miniprogramConfig.miniprogramName?.trim()) {
|
||||
ElMessage.error('小程序名称不能为空')
|
||||
return
|
||||
}
|
||||
miniprogramLoading.value = true
|
||||
try {
|
||||
const res: any = await request.put('/admin/settings/miniprogram', {
|
||||
miniprogramName: miniprogramConfig.miniprogramName.trim(),
|
||||
textConfig: miniprogramConfig.textConfig
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('小程序配置已保存')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
miniprogramLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 账号配置
|
||||
const accountConfig = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 加载当前用户信息
|
||||
const loadSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/settings')
|
||||
if (response.code === 200 && response.data) {
|
||||
accountConfig.username = response.data.username || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载设置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存账号设置
|
||||
const saveAccountSettings = async () => {
|
||||
if (!accountConfig.username) {
|
||||
ElMessage.error('用户名不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (accountConfig.password && accountConfig.password !== accountConfig.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/settings/credentials', {
|
||||
username: accountConfig.username,
|
||||
currentPassword: accountConfig.currentPassword,
|
||||
newPassword: accountConfig.password,
|
||||
confirmPassword: accountConfig.confirmPassword
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('账号设置已保存')
|
||||
accountConfig.password = ''
|
||||
accountConfig.currentPassword = ''
|
||||
accountConfig.confirmPassword = ''
|
||||
await loadSettings()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'miniprogram') loadMiniprogramConfig()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.no-pad {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-tab {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.content-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.content-description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.text-config-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
.section-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: #eff6ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.notice-icon {
|
||||
color: #3b82f6;
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="page-container" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理员账号与企业余额;小程序文案与海报请在「用户运营」中配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="selectTab(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }">
|
||||
<div v-if="activeTab === 'account'" class="tab-content">
|
||||
<div class="content-header">
|
||||
<h3>管理员账号设置</h3>
|
||||
<p class="content-description">修改管理员账号和密码</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label>管理员用户名</label>
|
||||
<el-input v-model="accountConfig.username" placeholder="输入管理员用户名" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>当前密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.currentPassword"
|
||||
type="password"
|
||||
placeholder="输入当前密码(修改密码时必填)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.password"
|
||||
type="password"
|
||||
placeholder="输入新密码(不修改则留空)"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>确认新密码</label>
|
||||
<el-input
|
||||
v-model="accountConfig.confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
show-password
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAccountSettings" :loading="loading">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>保存凭据</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'finance'" class="embed-wrap">
|
||||
<Finance embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Finance from './Finance.vue'
|
||||
|
||||
const TAB_IDS = ['account', 'finance'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('account')
|
||||
const loading = ref(false)
|
||||
|
||||
const tabs: { label: string; value: TabId }[] = [
|
||||
{ label: '账号设置', value: 'account' },
|
||||
{ label: '企业余额', value: 'finance' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'account'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'account') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/settings', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
const accountConfig = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
currentPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const loadSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/admin/settings')
|
||||
if (response.code === 200 && response.data) {
|
||||
accountConfig.username = response.data.username || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载设置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAccountSettings = async () => {
|
||||
if (!accountConfig.username) {
|
||||
ElMessage.error('用户名不能为空')
|
||||
return
|
||||
}
|
||||
if (accountConfig.password && accountConfig.password !== accountConfig.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.put('/admin/settings/credentials', {
|
||||
username: accountConfig.username,
|
||||
currentPassword: accountConfig.currentPassword,
|
||||
newPassword: accountConfig.password,
|
||||
confirmPassword: accountConfig.confirmPassword
|
||||
})
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('账号设置已保存')
|
||||
accountConfig.password = ''
|
||||
accountConfig.currentPassword = ''
|
||||
accountConfig.confirmPassword = ''
|
||||
await loadSettings()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.content-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.content-description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:deep(.el-input .el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.save-btn {
|
||||
height: 42px;
|
||||
padding: 0 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-content-card {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
200
admin/src/views/admin/UsersHub.vue
Normal file
200
admin/src/views/admin/UsersHub.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="users-hub">
|
||||
<div class="hub-header">
|
||||
<h2>用户运营</h2>
|
||||
<p class="hub-subtitle">测试用户数据、小程序展示文案与分销海报一站式维护</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hub-card"
|
||||
:class="{ 'no-pad': activeTab === 'poster', 'flat-embed': activeTab === 'users' }"
|
||||
>
|
||||
<Users v-if="activeTab === 'users'" embedded />
|
||||
<MiniprogramConfigPanel v-if="activeTab === 'miniprogram'" ref="miniRef" />
|
||||
<div v-if="activeTab === 'poster'" class="poster-wrap">
|
||||
<PosterEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Users from './Users.vue'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
import MiniprogramConfigPanel from './MiniprogramConfigPanel.vue'
|
||||
|
||||
const TAB_IDS = ['users', 'miniprogram', 'poster'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('users')
|
||||
const miniRef = ref<InstanceType<typeof MiniprogramConfigPanel> | null>(null)
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '用户列表', value: 'users' },
|
||||
{ label: '小程序配置', value: 'miniprogram' },
|
||||
{ label: '海报配置', value: 'poster' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'users'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'users') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/admin/users', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
applyRouteTab()
|
||||
if (activeTab.value === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
if (activeTab.value === 'miniprogram') {
|
||||
miniRef.value?.loadMiniprogramConfig?.()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.no-pad {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-wrap {
|
||||
min-height: 720px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
163
admin/src/views/superadmin/CommerceHub.vue
Normal file
163
admin/src/views/superadmin/CommerceHub.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="commerce-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单和财务</h2>
|
||||
<p class="hub-subtitle">
|
||||
监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body">
|
||||
<Orders
|
||||
v-if="activeTab === 'orders'"
|
||||
embedded
|
||||
orders-api-path="/superadmin/orders"
|
||||
/>
|
||||
<Finance v-if="activeTab === 'finance'" embedded />
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded enterprise-procurement-only />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from '@/views/admin/Orders.vue'
|
||||
import Finance from './Finance.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'finance', 'pricing'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('orders')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '订单列表', value: 'orders' },
|
||||
{ label: '财务看板', value: 'finance' },
|
||||
{ label: '全局定价', value: 'pricing' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'orders'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/commerce', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.commerce-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,483 +1,493 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>数据库管理</h2>
|
||||
<p class="subtitle">管理数据库连接、备份和恢复</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" color="#ef4444" @click="handleBackup">
|
||||
<el-icon class="mr-1"><DocumentCopy /></el-icon>备份数据库
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库信息 -->
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库类型</div>
|
||||
<div class="info-value">{{ dbInfo.databaseType }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">连接状态</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="dbInfo.connected ? 'success' : 'danger'" size="small">
|
||||
{{ dbInfo.connected ? '已连接' : '未连接' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库大小</div>
|
||||
<div class="info-value">{{ formatSize(dbInfo.databaseSize * 1024 * 1024) }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">表数量</div>
|
||||
<div class="info-value">{{ dbInfo.tableCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 集合列表 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>数据库集合</h3>
|
||||
<el-input
|
||||
v-model="searchTerm"
|
||||
placeholder="搜索集合名称..."
|
||||
clearable
|
||||
class="search-input"
|
||||
style="max-width: 300px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="filteredCollections"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
class="custom-table"
|
||||
v-if="filteredCollections.length > 0 && !loading"
|
||||
>
|
||||
<el-table-column label="集合名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="collection-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文档数量" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="doc-count">{{ row.docCount.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="索引数" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="index-count">{{ row.indexCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
|
||||
<el-button link @click="handleExport(row)"><el-icon><Download /></el-icon></el-button>
|
||||
<el-button link type="danger" @click="handleClear(row)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 空数据占位图 -->
|
||||
<div v-else-if="!loading" class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><DocumentCopy /></el-icon>
|
||||
<p class="empty-text">暂无数据表</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备份记录 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>备份记录</h3>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="backups"
|
||||
style="width: 100%"
|
||||
class="custom-table"
|
||||
v-if="backups.length > 0"
|
||||
>
|
||||
<el-table-column label="备份时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-time">{{ formatTime(row.time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备份大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleRestore(row)"><el-icon><RefreshLeft /></el-icon>恢复</el-button>
|
||||
<el-button link @click="handleDownload(row)"><el-icon><Download /></el-icon>下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { DocumentCopy, Search, View, Download, Delete, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const dbInfo = ref({
|
||||
databaseType: 'MySQL',
|
||||
databaseName: '',
|
||||
connected: false,
|
||||
databaseSize: 0,
|
||||
tableCount: 0
|
||||
})
|
||||
|
||||
const collections = ref([])
|
||||
const backups = ref([])
|
||||
|
||||
const filteredCollections = computed(() => {
|
||||
if (!searchTerm.value) {
|
||||
return collections.value
|
||||
}
|
||||
return collections.value.filter((item: any) =>
|
||||
item.name.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 加载数据库信息
|
||||
const loadDbInfo = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/info')
|
||||
if (response.code === 200 && response.data) {
|
||||
dbInfo.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据库信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载表列表
|
||||
const loadTables = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/tables')
|
||||
if (response.code === 200 && response.data) {
|
||||
collections.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载表列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载备份记录
|
||||
const loadBackups = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/backups')
|
||||
if (response.code === 200 && response.data) {
|
||||
backups.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载备份记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要备份数据库吗?', '备份数据库', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/backup')
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库备份成功')
|
||||
await loadBackups()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '备份失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async (row: any) => {
|
||||
ElMessage.info('查看表: ' + row.name + ' (功能开发中)')
|
||||
}
|
||||
|
||||
const handleExport = async (row: any) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/export-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 下载文件
|
||||
window.open(response.data.downloadUrl, '_blank')
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '导出失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空表 "${row.name}" 吗?此操作不可恢复!`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/clear-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('表数据已清空')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '清空失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要恢复此备份吗?当前数据将被覆盖!', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/restore', {
|
||||
file: row.filename
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库恢复成功')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '恢复失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (row: any) => {
|
||||
window.open(`/api/v1/superadmin/database/download?file=${encodeURIComponent(row.filename)}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDbInfo()
|
||||
loadTables()
|
||||
loadBackups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name, .doc-count, .size, .index-count, .backup-time, .backup-size {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.el-button {
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.el-button--danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="page-container" :class="{ 'is-embedded': embedded }">
|
||||
<div class="page-header" :class="{ 'header-embedded': embedded }">
|
||||
<div v-if="!embedded" class="header-left">
|
||||
<h2>数据库管理</h2>
|
||||
<p class="subtitle">管理数据库连接、备份和恢复</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" color="#ef4444" @click="handleBackup">
|
||||
<el-icon class="mr-1"><DocumentCopy /></el-icon>备份数据库
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库信息 -->
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库类型</div>
|
||||
<div class="info-value">{{ dbInfo.databaseType }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">连接状态</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="dbInfo.connected ? 'success' : 'danger'" size="small">
|
||||
{{ dbInfo.connected ? '已连接' : '未连接' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">数据库大小</div>
|
||||
<div class="info-value">{{ formatSize(dbInfo.databaseSize * 1024 * 1024) }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">表数量</div>
|
||||
<div class="info-value">{{ dbInfo.tableCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 集合列表 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>数据库集合</h3>
|
||||
<el-input
|
||||
v-model="searchTerm"
|
||||
placeholder="搜索集合名称..."
|
||||
clearable
|
||||
class="search-input"
|
||||
style="max-width: 300px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="filteredCollections"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
class="custom-table"
|
||||
v-if="filteredCollections.length > 0 && !loading"
|
||||
>
|
||||
<el-table-column label="集合名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="collection-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文档数量" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="doc-count">{{ row.docCount.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="索引数" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="index-count">{{ row.indexCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
|
||||
<el-button link @click="handleExport(row)"><el-icon><Download /></el-icon></el-button>
|
||||
<el-button link type="danger" @click="handleClear(row)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 空数据占位图 -->
|
||||
<div v-else-if="!loading" class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><DocumentCopy /></el-icon>
|
||||
<p class="empty-text">暂无数据表</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备份记录 -->
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<h3>备份记录</h3>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="backups"
|
||||
style="width: 100%"
|
||||
class="custom-table"
|
||||
v-if="backups.length > 0"
|
||||
>
|
||||
<el-table-column label="备份时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-time">{{ formatTime(row.time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备份大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="backup-size">{{ formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link @click="handleRestore(row)"><el-icon><RefreshLeft /></el-icon>恢复</el-button>
|
||||
<el-button link @click="handleDownload(row)"><el-icon><Download /></el-icon>下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { DocumentCopy, Search, View, Download, Delete, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const dbInfo = ref({
|
||||
databaseType: 'MySQL',
|
||||
databaseName: '',
|
||||
connected: false,
|
||||
databaseSize: 0,
|
||||
tableCount: 0
|
||||
})
|
||||
|
||||
const collections = ref([])
|
||||
const backups = ref([])
|
||||
|
||||
const filteredCollections = computed(() => {
|
||||
if (!searchTerm.value) {
|
||||
return collections.value
|
||||
}
|
||||
return collections.value.filter((item: any) =>
|
||||
item.name.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 加载数据库信息
|
||||
const loadDbInfo = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/info')
|
||||
if (response.code === 200 && response.data) {
|
||||
dbInfo.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据库信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载表列表
|
||||
const loadTables = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/tables')
|
||||
if (response.code === 200 && response.data) {
|
||||
collections.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载表列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载备份记录
|
||||
const loadBackups = async () => {
|
||||
try {
|
||||
const response: any = await request.get('/superadmin/database/backups')
|
||||
if (response.code === 200 && response.data) {
|
||||
backups.value = response.data
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载备份记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要备份数据库吗?', '备份数据库', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/backup')
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库备份成功')
|
||||
await loadBackups()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '备份失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async (row: any) => {
|
||||
ElMessage.info('查看表: ' + row.name + ' (功能开发中)')
|
||||
}
|
||||
|
||||
const handleExport = async (row: any) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/export-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 下载文件
|
||||
window.open(response.data.downloadUrl, '_blank')
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '导出失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空表 "${row.name}" 吗?此操作不可恢复!`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/clear-table', {
|
||||
table: row.name
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('表数据已清空')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '清空失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要恢复此备份吗?当前数据将被覆盖!', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
loading.value = true
|
||||
const response: any = await request.post('/superadmin/database/restore', {
|
||||
file: row.filename
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('数据库恢复成功')
|
||||
await loadTables()
|
||||
await loadDbInfo()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '恢复失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (row: any) => {
|
||||
window.open(`/api/v1/superadmin/database/download?file=${encodeURIComponent(row.filename)}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDbInfo()
|
||||
loadTables()
|
||||
loadBackups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f3f4f6;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name, .doc-count, .size, .index-count, .backup-time, .backup-size {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.el-button {
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.el-button--danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.page-header.header-embedded {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
160
admin/src/views/superadmin/DistributionStandalone.vue
Normal file
160
admin/src/views/superadmin/DistributionStandalone.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="distribution-hub">
|
||||
<div class="hub-header">
|
||||
<h2>分销管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-body">
|
||||
<Distribution v-if="activeTab === 'distribution'" embedded />
|
||||
<MpAnalytics v-if="activeTab === 'analytics'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Distribution from './Distribution.vue'
|
||||
import MpAnalytics from './MpAnalytics.vue'
|
||||
|
||||
const TAB_IDS = ['distribution', 'analytics'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('distribution')
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '分销推广', value: 'distribution' },
|
||||
{ label: '小程序埋点', value: 'analytics' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'distribution'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'distribution') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({
|
||||
path: '/superadmin/distribution',
|
||||
query: Object.keys(q).length ? q : {}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.distribution-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
220
admin/src/views/superadmin/EnterpriseHub.vue
Normal file
220
admin/src/views/superadmin/EnterpriseHub.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="enterprise-hub">
|
||||
<div class="hub-header">
|
||||
<h2>企业管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="activeTab === 'users'"
|
||||
type="info"
|
||||
class="migrate-alert"
|
||||
show-icon
|
||||
:closable="false"
|
||||
>
|
||||
<template #title>数据库归并(可选)</template>
|
||||
<p class="migrate-text">
|
||||
若需把库里的「无 enterpriseId」记录<strong>永久写入</strong>到「存客宝」企业,可点下方按钮(先预览条数,再二次确认)。仅统计展示时不必执行,接口已把个人池并入存客宝卡片。
|
||||
</p>
|
||||
<el-button type="primary" plain size="small" :loading="migrateLoading" @click="runOrphanMigrate">
|
||||
预览并归并到存客宝
|
||||
</el-button>
|
||||
</el-alert>
|
||||
|
||||
<div class="hub-body">
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('companies')
|
||||
const migrateLoading = ref(false)
|
||||
const usersRefreshKey = ref(0)
|
||||
|
||||
async function runOrphanMigrate() {
|
||||
migrateLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
|
||||
dryRun: true
|
||||
})
|
||||
const d = res.data ?? res
|
||||
const name = d.enterpriseName || '存客宝'
|
||||
const tr = d.testResultsRows ?? 0
|
||||
const wu = d.wechatUsersRows ?? 0
|
||||
await ElMessageBox.confirm(
|
||||
`将把无企业归属的数据写入「${name}」:测试记录约 ${tr} 条,小程序用户表约 ${wu} 行。此操作会修改数据库,是否继续?`,
|
||||
'确认归并',
|
||||
{ type: 'warning', confirmButtonText: '写入', cancelButtonText: '取消' }
|
||||
)
|
||||
await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
|
||||
dryRun: false,
|
||||
confirm: true
|
||||
})
|
||||
ElMessage.success('归并完成,已刷新用户总览')
|
||||
usersRefreshKey.value += 1
|
||||
} catch (e: unknown) {
|
||||
if (e === 'cancel') return
|
||||
const msg = e instanceof Error ? e.message : '操作失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
migrateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'companies'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'companies') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/enterprises', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.enterprise-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.migrate-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.migrate-text {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,254 +1,254 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h2 class="title">超级管理员登录</h2>
|
||||
<p class="description">请输入您的超级管理员凭据</p>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</el-alert>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@submit.prevent="handleLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<label class="form-label">用户名</label>
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="输入超级管理员用户名"
|
||||
size="large"
|
||||
clearable
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<label class="form-label">密码</label>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="输入超级管理员密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="security-info">
|
||||
<el-icon class="security-icon"><Lock /></el-icon>
|
||||
<span>安全连接 | 仅限授权人员访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const success = await authStore.superAdminLogin(loginForm.username, loginForm.password)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/superadmin/overview')
|
||||
} else {
|
||||
errorMessage.value = '用户名或密码错误'
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('表单验证失败:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 448px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 12px 16px;
|
||||
background-color: #f9fafb;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #5b21b6;
|
||||
border-color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
.security-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
gap: 6px;
|
||||
|
||||
.security-icon {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<h2 class="title">超级管理员登录</h2>
|
||||
<p class="description">请输入您的超级管理员凭据</p>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</el-alert>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
@submit.prevent="handleLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<label class="form-label">用户名</label>
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="输入超级管理员用户名"
|
||||
size="large"
|
||||
clearable
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<label class="form-label">密码</label>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="输入超级管理员密码"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="security-info">
|
||||
<el-icon class="security-icon"><Lock /></el-icon>
|
||||
<span>安全连接 | 仅限授权人员访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const success = await authStore.superAdminLogin(loginForm.username, loginForm.password)
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/superadmin/ops')
|
||||
} else {
|
||||
errorMessage.value = '用户名或密码错误'
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || error?.response?.data?.message || '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('表单验证失败:', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 448px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #e5e7eb inset;
|
||||
padding: 12px 16px;
|
||||
background-color: #f9fafb;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px #7c3aed inset, 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px #d1d5db inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
color: #111827;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
background-color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #6d28d9;
|
||||
border-color: #6d28d9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #5b21b6;
|
||||
border-color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
.security-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
gap: 6px;
|
||||
|
||||
.security-icon {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
230
admin/src/views/superadmin/MpAnalytics.vue
Normal file
230
admin/src/views/superadmin/MpAnalytics.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="mp-analytics" :class="{ 'is-embedded': embedded }">
|
||||
<el-alert
|
||||
v-if="tableMissing"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="setup-alert"
|
||||
title="埋点数据表未就绪"
|
||||
description="请在数据库执行 api/database/migrations/add_mp_analytics_events.sql(表前缀需与 .env 中 DATABASE_PREFIX 一致),执行后刷新本页。"
|
||||
/>
|
||||
<div class="page-head" v-if="!embedded">
|
||||
<h2>小程序埋点</h2>
|
||||
<p class="sub">仅超级管理员可见;统计来自小程序上报的 page_view 与业务点击事件。</p>
|
||||
<div class="toolbar">
|
||||
<span class="label">统计范围</span>
|
||||
<el-select v-model="days" style="width: 120px" @change="loadAll">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="14" label="近 14 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="embedded-toolbar">
|
||||
<span class="hint">监督各端上报行为;page_view 与业务点击</span>
|
||||
<div class="toolbar">
|
||||
<span class="label">统计范围</span>
|
||||
<el-select v-model="days" style="width: 120px" @change="loadAll">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="14" label="近 14 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>事件汇总(共 {{ summaryTotal }} 条记录)</span>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="summaryList"
|
||||
stripe
|
||||
:empty-text="tableMissing ? '表未创建,见上方说明' : '暂无数据(小程序有访问后会出现统计)'"
|
||||
>
|
||||
<el-table-column prop="eventName" label="事件名" min-width="200" />
|
||||
<el-table-column prop="cnt" label="次数" width="120" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>最近明细</span>
|
||||
</template>
|
||||
<el-table v-loading="loadingEvents" :data="eventRows" stripe empty-text="暂无明细">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column prop="eventName" label="事件" min-width="140" />
|
||||
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column label="附加" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="props-preview">{{ formatProps(row.props) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="eventTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadEvents"
|
||||
@size-change="loadEvents"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const days = ref(7)
|
||||
const tableMissing = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingEvents = ref(false)
|
||||
const summaryList = ref<{ eventName: string; cnt: number }[]>([])
|
||||
const summaryTotal = ref(0)
|
||||
const eventRows = ref<any[]>([])
|
||||
const eventTotal = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
function formatProps(p: unknown): string {
|
||||
if (p == null) return '—'
|
||||
try {
|
||||
const s = JSON.stringify(p)
|
||||
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/summary', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
summaryList.value = res.data?.list || []
|
||||
summaryTotal.value = res.data?.total ?? 0
|
||||
tableMissing.value = !!res.data?.tableMissing
|
||||
} catch {
|
||||
summaryList.value = []
|
||||
summaryTotal.value = 0
|
||||
tableMissing.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/events', {
|
||||
params: {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
eventRows.value = res.data?.list || []
|
||||
eventTotal.value = res.data?.total ?? 0
|
||||
if (res.data?.tableMissing) {
|
||||
tableMissing.value = true
|
||||
}
|
||||
} catch {
|
||||
eventRows.value = []
|
||||
eventTotal.value = 0
|
||||
tableMissing.value = true
|
||||
} finally {
|
||||
loadingEvents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
page.value = 1
|
||||
await loadSummary()
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setup-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mp-analytics {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.mp-analytics.is-embedded {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 20px;
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.sub {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
.card-block {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.props-preview {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
46
admin/src/views/superadmin/OpsHub.vue
Normal file
46
admin/src/views/superadmin/OpsHub.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="ops-hub">
|
||||
<div class="hub-header">
|
||||
<h2>总览</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级数据总览,用于对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hub-body">
|
||||
<Overview />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Overview from './Overview.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ops-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
|
||||
import axios from 'axios'
|
||||
|
||||
// ────── 类型 ──────
|
||||
@@ -534,7 +535,7 @@ const moveLayer = (id: string, dir: -1 | 1) => {
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const token = localStorage.getItem('authToken')
|
||||
const token = getBearerTokenForCurrentApp()
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL
|
||||
? (import.meta.env.VITE_API_BASE_URL.endsWith('/')
|
||||
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">管理系统配置、API密钥和管理员账户</p>
|
||||
<p class="subtitle">
|
||||
审核与站点、题库、数据库及超管账户等平台级配置(全局定价在「订单和财务」)。此处变更会间接影响各企业管理后台行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,13 +22,13 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="custom-tabs-container">
|
||||
<div class="custom-tabs">
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab-item', { active: activeTab === tab.value }]"
|
||||
@click="activeTab = tab.value"
|
||||
@click="selectTab(tab.value)"
|
||||
>
|
||||
<el-icon class="tab-icon"><component :is="tab.icon" /></el-icon>
|
||||
{{ tab.label }}
|
||||
@@ -34,7 +36,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content-card">
|
||||
<div
|
||||
class="tab-content-card"
|
||||
:class="{ 'flat-embed': isFlatEmbed }"
|
||||
>
|
||||
<!-- 审核模式 -->
|
||||
<div v-if="activeTab === 'review'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
@@ -464,30 +469,106 @@
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'questions'" class="embed-wrap">
|
||||
<Questions embedded />
|
||||
</div>
|
||||
<div v-if="activeTab === 'database'" class="embed-wrap">
|
||||
<Database embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Setting, Bell, Lock, Document, ChatDotRound, Postcard } from '@element-plus/icons-vue'
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Setting,
|
||||
Bell,
|
||||
Lock,
|
||||
Document,
|
||||
ChatDotRound,
|
||||
Postcard,
|
||||
DataLine,
|
||||
Reading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
import Questions from './Questions.vue'
|
||||
import Database from './Database.vue'
|
||||
|
||||
const activeTab = ref('review')
|
||||
const TAB_IDS = [
|
||||
'review',
|
||||
'system',
|
||||
'notification',
|
||||
'prompts',
|
||||
'poster',
|
||||
'security',
|
||||
'questions',
|
||||
'database'
|
||||
] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<TabId>('review')
|
||||
const saveSuccess = ref<string | null>(null)
|
||||
|
||||
const tabs = [
|
||||
const tabs: { label: string; value: TabId; icon: any }[] = [
|
||||
{ label: '审核模式', value: 'review', icon: Document },
|
||||
{ label: '系统配置', value: 'system', icon: Setting },
|
||||
{ label: '通知设置', value: 'notification', icon: Bell },
|
||||
{ label: '提示词配置', value: 'prompts', icon: ChatDotRound },
|
||||
{ label: '海报配置', value: 'poster', icon: Postcard },
|
||||
{ label: '账户安全', value: 'security', icon: Lock }
|
||||
{ label: '账户安全', value: 'security', icon: Lock },
|
||||
{ label: '题库管理', value: 'questions', icon: Reading },
|
||||
{ label: '数据库', value: 'database', icon: DataLine }
|
||||
]
|
||||
|
||||
const isFlatEmbed = computed(
|
||||
() => activeTab.value === 'questions' || activeTab.value === 'database'
|
||||
)
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
if (t === 'pricing') {
|
||||
router.replace({ path: '/superadmin/commerce', query: { tab: 'pricing' } })
|
||||
return
|
||||
}
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
activeTab.value = t
|
||||
} else {
|
||||
activeTab.value = 'review'
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'review') {
|
||||
q.tab = tab
|
||||
}
|
||||
router.replace({ path: '/superadmin/settings', query: Object.keys(q).length ? q : {} })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => applyRouteTab()
|
||||
)
|
||||
|
||||
// 审核模式
|
||||
const reviewMode = reactive({
|
||||
enabled: false
|
||||
@@ -603,6 +684,7 @@ async function loadEnterpriseOptions() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteTab()
|
||||
loadSettings()
|
||||
loadEnterpriseOptions()
|
||||
})
|
||||
@@ -739,10 +821,22 @@ const handleSave = async (section: string) => {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
@@ -783,6 +877,17 @@ const handleSave = async (section: string) => {
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 32px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.flat-embed {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
325
api/app/common/service/FeishuLeadWebhookService.php
Normal file
325
api/app/common/service/FeishuLeadWebhookService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,358 +1,261 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 测试用户(小程序用户)管理 - 只读列表与详情
|
||||
* 数据来源:wechat_users,测试记录来自 test_results(userId 关联 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_results(userId 关联 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// 使用企业题库
|
||||
|
||||
@@ -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_config(enterprise_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_config(enterprise_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
api/app/controller/admin/concern/ExtractsTestResults.php
Normal file
101
api/app/controller/admin/concern/ExtractsTestResults.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
81
api/app/controller/api/Analytics.php
Normal file
81
api/app/controller/api/Analytics.php
Normal 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
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
api/app/controller/superadmin/Analytics.php
Normal file
100
api/app/controller/superadmin/Analytics.php
Normal 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
393
api/app/controller/superadmin/DataMigration.php
Normal file
393
api/app/controller/superadmin/DataMigration.php
Normal 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, '归并完成');
|
||||
}
|
||||
}
|
||||
@@ -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, '操作成功');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
11
api/database/migrations/add_feishu_lead_webhook.sql
Normal file
11
api/database/migrations/add_feishu_lead_webhook.sql
Normal 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`);
|
||||
15
api/database/migrations/add_mp_analytics_events.sql
Normal file
15
api/database/migrations/add_mp_analytics_events.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 小程序埋点表(与 mbti_ 表前缀一致;若 .env 中 DATABASE_PREFIX 不同,请同步改表名)
|
||||
-- ThinkPHP:Db::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
7
api/public/router.php
Normal 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';
|
||||
@@ -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']);
|
||||
|
||||
// ==================== 兼容旧版路由(保留,逐步废弃)====================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/index/camera",
|
||||
"text": "查看报告",
|
||||
"text": "拍摄",
|
||||
"iconPath": "images/camera.png",
|
||||
"selectedIconPath": "images/camera-active.png"
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "拍照",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {}
|
||||
}
|
||||
{
|
||||
"navigationBarTitleText": "拍摄",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
// 上传照片(个人版入口:强制本次链路为个人定价)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }) },
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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] || {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
73
douyin-miniprogram/utils/analytics.js
Normal file
73
douyin-miniprogram/utils/analytics.js
Normal 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
@@ -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 解析出的企业ID(e_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 {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/index/camera",
|
||||
"text": "查看报告",
|
||||
"text": "拍摄",
|
||||
"iconPath": "images/camera.png",
|
||||
"selectedIconPath": "images/camera-active.png"
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "拍照",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {}
|
||||
}
|
||||
{
|
||||
"navigationBarTitleText": "拍摄",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"custom-tab-bar": "/custom-tab-bar/index"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
// 上传照片(个人版入口:强制本次链路为个人定价)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user