feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

3
.gitignore vendored
View File

@@ -11,12 +11,15 @@ build/
.env
.env.local
.env.*.local
.env.local.merge
.env.bak.*
# 日志与运行时
*.log
api/runtime/log/
api/runtime/cache/
api/runtime/temp/
api/runtime/dev-*.pid
# IDE
.idea/

View File

@@ -31,14 +31,44 @@ git push origin main
## 本地开发(管理后台 + API
1. 启动 API需 PHP 8+,示例用 Homebrew 的 8.4
### 推荐 A后台常驻关终端 / 关 Cursor 仍可用)
在仓库根目录执行(自动释放 **8787**、**5173**,起 PHP + Vite**nohup 后台**,并做接口自检):
```bash
bash scripts/dev_stop.sh # 可选:先停旧进程
bash scripts/dev_start_daemon.sh
# 或 admin 目录npm run dev:daemon
```
浏览器:**http://127.0.0.1:5173/admin/login**
日志:`api/runtime/php-dev-server.log``api/runtime/vite-dev-server.log`
自检:`bash scripts/dev_health.sh`(或 `npm run dev:health`
停止:`bash scripts/dev_stop.sh`
### 推荐 B前台一键调试用Ctrl+C 即停)
```bash
bash scripts/dev_start.sh
# 或 admin 目录npm run dev:all
```
(请保持 `admin/.env.development``VITE_API_BASE_URL` **留空**,走 Vite 代理。)
可选环境变量:`MBTI_PHP_BIN``MBTI_API_PORT``MBTI_ADMIN_PORT``MBTI_API_CURL_TIMEOUT`(守护进程等 API 的 curl 秒数,默认 120
前端开发环境 axios 默认 **180s** 超时(云库慢);可在 `admin/.env.development``VITE_REQUEST_TIMEOUT_MS`
Vite 代理默认 **180s**`VITE_PROXY_TIMEOUT_MS` 可改)。
### 手动分步(与旧流程一致)
1. 启动 API需 PHP 8+
```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
2. 启动管理后台:
```bash
cd admin
@@ -46,5 +76,13 @@ npm install
npm run dev
```
浏览器打开 `http://localhost:5173` 接口经 Vite 转发到 `http://127.0.0.1:8787`。若 API 端口不是 8787可执行
接口经 Vite 转发到 `http://127.0.0.1:8787`。若 API 端口不是 8787可执行
`VITE_DEV_API_PROXY=http://127.0.0.1:端口 npm run dev`
### 从 kr 宝塔同步 `.env`
1. 在宝塔站点目录下载或复制线上 `api/.env` 到本机(任意路径)。
2. 执行:`python3 scripts/apply_bt_download_env.py /你下载的路径/.env`
- 会自动备份当前 `api/.env``api/.env.bak.<时间戳>`
3. 若宝塔里数据库是 `127.0.0.1`(指服务器本机),本机需隧道或改连云库:可复制 `api/.env.local.merge.example``api/.env.local.merge`,按注释写好覆盖项,再重新执行第 2 步(会追加合并)。
4. **管理端**请保持 `admin/.env.development` 里 `VITE_API_BASE_URL` **留空**,否则浏览器会直连 `mbti.com` 等域名,易出现 **Network Error**

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -209,6 +209,42 @@
</div>
<div v-else class="ud-empty-hint">暂无人脸分析照片</div>
</el-tab-pane>
<el-tab-pane label="用户旅程" name="journey">
<div class="ud-journey">
<div class="ud-journey__toolbar">
<span class="ud-journey__label">统计范围</span>
<el-select v-model="journeyDays" style="width: 120px" @change="loadJourney">
<el-option :value="7" label="近 7 天" />
<el-option :value="30" label="近 30 天" />
<el-option :value="90" label="近 90 天" />
</el-select>
<el-button size="small" :loading="journeyLoading" @click="loadJourney">刷新</el-button>
</div>
<el-table
v-loading="journeyLoading"
:data="journeyRows"
stripe
size="small"
max-height="420"
empty-text="暂无行为记录"
>
<el-table-column prop="createdAt" label="时间" width="160" />
<el-table-column label="事件" min-width="220">
<template #default="{ row }">
<strong>{{ row.eventNameCn || row.eventName }}</strong>
<div class="ud-journey__subname">{{ row.eventName }}</div>
</template>
</el-table-column>
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
<el-table-column label="附加" min-width="220">
<template #default="{ row }">
<span class="ud-journey__props">{{ formatJourneyProps(row.props) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</main>
</template>
@@ -249,6 +285,7 @@ import VChart from 'vue-echarts'
import { discTopTwoLabel, discCompactLabel } from '@/utils/discDisplay'
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
import { SBTI_RADAR_DIMENSION_ORDER, buildSbtiRadarValues, formatSbtiSummary } from '@/utils/sbtiDisplay'
import { request } from '@/utils/request'
use([CanvasRenderer, RadarChart, GridComponent, TooltipComponent, LegendComponent, RadarComponent])
@@ -271,12 +308,17 @@ const udTab = ref('analysis')
const testPage = ref(1)
const testPageSize = 8
const journeyDays = ref(30)
const journeyLoading = ref(false)
const journeyRows = ref<any[]>([])
watch(
() => props.modelValue,
v => {
if (v) {
udTab.value = 'analysis'
testPage.value = 1
journeyRows.value = []
}
}
)
@@ -285,9 +327,45 @@ watch(
() => props.user?.id,
() => {
testPage.value = 1
journeyRows.value = []
}
)
watch(udTab, (t) => {
if (t === 'journey' && journeyRows.value.length === 0) {
void loadJourney()
}
})
async function loadJourney() {
const uid = props.user?.id
if (!uid) {
journeyRows.value = []
return
}
journeyLoading.value = true
try {
const res: any = await request.get('/superadmin/analytics/user-journey', {
params: { userId: uid, days: journeyDays.value }
})
journeyRows.value = res.data?.list || []
} catch {
journeyRows.value = []
} finally {
journeyLoading.value = false
}
}
function formatJourneyProps(p: unknown): string {
if (p == null) return '—'
try {
const s = JSON.stringify(p)
return s.length > 160 ? s.slice(0, 160) + '…' : s
} catch {
return '—'
}
}
const avatarLetter = computed(() => {
const n = (props.user?.username || props.user?.nickname || '?').trim()
return (n.charAt(0) || '?').toUpperCase()
@@ -1096,6 +1174,35 @@ function openMail(email: string) {
border-radius: 8px;
}
.ud-journey {
display: flex;
flex-direction: column;
gap: 12px;
}
.ud-journey__toolbar {
display: flex;
align-items: center;
gap: 10px;
}
.ud-journey__label {
font-size: 12px;
color: #6b7280;
}
.ud-journey__subname {
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.ud-journey__props {
font-size: 12px;
color: #64748b;
word-break: break-all;
}
@media (max-width: 900px) {
.ud-layout {
flex-direction: column;

View File

@@ -0,0 +1,24 @@
<template>
<div class="sa-page-head" :class="{ 'sa-page-head-row': !!$slots.actions }">
<div class="sa-head-main">
<h2 class="sa-title">
<slot name="title">{{ title }}</slot>
<span v-if="badge" class="sa-title-badge">{{ badge }}</span>
</h2>
<p v-if="subtitle || $slots.subtitle" class="sa-subtitle">
<slot name="subtitle">{{ subtitle }}</slot>
</p>
</div>
<div v-if="$slots.actions" class="sa-head-actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
badge?: string
}>()
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="sa-tabs" role="tablist">
<div
v-for="item in items"
:key="String(item.value)"
role="tab"
:class="['sa-tab-item', { 'is-active': modelValue === item.value }]"
@click="onSelect(item.value)"
>
<span v-if="withDot" class="sa-tab-dot" />
{{ item.label }}
<span v-if="item.badge != null" class="sa-tab-badge">{{ item.badge }}</span>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends string | number">
interface SaTabItem<V> {
label: string
value: V
badge?: string | number
}
const props = defineProps<{
modelValue: T
items: SaTabItem<T>[]
withDot?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: T): void
(e: 'change', v: T): void
}>()
function onSelect(v: T) {
if (v === props.modelValue) return
emit('update:modelValue', v)
emit('change', v)
}
</script>
<style scoped>
.sa-tab-badge {
margin-left: 6px;
padding: 1px 8px;
font-size: 11px;
font-weight: 500;
line-height: 1.5;
background: #f1f5f9;
color: #64748b;
border-radius: 999px;
vertical-align: middle;
}
.sa-tab-item.is-active .sa-tab-badge {
background: var(--sa-primary-soft, #eef2ff);
color: var(--sa-primary, #6366f1);
}
</style>

View File

@@ -86,11 +86,11 @@ const authStore = useAuthStore()
const sidebarOpen = ref(false)
const navItems = [
{ path: '/admin/dashboard', icon: DataLine, label: '概览' },
{ 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: '系统设置' },
{ path: '/admin/settings', icon: Setting, label: '企业设置' },
]
const isActive = (path: string) => {

View File

@@ -35,21 +35,43 @@
<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 class="nav-group-label">运营总控</div>
<template v-for="item in navMainItems" :key="item.path">
<!-- 一级条目 -->
<el-button
:class="['nav-item', { active: isActive(item.path) || hasActiveChild(item) }]"
text
@click="onNavClick(item)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-label">{{ item.label }}</span>
<el-icon v-if="item.children && item.children.length" class="nav-arrow">
<component :is="isExpanded(item.path) ? ArrowDown : ArrowRight" />
</el-icon>
</el-button>
<!-- 二级子菜单 -->
<div
v-if="item.children && item.children.length && (isExpanded(item.path) || hasActiveChild(item))"
class="nav-sub"
>
<el-button
v-for="sub in item.children"
:key="sub.path"
:class="['nav-item nav-item--sub', { active: isActive(sub.path) }]"
text
@click="navigateTo(sub.path)"
>
<span class="nav-sub-dot"></span>
<span class="nav-label">{{ sub.label }}</span>
</el-button>
</div>
</template>
</div>
<div class="nav-footer-block">
<div class="nav-group-label">系统</div>
<el-button
v-for="item in navFooterItems"
:key="item.path"
@@ -91,7 +113,9 @@ import {
Setting,
OfficeBuilding,
Share,
Monitor
Monitor,
ArrowDown,
ArrowRight
} from '@element-plus/icons-vue'
const router = useRouter()
@@ -100,22 +124,65 @@ const authStore = useAuthStore()
const sidebarOpen = ref(false)
const navMainItems: { path: string; icon: typeof TrendCharts; label: string }[] = [
interface NavItem {
path: string
icon: any
label: string
children?: NavItem[]
}
const navMainItems: NavItem[] = [
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
{
path: '/superadmin/enterprises',
icon: OfficeBuilding,
label: '企业管理',
children: [
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业列表' },
{ path: '/superadmin/soul-articles', icon: Share, label: 'Soul 引流文章' },
{ path: '/superadmin/mp-tabbar', icon: Setting, label: '小程序底部菜单' },
{ path: '/superadmin/profit-rules', icon: Share, 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 }[] = [
const navFooterItems: NavItem[] = [
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' }
]
const expandedPaths = ref<Set<string>>(new Set())
const isActive = (path: string) => {
return route.path === path || route.path.startsWith(`${path}/`)
}
const hasActiveChild = (item: NavItem): boolean => {
if (!item.children) return false
return item.children.some(c => isActive(c.path))
}
const isExpanded = (p: string) => expandedPaths.value.has(p)
const onNavClick = (item: NavItem) => {
if (item.children && item.children.length) {
// 有子菜单 → 切换展开
if (expandedPaths.value.has(item.path)) {
expandedPaths.value.delete(item.path)
} else {
expandedPaths.value.add(item.path)
}
// 首次展开时,若当前没选中任何子项,就默认跳第一个子项
if (expandedPaths.value.has(item.path) && !hasActiveChild(item)) {
navigateTo(item.children[0].path)
}
} else {
navigateTo(item.path)
}
}
const goAdminConsole = () => {
const url = router.resolve({ path: '/admin/dashboard' }).href
window.open(url, '_blank', 'noopener,noreferrer')
@@ -146,7 +213,7 @@ const handleLogout = async () => {
<style scoped lang="scss">
.superadmin-layout {
min-height: 100vh;
background-color: #f9fafb;
background-color: #f8fafc;
}
.layout-header {
@@ -187,12 +254,13 @@ const handleLogout = async () => {
width: 36px;
height: 36px;
border-radius: 8px;
background-color: #ef4444;
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.3);
}
.logo-text {
@@ -227,12 +295,12 @@ const handleLogout = async () => {
}
&.admin-entry {
color: #b91c1c;
color: #1E40AF;
font-weight: 600;
}
&.logout {
color: #ef4444;
color: #64748b;
}
.el-icon {
@@ -300,8 +368,8 @@ const handleLogout = async () => {
}
&.active {
background-color: #fef2f2;
color: #ef4444;
background-color: #E0E7FF;
color: #1E40AF;
font-weight: 600;
&::before {
@@ -311,7 +379,11 @@ const handleLogout = async () => {
top: 0;
bottom: 0;
width: 4px;
background-color: #ef4444;
background-color: #1E40AF;
}
.nav-icon {
color: #1E40AF;
}
}
@@ -323,6 +395,42 @@ const handleLogout = async () => {
flex: 1;
text-align: left;
}
.nav-arrow {
font-size: 12px;
color: #9CA3AF;
}
}
.nav-sub {
display: flex;
flex-direction: column;
background: #f9fafb;
border-left: 2px solid #e5e7eb;
margin: 0 0 4px 24px;
}
.nav-item--sub {
height: 40px;
padding: 0 20px;
font-size: 13px;
color: #6B7280;
.nav-sub-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #D1D5DB;
}
&.active {
background-color: #EEF2FF;
color: #1E40AF;
.nav-sub-dot {
background: #1E40AF;
}
}
}
}

View File

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

View File

@@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
path: 'dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue'),
meta: { title: '概览' }
meta: { title: '企业概览' }
},
{
path: 'users',
@@ -63,7 +63,7 @@ const routes: RouteRecordRaw[] = [
path: 'settings',
name: 'AdminSettings',
component: () => import('@/views/admin/Settings.vue'),
meta: { title: '系统设置' }
meta: { title: '企业设置' }
},
]
},
@@ -138,6 +138,30 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/superadmin/AIConfig.vue'),
meta: { title: '智能算力' }
},
{
path: 'soul-articles',
name: 'SuperAdminSoulArticles',
redirect: to => ({
path: '/superadmin/enterprises',
query: { ...to.query, tab: 'soulArticles' }
})
},
{
path: 'mp-tabbar',
name: 'SuperAdminMpTabBar',
redirect: to => ({
path: '/superadmin/enterprises',
query: { ...to.query, tab: 'mpTabbar' }
})
},
{
path: 'profit-rules',
name: 'SuperAdminProfitRules',
redirect: to => ({
path: '/superadmin/enterprises',
query: { ...to.query, tab: 'profitRules' }
})
},
{
path: 'settings',
name: 'SuperAdminSettings',

View File

@@ -28,10 +28,18 @@ const getBaseURL = (): string => {
return '/api/v1'
}
// 本地连云库时接口可能较慢:开发环境默认放宽;可用 VITE_REQUEST_TIMEOUT_MS 覆盖
const requestTimeoutMs = (() => {
const raw = import.meta.env.VITE_REQUEST_TIMEOUT_MS
const n = typeof raw === 'string' ? Number(raw.trim()) : Number(raw)
if (Number.isFinite(n) && n > 0) return n
return import.meta.env.DEV ? 180000 : 15000
})()
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
timeout: requestTimeoutMs,
headers: {
'Content-Type': 'application/json'
}

View File

@@ -1,10 +1,49 @@
<template>
<div class="dashboard-viewport" v-loading="loading">
<header class="dash-head">
<h1 class="dash-title">数据概览</h1>
<p class="dash-tagline"></p>
<h1 class="dash-title">企业概览</h1>
<p class="dash-tagline">实时掌握余额扣款冻结佣金与补充建议</p>
</header>
<div class="wallet-strip">
<div class="wallet-card wallet-card--primary">
<div class="wallet-info">
<div class="wallet-label">当前余额</div>
<div class="wallet-value">¥{{ fenToYuan(finance.balanceFen) }}</div>
<div class="wallet-foot">历史充值 ¥{{ fenToYuan(finance.manualRechargeFen) }}</div>
</div>
<div class="wallet-icon"><el-icon><Wallet /></el-icon></div>
<el-button type="primary" size="small" class="wallet-action" @click="openRechargeDialog">立即充值</el-button>
</div>
<div class="wallet-card wallet-card--consume">
<div class="wallet-info">
<div class="wallet-label">本月已扣款</div>
<div class="wallet-value">¥{{ fenToYuan(finance.monthConsumeFen) }}</div>
<div class="wallet-foot">日均 ¥{{ fenToYuan(finance.avgDailyConsumeFen) }} · 今日 ¥{{ fenToYuan(finance.todayConsumeFen) }}</div>
</div>
<div class="wallet-icon"><el-icon><TrendCharts /></el-icon></div>
</div>
<div class="wallet-card wallet-card--frozen">
<div class="wallet-info">
<div class="wallet-label">冻结佣金</div>
<div class="wallet-value">¥{{ fenToYuan(finance.frozenCommissionFen) }}</div>
<div class="wallet-foot">余额补足后自动解冻</div>
</div>
<div class="wallet-icon"><el-icon><Lock /></el-icon></div>
</div>
<div class="wallet-card wallet-card--suggest">
<div class="wallet-info">
<div class="wallet-label">建议充值</div>
<div class="wallet-value">
<template v-if="finance.suggestRechargeFen > 0">¥{{ fenToYuan(finance.suggestRechargeFen) }}</template>
<template v-else></template>
</div>
<div class="wallet-foot">{{ suggestHint }}</div>
</div>
<div class="wallet-icon"><el-icon><WarningFilled /></el-icon></div>
</div>
</div>
<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">
@@ -15,21 +54,43 @@
<el-icon><component :is="card.icon" /></el-icon>
</div>
</div>
<div class="stat-card stat-card-recharge" :style="{ animationDelay: `${kpiCards.length * 45}ms` }">
<div class="stat-card-recharge-inner">
<div class="stat-info stat-info--recharge">
<div class="stat-label">企业余额</div>
<div class="stat-value-row">
<span class="stat-value">{{ enterpriseBalanceDisplay }}</span>
<el-button link type="primary" size="small" class="recharge-btn-inline" @click="goEnterpriseRecharge">充值</el-button>
</div>
<el-dialog v-model="rechargeVisible" title="企业余额充值" width="420px" :close-on-click-modal="false" destroy-on-close>
<div class="recharge-dialog">
<el-form :model="rechargeForm" label-width="80px" size="default">
<el-form-item label="金额(元)">
<el-input-number
v-model="rechargeForm.amountYuan"
:min="1"
:max="100000"
:step="100"
controls-position="right"
style="width:100%"
/>
</el-form-item>
<el-form-item v-if="quickAmounts.length" label="快速">
<div class="recharge-quick">
<el-button
v-for="a in quickAmounts"
:key="a"
size="small"
:type="rechargeForm.amountYuan === a ? 'primary' : 'default'"
@click="rechargeForm.amountYuan = a"
>¥{{ a }}</el-button>
</div>
</div>
</el-form-item>
</el-form>
<div class="recharge-action">
<el-button type="primary" :loading="rechargeLoading" @click="generateRechargeQr">生成充值码</el-button>
<span class="recharge-tip">用微信扫码在小程序完成支付</span>
</div>
<div class="stat-icon amber">
<el-icon><Wallet /></el-icon>
<div v-if="rechargeQrcode" class="recharge-qr">
<img :src="rechargeQrcode" alt="充值二维码" />
<div class="recharge-qr-text">扫码支付 ¥{{ rechargeAmountYuanDisplay }}</div>
</div>
</div>
</div>
</el-dialog>
<div class="dash-catalog" v-if="testCatalog.length">
<div
@@ -158,7 +219,9 @@ import {
Reading,
Histogram,
Medal,
Wallet
Wallet,
Lock,
WarningFilled
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
@@ -192,12 +255,65 @@ const stats = reactive({
activeToday: 0
})
/** 企业余额(分),来自 /admin/finance/overview,与财务页一致 */
const balanceFen = ref(0)
/** 企业钱包概览(分),来自 /admin/finance/overview */
const finance = reactive({
balanceFen: 0,
manualRechargeFen: 0,
frozenCommissionFen: 0,
monthConsumeFen: 0,
todayConsumeFen: 0,
avgDailyConsumeFen: 0,
suggestRechargeFen: 0,
})
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
const enterpriseBalanceDisplay = computed(() => `¥${fenToYuan(balanceFen.value)}`)
const suggestHint = computed(() => {
if (finance.avgDailyConsumeFen <= 0) return '暂无消耗,按需充值即可'
if (finance.suggestRechargeFen <= 0) return '余额可支撑 > 14 日'
return '≥ 2 周使用量'
})
const rechargeVisible = ref(false)
const rechargeLoading = ref(false)
const rechargeForm = reactive({ amountYuan: 500 })
const rechargeQrcode = ref('')
const quickAmounts = [100, 500, 1000, 3000, 5000]
const rechargeAmountYuanDisplay = computed(() => Number(rechargeForm.amountYuan || 0).toFixed(2))
function openRechargeDialog() {
rechargeQrcode.value = ''
if (finance.suggestRechargeFen > 0) {
rechargeForm.amountYuan = Math.max(100, Math.round(finance.suggestRechargeFen / 100))
} else if (!rechargeForm.amountYuan) {
rechargeForm.amountYuan = 500
}
rechargeVisible.value = true
}
async function generateRechargeQr() {
if (rechargeLoading.value) return
const amountFen = Math.round(Number(rechargeForm.amountYuan || 0) * 100)
if (amountFen <= 0) {
ElMessage.warning('请先输入充值金额')
return
}
rechargeLoading.value = true
rechargeQrcode.value = ''
try {
const res: any = await request.post('/admin/finance/recharge-qrcode', { amountFen })
const qr = res?.data?.qrcode || res?.qrcode
if (typeof qr === 'string' && qr) {
rechargeQrcode.value = qr
} else {
ElMessage.error(res?.message || '二维码生成失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '二维码生成失败')
} finally {
rechargeLoading.value = false
}
}
const testTrends = ref<
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
@@ -244,7 +360,7 @@ const kpiCards = computed(() => [
])
function goEnterpriseRecharge() {
void router.push({ path: '/admin/settings', query: { tab: 'finance' } })
openRechargeDialog()
}
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
@@ -389,9 +505,22 @@ function summarizeTypes(row: TopUserRow) {
async function loadFinanceBalance() {
try {
const res: any = await request.get('/admin/finance/overview')
balanceFen.value = Number(res?.data?.balanceFen ?? 0)
const d = res?.data || {}
finance.balanceFen = Number(d.balanceFen ?? 0)
finance.manualRechargeFen = Number(d.manualRechargeFen ?? 0)
finance.frozenCommissionFen = Number(d.frozenCommissionFen ?? 0)
finance.monthConsumeFen = Number(d.monthConsumeFen ?? 0)
finance.todayConsumeFen = Number(d.todayConsumeFen ?? 0)
finance.avgDailyConsumeFen = Number(d.avgDailyConsumeFen ?? 0)
finance.suggestRechargeFen = Number(d.suggestRechargeFen ?? 0)
} catch {
balanceFen.value = 0
finance.balanceFen = 0
finance.manualRechargeFen = 0
finance.frozenCommissionFen = 0
finance.monthConsumeFen = 0
finance.todayConsumeFen = 0
finance.avgDailyConsumeFen = 0
finance.suggestRechargeFen = 0
}
}
@@ -526,11 +655,73 @@ const loadInviteQrcode = async () => {
max-width: 920px;
}
.dash-kpis {
.wallet-strip {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.wallet-card {
position: relative;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
overflow: hidden;
animation: dashFadeUp 0.45s ease-out both;
}
.wallet-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.wallet-label { font-size: 12px; color: #64748b; font-weight: 500; }
.wallet-value {
font-size: 22px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.wallet-foot { font-size: 11px; color: #94a3b8; }
.wallet-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.wallet-card--primary { background: linear-gradient(135deg, #eef2ff 0%, #ffffff 60%); }
.wallet-card--primary .wallet-icon { background: #eef2ff; color: #4f46e5; }
.wallet-card--primary .wallet-value { color: #3730a3; }
.wallet-card--consume .wallet-icon { background: #ecfdf5; color: #10b981; }
.wallet-card--frozen .wallet-icon { background: #fffbeb; color: #b45309; }
.wallet-card--suggest .wallet-icon { background: #fef2f2; color: #ef4444; }
.wallet-action {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
font-weight: 600;
height: auto;
}
.recharge-dialog { display: flex; flex-direction: column; gap: 12px; }
.recharge-quick { display: flex; flex-wrap: wrap; gap: 6px; }
.recharge-action { display: flex; align-items: center; gap: 10px; }
.recharge-tip { font-size: 12px; color: #94a3b8; }
.recharge-qr { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 6px; }
.recharge-qr img { width: 200px; height: 200px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; padding: 8px; }
.recharge-qr-text { font-size: 13px; color: #475569; }
.dash-kpis {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 8px;
}
@@ -983,11 +1174,17 @@ const loadInviteQrcode = async () => {
}
}
@media (max-width: 1100px) {
.wallet-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.dash-kpis {
grid-template-columns: repeat(2, 1fr);
}
.wallet-strip { grid-template-columns: 1fr; }
.dash-catalog {
grid-template-columns: repeat(2, 1fr);
}

View File

@@ -86,27 +86,29 @@
<VChart v-if="hasProductSeries" class="trend-chart-echarts" :option="productSeriesOption" autoresize />
<div v-else class="empty-chart">暂无产品数据</div>
</div>
<div class="chart-card">
<div class="chart-card top-ranking-card">
<div class="card-header">
<el-icon><UserFilled /></el-icon>
<span>分销商收益排行</span>
<span>分销商 Top 10 排行</span>
<span class="head-hint">点击查看订单定位该分销商带来的订单</span>
</div>
<div v-if="overviewTopDistributors.length" class="overview-list">
<div v-for="item in overviewTopDistributors" :key="item.id" class="overview-list-item">
<div v-for="(item, idx) in overviewTopDistributors" :key="item.id" class="overview-list-item">
<div class="overview-main">
<span :class="['rank-badge', idx < 3 ? 'rank-top' : '']">{{ idx + 1 }}</span>
<div class="agent-cell">
<el-avatar :size="28" :src="item.avatar" class="agent-avatar">
<el-avatar :size="30" :src="item.avatar" class="agent-avatar">
{{ item.agentName ? item.agentName[0] : '?' }}
</el-avatar>
<div class="overview-meta">
<strong>{{ item.agentName || '-' }}</strong>
<span>团队人数{{ item.teamCount || 0 }}</span>
<span>团队 {{ item.teamCount || 0 }} </span>
</div>
</div>
</div>
<div class="overview-side">
<strong>{{ formatCurrency(parseFloat(item.totalCommission || 0)) }}</strong>
<span>累计收益</span>
<el-button link type="primary" size="small" @click="gotoInviterOrders(item)">查看订单</el-button>
</div>
</div>
</div>
@@ -169,6 +171,11 @@
{{ row.createdAt ? new Date(row.createdAt * 1000).toLocaleString() : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="gotoInviterOrders(row)">该分销商订单</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="distributors.length === 0 && !loading" class="empty-placeholder">暂无分销商数据</div>
</div>
@@ -389,6 +396,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Refresh, User, Money, Clock, Connection, Search,
CaretTop, TrendCharts, PieChart, UserFilled, List
@@ -398,11 +406,13 @@ import { ElMessage } from 'element-plus'
import { formatCurrency } from '@/utils/format'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart as EchartsPieChart } from 'echarts/charts'
import { LineChart, PieChart as EchartsPieChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
use([CanvasRenderer, LineChart, BarChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
const router = useRouter()
const activeTab = ref('overview')
const distSearch = ref('')
@@ -445,35 +455,59 @@ const hasProductSeries = computed(() =>
productCommissionSeries.value.some(item => Number(item.value || 0) > 0)
)
const cumulativeTrend = computed(() => {
let acc = 0
return commissionTrend.value.map(item => {
acc += Number(item.amount || 0)
return Number(acc.toFixed(2))
})
})
const commissionTrendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 24, bottom: 30 },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['每日佣金', '累计佣金'], top: 0, textStyle: { color: '#64748b', fontSize: 11 } },
grid: { left: 44, right: 50, top: 30, bottom: 30 },
xAxis: {
type: 'category',
boundaryGap: false,
data: commissionTrend.value.map(item => item.date.slice(5)),
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280' }
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8', fontSize: 11 }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#e5e7eb' } },
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: {
color: '#6b7280',
formatter: (value: number) => `¥${value}`
yAxis: [
{
type: 'value',
name: '每日',
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
axisLine: { lineStyle: { color: '#e2e8f0' } },
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
},
{
type: 'value',
name: '累计',
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
axisLine: { lineStyle: { color: '#e2e8f0' } },
splitLine: { show: false },
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
}
},
],
series: [
{
name: '佣金',
name: '每日佣金',
type: 'bar',
barMaxWidth: 18,
itemStyle: { color: '#4F46E5', borderRadius: [4, 4, 0, 0] },
data: commissionTrend.value.map(item => Number(item.amount || 0))
},
{
name: '累计佣金',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 6,
itemStyle: { color: '#7c3aed' },
areaStyle: { color: 'rgba(124, 58, 237, 0.08)' },
data: commissionTrend.value.map(item => Number(item.amount || 0))
yAxisIndex: 1,
showSymbol: false,
lineStyle: { color: '#10B981', width: 2 },
itemStyle: { color: '#10B981' },
data: cumulativeTrend.value
}
]
}))
@@ -576,7 +610,7 @@ const loadOverview = async () => {
if (distributorsRes.code === 200 && distributorsRes.data) {
overviewTopDistributors.value = [...(distributorsRes.data.list || [])]
.sort((a: any, b: any) => parseFloat(b.totalCommission || 0) - parseFloat(a.totalCommission || 0))
.slice(0, 5)
.slice(0, 10)
} else {
overviewTopDistributors.value = []
}
@@ -726,6 +760,18 @@ watch(commFilter, () => {
}
})
function gotoInviterOrders(row: any) {
const id = row?.id ?? row?.userId ?? row?.inviterId
if (!id) {
ElMessage.warning('分销商 ID 缺失')
return
}
router.push({
path: '/admin/orders',
query: { inviterId: String(id), inviterName: row?.agentName || '' },
})
}
const refresh = async () => {
if (activeTab.value === 'overview') {
await loadOverview()
@@ -929,41 +975,74 @@ onMounted(() => {
.overview-list {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 180px;
gap: 8px;
max-height: 360px;
overflow-y: auto;
}
.overview-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
gap: 12px;
padding: 10px 14px;
border-radius: 8px;
background-color: #f9fafb;
border: 1px solid #f3f4f6;
transition: background 0.15s;
&:hover { background-color: #eef2ff; }
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 700;
margin-right: 8px;
flex-shrink: 0;
&.rank-top { background: #fef3c7; color: #b45309; }
}
.top-ranking-card {
grid-column: 1 / -1;
.card-header { justify-content: space-between; }
.head-hint {
font-size: 11px;
color: #94a3b8;
font-weight: 400;
margin-left: auto;
}
}
.overview-main {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
}
.overview-side {
min-width: 120px;
min-width: 150px;
text-align: right;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: flex-end;
gap: 12px;
strong {
font-size: 14px;
color: #111827;
}
span {
font-size: 12px;
color: #6b7280;
color: #4f46e5;
font-weight: 700;
}
}

View File

@@ -0,0 +1,329 @@
<template>
<el-drawer
v-model="visible"
:title="drawerTitle"
direction="rtl"
size="480px"
:destroy-on-close="true"
>
<div v-if="order" class="timeline-drawer">
<div class="summary-card">
<div class="summary-row">
<span class="label">订单号</span>
<span class="value monospaced">{{ order.orderNo || '—' }}</span>
</div>
<div class="summary-row">
<span class="label">用户</span>
<span class="value">{{ order.userName || '—' }} <em>{{ formatPhone(order.userPhone) }}</em></span>
</div>
<div class="summary-row">
<span class="label">产品</span>
<span class="value">{{ order.productTitle || productTypeLabel(order.productType) }}</span>
</div>
<div class="summary-row">
<span class="label">金额</span>
<span class="value amount">{{ formatAmount(order.amount) }}</span>
</div>
<div class="summary-row">
<span class="label">状态</span>
<span :class="['status-badge', 'status-' + (order.status || 'pending')]">{{ statusLabel(order.status) }}</span>
</div>
</div>
<h4 class="section-title">订单时间线</h4>
<ul class="timeline">
<li v-for="step in timelineSteps" :key="step.key" :class="['timeline-step', step.active ? 'active' : 'inactive']">
<span class="dot" />
<div class="step-body">
<div class="step-title">{{ step.title }}</div>
<div class="step-time">{{ step.active ? formatDateTime(step.time) : '—' }}</div>
<div class="step-desc" v-if="step.desc">{{ step.desc }}</div>
</div>
</li>
</ul>
<template v-if="commissionList.length">
<h4 class="section-title">分销分润</h4>
<div class="commission-list">
<div v-for="cr in commissionList" :key="cr.id" class="commission-card">
<div class="commission-top">
<span class="inviter">{{ cr.inviterName || cr.inviterNickname || ('#' + cr.inviterId) }}</span>
<span :class="['status-badge', 'status-' + mapCommissionStatus(cr.status)]">{{ commissionStatusLabel(cr.status) }}</span>
</div>
<div class="commission-meta">
<span>佣金 <strong>{{ formatAmount(cr.commissionFen) }}</strong></span>
<span v-if="cr.rate != null">比例 {{ Number(cr.rate * 100).toFixed(1) }}%</span>
<span v-if="cr.paidAt">结算 {{ formatDateTime(cr.paidAt) }}</span>
</div>
</div>
</div>
</template>
<div v-else class="empty-commission">无分销分润记录</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface OrderInput {
orderNo?: string
userName?: string
userPhone?: string
userId?: number
productType?: string
productTitle?: string
amount?: number
status?: string
createdAt?: number | null
payTime?: number | null
refundTime?: number | null
cancelTime?: number | null
commissions?: any[]
}
const props = defineProps<{
modelValue: boolean
order: OrderInput | null
commissions?: any[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const drawerTitle = computed(() => `订单详情 · ${props.order?.orderNo || ''}`)
const commissionList = computed(() => {
const inline = Array.isArray(props.order?.commissions) ? props.order!.commissions! : []
const extra = Array.isArray(props.commissions) ? props.commissions : []
return [...inline, ...extra]
})
const timelineSteps = computed(() => {
const order = props.order
if (!order) return []
const status = order.status || 'pending'
const commissionPaidAt = commissionList.value.find(c => c.paidAt)?.paidAt ?? null
const createdActive = !!order.createdAt
const paidActive = !!order.payTime || ['paid', 'completed', 'refunded'].includes(status)
const settledActive = !!commissionPaidAt || status === 'completed'
const refundActive = status === 'refunded' && !!(order.refundTime || order.payTime)
return [
{
key: 'created',
title: '下单',
time: order.createdAt,
active: createdActive,
desc: '用户发起订单',
},
{
key: 'paid',
title: '付款',
time: order.payTime,
active: paidActive,
desc: paidActive ? '支付已到账' : '等待支付',
},
{
key: 'settled',
title: '分销结算',
time: commissionPaidAt,
active: settledActive,
desc: settledActive
? (commissionList.value.length ? '佣金按规则结算' : '无分销,直接完成')
: '尚未结算',
},
...(refundActive
? [{ key: 'refunded', title: '退款', time: order.refundTime || order.payTime, active: true, desc: '已发起退款' }]
: []),
]
})
function formatPhone(phone?: string) {
if (!phone) return ''
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
return phone
}
function formatDateTime(ts?: number | null) {
if (ts == null) return '—'
const d = new Date(Number(ts) * 1000)
return (
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
)
}
function formatAmount(amountFen?: number | string | null) {
if (amountFen == null) return '¥0.00'
const n = typeof amountFen === 'number' ? amountFen : Number(amountFen)
if (!Number.isFinite(n)) return '¥0.00'
return '¥' + (n / 100).toFixed(2)
}
function productTypeLabel(type?: string) {
const map: Record<string, string> = {
face: 'AI人脸分析',
mbti: 'MBTI',
disc: 'DISC',
pdp: 'PDP',
sbti: 'SBTI',
report: '完整报告',
}
return map[type || ''] || type || '—'
}
function statusLabel(status?: string) {
const map: Record<string, string> = {
pending: '待支付',
paid: '已支付',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
failed: '失败',
}
return map[status || ''] || status || '—'
}
function mapCommissionStatus(s: string) {
if (s === 'paid' || s === 'settled') return 'completed'
if (s === 'frozen') return 'frozen'
if (s === 'cancelled') return 'cancelled'
return 'pending'
}
function commissionStatusLabel(s: string) {
const map: Record<string, string> = {
frozen: '冻结中',
pending: '待结算',
paid: '已结算',
settled: '已结算',
cancelled: '已撤销',
}
return map[s] || s || '—'
}
</script>
<style scoped lang="scss">
.timeline-drawer { padding: 8px 4px; }
.summary-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
.summary-row {
display: flex;
align-items: baseline;
gap: 12px;
font-size: 13px;
.label { color: #64748b; flex-shrink: 0; width: 56px; }
.value { color: #0f172a; font-weight: 500; }
.value.amount { color: #4f46e5; font-weight: 700; }
.value.monospaced { font-family: 'SF Mono', Menlo, monospace; font-weight: 500; }
em { color: #94a3b8; font-style: normal; margin-left: 6px; font-size: 12px; }
}
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin: 20px 0 10px;
}
.timeline {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-step {
position: relative;
padding-left: 22px;
.dot {
position: absolute;
left: 4px;
top: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #cbd5e1;
}
&::before {
content: '';
position: absolute;
left: 8px;
top: 14px;
bottom: -10px;
width: 2px;
background: #e2e8f0;
}
&:last-child::before { display: none; }
&.active .dot { background: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
.step-title { font-size: 13px; font-weight: 600; color: #0f172a; }
.step-time { font-size: 12px; color: #64748b; margin-top: 2px; }
.step-desc { font-size: 12px; color: #94a3b8; margin-top: 2px; }
&.inactive {
.step-title, .step-time, .step-desc { color: #94a3b8; }
}
}
.commission-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.commission-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
.commission-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.inviter { font-size: 13px; color: #0f172a; font-weight: 600; }
}
.commission-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #64748b;
flex-wrap: wrap;
strong { color: #4f46e5; font-weight: 600; }
}
}
.empty-commission {
font-size: 12px;
color: #94a3b8;
padding: 8px 0;
}
</style>

View File

@@ -91,7 +91,12 @@
</div>
</div>
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table">
<div v-if="inviterId" class="inviter-banner">
<span>筛选分销商 #{{ inviterId }}<span v-if="inviterName"> · {{ inviterName }}</span></span>
<el-button link type="primary" size="small" @click="clearInviterFilter">清除筛选</el-button>
</div>
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table" row-key="id">
<el-table-column label="订单号" width="180">
<template #default="{ row }">
<span class="order-id">{{ row.orderNo }}</span>
@@ -105,13 +110,11 @@
</div>
</template>
</el-table-column>
<el-table-column label="产品" min-width="180">
<el-table-column label="产品" min-width="160">
<template #default="{ row }">
<div class="product-cell">
<div class="product-main">{{ row.productTitle || productTypeLabel(row.productType) }}</div>
<div class="product-sub" v-if="row.productTitle">
{{ productTypeLabel(row.productType) }}
</div>
<div class="product-sub" v-if="row.productTitle">{{ productTypeLabel(row.productType) }}</div>
</div>
</template>
</el-table-column>
@@ -120,45 +123,36 @@
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="支付方式" width="100" align="center">
<el-table-column label="支付方式" width="90" align="center">
<template #default="{ row }">
{{ payMethodLabel(row.payMethod) }}
<span class="pay-method">{{ payMethodLabel(row.payMethod) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
<span :class="['status-badge', 'status-' + (row.status || 'pending')]">{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="关联测试" min-width="200">
<el-table-column label="时间" width="170">
<template #default="{ row }">
<div class="test-data-cell">
<template v-if="row.testData && row.testData.length">
<div
v-for="(t, i) in row.testData"
:key="t.id || i"
class="test-item"
>
<span class="test-type">{{ testTypeLabel(t.testType) }}</span>
<span class="test-summary">{{ t.resultSummary || '—' }}</span>
</div>
</template>
<span v-else class="no-test"></span>
<div class="time-cell">
<div class="time-row"><em>创建</em>{{ formatDate(row.createdAt) }}</div>
<div class="time-row"><em>支付</em>{{ row.payTime ? formatDate(row.payTime) : '—' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="支付时间" width="160">
<template #default="{ row }">
{{ row.payTime ? formatDate(row.payTime) : '—' }}
<el-button link type="primary" @click="openTimeline(row)">
<el-icon><View /></el-icon>
<span>详情</span>
</el-button>
</template>
</el-table-column>
</el-table>
<OrderTimelineDrawer v-model="timelineVisible" :order="currentOrder" :commissions="currentCommissions" />
<div class="empty-state" v-if="orders.length === 0 && !loading">
<span>暂无订单数据</span>
</div>
@@ -181,15 +175,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Refresh, ShoppingCart, Box, Money, Search } from '@element-plus/icons-vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Refresh, ShoppingCart, Box, Money, Search, View } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import OrderTimelineDrawer from './OrderTimelineDrawer.vue'
const props = withDefaults(
defineProps<{ embedded?: boolean; ordersApiPath?: string }>(),
{ embedded: false, ordersApiPath: '/admin/orders' }
)
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
@@ -201,6 +200,44 @@ const totalRevenueFen = ref(0)
const currentPage = ref(1)
const pageSize = 20
const timelineVisible = ref(false)
const currentOrder = ref<any | null>(null)
const currentCommissions = ref<any[]>([])
const inviterId = ref<string | number>('')
const inviterName = ref<string>('')
function readInviterFromRoute() {
const q = route?.query || {}
const id = Array.isArray(q.inviterId) ? q.inviterId[0] : q.inviterId
const name = Array.isArray(q.inviterName) ? q.inviterName[0] : q.inviterName
inviterId.value = (id ?? '') as any
inviterName.value = (name ?? '') as string
}
function clearInviterFilter() {
inviterId.value = ''
inviterName.value = ''
const q = { ...route.query }
delete (q as any).inviterId
delete (q as any).inviterName
router.replace({ path: route.path, query: q })
currentPage.value = 1
loadOrders()
}
watch(() => route.query, () => {
readInviterFromRoute()
currentPage.value = 1
loadOrders()
})
function openTimeline(row: any) {
currentOrder.value = row
currentCommissions.value = Array.isArray(row?.commissions) ? row.commissions : []
timelineVisible.value = true
}
const statusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'pending' },
@@ -303,15 +340,17 @@ function statusTagType(
async function loadOrders() {
loading.value = true
try {
const res: any = await request.get(props.ordersApiPath, {
params: {
page: currentPage.value,
pageSize,
keyword: searchQuery.value,
status: statusFilter.value,
productType: productFilter.value
}
})
const params: Record<string, any> = {
page: currentPage.value,
pageSize,
keyword: searchQuery.value,
status: statusFilter.value,
productType: productFilter.value
}
if (inviterId.value) {
params.inviterId = inviterId.value
}
const res: any = await request.get(props.ordersApiPath, { params })
const list = res.data?.list ?? res?.list ?? []
orders.value = list
total.value = res.data?.total ?? res?.total ?? 0
@@ -327,7 +366,10 @@ async function loadOrders() {
}
}
onMounted(() => loadOrders())
onMounted(() => {
readInviterFromRoute()
loadOrders()
})
</script>
<style scoped lang="scss">
@@ -531,8 +573,34 @@ onMounted(() => loadOrders())
.amount-cell {
font-size: 13px;
color: #111827;
font-weight: 500;
color: #4f46e5;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.pay-method {
font-size: 12px;
color: #475569;
}
.product-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.time-cell {
font-size: 12px;
color: #334155;
line-height: 1.5;
em {
display: inline-block;
width: 32px;
color: #94a3b8;
font-style: normal;
margin-right: 4px;
}
.time-row + .time-row { margin-top: 2px; }
}
.test-data-cell {
@@ -619,4 +687,17 @@ onMounted(() => loadOrders())
.page-container.is-embedded {
min-height: auto;
}
.inviter-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
margin: 0 20px 0;
background: #eef2ff;
color: #4338ca;
font-size: 13px;
border-radius: 8px;
margin-top: 12px;
}
</style>

View File

@@ -2,8 +2,8 @@
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>用户数据</h2>
<p class="subtitle">按企业用户池分类查看和管理用户数据</p>
<h2>用户测试画像</h2>
<p class="subtitle">以用户为核心 · 一行看清面容分析MBTI / DISC / PDP 结果</p>
</div>
<div class="header-actions">
<el-button variant="outline" size="small" @click="exportData">
@@ -12,8 +12,49 @@
</div>
</div>
<!-- 画像汇总条 -->
<div class="profile-summary">
<div class="summary-card">
<div class="summary-ic ic-blue">👥</div>
<div class="summary-body">
<div class="summary-label">总用户数</div>
<div class="summary-value">{{ total }}</div>
</div>
</div>
<div class="summary-card">
<div class="summary-ic ic-teal">🪞</div>
<div class="summary-body">
<div class="summary-label">已面容分析</div>
<div class="summary-value">
{{ profileStats.faceCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
</div>
</div>
</div>
<div class="summary-card">
<div class="summary-ic ic-indigo">🧠</div>
<div class="summary-body">
<div class="summary-label">已完成 MBTI</div>
<div class="summary-value">
{{ profileStats.mbtiCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
</div>
</div>
</div>
<div class="summary-card">
<div class="summary-ic ic-amber">📊</div>
<div class="summary-body">
<div class="summary-label">至少一项测试</div>
<div class="summary-value">
{{ profileStats.anyTestCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
</div>
</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-section">
<div class="search-section admin-filter-bar">
<el-input
v-model="searchTerm"
placeholder="搜索昵称、手机号、地区、MBTI..."
@@ -26,133 +67,93 @@
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="coldFaceFilter"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="冷脸分析"
class="coldface-select"
@change="() => { currentPage = 1; loadUsers() }"
>
<el-option label="暖" value="warm" />
<el-option label="中" value="neutral" />
<el-option label="冷" value="cold" />
</el-select>
<el-button type="primary" @click="loadUsers">搜索</el-button>
</div>
<!-- 用户列表表格 -->
<div class="table-card">
<el-table :data="users" style="width: 100%" v-loading="loading" class="user-table">
<el-table-column label="用户信息" min-width="180">
<el-table-column label="用户" min-width="220">
<template #default="{ row }">
<div class="user-info-cell">
<img
v-if="displayAvatarUrl(row.avatar)"
:src="displayAvatarUrl(row.avatar)"
class="user-avatar user-avatar-img"
referrerpolicy="no-referrer"
alt=""
/>
<div
v-else
class="user-avatar user-avatar-letter"
:style="{ backgroundColor: avatarBgColor(row) }"
>
{{ avatarLetter(row) }}
<div class="user-cell">
<div class="user-avatar">
<img
v-if="displayAvatarUrl(row.avatar)"
:src="displayAvatarUrl(row.avatar)"
referrerpolicy="no-referrer"
alt=""
/>
<span v-else :style="{ backgroundColor: avatarBgColor(row), width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }">
{{ avatarLetter(row) }}
</span>
</div>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
<div class="phone-line">{{ row.phone}}</div>
<div class="user-meta">
<div class="user-name">{{ row.username || '未设置昵称' }}</div>
<div class="user-sub">
<span
v-if="row.coldFaceLevel"
:class="['coldface-tag', 'coldface-' + row.coldFaceLevel]"
:title="coldFaceTooltip(row)"
>{{ coldFaceLabel(row.coldFaceLevel) }}<template v-if="row.coldFaceScore != null"> · {{ row.coldFaceScore }}</template></span>
<span v-else class="empty-hint">未测面相</span>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="人脸分析" width="180" align="center">
<el-table-column label="联系方式" min-width="160">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.faceMbtiType || row.faceDiscType || row.facePdpType">
<el-tag
v-if="row.faceMbtiType"
size="small"
class="result-tag"
type="success"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.faceMbtiType }}
</el-tag>
<el-tag
v-if="row.faceDiscType"
size="small"
class="result-tag"
type="info"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.faceDiscType }}
</el-tag>
<el-tag
v-if="row.facePdpType"
size="small"
class="result-tag"
type="warning"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.facePdpType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
<div class="contact-cell">
<div class="phone">{{ row.phone || '—' }}</div>
<div class="openid-line" :title="row.openid || ''">{{ row.openid ? (String(row.openid).length > 16 ? String(row.openid).slice(0,16) + '…' : row.openid) : '—' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="MBTI" width="120" align="center">
<el-table-column label="测评标签" min-width="220">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.mbtiType">
<el-tag size="small" class="result-tag" @click.stop="handleClickTestTag(row, 'mbti')">
{{ row.mbtiType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
<el-tag v-if="row.mbtiType" size="small" class="result-tag tag-mbti" @click.stop="handleClickTestTag(row, 'mbti')">MBTI · {{ row.mbtiType }}</el-tag>
<el-tag v-if="row.discType" size="small" class="result-tag tag-disc" type="info" @click.stop="handleClickTestTag(row, 'disc')">DISC · {{ row.discType }}</el-tag>
<el-tag v-if="row.pdpType" size="small" class="result-tag tag-pdp" type="warning" @click.stop="handleClickTestTag(row, 'pdp')">PDP · {{ row.pdpType }}</el-tag>
<el-tag v-if="row.faceMbtiType" size="small" class="result-tag tag-face" type="success" @click.stop="handleClickTestTag(row, 'face')">·{{ row.faceMbtiType }}</el-tag>
<el-tag v-if="row.faceDiscType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">·{{ row.faceDiscType }}</el-tag>
<el-tag v-if="row.facePdpType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">·{{ row.facePdpType }}</el-tag>
<span v-if="!row.mbtiType && !row.discType && !row.pdpType && !row.faceMbtiType && !row.faceDiscType && !row.facePdpType" class="no-test">暂无测评</span>
</div>
</template>
</el-table-column>
<el-table-column label="PDP" width="120" align="center">
<el-table-column label="测评次数 · 最近活跃" width="180">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.pdpType">
<el-tag
size="small"
class="result-tag"
type="warning"
@click.stop="handleClickTestTag(row, 'pdp')"
>
{{ row.pdpType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
<div class="activity-cell">
<div class="activity-row">测评 <strong>{{ row.testCount || 0 }}</strong> </div>
<div class="activity-row sub">{{ row.lastTestAt ? formatDate(row.lastTestAt) : '—' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="DISC" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.discType">
<el-tag
size="small"
class="result-tag"
type="info"
@click.stop="handleClickTestTag(row, 'disc')"
>
{{ row.discType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="注册时间" width="150">
<el-table-column label="注册时间" width="130">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link @click="handleView(row)">
<el-icon><View /></el-icon>
@@ -713,6 +714,7 @@ const total = ref(0)
const currentPage = ref(1)
const pageSize = 10
const searchTerm = ref('')
const coldFaceFilter = ref<string[]>([])
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
@@ -729,6 +731,24 @@ const currentTestType = computed(() => (currentTest.value?.testType || '').toLow
const users = ref<any[]>([])
/** 页内画像汇总 */
const profileStats = computed(() => {
const list = users.value || []
let faceCount = 0
let mbtiCount = 0
let anyTestCount = 0
for (const u of list) {
const hasFace = !!(u.faceMbtiType || u.faceDiscType || u.facePdpType)
const hasMbti = !!u.mbtiType
const hasDisc = !!u.discType
const hasPdp = !!u.pdpType
if (hasFace) faceCount++
if (hasMbti) mbtiCount++
if (hasFace || hasMbti || hasDisc || hasPdp) anyTestCount++
}
return { faceCount, mbtiCount, anyTestCount }
})
async function loadUsers() {
loading.value = true
try {
@@ -737,6 +757,9 @@ async function loadUsers() {
pageSize,
keyword: searchTerm.value
}
if (coldFaceFilter.value.length > 0) {
params.coldFaceLevel = coldFaceFilter.value.join(',')
}
const res: any = await request.get('/admin/app-users', { params })
const list = res.data?.list ?? res?.list ?? []
users.value = list.map((row: any) => ({
@@ -773,6 +796,20 @@ function avatarBgColor(row: { username?: string; nickname?: string }) {
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
}
function coldFaceLabel(level: string | null | undefined) {
if (level === 'cold') return '冷'
if (level === 'warm') return '暖'
if (level === 'neutral') return '中'
return ''
}
function coldFaceTooltip(row: any) {
const parts: string[] = []
if (row?.coldFaceScore != null) parts.push(`冷脸分:${row.coldFaceScore}`)
if (row?.coldFaceUpdatedAt) parts.push(`更新于 ${formatDate(row.coldFaceUpdatedAt)}`)
return parts.join(' · ') || '尚未完成面相分析'
}
function formatPhone(phone: string) {
if (!phone) return '-'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
@@ -1169,11 +1206,85 @@ onMounted(() => {
}
}
.profile-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
.summary-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: all 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
}
}
.summary-ic {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
&.ic-blue { background: #eff6ff; }
&.ic-teal { background: #f0fdfa; }
&.ic-indigo { background: #eef2ff; }
&.ic-amber { background: #fffbeb; }
}
.summary-body {
min-width: 0;
}
.summary-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.summary-value {
font-size: 22px;
font-weight: 700;
color: #111827;
line-height: 1;
font-variant-numeric: tabular-nums;
.summary-sub {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin-left: 6px;
}
}
}
.search-section {
margin-bottom: 20px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
.coldface-select {
width: 180px;
}
.search-input {
flex: 1;
@@ -1294,6 +1405,7 @@ onMounted(() => {
.result-tag {
font-size: 12px;
cursor: pointer;
}
.no-test {
@@ -1302,6 +1414,29 @@ onMounted(() => {
}
}
.contact-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
.phone { color: #0f172a; font-size: 13px; font-weight: 500; }
.openid-line { color: #94a3b8; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
}
.activity-cell {
font-size: 13px;
color: #334155;
strong {
color: #4f46e5;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.activity-row.sub { color: #94a3b8; font-size: 12px; margin-top: 2px; }
}
.time-cell {
font-size: 13px;
color: #374151;

View File

@@ -1,24 +1,11 @@
<template>
<div class="commerce-hub">
<div class="hub-header">
<h2>订单和财务</h2>
<p class="hub-subtitle">
监督全平台订单与资金流水全局定价影响各企业在管理后台侧的采购与结算企业日常接单请在管理后台查看
</p>
</div>
<div class="sa-page commerce-hub">
<SaPageHeader
title="订单和财务"
subtitle="监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。"
/>
<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>
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
<div class="hub-body">
<Orders
@@ -27,7 +14,6 @@
orders-api-path="/superadmin/orders"
/>
<Finance v-if="activeTab === 'finance'" embedded />
<!-- 与独立全局定价一致含个人版价格 + 深度服务左右栏个人/企业 -->
<Pricing v-if="activeTab === 'pricing'" embedded />
</div>
</div>
@@ -39,6 +25,8 @@ import { useRoute, useRouter } from 'vue-router'
import Orders from '@/views/admin/Orders.vue'
import Finance from './Finance.vue'
import Pricing from './Pricing.vue'
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
import SaTabs from '@/components/superadmin/SaTabs.vue'
const TAB_IDS = ['orders', 'finance', 'pricing'] as const
type TabId = (typeof TAB_IDS)[number]
@@ -66,99 +54,21 @@ const applyRouteTab = () => {
}
}
const selectTab = (tab: TabId) => {
activeTab.value = tab
const onTabChange = (tab: TabId) => {
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
}
if (tab !== 'orders') q.tab = tab
router.replace({ path: '/superadmin/commerce', query: Object.keys(q).length ? q : {} })
}
watch(
() => route.query.tab,
() => applyRouteTab()
)
onMounted(() => {
applyRouteTab()
})
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 scoped>
.hub-body { width: 100%; }
</style>

View File

@@ -79,6 +79,55 @@
</div>
</div>
</div>
<!-- 分销排行榜TOP 分销员 + 邀请与分润 -->
<div class="content-card top-agents-card">
<div class="card-title">
分销排行榜 · TOP {{ topAgents.length || 0 }}
<span class="card-title-sub">按累计分润排序 · {{ topAgentsDays }} </span>
</div>
<div class="filter-group top-days-group">
<div
v-for="d in [7, 30, 90]"
:key="d"
:class="['filter-item', { active: topAgentsDays === d }]"
@click="changeTopAgentsDays(d)"
> {{ d }} </div>
</div>
<el-table
:data="topAgents"
class="custom-table"
v-loading="loadingTopAgents"
empty-text="暂无分销记录"
>
<el-table-column label="名次" width="72">
<template #default="{ $index }">
<span
class="rank-badge"
:class="{ 'rank-badge--gold': $index === 0, 'rank-badge--silver': $index === 1, 'rank-badge--bronze': $index === 2 }"
>{{ $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column label="分销员" min-width="200">
<template #default="{ row }">
<div class="agent-cell">
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
<div>
<div class="agent-name">{{ row.nickname || '未命名' }}</div>
<div class="agent-meta">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="分享次数" width="110" align="center" prop="shareCount" />
<el-table-column label="邀请绑定" width="110" align="center" prop="inviteBound" />
<el-table-column label="累计分润(元)" width="150" align="right">
<template #default="{ row }">
<strong class="amount-positive">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</strong>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 提现管理 -->
@@ -331,6 +380,9 @@ const overview = reactive({
const withdrawals = ref<any[]>([])
const commissions = ref<any[]>([])
const topAgents = ref<any[]>([])
const topAgentsDays = ref(30)
const loadingTopAgents = ref(false)
const distEnabled = ref(true)
const promoCenterTitle = ref('推广中心')
@@ -394,6 +446,26 @@ const loadOverview = async () => {
} catch (e) {}
}
// ── 分销 TOP 榜(依赖 /superadmin/analytics/share-stats
const loadTopAgents = async () => {
loadingTopAgents.value = true
try {
const res: any = await request.get('/superadmin/analytics/share-stats', {
params: { days: topAgentsDays.value, page: 1, pageSize: 20 }
})
topAgents.value = res?.data?.list || []
} catch (e) {
topAgents.value = []
} finally {
loadingTopAgents.value = false
}
}
const changeTopAgentsDays = (d: number) => {
topAgentsDays.value = d
void loadTopAgents()
}
// ── 提现列表
const loadWithdrawals = async () => {
loading.value = true
@@ -508,7 +580,7 @@ const saveSettings = async () => {
}
watch(activeTab, (t) => {
if (t === 'overview') loadOverview()
if (t === 'overview') { loadOverview(); loadTopAgents() }
else if (t === 'withdrawals') loadWithdrawals()
else if (t === 'commissions') loadCommissions()
else if (t === 'settings') loadSettings()
@@ -518,13 +590,13 @@ watch(withFilter, () => { if (activeTab.value === 'withdrawals') loadWithdrawa
watch(commFilter, () => { if (activeTab.value === 'commissions') loadCommissions() })
const refresh = async () => {
if (activeTab.value === 'overview') await loadOverview()
if (activeTab.value === 'overview') { await loadOverview(); await loadTopAgents() }
else if (activeTab.value === 'withdrawals') await loadWithdrawals()
else if (activeTab.value === 'commissions') await loadCommissions()
ElMessage.success('数据已刷新')
}
onMounted(() => { loadOverview() })
onMounted(() => { loadOverview(); loadTopAgents() })
</script>
<style scoped lang="scss">
@@ -699,4 +771,69 @@ onMounted(() => { loadOverview() })
.page-container.is-embedded {
min-height: auto;
}
/* 分销 TOP 榜卡 */
.top-agents-card {
margin-top: 16px;
padding: 20px;
.card-title {
display: flex;
align-items: baseline;
gap: 8px;
.card-title-sub {
font-size: 12px;
color: #6b7280;
font-weight: 400;
}
}
.top-days-group {
display: inline-flex;
gap: 6px;
margin: 8px 0 12px;
background: #f3f4f6;
padding: 4px;
border-radius: 10px;
.filter-item {
padding: 6px 14px;
font-size: 12px;
color: #6b7280;
border-radius: 7px;
cursor: pointer;
}
.filter-item.active {
background: #fff;
color: #1e40af;
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
font-weight: 700;
font-size: 13px;
}
.rank-badge--gold { background: linear-gradient(135deg, #fde68a, #f59e0b); color: #78350f; }
.rank-badge--silver { background: linear-gradient(135deg, #e5e7eb, #9ca3af); color: #1f2937; }
.rank-badge--bronze { background: linear-gradient(135deg, #fecaca, #b45309); color: #fff; }
.agent-cell {
display: flex;
align-items: center;
gap: 10px;
.agent-name { font-weight: 600; color: #111827; font-size: 13px; }
.agent-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
}
.amount-positive { color: #d97706; }
}
</style>

View File

@@ -1,24 +1,11 @@
<template>
<div class="distribution-hub">
<div class="hub-header">
<h2>分销管理</h2>
<p class="hub-subtitle">
平台级分销规则提现与佣金小程序埋点用于监督各端行为数据企业管理员日常操作在管理后台侧完成
</p>
</div>
<div class="sa-page distribution-hub">
<SaPageHeader
title="分销管理"
subtitle="平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。"
/>
<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>
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
<div class="hub-body">
<Distribution v-if="activeTab === 'distribution'" embedded />
@@ -32,6 +19,8 @@ import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Distribution from './Distribution.vue'
import MpAnalytics from './MpAnalytics.vue'
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
import SaTabs from '@/components/superadmin/SaTabs.vue'
const TAB_IDS = ['distribution', 'analytics'] as const
type TabId = (typeof TAB_IDS)[number]
@@ -58,103 +47,24 @@ const applyRouteTab = () => {
}
}
const selectTab = (tab: TabId) => {
activeTab.value = tab
const onTabChange = (tab: TabId) => {
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
}
if (tab !== 'distribution') q.tab = tab
router.replace({
path: '/superadmin/distribution',
query: Object.keys(q).length ? q : {}
})
}
watch(
() => route.query.tab,
() => applyRouteTab()
)
onMounted(() => {
applyRouteTab()
})
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 scoped>
.hub-body { width: 100%; }
</style>

View File

@@ -1,24 +1,11 @@
<template>
<div class="enterprise-hub">
<div class="hub-header">
<h2>企业管理</h2>
<p class="hub-subtitle">
维护企业档案与平台用户池对应各企业管理员在普通管理后台可见的数据范围个人侧无企业归属的测试用户在统计与列表中并入存客宝
</p>
</div>
<div class="sa-page enterprise-hub">
<SaPageHeader
title="企业管理"
subtitle="维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。"
/>
<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>
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
<el-alert
v-if="activeTab === 'users'"
@@ -39,6 +26,9 @@
<div class="hub-body">
<Enterprises v-if="activeTab === 'companies'" embedded />
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
<SoulArticles v-if="activeTab === 'soulArticles'" />
<MpTabBar v-if="activeTab === 'mpTabbar'" />
<ProfitRules v-if="activeTab === 'profitRules'" />
</div>
</div>
</template>
@@ -50,8 +40,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/utils/request'
import Enterprises from './Enterprises.vue'
import Users from './Users.vue'
import SoulArticles from './SoulArticles.vue'
import MpTabBar from './MpTabBar.vue'
import ProfitRules from './ProfitRules.vue'
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
import SaTabs from '@/components/superadmin/SaTabs.vue'
const TAB_IDS = ['companies', 'users'] as const
const TAB_IDS = ['companies', 'users', 'soulArticles', 'mpTabbar', 'profitRules'] as const
type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId {
@@ -101,7 +96,10 @@ async function runOrphanMigrate() {
const innerTabs: { label: string; value: TabId }[] = [
{ label: '企业列表', value: 'companies' },
{ label: '用户总览', value: 'users' }
{ label: '用户总览', value: 'users' },
{ label: '引流文章', value: 'soulArticles' },
{ label: '底部菜单', value: 'mpTabbar' },
{ label: '分账规则', value: 'profitRules' }
]
const applyRouteTab = () => {
@@ -113,108 +111,25 @@ const applyRouteTab = () => {
}
}
const selectTab = (tab: TabId) => {
activeTab.value = tab
const onTabChange = (tab: TabId) => {
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
}
if (tab !== 'companies') q.tab = tab
router.replace({ path: '/superadmin/enterprises', query: Object.keys(q).length ? q : {} })
}
watch(
() => route.query.tab,
() => applyRouteTab()
)
onMounted(() => {
applyRouteTab()
})
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%;
}
<style scoped>
.hub-body { width: 100%; }
.migrate-alert {
margin-bottom: 16px;
margin-bottom: 4px;
}
.migrate-text {
margin: 0 0 10px;

View File

@@ -35,21 +35,114 @@
</div>
</div>
<el-card class="card-block" shadow="never">
<template #header>
<span>分享漏斗结果页分享登录付费分润</span>
</template>
<div class="funnel-wrap" v-loading="loadingFunnel">
<VChart v-if="funnelData.length" class="funnel-chart" :option="funnelOption" autoresize />
<div v-else class="empty-hint">暂无漏斗数据</div>
</div>
</el-card>
<el-card class="card-block" shadow="never">
<template #header>
<span>事件汇总 {{ summaryTotal }} 条记录</span>
</template>
<div class="filter-row">
<el-select
v-model="filterEvent"
clearable
filterable
placeholder="按事件筛选(中文名)"
style="width: 320px"
@change="onFilterChange"
>
<el-option
v-for="opt in labelOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</el-select>
</div>
<el-table
v-loading="loading"
:data="summaryList"
stripe
:empty-text="tableMissing ? '表未创建见上方说明' : '暂无数据小程序有访问后会出现统计'"
>
<el-table-column prop="eventName" label="事件" min-width="200" />
<el-table-column prop="eventNameCn" label="事件(中文)" min-width="220">
<template #default="{ row }">
<strong>{{ row.eventNameCn || row.eventName }}</strong>
<div class="props-preview">{{ row.eventName }}</div>
</template>
</el-table-column>
<el-table-column prop="cnt" label="次数" width="120" />
</el-table>
</el-card>
<el-card class="card-block" shadow="never">
<template #header>
<span>分享 & 邀请 · 用户榜 TOP {{ shareRows.length }}</span>
</template>
<el-table v-loading="loadingShare" :data="shareRows" stripe empty-text="暂无分享记录(需小程序分享或分销绑定后)">
<el-table-column label="#" width="60">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:8px;">
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
<div>
<div style="font-weight:600;">{{ row.nickname || '未命名' }}</div>
<div style="font-size:12px;color:#94a3b8;">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="shareCount" label="分享次数" width="110" align="center" />
<el-table-column prop="inviteBound" label="邀请人数" width="110" align="center" />
<el-table-column label="累计分润(元)" width="130" align="center">
<template #default="{ row }">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openUserJourney(row.userId, row.nickname)">用户旅程</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="sharePage"
v-model:page-size="sharePageSize"
:total="shareTotal"
:page-sizes="[20, 30, 50]"
layout="total, sizes, prev, pager, next"
@current-change="loadShareStats"
@size-change="loadShareStats"
/>
</div>
</el-card>
<el-dialog v-model="journeyVisible" :title="journeyTitle" width="720px">
<el-table :data="journeyRows" v-loading="journeyLoading" stripe empty-text="暂无记录" max-height="520">
<el-table-column prop="createdAt" label="时间" width="170" />
<el-table-column label="事件" min-width="220">
<template #default="{ row }">
<strong>{{ row.eventNameCn || row.eventName }}</strong>
<div class="props-preview">{{ row.eventName }}</div>
</template>
</el-table-column>
<el-table-column prop="pagePath" label="页面" min-width="200" show-overflow-tooltip />
<el-table-column label="附加" min-width="180">
<template #default="{ row }">
<span class="props-preview">{{ formatProps(row.props) }}</span>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-card class="card-block" shadow="never">
<template #header>
<span>最近明细</span>
@@ -57,7 +150,12 @@
<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 label="事件" min-width="220">
<template #default="{ row }">
<strong>{{ row.eventNameCn || row.eventName }}</strong>
<div class="props-preview">{{ row.eventName }}</div>
</template>
</el-table-column>
<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">
@@ -82,8 +180,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { request } from '@/utils/request'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { FunnelChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, FunnelChart, TooltipComponent, LegendComponent, TitleComponent])
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -91,12 +196,66 @@ const days = ref(7)
const tableMissing = ref(false)
const loading = ref(false)
const loadingEvents = ref(false)
const summaryList = ref<{ eventName: string; cnt: number }[]>([])
const summaryList = ref<{ eventName: string; eventNameCn?: string; cnt: number }[]>([])
const summaryTotal = ref(0)
const eventRows = ref<any[]>([])
const eventTotal = ref(0)
const page = ref(1)
const pageSize = ref(50)
const filterEvent = ref('')
const labelOptions = ref<{ value: string; label: string }[]>([])
const loadingShare = ref(false)
const shareRows = ref<any[]>([])
const shareTotal = ref(0)
const sharePage = ref(1)
const sharePageSize = ref(30)
const journeyVisible = ref(false)
const journeyLoading = ref(false)
const journeyRows = ref<any[]>([])
const journeyTitle = ref('')
const loadingFunnel = ref(false)
const funnelData = ref<{ stage: string; value: number }[]>([])
const funnelOption = computed(() => {
const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f97316', '#14b8a6']
return {
tooltip: { trigger: 'item' as const },
legend: { bottom: 0 },
series: [
{
name: '分享漏斗',
type: 'funnel',
left: '6%',
right: '6%',
top: 20,
bottom: 40,
width: '88%',
sort: 'none',
gap: 4,
labelLine: { length: 18 },
itemStyle: {
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
position: 'inside',
color: '#fff',
fontWeight: 600,
formatter: (p: any) => `${p.name}\n${p.value}`
},
data: funnelData.value.map((item, i) => ({
name: item.stage,
value: Number(item.value || 0),
itemStyle: { color: palette[i % palette.length] }
}))
}
]
}
})
function formatProps(p: unknown): string {
if (p == null) return '—'
@@ -111,12 +270,20 @@ function formatProps(p: unknown): string {
async function loadSummary() {
loading.value = true
try {
const res: any = await request.get('/superadmin/analytics/summary', {
params: { days: days.value }
})
const params: Record<string, string | number> = { days: days.value }
if (filterEvent.value) params.eventName = filterEvent.value
const res: any = await request.get('/superadmin/analytics/summary', { params })
summaryList.value = res.data?.list || []
summaryTotal.value = res.data?.total ?? 0
tableMissing.value = !!res.data?.tableMissing
const labels = res.data?.labels
if (labels && typeof labels === 'object') {
labelOptions.value = Object.entries(labels).map(([k, v]) => ({
value: k,
label: `${v as string}${k}`
}))
}
} catch {
summaryList.value = []
summaryTotal.value = 0
@@ -129,13 +296,13 @@ async function loadSummary() {
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
}
})
const params: Record<string, string | number> = {
days: days.value,
page: page.value,
pageSize: pageSize.value
}
if (filterEvent.value) params.eventName = filterEvent.value
const res: any = await request.get('/superadmin/analytics/events', { params })
eventRows.value = res.data?.list || []
eventTotal.value = res.data?.total ?? 0
if (res.data?.tableMissing) {
@@ -150,10 +317,64 @@ async function loadEvents() {
}
}
async function loadShareStats() {
loadingShare.value = true
try {
const res: any = await request.get('/superadmin/analytics/share-stats', {
params: { days: days.value, page: sharePage.value, pageSize: sharePageSize.value }
})
shareRows.value = res.data?.list || []
shareTotal.value = res.data?.total ?? 0
} catch {
shareRows.value = []
shareTotal.value = 0
} finally {
loadingShare.value = false
}
}
async function openUserJourney(userId: number, nickname: string) {
journeyTitle.value = `用户旅程 · ${nickname || '未命名'}ID ${userId}`
journeyVisible.value = true
journeyLoading.value = true
journeyRows.value = []
try {
const res: any = await request.get('/superadmin/analytics/user-journey', {
params: { userId, days: days.value }
})
journeyRows.value = res.data?.list || []
} finally {
journeyLoading.value = false
}
}
async function loadShareFunnel() {
loadingFunnel.value = true
try {
const res: any = await request.get('/superadmin/analytics/share-funnel', {
params: { days: days.value }
})
funnelData.value = res.data?.funnel || []
} catch {
funnelData.value = []
} finally {
loadingFunnel.value = false
}
}
async function loadAll() {
page.value = 1
sharePage.value = 1
await loadSummary()
await loadEvents()
await loadShareStats()
await loadShareFunnel()
}
function onFilterChange() {
page.value = 1
void loadSummary()
void loadEvents()
}
onMounted(() => {
@@ -165,6 +386,31 @@ onMounted(() => {
.setup-alert {
margin-bottom: 16px;
}
.filter-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.props-preview {
font-size: 12px;
color: #94a3b8;
}
.funnel-wrap {
width: 100%;
min-height: 340px;
display: flex;
align-items: center;
justify-content: center;
}
.funnel-chart {
width: 100%;
height: 360px;
}
.empty-hint {
color: #94a3b8;
font-size: 13px;
padding: 48px 0;
}
.mp-analytics {
padding: 24px;
max-width: 1200px;

View File

@@ -0,0 +1,351 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>小程序底部菜单</h2>
<p class="subtitle">
拖拽排序开关显隐修改文案/图标保存后小程序 60 秒内自动生效
至少保留 2 个可见 Tab最多 5
</p>
</div>
<div class="header-actions">
<el-button @click="fetchList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="primary" @click="onSave" :loading="saving">
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</div>
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>Tab 列表拖拽行首"☰"排序</span>
<el-button size="small" @click="onAdd" :disabled="list.length >= 5">
<el-icon><Plus /></el-icon>新增 Tab
</el-button>
</div>
</template>
<el-table :data="list" row-key="tempId" border stripe>
<el-table-column width="60" align="center" label="排序">
<template #default="{ row, $index }">
<div class="drag-handle">
<el-icon><Operation /></el-icon>
<span style="margin-left: 4px">{{ $index + 1 }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="SVG图标" width="110">
<template #default="{ row }">
<el-select v-model="row.iconKey" size="small" style="width: 100%">
<el-option label="首页" value="home" />
<el-option label="拍摄" value="camera" />
<el-option label="AI" value="ai" />
<el-option label="我" value="profile" />
</el-select>
</template>
</el-table-column>
<el-table-column label="图片URL优先" min-width="220">
<template #default="{ row }">
<el-input
v-model="row.iconUrl"
size="small"
placeholder="/images/xxx.png 或 https://..."
clearable
>
<template #append v-if="row.iconUrl">
<img :src="row.iconUrl" style="width: 22px; height: 22px; border-radius: 4px;" />
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="文案" width="140">
<template #default="{ row }">
<el-input v-model="row.text" size="small" maxlength="6" show-word-limit />
</template>
</el-table-column>
<el-table-column label="页面路径" min-width="240">
<template #default="{ row }">
<el-input v-model="row.pagePath" size="small" placeholder="pages/xxx/xxx">
<template #prepend>/</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="中间浮钮" width="110" align="center">
<template #default="{ row }">
<el-switch v-model="row.highlight" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column label="显示" width="90" align="center">
<template #default="{ row }">
<el-switch v-model="row.visible" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="{ row, $index }">
<el-button size="small" link @click="onMove($index, -1)" :disabled="$index === 0"></el-button>
<el-button size="small" link @click="onMove($index, 1)" :disabled="$index === list.length - 1"></el-button>
<el-button size="small" type="danger" link @click="onRemove($index)" :disabled="list.length <= 2">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="preview">
<div class="preview-title">📱 小程序底部预览</div>
<div class="mp-bar">
<template v-for="(it, idx) in visibleItems" :key="idx">
<div v-if="it.highlight" class="mp-item mp-item--fab">
<div class="mp-fab" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
<div class="mp-text">{{ it.text }}</div>
</div>
<div v-else class="mp-item">
<div class="mp-icon" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
<div class="mp-text">{{ it.text }}</div>
</div>
</template>
</div>
<div class="preview-hint">仅为示意实际样式以小程序端为准</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Check, Plus, Operation } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
interface TabItem {
id?: number
tempId?: string
sortOrder: number
pagePath: string
text: string
iconKey: string
iconUrl?: string | null
visible: number
highlight: number
badgeKey?: string | null
}
const list = ref<TabItem[]>([])
const saving = ref(false)
const visibleItems = computed(() => list.value.filter(x => x.visible === 1))
const tmpId = () => 't' + Date.now() + '-' + Math.floor(Math.random() * 1000)
const fetchList = async () => {
try {
const res: any = await request.get('/superadmin/tabbar')
if (res && res.code === 200) {
const raw = (res.data && res.data.list) || []
list.value = raw.map((r: any) => ({
id: r.id,
tempId: 'db-' + r.id,
sortOrder: r.sortOrder,
pagePath: r.pagePath,
text: r.text,
iconKey: r.iconKey || 'home',
iconUrl: r.iconUrl || '',
visible: Number(r.visible ?? 1),
highlight: Number(r.highlight ?? 0),
badgeKey: r.badgeKey || null,
}))
}
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
}
}
const onAdd = () => {
if (list.value.length >= 5) return
list.value.push({
tempId: tmpId(),
sortOrder: (list.value.length + 1) * 10,
pagePath: 'pages/',
text: '新菜单',
iconKey: 'home',
iconUrl: '',
visible: 1,
highlight: 0,
})
}
const onRemove = async (idx: number) => {
const it = list.value[idx]
const r = await ElMessageBox.confirm(`删除「${it.text}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
if (r === 'cancel') return
list.value.splice(idx, 1)
}
const onMove = (idx: number, dir: -1 | 1) => {
const target = idx + dir
if (target < 0 || target >= list.value.length) return
const arr = list.value.slice()
const [moved] = arr.splice(idx, 1)
arr.splice(target, 0, moved)
list.value = arr
}
const onSave = async () => {
if (list.value.length < 2) return ElMessage.warning('至少 2 个 Tab')
if (list.value.length > 5) return ElMessage.warning('最多 5 个 Tab')
const v = list.value.filter(x => x.visible === 1).length
if (v < 2) return ElMessage.warning('至少 2 个可见 Tab')
for (const [idx, it] of list.value.entries()) {
if (!it.text || !it.text.trim()) return ElMessage.warning(`${idx + 1} 行文案不能为空`)
if (!it.pagePath || !it.pagePath.trim()) return ElMessage.warning(`${idx + 1} 行页面路径不能为空`)
it.sortOrder = (idx + 1) * 10
it.pagePath = it.pagePath.replace(/^\//, '')
}
saving.value = true
try {
const res: any = await request.post('/superadmin/tabbar/save', {
items: list.value.map(it => ({
id: it.id,
sortOrder: it.sortOrder,
pagePath: it.pagePath,
text: it.text,
iconKey: it.iconKey,
iconUrl: it.iconUrl,
visible: it.visible,
highlight: it.highlight,
badgeKey: it.badgeKey,
}))
})
if (res && res.code === 200) {
ElMessage.success(res.message || '保存成功')
fetchList()
} else {
ElMessage.error((res && res.message) || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(fetchList)
</script>
<style scoped>
.page-container { padding: 24px; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.header-left h2 {
margin: 0 0 8px 0;
font-size: 22px;
color: #1F1B4D;
}
.subtitle {
color: #6B6894;
font-size: 13px;
line-height: 1.6;
max-width: 720px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
color: #9CA3AF;
}
.preview {
margin-top: 24px;
border: 1px dashed #E5E7EB;
padding: 16px;
border-radius: 12px;
background: #FAFAFA;
}
.preview-title {
font-size: 13px;
color: #6B6894;
margin-bottom: 12px;
}
.mp-bar {
display: flex;
align-items: center;
justify-content: space-around;
background: #fff;
border-radius: 14px;
padding: 10px 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.mp-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
}
.mp-item--fab {
flex: 1;
position: relative;
transform: translateY(-10px);
}
.mp-icon {
width: 24px;
height: 24px;
background-size: 22px 22px;
background-repeat: no-repeat;
background-position: center;
}
.mp-fab {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
box-shadow: 0 4px 12px rgba(124,58,237,0.4);
background-size: 24px 24px;
background-repeat: no-repeat;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
}
.mp-text {
font-size: 11px;
color: #6B6894;
}
.mp-icon--home { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M3 12l9-9 9 9M5 10v10h14V10'/></svg>"); }
.mp-icon--camera { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"); }
.mp-icon--ai { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linecap='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3'/><circle cx='12' cy='12' r='3'/></svg>"); }
.mp-icon--profile{ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8'/></svg>"); }
.mp-fab.mp-icon--camera,
.mp-fab.mp-icon--ai,
.mp-fab.mp-icon--home,
.mp-fab.mp-icon--profile {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"),
linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
}
.preview-hint {
margin-top: 8px;
font-size: 12px;
color: #9CA3AF;
}
</style>

View File

@@ -1,11 +1,10 @@
<template>
<div class="ops-hub">
<div class="hub-header">
<h2>总览</h2>
<p class="hub-subtitle">
平台级数据总览用于对照各企业在普通管理后台的经营情况分销与埋点在侧栏分销管理订单与定价在订单和财务模型与算力在智能算力
</p>
</div>
<div class="sa-page ops-hub">
<SaPageHeader
title="平台总览"
subtitle="平台级数据对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。"
badge="SuperAdmin"
/>
<div class="hub-body">
<Overview />
</div>
@@ -14,33 +13,9 @@
<script setup lang="ts">
import Overview from './Overview.vue'
import SaPageHeader from '@/components/superadmin/SaPageHeader.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 scoped>
.hub-body { width: 100%; }
</style>

View File

@@ -655,18 +655,18 @@ onMounted(async () => {
flex-shrink: 0;
&.purple {
background-color: #f3e8ff;
color: #a855f7;
background-color: #E0E7FF;
color: #1E40AF;
}
&.green {
background-color: #dcfce7;
color: #22c55e;
background-color: #D1FAE5;
color: #059669;
}
&.orange {
background-color: #fed7aa;
color: #f97316;
background-color: #FEF3C7;
color: #D97706;
}
}
}
@@ -719,23 +719,23 @@ onMounted(async () => {
}
&.blue .action-icon {
background-color: #3b82f6;
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
}
&.green .action-icon {
background-color: #22c55e;
background-image: linear-gradient(135deg, #059669 0%, #047857 100%);
}
&.orange .action-icon {
background-color: #f97316;
background-image: linear-gradient(135deg, #D97706 0%, #B45309 100%);
}
&.purple .action-icon {
background-color: #a855f7;
background-image: linear-gradient(135deg, #4338CA 0%, #312E81 100%);
}
&.gray .action-icon {
background-color: #6b7280;
background-color: #475569;
}
}
}
@@ -848,7 +848,7 @@ onMounted(async () => {
padding: 0;
&:hover {
color: #ef4444;
color: #1E40AF;
}
}
}

View File

@@ -0,0 +1,199 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h2>分账规则</h2>
<p class="subtitle">按产品类型配置分账接收人与比例支付成功后自动执行比例合计须等于 100%</p>
</div>
<el-button type="primary" @click="openEdit()">
<el-icon><Plus /></el-icon>新增规则
</el-button>
</div>
<el-table :data="list" border stripe v-loading="loading">
<el-table-column prop="productType" label="产品类型" width="200" />
<el-table-column prop="name" label="名称" width="240" />
<el-table-column label="分账明细" min-width="320">
<template #default="{ row }">
<div v-for="(r, i) in row.receivers" :key="i" class="receiver-line">
<el-tag size="small">{{ labelOf(r.type) }}</el-tag>
<span style="margin-left: 6px;">{{ r.name }}</span>
<span class="ratio">{{ (Number(r.ratio) * 100).toFixed(1) }}%</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '生效中' : '已禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" link @click="openEdit(row)">编辑</el-button>
<el-button size="small" link @click="onToggle(row)">
{{ row.status === 'active' ? '禁用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="showDialog" :title="form.id ? '编辑规则' : '新增规则'" width="680px">
<el-form :model="form" label-width="110px">
<el-form-item label="产品类型">
<el-input v-model="form.productType" :disabled="!!form.id" placeholder="如 ai_deep_report" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="如 AI 深度画像报告" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" active-value="active" inactive-value="disabled" active-text="生效" inactive-text="禁用" />
</el-form-item>
<el-form-item label="分账接收人">
<div>
<div v-for="(r, i) in form.receivers" :key="i" class="receiver-row">
<el-select v-model="r.type" style="width: 160px">
<el-option label="平台" value="platform" />
<el-option label="一级分销" value="distributor_l1" />
<el-option label="二级分销" value="distributor_l2" />
<el-option label="顾问" value="consultant" />
<el-option label="自定义" value="custom" />
</el-select>
<el-input v-model="r.name" placeholder="显示名" style="width: 180px; margin-left: 8px" />
<el-input-number v-model="r.ratio" :min="0" :max="1" :step="0.05" :precision="3" style="margin-left: 8px" />
<span style="margin-left: 8px">比例(0~1)</span>
<el-button link type="danger" @click="form.receivers.splice(i, 1)">删除</el-button>
</div>
<div style="margin-top: 10px">
<el-button size="small" @click="form.receivers.push({ type: 'platform', name: '平台', ratio: 0 })">
<el-icon><Plus /></el-icon>添加接收人
</el-button>
<span class="sum-tip" :class="{ err: !sumOk }">
当前合计{{ sumPercent.toFixed(1) }}%
</span>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="!sumOk" @click="onSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
interface Receiver { type: string; name: string; ratio: number }
interface Rule {
id?: number
productType: string
name: string
receivers: Receiver[]
status: 'active' | 'disabled'
}
const list = ref<Rule[]>([])
const loading = ref(false)
const showDialog = ref(false)
const saving = ref(false)
const emptyForm = (): Rule => ({
productType: '', name: '', receivers: [{ type: 'platform', name: '平台', ratio: 1 }], status: 'active'
})
const form = ref<Rule>(emptyForm())
const sumPercent = computed(() => {
return form.value.receivers.reduce((s, r) => s + (Number(r.ratio) || 0), 0) * 100
})
const sumOk = computed(() => Math.abs(sumPercent.value - 100) < 0.5)
const labelOf = (t: string) => ({
platform: '平台',
distributor_l1: '一级分销',
distributor_l2: '二级分销',
consultant: '顾问',
custom: '自定义',
}[t] || t)
const fetchList = async () => {
loading.value = true
try {
const res: any = await request.get('/superadmin/profit-rules')
if (res && res.code === 200) {
list.value = (res.data?.list || []).map((r: any) => ({
id: r.id,
productType: r.productType,
name: r.name,
receivers: Array.isArray(r.receivers) ? r.receivers : [],
status: r.status
}))
}
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
const openEdit = (row?: Rule) => {
if (row) {
form.value = JSON.parse(JSON.stringify(row))
} else {
form.value = emptyForm()
}
showDialog.value = true
}
const onSave = async () => {
if (!form.value.productType || !form.value.name) return ElMessage.warning('请填写产品类型与名称')
if (!sumOk.value) return ElMessage.warning('分账比例合计必须 = 100%')
saving.value = true
try {
const res: any = await request.post('/superadmin/profit-rules/save', form.value)
if (res && res.code === 200) {
ElMessage.success('已保存')
showDialog.value = false
fetchList()
} else {
ElMessage.error(res?.message || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
const onToggle = async (row: Rule) => {
try {
const res: any = await request.post(`/superadmin/profit-rules/${row.id}/toggle`)
if (res && res.code === 200) {
ElMessage.success('已切换')
fetchList()
}
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
}
}
onMounted(fetchList)
</script>
<style scoped>
.page-container { padding: 24px; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.page-header h2 { margin: 0 0 6px; color: #1F1B4D; }
.subtitle { color: #6B6894; font-size: 13px; max-width: 800px; }
.receiver-line { display: flex; align-items: center; margin-bottom: 4px; }
.ratio { margin-left: auto; color: #7c3aed; font-weight: 600; }
.receiver-row { display: flex; align-items: center; margin-bottom: 10px; }
.sum-tip { margin-left: 16px; color: #059669; font-weight: 600; }
.sum-tip.err { color: #EF4444; }
</style>

View File

@@ -0,0 +1,740 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h2>Soul 引流文章</h2>
<p class="subtitle">
采集由服务端<strong>仅通过 HTTPS</strong>请求一场 soul 创业实验开放接口<strong>不使用 SSH</strong>
当前推荐至多 3 需在下方开启<strong>神仙 AI </strong><strong>我的页推荐条</strong>小程序对应位置才会展示排序第 1 篇用于我的底部灰字链接
</p>
</div>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索一场创业实验内容关键词"
clearable
style="width: 260px"
@keyup.enter="onSearchImport"
/>
<el-button @click="onSearchImport" :loading="searching">
搜索并添加
</el-button>
<el-button type="primary" @click="onSync" :loading="syncing">
<el-icon><Refresh /></el-icon>
采集最新 10
</el-button>
</div>
</div>
<!-- 神仙 AI 健康小条 -->
<el-card v-if="health.loaded" class="health-card" shadow="never" :class="{ 'health-card--warn': health.hasAlert }">
<div class="health-bar">
<div class="health-title">
<span class="dot" :class="health.hasAlert ? 'dot-warn' : 'dot-ok'"></span>
神仙 AI 服务状态
<el-tag v-if="health.hasAlert" type="danger" size="small">有告警</el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
</div>
<div class="health-providers">
<span v-for="p in health.providers" :key="p.providerId" class="provider-chip" :class="{ 'chip-warn': p.status === 'low-balance' || p.status === 'no-key' || p.status === 'disabled' }">
{{ p.name }} · {{ p.currency === 'USD' ? '$' : '¥' }}{{ p.balance == null ? '' : Number(p.balance).toFixed(2) }}
<template v-if="p.status === 'low-balance'">欠费</template>
<template v-else-if="p.status === 'disabled'">已停用</template>
<template v-else-if="p.status === 'no-key'"> key</template>
</span>
</div>
<div class="health-actions">
<el-button size="small" :loading="checking" @click="onBalanceCheck">立即扫描余额</el-button>
<el-button size="small" type="primary" plain @click="fetchHealth">刷新</el-button>
</div>
</div>
<div v-if="health.lastAlert" class="health-alert">
最近告警{{ health.lastAlert.providerId }} · 余额 {{ health.lastAlert.balance }} / 阈值 {{ health.lastAlert.threshold }}{{ health.lastAlert.dateStr }}
</div>
</el-card>
<!-- 小程序 · 推荐文章展示神仙 AI / 我的 -->
<el-card class="ai-chat-display-card" shadow="never">
<template #header>
<span>小程序 · 推荐文章展示</span>
</template>
<el-form label-width="160px" class="ai-chat-display-form">
<el-form-item label="神仙 AI 页展示">
<el-switch v-model="aiChatDisplay.enabled" />
<span class="form-hint-inline">开启后神仙 AI 页显示精选推荐列表</span>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="最多展示条数">
<el-radio-group v-model="aiChatDisplay.maxShow">
<el-radio :label="1">1 </el-radio>
<el-radio :label="2">2 </el-radio>
<el-radio :label="3">3 </el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="默认展开列表">
<el-switch v-model="aiChatDisplay.sectionExpandedDefault" />
<span class="form-hint-inline">关闭则默认收起用户点击精选推荐后展开</span>
</el-form-item>
<el-divider content-position="left">我的 · 底部推荐条</el-divider>
<el-form-item label="我的页展示首条">
<el-switch v-model="aiChatDisplay.profileRecoEnabled" />
<span class="form-hint-inline">我的快捷入口下方展示<strong>当前推荐排序第 1 </strong>灰色字可点</span>
</el-form-item>
<el-form-item v-if="aiChatDisplay.profileRecoEnabled" label="区块标题 / 标签">
<el-input
v-model="aiChatDisplay.profileSectionLabel"
maxlength="32"
show-word-limit
placeholder="如:一场创业实验、精选阅读"
style="max-width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingAcDisplay" @click="saveAiChatDisplay">
保存展示设置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 推荐位概览 -->
<el-card class="reco-card" shadow="hover">
<template #header>
<div class="card-header">
<span>当前推荐至多 3 · 需在上方开启小程序展示后按条数展示</span>
<div class="reco-head-actions">
<span class="reco-count">{{ recommended.length }} / 3</span>
<el-button size="small" @click="onNormalizeOrder">一键归一排序</el-button>
</div>
</div>
</template>
<el-empty v-if="recommended.length === 0" description="还没有推荐文章,去下方列表勾选一篇吧" />
<div v-else class="reco-list">
<div v-for="(a, idx) in recommended" :key="a.id" class="reco-item">
<div class="reco-rank">{{ idx + 1 }}</div>
<img v-if="a.cover" :src="a.cover" class="reco-cover" />
<div v-else class="reco-cover reco-cover--placeholder">MBTI</div>
<div class="reco-body">
<div class="reco-title">{{ a.title }}</div>
<div class="reco-meta">
<el-tag size="small" type="primary">{{ a.tag || 'MBTI' }}</el-tag>
<span class="meta-text">{{ formatDate(a.publishedAt) }}</span>
</div>
</div>
<el-button size="small" @click="onToggleRecommend(a)">取消推荐</el-button>
</div>
</div>
</el-card>
<!-- 候选文章列表 -->
<el-card class="list-card" shadow="hover">
<template #header>
<div class="card-header card-header-col">
<span>候选文章池</span>
<div class="filter-row">
<el-input
v-model="keyword"
placeholder="按标题关键词筛选"
clearable
style="width: 220px"
@keyup.enter="onSearch"
/>
<el-input
v-model="tagFilter"
placeholder="标签(如 MBTI"
clearable
style="width: 140px"
@keyup.enter="onSearch"
/>
<el-date-picker
v-model="dateRange"
type="daterange"
unlink-panels
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 320px"
/>
<el-radio-group v-model="filter" size="small" @change="fetchList">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="0">未推荐</el-radio-button>
<el-radio-button label="1">已推荐</el-radio-button>
</el-radio-group>
<el-button type="primary" plain @click="onSearch">筛选</el-button>
<el-button @click="onReset">重置</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
<el-table-column label="封面" width="100">
<template #default="{ row }">
<img v-if="row.cover" :src="row.cover" style="width: 80px; height: 50px; object-fit: cover; border-radius: 6px;" />
<div v-else style="width: 80px; height: 50px; background: #a78bfa; color: white; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px;">MBTI</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="320" show-overflow-tooltip />
<el-table-column prop="tag" label="标签" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.tag || 'MBTI' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="author" label="作者" width="120" />
<el-table-column label="发布时间" width="160">
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="阅读" width="80">
<template #default="{ row }">{{ row.viewCount || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.isRecommended" type="success" size="small">推荐中</el-tag>
<el-tag v-else type="info" size="small">未推荐</el-tag>
</template>
</el-table-column>
<el-table-column label="置顶权重" width="120" align="center">
<template #default="{ row }">
<div v-if="row.isRecommended" class="order-cell">
<el-input-number
v-model="row.recommendedOrder"
:min="0"
:max="999"
:controls="false"
size="small"
style="width: 72px"
/>
<el-button size="small" type="primary" link @click="onUpdateOrder(row)">保存</el-button>
</div>
<span v-else class="order-empty"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="preview(row)">预览</el-button>
<el-button size="small" :type="row.isRecommended ? 'warning' : 'success'" @click="onToggleRecommend(row)">
{{ row.isRecommended ? '取消' : '推荐' }}
</el-button>
<el-button size="small" type="danger" link @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="fetchList"
@size-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
const loading = ref(false)
const syncing = ref(false)
const searching = ref(false)
const checking = ref(false)
const list = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filter = ref<string>('')
const searchKeyword = ref('')
const keyword = ref('')
const tagFilter = ref('')
const dateRange = ref<string[]>([])
const health = ref<any>({ loaded: false, providers: [], lastAlert: null, hasAlert: false })
const aiChatDisplay = ref({
enabled: false,
maxShow: 1,
sectionExpandedDefault: false,
profileRecoEnabled: false,
profileSectionLabel: '推荐阅读'
})
const savingAcDisplay = ref(false)
const recommended = computed(() => list.value.filter(x => x.isRecommended))
const formatDate = (ts: number) => {
if (!ts) return '—'
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
const pad = (n: number) => n < 10 ? '0' + n : '' + n
const fetchList = async () => {
loading.value = true
try {
const res: any = await request.get('/superadmin/soul-articles', {
params: {
page: page.value,
pageSize: pageSize.value,
...(filter.value !== '' ? { isRecommended: filter.value } : {}),
...(keyword.value ? { keyword: keyword.value } : {}),
...(tagFilter.value ? { tag: tagFilter.value } : {}),
...(dateRange.value && dateRange.value.length === 2 ? { dateRange: dateRange.value } : {})
}
})
if (res && res.code === 200) {
list.value = (res.data && res.data.list) || []
total.value = (res.data && res.data.total) || 0
}
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
const onSearch = () => {
page.value = 1
fetchList()
}
const onReset = () => {
keyword.value = ''
tagFilter.value = ''
dateRange.value = []
filter.value = ''
page.value = 1
fetchList()
}
const onSync = async () => {
syncing.value = true
try {
const res: any = await request.post('/superadmin/soul-articles/sync', { limit: 10, tag: 'MBTI' })
if (res && res.code === 200) {
const d = res.data || {}
ElMessage.success(`采集完成:新增 ${d.created || 0} 篇,更新 ${d.updated || 0}`)
page.value = 1
fetchList()
} else {
ElMessage.error((res && res.message) || '采集失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '采集失败,请稍后再试')
} finally {
syncing.value = false
}
}
const onSearchImport = async () => {
const keyword = (searchKeyword.value || '').trim()
if (!keyword) {
ElMessage.warning('请先输入关键词')
return
}
searching.value = true
try {
const res: any = await request.post('/superadmin/soul-articles/sync', {
limit: 10,
tag: 'MBTI',
keyword
})
if (res && res.code === 200) {
const d = res.data || {}
ElMessage.success(`已搜索并添加:新增 ${d.created || 0} 篇,更新 ${d.updated || 0}`)
page.value = 1
fetchList()
} else {
ElMessage.error((res && res.message) || '搜索添加失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '搜索添加失败')
} finally {
searching.value = false
}
}
const onToggleRecommend = async (row: any) => {
const current = list.value.filter(x => x.isRecommended).length
if (!row.isRecommended && current >= 3) {
const r = await ElMessageBox.confirm(
'当前已有 3 篇推荐,继续推荐将自动顶掉最老的一篇,确认继续?',
'推荐已满',
{ type: 'warning' }
).catch(() => 'cancel')
if (r === 'cancel') return
}
try {
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/recommend`)
if (res && res.code === 200) {
ElMessage.success(res.message || '操作成功')
fetchList()
} else {
ElMessage.error((res && res.message) || '操作失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
}
}
const onDelete = async (row: any) => {
const r = await ElMessageBox.confirm(`确认删除「${row.title}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
if (r === 'cancel') return
try {
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/delete`)
if (res && res.code === 200) {
ElMessage.success('已删除')
fetchList()
}
} catch (e: any) {
ElMessage.error(e?.message || '删除失败')
}
}
const onUpdateOrder = async (row: any) => {
try {
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/order`, {
recommendedOrder: Number(row.recommendedOrder || 0),
})
if (res && res.code === 200) {
ElMessage.success('置顶权重已更新')
fetchList()
} else {
ElMessage.error((res && res.message) || '更新失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '更新失败')
}
}
const onNormalizeOrder = async () => {
try {
const res: any = await request.post('/superadmin/soul-articles/reorder-normalize')
if (res && res.code === 200) {
ElMessage.success('推荐权重已归一')
fetchList()
} else {
ElMessage.error((res && res.message) || '归一失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '归一失败')
}
}
const preview = (row: any) => {
if (!row.url) {
ElMessage.warning('该文章无 URL')
return
}
window.open(row.url, '_blank')
}
const fetchAiChatDisplay = async () => {
try {
const res: any = await request.get('/superadmin/soul-articles/ai-chat-display')
if (res && res.code === 200 && res.data) {
const d = res.data
aiChatDisplay.value = {
enabled: !!d.enabled,
maxShow: Math.max(1, Math.min(3, Number(d.maxShow) || 1)),
sectionExpandedDefault: !!d.sectionExpandedDefault,
profileRecoEnabled: !!d.profileRecoEnabled,
profileSectionLabel: String(d.profileSectionLabel || '推荐阅读').slice(0, 32)
}
}
} catch (e) {}
}
const saveAiChatDisplay = async () => {
savingAcDisplay.value = true
try {
const res: any = await request.post('/superadmin/soul-articles/ai-chat-display', {
enabled: aiChatDisplay.value.enabled,
maxShow: aiChatDisplay.value.maxShow,
sectionExpandedDefault: aiChatDisplay.value.sectionExpandedDefault,
profileRecoEnabled: aiChatDisplay.value.profileRecoEnabled,
profileSectionLabel: aiChatDisplay.value.profileSectionLabel
})
if (res && res.code === 200) {
ElMessage.success('展示设置已保存')
await fetchAiChatDisplay()
} else {
ElMessage.error((res && res.message) || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingAcDisplay.value = false
}
}
const fetchHealth = async () => {
try {
const res: any = await request.get('/superadmin/ai/health')
if (res && res.code === 200) {
const d = res.data || {}
const providers = Array.isArray(d.providers) ? d.providers : []
const hasAlert = providers.some((p: any) => p.status === 'low-balance' || p.status === 'no-key') || !!d.lastAlert
health.value = { loaded: true, providers, lastAlert: d.lastAlert || null, hasAlert }
}
} catch (e) {}
}
const onBalanceCheck = async () => {
checking.value = true
try {
const res: any = await request.post('/superadmin/ai/balance-check')
if (res && res.code === 200) {
const d = res.data || {}
ElMessage.success(`扫描完成:推送 ${d.alerted || 0} 条告警,跳过 ${d.skipped || 0} 条(当日去重)`)
fetchHealth()
} else {
ElMessage.error((res && res.message) || '扫描失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '扫描失败')
} finally {
checking.value = false
}
}
onMounted(() => {
fetchList()
fetchHealth()
fetchAiChatDisplay()
})
</script>
<style scoped>
.page-container {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.header-left h2 {
margin: 0 0 8px 0;
font-size: 22px;
color: #1F1B4D;
}
.subtitle {
color: #6B6894;
font-size: 13px;
line-height: 1.6;
max-width: 720px;
}
.reco-card,
.list-card {
margin-bottom: 20px;
}
.ai-chat-display-card {
margin-bottom: 16px;
border: 1px solid #E9D5FF;
background: #FAF5FF;
}
.ai-chat-display-form {
max-width: 720px;
}
.form-hint-inline {
margin-left: 12px;
font-size: 12px;
color: #6B6894;
vertical-align: middle;
}
.health-card {
margin-bottom: 16px;
border: 1px solid #E5E7EB;
background: #F9FAFB;
}
.health-card--warn {
background: #FEF2F2;
border-color: #FECACA;
}
.health-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.health-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #1F1B4D;
min-width: 180px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot-ok { background: #10B981; }
.dot-warn { background: #EF4444; }
.health-providers {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
min-width: 240px;
}
.provider-chip {
padding: 4px 10px;
border-radius: 999px;
background: #EEF2FF;
color: #4338CA;
font-size: 12px;
font-weight: 500;
}
.chip-warn {
background: #FEE2E2;
color: #B91C1C;
}
.health-actions {
display: flex;
gap: 8px;
}
.health-alert {
margin-top: 10px;
font-size: 12px;
color: #B91C1C;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-col {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.order-cell {
display: inline-flex;
align-items: center;
gap: 4px;
}
.order-empty {
color: #9CA3AF;
}
.reco-count {
color: #7c3aed;
font-weight: 600;
}
.reco-head-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.reco-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.reco-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
border-radius: 8px;
background: #F5F3FF;
border: 1px solid #EDE9FE;
}
.reco-rank {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.reco-cover {
width: 120px;
height: 72px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.reco-cover--placeholder {
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.reco-body {
flex: 1;
min-width: 0;
}
.reco-title {
font-size: 15px;
color: #1F1B4D;
font-weight: 600;
margin-bottom: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.reco-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6B6894;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -34,14 +34,17 @@ export default defineConfig({
},
server: {
host: '0.0.0.0', // 允许局域网访问
port: 5173, // 首选端口
strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
port: Number(process.env.MBTI_ADMIN_PORT) || 5173,
strictPort: true, // 端口被占用则直接失败,避免静默改端口导致「打不开」
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,
// 云库首次连库较慢时避免代理提前断开(毫秒)
timeout: Number(process.env.VITE_PROXY_TIMEOUT_MS) || 180000,
proxyTimeout: Number(process.env.VITE_PROXY_TIMEOUT_MS) || 180000,
},
},
}

View File

@@ -0,0 +1,10 @@
# 复制本文件为 api/.env.local.merge与 .env 同级),再执行:
# python3 scripts/apply_bt_download_env.py <宝塔下载的文件路径>
# 脚本会把宝塔 .env 写入 api/.env并把下面内容追加在末尾后面的键覆盖前面的
# 示例:宝塔里数据库写的是 127.0.0.1(指服务器本机),你在本机用 SSH 隧道映射到 13306 时:
# DATABASE_HOSTNAME = 127.0.0.1
# DATABASE_HOSTPORT = 13306
# 示例:强制本机调试
# APP_DEBUG = true

View File

@@ -0,0 +1,132 @@
<?php
namespace app\common;
/**
* 埋点事件中文名字典
* 配合 analytics_events.eventName 展示
*/
class AnalyticsEventLabels
{
/** @var array<string,string> */
private static $MAP = [
// 基础
'page_view' => '页面浏览',
'app_launch' => '小程序启动',
'share' => '分享',
'pay_success' => '支付成功',
'pay_fail' => '支付失败',
'pay_success_attribution' => '支付成功·归因',
// 首页 / 拍照入口
'tap_start_camera' => '点击·开始拍照测试',
'tap_questionnaire_home' => '点击·首页·去做问卷',
'tap_enterprise_entry' => '点击·切换企业版',
'tap_test_select' => '点击·选择测评类型',
// 问卷类
'test_start' => '开始答题',
'test_submit' => '提交答题',
'test_complete' => '答题完成',
'test_next' => '下一题',
'test_prev' => '上一题',
'tap_upload_photo_home' => '点击·首页·上传照片',
// 付费墙
'paywall_view' => '付费墙·曝光',
// 结果页通用
'tap_read_full' => '点击·看全文',
'tap_share_moment' => '点击·分享到朋友圈',
'tap_share_friend' => '点击·分享给好友',
'tap_face_camera' => '点击·去拍照',
'tap_complete_profile' => '点击·去完善资料',
'tap_unlock_full' => '点击·解锁完整报告',
'tap_retake_test' => '点击·重新测试',
// 深度解读 / 推广(带测评类型后缀)
'tap_deep_service_from_mbti' => '点击·MBTI·深度解读方案',
'tap_deep_service_from_disc' => '点击·DISC·深度解读方案',
'tap_deep_service_from_pdp' => '点击·PDP·深度解读方案',
'tap_deep_service_from_sbti' => '点击·SBTI·深度解读方案',
'tap_deep_service_face_result' => '点击·面相·深度解读方案',
'tap_promo_from_mbti' => '点击·MBTI·推广中心',
'tap_promo_from_disc' => '点击·DISC·推广中心',
'tap_promo_from_pdp' => '点击·PDP·推广中心',
'tap_promo_from_sbti' => '点击·SBTI·推广中心',
'tap_promo_face_result' => '点击·面相·推广中心',
// 面相成交链路
'tap_questionnaire_face_result' => '点击·面相页·补做问卷',
// 我的 / 推广中心
'tap_deep_service' => '点击·我的·深度解读方案',
'tap_my_orders' => '点击·我的·我的订单',
'tap_promo_center' => '点击·进入推广中心',
'tap_promo_poster' => '点击·生成推广海报',
'tap_promo_withdraw' => '点击·提现',
'tap_promo_share' => '点击·推广分享',
// 面相 / AI 结果页
'face_analyze_success' => '面相分析成功',
'face_analyze_fail' => '面相分析失败',
// 企业 / 简历
'tap_resume_upload' => '点击·上传简历',
'tap_resume_analyze' => '点击·简历分析',
// 登录 / 绑手机
'login_silent_success' => '静默登录成功',
'login_silent_fail' => '静默登录失败',
'bind_phone_success' => '绑定手机成功',
'bind_phone_fail' => '绑定手机失败',
// 神仙 AI功能六
'tap_tab_ai_chat' => '点击·底部Tab·神仙AI',
'ai_chat_send' => '神仙AI·发送消息',
'ai_chat_receive' => '神仙AI·收到回复',
'ai_chat_degrade' => '神仙AI·降级兜底',
'ai_quick_question_click' => '神仙AI·快捷问题点击',
'tap_ai_article' => '神仙AI·点击推荐文章',
'ai_article_read' => '神仙AI·文章阅读完成',
'ai_chat_share' => '神仙AI·分享对话',
'ai_chat_share_invite_tap' => '神仙AI·点击邀请赚佣金',
'ai_report_cta_tap' => '神仙AI·点击深度报告CTA',
'ai_report_pay_tap' => '神仙AI·深度报告点击支付',
'ai_report_pay_success' => '神仙AI·深度报告支付成功',
'ai_report_share' => '神仙AI·分享深度报告',
];
/**
* 获取事件中文名。未命中时回退到原 eventName。
*/
public static function cn(string $eventName): string
{
$name = trim($eventName);
if ($name === '') return '';
return self::$MAP[$name] ?? $name;
}
/**
* 批量翻译:在数组每行补 eventNameCn 字段
* @param array<int,array<string,mixed>> $rows
*/
public static function withCn(array $rows, string $field = 'eventName', string $cnField = 'eventNameCn'): array
{
foreach ($rows as &$r) {
$name = isset($r[$field]) ? (string) $r[$field] : '';
$r[$cnField] = self::cn($name);
}
unset($r);
return $rows;
}
/**
* 返回整张字典(供管理端下拉筛选)
* @return array<string,string>
*/
public static function all(): array
{
return self::$MAP;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\common\service;
use app\model\AiProvider as AiProviderModel;
use think\facade\Db;
use think\facade\Log;
/**
* AI 服务商欠费预警
*
* 复用 `feishu_lead_webhook` 的 webhookUrl但触发文案独立不用走 lead 模板);
* 每个服务商·每天·同阈值只推一次ai_balance_alerts 表)。
*
* 触发建议:
* 1) 超管页面手动点击「余额检查」
* 2) 宝塔/Linux crontab 每天 9:00 + 21:00 POST 调用
* curl -X POST -H "Authorization: Bearer {SUPERADMIN_TOKEN}" https://.../api/v1/superadmin/ai/balance-check
*/
class AiBalanceAlertService
{
/**
* 扫描所有启用且开启预警的服务商,低于阈值则推飞书
* @return array{alerted:int,skipped:int,items:array<int,array<string,mixed>>}
*/
public static function scanAndAlert(): array
{
$rows = AiProviderModel::where('enabled', 1)
->where('balanceAlertEnabled', 1)
->whereNotNull('lastBalance')
->select()
->toArray();
$alerted = 0;
$skipped = 0;
$items = [];
foreach ($rows as $row) {
$threshold = (float) ($row['balanceAlertThreshold'] ?? 0);
$balance = (float) $row['lastBalance'];
if ($threshold <= 0 || $balance > $threshold) {
continue;
}
$dateStr = date('Y-m-d');
$dup = Db::name('ai_balance_alerts')
->where('providerId', $row['providerId'])
->where('dateStr', $dateStr)
->find();
if ($dup) {
$skipped++;
$items[] = ['providerId' => $row['providerId'], 'status' => 'dedup'];
continue;
}
$ok = self::pushFeishu($row, $balance, $threshold);
if ($ok) {
try {
Db::name('ai_balance_alerts')->insert([
'providerId' => $row['providerId'],
'balance' => $balance,
'threshold' => $threshold,
'currency' => $row['lastBalanceCurrency'] ?? 'CNY',
'alertedAt' => time(),
'dateStr' => $dateStr,
]);
} catch (\Throwable $e) {}
$alerted++;
$items[] = ['providerId' => $row['providerId'], 'status' => 'alerted', 'balance' => $balance, 'threshold' => $threshold];
} else {
$items[] = ['providerId' => $row['providerId'], 'status' => 'send-failed'];
}
}
return ['alerted' => $alerted, 'skipped' => $skipped, 'items' => $items];
}
private static function pushFeishu(array $row, float $balance, float $threshold): bool
{
$cfg = FeishuLeadWebhookService::getConfig();
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
if ($url === '' || stripos($url, 'http') !== 0) {
Log::warning('AiBalanceAlertService: feishu webhookUrl 未配置');
return false;
}
$currency = ($row['lastBalanceCurrency'] ?? 'CNY') === 'USD' ? '$' : '¥';
$now = date('Y-m-d H:i');
$text = "⚠️ AI 服务余额预警\n"
. "服务商: {$row['name']} ({$row['providerId']})\n"
. "当前余额: {$currency}" . number_format($balance, 2) . "\n"
. "告警阈值: {$currency}" . number_format($threshold, 2) . "\n"
. "━━━━━━━━━━\n"
. "神仙 AI 对话 / 面相分析可能受影响,已自动把该服务商降权到故障切换队尾。\n"
. "请尽快到后台充值:\n"
. "时间: {$now}";
$payload = stripos($url, 'qyapi.weixin.qq.com') !== false
? ['msgtype' => 'text', 'text' => ['content' => $text]]
: ['msg_type' => 'text', 'content' => ['text' => $text]];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) {
Log::warning("AiBalanceAlertService feishu push http={$code}");
return false;
}
if ($body !== false && $body !== '') {
$resp = json_decode($body, true);
if (is_array($resp)) {
if (isset($resp['code']) && (int) $resp['code'] !== 0) return false;
if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,623 @@
<?php
namespace app\common\service;
use app\model\AiProvider as AiProviderModel;
use think\facade\Db;
use think\facade\Log;
/**
* 神仙 AI · 统一对话调用服务
*
* 能力:
* 1. 自动按 sortWeight / id 升序挑选可用服务商
* 2. 余额低于阈值的服务商自动降权沉底
* 3. 故障自动切换429/401/402/5xx/解析错误)
* 4. 支持 OpenAI 兼容 / Anthropic 两类协议
* 5. 全部失败返回降级兜底回答,**不抛给前端**
*
* 典型用法:
* $r = AiCallService::chat([
* ['role'=>'system','content'=>'你是…'],
* ['role'=>'user','content'=>'我是 INFP今天压力大'],
* ]);
* // => ['content'=>'...', 'providerId'=>'deepseek', 'isDegraded'=>false]
*/
class AiCallService
{
/**
* 对话入口
*
* @param array $messages OpenAI messages 格式
* @param array $options ['temperature'=>0.7, 'maxTokens'=>2048, 'stream'=>false]
* @return array ['content'=>string, 'providerId'=>string, 'isDegraded'=>bool, 'tokensIn'=>int, 'tokensOut'=>int]
*/
public static function chat(array $messages, array $options = []): array
{
$maxTokens = (int) ($options['maxTokens'] ?? 2048);
$temperature = isset($options['temperature']) ? (float) $options['temperature'] : 0.7;
$providers = self::resolveProviders();
if (empty($providers)) {
return self::degrade('no-provider', '小神仙在喝茶呢,超管还没配置好法力,稍后再来找我呀~');
}
$lastError = '';
foreach ($providers as $provider) {
$providerId = strtolower((string) ($provider->providerId ?? ''));
$apiKey = $provider->getRawApiKey();
if ($apiKey === '') {
continue;
}
$endpoint = self::resolveEndpoint($provider);
$model = !empty($provider->model) ? $provider->model : self::defaultModel($providerId);
try {
if ($providerId === 'anthropic') {
$content = self::callAnthropic($endpoint, $apiKey, $model, $messages, $maxTokens);
} else {
// openai / deepseek / moonshot / qwen / zhipu / zhizengzeng / groq 等
$content = self::callOpenAICompatible($endpoint, $apiKey, $model, $messages, $maxTokens, $temperature);
}
if ($content === '') {
$lastError = "provider={$providerId} empty-content";
continue;
}
return [
'content' => $content,
'providerId' => $providerId,
'model' => $model,
'isDegraded' => false,
'tokensIn' => 0, // 各家返回字段不一,留空(可二期补 usage
'tokensOut' => 0,
];
} catch (\Throwable $e) {
$lastError = "provider={$providerId} error=" . $e->getMessage();
Log::warning('AiCallService chat fallback: ' . $lastError);
// 遇到明显欠费/鉴权类错误,给该服务商的余额打低权重沉底
if (self::looksLikeBillingError($e->getMessage())) {
try {
$provider->sortWeight = 999;
$provider->save();
} catch (\Throwable $ignore) {}
}
continue;
}
}
return self::degrade('all-failed', '小神仙暂时联系不上天界了(' . $lastError . '),工程师已收到告警,请稍后再试 🌿');
}
/**
* 选出可用服务商列表(按 sortWeight 升序、余额不足的沉底)
* @return AiProviderModel[]
*/
private static function resolveProviders(): array
{
$all = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('sortWeight', 'asc')
->order('id', 'asc')
->select()
->toArray();
if (empty($all)) {
return [];
}
// 把「余额低于阈值」的服务商排到队尾
$healthy = [];
$depleted = [];
foreach ($all as $row) {
$provider = AiProviderModel::where('id', $row['id'])->find();
if (!$provider) continue;
$isLowBalance = $provider->balanceAlertEnabled == 1
&& $provider->lastBalance !== null
&& $provider->lastBalance <= (float) ($provider->balanceAlertThreshold ?? 0);
if ($isLowBalance) {
$depleted[] = $provider;
} else {
$healthy[] = $provider;
}
}
return array_merge($healthy, $depleted);
}
private static function resolveEndpoint($provider): string
{
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : '';
if ($endpoint !== '') return $endpoint;
return self::defaultEndpoint(strtolower((string) $provider->providerId));
}
private static function defaultEndpoint(string $providerId): string
{
switch ($providerId) {
case 'openai': return 'https://api.openai.com/v1';
case 'groq': return 'https://api.groq.com/openai/v1';
case 'deepseek': return 'https://api.deepseek.com/v1';
case 'moonshot': return 'https://api.moonshot.ai/v1';
case 'qwen': return 'https://dashscope.aliyuncs.com/compatible-mode/v1';
case 'anthropic': return 'https://api.anthropic.com/v1';
case 'zhipu': return 'https://api.z.ai/api/paas/v4';
case 'zhizengzeng': return 'https://api.zhizengzeng.com/v1';
default: return 'https://api.openai.com/v1';
}
}
private static function defaultModel(string $providerId): string
{
switch ($providerId) {
case 'deepseek': return 'deepseek-chat';
case 'moonshot': return 'moonshot-v1-8k';
case 'qwen': return 'qwen-turbo';
case 'zhipu': return 'glm-4-flash';
case 'anthropic': return 'claude-3-5-haiku-latest';
case 'groq': return 'llama-3.1-70b-versatile';
default: return 'gpt-4o-mini';
}
}
/**
* OpenAI 兼容协议deepseek / moonshot / qwen / zhipu / zhizengzeng / groq / openai…
*/
private static function callOpenAICompatible(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens, float $temperature): string
{
$body = [
'model' => $model,
'messages' => $messages,
'temperature' => $temperature,
];
if (strpos($endpoint, 'api.zhizengzeng.com') !== false) {
$body['max_completion_tokens'] = $maxTokens;
} else {
$body['max_tokens'] = $maxTokens;
}
$url = rtrim($endpoint, '/') . '/chat/completions';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrNo = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $response === '') {
throw new \RuntimeException("curl-failed code={$curlErrNo} msg={$curlError}");
}
if ($httpCode >= 400) {
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
}
$data = json_decode($response, true);
if (!is_array($data)) {
throw new \RuntimeException('invalid-json');
}
if (!empty($data['error'])) {
$errMsg = is_array($data['error']) ? ($data['error']['message'] ?? json_encode($data['error'])) : (string) $data['error'];
throw new \RuntimeException('api-error: ' . $errMsg);
}
$text = $data['choices'][0]['message']['content'] ?? '';
return trim((string) $text);
}
/**
* Anthropic Messages 协议
*/
private static function callAnthropic(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens): string
{
// Anthropic 需要单独传 systemmessages 只接受 user/assistant
$system = '';
$userMessages = [];
foreach ($messages as $m) {
if (($m['role'] ?? '') === 'system') {
$system .= "\n" . ($m['content'] ?? '');
} else {
$userMessages[] = [
'role' => $m['role'] ?? 'user',
'content' => [['type' => 'text', 'text' => (string) ($m['content'] ?? '')]],
];
}
}
$body = [
'model' => $model,
'max_tokens' => $maxTokens,
'messages' => $userMessages,
];
if (trim($system) !== '') {
$body['system'] = trim($system);
}
$url = rtrim($endpoint, '/') . '/messages';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $response === '') {
throw new \RuntimeException('anthropic-no-response');
}
if ($httpCode >= 400) {
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
}
$data = json_decode($response, true);
$text = '';
if (!empty($data['content']) && is_array($data['content'])) {
foreach ($data['content'] as $block) {
if (($block['type'] ?? '') === 'text') {
$text .= (string) ($block['text'] ?? '');
}
}
}
return trim($text);
}
/**
* 常见欠费/鉴权类错误关键字 → 触发降权
*/
private static function looksLikeBillingError(string $msg): bool
{
$lower = strtolower($msg);
foreach (['insufficient', 'balance', 'quota', 'rate_limit', '402', '429', 'unauthorized', '401'] as $kw) {
if (strpos($lower, $kw) !== false) return true;
}
return false;
}
private static function degrade(string $reason, string $content): array
{
Log::warning('AiCallService degraded: ' . $reason);
return [
'content' => $content,
'providerId' => 'degrade',
'model' => '',
'isDegraded' => true,
'tokensIn' => 0,
'tokensOut' => 0,
];
}
/**
* 构造针对某用户的 system prompt结合 MBTI 类型 + 画像)
*/
public static function buildSystemPrompt(array $userContext): string
{
$mbtiType = trim((string) ($userContext['mbtiType'] ?? ''));
$summary = trim((string) ($userContext['summary'] ?? ''));
$nickname = trim((string) ($userContext['nickname'] ?? ''));
$prompt = "你是「神仙 AI」神仙团队 MBTI 性格小程序的专属人格伙伴。\n";
$prompt .= "说话风格:像朋友一样亲切、简短(每次回答 150 字以内必要时可延展不堆砌术语适度鼓励用户完成其他测评DISC / PDP / SBTI / 面相)或去「一场 soul 创业实验」公众号阅读相关文章。\n";
$prompt .= "禁止:做医疗诊断 / 政治议题 / 具体投资建议。\n";
if ($nickname !== '') {
$prompt .= "称呼:可以偶尔叫用户「{$nickname}」。\n";
}
if ($mbtiType !== '') {
$prompt .= "用户 MBTI{$mbtiType}。请结合此类型做个性化回答。\n";
} else {
$prompt .= "用户尚未完成 MBTI 测试。回答时可友好建议先去做一下测评。\n";
}
if ($summary !== '') {
$prompt .= "用户性格画像摘要:{$summary}\n";
}
$appendix = trim((string) ($userContext['testAppendix'] ?? ''));
if ($appendix !== '') {
$prompt .= "\n【用户测评客观记录(含问卷选项,供你结合其当前提问做针对性回答;勿机械罗列题号套话)】\n";
$prompt .= $appendix . "\n";
}
return $prompt;
}
/**
* 拉取用户 MBTI 画像(最近一次 test_results 结果)
*/
public static function fetchUserContext(int $userId): array
{
if ($userId <= 0) {
return ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
}
$nickname = (string) Db::name('wechat_users')->where('id', $userId)->value('nickname');
$mbtiType = '';
$summary = '';
$row = Db::name('test_results')
->where('userId', $userId)
->where('testType', 'mbti')
->order('id', 'desc')
->find();
if ($row && !empty($row['resultData'])) {
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
if (is_array($data)) {
$mbtiType = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
if (!empty($data['description']['summary'])) {
$summary = (string) $data['description']['summary'];
} elseif (!empty($data['description']['overview'])) {
$summary = (string) $data['description']['overview'];
}
}
}
// 若无 MBTI尝试回落到面相分析给出的 mbti
if ($mbtiType === '') {
$faceRow = Db::name('test_results')
->where('userId', $userId)
->where('testType', 'face')
->order('id', 'desc')
->find();
if ($faceRow && !empty($faceRow['resultData'])) {
$data = is_string($faceRow['resultData']) ? json_decode($faceRow['resultData'], true) : $faceRow['resultData'];
if (is_array($data) && isset($data['mbti']['type'])) {
$mbtiType = (string) $data['mbti']['type'];
$summary = (string) ($data['personalitySummary'] ?? ($data['overview'] ?? ''));
}
}
}
$testAppendix = self::buildLatestTestsAppendix($userId);
return [
'mbtiType' => $mbtiType,
'summary' => $summary,
'nickname' => $nickname,
'testAppendix' => $testAppendix,
];
}
/**
* 汇总用户最近一次各类型测评的答题与结果,供 system prompt 使用(有长度上限)
*/
private static function buildLatestTestsAppendix(int $userId): string
{
$types = ['mbti', 'sbti', 'disc', 'pdp'];
$blocks = [];
foreach ($types as $type) {
$row = Db::name('test_results')
->where('userId', $userId)
->where('testType', $type)
->order('id', 'desc')
->find();
if (!$row || empty($row['resultData'])) {
continue;
}
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
if (!is_array($data)) {
continue;
}
$block = self::formatTestBlockForPrompt($type, $data);
if ($block !== '') {
$blocks[] = $block;
}
}
$text = implode("\n——\n", $blocks);
if ($text === '') {
return '';
}
$maxLen = 3800;
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8') . '…(档案已截断)';
}
return $text;
}
/**
* @param array<string, mixed> $data
*/
private static function formatTestBlockForPrompt(string $testType, array $data): string
{
switch ($testType) {
case 'mbti':
return self::formatMbtiBlockForPrompt($data);
case 'sbti':
return self::formatSbtiBlockForPrompt($data);
case 'disc':
return self::formatDiscBlockForPrompt($data);
case 'pdp':
return self::formatPdpBlockForPrompt($data);
default:
return '';
}
}
/**
* @param array<string, mixed>|null $answers
*/
private static function formatAnswersCompact(?array $answers): string
{
if (!is_array($answers) || $answers === []) {
return '';
}
$keys = array_keys($answers);
sort($keys, SORT_NATURAL);
$parts = [];
foreach ($keys as $k) {
$v = $answers[$k];
if (is_array($v) || is_object($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
}
$parts[] = (string) $k . '→' . (string) $v;
if (count($parts) >= 100) {
$parts[] = '…共' . count($keys) . '题';
break;
}
}
return implode('', $parts);
}
/**
* @param array<string, mixed> $data
*/
private static function formatMbtiBlockForPrompt(array $data): string
{
$lines = ['【MBTI·最近一次】'];
$type = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
if ($type !== '') {
$lines[] = '类型:' . $type;
}
if (isset($data['confidence'])) {
$lines[] = '置信度:' . (int) $data['confidence'] . '%';
}
if (!empty($data['dimensionScores']) && is_array($data['dimensionScores'])) {
$lines[] = '四轴:' . json_encode($data['dimensionScores'], JSON_UNESCAPED_UNICODE);
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '字母计数:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项(题号/ID→所选' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatSbtiBlockForPrompt(array $data): string
{
$lines = ['【SBTI·最近一次】'];
$final = $data['finalType'] ?? null;
$fcode = is_array($final) ? (string) ($final['code'] ?? '') : '';
$code = (string) ($data['sbtiType'] ?? $fcode);
if ($code !== '') {
$lines[] = '类型代码:' . $code;
}
if (!empty($data['special'])) {
$lines[] = '特殊标记:' . json_encode($data['special'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatDiscBlockForPrompt(array $data): string
{
$lines = ['【DISC·最近一次】'];
if (!empty($data['dominantType'])) {
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatPdpBlockForPrompt(array $data): string
{
$lines = ['【PDP·最近一次】'];
if (!empty($data['dominantType'])) {
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* 过滤不在小程序展示的快捷问句(与产品配置一致)
*
* @param string[] $questions
* @return string[]
*/
public static function filterQuickQuestions(array $questions): array
{
$out = [];
foreach ($questions as $q) {
$s = trim((string) $q);
if ($s === '') {
continue;
}
if (preg_match('/记\s*一下\s*我的\s*MBTI/ui', $s)) {
continue;
}
$out[] = $s;
}
return $out;
}
/**
* 基于 MBTI 类型返回 3 条快捷问句
*/
public static function quickQuestions(string $mbtiType): array
{
$mbtiType = strtoupper(trim($mbtiType));
$generic = [
'我应该找什么样的工作?',
'我适合什么样的伴侣?',
'我的职业发展方向是什么?',
'我最近有点迷茫,有什么建议?',
'帮我做一个简短的自我介绍',
'我有哪些需要警惕的盲点?',
];
if ($mbtiType === '') return $generic;
return [
"作为 {$mbtiType},我最大的优势和盲点是什么?",
"{$mbtiType} 适合什么样的工作?",
"{$mbtiType} 适合找什么样的伴侣?",
"{$mbtiType} 的职业发展路径推荐?",
"{$mbtiType} 如何处理职场人际关系?",
"{$mbtiType} 最容易掉进什么心理陷阱?",
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 神仙 AI 页 · 推荐文章区块展示(超管可配)
*
* system_config.key = ai_chat_articles, enterprise_id = 0
* JSON: { enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel }
*/
class AiChatArticleDisplayService
{
public const CONFIG_KEY = 'ai_chat_articles';
/** 我的页推荐条默认标题(后台可改) */
public const DEFAULT_PROFILE_SECTION_LABEL = '推荐阅读';
/**
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
*/
public static function getSettings(): array
{
$defaults = [
'enabled' => false,
'maxShow' => 1,
'sectionExpandedDefault' => false,
'profileRecoEnabled' => false,
'profileSectionLabel' => self::DEFAULT_PROFILE_SECTION_LABEL,
];
$row = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->find();
if (!$row || empty($row['value'])) {
return $defaults;
}
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (!is_array($v)) {
return $defaults;
}
$maxShow = (int) ($v['maxShow'] ?? 1);
if ($maxShow < 1) {
$maxShow = 1;
}
if ($maxShow > 3) {
$maxShow = 3;
}
$profileLabel = isset($v['profileSectionLabel']) ? trim((string) $v['profileSectionLabel']) : '';
if ($profileLabel === '') {
$profileLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
}
if (function_exists('mb_substr')) {
$profileLabel = mb_substr($profileLabel, 0, 32, 'UTF-8');
} else {
$profileLabel = substr($profileLabel, 0, 32);
}
return [
'enabled' => !empty($v['enabled']),
'maxShow' => $maxShow,
'sectionExpandedDefault' => !empty($v['sectionExpandedDefault']),
'profileRecoEnabled' => !empty($v['profileRecoEnabled']),
'profileSectionLabel' => $profileLabel,
];
}
/**
* @param array<string, mixed> $input
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
*/
public static function saveSettings(array $input): array
{
$rawLabel = isset($input['profileSectionLabel']) ? trim((string) $input['profileSectionLabel']) : '';
if ($rawLabel === '') {
$rawLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
}
if (function_exists('mb_substr')) {
$rawLabel = mb_substr($rawLabel, 0, 32, 'UTF-8');
} else {
$rawLabel = substr($rawLabel, 0, 32);
}
$settings = [
'enabled' => !empty($input['enabled']),
'maxShow' => max(1, min(3, (int) ($input['maxShow'] ?? 1))),
'sectionExpandedDefault' => !empty($input['sectionExpandedDefault']),
'profileRecoEnabled' => !empty($input['profileRecoEnabled']),
'profileSectionLabel' => $rawLabel,
];
$json = json_encode($settings, JSON_UNESCAPED_UNICODE);
$now = time();
$exists = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->find();
if ($exists) {
Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => self::CONFIG_KEY,
'enterprise_id' => 0,
'value' => $json,
'description' => '神仙 AI / 我的页:推荐文章展示配置',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return $settings;
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace app\common\service;
use app\model\AiReport as AiReportModel;
use think\facade\Db;
use think\facade\Log;
/**
* AI 深度画像报告服务
*
* 生命周期:
* pending创建→ paid支付成功 / devmarkPaidDev
* → generating后台开始调 AI → done生成完成
* 失败时置 failed + 自动重试retryCount<=3
*/
class AiReportService
{
const PRICE_FEN = 990; // 9.9 元
public static function createOrGetPending(int $userId, int $conversationId = 0, string $mbtiType = ''): array
{
// 已有 pending/paid/generating/done → 返回最新那条
$existing = AiReportModel::where('userId', $userId)
->order('id', 'desc')
->find();
if ($existing && in_array($existing->status, ['pending', 'paid', 'generating', 'done'])) {
// 已完成的报告允许用户再做一份?按默认:只有 done 才允许再发起新的
if ($existing->status !== 'done') {
return self::toArray($existing);
}
}
$orderSn = 'AIR' . date('YmdHis') . mt_rand(1000, 9999);
$model = AiReportModel::create([
'userId' => $userId,
'conversationId' => $conversationId ?: null,
'mbtiType' => $mbtiType ?: null,
'orderSn' => $orderSn,
'priceFen' => self::PRICE_FEN,
'status' => 'pending',
]);
return self::toArray($model);
}
public static function myLatest(int $userId): ?array
{
$m = AiReportModel::where('userId', $userId)->order('id', 'desc')->find();
return $m ? self::toArray($m) : null;
}
public static function get(int $id, int $userId): ?array
{
$m = AiReportModel::where('id', $id)->where('userId', $userId)->find();
return $m ? self::toArray($m) : null;
}
/**
* 支付成功回调:把 report 置为 paid并触发异步生成
* 同时执行订单分账(走 ProfitSharingService
*/
public static function markPaid(string $orderSn): array
{
$m = AiReportModel::where('orderSn', $orderSn)->find();
if (!$m) return ['status' => 'not-found'];
if ($m->status !== 'pending') {
// 已处理过
return ['status' => 'already', 'reportStatus' => $m->status];
}
$m->status = 'paid';
$m->paidAt = time();
$m->save();
// 分账idempotent
try {
ProfitSharingService::executeSharing(
$m->orderSn,
'ai_deep_report',
(int) $m->priceFen,
['userId' => (int) $m->userId, 'orderId' => (int) $m->id]
);
} catch (\Throwable $e) {
Log::warning('AiReportService profit-sharing failed: ' . $e->getMessage());
}
// 同步生成报告(若耗时 >30s 可改异步队列,此处 PHP fpm 环境先同步,失败自动降级)
self::generate((int) $m->id);
$fresh = AiReportModel::find($m->id);
return ['status' => 'ok', 'report' => self::toArray($fresh)];
}
/** 调试/内部:跳过支付直接 markPaid */
public static function markPaidDev(string $orderSn): array
{
return self::markPaid($orderSn);
}
/**
* 生成报告正文(调 AI
*/
public static function generate(int $reportId): void
{
$m = AiReportModel::find($reportId);
if (!$m) return;
if (!in_array($m->status, ['paid', 'failed', 'generating'])) return;
$m->status = 'generating';
$m->retryCount = (int) $m->retryCount + 1;
$m->save();
try {
$user = Db::name('user')->where('id', (int) $m->userId)->find();
$mbti = $m->mbtiType ?: ($user['mbti_type'] ?? '未知');
$nickname = $user['nickname'] ?? '朋友';
// 拉取最近对话作为输入素材
$history = [];
if (!empty($m->conversationId)) {
$msgs = Db::name('ai_messages')
->where('conversationId', (int) $m->conversationId)
->order('id', 'asc')
->limit(40)
->select()
->toArray();
foreach ($msgs as $msg) {
if ($msg['role'] === 'system') continue;
$history[] = ($msg['role'] === 'user' ? '【你】' : '【神仙AI】') . "" . $msg['content'];
}
}
$system = "你是「神仙 AI」要基于用户的 MBTI 类型与最近的对话,输出一份具个性化的深度画像报告。\n"
. "报告要求:\n"
. "1) 长度 1200-1800 字,分 5 个章节(性格内核 / 优势雷达 / 潜在盲点 / 亲密关系建议 / 下一步成长路径)\n"
. "2) 每章 2-3 段,语气温暖不说教;禁止玄学与迷信\n"
. "3) 首段总结一句,用一条「超短金句」概括这个人\n"
. "4) 使用第二人称「你」,称呼:{$nickname}\n"
. "5) Markdown 纯文本,章节用 「## 章节标题」 "
. "";
$prompt = "【用户 MBTI】{$mbti}\n【最近对话】\n" . (empty($history) ? '(无历史对话)' : implode("\n", $history));
$r = AiCallService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
], ['maxTokens' => 3000, 'temperature' => 0.75]);
$content = (string) ($r['content'] ?? '');
if ($content === '' || !empty($r['isDegraded'])) {
throw new \RuntimeException('AI 生成失败或降级:' . (string) ($r['content'] ?? ''));
}
$summary = self::extractSummary($content);
$title = self::extractTitle($content, $mbti);
$m->title = $title;
$m->summary = $summary;
$m->content = $content;
$m->status = 'done';
$m->generatedAt = time();
$m->lastError = null;
$m->save();
} catch (\Throwable $e) {
Log::warning('AiReportService generate fail: ' . $e->getMessage());
$m->status = $m->retryCount >= 3 ? 'failed' : 'paid';
$m->lastError = mb_substr($e->getMessage(), 0, 480);
$m->save();
}
}
private static function extractSummary(string $md): string
{
$plain = preg_replace('/[#>*_`\[\]]+/', '', $md);
$plain = trim((string) $plain);
return mb_substr($plain, 0, 180);
}
private static function extractTitle(string $md, string $mbti): string
{
if (preg_match('/^\s*#+\s*(.+)$/m', $md, $mch)) {
return mb_substr(trim($mch[1]), 0, 80);
}
return "{$mbti} · 神仙 AI 深度画像";
}
private static function toArray(AiReportModel $m): array
{
return [
'id' => (int) $m->id,
'userId' => (int) $m->userId,
'conversationId' => (int) $m->conversationId,
'mbtiType' => $m->mbtiType,
'orderSn' => $m->orderSn,
'priceFen' => (int) $m->priceFen,
'priceYuan' => round($m->priceFen / 100, 2),
'status' => $m->status,
'title' => $m->title,
'summary' => $m->summary,
'content' => $m->content,
'posterUrl' => $m->posterUrl,
'paidAt' => (int) $m->paidAt,
'generatedAt' => (int) $m->generatedAt,
'createdAt' => (int) $m->createdAt,
];
}
}

View File

@@ -76,6 +76,26 @@ class FeishuLeadWebhookService
]);
}
/**
* 小程序「了解自己」页提交咨询申请(与 CrmReport 存客宝上报并行;飞书机器人需 feishu_lead_webhook.enabled
*/
public static function onDeepServiceConsultApply(int $userId, string $source, string $categoryTag): void
{
if ($userId <= 0) {
return;
}
$src = trim($source) !== '' ? trim($source) : '深度服务·申请咨询';
$tag = trim($categoryTag);
$dedup = 'deep_consult_apply:' . $userId . ':' . md5($src . '|' . $tag) . ':' . date('YmdH');
$extra = $tag !== '' ? ('套餐: ' . self::oneLine($tag, 60)) : '';
self::pushLead([
'dedupKey' => $dedup,
'userId' => $userId,
'source' => $src,
'extraLine' => $extra,
]);
}
/**
* @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
*/
@@ -127,7 +147,7 @@ class FeishuLeadWebhookService
$text .= "\n" . $p['extraLine'];
}
$lines = self::recentBehaviorLines($userId, 8);
$lines = UserJourneyService::recentBehaviorLines($userId, 8);
if (count($lines) > 0) {
$text .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
@@ -184,85 +204,6 @@ class FeishuLeadWebhookService
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 {

View File

@@ -243,8 +243,29 @@ class OutboundPushHookService
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
}
$lines[] = '测试时间: ' . $testTime;
$body = implode('', $lines);
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
if ($mgmtSummary !== '') {
$body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary;
}
$um = $payload['userManagement'] ?? null;
if (is_array($um) && !empty($um['openidTail6'])) {
$body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6'];
}
$beh = $payload['recentBehaviors'] ?? [];
if (is_array($beh) && count($beh) > 0) {
$body .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
foreach ($beh as $bl) {
if (!is_string($bl) || $bl === '') {
continue;
}
$body .= "\n {$i}. {$bl}";
$i++;
}
}
return implode('', $lines);
return $body;
default:
return $head . "\n事件: " . $event;
}
@@ -590,6 +611,50 @@ class OutboundPushHookService
return 0;
}
/**
* 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源)
*
* @param array<string,mixed> $payload 引用
* @param array<string,mixed> $testResultRow test_results 一行
* @param array<string,mixed>|null $wuArr wechat_users 行(可含 openid
*/
private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void
{
if ($userId <= 0) {
$payload['recentBehaviors'] = [];
$payload['userManagement'] = [
'wechatUserId' => 0,
'enterpriseId' => 0,
'enterpriseName' => null,
'openidTail6' => null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['managementSummary'] = '';
return;
}
$eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr);
$tenant = self::tenantPayload($eidResolved);
$openid = '';
if ($wuArr !== null && isset($wuArr['openid'])) {
$openid = trim((string) $wuArr['openid']);
}
$tail = '';
if ($openid !== '') {
$tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid;
}
$payload['userManagement'] = [
'wechatUserId' => $userId,
'enterpriseId' => $eidResolved,
'enterpriseName' => $tenant['enterpriseName'],
'openidTail6' => $tail !== '' ? $tail : null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12);
$trEid = (int) ($testResultRow['enterpriseId'] ?? 0);
$payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid);
}
public static function isEventEnabled(string $event, array $cfg): bool
{
if (empty($cfg['enabled'])) {
@@ -712,7 +777,7 @@ class OutboundPushHookService
$completedAt = date('Y-m-d H:i:s', $createdAt);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
: null;
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
if ($userName === '') {
@@ -765,6 +830,7 @@ class OutboundPushHookService
$payload['resultDisc'] = $dims['disc'];
}
}
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
self::dispatch('test.result_completed', [
'event' => 'test.result_completed',
@@ -807,7 +873,7 @@ class OutboundPushHookService
$completedAt = date('Y-m-d H:i:s', $createdAt);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
: null;
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
if ($userName === '') {
@@ -860,6 +926,7 @@ class OutboundPushHookService
$payload['resultDisc'] = $dims['disc'];
}
}
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
return self::dispatchDetailed('test.result_completed', [
'event' => 'test.result_completed',

View File

@@ -0,0 +1,259 @@
<?php
namespace app\common\service;
use think\facade\Db;
use think\facade\Log;
/**
* 订单分账服务(参考知己 profit-sharing 思路)
*
* 用法:订单支付成功后调用 ::executeSharing($orderSn, $productType, $totalFen, $extra)
* - 按 mbti_profit_sharing_rules 配置把 totalFen 拆给多个 receiver
* - 对 distributor_l1 / distributor_l2会联动现有 mbti_commission_records 复用项目已有分销系统
* - 最终写入 mbti_profit_sharing_records 一行orderSn 唯一details JSON 里记所有明细
*
* 该服务 **idempotent**:同一 orderSn 重复调用不会重复分账。
*/
class ProfitSharingService
{
/**
* @param string $orderSn 订单号(唯一)
* @param string $productType 产品类型,对应 mbti_profit_sharing_rules.productType找不到则走 default
* @param int $totalFen 订单总金额(分)
* @param array $extra 扩展 ['userId'=>购买者id, 'orderId'=>订单表id, 'consultantId'=>?]
* @return array 分账明细
*/
public static function executeSharing(string $orderSn, string $productType, int $totalFen, array $extra = []): array
{
if ($totalFen <= 0 || $orderSn === '') {
return ['status' => 'skipped', 'reason' => 'invalid-input'];
}
$dup = Db::name('profit_sharing_records')->where('orderSn', $orderSn)->find();
if ($dup) {
return ['status' => 'dedup', 'recordId' => $dup['id']];
}
$rule = self::resolveRule($productType);
$receivers = $rule['receivers'] ?? [];
if (!is_array($receivers) || count($receivers) === 0) {
$receivers = [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]];
}
$totalRatio = 0.0;
foreach ($receivers as $r) { $totalRatio += (float) ($r['ratio'] ?? 0); }
if (abs($totalRatio - 1.0) > 0.001) {
Log::warning("ProfitSharingService: rule {$productType} ratio sum={$totalRatio}, will renormalize");
}
$now = time();
$details = [];
$buyerUserId = (int) ($extra['userId'] ?? 0);
// 一级 / 二级分销人解析(复用现有 distribution_bindings
list($inviterL1, $inviterL2) = self::resolveInviters($buyerUserId);
$allocated = 0;
foreach ($receivers as $idx => $r) {
$ratio = (float) ($r['ratio'] ?? 0);
$amount = (int) floor($totalFen * $ratio);
$type = (string) ($r['type'] ?? 'platform');
$account = null;
$status = 'success';
if ($type === 'distributor_l1') {
$account = $inviterL1 ? (string) $inviterL1 : null;
if (!$inviterL1) {
// 没有一级分销 → 金额归还平台
$status = 'no-inviter';
$amount = 0;
}
} elseif ($type === 'distributor_l2') {
$account = $inviterL2 ? (string) $inviterL2 : null;
if (!$inviterL2) {
$status = 'no-inviter';
$amount = 0;
}
} elseif ($type === 'consultant') {
$account = isset($extra['consultantId']) ? (string) $extra['consultantId'] : null;
}
$details[] = [
'receiverType' => $type,
'receiverName' => (string) ($r['name'] ?? $type),
'amount' => $amount,
'ratio' => $ratio,
'account' => $account,
'status' => $status,
];
$allocated += $amount;
}
// 尾差归平台
if ($allocated < $totalFen) {
$diff = $totalFen - $allocated;
foreach ($details as &$d) {
if ($d['receiverType'] === 'platform') { $d['amount'] += $diff; break; }
}
unset($d);
}
// 写主记录
$recordId = Db::name('profit_sharing_records')->insertGetId([
'orderSn' => $orderSn,
'orderId' => (int) ($extra['orderId'] ?? 0) ?: null,
'productType' => $productType,
'totalAmount' => $totalFen,
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
'status' => 'processing',
'createdAt' => $now,
'updatedAt' => $now,
]);
// 联动现有分销系统:把 distributor_l1/l2 的金额写进 mbti_commission_records
try {
foreach ($details as $d) {
if (in_array($d['receiverType'], ['distributor_l1', 'distributor_l2'], true)
&& (int) $d['amount'] > 0 && !empty($d['account'])) {
self::createCommissionRecord(
(int) $d['account'],
$buyerUserId,
$d['receiverType'] === 'distributor_l1' ? 1 : 2,
(int) $d['amount'],
(float) $d['ratio'],
$totalFen,
$orderSn,
(int) ($extra['orderId'] ?? 0)
);
}
}
} catch (\Throwable $e) {
Log::warning('ProfitSharingService commission hook failed: ' . $e->getMessage());
}
Db::name('profit_sharing_records')->where('id', $recordId)->update([
'status' => 'completed',
'processedAt' => time(),
'updatedAt' => time(),
]);
return [
'status' => 'completed',
'recordId' => $recordId,
'details' => $details,
];
}
private static function resolveRule(string $productType): array
{
$row = Db::name('profit_sharing_rules')
->where('productType', $productType)
->where('status', 'active')
->find();
if (!$row) {
$row = Db::name('profit_sharing_rules')
->where('productType', 'default')
->where('status', 'active')
->find();
}
if (!$row) {
return ['receivers' => [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]]];
}
$receivers = json_decode($row['receivers'] ?? '[]', true) ?: [];
return ['receivers' => $receivers];
}
/**
* 解析一级/二级分销人(复用 mbti_distribution_bindings
* @return array [inviterL1_userId|null, inviterL2_userId|null]
*/
private static function resolveInviters(int $buyerUserId): array
{
if ($buyerUserId <= 0) return [null, null];
try {
$now = time();
$bind1 = Db::name('distribution_bindings')
->where('inviteeId', $buyerUserId)
->where('status', 'active')
->where('expireAt', '>', $now)
->order('id', 'desc')
->find();
if (!$bind1) return [null, null];
$l1 = (int) $bind1['inviterId'];
$bind2 = Db::name('distribution_bindings')
->where('inviteeId', $l1)
->where('status', 'active')
->where('expireAt', '>', $now)
->order('id', 'desc')
->find();
$l2 = $bind2 ? (int) $bind2['inviterId'] : null;
return [$l1, $l2];
} catch (\Throwable $e) {
return [null, null];
}
}
/**
* 写入 mbti_commission_records复用项目已有的分销佣金表结构
* 若该表结构/字段命名不同,请在本方法内做适配;当前按 mbti_data.sql 中看到的字段写入
*/
private static function createCommissionRecord(
int $inviterId,
int $inviteeId,
int $level,
int $amountFen,
float $rate,
int $orderTotalFen,
string $orderSn,
int $orderId
): void {
$now = time();
// 防重:相同 orderSn + inviterId + level 只插一次
$dup = Db::name('commission_records')
->where('orderSn', $orderSn)
->where('inviterId', $inviterId)
->where('level', $level)
->find();
if ($dup) return;
// 字段按 mbti_commission_records 实际结构写amountFen 存分、amount 存元(容错)
$data = [
'inviterId' => $inviterId,
'inviteeId' => $inviteeId,
'level' => $level,
'rate' => $rate,
'amount' => round($amountFen / 100, 2),
'amountFen' => $amountFen,
'orderId' => $orderId ?: null,
'orderSn' => $orderSn,
'orderAmountFen'=> $orderTotalFen,
'status' => 'pending',
'scope' => 'personal',
'source' => 'ai_deep_report',
'createdAt' => $now,
'updatedAt' => $now,
];
try {
Db::name('commission_records')->insert($data);
} catch (\Throwable $e) {
// 字段不匹配时退化:只写关键字段
Log::warning('commission_records insert fallback: ' . $e->getMessage());
try {
Db::name('commission_records')->insert([
'inviterId' => $inviterId,
'inviteeId' => $inviteeId,
'level' => $level,
'rate' => $rate,
'amount' => round($amountFen / 100, 2),
'status' => 'pending',
'orderId' => $orderId ?: null,
'createdAt' => $now,
'updatedAt' => $now,
]);
} catch (\Throwable $e2) {
Log::error('commission_records insert failed: ' . $e2->getMessage());
}
}
}
}

View File

@@ -0,0 +1,441 @@
<?php
namespace app\common\service;
use app\model\SoulArticle as SoulArticleModel;
use app\model\SystemConfig as SystemConfigModel;
use think\facade\Db;
use think\facade\Log;
/**
* Soul 文章采集与推荐服务
*
* 数据源:一场 soul 创业实验 对外 API默认 base 见 resolveConfig
* 采集方式:仅本服务内 curl HTTP(S) 请求上述接口;不写磁盘拉取、不经 SSH、不连对方服务器 shell
* 鉴权system_config.key=`soul_api` 的 {baseUrl, token};环境变量 SOUL_API_BASE / SOUL_API_TOKEN 兜底
*/
class SoulArticleService
{
/**
* 按关键词搜索 Soul 内容并写入本地文章池
*/
public static function syncByKeyword(string $keyword, int $limit = 10, string $tag = 'MBTI'): array
{
$keyword = trim($keyword);
if ($keyword === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => '关键词不能为空'];
}
$cfg = self::resolveConfig();
if ($cfg['baseUrl'] === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
}
$base = rtrim($cfg['baseUrl'], '/');
$headers = ['Accept: application/json'];
if ($cfg['token'] !== '') {
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
}
// 优先尝试通用文章接口(若支持 keyword
$url = $base . '/api/articles?' . http_build_query([
'keyword' => $keyword,
'limit' => $limit,
'sort' => 'publishedAt:desc',
]);
[$response] = self::httpGet($url, $headers);
$data = is_string($response) ? json_decode($response, true) : null;
$list = [];
if (is_array($data)) {
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
$list = $data['data']['list'];
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
$list = $data['data'];
} elseif (isset($data['items']) && is_array($data['items'])) {
$list = $data['items'];
} elseif (isset($data[0])) {
$list = $data;
}
}
// 若无结果,回退到 book API 全量抓取后关键词过滤
if (empty($list)) {
$list = self::fetchFromSoulBookApi($base, max($limit * 3, 30), $tag, $keyword);
}
return self::upsertArticles($list, $tag, $limit);
}
/**
* 从 Soul 后台拉取最新 N 篇 MBTI 主题文章并写入本地 soul_articles 表
*
* @return array ['fetched'=>int, 'created'=>int, 'updated'=>int, 'items'=>[…]]
*/
public static function syncLatest(int $limit = 10, string $tag = 'MBTI'): array
{
$cfg = self::resolveConfig();
if ($cfg['baseUrl'] === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
}
$base = rtrim($cfg['baseUrl'], '/');
$url = $base . '/api/articles';
$params = [
'tag' => $tag,
'limit' => $limit,
'sort' => 'publishedAt:desc',
];
$url .= '?' . http_build_query($params);
$headers = ['Accept: application/json'];
if ($cfg['token'] !== '') {
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
}
[$response, $httpCode, $err] = self::httpGet($url, $headers);
if ($response === false || $response === '') {
Log::warning("SoulArticleService sync failed: http={$httpCode} err={$err}");
}
$data = json_decode($response, true);
if (!is_array($data)) {
$data = [];
}
// 兼容常见返回结构:{data:{list:[]}} / {data:[]} / {items:[]}
$list = [];
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
$list = $data['data']['list'];
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
$list = $data['data'];
} elseif (isset($data['items']) && is_array($data['items'])) {
$list = $data['items'];
} elseif (isset($data[0])) {
$list = $data;
}
// 兼容「一场 soul 创业实验」新接口:/api/miniprogram/book/*
if (empty($list)) {
$list = self::fetchFromSoulBookApi($base, $limit, $tag);
}
return self::upsertArticles($list, $tag, $limit);
}
/**
* 自动推送(傻瓜模式):检查 last_sync_at超过 intervalSec 则自动执行一次采集
* - 非阻塞:调用方可忽略返回值
* - 配置system_config.key='soul_article_auto_sync' {enabled, intervalSec, limit}
* @return array ['didSync'=>bool, ...syncResult]
*/
public static function autoSyncIfStale(): array
{
try {
$cfgRow = SystemConfigModel::where('key', 'soul_article_auto_sync')->find();
$enabled = true;
$intervalSec = 3600;
$limit = 10;
if ($cfgRow && !empty($cfgRow->value)) {
$v = $cfgRow->value;
if (is_string($v)) {
$d = json_decode($v, true);
if (is_array($d)) $v = $d;
}
if (is_array($v)) {
$enabled = !isset($v['enabled']) ? true : (bool) $v['enabled'];
$intervalSec = (int) ($v['intervalSec'] ?? 3600);
$limit = (int) ($v['limit'] ?? 10);
}
}
if (!$enabled) return ['didSync' => false, 'reason' => 'disabled'];
$lastRow = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
$last = 0;
if ($lastRow && !empty($lastRow->value)) {
$last = is_numeric($lastRow->value) ? (int) $lastRow->value : (int) strtotime($lastRow->value);
}
$now = time();
if ($last > 0 && ($now - $last) < $intervalSec) {
return ['didSync' => false, 'lastAt' => $last, 'reason' => 'fresh'];
}
$r = self::syncLatest($limit, 'MBTI');
// 写回 last sync即使采集 0 篇也写回,避免高频重试)
$val = (string) $now;
$exist = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
if ($exist) {
$exist->value = $val;
$exist->save();
} else {
SystemConfigModel::create([
'key' => 'soul_article_last_sync_at',
'value' => $val,
'description' => 'Soul 文章最近一次自动采集时间戳',
]);
}
return array_merge(['didSync' => true, 'lastAt' => $now], $r);
} catch (\Throwable $e) {
Log::warning('SoulArticleService autoSync failed: ' . $e->getMessage());
return ['didSync' => false, 'error' => $e->getMessage()];
}
}
/**
* 返回当前已推荐(最多 3 篇)
*/
public static function getRecommended(int $limit = 3): array
{
// 仅返回后台「推荐位」文章,不用候选池补齐(与超管「当前推荐」严格一致)
$rows = SoulArticleModel::where('isRecommended', 1)
->order('recommendedOrder', 'asc')
->order('publishedAt', 'desc')
->limit($limit)
->select()
->toArray();
return array_map(function ($r) {
$pub = (int) ($r['publishedAt'] ?? 0);
$upd = (int) ($r['updatedAt'] ?? 0);
// 避免 0 / 无效时间戳被格式化成 1970-01-01
$dateStr = '';
if ($pub > 946684800) {
$dateStr = date('Y-m-d', $pub);
} elseif ($upd > 946684800) {
$dateStr = date('Y-m-d', $upd);
}
return [
'id' => $r['id'],
'sourceId' => $r['sourceId'],
'title' => $r['title'],
'cover' => $r['cover'],
'url' => $r['url'],
'summary' => $r['summary'],
'author' => $r['author'],
'tag' => $r['tag'],
'publishedAt' => $dateStr,
];
}, $rows);
}
/**
* 把某篇文章设为推荐(至多 3 篇;超出时自动顶掉最老的一篇)
*/
public static function recommend(int $articleId): array
{
$article = SoulArticleModel::find($articleId);
if (!$article) {
return ['ok' => false, 'message' => '文章不存在'];
}
if ($article->isRecommended) {
$article->isRecommended = 0;
$article->save();
return ['ok' => true, 'message' => '已取消推荐', 'isRecommended' => false];
}
$current = SoulArticleModel::where('isRecommended', 1)->order('recommendedOrder', 'asc')->select()->toArray();
if (count($current) >= 3) {
// 顶掉最老的一篇
$oldest = end($current);
$old = SoulArticleModel::find($oldest['id']);
if ($old) {
$old->isRecommended = 0;
$old->save();
}
}
$maxOrder = (int) SoulArticleModel::where('isRecommended', 1)->max('recommendedOrder');
$article->isRecommended = 1;
$article->recommendedOrder = $maxOrder + 1;
$article->save();
return ['ok' => true, 'message' => '已设为推荐', 'isRecommended' => true];
}
/**
* soul_api 配置system_config.key='soul_api' / env 兜底
*/
private static function resolveConfig(): array
{
$baseUrl = trim((string) getenv('SOUL_API_BASE'));
$token = trim((string) getenv('SOUL_API_TOKEN'));
$row = SystemConfigModel::where('key', 'soul_api')->find();
if ($row && !empty($row->value)) {
$v = $row->value;
if (is_string($v)) {
$decoded = json_decode($v, true);
if (is_array($decoded)) $v = $decoded;
}
if (is_array($v)) {
if (!empty($v['baseUrl'])) $baseUrl = (string) $v['baseUrl'];
if (!empty($v['token'])) $token = (string) $v['token'];
}
}
if ($baseUrl === '') {
$baseUrl = 'https://soulapi.quwanzhi.com';
}
return ['baseUrl' => $baseUrl, 'token' => $token];
}
/**
* 从 Soul 小程序图书接口拉取最新章节,映射成文章池结构(用于 /api/articles 不可用时兜底)
*/
private static function fetchFromSoulBookApi(string $baseUrl, int $limit, string $tag, string $keyword = ''): array
{
$partsUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/parts';
[$rawParts] = self::httpGet($partsUrl, ['Accept: application/json']);
$partsData = is_string($rawParts) ? json_decode($rawParts, true) : null;
if (!is_array($partsData)) return [];
$parts = $partsData['parts'] ?? $partsData['data'] ?? [];
if (!is_array($parts) || empty($parts)) return [];
$chapters = [];
foreach ($parts as $p) {
$partId = (string) ($p['id'] ?? '');
if ($partId === '') continue;
$chaptersUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/chapters-by-part?partId=' . rawurlencode($partId);
[$rawRows] = self::httpGet($chaptersUrl, ['Accept: application/json']);
$rowsData = is_string($rawRows) ? json_decode($rawRows, true) : null;
if (!is_array($rowsData)) continue;
$rows = $rowsData['data'] ?? $rowsData['list'] ?? [];
if (!is_array($rows)) continue;
foreach ($rows as $row) {
$id = (string) ($row['id'] ?? '');
$title = trim((string) ($row['sectionTitle'] ?? $row['title'] ?? ''));
if ($id === '' || $title === '') continue;
// 只抓 MBTI 相关,减少噪音;标题无 MBTI 时保留“第x场”高频经营内容
$lc = mb_strtolower($title, 'UTF-8');
if (mb_strpos($lc, 'mbti') === false && mb_strpos($title, '第') === false) {
continue;
}
if ($keyword !== '') {
$kw = mb_strtolower($keyword, 'UTF-8');
$summary = trim((string) ($row['summary'] ?? ''));
$haystack = mb_strtolower($title . ' ' . $summary, 'UTF-8');
if (mb_strpos($haystack, $kw) === false) {
continue;
}
}
$publishedAt = 0;
$pubRaw = $row['updatedAt'] ?? ($row['createdAt'] ?? 0);
if (is_numeric($pubRaw)) {
$publishedAt = (int) $pubRaw;
} elseif (is_string($pubRaw) && trim($pubRaw) !== '') {
$t = strtotime((string) $pubRaw);
if ($t) $publishedAt = (int) $t;
}
// 异常时间戳(如 0 / 1970兜底当前时间
if ($publishedAt < 946684800) {
$publishedAt = time();
}
$cover = (string) ($row['coverUrl'] ?? $row['cover'] ?? '');
$summary = trim((string) ($row['summary'] ?? ''));
$chapters[] = [
'id' => $id,
'title' => $title,
'url' => rtrim($baseUrl, '/') . '/read/' . rawurlencode($id),
'cover' => $cover,
'summary' => $summary,
'author' => '一场 soul 创业实验',
'tag' => $tag,
'publishedAt' => $publishedAt ?: time(),
];
}
}
usort($chapters, function ($a, $b) {
return (int)($b['publishedAt'] ?? 0) <=> (int)($a['publishedAt'] ?? 0);
});
if (count($chapters) > $limit) {
$chapters = array_slice($chapters, 0, $limit);
}
return $chapters;
}
/**
* 统一写入文章池(幂等按 sourceId
*/
private static function upsertArticles(array $list, string $tag, int $limit): array
{
$created = 0;
$updated = 0;
$items = [];
$now = time();
if (!empty($list) && count($list) > $limit) {
$list = array_slice($list, 0, $limit);
}
foreach ($list as $raw) {
$sourceId = (string) ($raw['id'] ?? $raw['sourceId'] ?? $raw['articleId'] ?? '');
$title = trim((string) ($raw['title'] ?? ''));
$url2 = trim((string) ($raw['url'] ?? $raw['link'] ?? $raw['shareUrl'] ?? ''));
if ($sourceId === '' || $title === '' || $url2 === '') {
continue;
}
$cover = trim((string) ($raw['cover'] ?? $raw['coverUrl'] ?? $raw['thumbnail'] ?? ''));
$summary = trim((string) ($raw['summary'] ?? $raw['excerpt'] ?? $raw['description'] ?? ''));
$author = trim((string) ($raw['author'] ?? ($raw['authorName'] ?? '')));
$publishedAt = 0;
$pub = $raw['publishedAt'] ?? ($raw['createdAt'] ?? null);
if (is_numeric($pub)) {
$publishedAt = (int) $pub > 2000000000 ? (int)($pub / 1000) : (int) $pub;
} elseif (is_string($pub) && $pub !== '') {
$t = strtotime($pub);
if ($t) $publishedAt = $t;
}
if ($publishedAt < 946684800) {
$publishedAt = $now;
}
$payload = [
'title' => mb_substr($title, 0, 250, 'UTF-8'),
'cover' => $cover,
'url' => $url2,
'summary' => mb_substr($summary, 0, 500, 'UTF-8'),
'author' => mb_substr($author, 0, 60, 'UTF-8'),
'tag' => $tag,
'publishedAt' => $publishedAt ?: $now,
];
$existed = SoulArticleModel::where('sourceId', $sourceId)->find();
if ($existed) {
$existed->save($payload);
$updated++;
} else {
$payload['sourceId'] = $sourceId;
SoulArticleModel::create($payload);
$created++;
}
$items[] = array_merge(['sourceId' => $sourceId], $payload);
}
return ['fetched' => count($list), 'created' => $created, 'updated' => $updated, 'items' => $items];
}
/**
* 统一 HTTP GET返回 body/httpCode/error
*/
private static function httpGet(string $url, array $headers = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$response, $httpCode, $err];
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 用户旅程:从 analytics_events 聚合最近行为,供飞书获客、出站 Hook、存客宝备注等复用。
*/
class UserJourneyService
{
/**
* @return string[] 每条一行展示文案(与 FeishuLeadWebhookService 历史格式一致)
*/
public static function recentBehaviorLines(int $userId, int $limit = 8): array
{
if ($userId <= 0 || $limit <= 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) {
if (is_array($r)) {
$out[] = self::formatAnalyticsLine($r);
}
}
return $out;
}
/**
* 用户管理摘要一行(平台用户 ID、归属企业用于 CRM 备注与推送文案。
*/
public static function managementSummaryLine(int $userId, int $testResultEnterpriseId = 0): string
{
if ($userId <= 0) {
return '';
}
$parts = ['平台用户ID:' . $userId];
$eid = $testResultEnterpriseId > 0 ? $testResultEnterpriseId : 0;
if ($eid <= 0) {
try {
$bound = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
$eid = (int) ($bound ?? 0);
} catch (\Throwable $e) {
$eid = 0;
}
}
if ($eid > 0) {
try {
$name = Db::name('enterprises')->where('id', $eid)->value('name');
} catch (\Throwable $e) {
$name = null;
}
$label = $name !== null && (string) $name !== '' ? (string) $name : '企业';
$parts[] = '归属企业:' . $label . '(ID' . $eid . ')';
}
return implode(' · ', $parts);
}
/**
* @param array<string,mixed> $r analytics_events 一行
*/
public 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;
}
public 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) . '天前';
}
/**
* 将旅程行压成一段备注(用于存客宝 remark 等),超长截断。
*
* @param string[] $lines
*/
public static function journeyLinesToRemarkBlock(array $lines, int $maxChars = 600): string
{
if (count($lines) === 0) {
return '';
}
$body = '用户旅程:' . implode('', $lines);
if (function_exists('mb_strlen') && mb_strlen($body) > $maxChars) {
return mb_substr($body, 0, $maxChars) . '…';
}
if (!function_exists('mb_strlen') && strlen($body) > $maxChars) {
return substr($body, 0, $maxChars) . '…';
}
return $body;
}
}

View File

@@ -31,6 +31,14 @@ class AppUser extends BaseController
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
$coldFaceLevels = [];
if (is_array($coldFaceLevelRaw)) {
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
}
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
$where = [];
if ($keyword !== '') {
@@ -72,6 +80,19 @@ class AppUser extends BaseController
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
if (!empty($coldFaceLevels)) {
try {
$cfSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->whereIn('coldFaceLevel', $coldFaceLevels)
->field('userId')
->buildSql(true);
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
} catch (\Throwable $e) {
// coldFace 字段不存在时忽略筛选
}
}
if ($keyword !== '') {
$like = '%' . addcslashes($keyword, '%_\\') . '%';
$baseQuery->whereRaw(
@@ -215,6 +236,31 @@ class AppUser extends BaseController
} catch (\Throwable $e) {
$payStats = [];
}
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
$coldFaceMap = [];
try {
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRows = $cfQuery
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
->select()
->toArray();
foreach ($cfRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$coldFaceMap[$uid] = [
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
'level' => $r['coldFaceLevel'] ?? null,
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
];
}
}
} catch (\Throwable $e) {
$coldFaceMap = [];
}
}
foreach ($list as &$row) {
@@ -236,6 +282,18 @@ class AppUser extends BaseController
$pay = $payStats[$id] ?? null;
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
$cf = $coldFaceMap[$id] ?? null;
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
$calc = $this->calcColdFace($testsForUser);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
}
return paginate_response($list, $total, $page, $pageSize);
@@ -322,6 +380,35 @@ class AppUser extends BaseController
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
// 冷脸分字段(详情)
$coldFace = null;
try {
$cfQuery = Db::name('user_profile')->where('userId', $id);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
if ($cfRow) {
$coldFace = [
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
'level' => $cfRow['coldFaceLevel'] ?? null,
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
];
}
} catch (\Throwable $e) {
$coldFace = null;
}
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
$calc = $this->calcColdFace($tests);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$data['coldFaceScore'] = $coldFace['score'] ?? null;
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
return success($data);
}

View File

@@ -36,7 +36,7 @@ class Finance extends BaseController
$baseOrderQuery = Db::name('orders')
->where('enterpriseId', $enterpriseId)
->whereIn('status', ['paid', 'completed'])
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report']);
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
@@ -54,16 +54,44 @@ class Finance extends BaseController
->where('status', 'frozen')
->sum('commissionFen') ?? 0);
// 本月累计扣款(平台扣费 + 佣金扣减)
$monthConsumeFen = (int) (Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('type', 'consume')
->where('createdAt', '>=', $monthStart)
->sum('amount') ?? 0);
// 今日扣款
$todayConsumeFen = (int) (Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('type', 'consume')
->where('createdAt', '>=', $todayStart)
->sum('amount') ?? 0);
// 建议充值:按本月实际天数日均 × 14 天,低于 500 元按 500 元兜底;余额足够则 0
$balanceFen = (int) ($enterprise['balance'] ?? 0);
$daysElapsed = max(1, (int) date('j'));
$avgDaily = $monthConsumeFen > 0 ? intval($monthConsumeFen / $daysElapsed) : 0;
$suggestThreshold = $avgDaily * 14;
$suggestRechargeFen = 0;
if ($avgDaily > 0 && $balanceFen < $suggestThreshold) {
$suggestRechargeFen = max($suggestThreshold - $balanceFen, 50000); // ≥ 500 元
}
return success([
'enterpriseId' => $enterpriseId,
'enterpriseName' => $enterprise['name'] ?? '',
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
'balanceFen' => $balanceFen,
'totalIncomeFen' => $totalIncomeFen,
'todayIncomeFen' => $todayIncomeFen,
'monthIncomeFen' => $monthIncomeFen,
'manualRechargeFen' => $manualRechargeFen,
'frozenCommissionFen' => $frozenCommissionFen,
'paidOrderCount' => $paidOrderCount,
'monthConsumeFen' => $monthConsumeFen,
'todayConsumeFen' => $todayConsumeFen,
'avgDailyConsumeFen' => $avgDaily,
'suggestRechargeFen' => $suggestRechargeFen,
]);
} catch (\Throwable $e) {
return error('获取企业财务概览失败:' . $e->getMessage(), 500);

View File

@@ -31,6 +31,7 @@ class Order extends BaseController
$keyword = trim(Request::param('keyword', ''));
$status = trim(Request::param('status', ''));
$productType = trim(Request::param('productType', ''));
$inviterId = (int) Request::param('inviterId', 0);
// 超管:全平台订单;其余管理员仅本企业
$enterpriseId = null;
@@ -53,6 +54,24 @@ class Order extends BaseController
if ($productType !== '') {
$query->where('productType', $productType);
}
if ($inviterId > 0) {
// 通过分销绑定表过滤「该分销商带来的订单」orders.userId = distribution_bindings.inviteeIdinviter=inviterId
try {
$inviteeIds = Db::name('distribution_bindings')
->where('inviterId', $inviterId)
->column('inviteeId');
$inviteeIds = array_values(array_filter(array_map('intval', $inviteeIds ?: [])));
if (empty($inviteeIds)) {
return success([
'list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize,
'hasMore' => false, 'paidCompletedCount' => 0, 'totalRevenueFen' => 0,
]);
}
$query->whereIn('userId', $inviteeIds);
} catch (\Throwable $e) {
// 表不存在或字段差异:忽略筛选
}
}
if ($keyword !== '') {
if (is_numeric($keyword)) {
$query->where(function ($q) use ($keyword) {
@@ -119,12 +138,58 @@ class Order extends BaseController
}
}
// 当前页订单的分销分润记录
$commissionsByOrder = [];
if (!empty($orderIds)) {
try {
$crRows = Db::name('commission_records')
->where('orderId', 'in', $orderIds)
->field('id, orderId, inviterId, commissionFen, rate, status, paidAt, createdAt')
->order('createdAt', 'asc')
->select()
->toArray();
$inviterIds = array_values(array_unique(array_filter(array_column($crRows, 'inviterId'))));
$inviterMap = [];
if (!empty($inviterIds)) {
$inviters = Db::name('wechat_users')
->where('id', 'in', $inviterIds)
->field('id, nickname, phone')
->select()
->toArray();
foreach ($inviters as $iv) {
$inviterMap[(int) $iv['id']] = $iv;
}
}
foreach ($crRows as $cr) {
$oid = (int) ($cr['orderId'] ?? 0);
if ($oid <= 0) {
continue;
}
$iv = $inviterMap[(int) ($cr['inviterId'] ?? 0)] ?? null;
$commissionsByOrder[$oid][] = [
'id' => (int) $cr['id'],
'inviterId' => (int) ($cr['inviterId'] ?? 0),
'inviterName' => $iv['nickname'] ?? null,
'inviterPhone' => $iv['phone'] ?? null,
'commissionFen' => (int) ($cr['commissionFen'] ?? 0),
'rate' => isset($cr['rate']) ? (float) $cr['rate'] : null,
'status' => (string) ($cr['status'] ?? ''),
'paidAt' => isset($cr['paidAt']) ? (int) $cr['paidAt'] : null,
'createdAt' => isset($cr['createdAt']) ? (int) $cr['createdAt'] : null,
];
}
} catch (\Throwable $e) {
$commissionsByOrder = [];
}
}
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']] ?? [];
$row['commissions'] = $commissionsByOrder[$row['id']] ?? [];
}
return success([

View File

@@ -2,12 +2,115 @@
namespace app\controller\admin\concern;
use app\common\PdpDiscResultText;
use think\facade\Db;
/**
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
*/
trait ExtractsTestResults
{
/**
* 基于测试记录推算冷脸分值0-100
* - 面相 emotionScore/emotionNeutrality/microExpression 作为主输入
* - MBTI I/E 作为补充I 更冷E 更暖)
* 返回 ['score'=>int, 'level'=>'cold|neutral|warm'];如无任何线索返回 null
*/
public function calcColdFace(array $tests): ?array
{
$score = null; // 0-100
$mbtiType = '';
foreach ($tests as $t) {
$type = strtolower($t['testType'] ?? '');
$raw = $t['result'] ?? ($t['resultData'] ?? '');
if (!is_string($raw) || $raw === '') {
continue;
}
$dec = json_decode($raw, true);
if (!is_array($dec)) {
continue;
}
if ($type === 'face' && $score === null) {
$face = is_array($dec['face'] ?? null) ? $dec['face'] : $dec;
$emotion = null;
foreach (['coldFaceScore', 'coldScore', 'emotionNeutrality', 'neutrality'] as $k) {
if (isset($face[$k]) && is_numeric($face[$k])) {
$emotion = (float) $face[$k];
break;
}
}
if ($emotion === null && isset($face['emotionScore']) && is_numeric($face['emotionScore'])) {
// emotionScore 越高越暖 → 冷脸分取反
$emotion = 100 - (float) $face['emotionScore'];
}
if ($emotion === null && isset($face['microExpression']) && is_array($face['microExpression'])) {
$mx = $face['microExpression'];
$happy = is_numeric($mx['happy'] ?? null) ? (float) $mx['happy'] : 0;
$neutral = is_numeric($mx['neutral'] ?? null) ? (float) $mx['neutral'] : 0;
$total = $happy + $neutral + 1;
$emotion = ($neutral / $total) * 100;
}
if ($emotion !== null) {
if ($emotion <= 1) {
$emotion = $emotion * 100;
}
$score = max(0, min(100, (int) round($emotion)));
}
}
if ($type === 'mbti' && $mbtiType === '') {
foreach (['mbtiType', 'type', 'result'] as $k) {
$s = $this->coerceResultLabel($dec[$k] ?? null);
if ($s !== '') {
$mbtiType = strtoupper($s);
break;
}
}
}
}
if ($score === null && $mbtiType !== '') {
$score = strpos($mbtiType, 'I') === 0 ? 62 : 42;
}
if ($score === null) {
return null;
}
$level = 'neutral';
if ($score > 65) {
$level = 'cold';
} elseif ($score < 35) {
$level = 'warm';
}
return ['score' => (int) $score, 'level' => $level];
}
/**
* 写回 user_profile 的冷脸字段(存在才更新,否则跳过;失败静默)
*/
protected function writeColdFace(int $userId, ?int $enterpriseId, array $cold): void
{
if (!isset($cold['score'], $cold['level'])) {
return;
}
try {
$query = Db::name('user_profile')->where('userId', $userId);
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->update([
'coldFaceScore' => (int) $cold['score'],
'coldFaceLevel' => (string) $cold['level'],
'coldFaceUpdatedAt' => time(),
'updatedAt' => time(),
]);
} catch (\Throwable $e) {
// 字段不存在(未执行迁移)时直接忽略
}
}
/**
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
*/

View File

@@ -0,0 +1,503 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\AiCallService;
use app\common\service\AiChatArticleDisplayService;
use app\common\service\SoulArticleService;
use app\model\AiConversation as AiConversationModel;
use app\model\AiMessage as AiMessageModel;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
* 神仙 AI 聊天接口(小程序侧)
*
* 路由:/api/ai/*
* 鉴权auth微信登录后 token
*/
class AiChat extends BaseController
{
/** 每用户每日发送上限(超出则返回 429 */
private const DAILY_LIMIT = 20;
/** 单次对话最多携带的历史上下文user+assistant 轮数) */
private const CONTEXT_TURNS = 8;
/**
* POST /api/ai/chat
* body: { conversationId?: int, message: string }
* 返回 { conversationId, message: {role, content, providerId, isDegraded, createdAt}, usageToday, dailyLimit }
*/
public function chat()
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
$message = trim((string) Request::post('message', ''));
if ($message === '') return error('消息不能为空', 400);
if (mb_strlen($message, 'UTF-8') > 800) {
return error('消息过长,请精简到 800 字以内', 400);
}
$conversationId = (int) Request::post('conversationId', 0);
// 每日限流(必须在 try 外先取 used失败时返回 200+降级,避免未捕获异常导致 HTTP 500
$used = 0;
try {
$used = $this->incrementAndCheckDaily($userId, self::DAILY_LIMIT);
} catch (\Throwable $e) {
Log::warning('AiChat incrementAndCheckDaily: ' . $e->getMessage());
return $this->chatDegradeResponse(
$conversationId,
0,
'小神仙这边计数服务抖了一下,请稍后再发一条消息~'
);
}
if ($used === -1) {
return error('今日对话次数已用完,明天再来找我呀~', 429);
}
$userMsgSaved = false;
$conversation = null;
try {
// 拉取/新建对话(测评档案组装失败时不阻断聊天)
$userContext = [
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
];
try {
$userContext = AiCallService::fetchUserContext($userId);
} catch (\Throwable $e) {
Log::warning('AiChat fetchUserContext: ' . $e->getMessage());
}
$conversation = $conversationId > 0
? AiConversationModel::where('userId', $userId)->where('id', $conversationId)->find()
: null;
$now = time();
if (!$conversation) {
$title = mb_substr($message, 0, 24, 'UTF-8');
if ($title === false) {
$title = mb_substr($message, 0, 24);
}
if (!is_string($title) || $title === '') {
$title = '新对话';
}
$conversation = AiConversationModel::create([
'userId' => $userId,
'title' => $title,
'mbtiType' => $userContext['mbtiType'] ?: '',
'providerId' => '',
'lastMessageAt' => $now,
'messageCount' => 0,
]);
$conversationId = (int) $conversation->id;
}
// 写入 user 消息
AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'user',
'content' => $message,
'tokensIn' => 0,
'tokensOut' => 0,
'providerId' => '',
'isDegraded' => 0,
'createdAt' => $now,
]);
$userMsgSaved = true;
// 组装 messagessystem + 最近 N 轮历史 + 本次 user
try {
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
} catch (\Throwable $e) {
Log::warning('AiChat buildSystemPrompt: ' . $e->getMessage());
$systemPrompt = "你是「神仙 AI」神仙团队 MBTI 小程序的专属伙伴。请亲切、简短地回答用户;禁止医疗诊断与投资建议。\n";
}
$history = AiMessageModel::where('conversationId', $conversationId)
->order('id', 'desc')
->limit(self::CONTEXT_TURNS * 2)
->select()
->toArray();
$history = array_reverse($history);
$messages = [['role' => 'system', 'content' => $systemPrompt]];
foreach ($history as $row) {
if (!in_array($row['role'], ['user', 'assistant'], true)) {
continue;
}
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
}
$r = AiCallService::chat($messages, ['temperature' => 0.75, 'maxTokens' => 1024]);
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
// 写入 assistant 消息
$msgRow = AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'assistant',
'content' => $assistantContent,
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
'providerId' => (string) ($r['providerId'] ?? ''),
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
'createdAt' => time(),
]);
// 更新对话元数据
$conversation->lastMessageAt = time();
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
$conversation->providerId = (string) ($r['providerId'] ?? '');
$conversation->save();
return success([
'conversationId' => $conversationId,
'message' => [
'id' => (int) $msgRow->id,
'role' => 'assistant',
'content' => $assistantContent,
'providerId' => $r['providerId'] ?? '',
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => $used,
'dailyLimit' => self::DAILY_LIMIT,
]);
} catch (\Throwable $e) {
Log::error('AiChat::chat 异常: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
// 用户消息已落库时:降级返回一条助手回复,避免前端只看到 500
if ($userMsgSaved && $conversationId > 0 && $conversation) {
try {
$assistantContent = '小神仙这边刚刚抖了一下(已自动记录)。你可以把问题缩短一点重发,或稍后再试~';
$msgRow = AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'assistant',
'content' => $assistantContent,
'tokensIn' => 0,
'tokensOut' => 0,
'providerId' => 'degrade',
'isDegraded' => 1,
'createdAt' => time(),
]);
$conversation->lastMessageAt = time();
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
$conversation->providerId = 'degrade';
$conversation->save();
return success([
'conversationId' => $conversationId,
'message' => [
'id' => (int) $msgRow->id,
'role' => 'assistant',
'content' => $assistantContent,
'providerId' => 'degrade',
'isDegraded' => true,
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => $used,
'dailyLimit' => self::DAILY_LIMIT,
]);
} catch (\Throwable $e2) {
Log::error('AiChat::chat 降级回复写入失败: ' . $e2->getMessage());
}
}
// 统一不再返回业务 code=500前端只认 code=200避免用户看到「服务异常(500)」
return $this->chatDegradeResponse(
$conversationId,
$used,
'小神仙服务暂时不可用,请稍后再试。如反复出现可在「我的」联系客服~'
);
}
}
/**
* 聊天失败时仍返回 HTTP JSON code=200由 message.isDegraded 标记,小程序可正常展示气泡
*
* @param int $conversationId 当前会话 id未知则 0
* @param int $usageToday 今日已计次数(失败时可能为 0
* @param string $assistantText 助手可见文案
*/
private function chatDegradeResponse(int $conversationId, int $usageToday, string $assistantText)
{
$now = time();
return success([
'conversationId' => $conversationId,
'message' => [
'id' => 0,
'role' => 'assistant',
'content' => $assistantText,
'providerId' => 'degrade',
'isDegraded' => true,
'createdAt' => $now,
],
'usageToday' => max(0, $usageToday),
'dailyLimit' => self::DAILY_LIMIT,
]);
}
/**
* GET /api/ai/articles/recommended
* 无需登录(也可以;这里先要求登录保持一致)
*/
public function recommendedArticles()
{
$display = AiChatArticleDisplayService::getSettings();
$rows = [];
if ($display['enabled']) {
try { SoulArticleService::autoSyncIfStale(); } catch (\Throwable $e) {}
$limit = (int) $display['maxShow'];
$rows = SoulArticleService::getRecommended($limit);
if (empty($rows)) {
try { SoulArticleService::syncLatest(10, 'MBTI'); } catch (\Throwable $e) {}
$rows = SoulArticleService::getRecommended($limit);
}
}
$resp = success([
'list' => $rows,
'recoCount' => count($rows),
'display' => [
'enabled' => $display['enabled'],
'maxShow' => $display['maxShow'],
'sectionExpandedDefault' => $display['sectionExpandedDefault'],
],
]);
if (method_exists($resp, 'header')) {
$resp->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
return $resp;
}
/**
* GET /api/ai/articles/profile-teaser
* 我的页底部:展示后台「当前推荐」排序第一的一篇 + 可配区块标题(无需登录)
*/
public function profileArticleTeaser()
{
$display = AiChatArticleDisplayService::getSettings();
if (empty($display['profileRecoEnabled'])) {
$resp = success([
'enabled' => false,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => null,
]);
} else {
try {
SoulArticleService::autoSyncIfStale();
} catch (\Throwable $e) {
}
$rows = SoulArticleService::getRecommended(1);
if (empty($rows)) {
try {
SoulArticleService::syncLatest(10, 'MBTI');
} catch (\Throwable $e) {
}
$rows = SoulArticleService::getRecommended(1);
}
$article = !empty($rows[0]) ? $rows[0] : null;
$resp = success([
'enabled' => true,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => $article,
]);
}
if (method_exists($resp, 'header')) {
$resp->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
return $resp;
}
/**
* GET /api/ai/quick-questions
* 基于当前用户 MBTI 类型返回快捷提问
*/
public function quickQuestions()
{
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
$userId = $this->currentUserId();
if ($userId <= 0) {
$u = $this->resolveUser();
if ($u && ($u['source'] ?? '') === 'wechat') {
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
}
}
$ctx = AiCallService::fetchUserContext($userId);
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
return success([
'mbtiType' => $ctx['mbtiType'],
'nickname' => $ctx['nickname'],
'questions' => $qs,
]);
}
/**
* GET /api/ai/conversations 分页取当前用户会话列表
* query: page, pageSize
*/
public function conversations()
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
$page = max(1, (int) Request::get('page', 1));
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
$query = AiConversationModel::where('userId', $userId);
$total = $query->count();
$list = $query
->order('lastMessageAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
return paginate_response($list, $total, $page, $pageSize);
}
/**
* GET /api/ai/conversations/:id/messages
*/
public function messages()
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
$cid = (int) Request::param('id', 0);
$conversation = AiConversationModel::where('userId', $userId)->where('id', $cid)->find();
if (!$conversation) return error('会话不存在', 404);
$rows = AiMessageModel::where('conversationId', $cid)
->order('id', 'asc')
->limit(200)
->select()
->toArray();
$list = array_map(function ($r) {
return [
'id' => (int) $r['id'],
'role' => $r['role'],
'content' => $r['content'],
'providerId' => $r['providerId'],
'isDegraded' => (bool) $r['isDegraded'],
'createdAt' => (int) $r['createdAt'],
];
}, $rows);
return success([
'conversation' => [
'id' => (int) $conversation->id,
'title' => (string) $conversation->title,
'mbtiType' => (string) $conversation->mbtiType,
'messageCount' => (int) $conversation->messageCount,
'lastMessageAt' => (int) $conversation->lastMessageAt,
],
'messages' => $list,
]);
}
/**
* POST /api/ai/transcribe
* 语音转文字:接受前端 audio 文件上传。
* 当前占位若未配置语音识别服务ai_transcribe_provider返回 501 + 降级提示,
* 前端会降级到"请用键盘麦克风"。接入 ASR 后在此填充 provider 调用即可。
*/
public function transcribe()
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
$file = Request::file('audio');
if (!$file) return error('未收到音频文件', 400);
// 读取 system_config.ai_transcribe_provider未配置则降级
$cfgRow = Db::name('system_config')->where('key', 'ai_transcribe_provider')->find();
if (!$cfgRow || empty($cfgRow['value'])) {
return error('语音识别未开通,请使用键盘麦克风', 501);
}
// TODO: 二期对接 ASR如通义听悟/讯飞/whisper API
// 当前阶段:保持降级,避免伪造返回值骗用户
return error('语音识别通道建设中,请使用键盘麦克风', 501);
}
/**
* POST /api/ai/articles/:id/click
* 轻量记录点击,用于分析(失败不影响用户)
*/
public function articleClick()
{
$id = (int) Request::param('id', 0);
if ($id > 0) {
try {
Db::name('soul_articles')->where('id', $id)->inc('viewCount')->update();
} catch (\Throwable $e) {
}
}
return success(null);
}
/** 从 auth 中间件挂好的 request->user 中取出当前微信用户 id */
private function currentUserId(): int
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') return 0;
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
}
/**
* 自增今日用量,若已达上限返回 -1
*/
private function incrementAndCheckDaily(int $userId, int $limit): int
{
$dateStr = date('Y-m-d');
$now = time();
$row = Db::name('ai_usage_daily')
->where('userId', $userId)
->where('dateStr', $dateStr)
->find();
if (!$row) {
try {
Db::name('ai_usage_daily')->insert([
'userId' => $userId,
'dateStr' => $dateStr,
'messageCount' => 1,
'createdAt' => $now,
'updatedAt' => $now,
]);
return 1;
} catch (\Throwable $e) {
// 并发首条:另一请求已插入,回读后再自增
$row = Db::name('ai_usage_daily')
->where('userId', $userId)
->where('dateStr', $dateStr)
->find();
if (!$row) {
throw $e;
}
}
}
$used = (int) $row['messageCount'];
if ($used >= $limit) return -1;
Db::name('ai_usage_daily')
->where('id', $row['id'])
->update(['messageCount' => $used + 1, 'updatedAt' => $now]);
return $used + 1;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\AiReportService;
use think\facade\Request;
/**
* AI 深度画像报告 · 小程序 API
*
* POST /api/ai/report/create 创建或返回现有 pending 报告
* GET /api/ai/report/my-latest 我最近的一份报告(判断是否已买)
* GET /api/ai/report/:id 获取报告正文(必须属于当前用户)
* POST /api/ai/report/:id/mark-paid-dev 调试用:跳过支付直接置已付
* POST /api/ai/report/:id/regenerate 失败后重试生成(仅管理员/作者)
*/
class AiReport extends BaseController
{
public function create()
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$conversationId = (int) Request::param('conversationId', 0);
$mbtiType = trim((string) Request::param('mbtiType', ''));
$r = AiReportService::createOrGetPending((int) $user['id'], $conversationId, $mbtiType);
return success($r);
}
public function myLatest()
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return success(['status' => '']);
$r = AiReportService::myLatest((int) $user['id']);
if (!$r) return success(['status' => '']);
return success($r);
}
public function show($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
if (!$r) return error('报告不存在', 404);
// 未付费时content 不下发,只给 summary + 解锁引导
if ($r['status'] !== 'done') {
unset($r['content']);
}
return success($r);
}
public function markPaidDev($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
if (!$r) return error('报告不存在', 404);
// 仅超管或本地 debug 开关允许
$role = $user['role'] ?? '';
$isSuper = $role === 'superadmin';
$isDebug = function_exists('env') ? (bool) env('app.ai_report_paid_dev', false) : false;
if (!$isSuper && !$isDebug) {
return error('仅测试模式可用', 403);
}
$ret = AiReportService::markPaidDev((string) $r['orderSn']);
return success($ret);
}
public function regenerate($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
if (!$r) return error('报告不存在', 404);
if (!in_array($r['status'], ['failed', 'paid'])) {
return error('当前状态不支持重试:' . $r['status']);
}
AiReportService::generate((int) $r['id']);
$r2 = AiReportService::get((int) $id, (int) $user['id']);
return success($r2);
}
}

View File

@@ -3,7 +3,9 @@ namespace app\controller\api;
use app\BaseController;
use app\common\PdpDiscResultText;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\JwtService;
use app\common\service\UserJourneyService;
use think\facade\Db;
use think\facade\Log;
@@ -22,6 +24,7 @@ class CrmReport extends BaseController
* @param string remark 备注,如"申请咨询"/"完成付款"
* @param string tags 可选,逗号分隔的微信标签
* @param string siteTags 可选,逗号分隔的站内标签
* @param bool deepConsult 为 true 时「了解自己」申请咨询apiKey 可空,后端按用户归属企业从 cunkebao_keys 解析;并推送飞书获客(若已配置)
*/
public function report()
{
@@ -49,6 +52,8 @@ class CrmReport extends BaseController
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
$deepConsult = self::isTruthyParam($this->request->param('deepConsult', false));
// 测评类付费(人脸/MBTI/PDP/DISC未传 apiKey 时从企业后台配置 cunkebao_keys 解析(与深度服务「完成付款」上报一致)
$testType = trim((string) ($this->request->param('testType', '') ?? ''));
$testResultId = (int) ($this->request->param('testResultId', 0) ?? 0);
@@ -70,6 +75,15 @@ class CrmReport extends BaseController
}
}
// 「了解自己」申请咨询:类目未配 consultWechat 时,按用户归属企业回落 cunkebao_keys
if ($apiKey === '' && $deepConsult && $userId > 0) {
$eidDeep = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
$fk = self::readSingleCunkebaoApiKeyWithFallback($eidDeep);
if ($fk !== '') {
$apiKey = $fk;
}
}
// 使用企业 cunkebao_keys 的「测评付费」上报:须校验已支付(防止未支付伪造「完成付款」)
if ($resolvedFromEnterpriseKeys && !empty($apiKey)) {
$deny = self::verifyTestPaidForCrmReport($userId, $testType, $testResultId);
@@ -78,19 +92,58 @@ class CrmReport extends BaseController
}
}
// apiKey 为空则跳过,不影响主流程
if (empty($apiKey)) {
if (empty($apiKey) && !$deepConsult) {
return success(['reported' => false, 'reason' => 'no_api_key']);
}
// 测评结果:备注仅摘要;站内/微信标签含「测评名,结果」
if ($testResultId > 0 && $userId > 0) {
self::applyTestResultSummaryToReportPayload($userId, $testType, $testResultId, $remark, $tags, $siteTags);
$eidForMgmt = $contextEnterpriseId;
if ($eidForMgmt <= 0) {
$eidForMgmt = (int) (Db::name('test_results')->where('id', $testResultId)->where('userId', $userId)->value('enterpriseId') ?? 0);
}
self::appendCrmRemarkUserJourney($userId, $eidForMgmt, $remark);
}
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
// 申请咨询:备注中附带用户管理摘要与旅程(与飞书卡片「最近行为」同源)
if ($deepConsult && $userId > 0 && !empty($apiKey)) {
$eidJ = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
self::appendCrmRemarkUserJourney($userId, $eidJ, $remark);
}
return success(['reported' => $ok, 'reason' => $ok ? '' : 'api_error']);
$ok = false;
if (!empty($apiKey)) {
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
}
if ($deepConsult && $userId > 0) {
$feishuSource = $source !== '' ? $source : '深度服务·申请咨询';
FeishuLeadWebhookService::onDeepServiceConsultApply($userId, $feishuSource, $siteTags);
}
if ($ok) {
return success(['reported' => true, 'reason' => '']);
}
return success([
'reported' => false,
'reason' => empty($apiKey) ? 'no_api_key' : 'api_error',
]);
}
/** @param mixed $v */
private static function isTruthyParam($v): bool
{
if ($v === true || $v === 1) {
return true;
}
if ($v === false || $v === 0 || $v === null || $v === '') {
return false;
}
$s = strtolower(trim((string) $v));
return in_array($s, ['1', 'true', 'yes', 'on'], true);
}
/**
@@ -398,6 +451,8 @@ class CrmReport extends BaseController
$tags = $merged;
$siteTags = $merged;
self::appendCrmRemarkUserJourney($userId, $enterpriseId, $remark);
self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
} catch (\Throwable $e) {
Log::warning('[CrmReport] reportTestCompletion 异常 userId=' . $userId . ' err=' . $e->getMessage());
@@ -460,6 +515,36 @@ class CrmReport extends BaseController
return '';
}
/**
* 存客宝 remark 追加:用户管理一行 + analytics 用户旅程(与飞书/出站 Hook 字段同源)
*/
private static function appendCrmRemarkUserJourney(int $userId, int $enterpriseIdForMgmt, string &$remark): void
{
if ($userId <= 0) {
return;
}
$blocks = [];
$m = UserJourneyService::managementSummaryLine($userId, $enterpriseIdForMgmt);
if ($m !== '') {
$blocks[] = '【用户管理】' . $m;
}
$jb = UserJourneyService::journeyLinesToRemarkBlock(UserJourneyService::recentBehaviorLines($userId, 10), 720);
if ($jb !== '') {
$blocks[] = $jb;
}
if (count($blocks) === 0) {
return;
}
$append = implode("\n", $blocks);
$remark = trim($remark === '' ? $append : ($remark . "\n" . $append));
$cap = 900;
if (function_exists('mb_strlen') && mb_strlen($remark) > $cap) {
$remark = mb_substr($remark, 0, $cap) . '…';
} elseif (!function_exists('mb_strlen') && strlen($remark) > $cap) {
$remark = substr($remark, 0, $cap) . '…';
}
}
/**
* 内部通用上报(供 report() 接口和 reportTestCompletion 共用)
*/

View File

@@ -0,0 +1,58 @@
<?php
namespace app\controller\api;
use app\BaseController;
use think\facade\Db;
/**
* 小程序公开运行配置
* - GET /api/mp/tabbar : 返回底部 TabBar 动态布局
*
* 不强制登录,结果做 60s 软缓存(客户端也会做 localStorage 缓存)。
*/
class MpConfig extends BaseController
{
public function tabbar()
{
$items = Db::name('mp_tabbar_items')
->where('visible', 1)
->order('sortOrder', 'asc')
->order('id', 'asc')
->select()
->toArray();
$list = [];
foreach ($items as $row) {
$iconKey = $row['iconKey'] ?? 'home';
$iconUrl = $row['iconUrl'] ?? null;
if ($iconKey === 'ai') {
// 神仙AI纯圆老头像无放射线装饰避免后台误配导致小图标异常
$iconUrl = '/images/shenxian-oldman-circle.png';
}
$list[] = [
'id' => (int) $row['id'],
'pagePath' => $row['pagePath'],
'text' => $row['text'],
'iconKey' => $iconKey,
'iconUrl' => $iconUrl,
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
'badgeKey' => $row['badgeKey'] ?? null,
];
}
// 兜底:后台还没配/表空,返回硬编码 4 项,保证小程序永远可用
if (empty($list)) {
$list = [
['id' => 0, 'pagePath' => 'pages/index/index', 'text' => '首页', 'iconKey' => 'home', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
['id' => 0, 'pagePath' => 'pages/index/camera', 'text' => '拍摄', 'iconKey' => 'camera', 'iconUrl' => null, 'highlight' => true, 'badgeKey' => null],
['id' => 0, 'pagePath' => 'pages/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => '/images/shenxian-oldman-circle.png', 'highlight' => false, 'badgeKey' => null],
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
];
}
return success([
'items' => $list,
'version' => (int) Db::name('mp_tabbar_items')->max('updatedAt'),
]);
}
}

View File

@@ -6,6 +6,7 @@ use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\AiReportService;
use think\facade\Request;
use think\facade\Db;
@@ -364,6 +365,14 @@ class Payment extends BaseController
// 企业四项测试支付后,订单金额进入企业余额
$this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now);
// AI 深度报告:支付成功后置 paid 并触发报告生成(幂等)
if (($order['productType'] ?? '') === 'ai_deep_report' && !empty($order['orderNo'])) {
try {
AiReportService::markPaid((string) $order['orderNo']);
} catch (\Throwable $e) {
}
}
if (($order['productType'] ?? '') !== 'recharge') {
// 触发分销佣金结算
try {
@@ -372,6 +381,43 @@ class Payment extends BaseController
// 佣金结算失败不影响主流程
}
}
// 成交归因:写入一条 analytics_events便于漏斗统计
try {
$userId = (int) ($order['userId'] ?? 0);
$inviterRow = null;
if ($userId > 0) {
$inviterRow = Db::name('distribution_bindings')
->where('inviteeId', $userId)
->where('status', 'active')
->where('expireAt', '>', time())
->order('id', 'desc')
->field('inviterId')
->find();
}
$amountFenForEvent = (int) ($order['amount'] ?? 0);
$props = [
'orderId' => (int) $order['id'],
'orderNo' => $order['orderNo'] ?? '',
'amountFen' => $amountFenForEvent,
'amountYuan'=> $amountFenForEvent / 100,
'productType' => $order['productType'] ?? '',
'testType' => $order['testType'] ?? '',
'inviterId' => $inviterRow ? (int) ($inviterRow['inviterId'] ?? 0) : 0,
];
Db::name('analytics_events')->insert([
'userId' => $userId ?: null,
'eventName' => 'pay_success_attribution',
'pagePath' => 'server/payment/notify',
'propsJson' => json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
'clientTs' => (int) ($now * 1000),
'platform' => 'server',
'sessionId' => null,
'createdAt' => date('Y-m-d H:i:s', $now),
]);
} catch (\Throwable $e) {
// 埋点失败不影响主流程
}
}
return success([
@@ -461,6 +507,14 @@ class Payment extends BaseController
// 企业四项测试支付后,订单金额进入企业余额
$this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now);
// AI 深度报告:查询确认支付时补偿置 paid幂等
if (($localOrder['productType'] ?? '') === 'ai_deep_report' && !empty($localOrder['orderNo'])) {
try {
AiReportService::markPaid((string) $localOrder['orderNo']);
} catch (\Throwable $e) {
}
}
if (($localOrder['productType'] ?? '') !== 'recharge') {
// 触发分销佣金结算
try {

View File

@@ -0,0 +1,29 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\AiBalanceAlertService;
/**
* 超管 · 神仙 AI 监控面板
*
* - POST /api/v1/superadmin/ai/balance-check 手动触发余额预警扫描
* 可直接由宝塔 cron 每 12 小时调用一次
*/
class AiMonitor extends BaseController
{
public function balanceCheck()
{
$this->ensureSuperadmin();
$r = AiBalanceAlertService::scanAndAlert();
return success($r, "扫描完成:推送 {$r['alerted']} 条,跳过 {$r['skipped']} 条(当日去重)");
}
private function ensureSuperadmin()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
abort(403, '无权限访问');
}
}
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\superadmin;
use app\BaseController;
use app\common\AnalyticsEventLabels;
use think\facade\Db;
use think\facade\Request;
@@ -11,30 +12,42 @@ use think\facade\Request;
class Analytics extends BaseController
{
/**
* GET /api/v1/superadmin/analytics/summary?days=7
* GET /api/v1/superadmin/analytics/summary?days=7&eventName=可选
* 返回字段附加 eventNameCn中文名
*/
public function summary()
{
$days = min(90, max(1, (int) Request::param('days', 7)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$eventFilter = trim((string) Request::param('eventName', ''));
try {
$list = Db::name('analytics_events')
$q = Db::name('analytics_events')
->field('eventName, COUNT(*) AS cnt')
->where('createdAt', '>=', $since)
->where('createdAt', '>=', $since);
if ($eventFilter !== '') {
$q->where('eventName', $eventFilter);
}
$list = $q
->group('eventName')
->order('cnt', 'desc')
->select()
->toArray();
$total = Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
$list = AnalyticsEventLabels::withCn($list);
$cntQuery = Db::name('analytics_events')
->where('createdAt', '>=', $since);
if ($eventFilter !== '') {
$cntQuery->where('eventName', $eventFilter);
}
$total = $cntQuery->count();
return success([
'days' => $days,
'total' => (int) $total,
'list' => $list,
'labels' => AnalyticsEventLabels::all(),
'tableMissing' => false,
]);
} catch (\Throwable $e) {
@@ -42,13 +55,14 @@ class Analytics extends BaseController
'days' => $days,
'total' => 0,
'list' => [],
'labels' => AnalyticsEventLabels::all(),
'tableMissing' => true,
]);
}
}
/**
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50&eventName=&userId=
*/
public function events()
{
@@ -56,14 +70,21 @@ class Analytics extends BaseController
$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)));
$eventFilter = trim((string) Request::param('eventName', ''));
$userIdFilter = (int) Request::param('userId', 0);
try {
$total = (int) Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
$baseQ = Db::name('analytics_events')->where('createdAt', '>=', $since);
if ($eventFilter !== '') {
$baseQ->where('eventName', $eventFilter);
}
if ($userIdFilter > 0) {
$baseQ->where('userId', $userIdFilter);
}
$total = (int) (clone $baseQ)->count();
$offset = ($page - 1) * $pageSize;
$rows = Db::name('analytics_events')
->where('createdAt', '>=', $since)
$rows = $baseQ
->order('id', 'desc')
->limit($offset, $pageSize)
->select()
@@ -80,6 +101,8 @@ class Analytics extends BaseController
}
unset($r);
$rows = AnalyticsEventLabels::withCn($rows);
return success([
'list' => $rows,
'total' => $total,
@@ -97,4 +120,260 @@ class Analytics extends BaseController
]);
}
}
/**
* GET /api/v1/superadmin/analytics/user-journey?userId=&days=30
* 单个用户旅程:按时间倒序展示最近 200 条事件(中文名)
*/
public function userJourney()
{
$userId = (int) Request::param('userId', 0);
if ($userId <= 0) {
return error('userId 不能为空', 400);
}
$days = min(180, max(1, (int) Request::param('days', 30)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
try {
$rows = Db::name('analytics_events')
->where('userId', $userId)
->where('createdAt', '>=', $since)
->order('id', 'desc')
->limit(200)
->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);
$rows = AnalyticsEventLabels::withCn($rows);
return success([
'userId' => $userId,
'days' => $days,
'list' => $rows,
'total' => count($rows),
]);
} catch (\Throwable $e) {
return success([
'userId' => $userId,
'days' => $days,
'list' => [],
'total' => 0,
'tableMissing' => true,
]);
}
}
/**
* GET /api/v1/superadmin/analytics/share-stats?days=30
* 分享与邀请统计:
* - 每位用户累计分享次数
* - 每位用户邀请绑定人数distribution_bindings
* - 每位用户累计产生分润distribution_commissions
*/
public function shareStats()
{
$days = min(180, max(1, (int) Request::param('days', 30)));
$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', 30)));
$out = [];
$total = 0;
$tableMissing = false;
try {
// 分享次数share / tap_share_moment / tap_share_friend 都算
$shareEvents = ['share', 'tap_share_moment', 'tap_share_friend'];
$shareRows = Db::name('analytics_events')
->field('userId, COUNT(*) AS shareCount')
->whereIn('eventName', $shareEvents)
->where('createdAt', '>=', $since)
->where('userId', '>', 0)
->group('userId')
->order('shareCount', 'desc')
->select()
->toArray();
foreach ($shareRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) continue;
$out[$uid] = [
'userId' => $uid,
'shareCount' => (int) ($row['shareCount'] ?? 0),
'inviteBound' => 0,
'totalCommissionFen' => 0,
];
}
} catch (\Throwable $e) {
$tableMissing = true;
}
// 邀请绑定人数(按 inviteeId 去重,只统计当前 active
try {
$bindRows = Db::name('distribution_bindings')
->field('inviterId AS userId, COUNT(DISTINCT inviteeId) AS cnt')
->where('status', 'active')
->group('inviterId')
->select()
->toArray();
foreach ($bindRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) continue;
if (!isset($out[$uid])) {
$out[$uid] = [
'userId' => $uid,
'shareCount' => 0,
'inviteBound' => 0,
'totalCommissionFen' => 0,
];
}
$out[$uid]['inviteBound'] = (int) ($row['cnt'] ?? 0);
}
} catch (\Throwable $e) {}
// 分润金额(从 commission_records 聚合)
try {
$commRows = Db::name('commission_records')
->field('inviterId AS userId, SUM(commissionFen) AS sumFen')
->where('commissionFen', '>', 0)
->group('inviterId')
->select()
->toArray();
foreach ($commRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) continue;
if (!isset($out[$uid])) {
$out[$uid] = [
'userId' => $uid,
'shareCount' => 0,
'inviteBound' => 0,
'totalCommissionFen' => 0,
];
}
$out[$uid]['totalCommissionFen'] = (int) ($row['sumFen'] ?? 0);
}
} catch (\Throwable $e) {}
// 合并后按邀请人数、分润、分享次数排序
$list = array_values($out);
usort($list, function ($a, $b) {
if ($a['totalCommissionFen'] !== $b['totalCommissionFen']) {
return $b['totalCommissionFen'] <=> $a['totalCommissionFen'];
}
if ($a['inviteBound'] !== $b['inviteBound']) {
return $b['inviteBound'] <=> $a['inviteBound'];
}
return $b['shareCount'] <=> $a['shareCount'];
});
$total = count($list);
$offset = ($page - 1) * $pageSize;
$pageList = array_slice($list, $offset, $pageSize);
// 补用户资料
$uids = array_column($pageList, 'userId');
if (!empty($uids)) {
try {
$users = Db::name('wechat_users')
->whereIn('id', $uids)
->field('id, nickname, avatar, phone, enterpriseId, createdAt')
->select()
->toArray();
$byId = [];
foreach ($users as $u) { $byId[(int) $u['id']] = $u; }
foreach ($pageList as &$row) {
$u = $byId[$row['userId']] ?? null;
$row['nickname'] = $u['nickname'] ?? '';
$row['avatar'] = $u['avatar'] ?? '';
$row['phone'] = $u['phone'] ?? '';
$row['enterpriseId'] = $u['enterpriseId'] ?? null;
$row['createdAt'] = $u['createdAt'] ?? null;
}
unset($row);
} catch (\Throwable $e) {}
}
return success([
'days' => $days,
'total' => $total,
'list' => $pageList,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => $tableMissing,
]);
}
/**
* GET /api/v1/superadmin/analytics/share-funnel?days=14
* 分享漏斗:
* 1) 结果页访问pages/result/* page_view
* 2) 分享动作share / tap_share_moment / tap_share_friend
* 3) 好友登录login_silent_success
* 4) 好友付费pay_success_attribution 或 pay_success
* 5) 累计分润distribution_commissions
*/
public function shareFunnel()
{
$days = min(180, max(1, (int) Request::param('days', 14)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$out = [
['stage' => '结果页访问', 'value' => 0],
['stage' => '分享动作', 'value' => 0],
['stage' => '好友登录', 'value' => 0],
['stage' => '好友付费', 'value' => 0],
['stage' => '累计分润(元)', 'value' => 0],
];
$tableMissing = false;
try {
$out[0]['value'] = (int) Db::name('analytics_events')
->where('eventName', 'page_view')
->where('pagePath', 'like', 'pages/result/%')
->where('createdAt', '>=', $since)
->count();
$out[1]['value'] = (int) Db::name('analytics_events')
->whereIn('eventName', ['share', 'tap_share_moment', 'tap_share_friend'])
->where('createdAt', '>=', $since)
->count();
$out[2]['value'] = (int) Db::name('analytics_events')
->where('eventName', 'login_silent_success')
->where('createdAt', '>=', $since)
->count();
$out[3]['value'] = (int) Db::name('analytics_events')
->whereIn('eventName', ['pay_success_attribution', 'pay_success'])
->where('createdAt', '>=', $since)
->count();
} catch (\Throwable $e) {
$tableMissing = true;
}
// 分润金额(元)— commission_records.commissionFen
// createdAt 在该表为整数秒时间戳,而不是 datetime需要改成整数比较
try {
$sinceTs = time() - $days * 86400;
$totalFen = (int) Db::name('commission_records')
->where('createdAt', '>=', $sinceTs)
->where('commissionFen', '>', 0)
->sum('commissionFen');
$out[4]['value'] = round($totalFen / 100, 2);
} catch (\Throwable $e) {}
return success([
'days' => $days,
'funnel' => $out,
'tableMissing' => $tableMissing,
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 超管 · 小程序 TabBar 配置管理
*/
class MpTabBar extends BaseController
{
public function index()
{
$this->ensureSuperadmin();
$list = Db::name('mp_tabbar_items')
->order('sortOrder', 'asc')
->order('id', 'asc')
->select()
->toArray();
return success(['list' => $list]);
}
/**
* POST /superadmin/tabbar/save 整体覆盖保存
* body: { items: [{id?, sortOrder, pagePath, text, iconKey, highlight, visible, badgeKey}] }
*/
public function save()
{
$this->ensureSuperadmin();
$items = Request::param('items/a', []);
if (!is_array($items) || count($items) === 0) {
return error('items 不能为空');
}
if (count($items) > 5) {
return error('最多支持 5 个 Tab');
}
$visibleCount = 0;
foreach ($items as $it) {
if ((int) ($it['visible'] ?? 1) === 1) $visibleCount++;
}
if ($visibleCount < 2) {
return error('至少保留 2 个可见 Tab');
}
if ($visibleCount > 5) {
return error('可见 Tab 不得超过 5 个');
}
$now = time();
Db::startTrans();
try {
$keepIds = [];
foreach ($items as $idx => $it) {
$data = [
'sortOrder' => (int) ($it['sortOrder'] ?? ($idx + 1) * 10),
'pagePath' => trim((string) ($it['pagePath'] ?? '')),
'text' => trim((string) ($it['text'] ?? '')),
'iconKey' => trim((string) ($it['iconKey'] ?? 'home')),
'iconUrl' => isset($it['iconUrl']) && $it['iconUrl'] !== '' ? (string) $it['iconUrl'] : null,
'visible' => (int) ($it['visible'] ?? 1) === 1 ? 1 : 0,
'highlight' => (int) ($it['highlight'] ?? 0) === 1 ? 1 : 0,
'badgeKey' => isset($it['badgeKey']) ? (string) $it['badgeKey'] : null,
'updatedAt' => $now,
];
if ($data['pagePath'] === '' || $data['text'] === '') {
throw new \Exception('pagePath / text 不能为空');
}
if (!empty($it['id'])) {
Db::name('mp_tabbar_items')->where('id', (int) $it['id'])->update($data);
$keepIds[] = (int) $it['id'];
} else {
$data['createdAt'] = $now;
$newId = Db::name('mp_tabbar_items')->insertGetId($data);
$keepIds[] = (int) $newId;
}
}
// 删除不在本次保存列表中的旧行
Db::name('mp_tabbar_items')->whereNotIn('id', $keepIds)->delete();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('保存失败:' . $e->getMessage());
}
return success(null, '已保存 ' . count($items) . ' 个 Tab 项');
}
/**
* POST /superadmin/tabbar/reorder 仅排序
* body: { ids: [id1,id2,id3,...] }
*/
public function reorder()
{
$this->ensureSuperadmin();
$ids = Request::param('ids/a', []);
if (!is_array($ids) || empty($ids)) return error('ids 不能为空');
$now = time();
foreach ($ids as $idx => $id) {
Db::name('mp_tabbar_items')->where('id', (int) $id)->update([
'sortOrder' => ($idx + 1) * 10,
'updatedAt' => $now,
]);
}
return success(null, '排序已更新');
}
public function remove()
{
$this->ensureSuperadmin();
$id = (int) Request::param('id', 0);
if ($id <= 0) return error('id 非法');
$left = Db::name('mp_tabbar_items')->where('visible', 1)->where('id', '<>', $id)->count();
if ($left < 2) return error('至少保留 2 个可见 Tab不能再删');
Db::name('mp_tabbar_items')->where('id', $id)->delete();
return success(null, '已删除');
}
private function ensureSuperadmin()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
abort(403, '无权限访问');
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
class ProfitRule extends BaseController
{
public function index()
{
$this->ensureSuperadmin();
$list = Db::name('profit_sharing_rules')->order('id', 'asc')->select()->toArray();
foreach ($list as &$row) {
$row['receivers'] = json_decode($row['receivers'] ?? '[]', true) ?: [];
}
unset($row);
return success(['list' => $list]);
}
public function save()
{
$this->ensureSuperadmin();
$id = (int) Request::param('id', 0);
$productType = trim((string) Request::param('productType', ''));
$name = trim((string) Request::param('name', ''));
$receivers = Request::param('receivers/a', []);
$status = Request::param('status', 'active');
if ($productType === '' || $name === '') return error('productType / name 必填');
if (!is_array($receivers) || count($receivers) === 0) return error('至少一个收款人');
// 校验比例合计 = 1
$sum = 0.0;
foreach ($receivers as $r) { $sum += (float) ($r['ratio'] ?? 0); }
if (abs($sum - 1.0) > 0.005) {
return error('分账比例合计必须等于 100%(当前 ' . round($sum * 100, 2) . '%');
}
$now = time();
$data = [
'productType' => $productType,
'name' => $name,
'receivers' => json_encode($receivers, JSON_UNESCAPED_UNICODE),
'status' => $status === 'disabled' ? 'disabled' : 'active',
'updatedAt' => $now,
];
if ($id > 0) {
Db::name('profit_sharing_rules')->where('id', $id)->update($data);
} else {
$dup = Db::name('profit_sharing_rules')->where('productType', $productType)->find();
if ($dup) return error('该产品类型已存在规则,请编辑现有规则');
$data['createdAt'] = $now;
Db::name('profit_sharing_rules')->insert($data);
}
return success(null, '已保存');
}
public function toggle($id)
{
$this->ensureSuperadmin();
$row = Db::name('profit_sharing_rules')->where('id', (int) $id)->find();
if (!$row) return error('规则不存在');
$next = $row['status'] === 'active' ? 'disabled' : 'active';
Db::name('profit_sharing_rules')->where('id', (int) $id)->update([
'status' => $next,
'updatedAt' => time(),
]);
return success(['status' => $next]);
}
private function ensureSuperadmin()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
abort(403, '无权限访问');
}
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\AiChatArticleDisplayService;
use app\common\service\SoulArticleService;
use app\model\SoulArticle as SoulArticleModel;
use app\model\AiProvider as AiProviderModel;
use think\facade\Db;
use think\facade\Request;
/**
* 超管 · Soul 文章管理(采集 / 推荐 / AI 健康概览)
*/
class SoulArticle extends BaseController
{
/** POST /api/v1/superadmin/soul-articles/sync */
public function sync()
{
$this->ensureSuperadmin();
$limit = (int) Request::post('limit', 10);
$limit = max(1, min($limit, 30));
$tag = trim((string) Request::post('tag', 'MBTI'));
$keyword = trim((string) Request::post('keyword', ''));
if ($tag === '') $tag = 'MBTI';
$r = $keyword !== ''
? SoulArticleService::syncByKeyword($keyword, $limit, $tag)
: SoulArticleService::syncLatest($limit, $tag);
if (!empty($r['error'])) {
return error($r['error'], 500);
}
if ($keyword !== '') {
return success($r, "搜索并添加完成:新增 {$r['created']} 篇,更新 {$r['updated']}");
}
return success($r, "采集完成:新增 {$r['created']} 篇,更新 {$r['updated']}");
}
/** GET /api/v1/superadmin/soul-articles/ai-chat-display */
public function aiChatDisplayGet()
{
$this->ensureSuperadmin();
return success(AiChatArticleDisplayService::getSettings());
}
/** POST /api/v1/superadmin/soul-articles/ai-chat-display */
public function aiChatDisplaySave()
{
$this->ensureSuperadmin();
$input = Request::post();
$settings = AiChatArticleDisplayService::saveSettings(is_array($input) ? $input : []);
return success($settings, '已保存');
}
/** GET /api/v1/superadmin/soul-articles */
public function index()
{
$this->ensureSuperadmin();
$page = max(1, (int) Request::get('page', 1));
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
$isReco = Request::get('isRecommended', '');
$keyword = trim((string) Request::get('keyword', ''));
$tag = trim((string) Request::get('tag', ''));
$dateRange = Request::get('dateRange', '');
$query = SoulArticleModel::order('isRecommended', 'desc')
->order('recommendedOrder', 'asc')
->order('publishedAt', 'desc');
if ($isReco === '1' || $isReco === 1) {
$query = $query->where('isRecommended', 1);
} elseif ($isReco === '0' || $isReco === 0) {
$query = $query->where('isRecommended', 0);
}
if ($keyword !== '') {
$query = $query->whereLike('title', "%{$keyword}%");
}
if ($tag !== '') {
$query = $query->where('tag', $tag);
}
if (is_array($dateRange) && count($dateRange) === 2) {
$start = (int) strtotime((string) $dateRange[0] . ' 00:00:00');
$end = (int) strtotime((string) $dateRange[1] . ' 23:59:59');
if ($start > 0 && $end > 0) {
$query = $query->whereBetween('publishedAt', [$start, $end]);
}
}
$total = $query->count();
$rows = $query->page($page, $pageSize)->select()->toArray();
return paginate_response($rows, $total, $page, $pageSize);
}
/** POST /api/v1/superadmin/soul-articles/:id/order */
public function setOrder()
{
$this->ensureSuperadmin();
$id = (int) Request::param('id', 0);
$order = (int) Request::post('recommendedOrder', 0);
if ($id <= 0) return error('无效 id', 400);
if ($order < 0) return error('排序值不合法', 400);
$article = SoulArticleModel::find($id);
if (!$article) return error('文章不存在', 404);
if ((int) $article->isRecommended !== 1) {
return error('仅推荐中的文章可调整排序', 400);
}
$article->recommendedOrder = $order;
$article->save();
return success(['id' => $id, 'recommendedOrder' => $order], '排序已更新');
}
/** POST /api/v1/superadmin/soul-articles/reorder-normalize */
public function normalizeOrder()
{
$this->ensureSuperadmin();
$rows = SoulArticleModel::where('isRecommended', 1)
->order('recommendedOrder', 'asc')
->order('publishedAt', 'desc')
->select()
->toArray();
$idx = 1;
foreach ($rows as $row) {
$m = SoulArticleModel::find((int) $row['id']);
if (!$m) continue;
$m->recommendedOrder = $idx;
$m->save();
$idx++;
}
return success(['count' => count($rows)], '推荐权重已归一化');
}
/** POST /api/v1/superadmin/soul-articles/:id/recommend */
public function recommend()
{
$this->ensureSuperadmin();
$id = (int) Request::param('id', 0);
if ($id <= 0) return error('无效 id', 400);
$r = SoulArticleService::recommend($id);
if (empty($r['ok'])) {
return error($r['message'] ?? '操作失败', 400);
}
return success($r, $r['message']);
}
/** POST /api/v1/superadmin/soul-articles/:id/delete */
public function remove()
{
$this->ensureSuperadmin();
$id = (int) Request::param('id', 0);
if ($id <= 0) return error('无效 id', 400);
$article = SoulArticleModel::find($id);
if (!$article) return error('文章不存在', 404);
$article->delete();
return success(null, '已删除');
}
/**
* GET /api/v1/superadmin/ai/health
* AI 健康小条:各服务商可用 / 余额 / 最后检查时间
*/
public function health()
{
$this->ensureSuperadmin();
$rows = AiProviderModel::whereRaw('(visible IS NULL OR visible = 1)')
->order('sortWeight', 'asc')
->order('id', 'asc')
->select()
->toArray();
$list = [];
foreach ($rows as $r) {
$balance = isset($r['lastBalance']) ? (float) $r['lastBalance'] : null;
$threshold = isset($r['balanceAlertThreshold']) ? (float) $r['balanceAlertThreshold'] : 0.0;
$alertOn = (int) ($r['balanceAlertEnabled'] ?? 0) === 1;
$hasKey = !empty($r['apiKey']);
$enabled = (int) ($r['enabled'] ?? 0) === 1;
$status = 'unknown';
if (!$enabled) {
$status = 'disabled';
} elseif (!$hasKey) {
$status = 'no-key';
} elseif ($alertOn && $balance !== null && $balance <= $threshold) {
$status = 'low-balance';
} elseif ($balance !== null) {
$status = 'healthy';
} else {
$status = 'pending-check';
}
$list[] = [
'providerId' => $r['providerId'],
'name' => $r['name'],
'enabled' => $enabled,
'hasKey' => $hasKey,
'balance' => $balance,
'currency' => $r['lastBalanceCurrency'] ?? 'CNY',
'threshold' => $threshold,
'balanceAlertEnabled' => $alertOn,
'lastBalanceCheckedAt' => !empty($r['lastBalanceCheckedAt'])
? date('Y-m-d H:i:s', (int) $r['lastBalanceCheckedAt'])
: null,
'sortWeight' => (int) ($r['sortWeight'] ?? 100),
'status' => $status,
];
}
// 最近一次告警
$lastAlert = Db::name('ai_balance_alerts')->order('alertedAt', 'desc')->find();
return success([
'providers' => $list,
'lastAlert' => $lastAlert ? [
'providerId' => $lastAlert['providerId'],
'balance' => (float) $lastAlert['balance'],
'threshold' => (float) $lastAlert['threshold'],
'alertedAt' => date('Y-m-d H:i:s', (int) $lastAlert['alertedAt']),
] : null,
]);
}
private function ensureSuperadmin()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
abort(403, '无权限访问');
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* 神仙 AI 对话
*/
class AiConversation extends Model
{
use SoftDelete;
protected $strict = false;
protected $name = 'ai_conversations';
protected $deleteTime = 'deletedAt';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'lastMessageAt' => 'integer',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace app\model;
use think\Model;
/**
* 神仙 AI 消息流
*/
class AiMessage extends Model
{
protected $name = 'ai_messages';
/** 避免因库表字段与模型缓存不一致导致写入抛错 */
protected $strict = false;
protected $autoWriteTimestamp = false;
protected $type = [
'createdAt' => 'integer',
'tokensIn' => 'integer',
'tokensOut' => 'integer',
];
}

View File

@@ -1,109 +1,110 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* AI服务商配置模型
*/
class AiProvider extends Model
{
use SoftDelete;
// 设置表名(不带前缀,前缀在数据库配置中设置)
protected $name = 'ai_providers';
// 软删除字段(驼峰命名,时间戳格式)
protected $deleteTime = 'deletedAt';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'providerId' => 'string',
'name' => 'string',
'enabled' => 'int',
'visible' => 'int',
'apiKey' => 'string',
'apiEndpoint' => 'string',
'model' => 'string',
'organizationId' => 'string',
'maxTokens' => 'int',
'balanceAlertEnabled' => 'int',
'balanceAlertThreshold' => 'float',
'notes' => 'string',
'docUrl' => 'string',
'isFree' => 'int',
'supportsBalance' => 'int',
'lastBalance' => 'float',
'lastBalanceCurrency' => 'string',
'lastBalanceCheckedAt' => 'int',
'createdAt' => 'int',
'updatedAt' => 'int',
'deletedAt' => 'int',
'extraConfig' => 'string',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'lastBalanceCheckedAt' => 'integer',
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'extraConfig' => 'json',
];
// 注意API Key需要可逆读取用于API调用所以不隐藏但在获取器中脱敏
/**
* API Key 修改器存储原始值用于API调用
*/
public function setApiKeyAttr($value)
{
if (empty($value)) {
return null;
}
// 如果输入的是脱敏格式(包含****),不更新
if (strpos($value, '****') !== false) {
return null; // 返回null表示不更新此字段
}
// 直接存储原始值实际生产环境建议使用AES加密
return $value;
}
/**
* API Key 获取器(返回脱敏后的密钥)
* 注意如果需要原始密钥用于API调用使用 getRawApiKey() 方法
*/
public function getApiKeyAttr($value)
{
if (empty($value)) {
return '';
}
// 返回脱敏后的密钥显示前6位和后4位
if (strlen($value) > 10) {
return substr($value, 0, 6) . '****' . substr($value, -4);
}
return '****';
}
/**
* 获取原始API Key用于API调用
* @return string
*/
public function getRawApiKey()
{
// 直接从数据库读取原始值,绕过获取器
return \think\facade\Db::name('ai_providers')
->where('id', $this->id)
->value('apiKey') ?: '';
}
}
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* AI服务商配置模型
*/
class AiProvider extends Model
{
use SoftDelete;
// 设置表名(不带前缀,前缀在数据库配置中设置)
protected $name = 'ai_providers';
// 软删除字段(驼峰命名,时间戳格式)
protected $deleteTime = 'deletedAt';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'providerId' => 'string',
'name' => 'string',
'enabled' => 'int',
'visible' => 'int',
'apiKey' => 'string',
'apiEndpoint' => 'string',
'model' => 'string',
'organizationId' => 'string',
'maxTokens' => 'int',
'balanceAlertEnabled' => 'int',
'balanceAlertThreshold' => 'float',
'notes' => 'string',
'docUrl' => 'string',
'isFree' => 'int',
'supportsBalance' => 'int',
'lastBalance' => 'float',
'lastBalanceCurrency' => 'string',
'lastBalanceCheckedAt' => 'int',
'createdAt' => 'int',
'updatedAt' => 'int',
'deletedAt' => 'int',
'extraConfig' => 'string',
'sortWeight' => 'int',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'lastBalanceCheckedAt' => 'integer',
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'extraConfig' => 'json',
];
// 注意API Key需要可逆读取用于API调用所以不隐藏但在获取器中脱敏
/**
* API Key 修改器存储原始值用于API调用
*/
public function setApiKeyAttr($value)
{
if (empty($value)) {
return null;
}
// 如果输入的是脱敏格式(包含****),不更新
if (strpos($value, '****') !== false) {
return null; // 返回null表示不更新此字段
}
// 直接存储原始值实际生产环境建议使用AES加密
return $value;
}
/**
* API Key 获取器(返回脱敏后的密钥)
* 注意如果需要原始密钥用于API调用使用 getRawApiKey() 方法
*/
public function getApiKeyAttr($value)
{
if (empty($value)) {
return '';
}
// 返回脱敏后的密钥显示前6位和后4位
if (strlen($value) > 10) {
return substr($value, 0, 6) . '****' . substr($value, -4);
}
return '****';
}
/**
* 获取原始API Key用于API调用
* @return string
*/
public function getRawApiKey()
{
// 直接从数据库读取原始值,绕过获取器
return \think\facade\Db::name('ai_providers')
->where('id', $this->id)
->value('apiKey') ?: '';
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace app\model;
use think\Model;
class AiReport extends Model
{
protected $name = 'ai_reports';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
}

View File

@@ -61,6 +61,8 @@ class Enterprise extends Model
'sbti' => true,
'pdp' => true,
'disc' => true,
/** 详细测试列表中的「AI 性格测试」聚合入口 */
'aiHub' => true,
'distribution' => true,
];
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\model;
use think\Model;
class ProfitSharingRule extends Model
{
protected $name = 'profit_sharing_rules';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'receivers' => 'json',
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* Soul 采集文章MBTI 主题 · 为一场 soul 创业实验引流)
*/
class SoulArticle extends Model
{
use SoftDelete;
protected $name = 'soul_articles';
protected $deleteTime = 'deletedAt';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'publishedAt' => 'integer',
'isRecommended' => 'integer',
'recommendedOrder' => 'integer',
'viewCount' => 'integer',
];
}

View File

@@ -0,0 +1,80 @@
-- AI 聊天 + Soul 文章推荐 + 欠费预警字段
-- 对应需求ai功能202600417 -6.md
-- 建议部署时用 source 一次性执行;前缀 mbti_ 按环境 .env DATABASE_PREFIX 调整
-- 1) ai_providers 新增排序权重(故障切换时可手动调优先级)
ALTER TABLE `mbti_ai_providers`
ADD COLUMN `sortWeight` INT NOT NULL DEFAULT 100 COMMENT '优先级,升序;欠费自动降到999' AFTER `visible`;
-- 2) AI 对话表
CREATE TABLE IF NOT EXISTS `mbti_ai_conversations` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`userId` INT UNSIGNED NOT NULL,
`title` VARCHAR(128) NOT NULL DEFAULT '',
`mbtiType` VARCHAR(16) NOT NULL DEFAULT '',
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
`lastMessageAt` INT UNSIGNED NOT NULL DEFAULT 0,
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
`deletedAt` INT UNSIGNED NULL,
INDEX `idx_user` (`userId`, `updatedAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 对话';
-- 3) AI 消息表
CREATE TABLE IF NOT EXISTS `mbti_ai_messages` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`conversationId` INT UNSIGNED NOT NULL,
`role` ENUM('system','user','assistant') NOT NULL,
`content` MEDIUMTEXT NOT NULL,
`tokensIn` INT UNSIGNED NOT NULL DEFAULT 0,
`tokensOut` INT UNSIGNED NOT NULL DEFAULT 0,
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
`isDegraded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为降级兜底回答',
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
INDEX `idx_conv` (`conversationId`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 消息流';
-- 4) Soul 采集文章表(一场 soul 创业实验 引流内容池)
CREATE TABLE IF NOT EXISTS `mbti_soul_articles` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`sourceId` VARCHAR(64) NOT NULL COMMENT 'soul 侧文章 id',
`title` VARCHAR(255) NOT NULL,
`cover` VARCHAR(512) NOT NULL DEFAULT '',
`url` VARCHAR(512) NOT NULL,
`summary` VARCHAR(512) NOT NULL DEFAULT '',
`author` VARCHAR(64) NOT NULL DEFAULT '',
`tag` VARCHAR(32) NOT NULL DEFAULT 'MBTI',
`publishedAt` INT UNSIGNED NOT NULL DEFAULT 0,
`isRecommended` TINYINT(1) NOT NULL DEFAULT 0,
`recommendedOrder` INT NOT NULL DEFAULT 0,
`viewCount` INT UNSIGNED NOT NULL DEFAULT 0,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
`deletedAt` INT UNSIGNED NULL,
UNIQUE KEY `uk_source` (`sourceId`),
INDEX `idx_reco` (`isRecommended`, `recommendedOrder`, `publishedAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Soul 采集文章MBTI 主题)';
-- 5) AI 使用限额(每日)—— 用户层面的限流记录
CREATE TABLE IF NOT EXISTS `mbti_ai_usage_daily` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`userId` INT UNSIGNED NOT NULL,
`dateStr` CHAR(10) NOT NULL COMMENT '格式 YYYY-MM-DD',
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY `uk_user_date` (`userId`, `dateStr`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 每日限流计数';
-- 6) AI 余额预警日志(去重同一阈值每天只发 1 次)
CREATE TABLE IF NOT EXISTS `mbti_ai_balance_alerts` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`providerId` VARCHAR(32) NOT NULL,
`balance` DECIMAL(10,2) NOT NULL DEFAULT 0,
`threshold` DECIMAL(10,2) NOT NULL DEFAULT 0,
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY',
`alertedAt` INT UNSIGNED NOT NULL DEFAULT 0,
`dateStr` CHAR(10) NOT NULL,
UNIQUE KEY `uk_provider_date` (`providerId`, `dateStr`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 欠费预警去重日志';

View File

@@ -0,0 +1,12 @@
-- 冷脸分析字段:扩展 user_profile用于列表/详情展示
-- 说明:
-- coldFaceScore 0-100<35=暖(warm), 35-65=中(neutral), >65=冷(cold)
-- coldFaceLevel 字符串枚举 cold/neutral/warm
-- coldFaceUpdatedAt 最近一次测评更新时间戳(秒)
-- 执行: 在 phpMyAdmin 或 CLI 运行
ALTER TABLE `mbti_user_profile`
ADD COLUMN `coldFaceScore` tinyint(3) UNSIGNED NULL DEFAULT NULL COMMENT '冷脸分(0-100)' AFTER `lastFaceResultId`,
ADD COLUMN `coldFaceLevel` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '冷脸等级: cold/neutral/warm' AFTER `coldFaceScore`,
ADD COLUMN `coldFaceUpdatedAt` int(11) NULL DEFAULT NULL COMMENT '冷脸更新时间(时间戳)' AFTER `coldFaceLevel`,
ADD INDEX `idx_coldFaceLevel` (`coldFaceLevel`) USING BTREE;

View File

@@ -0,0 +1,4 @@
-- 埋点表补充userId + createdAt 复合索引(用于用户旅程、分享统计)
-- 幂等写法:若索引已存在会报错,可忽略(或手工 SHOW INDEX 检查后再执行)
ALTER TABLE `mbti_analytics_events`
ADD INDEX `idx_user_created` (`userId`, `createdAt`);

View File

@@ -0,0 +1,95 @@
-- =====================================================
-- 神仙 AI v2 · TabBar 后台可配 + AI 深度报告 + 分账规则
-- Date: 2026-04-17
-- 生产库表前缀若为 mbti_则下方表名以 mbti_ 为准
-- =====================================================
-- ---------- 1) 小程序 TabBar 配置 ----------
CREATE TABLE IF NOT EXISTS `mbti_mp_tabbar_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`sortOrder` INT NOT NULL DEFAULT 100 COMMENT '排序,小的在前',
`pagePath` VARCHAR(128) NOT NULL COMMENT '目标页面 pages/xxx/xxx',
`text` VARCHAR(32) NOT NULL COMMENT '底部文案',
`iconKey` VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '图标key: home/camera/ai/profile',
`visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏(审核模式)',
`highlight` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=中间突出按钮(圆形)',
`badgeKey` VARCHAR(32) NULL COMMENT '红点角标来源key(预留)',
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_sort` (`sortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序 TabBar 后台可配';
-- 初始 4 项默认顺序首页·拍摄·神仙AI·我
INSERT INTO `mbti_mp_tabbar_items` (sortOrder, pagePath, text, iconKey, highlight, visible, createdAt, updatedAt) VALUES
(10, 'pages/index/index', '首页', 'home', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(20, 'pages/index/camera', '拍摄', 'camera', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(30, 'pages/ai-chat/index', '神仙AI', 'ai', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(40, 'pages/profile/index', '', 'profile', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- ---------- 2) 分账规则(知己 profit-sharing 思路) ----------
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_rules` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`productType` VARCHAR(64) NOT NULL COMMENT 'ai_deep_report / mbti_report / face_analysis / default',
`name` VARCHAR(128) NOT NULL,
`receivers` JSON NOT NULL COMMENT '[{type,name,ratio,account?}]',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product` (`productType`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账规则';
INSERT INTO `mbti_profit_sharing_rules` (productType, name, receivers, status, createdAt, updatedAt) VALUES
('ai_deep_report', 'AI 深度画像报告',
'[{"type":"platform","name":"平台","ratio":0.70},{"type":"distributor_l1","name":"一级分销","ratio":0.20},{"type":"distributor_l2","name":"二级分销","ratio":0.10}]',
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('default', '默认规则 · 平台 100%',
'[{"type":"platform","name":"平台","ratio":1.00}]',
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- ---------- 3) 分账记录 ----------
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_records` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`orderSn` VARCHAR(64) NOT NULL COMMENT '订单号',
`orderId` INT UNSIGNED NULL COMMENT '订单表id',
`productType` VARCHAR(64) NOT NULL,
`totalAmount` INT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
`details` JSON NOT NULL COMMENT '分账明细 [{receiverType,receiverName,amount,ratio,account,status}]',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/completed/failed',
`processedAt` INT UNSIGNED NULL,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_sn` (`orderSn`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账记录';
-- ---------- 4) AI 深度报告(付费解锁阅读) ----------
CREATE TABLE IF NOT EXISTS `mbti_ai_reports` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`userId` INT UNSIGNED NOT NULL,
`conversationId` INT UNSIGNED NULL COMMENT '来源对话',
`mbtiType` VARCHAR(8) NULL,
`orderSn` VARCHAR(64) NULL COMMENT '对应订单号',
`orderId` INT UNSIGNED NULL,
`priceFen` INT NOT NULL DEFAULT 990 COMMENT '单价(分)',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending=未支付 paid=已支付未生成 generating=生成中 done=已完成 failed=失败',
`title` VARCHAR(255) NULL,
`summary` TEXT NULL COMMENT '免费预览的摘要片段',
`content` MEDIUMTEXT NULL COMMENT '完整报告正文',
`posterUrl` VARCHAR(512) NULL COMMENT '分享海报',
`retryCount` INT NOT NULL DEFAULT 0,
`lastError` VARCHAR(512) NULL,
`paidAt` INT UNSIGNED NULL,
`generatedAt` INT UNSIGNED NULL,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_user` (`userId`),
KEY `idx_order_sn` (`orderSn`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 深度画像报告';

View File

@@ -0,0 +1,24 @@
-- =====================================================
-- plan7 补丁:给 tabBar 配置加 iconUrl可传图片 URL 覆盖 SVG
-- 给 soul_articles 自动推送时间戳
-- Date: 2026-04-17
-- =====================================================
ALTER TABLE `mbti_mp_tabbar_items`
ADD COLUMN `iconUrl` VARCHAR(512) NULL AFTER `iconKey`
COMMENT '图片图标 URL若有则优先覆盖 iconKey 的 SVG';
-- 给"神仙AI"tab 默认用团队 logo 图
UPDATE `mbti_mp_tabbar_items`
SET `iconUrl` = '/images/mbti-team-image.png'
WHERE `iconKey` = 'ai' AND (`iconUrl` IS NULL OR `iconUrl` = '');
-- Soul 自动采集last sync 时间戳记录到 system_config
INSERT INTO `mbti_system_config` (`key`, `value`, `description`, `createdAt`, `updatedAt`)
SELECT 'soul_article_auto_sync',
JSON_OBJECT('enabled', TRUE, 'intervalSec', 3600, 'limit', 10),
'Soul 文章自动采集配置',
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (
SELECT 1 FROM `mbti_system_config` WHERE `key` = 'soul_article_auto_sync'
);

View File

@@ -27,6 +27,14 @@ Route::group('api', function () {
Route::post('analytics/events', 'api.Analytics/batch');
// 好友打开分享卡片:凭 st 校验查看他人测试结果(无需登录)
Route::get('test/share-detail', 'api.Test/shareDetail');
// 神仙 AI · 推荐文章(无需登录也可拉取,首屏展示)
Route::get('ai/articles/recommended', 'api.AiChat/recommendedArticles');
Route::get('ai/articles/profile-teaser', 'api.AiChat/profileArticleTeaser');
Route::post('ai/articles/:id/click', 'api.AiChat/articleClick');
// 快捷问句:公开接口;若请求头带有效微信 token 则按用户 MBTI 个性化
Route::get('ai/quick-questions', 'api.AiChat/quickQuestions');
// 小程序 TabBar 动态配置(无需登录,启动拉取)
Route::get('mp/tabbar', 'api.MpConfig/tabbar');
})->middleware('cors');
// 前端需要认证的API路由
@@ -81,6 +89,19 @@ Route::group('api', function () {
Route::post('wechat/transfer/notify', 'api.WechatTransferNotify/notify')->middleware('cors');
// 存客宝获客线索上报
Route::post('crm/report', 'api.CrmReport/report');
// ==================== 神仙 AI需要微信登录====================
Route::post('ai/chat', 'api.AiChat/chat');
Route::get('ai/conversations', 'api.AiChat/conversations');
Route::get('ai/conversations/:id/messages', 'api.AiChat/messages');
Route::post('ai/transcribe', 'api.AiChat/transcribe');
// AI 深度画像报告
Route::post('ai/report/create', 'api.AiReport/create');
Route::get('ai/report/my-latest', 'api.AiReport/myLatest');
Route::get('ai/report/:id', 'api.AiReport/show');
Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev');
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
})->middleware(['cors', 'auth']);
// ==================== 小程序API路由匹配前端 /api 路径)====================
@@ -295,6 +316,34 @@ Route::group('api/v1/superadmin', function () {
// 小程序埋点统计(仅超管)
Route::get('analytics/summary', 'superadmin.Analytics/summary');
Route::get('analytics/events', 'superadmin.Analytics/events');
Route::get('analytics/user-journey', 'superadmin.Analytics/userJourney');
Route::get('analytics/share-stats', 'superadmin.Analytics/shareStats');
Route::get('analytics/share-funnel', 'superadmin.Analytics/shareFunnel');
// Soul 文章采集与推荐(一场 soul 创业实验引流)
Route::get('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplayGet');
Route::post('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplaySave');
Route::post('soul-articles/sync', 'superadmin.SoulArticle/sync');
Route::post('soul-articles/:id/recommend', 'superadmin.SoulArticle/recommend');
Route::post('soul-articles/:id/order', 'superadmin.SoulArticle/setOrder');
Route::post('soul-articles/reorder-normalize', 'superadmin.SoulArticle/normalizeOrder');
Route::post('soul-articles/:id/delete', 'superadmin.SoulArticle/remove');
Route::get('soul-articles', 'superadmin.SoulArticle/index');
// 神仙 AI 健康与欠费监控
Route::get('ai/health', 'superadmin.SoulArticle/health');
Route::post('ai/balance-check', 'superadmin.AiMonitor/balanceCheck');
// 小程序 TabBar 后台可配
Route::get('tabbar', 'superadmin.MpTabBar/index');
Route::post('tabbar/save', 'superadmin.MpTabBar/save');
Route::post('tabbar/reorder', 'superadmin.MpTabBar/reorder');
Route::post('tabbar/:id/delete', 'superadmin.MpTabBar/remove');
// 分账规则
Route::get('profit-rules', 'superadmin.ProfitRule/index');
Route::post('profit-rules/save', 'superadmin.ProfitRule/save');
Route::post('profit-rules/:id/toggle', 'superadmin.ProfitRule/toggle');
})->middleware(['cors', 'auth', 'superadmin']);
// ==================== 兼容旧版路由(保留,逐步废弃)====================

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -627,6 +627,19 @@ Page({
tt.navigateTo({ url: '/pages/test-select/index' })
},
goToQuestionnaireFromFace() {
tt.navigateTo({ url: '/pages/test-select/index' })
},
goToDeepServiceFromFace() {
tt.navigateTo({ url: '/pages/purchase/index' })
},
goToPromoFromFace() {
tt.showShareMenu && tt.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage'] })
tt.navigateTo({ url: '/pages/promo/index' })
},
retake() {
// camera 是 tabBar 页面,必须用 switchTab
tt.switchTab({ url: '/pages/index/camera' })

View File

@@ -116,18 +116,10 @@
</view>
</view>
<view
class="result-cta-questionnaire"
hover-class="result-cta-questionnaire--pressed"
hover-stay-time="120"
bindtap="goToQuestionnaireTest"
>
<text class="result-cta-questionnaire-text">去做详细性格测试</text>
</view>
</view>
<view class="card" tt:if="{{!hasError && result.summary}}">
<text class="card-title">性格概述</text>
<text class="card-title">📝 性格概述</text>
<text class="card-text">{{result.summary}}</text>
</view>
@@ -337,4 +329,23 @@
</block>
</view>
<!-- 固定底部操作栏:三按钮常驻(抖音) -->
<view class="result-action-bar" tt:if="{{!isAnalyzing && showResult && !hasError}}">
<view class="result-action-bar__btn result-action-bar__btn--test" bindtap="goToQuestionnaireFromFace">
<text class="result-action-bar__ic">🧠</text>
<text class="result-action-bar__label">详细性格测试</text>
</view>
<view class="result-action-bar__btn result-action-bar__btn--deep" bindtap="goToDeepServiceFromFace">
<text class="result-action-bar__ic">💎</text>
<text class="result-action-bar__label">找专家·深度解读</text>
</view>
<view class="result-action-bar__btn result-action-bar__btn--share" bindtap="goToPromoFromFace">
<text class="result-action-bar__ic">📣</text>
<view class="result-action-bar__share-body">
<text class="result-action-bar__label">分享赚</text>
<text class="result-action-bar__label-hl">90%</text>
</view>
</view>
</view>
</view>

View File

@@ -2,7 +2,7 @@
.container {
min-height: 100vh;
background-color: #f5f5f5;
padding: 0 24rpx 60rpx;
padding: 0 24rpx calc(200rpx + env(safe-area-inset-bottom));
}
/* ========== 分析中模态框 ========== */
@@ -1248,3 +1248,99 @@
.boss-metric-value.low {
color: #f44336;
}
/* ========== 底部固定操作栏(三按钮常驻) ========== */
.result-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: stretch;
gap: 12rpx;
padding: 16rpx 20rpx calc(16rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
border-top: 1rpx solid #eceff4;
box-shadow: 0 -6rpx 24rpx rgba(15, 23, 42, 0.08);
}
.result-action-bar__btn {
flex: 1;
min-height: 96rpx;
margin: 0;
padding: 10rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
border-radius: 20rpx;
background: #f3f4f6;
color: #1f2937;
font-size: 24rpx;
line-height: 1.2;
text-align: center;
border: none;
box-sizing: border-box;
}
.result-action-bar__ic {
font-size: 30rpx;
line-height: 1;
}
.result-action-bar__label {
font-size: 22rpx;
font-weight: 600;
color: #1f2937;
letter-spacing: 0.5rpx;
}
.result-action-bar__btn--test {
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
}
.result-action-bar__btn--test .result-action-bar__label {
color: #4338ca;
}
.result-action-bar__btn--deep {
background: linear-gradient(135deg, #fdf4ff 0%, #fae8ff 100%);
}
.result-action-bar__btn--deep .result-action-bar__label {
color: #a21caf;
}
.result-action-bar__btn--share {
background: linear-gradient(135deg, #ff6b9d 0%, #e63946 100%);
box-shadow: 0 6rpx 20rpx rgba(230, 57, 70, 0.35);
flex-direction: row;
gap: 10rpx;
padding: 10rpx 14rpx;
}
.result-action-bar__btn--share .result-action-bar__ic {
font-size: 34rpx;
}
.result-action-bar__share-body {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.05;
}
.result-action-bar__btn--share .result-action-bar__label {
color: #fff;
font-size: 22rpx;
}
.result-action-bar__label-hl {
color: #fff;
font-size: 32rpx;
font-weight: 800;
letter-spacing: 1rpx;
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
}

View File

@@ -350,6 +350,7 @@ Page({
goToPurchaseEnterprise() { tt.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToEnterprise() { tt.navigateTo({ url: '/pages/enterprise/index' }) },
goToPromo() { tt.navigateTo({ url: '/pages/promo/index' }) },
goToPromoWithdrawals() { tt.navigateTo({ url: '/pages/promo/withdrawals' }) },
goToMyResume() { tt.navigateTo({ url: '/pages/enterprise/resume-history' }) },
goToSettings() {
tt.showToast({ title: '开发中', icon: 'none' })

View File

@@ -177,29 +177,23 @@
</view>
</view>
<!-- 推广中心(根据管理端开关显示/隐藏,标题可配置 -->
<!-- 推广数据卡:可提现格子拆成两区(上=分销中心 / 下=提现记录 -->
<view class="section px-section" tt:if="{{hasLogin && promoDistributionEnabled && !reviewMode && permDistribution}}">
<view class="promo-card" bindtap="goToPromo">
<view class="promo-header">
<view class="promo-title-wrap">
<text class="promo-icon">📈</text>
<text class="promo-title">{{promoCenterTitle}}</text>
</view>
</view>
<view class="promo-card">
<view class="promo-stats">
<view class="promo-stat-item">
<view class="promo-stat-item" catchtap="goToPromo">
<text class="promo-stat-label">邀请好友</text>
<text class="promo-stat-value">{{promoTotalInvite}}</text>
</view>
<view class="promo-stat-divider"></view>
<view class="promo-stat-item">
<text class="promo-stat-label">累计收益</text>
<text class="promo-stat-value">¥{{promoTotalEarned}}</text>
<view class="promo-stat-item" catchtap="goToPromo">
<text class="promo-stat-label">可提现 </text>
<text class="promo-stat-value promo-highlight">¥{{promoWithdrawable}}</text>
</view>
<view class="promo-stat-divider"></view>
<view class="promo-stat-item">
<text class="promo-stat-label">可提现</text>
<text class="promo-stat-value promo-highlight">¥{{promoWithdrawable}}</text>
<view class="promo-stat-item promo-stat-item--sub" catchtap="goToPromoWithdrawals">
<text class="promo-stat-label">累计 </text>
<text class="promo-stat-value promo-stat-value--sub">¥{{promoTotalEarned}}</text>
</view>
</view>
</view>

View File

@@ -559,6 +559,21 @@ page {
color: #F43F5E;
}
/* "累计"辅格:紫色较小字号 */
.promo-stat-item--sub .promo-stat-value {
color: #a78bfa;
font-size: 30rpx;
}
.promo-stat-item--sub .promo-stat-label {
color: #7c3aed;
font-weight: 600;
}
.promo-stat-value--sub {
font-size: 30rpx;
}
/* ===== 服务菜单 ===== */
.menu-card {
background: #FFFFFF;

View File

@@ -14,8 +14,7 @@ Page({
successModal: {
visible: false,
title: '',
content: '',
wechat: ''
content: ''
}
},
@@ -148,8 +147,7 @@ Page({
this.setData({ purchasing: false })
this._reportCrmLead(category, 'buy')
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
const wechat = (category.serviceWechat || '').trim()
this._showSuccessModal('购买成功', successMsg, wechat)
this._showSuccessModal('购买成功', successMsg)
},
fail: () => {
tt.hideLoading()
@@ -159,27 +157,21 @@ Page({
},
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(() => {
const done = () => {
tt.hideLoading()
this._showSuccessModal('申请成功', successMsg, wechat)
}, 600)
this._showSuccessModal('申请成功', successMsg)
}
this._reportCrmLead(category, 'consult', done)
},
_showSuccessModal(title, content, wechat) {
_showSuccessModal(title, content) {
this.setData({
successModal: {
visible: true,
title: title || '成功',
content: content || '',
wechat: wechat || ''
content: content || ''
}
})
},
@@ -190,25 +182,23 @@ Page({
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 {Object} category 深度服务类目对象consultWechat 可空,申请咨询时由后端按企业配置回落
* @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
* @param {Function} [onDone]
*/
_reportCrmLead(category, actionType) {
const apiKey = category.consultWechat || ''
if (!apiKey) return
_reportCrmLead(category, actionType, onDone) {
const apiKey = (category.consultWechat || '').trim()
const apiBase = app.globalData.apiBase || ''
if (!apiBase) return
if (actionType === 'buy' && !apiKey) {
if (typeof onDone === 'function') onDone()
return
}
if (!apiBase) {
if (typeof onDone === 'function') onDone()
return
}
const isEnterprise = this.data.activeTab === 'enterprise'
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
@@ -226,6 +216,7 @@ Page({
source,
remark,
siteTags: category.title || '',
deepConsult: actionType === 'consult',
},
success(res) {
console.log('[CRM] 线索上报结果', res.data)
@@ -233,6 +224,9 @@ Page({
fail(err) {
console.warn('[CRM] 线索上报请求失败', err)
},
complete() {
if (typeof onDone === 'function') onDone()
},
})
},

View File

@@ -10,12 +10,7 @@
</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 File

@@ -59,6 +59,21 @@ function applyDrawCountAfterShuffle(questions) {
return questions.slice(0, n)
}
function parseJsonBody(raw) {
if (raw == null) return {}
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return {}
try {
return JSON.parse(s)
} catch (e) {
return { _parseError: true }
}
}
return {}
}
function fetchQuestionBank(type, enterpriseId) {
const q = [`type=${encodeURIComponent(type)}`]
if (enterpriseId != null && Number(enterpriseId) > 0) {
@@ -69,9 +84,14 @@ function fetchQuestionBank(type, enterpriseId) {
method: 'GET',
needAuth: true
}).then((res) => {
const body = res.data || {}
const body = parseJsonBody(res.data)
if (body._parseError) {
throw new Error('接口返回非 JSON请检查 apiBase 与服务器域名')
}
if (body.code !== 200 || body.data == null) {
throw new Error(body.message || '拉取题库失败')
const msg = body.message || '拉取题库失败'
const hint = body.code === 401 || body.code === 403 ? `${msg}(请重新进入小程序)` : msg
throw new Error(hint)
}
const list = body.data.list
if (!Array.isArray(list)) {

4
miniprogram/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# 复制本文件为同目录下的 .env 并填写 Secret
# cp .env.example .env
WX_MINIPROGRAM_APPID=wx3d15ed02e98b04e3
WX_MINIPROGRAM_APPSECRET=

View File

@@ -46,9 +46,9 @@ App({
enterpriseIdFromScene: null,
// 超管配置的默认企业 ID无 scene/eid 等入口参数时回落)
defaultEnterpriseId: null,
// API基础地址(开发时用本地,生产环境替换为实际域名)
//apiBase: 'https://mbtiapi.quwanzhi.com',
apiBase: 'http://mbti.com',
// API 基础地址:默认走线上;本机/内网调试可在开发者工具执行
// wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序
apiBase: 'https://mbtiapi.quwanzhi.com',
// VIP信息
vipInfo: null,
// 测试次数
@@ -69,6 +69,13 @@ App({
// 记录场景值供埋点使用
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
try {
const ov = wx.getStorageSync('apiBaseOverride')
if (ov && typeof ov === 'string' && ov.trim()) {
this.globalData.apiBase = ov.trim().replace(/\/$/, '')
}
} catch (e) {}
try {
const { mergeThirdPartyFromQuery } = require('./utils/thirdPartyContext.js')
mergeThirdPartyFromQuery((launchOptions && launchOptions.query) || {})
@@ -83,6 +90,14 @@ App({
analytics.reportAppLaunch(launchOptions)
} catch (e) {}
// 先读取缓存的 TabBar 配置,避免首屏 flash后续拉接口再刷新
try {
const cachedTabBar = wx.getStorageSync('tabBarConfig')
if (cachedTabBar && Array.isArray(cachedTabBar.items) && cachedTabBar.items.length >= 2) {
this.globalData.tabBar = cachedTabBar
}
} catch (e) {}
// 必须先完成静默登录再拉 runtime否则无 token 时 enterprisePermissions 永远 null清除缓存后必现「要二次刷新」
this.silentLogin()
.catch(() => {})
@@ -91,6 +106,46 @@ App({
this._afterRuntimeSynced()
})
.catch(() => {})
// 并行拉取 TabBar 动态配置(不阻塞主流程)
this.loadTabBarConfig().catch(() => {})
},
/**
* 拉取小程序 TabBar 动态配置(后台可调整顺序/显示)
* 写入 globalData.tabBar 并缓存到本地
*/
loadTabBarConfig() {
return new Promise((resolve) => {
const base = (this.globalData.apiBase || '').replace(/\/$/, '')
if (!base) { resolve(null); return }
wx.request({
url: base + '/api/mp/tabbar',
method: 'GET',
timeout: 6000,
success: (res) => {
if (res && res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) {
const cfg = res.data.data
if (Array.isArray(cfg.items) && cfg.items.length >= 2) {
this.globalData.tabBar = cfg
try { wx.setStorageSync('tabBarConfig', cfg) } catch (e) {}
try {
const pages = getCurrentPages()
if (pages && pages.length > 0) {
const top = pages[pages.length - 1]
if (top && typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig()
}
}
} catch (e) {}
}
}
resolve(null)
},
fail: () => resolve(null)
})
})
},
/**
@@ -254,21 +309,28 @@ App({
}
if (user) {
this.globalData.userInfo = user
// 使用微信真实 openid而不是 wechat_users 表的 id
this.globalData.openId = user.openid || user.openId || null
wx.setStorageSync('userInfo', user)
}
// 登录成功后处理分销绑定(若进入时携带了推荐人参数)
if (token) {
this._tryDistributionBind()
try {
require('./utils/analytics.js').track('login_silent_success', {
userId: user && user.id,
hasEnterprise: !!(user && user.enterpriseId),
invited: !!(this.globalData._pendingInviterId)
})
} catch (e) {}
}
resolve(!!token)
return
}
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'non_200' }) } catch (e) {}
resolve(false)
},
fail: (err) => {
console.error('登录请求失败:', err)
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'network' }) } catch (e) {}
const storedToken = wx.getStorageSync('token')
const storedUser = wx.getStorageSync('userInfo')
if (storedToken) {
@@ -282,18 +344,43 @@ App({
}
})
},
fail: () => resolve(false)
fail: () => {
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'wx_login' }) } catch (e) {}
resolve(false)
}
})
})
},
/**
* 确保已登录:有 token 直接 resolve否则先执行静默登录
* 加 15s 超时兜底:若 wx.login / wx.request 卡住,强制 resolve(false),避免调用方 promise 悬挂
* @returns {Promise<boolean>} 当前是否有有效登录态
*/
ensureLogin() {
if (this.globalData.token) return Promise.resolve(true)
return this.silentLogin()
return new Promise((resolve) => {
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'timeout' }) } catch (e) {}
resolve(false)
}, 15000)
this.silentLogin()
.then((ok) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(!!ok)
})
.catch(() => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(false)
})
})
},
/**

View File

@@ -5,6 +5,7 @@
"pages/index/upload",
"pages/index/result",
"pages/test-select/index",
"pages/ai-test/index",
"pages/test/mbti",
"pages/test/sbti",
"pages/test/disc",
@@ -25,7 +26,12 @@
"pages/phone-auth/index",
"pages/promo/index",
"pages/promo/poster",
"pages/promo/withdrawals"
"pages/promo/withdrawals",
"pages/match-job/index",
"pages/ai-chat/index",
"pages/ai-chat/report",
"pages/ai-chat/history",
"pages/webview/index"
],
"tabBar": {
"custom": true,
@@ -46,6 +52,12 @@
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},
{
"pagePath": "pages/ai-chat/index",
"text": "神仙AI",
"iconPath": "images/home.png",
"selectedIconPath": "images/home-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",

View File

@@ -1,176 +1,193 @@
/* app.wxss - 全局样式 */
/* 主题颜色 */
page {
--primary-color: #FF6B8A;
--primary-light: #FFE4E9;
--secondary-color: #8B5CF6;
--secondary-light: #EDE9FE;
--success-color: #10B981;
--warning-color: #F59E0B;
--danger-color: #EF4444;
--text-primary: #1F2937;
--text-secondary: #6B7280;
--text-muted: #9CA3AF;
--bg-primary: #FFFFFF;
--bg-secondary: #F3F4F6;
--border-color: #E5E7EB;
background-color: var(--bg-secondary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28rpx;
color: var(--text-primary);
line-height: 1.6;
}
/* 玻璃卡片效果 */
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
padding: 32rpx;
margin-bottom: 24rpx;
}
/* 渐变背景 */
.gradient-personal {
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
}
.gradient-enterprise {
background: linear-gradient(135deg, #8B5CF6 0%, #3B82F6 100%);
}
/* 按钮样式 */
.btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
border: none;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
color: #FFFFFF;
}
.btn-secondary {
background: #FFFFFF;
color: var(--primary-color);
border: 2rpx solid var(--primary-color);
}
.btn-outline {
background: transparent;
color: var(--text-secondary);
border: 2rpx solid var(--border-color);
}
.btn:active {
transform: scale(0.98);
opacity: 0.9;
}
/* 进度条 */
.progress-bar {
width: 100%;
height: 12rpx;
background: var(--bg-secondary);
border-radius: 6rpx;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.3s ease;
}
/* 标签 */
.tag {
display: inline-flex;
align-items: center;
padding: 8rpx 20rpx;
border-radius: 24rpx;
font-size: 24rpx;
font-weight: 500;
}
.tag-primary {
background: var(--primary-light);
color: var(--primary-color);
}
.tag-secondary {
background: var(--secondary-light);
color: var(--secondary-color);
}
/* 文字样式 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
.text-center {
text-align: center;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
/* 间距 */
.mt-2 { margin-top: 16rpx; }
.mt-4 { margin-top: 32rpx; }
.mb-2 { margin-bottom: 16rpx; }
.mb-4 { margin-bottom: 32rpx; }
.p-4 { padding: 32rpx; }
/* 弹性布局 */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.flex-1 {
flex: 1;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx);
}
/* app.wxss - 全局样式 */
/* 主题颜色 */
page {
--primary-color: #FF6B8A;
--primary-light: #FFE4E9;
--secondary-color: #8B5CF6;
--secondary-light: #EDE9FE;
--success-color: #10B981;
--warning-color: #F59E0B;
--danger-color: #EF4444;
--text-primary: #1F2937;
--text-secondary: #6B7280;
--text-muted: #9CA3AF;
--bg-primary: #FFFFFF;
--bg-secondary: #F3F4F6;
--border-color: #E5E7EB;
background-color: var(--bg-secondary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28rpx;
color: var(--text-primary);
line-height: 1.6;
}
/* 玻璃卡片效果 */
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
padding: 32rpx;
margin-bottom: 24rpx;
}
/* 渐变背景 */
.gradient-personal {
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
}
.gradient-enterprise {
background: linear-gradient(135deg, #8B5CF6 0%, #3B82F6 100%);
}
/* 按钮样式 */
.btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 600;
border: none;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%);
color: #FFFFFF;
}
.btn-secondary {
background: #FFFFFF;
color: var(--primary-color);
border: 2rpx solid var(--primary-color);
}
.btn-outline {
background: transparent;
color: var(--text-secondary);
border: 2rpx solid var(--border-color);
}
.btn:active {
transform: scale(0.98);
opacity: 0.9;
}
/* 进度条 */
.progress-bar {
width: 100%;
height: 12rpx;
background: var(--bg-secondary);
border-radius: 6rpx;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.3s ease;
}
/* 标签 */
.tag {
display: inline-flex;
align-items: center;
padding: 8rpx 20rpx;
border-radius: 24rpx;
font-size: 24rpx;
font-weight: 500;
}
.tag-primary {
background: var(--primary-light);
color: var(--primary-color);
}
.tag-secondary {
background: var(--secondary-light);
color: var(--secondary-color);
}
/* 文字样式 */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
.text-center {
text-align: center;
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
/* 间距 */
.mt-2 { margin-top: 16rpx; }
.mt-4 { margin-top: 32rpx; }
.mb-2 { margin-bottom: 16rpx; }
.mb-4 { margin-bottom: 32rpx; }
.p-4 { padding: 32rpx; }
/* 弹性布局 */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.flex-1 {
flex: 1;
}
/* 安全区域 */
.safe-area-bottom {
padding-bottom: calc(constant(safe-area-inset-bottom) + 16rpx);
padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx);
}
/* ============ 全局 TabBar 底部避让 ============ */
/* 自定义 TabBar 高度约 140rpx + 底部安全区。
在 tab 页外层 view 加 .tabbar-pad 即可避免被遮挡;
也可直接写 style="padding-bottom: var(--tabbar-pad)" */
page {
--tabbar-height: 140rpx;
--tabbar-pad: calc(var(--tabbar-height) + env(safe-area-inset-bottom) + 20rpx);
}
.tabbar-pad {
padding-bottom: var(--tabbar-pad) !important;
}
.tabbar-pad-fixed {
bottom: var(--tabbar-pad) !important;
}

View File

@@ -0,0 +1,23 @@
// components/result-section-nav/index.js
// 结果页顶部分类锚点导航:横向 chip 条,点击触发 navtap 事件,由页面 scroll-into-view 接管跳转
Component({
options: {
addGlobalClass: true,
multipleSlots: false
},
properties: {
// [{ id: 'sec-insight', label: '洞察', emoji: '🧠' }, ...]
sections: { type: Array, value: [] },
// 当前高亮的 section id可选暂未接滚动联动
active: { type: String, value: '' },
// 色系锚点purple / blue / orange / teal对应 MBTI/DISC/PDP/SBTI
theme: { type: String, value: 'purple' }
},
methods: {
onTap(e) {
const id = e.currentTarget.dataset.id
if (!id) return
this.triggerEvent('navtap', { id })
}
}
})

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,17 @@
<!--components/result-section-nav/index.wxml-->
<view class="rsn rsn--{{theme}}" wx:if="{{sections && sections.length}}">
<scroll-view class="rsn__scroll" scroll-x enhanced show-scrollbar="{{false}}">
<view class="rsn__row">
<view
class="rsn__chip {{active === item.id ? 'rsn__chip--active' : ''}}"
wx:for="{{sections}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onTap"
>
<text wx:if="{{item.emoji}}" class="rsn__emoji">{{item.emoji}}</text>
<text class="rsn__label">{{item.label}}</text>
</view>
</view>
</scroll-view>
</view>

View File

@@ -0,0 +1,67 @@
/* components/result-section-nav/index.wxss */
.rsn {
position: sticky;
top: 0;
z-index: 50;
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.94) 100%);
backdrop-filter: saturate(140%) blur(8rpx);
border-bottom: 1rpx solid #f1f5f9;
padding: 14rpx 8rpx 12rpx;
}
.rsn__scroll {
width: 100%;
white-space: nowrap;
}
.rsn__row {
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 0 12rpx;
}
.rsn__chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6rpx;
height: 56rpx;
padding: 0 22rpx;
border-radius: 28rpx;
background: #f1f5f9;
border: 1rpx solid transparent;
transition: all 0.2s ease;
}
.rsn__emoji {
font-size: 24rpx;
line-height: 1;
}
.rsn__label {
font-size: 24rpx;
font-weight: 500;
color: #475569;
letter-spacing: 0.02em;
line-height: 1;
}
.rsn__chip--active {
background: #fff;
border-color: currentColor;
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.08);
}
/* 色系:与四测 hero 色对齐 */
.rsn--purple .rsn__chip--active { color: #6366f1; }
.rsn--purple .rsn__chip--active .rsn__label { color: #4f46e5; font-weight: 600; }
.rsn--blue .rsn__chip--active { color: #0ea5e9; }
.rsn--blue .rsn__chip--active .rsn__label { color: #0284c7; font-weight: 600; }
.rsn--orange .rsn__chip--active { color: #f97316; }
.rsn--orange .rsn__chip--active .rsn__label { color: #ea580c; font-weight: 600; }
.rsn--teal .rsn__chip--active { color: #14b8a6; }
.rsn--teal .rsn__chip--active .rsn__label { color: #0d9488; font-weight: 600; }

View File

@@ -1,28 +1,65 @@
// 自定义 tabBar(不使用微信内置):灰线 + 三栏,中间为浮起圆钮
// 自定义 TabBar:完全由后台配置驱动
// 数据来源app.globalData.tabBar.itemsGET /api/mp/tabbar
// 兜底:若配置缺失,使用默认 4 项首页·拍摄·神仙AI·我
const DEFAULT_ITEMS = [
{ pagePath: 'pages/index/index', text: '首页', iconKey: 'home', iconUrl: '', highlight: false },
{ pagePath: 'pages/index/camera', text: '拍摄', iconKey: 'camera', iconUrl: '', highlight: true },
{ pagePath: 'pages/ai-chat/index', text: '神仙AI', iconKey: 'ai', iconUrl: '/images/shenxian-oldman-circle.png', highlight: false },
{ pagePath: 'pages/profile/index', text: '我', iconKey: 'profile', iconUrl: '', highlight: false }
]
Component({
data: {
selected: 0,
/** 审核模式 */
reviewMode: false,
/** 为 true 时隐藏中间「拍摄」:审核模式 或 企业权限关闭 face */
hideMiddleFab: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
]
items: [],
},
lifetimes: {
attached() {
this.updateSelected()
this.refreshFromConfig()
}
},
pageLifetimes: {
show() {
this.updateSelected()
this.refreshFromConfig()
}
},
methods: {
refreshFromConfig() {
try {
const app = getApp() || {}
const gd = app.globalData || {}
const cfg = gd.tabBar && Array.isArray(gd.tabBar.items) ? gd.tabBar.items : null
const rawItems = (cfg && cfg.length >= 2) ? cfg.slice() : DEFAULT_ITEMS.slice()
const items = rawItems.map((it) => ({
...it,
// 仅神仙AI使用指定 logo其他图标保持后台配置
iconUrl: it.iconKey === 'ai' ? '/images/shenxian-oldman-circle.png' : (it.iconUrl || '')
}))
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const faceOff = !!(ep && ep.face === false)
const hideMiddleFab = reviewMode || faceOff
// 审核模式 / face 关闭时:隐藏所有 highlight=true 的 Tab
const effectiveItems = items.filter(it => !(hideMiddleFab && it.highlight))
this.setData({
items: effectiveItems,
reviewMode,
hideMiddleFab,
}, () => this.updateSelected())
} catch (e) {
this.setData({ items: DEFAULT_ITEMS, reviewMode: false, hideMiddleFab: false }, () => this.updateSelected())
}
},
updateSelected() {
try {
const pages = getCurrentPages()
@@ -30,35 +67,41 @@ Component({
const currentPage = pages[pages.length - 1]
if (!currentPage || !currentPage.route) return
const url = currentPage.route
const gd = getApp().globalData || {}
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const hideMiddleFab = reviewMode || !!(ep && ep.face === false)
const items = this.data.items || []
let selected = 0
if (url === 'pages/index/index' || url === 'pages/enterprise/index') {
selected = 0
} else if (url === 'pages/index/camera') {
selected = hideMiddleFab ? 0 : 1
} else if (url === 'pages/profile/index') {
selected = 2
for (let i = 0; i < items.length; i++) {
const path = (items[i].pagePath || '').replace(/^\//, '')
if (url === path) { selected = i; break }
// 企业首页归一到"首页"Tab
if (url === 'pages/enterprise/index' && path === 'pages/index/index') { selected = i; break }
if (url === 'pages/index/camera' && items[i].highlight) { selected = i; break }
}
this.setData({ selected, reviewMode, hideMiddleFab })
} catch (error) {
console.error('updateSelected error:', error)
this.setData({ selected: 0, reviewMode: false, hideMiddleFab: false })
this.setData({ selected })
} catch (err) {
this.setData({ selected: 0 })
}
},
switchTab(e) {
const index = parseInt(e.currentTarget.dataset.index, 10)
let url = e.currentTarget.dataset.path
const it = (this.data.items || [])[index]
if (!it) return
const gd = getApp().globalData || {}
const faceOff = !!(gd.enterprisePermissions && gd.enterprisePermissions.face === false)
if (index === 1 && (!!(gd.reviewMode || gd.maintenanceMode) || faceOff)) {
return
// 中间浮钮在审核模式下被 refreshFromConfig 过滤掉了,不会到这里
// 神仙 AI Tab 的轻量埋点
if ((it.iconKey === 'ai' || /ai-chat/.test(it.pagePath))) {
try {
const analytics = require('../utils/analytics.js')
if (analytics && typeof analytics.track === 'function') {
analytics.track('tap_tab_ai_chat', {})
}
} catch (e) {}
}
if (index === 0) {
// 首页点击:企业作用域走企业首页(保留旧行为)
if (it.pagePath === 'pages/index/index' || it.iconKey === 'home') {
try {
const app = getApp()
const scope = (app && app.globalData && app.globalData.appScope) || 'personal'
@@ -70,7 +113,14 @@ Component({
} catch (e) {}
}
wx.switchTab({ url })
const url = '/' + it.pagePath.replace(/^\//, '')
wx.switchTab({
url,
fail: () => {
// 非 tabBar 页面 fallback 到 navigateTo
wx.navigateTo({ url })
}
})
this.setData({ selected: index })
}
}

View File

@@ -1,40 +1,42 @@
<!-- 自定义 tabBar顶部分割线 + 三栏;中间单独一列避免浮钮与左右叠层 -->
<!-- 自定义 TabBar由 globalData.tabBar 动态渲染;支持 iconUrl 图片覆盖 SVGhighlight=中间浮钮 -->
<view class="tab-bar">
<view class="tab-bar-line"></view>
<view class="tab-bar-inner {{hideMiddleFab ? 'tab-bar-inner--audit' : ''}}">
<view
class="tab-item {{selected === 0 ? 'active' : ''}}"
data-index="0"
data-path="/pages/index/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 0 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">首页</text>
</view>
<view class="tab-bar-inner tab-bar-count-{{items.length}}">
<block wx:for="{{items}}" wx:key="pagePath" wx:for-item="it">
<!-- 中间浮钮highlight=true -->
<view wx:if="{{it.highlight}}" class="tab-slot-middle">
<view
class="middle-fab {{selected === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="center-circle">
<image wx:if="{{it.iconUrl}}" class="center-img" src="{{it.iconUrl}}" mode="aspectFit" />
<view wx:else class="center-svg center-svg--{{it.iconKey || 'camera'}}"></view>
</view>
<text class="tab-text tab-text-fab">{{it.text}}</text>
</view>
</view>
<!-- 中间列:非审核模式显示「拍摄」 -->
<view class="tab-slot-middle" wx:if="{{!hideMiddleFab}}">
<!-- 普通 Tab item -->
<view
class="middle-fab {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
wx:else
class="tab-item {{selected === index ? 'active' : ''}} tab-item--{{it.iconKey || 'home'}}"
data-index="{{index}}"
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>
<image
wx:if="{{it.iconUrl}}"
class="tab-icon-img {{selected === index ? 'tab-icon-img--active' : ''}}"
src="{{it.iconUrl}}"
mode="aspectFit"
/>
<view
wx:else
class="tab-icon tab-icon--{{it.iconKey || 'home'}} {{selected === index ? 'tab-icon--active' : ''}}"
></view>
<text class="tab-text">{{it.text}}</text>
</view>
</view>
<view
class="tab-item {{selected === 2 ? 'active' : ''}}"
data-index="2"
data-path="/pages/profile/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 2 ? '/images/user-active.png' : '/images/user.png'}}" mode="aspectFit" />
<text class="tab-text">我的</text>
</view>
</block>
</view>
</view>

View File

@@ -1,4 +1,4 @@
/* 自定义 tabBar白底、顶部分割线、中间浮起圆钮 */
/* 自定义 TabBar白底、顶部分割线、支持 highlight 圆钮 · 动态数量 */
.tab-bar {
position: fixed;
left: 0;
@@ -18,19 +18,16 @@
.tab-bar-inner {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: space-around;
min-height: 120rpx;
padding: 12rpx 12rpx 10rpx;
position: relative;
box-sizing: border-box;
}
/* 审核模式仅「首页」「我的」两栏时,左右均匀留白 */
.tab-bar-inner--audit {
justify-content: space-between;
padding-left: 56rpx;
padding-right: 56rpx;
}
.tab-bar-count-3 { padding-left: 40rpx; padding-right: 40rpx; }
.tab-bar-count-4,
.tab-bar-count-5 { padding-left: 12rpx; padding-right: 12rpx; }
.tab-item {
flex: 1;
@@ -47,6 +44,32 @@
width: 48rpx;
height: 48rpx;
margin-bottom: 4rpx;
background-repeat: no-repeat;
background-position: center;
background-size: 44rpx 44rpx;
}
/* 图片形态的图标iconUrl 传图片时使用,保留圆角便于 logo 类图片显示) */
.tab-icon-img {
width: 52rpx;
height: 52rpx;
margin-bottom: 4rpx;
border-radius: 12rpx;
filter: grayscale(60%);
opacity: 0.85;
}
.tab-icon-img--active {
filter: none;
opacity: 1;
transform: scale(1.05);
box-shadow: 0 4rpx 10rpx rgba(124, 58, 237, 0.25);
}
/* 中间浮钮的图片形态 */
.center-img {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
}
.tab-text {
@@ -58,7 +81,43 @@
color: #7c3aed;
}
/* 中间列:独立层叠上下文,浮钮不盖住左右 tab */
/* ===== 图标home ===== */
.tab-icon--home {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M3 12l9-9 9 9'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6'/></svg>");
}
.tab-icon--home.tab-icon--active,
.tab-item.active .tab-icon--home {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M3 12l9-9 9 9'/><path d='M5 10v10h14V10'/><path d='M10 20v-6h4v6' fill='none' stroke='%23ffffff'/></svg>");
}
/* ===== 图标ai (sparkle) ===== */
.tab-icon--ai {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1'/><circle cx='12' cy='12' r='3'/></svg>");
}
.tab-icon--ai.tab-icon--active,
.tab-item.active .tab-icon--ai {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1' stroke='%237c3aed' fill='none' stroke-linecap='round'/><circle cx='12' cy='12' r='3.5' fill='%237c3aed'/></svg>");
}
/* ===== 图标profile ===== */
.tab-icon--profile {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8'/></svg>");
}
.tab-icon--profile.tab-icon--active,
.tab-item.active .tab-icon--profile {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8' fill='%237c3aed'/></svg>");
}
/* ===== 图标camera作为普通 Tab 时用) ===== */
.tab-icon--camera {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>");
}
.tab-icon--camera.tab-icon--active,
.tab-item.active .tab-icon--camera {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3aed' stroke='%237c3aed' stroke-width='1.6' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4' fill='%23ffffff' stroke='%237c3aed'/></svg>");
}
/* ===== 中间浮钮 ===== */
.tab-slot-middle {
flex: 1;
min-width: 0;
@@ -71,7 +130,6 @@
padding-bottom: 0;
}
/* 浮钮:整体上移,更突出圆形快门、减少与底栏顶边重叠感 */
.middle-fab {
display: flex;
flex-direction: column;
@@ -104,9 +162,19 @@
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6);
}
.center-icon {
width: 40rpx;
height: 40rpx;
.center-svg {
width: 44rpx;
height: 44rpx;
background-repeat: no-repeat;
background-position: center;
background-size: 42rpx 42rpx;
}
.center-svg--camera {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>");
}
.center-svg--ai {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1'/><circle cx='12' cy='12' r='3.5' fill='%23ffffff'/></svg>");
}
.tab-text-fab {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,59 @@
const { request } = require('../../utils/request.js')
Page({
data: {
list: [],
page: 1,
pageSize: 20,
hasMore: true
},
onShow() {
this.setData({ list: [], page: 1, hasMore: true }, () => this.fetchPage())
},
fetchPage() {
const { page, pageSize, list, hasMore } = this.data
if (!hasMore) return
request({
url: '/api/ai/conversations',
method: 'GET',
data: { page, pageSize },
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const d = body.data || {}
const items = (d.list || []).map(x => ({
id: x.id,
title: x.title,
mbtiType: x.mbtiType,
messageCount: x.messageCount,
lastMessageAt: x.lastMessageAt,
lastMessageAtStr: formatTime(x.lastMessageAt)
}))
this.setData({
list: list.concat(items),
hasMore: !!d.hasMore,
page: page + 1
})
}
})
},
onReachBottom() {
this.fetchPage()
},
onOpen(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/ai-chat/index?cid=${id}` })
}
})
function formatTime(t) {
if (!t) return ''
const d = new Date(t * 1000)
const now = Date.now()
const diff = now - t * 1000
if (diff < 86400000) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
return `${d.getMonth() + 1}-${d.getDate()}`
}
function pad(n) { return n < 10 ? '0' + n : '' + n }

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "神仙 AI · 历史对话",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F3FF",
"usingComponents": {}
}

View File

@@ -0,0 +1,14 @@
<view class="history-page">
<block wx:if="{{list.length > 0}}">
<view class="item" wx:for="{{list}}" wx:key="id" bindtap="onOpen" data-id="{{item.id}}">
<view class="item-title">{{item.title || '未命名对话'}}</view>
<view class="item-meta">
<text class="mbti-tag" wx:if="{{item.mbtiType}}">{{item.mbtiType}}</text>
<text class="meta">{{item.messageCount || 0}} 条消息</text>
<text class="meta">{{item.lastMessageAtStr}}</text>
</view>
</view>
<view class="empty" wx:if="{{!hasMore && list.length > 0}}">— 到底啦 —</view>
</block>
<view class="empty" wx:else>还没有历史对话,快去和神仙 AI 聊聊吧~</view>
</view>

View File

@@ -0,0 +1,39 @@
.history-page {
min-height: 100vh;
background: #F5F3FF;
padding: 24rpx;
}
.item {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.06);
}
.item-title {
font-size: 30rpx;
font-weight: 600;
color: #1F1B4D;
margin-bottom: 10rpx;
}
.item-meta {
display: flex;
gap: 16rpx;
align-items: center;
font-size: 22rpx;
color: #6B6894;
}
.mbti-tag {
background: #EDE9FE;
color: #7c3aed;
border-radius: 100rpx;
padding: 2rpx 16rpx;
font-size: 22rpx;
font-weight: 600;
}
.empty {
text-align: center;
color: #9CA3AF;
padding: 96rpx 0 40rpx;
font-size: 26rpx;
}

View File

@@ -0,0 +1,396 @@
// 神仙 AI · 对话页
const { request } = require('../../utils/request.js')
const analytics = require('../../utils/analytics.js')
// 兜底封面base64 SVG柔和渐变 + 圆角感)
const DEFAULT_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">' +
'<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">' +
'<stop offset="0%" stop-color="#c4b5fd"/><stop offset="100%" stop-color="#7c3aed"/>' +
'</linearGradient></defs>' +
'<rect width="200" height="120" rx="16" fill="url(%23g)"/>' +
'<text x="100" y="56" font-size="28" fill="white" text-anchor="middle" font-family="PingFang SC,sans-serif" font-weight="700">读</text>' +
'<text x="100" y="82" font-size="11" fill="rgba(255,255,255,0.9)" text-anchor="middle" font-family="PingFang SC,sans-serif">好文</text>' +
'</svg>'
)
Page({
data: {
articles: [],
defaultCover: DEFAULT_COVER,
mbtiType: '',
nickname: '',
quickQuestions: [
'帮我了解并分析一下我现在的状态',
'我适合什么样的伴侣关系?',
'我下一步的职业发展方向是什么?',
'我最适合的工作类型是什么?'
],
messages: [],
conversationId: 0,
draft: '',
sending: false,
scrollTarget: '',
usageToday: 0,
dailyLimit: 20,
showReportCta: false,
myReportStatus: '',
/** 推荐文章区块(与超管 ai_chat_articles 同步) */
articlesDisplayEnabled: false,
articlesBlockExpanded: false,
articlesLoaded: false,
},
onLoad(options) {
const cid = parseInt(options && options.cid, 10)
if (cid > 0) {
this.setData({ conversationId: cid })
this.loadHistory(cid)
}
this.loadArticles()
this.loadQuickQuestions()
this.tryBindReferral(options)
this.loadMyReportStatus()
analytics.track('page_view', { pagePath: 'pages/ai-chat/index' })
},
/** 分享链接里带 inviterId 时,绑定分销关系(复用现有分销系统) */
tryBindReferral(options) {
const inviterId = parseInt(options && options.inviterId, 10)
if (!inviterId || inviterId <= 0) return
request({
url: '/api/distribution/bind',
method: 'POST',
data: { inviterId },
success() {}, fail() {}
})
},
/** 查询我最近的 AI 报告状态(决定是否展示 CTA */
loadMyReportStatus() {
request({
url: '/api/ai/report/my-latest',
method: 'GET',
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const d = body.data || {}
this.setData({ myReportStatus: d.status || '' }, () => this._refreshReportCta())
},
fail() {}
})
},
onShow() {
const tabBar = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tabBar && typeof tabBar.refreshFromConfig === 'function') {
tabBar.refreshFromConfig()
}
this.loadArticles()
},
onInput(e) {
this.setData({ draft: e.detail.value })
},
// ---------- 数据加载 ----------
/** 与后台推荐位一致:去重、条数由 maxShow 限制 */
normalizeRecoArticles(raw, maxShow) {
const cap = Math.max(1, Math.min(3, parseInt(maxShow, 10) || 1))
const list = Array.isArray(raw) ? raw : []
const seen = new Set()
const out = []
for (let i = 0; i < list.length; i++) {
const it = list[i]
if (!it || it.id == null) continue
const id = Number(it.id)
if (!id || seen.has(id)) continue
seen.add(id)
out.push(it)
if (out.length >= cap) break
}
return out
},
loadArticles() {
const t = Date.now()
request({
url: `/api/ai/articles/recommended?_t=${t}`,
method: 'GET',
needAuth: false,
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const d = body.data || {}
const disp = d.display || {}
const enabled = !!disp.enabled
const maxShow = Math.max(1, Math.min(3, parseInt(disp.maxShow, 10) || 1))
const expandedDef = !!disp.sectionExpandedDefault
const list = enabled ? this.normalizeRecoArticles(d.list || [], maxShow) : []
this.setData({
articles: list,
articlesDisplayEnabled: enabled,
articlesBlockExpanded: expandedDef,
articlesLoaded: true
})
}
})
},
onToggleArticlesFold() {
this.setData({ articlesBlockExpanded: !this.data.articlesBlockExpanded })
},
/** 去测 MBTI进入「详细性格测试」列表含 MBTI 问卷等),不直跳拍摄页 */
onGoMbtiTest() {
try {
analytics.track('ai_chat_go_test_select', {})
} catch (e) {}
wx.navigateTo({
url: '/pages/test-select/index',
fail: () => {
wx.showToast({ title: '暂时无法打开测试列表', icon: 'none' })
}
})
},
loadQuickQuestions() {
request({
url: '/api/ai/quick-questions',
method: 'GET',
// 接口公开:无 token 也可访问;有 token 时自动带上以匹配 MBTI 快捷问句
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) {
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
return
}
const d = body.data || {}
this.setData({
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions || [])
})
},
fail: () => {
// 小程序未登录/接口偶发失败时,仍展示本地快捷提问,避免底部空白
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
}
})
},
buildQuickQuestions(serverList) {
const preferred = [
'帮我了解并分析一下我现在的状态',
'我适合什么样的伴侣关系?',
'我下一步的职业发展方向是什么?',
'我最适合的工作类型是什么?',
'我现在最该改掉的一个习惯是什么?'
]
const ban = /记\s*一下\s*我的\s*MBTI/i
const all = []
;(Array.isArray(serverList) ? serverList : []).forEach((q) => {
if (typeof q === 'string' && q.trim()) {
const t = q.trim()
if (!ban.test(t)) all.push(t)
}
})
preferred.forEach((q) => all.push(q))
const uniq = []
const seen = {}
all.forEach((q) => {
if (!seen[q]) {
seen[q] = true
uniq.push(q)
}
})
return uniq.slice(0, 8)
},
loadHistory(cid) {
request({
url: `/api/ai/conversations/${cid}/messages`,
method: 'GET',
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) return
const d = body.data || {}
const msgs = (d.messages || []).filter(m => m.role !== 'system')
this.setData({ messages: msgs }, () => {
this.scrollToBottom()
this._refreshReportCta()
})
}
})
},
// ---------- 交互 ----------
onTapQuick(e) {
const q = (e.currentTarget.dataset.q || '').trim()
if (!q || this.data.sending) return
analytics.track('ai_quick_question_click', { question: q })
this.setData({ draft: q }, () => this.onSend())
},
onTapArticle(e) {
const { id, url, title } = e.currentTarget.dataset
analytics.track('tap_ai_article', { articleId: id, url, title })
// 轻量回传点击(异步)
if (id) {
request({
url: `/api/ai/articles/${id}/click`,
method: 'POST',
needAuth: false,
success() {}, fail() {}
})
}
if (!url) return
// 小程序内嵌 webview 打开外链
const enc = encodeURIComponent(url)
wx.navigateTo({
url: `/pages/webview/index?url=${enc}`,
fail: () => {
// 如无 webview 中转页,回退到复制链接
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '已复制链接', icon: 'none' })
})
}
})
},
onSend() {
const content = (this.data.draft || '').trim()
if (!content || this.data.sending) return
if (this.data.usageToday >= this.data.dailyLimit && this.data.dailyLimit > 0) {
wx.showToast({ title: '今日对话次数已用完', icon: 'none' })
return
}
const now = Date.now()
const userMsg = {
id: 'u_' + now,
role: 'user',
content,
createdAt: Math.floor(now / 1000)
}
const messages = this.data.messages.concat([userMsg])
this.setData({ messages, draft: '', sending: true }, () => this.scrollToBottom())
analytics.track('ai_chat_send', { length: content.length })
request({
url: '/api/ai/chat',
method: 'POST',
data: {
conversationId: this.data.conversationId || 0,
message: content
},
success: (res) => {
const body = (res && res.data) || {}
if (res.statusCode === 429) {
wx.showToast({ title: body.message || '今日次数用完', icon: 'none' })
this.setData({ sending: false })
return
}
if (body.code !== 200) {
const hint = body.message || (res.statusCode ? `服务异常(${res.statusCode})` : 'AI 失联了')
wx.showToast({ title: hint, icon: 'none', duration: 2800 })
this.setData({ sending: false })
return
}
const d = body.data || {}
const aiMsg = d.message || {}
const next = this.data.messages.concat([{
id: aiMsg.id || ('a_' + Date.now()),
role: 'assistant',
content: aiMsg.content || '',
isDegraded: !!aiMsg.isDegraded,
providerId: aiMsg.providerId || '',
createdAt: aiMsg.createdAt || Math.floor(Date.now() / 1000)
}])
this.setData({
messages: next,
conversationId: d.conversationId || this.data.conversationId,
usageToday: d.usageToday || this.data.usageToday,
dailyLimit: d.dailyLimit || this.data.dailyLimit,
sending: false
}, () => {
this.scrollToBottom()
this._refreshReportCta()
})
analytics.track('ai_chat_receive', {
providerId: aiMsg.providerId || '',
length: (aiMsg.content || '').length
})
if (aiMsg.isDegraded) {
analytics.track('ai_chat_degrade', { providerId: aiMsg.providerId || '' })
}
},
fail: () => {
wx.showToast({ title: '网络异常,稍后再试', icon: 'none' })
this.setData({ sending: false })
}
})
},
scrollToBottom() {
const msgs = this.data.messages
const last = msgs.length ? msgs[msgs.length - 1] : null
this.setData({ scrollTarget: last ? ('msg-' + last.id) : 'bottom' })
},
/** 点击"邀请赚佣金"快捷方式:触发原生分享 */
onTapShare() {
analytics.track('ai_chat_share_invite_tap', {})
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
wx.showToast({ title: '点右上角 ··· 分享给好友', icon: 'none' })
},
/** 点击"生成深度报告 CTA" */
onTapReportCta() {
analytics.track('ai_report_cta_tap', { messageCount: this.data.messages.length })
wx.navigateTo({
url: `/pages/ai-chat/report?cid=${this.data.conversationId || 0}`,
fail: () => {
wx.showToast({ title: '报告功能即将开放', icon: 'none' })
}
})
},
/** 根据消息数刷新 CTA 显隐 */
_refreshReportCta() {
const aiCount = (this.data.messages || []).filter(m => m.role === 'assistant').length
const show = aiCount >= 5 && this.data.myReportStatus !== 'done' && this.data.myReportStatus !== 'paid'
if (show !== this.data.showReportCta) {
this.setData({ showReportCta: show })
}
},
onShareAppMessage() {
const app = getApp() || {}
const gd = app.globalData || {}
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
const mbti = this.data.mbtiType || ''
const title = mbti
? `神仙 AI 帮我看懂了 ${mbti},你也来试试?`
: '和神仙 AI 聊聊你的 MBTI超准'
const path = `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_chat`
analytics.track('ai_chat_share', { mbti })
return { title, path }
},
onShareTimeline() {
return {
title: '神仙 AI · 帮你看懂自己的 MBTI',
query: 'src=ai_chat_timeline'
}
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "神仙 AI",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F3FF",
"usingComponents": {}
}

View File

@@ -0,0 +1,113 @@
<!-- 神仙 AI · 对话页 -->
<view class="ai-page">
<!-- 推荐文章:超管开启后显示;默认折叠(由后台 sectionExpandedDefault 控制首次是否展开) -->
<view class="soul-articles-wrap" wx:if="{{articlesDisplayEnabled}}">
<view class="articles-fold-bar" bindtap="onToggleArticlesFold">
<view class="articles-fold-left">
<text class="articles-fold-title">精选推荐</text>
<text class="articles-fold-pill">Soul</text>
<text class="articles-fold-count" wx:if="{{!articlesBlockExpanded}}">· {{articles.length}} 篇</text>
</view>
<text class="articles-fold-chevron">{{articlesBlockExpanded ? '▼' : '▶'}}</text>
</view>
<view class="soul-articles" wx:if="{{articlesBlockExpanded && articles.length > 0}}">
<view class="articles-list">
<view class="article-card" wx:for="{{articles}}" wx:key="id" bindtap="onTapArticle" data-id="{{item.id}}" data-url="{{item.url}}" data-title="{{item.title}}">
<view class="article-cover-wrap">
<image class="article-cover" src="{{item.cover || defaultCover}}" mode="aspectFill" />
</view>
<view class="article-body">
<text class="article-title">{{item.title}}</text>
<view class="article-meta">
<text class="article-pill">{{item.tag || 'MBTI'}}</text>
<text class="article-time" wx:if="{{item.publishedAt}}">{{item.publishedAt}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- MBTI 提醒:去测 → 详细性格测试列表 -->
<view class="mbti-remind-card">
<view class="mbti-remind-row">
<image class="mbti-remind-avatar" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<view class="mbti-remind-texts">
<view class="mbti-remind-title">我是神仙 AI</view>
<view class="mbti-remind-sub">{{mbtiType ? ('已了解你的 ' + mbtiType + ',继续聊~') : '先做一下 MBTI 测评,我能帮你聊得更懂你哦~'}}</view>
</view>
</view>
<view class="mbti-remind-actions">
<text class="mbti-remind-tag">MBTI</text>
<view class="mbti-remind-btn" hover-class="mbti-remind-btn--press" bindtap="onGoMbtiTest">去测 MBTI</view>
</view>
</view>
<!-- 聊天区 -->
<scroll-view class="chat-scroll" scroll-y="true" scroll-into-view="{{scrollTarget}}" scroll-with-animation="true" enhanced show-scrollbar="{{false}}">
<!-- 消息气泡 -->
<view class="msg-wrap" wx:for="{{messages}}" wx:key="id" id="msg-{{item.id}}">
<view wx:if="{{item.role === 'assistant'}}" class="msg msg-ai">
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<view class="bubble bubble-ai {{item.isDegraded ? 'bubble-degraded' : ''}}">
<text selectable="true">{{item.content}}</text>
<view class="bubble-meta" wx:if="{{item.isDegraded}}">(降级兜底)</view>
</view>
</view>
<view wx:else class="msg msg-me">
<view class="bubble bubble-me">
<text selectable="true">{{item.content}}</text>
</view>
</view>
</view>
<view class="typing" wx:if="{{sending}}" id="msg-typing">
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<view class="bubble bubble-ai"><text>神仙在思考…</text></view>
</view>
<!-- 深度报告 CTA对话 >=10 条且还没付费时显示 -->
<view class="report-cta" wx:if="{{showReportCta}}" bindtap="onTapReportCta">
<view class="report-cta-text">✨ 聊了这么多,来份专属 AI 深度画像报告¥9.9</view>
<view class="report-cta-btn">立即生成</view>
</view>
<view class="scroll-bottom" id="bottom"></view>
</scroll-view>
<!-- 底部快捷问法(常驻) -->
<scroll-view class="quick-bottom-scroll" scroll-x="true" show-scrollbar="{{false}}" wx:if="{{quickQuestions.length > 0}}">
<view class="quick-bottom-list">
<view class="quick-bottom-item" wx:for="{{quickQuestions}}" wx:for-item="q" wx:key="*this" bindtap="onTapQuick" data-q="{{q}}">
{{q}}
</view>
</view>
</scroll-view>
<!-- 输入区:输入框 + 发送 -->
<view class="input-bar">
<input
class="input"
placeholder="问我你想了解的自己"
placeholder-class="input-ph"
value="{{draft}}"
bindinput="onInput"
bindconfirm="onSend"
confirm-type="send"
confirm-hold="true"
adjust-position="{{true}}"
maxlength="800"
disabled="{{sending}}"
/>
<view
class="send-btn {{draft && !sending ? 'send-btn--active' : ''}} {{sending || !draft ? 'send-btn--disabled' : ''}}"
hover-class="{{draft && !sending ? 'send-btn--press' : ''}}"
hover-start-time="0"
hover-stay-time="80"
bindtap="onSend"
>
<text class="send-btn__label">发送</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,507 @@
/* 神仙 AI · 对话页样式 */
page {
height: 100%;
background: linear-gradient(180deg, #F5F3FF 0%, #FFFFFF 40%);
}
.ai-page {
min-height: 100vh;
display: flex;
flex-direction: column;
/* 给底部 tabBar + 输入栏预留空间;使用全局变量避免不同机型遮挡 */
padding-bottom: calc(var(--tabbar-pad, 160rpx) + 150rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 推荐区块:折叠条 */
.soul-articles-wrap {
margin: 0 24rpx 12rpx;
}
.articles-fold-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 20rpx;
background: #ffffff;
border-radius: 16rpx;
border: 1rpx solid #e9d5ff;
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.06);
}
.articles-fold-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
min-width: 0;
}
.articles-fold-title {
font-size: 28rpx;
font-weight: 700;
color: #4c1d95;
}
.articles-fold-pill {
font-size: 20rpx;
color: #6d28d9;
background: #ede9fe;
padding: 4rpx 14rpx;
border-radius: 100rpx;
font-weight: 600;
}
.articles-fold-count {
font-size: 22rpx;
color: #9ca3af;
}
.articles-fold-chevron {
font-size: 22rpx;
color: #7c3aed;
flex-shrink: 0;
margin-left: 12rpx;
}
/* MBTI 测评提醒卡 */
.mbti-remind-card {
margin: 0 24rpx 14rpx;
padding: 20rpx 22rpx;
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
border-radius: 20rpx;
border: 1rpx solid #e9d5ff;
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.08);
}
.mbti-remind-row {
display: flex;
align-items: flex-start;
gap: 14rpx;
margin-bottom: 16rpx;
}
.mbti-remind-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid #e9d5ff;
flex-shrink: 0;
}
.mbti-remind-texts {
flex: 1;
min-width: 0;
}
.mbti-remind-title {
font-size: 30rpx;
font-weight: 700;
color: #1f1b4d;
margin-bottom: 6rpx;
}
.mbti-remind-sub {
font-size: 24rpx;
color: #6b5f8a;
line-height: 1.45;
}
.mbti-remind-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.mbti-remind-tag {
font-size: 22rpx;
font-weight: 600;
color: #5b21b6;
background: rgba(255, 255, 255, 0.85);
padding: 8rpx 20rpx;
border-radius: 100rpx;
border: 1rpx solid #ddd6fe;
}
.mbti-remind-btn {
flex-shrink: 0;
padding: 14rpx 36rpx;
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
border-radius: 100rpx;
background: linear-gradient(135deg, #9333ea 0%, #7c3aed 100%);
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
}
.mbti-remind-btn--press {
opacity: 0.88;
transform: scale(0.98);
}
/* 顶部推荐文章列表 */
.soul-articles {
padding: 12rpx 0 0;
}
.articles-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.article-card {
display: flex;
align-items: stretch;
width: 100%;
background: #ffffff;
border-radius: 18rpx;
box-shadow: 0 6rpx 20rpx rgba(124, 58, 237, 0.1);
border: 1rpx solid rgba(124, 58, 237, 0.08);
overflow: hidden;
}
.article-cover-wrap {
width: 200rpx;
min-width: 200rpx;
padding: 10rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #faf5ff 0%, #f3e8ff 100%);
}
.article-cover {
width: 100%;
height: 120rpx;
border-radius: 14rpx;
background: #ede9fe;
box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.12);
}
.article-body {
flex: 1;
min-width: 0;
padding: 10rpx 12rpx;
}
.article-title {
font-size: 22rpx;
color: #1F1B4D;
line-height: 1.4;
font-weight: 600;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.article-meta {
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.article-pill {
font-size: 20rpx;
color: #5b21b6;
background: linear-gradient(135deg, #ede9fe 0%, #f5f3ff 100%);
padding: 4rpx 14rpx;
border-radius: 100rpx;
font-weight: 600;
border: 1rpx solid #ddd6fe;
}
.article-time {
font-size: 18rpx;
color: #9CA3AF;
}
/* 聊天区flex:1 撑开,不再用 100vh 硬算 */
.chat-scroll {
flex: 1;
min-height: 0;
padding: 8rpx 24rpx 160rpx;
box-sizing: border-box;
}
/* 欢迎卡:紧凑化,不再占整屏 */
.welcome {
margin: 8rpx 0 20rpx;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%);
border: 1rpx solid #EDE9FE;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.welcome-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.welcome-logo {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 2rpx solid #E9D5FF;
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.2);
}
.welcome-title {
font-size: 30rpx;
font-weight: 700;
color: #1F1B4D;
}
.welcome-sub {
font-size: 24rpx;
color: #6B6894;
margin-bottom: 16rpx;
line-height: 1.5;
}
.mbti-badge {
display: inline-block;
margin: 0 4rpx;
padding: 2rpx 14rpx;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
color: #ffffff;
border-radius: 100rpx;
font-weight: 600;
font-size: 22rpx;
}
.quick-title {
font-size: 24rpx;
color: #4C1D95;
font-weight: 600;
margin-bottom: 10rpx;
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.quick-item {
background: #ffffff;
border-radius: 100rpx;
padding: 14rpx 22rpx;
font-size: 24rpx;
color: #4C1D95;
border: 1rpx solid #DDD6FE;
}
.quick-item:active {
background: #EDE9FE;
transform: scale(0.98);
}
/* 消息气泡 */
.msg-wrap {
margin-bottom: 24rpx;
}
.msg {
display: flex;
align-items: flex-start;
}
.msg-ai {
justify-content: flex-start;
}
.msg-me {
justify-content: flex-end;
}
.avatar-ai {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 2rpx solid #E9D5FF;
background: #fff;
margin-right: 14rpx;
flex-shrink: 0;
overflow: hidden;
}
.bubble {
max-width: 74%;
padding: 18rpx 22rpx;
border-radius: 20rpx;
font-size: 28rpx;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.bubble-ai {
background: #ffffff;
color: #1F1B4D;
border-top-left-radius: 4rpx;
box-shadow: 0 2rpx 10rpx rgba(31, 27, 77, 0.06);
}
.bubble-me {
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
color: #ffffff;
border-top-right-radius: 4rpx;
}
.bubble-degraded {
background: #FFF7ED;
color: #C2410C;
border: 1rpx solid #FED7AA;
}
.bubble-meta {
margin-top: 8rpx;
font-size: 20rpx;
opacity: 0.7;
}
.typing {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.scroll-bottom {
height: 40rpx;
}
/* ==== 深度报告 CTA ==== */
.report-cta {
margin: 16rpx 0 8rpx;
padding: 22rpx 24rpx;
background: linear-gradient(135deg, #FDE68A 0%, #F9A8D4 100%);
color: #78350F;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 6rpx 18rpx rgba(249, 168, 212, 0.3);
}
.report-cta-text {
font-size: 26rpx;
font-weight: 600;
flex: 1;
padding-right: 12rpx;
}
.report-cta-btn {
padding: 10rpx 22rpx;
border-radius: 100rpx;
background: #78350F;
color: #FEF3C7;
font-size: 24rpx;
}
.quick-bottom-scroll {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom) + 84rpx);
padding: 0 16rpx 4rpx;
z-index: 21;
}
.quick-bottom-list {
white-space: nowrap;
}
.quick-bottom-item {
display: inline-block;
margin-right: 10rpx;
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #F5F3FF;
color: #5B21B6;
font-size: 22rpx;
border: 1rpx solid #DDD6FE;
}
/* ==== 输入区:紧贴自定义 tabBar 上方 ==== */
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom));
background: #ffffff;
padding: 12rpx 16rpx 12rpx 20rpx;
display: flex;
align-items: center;
gap: 10rpx;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
border-top: 1rpx solid #F3F4F6;
z-index: 20;
}
.input {
flex: 1;
background: #F3F4F6;
border-radius: 100rpx;
padding: 12rpx 20rpx;
font-size: 27rpx;
min-height: 62rpx;
color: #1F1B4D;
}
.input-ph {
color: #9CA3AF;
font-size: 26rpx;
}
/* 发送:独立按钮块(非原生 button样式可控 */
.send-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 28rpx;
min-width: 108rpx;
height: 64rpx;
border-radius: 16rpx;
margin-left: 16rpx;
margin-right: 4rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #f3f4f6 0%, #e5e7eb 100%);
border: 2rpx solid #d1d5db;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.06);
}
.send-btn__label {
font-size: 28rpx;
font-weight: 600;
color: #6b7280;
}
.send-btn--active {
background: linear-gradient(180deg, #9333ea 0%, #7c3aed 100%);
border-color: #6d28d9;
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
}
.send-btn--active .send-btn__label {
color: #ffffff;
}
.send-btn--disabled {
opacity: 0.5;
}
.send-btn--press {
transform: scale(0.97);
opacity: 0.92;
}

View File

@@ -0,0 +1,194 @@
// 神仙 AI · 深度画像报告页
const { request } = require('../../utils/request.js')
const analytics = require('../../utils/analytics.js')
Page({
data: {
cid: 0, // 来源对话 id
reportId: 0, // 当前报告 id
report: {}, // 报告对象
paying: false,
pollTimer: null
},
onLoad(options) {
const cid = parseInt(options && options.cid, 10) || 0
const rid = parseInt(options && options.rid, 10) || 0
this.setData({ cid, reportId: rid })
analytics.track('page_view', { pagePath: 'pages/ai-chat/report' })
this.initReport()
},
onUnload() {
if (this.data.pollTimer) clearTimeout(this.data.pollTimer)
},
/** 初始化:有 rid 直接拉;否则先 createOrGetPending */
initReport() {
if (this.data.reportId > 0) {
this.loadReport(this.data.reportId)
return
}
request({
url: '/api/ai/report/create',
method: 'POST',
data: { conversationId: this.data.cid },
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) {
wx.showToast({ title: body.message || '创建失败', icon: 'none' })
return
}
const r = body.data || {}
this.setData({ reportId: r.id, report: r }, () => {
if (r.status === 'generating' || r.status === 'paid') this._schedulePoll()
})
}
})
},
loadReport(id) {
request({
url: `/api/ai/report/${id}`,
method: 'GET',
success: (res) => {
const body = (res && res.data) || {}
if (body.code !== 200) {
wx.showToast({ title: body.message || '加载失败', icon: 'none' })
return
}
this.setData({ report: body.data || {} }, () => {
const s = this.data.report.status
if (s === 'generating' || s === 'paid') this._schedulePoll()
})
}
})
},
_schedulePoll() {
if (this.data.pollTimer) clearTimeout(this.data.pollTimer)
const t = setTimeout(() => {
this.loadReport(this.data.reportId)
}, 3500)
this.setData({ pollTimer: t })
},
reload() {
this.loadReport(this.data.reportId)
},
/** 付费解锁:先尝试 dev 模式,生产环境走 payment/create 下单 */
onTapPay() {
if (this.data.paying) return
this.setData({ paying: true })
analytics.track('ai_report_pay_tap', { reportId: this.data.reportId })
const report = this.data.report || {}
const orderSn = report.orderSn || ''
// 1) 尝试调用现有 /api/payment/create 下单
request({
url: '/api/payment/create',
method: 'POST',
data: {
productType: 'ai_deep_report',
productId: this.data.reportId,
amount: report.priceFen || 990,
orderSn
},
success: (res) => {
const body = (res && res.data) || {}
if (body.code === 200 && body.data && body.data.timeStamp) {
const p = body.data
wx.requestPayment({
timeStamp: p.timeStamp,
nonceStr: p.nonceStr,
package: p.package,
signType: p.signType || 'RSA',
paySign: p.paySign,
success: () => {
wx.showToast({ title: '支付成功', icon: 'success' })
analytics.track('ai_report_pay_success', { reportId: this.data.reportId })
setTimeout(() => this.loadReport(this.data.reportId), 1500)
this.setData({ paying: false })
},
fail: () => {
wx.showToast({ title: '已取消支付', icon: 'none' })
this.setData({ paying: false })
}
})
} else {
// 2) 下单失败(比如接口不支持该 productType→ 兜底 dev markPaid仅超管可用
this._fallbackDevMarkPaid()
}
},
fail: () => this._fallbackDevMarkPaid()
})
},
_fallbackDevMarkPaid() {
// 仅在配置了 dev 模式或超管账号时生效
request({
url: `/api/ai/report/${this.data.reportId}/mark-paid-dev`,
method: 'POST',
success: (res) => {
const body = (res && res.data) || {}
if (body.code === 200) {
wx.showToast({ title: '解锁成功', icon: 'success' })
this.setData({ paying: false })
setTimeout(() => this.loadReport(this.data.reportId), 800)
} else {
wx.showToast({ title: body.message || '支付接口未开通', icon: 'none' })
this.setData({ paying: false })
}
},
fail: () => {
wx.showToast({ title: '支付通道异常,稍后再试', icon: 'none' })
this.setData({ paying: false })
}
})
},
onRetry() {
if (!this.data.reportId) return
request({
url: `/api/ai/report/${this.data.reportId}/regenerate`,
method: 'POST',
success: (res) => {
const body = (res && res.data) || {}
if (body.code === 200) {
this.setData({ report: body.data || {} })
const s = body.data && body.data.status
if (s === 'generating' || s === 'paid') this._schedulePoll()
} else {
wx.showToast({ title: body.message || '重试失败', icon: 'none' })
}
}
})
},
onCopy() {
const c = (this.data.report && this.data.report.content) || ''
if (!c) return
wx.setClipboardData({ data: c })
},
onShareAppMessage() {
const app = getApp() || {}
const gd = app.globalData || {}
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
const mbti = (this.data.report && this.data.report.mbtiType) || ''
const title = mbti
? `神仙 AI 帮我看懂了 ${mbti},推荐你也来做一份专属报告`
: '神仙 AI · 深度画像报告'
analytics.track('ai_report_share', { reportId: this.data.reportId, mbti })
return {
title,
path: `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_report_share`,
}
},
onShareTimeline() {
return { title: '神仙 AI 帮我看懂了自己的 MBTI', query: 'src=ai_report_timeline' }
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "AI 深度画像报告",
"navigationBarBackgroundColor": "#F5F3FF",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F3FF",
"usingComponents": {}
}

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