chore: 管理端与 API 批量更新(测评恢复、看板、分销与用户)
1、修复了用户详情/测评结果与人脸结果解析展示问题;管理端订单、分销及超管用户相关接口与展示异常。 2、新增了 testResultParse 统一解析工具、mbti_test_results 恢复 SQL 及生成脚本;补充 API 路由与 Dashboard/用户侧能力。 3、优化了企业/超管数据看板与分销结算逻辑;精简超管 Settings 与布局代码;完善 faceResultDetail;小程序 app.js 小幅同步。 Made-with: Cursor
This commit is contained in:
@@ -153,7 +153,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="$emit('view-test', row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="$emit('view-test', row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -71,18 +71,6 @@
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="layout-main">
|
||||
<div class="mission-strip">
|
||||
<div class="mission-inner">
|
||||
<span class="mission-text">
|
||||
<strong>核心职责</strong>:配置与监督各企业使用的
|
||||
<strong>普通管理后台</strong>
|
||||
(用户运营、订单、分销等由企业管理员在日常后台完成)。
|
||||
</span>
|
||||
<el-button type="danger" size="small" plain @click="goAdminConsole">
|
||||
进入管理后台
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
@@ -358,30 +346,4 @@ const handleLogout = async () => {
|
||||
padding-left: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.mission-strip {
|
||||
background: linear-gradient(90deg, #fef2f2 0%, #fff7ed 100%);
|
||||
border-bottom: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.mission-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mission-text {
|
||||
font-size: 13px;
|
||||
color: #44403c;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,73 @@ export interface FaceDetailView {
|
||||
careers: string[]
|
||||
}
|
||||
|
||||
function collectPhotoUrls(parsed: Record<string, any>): string[] {
|
||||
const out: string[] = []
|
||||
const pushUrl = (s: string) => {
|
||||
const t = s.trim()
|
||||
if (t && (t.startsWith('http://') || t.startsWith('https://'))) out.push(t)
|
||||
}
|
||||
const pushArr = (v: unknown) => {
|
||||
if (!Array.isArray(v)) return
|
||||
for (const x of v) {
|
||||
if (typeof x === 'string') pushUrl(x)
|
||||
}
|
||||
}
|
||||
pushArr(parsed.photoUrls)
|
||||
pushArr(parsed.photos)
|
||||
pushArr(parsed.imageUrls)
|
||||
for (const k of ['photoUrl', 'imageUrl', 'faceImageUrl'] as const) {
|
||||
const u = parsed[k]
|
||||
if (typeof u === 'string') pushUrl(u)
|
||||
}
|
||||
return [...new Set(out)]
|
||||
}
|
||||
|
||||
function isPlaceholderTitle(s: string): boolean {
|
||||
return !s || s === '—' || s === '-' || s === '–' || s === '--'
|
||||
}
|
||||
|
||||
function normalizeMbtiBlock(parsed: Record<string, any>): { type?: string; title?: string } | null {
|
||||
const m = parsed.mbti
|
||||
if (m && typeof m === 'object' && !Array.isArray(m)) {
|
||||
const o = m as { type?: string; title?: string }
|
||||
const type = o.type != null ? String(o.type).trim() : ''
|
||||
let title = o.title != null ? String(o.title).trim() : ''
|
||||
if (isPlaceholderTitle(title)) title = ''
|
||||
if (!type) return null
|
||||
return title ? { type, title } : { type }
|
||||
}
|
||||
const typeOnly =
|
||||
(typeof m === 'string' && m.trim() ? m.trim() : '') ||
|
||||
String(parsed.mbtiType ?? parsed.mbti_type ?? '').trim()
|
||||
if (!typeOnly) return null
|
||||
let titleRaw = String(parsed.mbtiTitle ?? parsed.title ?? '').trim()
|
||||
if (isPlaceholderTitle(titleRaw)) titleRaw = ''
|
||||
return titleRaw ? { type: typeOnly, title: titleRaw } : { type: typeOnly }
|
||||
}
|
||||
|
||||
function normalizeDiscBlock(parsed: Record<string, any>): { primary?: string; secondary?: string } | null {
|
||||
const d = parsed.disc
|
||||
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
||||
return d as { primary?: string; secondary?: string }
|
||||
}
|
||||
if (typeof d === 'string' && d.trim()) {
|
||||
return { primary: d.trim() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizePdpBlock(parsed: Record<string, any>): { primary?: string; secondary?: string } | null {
|
||||
const p = parsed.pdp
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
return p as { primary?: string; secondary?: string }
|
||||
}
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
return { primary: p.trim() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseMaybeJsonObject(raw: unknown): Record<string, any> | null {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, any>
|
||||
@@ -113,10 +180,10 @@ export function buildFaceDetailFromParsed(parsed: Record<string, any> | null | u
|
||||
Array.isArray(v) ? (v as unknown[]).map((x) => String(x)).filter(Boolean) : []
|
||||
|
||||
return {
|
||||
photos: Array.isArray(parsed.photoUrls) ? (parsed.photoUrls as string[]) : [],
|
||||
mbti: parsed.mbti && typeof parsed.mbti === 'object' ? parsed.mbti : null,
|
||||
disc: parsed.disc && typeof parsed.disc === 'object' ? parsed.disc : null,
|
||||
pdp: parsed.pdp && typeof parsed.pdp === 'object' ? parsed.pdp : null,
|
||||
photos: collectPhotoUrls(parsed),
|
||||
mbti: normalizeMbtiBlock(parsed),
|
||||
disc: normalizeDiscBlock(parsed),
|
||||
pdp: normalizePdpBlock(parsed),
|
||||
overview: String(parsed.overview ?? ''),
|
||||
personalitySummary: String(parsed.personalitySummary ?? ''),
|
||||
faceAnalysisText,
|
||||
|
||||
17
admin/src/utils/testResultParse.ts
Normal file
17
admin/src/utils/testResultParse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 统一解析 test_results 单条的 result / resultData(字符串 JSON 或已解析对象)
|
||||
*/
|
||||
export function parseTestResultPayload(raw: unknown): Record<string, any> | null {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, any>
|
||||
}
|
||||
if (typeof raw !== 'string') return null
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
||||
return data as Record<string, any>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,18 @@
|
||||
<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-label">企业余额</div>
|
||||
<el-button type="primary" class="recharge-btn" @click="goEnterpriseRecharge">
|
||||
<el-icon class="recharge-btn-icon"><Wallet /></el-icon>
|
||||
企业充值
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-icon amber">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-catalog" v-if="testCatalog.length">
|
||||
@@ -75,19 +87,19 @@
|
||||
<div class="side-block side-users">
|
||||
<div class="panel-head row">
|
||||
<div>
|
||||
<h2 class="panel-title">测试 Top 10</h2>
|
||||
<h2 class="panel-title">测试 Top 20</h2>
|
||||
<p class="panel-desc">按完成次数 · 本企业口径</p>
|
||||
</div>
|
||||
<el-button type="primary" link size="small" @click="router.push('/admin/users')">全部用户</el-button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<div ref="tableWrapRef" class="table-wrap">
|
||||
<el-table
|
||||
v-if="topTestUsers.length"
|
||||
:data="topTestUsers"
|
||||
size="small"
|
||||
stripe
|
||||
class="compact-table"
|
||||
:max-height="tableMaxH"
|
||||
:height="sideTableHeight"
|
||||
>
|
||||
<el-table-column label="#" width="42">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
@@ -134,7 +146,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
User,
|
||||
@@ -143,7 +155,8 @@ import {
|
||||
Camera,
|
||||
Reading,
|
||||
Histogram,
|
||||
Medal
|
||||
Medal,
|
||||
Wallet
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -174,8 +187,7 @@ interface TopUserRow {
|
||||
const stats = reactive({
|
||||
totalUsers: 0,
|
||||
testsCompleted: 0,
|
||||
activeToday: 0,
|
||||
pendingReviews: 0
|
||||
activeToday: 0
|
||||
})
|
||||
|
||||
const testTrends = ref<
|
||||
@@ -200,16 +212,32 @@ const inviteQrcodeEnterprise = ref<string>('')
|
||||
const inviteQrcodePersonal = ref<string>('')
|
||||
const inviteLoadError = ref<string>('')
|
||||
|
||||
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
|
||||
const tableMaxH = 220
|
||||
/** 侧栏表格高度:随 `.table-wrap` 可用空间变化(适配 Top 20,避免固定 220px 导致大片留白) */
|
||||
const tableWrapRef = ref<HTMLElement | null>(null)
|
||||
const sideTableHeight = ref(360)
|
||||
let tableWrapResizeObserver: ResizeObserver | null = null
|
||||
|
||||
function updateSideTableHeight() {
|
||||
const el = tableWrapRef.value
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const h = Math.floor(el.getBoundingClientRect().height)
|
||||
if (h >= 120) {
|
||||
sideTableHeight.value = h
|
||||
}
|
||||
}
|
||||
|
||||
const kpiCards = computed(() => [
|
||||
{ key: 'u', label: '总用户数', value: stats.totalUsers, icon: User, tone: 'blue' },
|
||||
{ key: 't', label: '已完成测试', value: stats.testsCompleted, icon: Document, tone: 'green' },
|
||||
{ key: 'a', label: '今日活跃', value: stats.activeToday, icon: TrendCharts, tone: 'purple' },
|
||||
{ key: 'p', label: '待审核', value: stats.pendingReviews, icon: User, tone: 'orange' }
|
||||
{ key: 'a', label: '今日活跃', value: stats.activeToday, icon: TrendCharts, tone: 'purple' }
|
||||
])
|
||||
|
||||
function goEnterpriseRecharge() {
|
||||
void router.push({ path: '/admin/settings', query: { tab: 'finance' } })
|
||||
}
|
||||
|
||||
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
|
||||
face: { icon: Camera, tone: 'teal' },
|
||||
mbti: { icon: Reading, tone: 'blue' },
|
||||
@@ -357,7 +385,6 @@ const loadData = async () => {
|
||||
stats.totalUsers = response.data.totalUsers || 0
|
||||
stats.testsCompleted = response.data.testsCompleted || 0
|
||||
stats.activeToday = response.data.activeToday || 0
|
||||
stats.pendingReviews = response.data.pendingReviews || 0
|
||||
testTrends.value = response.data.testTrends || []
|
||||
topTestUsers.value = Array.isArray(response.data.topTestUsers) ? response.data.topTestUsers : []
|
||||
testCatalog.value = Array.isArray(response.data.testCatalog) ? response.data.testCatalog : []
|
||||
@@ -385,12 +412,27 @@ const loadData = async () => {
|
||||
ElMessage.error(error.message || '加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
void nextTick(() => updateSideTableHeight())
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadInviteQrcode()
|
||||
void nextTick(() => {
|
||||
updateSideTableHeight()
|
||||
if (tableWrapRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
tableWrapResizeObserver = new ResizeObserver(() => updateSideTableHeight())
|
||||
tableWrapResizeObserver.observe(tableWrapRef.value)
|
||||
}
|
||||
})
|
||||
window.addEventListener('resize', updateSideTableHeight)
|
||||
void loadData()
|
||||
void loadInviteQrcode()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
tableWrapResizeObserver?.disconnect()
|
||||
tableWrapResizeObserver = null
|
||||
window.removeEventListener('resize', updateSideTableHeight)
|
||||
})
|
||||
|
||||
const loadInviteQrcode = async () => {
|
||||
@@ -561,6 +603,30 @@ const loadInviteQrcode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card-recharge {
|
||||
.stat-card-recharge-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recharge-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recharge-btn-icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stat-icon.amber {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
@@ -802,15 +868,19 @@ const loadInviteQrcode = async () => {
|
||||
.side-users {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">已完成/已支付</div>
|
||||
<div class="stat-value">{{ paidCount }}</div>
|
||||
<div class="stat-value">{{ paidCompletedTotal }}</div>
|
||||
</div>
|
||||
<div class="stat-icon green">
|
||||
<el-icon><Box /></el-icon>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总收入</div>
|
||||
<div class="stat-value">{{ totalIncome }}</div>
|
||||
<div class="stat-value">{{ totalIncomeDisplay }}</div>
|
||||
</div>
|
||||
<div class="stat-icon purple">
|
||||
<el-icon><Money /></el-icon>
|
||||
@@ -196,6 +196,8 @@ const statusFilter = ref('')
|
||||
const productFilter = ref('')
|
||||
const orders = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const paidCompletedTotal = ref(0)
|
||||
const totalRevenueFen = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
@@ -218,14 +220,10 @@ const productOptions = [
|
||||
{ label: '完整报告', value: 'report' }
|
||||
]
|
||||
|
||||
const paidCount = computed(() =>
|
||||
orders.value.filter((o: any) => ['paid', 'completed'].includes(o.status || '')).length
|
||||
)
|
||||
const totalIncome = computed(() => {
|
||||
const sum = orders.value
|
||||
.filter((o: any) => ['paid', 'completed'].includes(o.status || ''))
|
||||
.reduce((acc: number, o: any) => acc + (Number(o.amount) || 0), 0)
|
||||
return '¥' + (sum / 100).toFixed(2)
|
||||
const totalIncomeDisplay = computed(() => {
|
||||
const n = totalRevenueFen.value
|
||||
if (!Number.isFinite(n)) return '¥0.00'
|
||||
return '¥' + (n / 100).toFixed(2)
|
||||
})
|
||||
|
||||
function formatPhone(phone: string) {
|
||||
@@ -317,9 +315,13 @@ async function loadOrders() {
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
orders.value = list
|
||||
total.value = res.data?.total ?? res?.total ?? 0
|
||||
paidCompletedTotal.value = Number(res.data?.paidCompletedCount ?? 0) || 0
|
||||
totalRevenueFen.value = Number(res.data?.totalRevenueFen ?? 0) || 0
|
||||
} catch {
|
||||
orders.value = []
|
||||
total.value = 0
|
||||
paidCompletedTotal.value = 0
|
||||
totalRevenueFen.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
v-for="(url, idx) in faceDetail.photos"
|
||||
:key="url + idx"
|
||||
:src="url"
|
||||
fit="cover"
|
||||
fit="contain"
|
||||
:preview-src-list="faceDetail.photos"
|
||||
/>
|
||||
</div>
|
||||
@@ -650,6 +650,7 @@ import { Download, Search, View } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
|
||||
import { parseTestResultPayload } from '@/utils/testResultParse'
|
||||
import UserDetailDialog from '@/components/UserDetailDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -753,15 +754,11 @@ function formatTestType(testType: string) {
|
||||
}
|
||||
|
||||
function extractTestSummary(test: any): string {
|
||||
const raw = test?.result
|
||||
if (typeof raw !== 'string' || !raw) return ''
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
const data = parseTestResultPayload(test?.result ?? test?.resultData)
|
||||
if (!data) {
|
||||
const raw = test?.result
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
if (!data || typeof data !== 'object') return raw
|
||||
|
||||
const type = (test?.testType || '').toLowerCase()
|
||||
|
||||
@@ -869,15 +866,8 @@ const resumeDetail = computed(() => {
|
||||
})
|
||||
|
||||
function parseTestResult(test: any): any {
|
||||
const raw = test?.result
|
||||
if (typeof raw !== 'string' || !raw) return null
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (!data || typeof data !== 'object') return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!test) return null
|
||||
return parseTestResultPayload(test.result ?? test.resultData)
|
||||
}
|
||||
|
||||
function extractTestDescription(parsed: any, testType: string): string {
|
||||
@@ -966,9 +956,29 @@ async function handleView(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewTest(row: any) {
|
||||
currentTest.value = row
|
||||
async function handleViewTest(row: any) {
|
||||
const testId = row?.id != null ? Number(row.id) : NaN
|
||||
if (!Number.isFinite(testId) || testId <= 0) {
|
||||
ElMessage.warning('测试记录缺少有效 ID')
|
||||
return
|
||||
}
|
||||
currentTest.value = null
|
||||
showTestDetailDialog.value = true
|
||||
testDetailLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/test-records/${testId}`)
|
||||
const payload = res?.data != null ? res.data : res
|
||||
if (payload && typeof payload === 'object' && (payload.testType != null || payload.result != null)) {
|
||||
currentTest.value = payload
|
||||
} else {
|
||||
ElMessage.warning('未获取到测试详情')
|
||||
showTestDetailDialog.value = false
|
||||
}
|
||||
} catch {
|
||||
showTestDetailDialog.value = false
|
||||
} finally {
|
||||
testDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickTestTag(row: any, testType: string) {
|
||||
@@ -1486,7 +1496,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.face-left {
|
||||
width: 220px;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -1494,15 +1504,27 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.face-photos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
:deep(.el-image) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(65vh, 560px);
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p class="subtitle">
|
||||
审核与站点、题库、数据库及超管账户等平台级配置(全局定价在「订单和财务」)。此处变更会间接影响各企业管理后台行为。
|
||||
审核与站点、题库及超管账户等平台级配置(全局定价在「订单和财务」)。此处变更会间接影响各企业管理后台行为。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,68 +269,6 @@
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 通知设置 -->
|
||||
<div v-if="activeTab === 'notification'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="header-icon"><Bell /></el-icon>
|
||||
<span>通知与告警设置</span>
|
||||
</div>
|
||||
<p class="header-description">管理系统通知和企业告警规则</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<div class="switch-section">
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">邮件通知</p>
|
||||
<p class="switch-desc">发送重要事件邮件通知</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.emailNotification" />
|
||||
</div>
|
||||
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">余额不足告警</p>
|
||||
<p class="switch-desc">企业余额低于阈值时提醒</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.lowBalanceAlert" />
|
||||
</div>
|
||||
|
||||
<div v-if="notificationConfig.lowBalanceAlert" class="threshold-input">
|
||||
<label class="form-label">余额告警阈值(元)</label>
|
||||
<el-input-number
|
||||
v-model="notificationConfig.lowBalanceThreshold"
|
||||
:min="100"
|
||||
:step="100"
|
||||
:controls="false"
|
||||
class="form-input threshold-input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="switch-item">
|
||||
<div class="switch-info">
|
||||
<p class="switch-title">新企业入驻通知</p>
|
||||
<p class="switch-desc">有新企业入驻时发送通知</p>
|
||||
</div>
|
||||
<el-switch v-model="notificationConfig.newEnterpriseNotify" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
color="#6366f1"
|
||||
class="save-button"
|
||||
@click="handleSave('notification')"
|
||||
>
|
||||
<el-icon class="mr-1"><Document /></el-icon>保存通知设置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 提示词配置 -->
|
||||
<div v-if="activeTab === 'prompts'" class="tab-content">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
@@ -464,9 +402,6 @@
|
||||
<div v-if="activeTab === 'questions'" class="embed-wrap">
|
||||
<Questions embedded />
|
||||
</div>
|
||||
<div v-if="activeTab === 'database'" class="embed-wrap">
|
||||
<Database embedded />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,29 +412,24 @@ import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Setting,
|
||||
Bell,
|
||||
Lock,
|
||||
Document,
|
||||
ChatDotRound,
|
||||
Postcard,
|
||||
DataLine,
|
||||
Reading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import PosterEditor from './PosterEditor.vue'
|
||||
import Questions from './Questions.vue'
|
||||
import Database from './Database.vue'
|
||||
|
||||
const TAB_IDS = [
|
||||
'review',
|
||||
'system',
|
||||
'notification',
|
||||
'prompts',
|
||||
'poster',
|
||||
'security',
|
||||
'questions',
|
||||
'database'
|
||||
'questions'
|
||||
] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
@@ -516,17 +446,13 @@ const saveSuccess = ref<string | null>(null)
|
||||
const tabs: { label: string; value: TabId; icon: any }[] = [
|
||||
{ label: '审核模式', value: 'review', icon: Document },
|
||||
{ label: '系统配置', value: 'system', icon: Setting },
|
||||
{ label: '通知设置', value: 'notification', icon: Bell },
|
||||
{ label: '提示词配置', value: 'prompts', icon: ChatDotRound },
|
||||
{ label: '海报配置', value: 'poster', icon: Postcard },
|
||||
{ label: '账户安全', value: 'security', icon: Lock },
|
||||
{ label: '题库管理', value: 'questions', icon: Reading },
|
||||
{ label: '数据库', value: 'database', icon: DataLine }
|
||||
{ label: '题库管理', value: 'questions', icon: Reading }
|
||||
]
|
||||
|
||||
const isFlatEmbed = computed(
|
||||
() => activeTab.value === 'questions' || activeTab.value === 'database'
|
||||
)
|
||||
const isFlatEmbed = computed(() => activeTab.value === 'questions')
|
||||
|
||||
const applyRouteTab = () => {
|
||||
const t = route.query.tab
|
||||
@@ -582,14 +508,6 @@ const credentials = reactive({
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
// 通知配置
|
||||
const notificationConfig = reactive({
|
||||
emailNotification: true,
|
||||
lowBalanceAlert: true,
|
||||
lowBalanceThreshold: 1000,
|
||||
newEnterpriseNotify: true,
|
||||
})
|
||||
|
||||
// 小程序文案配置(分析中提示、按钮、报告标题等)
|
||||
const textConfig = reactive({
|
||||
analyzingTitle: '正在分析中',
|
||||
@@ -623,11 +541,6 @@ const loadSettings = async () => {
|
||||
if (response.data.textConfig && typeof response.data.textConfig === 'object') {
|
||||
Object.assign(textConfig, response.data.textConfig)
|
||||
}
|
||||
|
||||
// 加载通知配置
|
||||
if (response.data.notification) {
|
||||
Object.assign(notificationConfig, response.data.notification)
|
||||
}
|
||||
|
||||
// 加载提示词配置
|
||||
if (response.data.prompts && typeof response.data.prompts === 'object') {
|
||||
@@ -702,17 +615,6 @@ const handleSave = async (section: string) => {
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'notification':
|
||||
response = await request.put('/superadmin/settings/notification', notificationConfig)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('通知配置已保存')
|
||||
saveSuccess.value = section
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = null
|
||||
}, 3000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'prompts':
|
||||
response = await request.put('/superadmin/settings/prompts', { prompts: promptsConfig })
|
||||
@@ -993,15 +895,6 @@ const handleSave = async (section: string) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&.threshold-input {
|
||||
margin-left: 16px;
|
||||
margin-top: -8px;
|
||||
|
||||
.threshold-input-field {
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credentials-input {
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
v-for="(url, idx) in faceDetail.photos"
|
||||
:key="url + idx"
|
||||
:src="url"
|
||||
fit="cover"
|
||||
fit="contain"
|
||||
:preview-src-list="faceDetail.photos"
|
||||
/>
|
||||
</div>
|
||||
@@ -729,6 +729,7 @@ import { Download, Search, View, UserFilled, User, OfficeBuilding, DataLine } fr
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
|
||||
import { parseTestResultPayload } from '@/utils/testResultParse'
|
||||
import UserDetailDialog from '@/components/UserDetailDialog.vue'
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
@@ -884,15 +885,11 @@ function formatTestType(testType: string) {
|
||||
}
|
||||
|
||||
function extractTestSummary(test: any): string {
|
||||
const raw = test?.result
|
||||
if (typeof raw !== 'string' || !raw) return ''
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
const data = parseTestResultPayload(test?.result ?? test?.resultData)
|
||||
if (!data) {
|
||||
const raw = test?.result
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}
|
||||
if (!data || typeof data !== 'object') return raw
|
||||
|
||||
const type = (test?.testType || '').toLowerCase()
|
||||
|
||||
@@ -999,15 +996,8 @@ const resumeDetail = computed(() => {
|
||||
})
|
||||
|
||||
function parseTestResult(test: any): any {
|
||||
const raw = test?.result
|
||||
if (typeof raw !== 'string' || !raw) return null
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (!data || typeof data !== 'object') return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!test) return null
|
||||
return parseTestResultPayload(test.result ?? test.resultData)
|
||||
}
|
||||
|
||||
function extractTestDescription(parsed: any, testType: string): string {
|
||||
@@ -1108,9 +1098,29 @@ async function handleView(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewTest(row: any) {
|
||||
currentTest.value = row
|
||||
async function handleViewTest(row: any) {
|
||||
const testId = row?.id != null ? Number(row.id) : NaN
|
||||
if (!Number.isFinite(testId) || testId <= 0) {
|
||||
ElMessage.warning('测试记录缺少有效 ID')
|
||||
return
|
||||
}
|
||||
currentTest.value = null
|
||||
showTestDetailDialog.value = true
|
||||
testDetailLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/superadmin/test-records/${testId}`)
|
||||
const payload = res?.data != null ? res.data : res
|
||||
if (payload && typeof payload === 'object' && (payload.testType != null || payload.result != null)) {
|
||||
currentTest.value = payload
|
||||
} else {
|
||||
ElMessage.warning('未获取到测试详情')
|
||||
showTestDetailDialog.value = false
|
||||
}
|
||||
} catch {
|
||||
showTestDetailDialog.value = false
|
||||
} finally {
|
||||
testDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickTestTag(row: any, testType: string) {
|
||||
@@ -1872,7 +1882,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.face-left {
|
||||
width: 220px;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -1880,15 +1890,27 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.face-photos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
:deep(.el-image) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: min(65vh, 560px);
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,13 +73,11 @@ class AppUser extends BaseController
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$kw = '%' . $keyword . '%';
|
||||
$baseQuery->where(function ($q) use ($kw) {
|
||||
$q->whereLike('w.nickname', $kw)
|
||||
->whereOr('w.phone', 'like', $kw)
|
||||
->whereOr('w.city', 'like', $kw)
|
||||
->whereOr('w.province', 'like', $kw);
|
||||
});
|
||||
$like = '%' . addcslashes($keyword, '%_\\') . '%';
|
||||
$baseQuery->whereRaw(
|
||||
'(w.nickname LIKE ? OR w.phone LIKE ? OR w.city LIKE ? OR w.province LIKE ?)',
|
||||
[$like, $like, $like, $like]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 无企业归属(极少):沿用全表 openid 去重 + IN 列表
|
||||
@@ -103,7 +101,7 @@ class AppUser extends BaseController
|
||||
$total = (int) (clone $baseQuery)->distinct(true)->count('w.id');
|
||||
$list = (clone $baseQuery)
|
||||
->field('w.id,w.nickname,w.openid,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt')
|
||||
->order('w.createdAt', 'desc')
|
||||
->order('w.id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -111,7 +109,7 @@ class AppUser extends BaseController
|
||||
$total = (int) (clone $baseQuery)->count();
|
||||
$list = (clone $baseQuery)
|
||||
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->order('id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -324,4 +322,71 @@ class AppUser extends BaseController
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条测试记录详情(与 testList 中单条结构一致,供后台「测试记录 → 详情」)
|
||||
* GET /api/v1/admin/test-records/:id
|
||||
*/
|
||||
public function testRecord($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$testId = (int) $id;
|
||||
if ($testId <= 0) {
|
||||
return error('记录ID无效', 400);
|
||||
}
|
||||
|
||||
$tr = Db::name('test_results')->where('id', $testId)->find();
|
||||
if (!$tr) {
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
|
||||
$wechatUserId = (int) ($tr['userId'] ?? 0);
|
||||
if ($wechatUserId <= 0) {
|
||||
return error('记录数据异常', 400);
|
||||
}
|
||||
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
if ($enterpriseId) {
|
||||
$has = Db::name('user_profile')
|
||||
->where('userId', $wechatUserId)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->find();
|
||||
if (!$has) {
|
||||
return error('无权限查看', 403);
|
||||
}
|
||||
$tid = isset($tr['enterpriseId']) ? (int) $tr['enterpriseId'] : 0;
|
||||
if ($tid !== (int) $enterpriseId) {
|
||||
return error('无权限查看该测试记录', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $tr['resultData'] ?? '';
|
||||
$out = [
|
||||
'id' => $testId,
|
||||
'userId' => $wechatUserId,
|
||||
'testType' => $tr['testType'] ?? '',
|
||||
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => $tr['createdAt'] ?? null,
|
||||
'requiresPayment' => (int) ($tr['requiresPayment'] ?? 0),
|
||||
'isPaid' => (int) ($tr['isPaid'] ?? 0),
|
||||
'paidAmount' => isset($tr['paidAmount']) ? (int) $tr['paidAmount'] : null,
|
||||
'paidAt' => $tr['paidAt'] ?? null,
|
||||
'orderId' => isset($tr['orderId']) ? (int) $tr['orderId'] : null,
|
||||
'testScope' => !empty($tr['enterpriseId']) ? 'enterprise' : 'personal',
|
||||
];
|
||||
|
||||
return success($out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,21 +48,13 @@ class Dashboard extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 企业用户 ID 集合(用于后续统计个人版测试)
|
||||
$enterpriseUserIds = [];
|
||||
// 总用户数:企业维度用 COUNT;全局用 DISTINCT openid(禁止拉全表 id)
|
||||
if ($enterpriseId) {
|
||||
$enterpriseUserIds = Db::name('wechat_users')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->column('id');
|
||||
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
|
||||
}
|
||||
|
||||
// 总用户数:wechat_users.enterpriseId = 本企业
|
||||
if ($enterpriseId) {
|
||||
$totalUsers = count($enterpriseUserIds);
|
||||
$totalUsers = (int) Db::name('wechat_users')->where('enterpriseId', $enterpriseId)->count();
|
||||
} else {
|
||||
try {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
|
||||
$ru = Db::name('wechat_users')->field('COUNT(DISTINCT openid) AS c')->find();
|
||||
$totalUsers = (int) ($ru['c'] ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
$totalUsers = (int) Db::name('wechat_users')->count();
|
||||
}
|
||||
@@ -77,20 +69,18 @@ class Dashboard extends BaseController
|
||||
$testsCompleted = (int) Db::name('test_results')->count();
|
||||
}
|
||||
|
||||
// 今日活跃用户数
|
||||
// 今日活跃用户数(库内 COUNT DISTINCT,禁止拉全量 userId)
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$activeQuery = Db::name('test_results')
|
||||
$activeRow = Db::name('test_results')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->where('createdAt', '<=', $todayEnd);
|
||||
if ($enterpriseId) {
|
||||
$activeQuery->where('enterpriseId', $enterpriseId);
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
} else {
|
||||
$activeIds = $activeQuery->distinct(true)->column('userId');
|
||||
$activeToday = count(array_filter($activeIds));
|
||||
}
|
||||
->where('createdAt', '<=', $todayEnd)
|
||||
->when((bool) $enterpriseId, static function ($q) use ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
})
|
||||
->field('COUNT(DISTINCT userId) AS c')
|
||||
->find();
|
||||
$activeToday = (int) ($activeRow['c'] ?? 0);
|
||||
|
||||
// 待审核(暂返回0)
|
||||
$pendingReviews = 0;
|
||||
@@ -151,7 +141,7 @@ class Dashboard extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
|
||||
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 20);
|
||||
|
||||
$testCatalog = $this->buildTestCatalog($enterpriseId);
|
||||
$distributionMbti = $this->aggregateTestLabels($enterpriseId, 'mbti', 14);
|
||||
@@ -180,7 +170,7 @@ class Dashboard extends BaseController
|
||||
/**
|
||||
* 按测试完成次数排序,取前 N 名小程序用户(与列表页口径一致:test_results 按企业过滤)
|
||||
*/
|
||||
private function buildTopTestUsers(?int $enterpriseId, int $limit = 10): array
|
||||
private function buildTopTestUsers(?int $enterpriseId, int $limit = 20): array
|
||||
{
|
||||
$limit = min(max($limit, 1), 50);
|
||||
$q = Db::name('test_results')->field('userId, COUNT(*) as cnt')->group('userId')->order('cnt', 'desc')->limit($limit);
|
||||
@@ -215,31 +205,74 @@ class Dashboard extends BaseController
|
||||
$userMap[(int) $u['id']] = $u;
|
||||
}
|
||||
|
||||
$trQuery = Db::name('test_results')->whereIn('userId', $uids);
|
||||
if ($enterpriseId) {
|
||||
$trQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$testRows = $trQuery
|
||||
->field('userId, testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 每人每类测评仅取最新一条(MAX id),禁止拉取 TopN 用户历史上全部 test_results
|
||||
$testsByUser = [];
|
||||
foreach ($testRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
try {
|
||||
$aggSub = Db::name('test_results')
|
||||
->whereIn('userId', $uids)
|
||||
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
if ($enterpriseId) {
|
||||
$aggSub->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
if (!isset($testsByUser[$uid])) {
|
||||
$testsByUser[$uid] = [];
|
||||
$aggSql = $aggSub
|
||||
->field('userId, testType, MAX(id) AS mid')
|
||||
->group('userId, testType')
|
||||
->buildSql(true);
|
||||
|
||||
$testRows = Db::name('test_results')->alias('t')
|
||||
->join([$aggSql => 'agg'], 't.userId = agg.userId AND t.testType = agg.testType AND t.id = agg.mid')
|
||||
->field('t.userId, t.testType, t.resultData, t.createdAt')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($testRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($testsByUser[$uid])) {
|
||||
$testsByUser[$uid] = [];
|
||||
}
|
||||
$raw = $row['resultData'] ?? '';
|
||||
$testsByUser[$uid][] = [
|
||||
'testType' => $row['testType'] ?? '',
|
||||
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$trQuery = Db::name('test_results')->whereIn('userId', $uids);
|
||||
if ($enterpriseId) {
|
||||
$trQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$testRows = $trQuery
|
||||
->field('userId, testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->limit(5000)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($testRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($testsByUser[$uid])) {
|
||||
$testsByUser[$uid] = [];
|
||||
}
|
||||
$tt = strtolower((string) ($row['testType'] ?? ''));
|
||||
foreach ($testsByUser[$uid] as $ex) {
|
||||
if (strtolower((string) ($ex['testType'] ?? '')) === $tt) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$raw = $row['resultData'] ?? '';
|
||||
$testsByUser[$uid][] = [
|
||||
'testType' => $row['testType'] ?? '',
|
||||
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}
|
||||
$raw = $row['resultData'] ?? '';
|
||||
$testsByUser[$uid][] = [
|
||||
'testType' => $row['testType'] ?? '',
|
||||
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => (int) ($row['createdAt'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
@@ -283,24 +316,34 @@ class Dashboard extends BaseController
|
||||
['key' => 'disc', 'label' => 'DISC'],
|
||||
['key' => 'pdp', 'label' => 'PDP'],
|
||||
];
|
||||
|
||||
$q = Db::name('test_results')
|
||||
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$rows = $q->field('testType, COUNT(*) AS records, COUNT(DISTINCT userId) AS uniqueUsers')
|
||||
->group('testType')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$byType = [];
|
||||
foreach ($rows as $r) {
|
||||
$byType[$r['testType'] ?? ''] = [
|
||||
'records' => (int) ($r['records'] ?? 0),
|
||||
'uniqueUsers' => (int) ($r['uniqueUsers'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($defs as $def) {
|
||||
$tt = $def['key'];
|
||||
$q = Db::name('test_results')->where('testType', $tt);
|
||||
if ($enterpriseId) {
|
||||
$q->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$records = (int) $q->count();
|
||||
$q2 = Db::name('test_results')->where('testType', $tt);
|
||||
if ($enterpriseId) {
|
||||
$q2->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$uniqueUsers = (int) $q2->distinct(true)->count('userId');
|
||||
$st = $byType[$tt] ?? ['records' => 0, 'uniqueUsers' => 0];
|
||||
$out[] = [
|
||||
'key' => $tt,
|
||||
'label' => $def['label'],
|
||||
'records' => $records,
|
||||
'uniqueUsers' => $uniqueUsers,
|
||||
'records' => $st['records'],
|
||||
'uniqueUsers' => $st['uniqueUsers'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -313,6 +356,90 @@ class Dashboard extends BaseController
|
||||
* @return array<int, array{label:string,count:int}>
|
||||
*/
|
||||
private function aggregateTestLabels(?int $enterpriseId, string $testType, int $topN): array
|
||||
{
|
||||
try {
|
||||
return $this->aggregateTestLabelsWithSql($enterpriseId, $testType, $topN);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->aggregateTestLabelsChunked($enterpriseId, $testType, $topN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 MySQL JSON 函数在库内分组统计(避免全表 chunk)
|
||||
*
|
||||
* @return array<int, array{label:string,count:int}>
|
||||
*/
|
||||
private function aggregateTestLabelsWithSql(?int $enterpriseId, string $testType, int $topN): array
|
||||
{
|
||||
if (!in_array($testType, ['mbti', 'disc', 'pdp'], true)) {
|
||||
throw new \InvalidArgumentException('unsupported testType');
|
||||
}
|
||||
|
||||
$table = $this->testResultsTableName();
|
||||
$entClause = '';
|
||||
$bind = [$testType];
|
||||
if ($enterpriseId) {
|
||||
$entClause = ' AND `enterpriseId` = ? ';
|
||||
$bind[] = $enterpriseId;
|
||||
}
|
||||
|
||||
if ($testType === 'mbti') {
|
||||
$expr = "CASE
|
||||
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
|
||||
ELSE COALESCE(
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbtiType'))), ''),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.result')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.result'))), ''), NULL),
|
||||
'未识别'
|
||||
)
|
||||
END";
|
||||
} elseif ($testType === 'disc') {
|
||||
$expr = "CASE
|
||||
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
|
||||
ELSE COALESCE(
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.description.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.description.type'))), ''), NULL),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.dominantType')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.dominantType'))), ''), NULL),
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc.primary'))), ''),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.disc')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc'))), ''), NULL),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
|
||||
'未识别'
|
||||
)
|
||||
END";
|
||||
} else {
|
||||
$expr = "CASE
|
||||
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
|
||||
ELSE COALESCE(
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.description.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.description.type'))), ''), NULL),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.dominantType')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.dominantType'))), ''), NULL),
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp.primary'))), ''),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.pdp')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp'))), ''), NULL),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
|
||||
'未识别'
|
||||
)
|
||||
END";
|
||||
}
|
||||
|
||||
$sql = "SELECT `label`, COUNT(*) AS `cnt` FROM (
|
||||
SELECT ({$expr}) AS `label`
|
||||
FROM `{$table}`
|
||||
WHERE `testType` = ? {$entClause}
|
||||
) `t`
|
||||
GROUP BY `label`";
|
||||
|
||||
$rows = Db::query($sql, $bind);
|
||||
$counts = [];
|
||||
foreach ($rows as $r) {
|
||||
$counts[(string) ($r['label'] ?? '')] = (int) ($r['cnt'] ?? 0);
|
||||
}
|
||||
arsort($counts);
|
||||
|
||||
return $this->countsToTopNWithOther($counts, $topN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label:string,count:int}>
|
||||
*/
|
||||
private function aggregateTestLabelsChunked(?int $enterpriseId, string $testType, int $topN): array
|
||||
{
|
||||
$counts = [];
|
||||
$query = Db::name('test_results')
|
||||
@@ -321,7 +448,7 @@ class Dashboard extends BaseController
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$query->chunk(400, function ($rows) use (&$counts, $testType) {
|
||||
$query->chunk(800, function ($rows) use (&$counts, $testType) {
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row['resultData'] ?? '';
|
||||
$label = $this->labelFromResultRow($testType, $raw);
|
||||
@@ -342,6 +469,87 @@ class Dashboard extends BaseController
|
||||
* @return array{mbti:array,disc:array,pdp:array}
|
||||
*/
|
||||
private function aggregateFaceSubtypeHints(?int $enterpriseId, int $topN): array
|
||||
{
|
||||
try {
|
||||
return $this->aggregateFaceSubtypeHintsWithSql($enterpriseId, $topN);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->aggregateFaceSubtypeHintsChunked($enterpriseId, $topN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mbti:array,disc:array,pdp:array}
|
||||
*/
|
||||
private function aggregateFaceSubtypeHintsWithSql(?int $enterpriseId, int $topN): array
|
||||
{
|
||||
$table = $this->testResultsTableName();
|
||||
$entClause = '';
|
||||
$bind = [];
|
||||
if ($enterpriseId) {
|
||||
$entClause = ' AND `enterpriseId` = ? ';
|
||||
$bind[] = $enterpriseId;
|
||||
}
|
||||
|
||||
$sqlFaceMbti = "SELECT `label`, COUNT(*) AS `cnt` FROM (
|
||||
SELECT COALESCE(
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbti.type'))), ''),
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbtiType'))), ''),
|
||||
''
|
||||
) AS `label`
|
||||
FROM `{$table}`
|
||||
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
|
||||
) `t`
|
||||
WHERE `label` <> ''
|
||||
GROUP BY `label`";
|
||||
|
||||
$sqlFaceDisc = "SELECT `label`, COUNT(*) AS `cnt` FROM (
|
||||
SELECT COALESCE(
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc.primary'))), ''),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.disc')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc'))), ''), ''),
|
||||
''
|
||||
) AS `label`
|
||||
FROM `{$table}`
|
||||
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
|
||||
) `t`
|
||||
WHERE `label` <> ''
|
||||
GROUP BY `label`";
|
||||
|
||||
$sqlFacePdp = "SELECT `label`, COUNT(*) AS `cnt` FROM (
|
||||
SELECT COALESCE(
|
||||
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp.primary'))), ''),
|
||||
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.pdp')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp'))), ''), ''),
|
||||
''
|
||||
) AS `label`
|
||||
FROM `{$table}`
|
||||
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
|
||||
) `t`
|
||||
WHERE `label` <> ''
|
||||
GROUP BY `label`";
|
||||
|
||||
$out = [];
|
||||
foreach (
|
||||
[
|
||||
'mbti' => $sqlFaceMbti,
|
||||
'disc' => $sqlFaceDisc,
|
||||
'pdp' => $sqlFacePdp,
|
||||
] as $k => $sql
|
||||
) {
|
||||
$rows = Db::query($sql, $bind);
|
||||
$counts = [];
|
||||
foreach ($rows as $r) {
|
||||
$counts[(string) ($r['label'] ?? '')] = (int) ($r['cnt'] ?? 0);
|
||||
}
|
||||
arsort($counts);
|
||||
$out[$k] = $this->countsToTopNWithOther($counts, $topN);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mbti:array,disc:array,pdp:array}
|
||||
*/
|
||||
private function aggregateFaceSubtypeHintsChunked(?int $enterpriseId, int $topN): array
|
||||
{
|
||||
$subMaps = ['mbti' => [], 'disc' => [], 'pdp' => []];
|
||||
$query = Db::name('test_results')
|
||||
@@ -350,7 +558,7 @@ class Dashboard extends BaseController
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$query->chunk(400, function ($rows) use (&$subMaps) {
|
||||
$query->chunk(800, function ($rows) use (&$subMaps) {
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row['resultData'] ?? '';
|
||||
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
|
||||
@@ -376,6 +584,11 @@ class Dashboard extends BaseController
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function testResultsTableName(): string
|
||||
{
|
||||
return Db::name('test_results')->getTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,int> $counts
|
||||
* @return array<int, array{label:string,count:int}>
|
||||
|
||||
@@ -11,6 +11,65 @@ use think\facade\Request;
|
||||
*/
|
||||
class Distribution extends BaseController
|
||||
{
|
||||
/**
|
||||
* 企业管理员 enterpriseId:JWT 可能为空,需与 users 表对齐(与订单等接口一致)
|
||||
*/
|
||||
private function resolveEnterpriseIdForAdmin(?array $user): int
|
||||
{
|
||||
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
|
||||
return 0;
|
||||
}
|
||||
$eid = $user['enterpriseId'] ?? null;
|
||||
if (is_array($eid)) {
|
||||
$eid = null;
|
||||
}
|
||||
$enterpriseId = $eid !== null && $eid !== '' ? (int) $eid : 0;
|
||||
if ($enterpriseId <= 0) {
|
||||
$adminRow = Db::name('users')->where('id', (int) ($user['userId'] ?? 0))->find();
|
||||
$e2 = $adminRow['enterpriseId'] ?? null;
|
||||
$enterpriseId = ($e2 !== null && $e2 !== '') ? (int) $e2 : 0;
|
||||
}
|
||||
|
||||
return $enterpriseId > 0 ? $enterpriseId : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本企业维度的「推荐人 id」集合:佣金表 + 企业绑定表 + 历史仅填 agentId 的佣金行
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function distributorUserIdsForEnterprise(int $enterpriseId): array
|
||||
{
|
||||
if ($enterpriseId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$a = Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('inviterId', '>', 0)
|
||||
->distinct(true)
|
||||
->column('inviterId');
|
||||
$b = Db::name('distribution_bindings')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('inviterId', '>', 0)
|
||||
->distinct(true)
|
||||
->column('inviterId');
|
||||
$c = Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('inviterId')->whereOr('inviterId', 0);
|
||||
})
|
||||
->where('agentId', '>', 0)
|
||||
->distinct(true)
|
||||
->column('agentId');
|
||||
|
||||
$merged = array_merge($a ?: [], $b ?: [], $c ?: []);
|
||||
|
||||
return array_values(array_unique(array_filter(array_map('intval', $merged), static function ($v) {
|
||||
return $v > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// GET distribution/overview
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -21,7 +80,10 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$days = 7;
|
||||
$trendStart = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
|
||||
@@ -105,83 +167,102 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$search = trim((string) Request::param('search', ''));
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, (int) Request::param('pageSize', 20));
|
||||
|
||||
try {
|
||||
// 找出与本企业关联的所有有过邀请行为的用户(不限 scope,按 enterpriseId 筛选)
|
||||
$query = Db::name('distribution_bindings')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->distinct(true)
|
||||
->field('inviterId')
|
||||
->buildSql();
|
||||
$poolIds = $this->distributorUserIdsForEnterprise($enterpriseId);
|
||||
if (empty($poolIds)) {
|
||||
return success(['list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize]);
|
||||
}
|
||||
|
||||
$inviterQuery = Db::name('wechat_users')
|
||||
->alias('u')
|
||||
->whereRaw("u.id IN {$query}")
|
||||
->whereIn('u.id', $poolIds)
|
||||
->field('u.id, u.nickname, u.avatar, u.createdAt');
|
||||
|
||||
if ($search !== '') {
|
||||
$inviterQuery->where(function ($q) use ($search) {
|
||||
$q->where('u.nickname', 'like', "%{$search}%")
|
||||
->whereOr('u.id', '=', is_numeric($search) ? (int)$search : -1);
|
||||
->whereOr('u.id', '=', is_numeric($search) ? (int) $search : -1);
|
||||
});
|
||||
}
|
||||
|
||||
$total = (clone $inviterQuery)->count();
|
||||
$inviters = $inviterQuery->page($page, $pageSize)->select()->toArray();
|
||||
$inviterQuery->order('u.id', 'desc');
|
||||
|
||||
$inviterIds = array_column($inviters, 'id');
|
||||
$total = (int) (clone $inviterQuery)->count();
|
||||
$inviters = $inviterQuery->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
$inviterIds = array_values(array_filter(array_map('intval', array_column($inviters, 'id'))));
|
||||
|
||||
$commStats = [];
|
||||
$withdrawnMap = [];
|
||||
$teamMap = [];
|
||||
|
||||
// 各邀请人的累计佣金与可提现佣金
|
||||
$commStats = [];
|
||||
if (!empty($inviterIds)) {
|
||||
$rows = Db::name('commission_records')
|
||||
->whereIn('inviterId', $inviterIds)
|
||||
$inStr = implode(',', $inviterIds);
|
||||
$rows = Db::name('commission_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->field('inviterId,
|
||||
SUM(IF(status IN ("paid","frozen"), commissionFen, 0)) as totalFen,
|
||||
SUM(IF(status = "paid", commissionFen, 0)) as paidFen')
|
||||
->group('inviterId')
|
||||
->select()->toArray();
|
||||
->whereRaw('COALESCE(inviterId, agentId) IN (' . $inStr . ')')
|
||||
->fieldRaw("COALESCE(inviterId, agentId) AS inviterKey, SUM(IF(status IN ('paid','frozen'), commissionFen, 0)) AS totalFen, SUM(IF(status = 'paid', commissionFen, 0)) AS paidFen")
|
||||
->group('inviterKey')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $r) {
|
||||
$commStats[$r['inviterId']] = $r;
|
||||
$kid = (int) ($r['inviterKey'] ?? 0);
|
||||
if ($kid > 0) {
|
||||
$commStats[$kid] = $r;
|
||||
}
|
||||
}
|
||||
|
||||
// 已提现金额
|
||||
$withdrawnRows = Db::name('distribution_withdrawals')
|
||||
->whereIn('userId', $inviterIds)
|
||||
// 提现金额统计:0=审核中,2=待收款,3=已收款
|
||||
->whereIn('status', [0, 2, 3])
|
||||
->field('userId, SUM(amountFen) as withdrawnFen')
|
||||
->group('userId')
|
||||
->select()->toArray();
|
||||
$withdrawnMap = [];
|
||||
->alias('w')
|
||||
->join('wechat_users u', 'w.userId = u.id')
|
||||
->whereIn('w.userId', $inviterIds)
|
||||
->where('u.enterpriseId', $enterpriseId)
|
||||
->whereIn('w.status', [0, 2, 3])
|
||||
->field('w.userId, SUM(w.amountFen) AS withdrawnFen')
|
||||
->group('w.userId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($withdrawnRows as $r) {
|
||||
$withdrawnMap[$r['userId']] = (int)$r['withdrawnFen'];
|
||||
$withdrawnMap[(int) ($r['userId'] ?? 0)] = (int) ($r['withdrawnFen'] ?? 0);
|
||||
}
|
||||
|
||||
// 团队人数(绑定人数,不限 scope)
|
||||
$now = time();
|
||||
$teamRows = Db::name('distribution_bindings')
|
||||
->whereIn('inviterId', $inviterIds)
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->field('inviterId, COUNT(DISTINCT inviteeId) as teamCount')
|
||||
->group('inviterId')
|
||||
->select()->toArray();
|
||||
$teamMap = [];
|
||||
->alias('b')
|
||||
->leftJoin('wechat_users w', 'w.id = b.inviteeId')
|
||||
->whereIn('b.inviterId', $inviterIds)
|
||||
->where(function ($q) use ($enterpriseId) {
|
||||
$q->where('b.enterpriseId', $enterpriseId)
|
||||
->whereOr(function ($q2) use ($enterpriseId) {
|
||||
$q2->whereNull('b.enterpriseId')->where('w.enterpriseId', $enterpriseId);
|
||||
});
|
||||
})
|
||||
->where('b.status', 'active')
|
||||
->where('b.expireAt', '>', $now)
|
||||
->fieldRaw('b.inviterId, COUNT(DISTINCT b.inviteeId) AS teamCount')
|
||||
->group('b.inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($teamRows as $r) {
|
||||
$teamMap[$r['inviterId']] = (int)$r['teamCount'];
|
||||
$teamMap[(int) ($r['inviterId'] ?? 0)] = (int) ($r['teamCount'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($inviters as $inv) {
|
||||
$uid = $inv['id'];
|
||||
$totalFen = (int)($commStats[$uid]['totalFen'] ?? 0);
|
||||
$paidFen = (int)($commStats[$uid]['paidFen'] ?? 0);
|
||||
$withdrawn = $withdrawnMap[$uid] ?? 0;
|
||||
$uid = (int) ($inv['id'] ?? 0);
|
||||
$totalFen = (int) ($commStats[$uid]['totalFen'] ?? 0);
|
||||
$paidFen = (int) ($commStats[$uid]['paidFen'] ?? 0);
|
||||
$withdrawn = (int) ($withdrawnMap[$uid] ?? 0);
|
||||
$avail = max(0, $paidFen - $withdrawn);
|
||||
$list[] = [
|
||||
'id' => $uid,
|
||||
@@ -189,7 +270,7 @@ class Distribution extends BaseController
|
||||
'avatar' => $inv['avatar'] ?? '',
|
||||
'totalCommission' => number_format($totalFen / 100, 2, '.', ''),
|
||||
'availableCommission'=> number_format($avail / 100, 2, '.', ''),
|
||||
'teamCount' => $teamMap[$uid] ?? 0,
|
||||
'teamCount' => (int) ($teamMap[$uid] ?? 0),
|
||||
'teamPerformance' => '-',
|
||||
'inviteCode' => '-',
|
||||
'level' => '-',
|
||||
@@ -213,7 +294,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, (int) Request::param('pageSize', 20));
|
||||
$status = Request::param('status', '');
|
||||
@@ -226,7 +311,16 @@ class Distribution extends BaseController
|
||||
->leftJoin('wechat_users invt', 'b.inviteeId = invt.id')
|
||||
->field('b.*, inv.nickname as inviterName, inv.avatar as inviterAvatar,
|
||||
invt.nickname as inviteeName, invt.avatar as inviteeAvatar')
|
||||
->where('b.enterpriseId', $enterpriseId);
|
||||
->where(function ($q) use ($enterpriseId) {
|
||||
$q->where('b.enterpriseId', $enterpriseId)
|
||||
->whereOr(function ($q2) use ($enterpriseId) {
|
||||
$q2->whereNull('b.enterpriseId')
|
||||
->where(function ($q3) use ($enterpriseId) {
|
||||
$q3->where('inv.enterpriseId', $enterpriseId)
|
||||
->whereOr('invt.enterpriseId', $enterpriseId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if ($inviterId > 0) {
|
||||
$query->where('b.inviterId', $inviterId);
|
||||
@@ -264,7 +358,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, (int) Request::param('pageSize', 20));
|
||||
$status = Request::param('status', '');
|
||||
@@ -279,7 +377,7 @@ class Distribution extends BaseController
|
||||
->where('c.enterpriseId', $enterpriseId);
|
||||
|
||||
if ($inviterId > 0) {
|
||||
$query->where('c.inviterId', $inviterId);
|
||||
$query->whereRaw('COALESCE(c.inviterId, c.agentId) = ?', [$inviterId]);
|
||||
}
|
||||
if ($status) {
|
||||
$query->where('c.status', $status);
|
||||
@@ -356,7 +454,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, (int) Request::param('pageSize', 20));
|
||||
$status = Request::param('status', '');
|
||||
@@ -426,7 +528,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$note = Request::param('note', '');
|
||||
$now = time();
|
||||
|
||||
@@ -497,7 +603,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$note = Request::param('note', '');
|
||||
$now = time();
|
||||
|
||||
@@ -549,7 +659,10 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Db::name('system_config')
|
||||
@@ -593,7 +706,11 @@ class Distribution extends BaseController
|
||||
return error('无权限', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
|
||||
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
|
||||
if ($enterpriseId <= 0) {
|
||||
return error('未绑定企业或企业无效', 400);
|
||||
}
|
||||
|
||||
$settings = Request::only(['enabled', 'promoCenterTitle', 'bindingDays', 'testSettings']);
|
||||
|
||||
$promoTitle = trim((string)($settings['promoCenterTitle'] ?? ''));
|
||||
|
||||
@@ -69,6 +69,10 @@ class Order extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 与列表相同筛选条件下的全量统计(非当前页):已支付/已完成单数、实收金额(分)
|
||||
$paidCompletedCount = (int) (clone $query)->whereIn('status', ['paid', 'completed'])->count();
|
||||
$totalRevenueFen = (int) (clone $query)->whereIn('status', ['paid', 'completed'])->sum('amount');
|
||||
|
||||
$query->order('createdAt', 'desc');
|
||||
$total = (int) (clone $query)->count();
|
||||
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
|
||||
@@ -122,7 +126,16 @@ class Order extends BaseController
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
return success([
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'hasMore' => ($page * $pageSize) < $total,
|
||||
// 看板卡片:全量口径(随 keyword / status / productType / 企业 筛选变化,与 total 一致)
|
||||
'paidCompletedCount' => $paidCompletedCount,
|
||||
'totalRevenueFen' => $totalRevenueFen,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\controller\admin\concern\ExtractsTestResults;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
@@ -11,6 +12,8 @@ use think\facade\Request;
|
||||
*/
|
||||
class AppUser extends BaseController
|
||||
{
|
||||
use ExtractsTestResults;
|
||||
|
||||
/**
|
||||
* 名称包含「存客宝」的企业(取 id 最小的一条),用于合并个人侧无归属测试数据
|
||||
*/
|
||||
@@ -24,9 +27,32 @@ class AppUser extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 test_results.resultData 中解析 MBTI 四字母(与概览统计口径一致)
|
||||
*/
|
||||
private function parseOverviewMbtiTypeFromResultData($raw): string
|
||||
{
|
||||
$dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
|
||||
if (!is_array($dec)) {
|
||||
return '';
|
||||
}
|
||||
$type = '';
|
||||
if (isset($dec['mbtiType'])) {
|
||||
$type = $dec['mbtiType'];
|
||||
} elseif (isset($dec['mbti']['type'])) {
|
||||
$type = $dec['mbti']['type'];
|
||||
} elseif (isset($dec['type'])) {
|
||||
$type = $dec['type'];
|
||||
}
|
||||
|
||||
return strtoupper(trim((string) $type));
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览:用户统计、卡片、MBTI 分布
|
||||
* GET /api/v1/superadmin/app-users/overview
|
||||
*
|
||||
* 性能:openid 去重与各统计均在库内 JOIN/聚合完成,禁止拉全表 mid、全表 distinct userId、全量 MBTI 行。
|
||||
*/
|
||||
public function overview()
|
||||
{
|
||||
@@ -35,9 +61,136 @@ class AppUser extends BaseController
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
// ========== 统计全部基于 wechat_users.enterpriseId(而非 test_results.enterpriseId) ==========
|
||||
$last30d = time() - 30 * 86400;
|
||||
|
||||
// ========== 统计全部基于 wechat_users.enterpriseId(而非 test_results.enterpriseId) ==========
|
||||
try {
|
||||
$dedupSql = Db::name('wechat_users')
|
||||
->alias('w2')
|
||||
->field('w2.openid, MAX(w2.id) AS mid')
|
||||
->group('w2.openid')
|
||||
->buildSql(true);
|
||||
|
||||
$trTestedSql = Db::name('test_results')->distinct(true)->field('userId')->buildSql(true);
|
||||
$trActiveSql = Db::name('test_results')
|
||||
->where('createdAt', '>=', $last30d)
|
||||
->distinct(true)
|
||||
->field('userId')
|
||||
->buildSql(true);
|
||||
|
||||
$totalUsers = (int) Db::name('wechat_users')->alias('w')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid')
|
||||
->count();
|
||||
|
||||
$testedRow = Db::name('test_results')->alias('tr')
|
||||
->join([$dedupSql => 'd'], 'tr.userId = d.mid')
|
||||
->field('COUNT(DISTINCT tr.userId) AS c')
|
||||
->find();
|
||||
$testedUsers = (int) ($testedRow['c'] ?? 0);
|
||||
|
||||
$activeRow = Db::name('test_results')->alias('tr')
|
||||
->join([$dedupSql => 'd'], 'tr.userId = d.mid')
|
||||
->where('tr.createdAt', '>=', $last30d)
|
||||
->field('COUNT(DISTINCT tr.userId) AS c')
|
||||
->find();
|
||||
$activeUsers = (int) ($activeRow['c'] ?? 0);
|
||||
|
||||
$eidExpr = "CASE WHEN w.enterpriseId IS NULL OR w.enterpriseId = '' OR w.enterpriseId = 0 THEN 0 ELSE w.enterpriseId END";
|
||||
|
||||
$aggRows = Db::name('wechat_users')->alias('w')
|
||||
->join([$dedupSql => 'ded'], 'w.id = ded.mid')
|
||||
->leftJoin([$trTestedSql => 'tt'], 'tt.userId = w.id')
|
||||
->leftJoin([$trActiveSql => 'ta'], 'ta.userId = w.id')
|
||||
->field($eidExpr . ' AS eid, COUNT(*) AS total, SUM(IF(tt.userId IS NOT NULL, 1, 0)) AS tested, SUM(IF(ta.userId IS NOT NULL, 1, 0)) AS active')
|
||||
->group($eidExpr)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$byEid = [];
|
||||
foreach ($aggRows as $row) {
|
||||
$byEid[(int) ($row['eid'] ?? 0)] = [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'tested' => (int) ($row['tested'] ?? 0),
|
||||
'active' => (int) ($row['active'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$userCards = [
|
||||
[
|
||||
'type' => 'all',
|
||||
'name' => '全部用户',
|
||||
'total' => $totalUsers,
|
||||
'active' => $activeUsers,
|
||||
'tested' => $testedUsers,
|
||||
],
|
||||
];
|
||||
|
||||
$enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
|
||||
foreach ($enterprises as $e) {
|
||||
$eid = (int) $e['id'];
|
||||
$st = $byEid[$eid] ?? ['total' => 0, 'active' => 0, 'tested' => 0];
|
||||
$userCards[] = [
|
||||
'type' => 'enterprise',
|
||||
'enterpriseId' => $eid,
|
||||
'name' => $e['name'] ?? ('企业' . $eid),
|
||||
'total' => $st['total'],
|
||||
'active' => $st['active'],
|
||||
'tested' => $st['tested'],
|
||||
];
|
||||
}
|
||||
|
||||
$ind = $byEid[0] ?? null;
|
||||
if ($ind !== null && ($ind['total'] ?? 0) > 0) {
|
||||
$userCards[] = [
|
||||
'type' => 'individual',
|
||||
'name' => '个人用户(无企业)',
|
||||
'total' => $ind['total'],
|
||||
'active' => $ind['active'],
|
||||
'tested' => $ind['tested'],
|
||||
];
|
||||
}
|
||||
|
||||
// MBTI:每人仅最新一条(MAX(id)),且须在 openid 去重后的用户集合内
|
||||
$mbtiTypes = [];
|
||||
try {
|
||||
$mbtiLatestSub = Db::name('test_results')
|
||||
->where('testType', 'mbti')
|
||||
->field('userId, MAX(id) AS mid')
|
||||
->group('userId')
|
||||
->buildSql(true);
|
||||
|
||||
$mbtiRows = Db::name('test_results')->alias('t')
|
||||
->join([$mbtiLatestSub => 'lm'], 't.userId = lm.userId AND t.id = lm.mid')
|
||||
->join([$dedupSql => 'd'], 't.userId = d.mid')
|
||||
->column('t.resultData');
|
||||
|
||||
foreach ($mbtiRows as $raw) {
|
||||
$type = $this->parseOverviewMbtiTypeFromResultData($raw);
|
||||
if ($type === '') {
|
||||
continue;
|
||||
}
|
||||
$mbtiTypes[$type] = ($mbtiTypes[$type] ?? 0) + 1;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$mbtiTypes = [];
|
||||
}
|
||||
|
||||
$mbtiDistribution = [];
|
||||
foreach ($mbtiTypes as $type => $count) {
|
||||
$mbtiDistribution[] = ['type' => $type, 'count' => $count];
|
||||
}
|
||||
|
||||
return success([
|
||||
'totalUsers' => $totalUsers,
|
||||
'testedUsers' => $testedUsers,
|
||||
'activeUsers' => $activeUsers,
|
||||
'userCards' => $userCards,
|
||||
'mbtiDistribution' => $mbtiDistribution,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 极少数环境无法 buildSql / JOIN 时降级(仍可能较慢,但保证有数据)
|
||||
}
|
||||
|
||||
// openid 去重:每个 openid 只保留 id 最大的一条
|
||||
try {
|
||||
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
|
||||
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
|
||||
@@ -46,14 +199,11 @@ class AppUser extends BaseController
|
||||
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
|
||||
}
|
||||
$totalUsers = count($dedupIds);
|
||||
$last30d = time() - 30 * 86400;
|
||||
|
||||
// 已测试用户(在 test_results 有记录的 userId 与 dedupIds 取交集)
|
||||
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
|
||||
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
|
||||
$testedUsers = count(array_intersect($testedUserIds, $dedupIds));
|
||||
|
||||
// 近 30 天活跃用户
|
||||
$activeUserIds = Db::name('test_results')
|
||||
->where('createdAt', '>=', $last30d)
|
||||
->distinct(true)
|
||||
@@ -71,10 +221,8 @@ class AppUser extends BaseController
|
||||
]
|
||||
];
|
||||
|
||||
// 按 wechat_users.enterpriseId 分组统计
|
||||
$userEidMap = Db::name('wechat_users')->where('id', 'in', $dedupIds)->column('enterpriseId', 'id');
|
||||
|
||||
// 按企业统计:从注册表统计 total,再交叉 test_results 统计 active/tested
|
||||
$enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
|
||||
foreach ($enterprises as $e) {
|
||||
$eid = (int) $e['id'];
|
||||
@@ -95,7 +243,6 @@ class AppUser extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
// 无企业归属的个人用户
|
||||
$individualUsers = array_keys(array_filter($userEidMap, function ($v) {
|
||||
return $v === null || $v === '' || (int) $v === 0;
|
||||
}));
|
||||
@@ -112,7 +259,6 @@ class AppUser extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
// MBTI 类型分布:按用户去重,每人只计其最新一次 MBTI 结果
|
||||
$mbtiTypes = [];
|
||||
try {
|
||||
$rows = Db::name('test_results')
|
||||
@@ -127,21 +273,7 @@ class AppUser extends BaseController
|
||||
if ($uid <= 0 || isset($seenUserIds[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$raw = $r['resultData'] ?? '';
|
||||
$dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
|
||||
if (!is_array($dec)) {
|
||||
$seenUserIds[$uid] = true;
|
||||
continue;
|
||||
}
|
||||
$type = '';
|
||||
if (isset($dec['mbtiType'])) {
|
||||
$type = $dec['mbtiType'];
|
||||
} elseif (isset($dec['mbti']['type'])) {
|
||||
$type = $dec['mbti']['type'];
|
||||
} elseif (isset($dec['type'])) {
|
||||
$type = $dec['type'];
|
||||
}
|
||||
$type = strtoupper(trim((string) $type));
|
||||
$type = $this->parseOverviewMbtiTypeFromResultData($r['resultData'] ?? '');
|
||||
$seenUserIds[$uid] = true;
|
||||
if ($type === '') {
|
||||
continue;
|
||||
@@ -184,52 +316,56 @@ class AppUser extends BaseController
|
||||
$enterpriseId = Request::param('enterpriseId', '');
|
||||
$mbti = trim(Request::param('mbti', ''));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
// 按 openid 去重:每个 openid 只保留 id 最大的一条
|
||||
// 与管理后台一致:openid 去重在库内用子查询 JOIN,禁止把全表 mid 拉进 PHP 再 whereIn
|
||||
try {
|
||||
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
|
||||
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
|
||||
$dedupSql = Db::name('wechat_users')
|
||||
->alias('w2')
|
||||
->field('w2.openid, MAX(w2.id) AS mid')
|
||||
->group('w2.openid')
|
||||
->buildSql(true);
|
||||
} catch (\Throwable $e) {
|
||||
$dedupIds = Db::name('wechat_users')->column('id');
|
||||
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
|
||||
}
|
||||
if (empty($dedupIds)) {
|
||||
return paginate_response([], 0, $page, $pageSize);
|
||||
}
|
||||
|
||||
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
|
||||
if ($where) {
|
||||
$baseQuery->where($where);
|
||||
$baseQuery = Db::name('wechat_users')->alias('w')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
// 含表别名时用 whereLike+whereOr 链式会在部分 ThinkPHP 版本报「查询表达式错误:W.PHONE」,改 whereRaw 绑定
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . addcslashes($keyword, '%_\\') . '%';
|
||||
$baseQuery->whereRaw(
|
||||
'(w.nickname LIKE ? OR w.phone LIKE ? OR w.city LIKE ? OR w.province LIKE ?)',
|
||||
[$like, $like, $like, $like]
|
||||
);
|
||||
}
|
||||
|
||||
// 池筛选:直接基于 wechat_users.enterpriseId
|
||||
if ($pool === 'individual') {
|
||||
$baseQuery->where(function ($q) {
|
||||
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
|
||||
$q->whereNull('w.enterpriseId')->whereOr('w.enterpriseId', '')->whereOr('w.enterpriseId', 0);
|
||||
});
|
||||
} elseif ($pool === 'enterprise' && $enterpriseId !== '') {
|
||||
$baseQuery->where('enterpriseId', (int) $enterpriseId);
|
||||
$baseQuery->where('w.enterpriseId', (int) $enterpriseId);
|
||||
}
|
||||
|
||||
// MBTI 筛选:保留旧逻辑,从 test_results 取有 mbti 测试的用户
|
||||
// MBTI 筛选(与历史行为一致:params.mbti 非空则只保留「有过 MBTI 测评」的用户;在库内 JOIN 替代全表 distinct userId)
|
||||
if ($mbti !== '') {
|
||||
$mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
|
||||
$mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
|
||||
if (!empty($mbtiUserIds)) {
|
||||
$baseQuery->where('id', 'in', $mbtiUserIds);
|
||||
} else {
|
||||
try {
|
||||
$hasMbtiSql = Db::name('test_results')
|
||||
->where('testType', 'mbti')
|
||||
->field('userId')
|
||||
->group('userId')
|
||||
->buildSql(true);
|
||||
$baseQuery->join([$hasMbtiSql => 'hm'], 'w.id = hm.userId');
|
||||
} catch (\Throwable $e) {
|
||||
return paginate_response([], 0, $page, $pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $baseQuery->count();
|
||||
$total = (int) (clone $baseQuery)->count();
|
||||
$list = (clone $baseQuery)
|
||||
->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->field('w.id,w.openid,w.nickname,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt,w.enterpriseId')
|
||||
->order('w.id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -238,46 +374,75 @@ class AppUser extends BaseController
|
||||
$testCounts = [];
|
||||
$lastTestAt = [];
|
||||
$testTypes = [];
|
||||
$userEnterprise = [];
|
||||
$payStats = [];
|
||||
$enterpriseNames = [];
|
||||
if (!empty($ids)) {
|
||||
$counts = Db::name('test_results')->where('userId', 'in', $ids)->group('userId')->column('COUNT(*) as cnt', 'userId');
|
||||
$testCounts = $counts ?: [];
|
||||
$lastRows = Db::name('test_results')
|
||||
->where('userId', 'in', $ids)
|
||||
->field('id, userId, testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select();
|
||||
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
$trAggRows = (clone $trBase)
|
||||
->field('userId, COUNT(*) AS cnt, MAX(createdAt) AS lastAt')
|
||||
->group('userId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($trAggRows as $r) {
|
||||
$uid = (int) ($r['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$testCounts[$uid] = (int) ($r['cnt'] ?? 0);
|
||||
$lastTestAt[$uid] = $r['lastAt'];
|
||||
}
|
||||
}
|
||||
|
||||
// 每人每种 testType 仅取最新一条(含 MAX(id)),与管理端一致,禁止拉全量 test_results
|
||||
try {
|
||||
$aggSub = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
$aggSql = $aggSub
|
||||
->field('userId, testType, MAX(createdAt) as mc, MAX(id) as mid')
|
||||
->group('userId, testType')
|
||||
->buildSql(true);
|
||||
|
||||
$lastRows = Db::name('test_results')->alias('t')
|
||||
->join([$aggSql => 'agg'], 't.userId = agg.userId AND t.testType = agg.testType AND t.id = agg.mid')
|
||||
->field('t.id, t.userId, t.testType, t.resultData, t.createdAt, t.enterpriseId as testEnterpriseId')
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$lastRows = (clone $trBase)
|
||||
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
|
||||
->order('createdAt', 'desc')
|
||||
->limit(2000)
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
foreach ($lastRows as $row) {
|
||||
$uid = $row['userId'];
|
||||
if (!isset($lastTestAt[$uid])) {
|
||||
$lastTestAt[$uid] = $row['createdAt'];
|
||||
}
|
||||
if (!isset($testTypes[$uid])) {
|
||||
$testTypes[$uid] = [];
|
||||
}
|
||||
$testTypes[$uid][] = [
|
||||
'testType' => $row['testType'],
|
||||
'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => $row['createdAt'],
|
||||
'testType' => $row['testType'],
|
||||
'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => $row['createdAt'],
|
||||
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
|
||||
];
|
||||
}
|
||||
foreach ($testTypes as $uid => &$tlist) {
|
||||
usort($tlist, static function ($a, $b) {
|
||||
return (int) ($b['createdAt'] ?? 0) <=> (int) ($a['createdAt'] ?? 0);
|
||||
});
|
||||
}
|
||||
unset($tlist);
|
||||
|
||||
// 所属企业直接从 wechat_users.enterpriseId 读取
|
||||
$userEids = Db::name('wechat_users')->where('id', 'in', $ids)->column('enterpriseId', 'id');
|
||||
$allEids = array_values(array_unique(array_filter(array_map('intval', $userEids))));
|
||||
$enterpriseNames = [];
|
||||
$allEids = [];
|
||||
foreach ($list as $lr) {
|
||||
$eid = isset($lr['enterpriseId']) ? (int) $lr['enterpriseId'] : 0;
|
||||
if ($eid > 0) {
|
||||
$allEids[] = $eid;
|
||||
}
|
||||
}
|
||||
$allEids = array_values(array_unique($allEids));
|
||||
if (!empty($allEids)) {
|
||||
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $allEids)->column('name', 'id');
|
||||
}
|
||||
foreach ($ids as $uid) {
|
||||
$eid = isset($userEids[$uid]) ? (int) $userEids[$uid] : 0;
|
||||
if ($eid > 0 && isset($enterpriseNames[$eid])) {
|
||||
$userEnterprise[$uid] = $enterpriseNames[$eid];
|
||||
} else {
|
||||
$userEnterprise[$uid] = '个人用户(无企业)';
|
||||
}
|
||||
}
|
||||
|
||||
// 从用户画像表汇总支付统计(付款次数与总金额)
|
||||
try {
|
||||
@@ -316,7 +481,10 @@ class AppUser extends BaseController
|
||||
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
|
||||
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
|
||||
$row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
|
||||
$row['enterprise'] = $userEnterprise[$id] ?? '个人用户(无企业)';
|
||||
$eidRow = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : 0;
|
||||
$row['enterprise'] = ($eidRow > 0 && isset($enterpriseNames[$eidRow]))
|
||||
? $enterpriseNames[$eidRow]
|
||||
: '个人用户(无企业)';
|
||||
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$totalPaidFen = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
@@ -363,13 +531,15 @@ class AppUser extends BaseController
|
||||
|
||||
$tests = Db::name('test_results')
|
||||
->where('userId', $id)
|
||||
->field('id, testType, resultData, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
|
||||
->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($tests as &$t) {
|
||||
$raw = $t['resultData'] ?? '';
|
||||
$t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
|
||||
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
|
||||
unset($t['testEnterpriseId']);
|
||||
}
|
||||
|
||||
$data['testCount'] = count($tests);
|
||||
@@ -392,6 +562,50 @@ class AppUser extends BaseController
|
||||
return success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条测试记录详情(与 testList 中单条结构一致)
|
||||
* GET /api/v1/superadmin/test-records/:id
|
||||
*/
|
||||
public function testRecord($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$testId = (int) $id;
|
||||
if ($testId <= 0) {
|
||||
return error('记录ID无效', 400);
|
||||
}
|
||||
|
||||
$tr = Db::name('test_results')->where('id', $testId)->find();
|
||||
if (!$tr) {
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
|
||||
$wechatUserId = (int) ($tr['userId'] ?? 0);
|
||||
if ($wechatUserId <= 0) {
|
||||
return error('记录数据异常', 400);
|
||||
}
|
||||
|
||||
$raw = $tr['resultData'] ?? '';
|
||||
$out = [
|
||||
'id' => $testId,
|
||||
'userId' => $wechatUserId,
|
||||
'testType' => $tr['testType'] ?? '',
|
||||
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
|
||||
'createdAt' => $tr['createdAt'] ?? null,
|
||||
'requiresPayment' => (int) ($tr['requiresPayment'] ?? 0),
|
||||
'isPaid' => (int) ($tr['isPaid'] ?? 0),
|
||||
'paidAmount' => isset($tr['paidAmount']) ? (int) $tr['paidAmount'] : null,
|
||||
'paidAt' => $tr['paidAt'] ?? null,
|
||||
'orderId' => isset($tr['orderId']) ? (int) $tr['orderId'] : null,
|
||||
'testScope' => !empty($tr['enterpriseId']) ? 'enterprise' : 'personal',
|
||||
];
|
||||
|
||||
return success($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按企业测评池内与用户 MBTI/PDP/DISC 的同质比例推荐企业,并附带登记负责人联系方式。
|
||||
* 仅超级管理后台使用;无测评维度时按池内活跃人数近似排序。
|
||||
@@ -561,116 +775,4 @@ class AppUser extends BaseController
|
||||
return mb_strtolower($s, 'UTF-8');
|
||||
}
|
||||
|
||||
private function parseMbtiFromResult($result): string
|
||||
{
|
||||
if (!is_string($result)) return '';
|
||||
$dec = json_decode($result, true);
|
||||
if (is_array($dec)) {
|
||||
return (string) ($dec['type'] ?? $dec['result'] ?? $dec['mbtiType'] ?? '');
|
||||
}
|
||||
return trim($result);
|
||||
}
|
||||
|
||||
private function extractResultType(array $tests, string $type): string
|
||||
{
|
||||
$targetType = strtolower($type);
|
||||
foreach ($tests as $t) {
|
||||
if (strtolower($t['testType'] ?? '') !== $targetType) {
|
||||
continue;
|
||||
}
|
||||
$result = $t['result'] ?? '';
|
||||
if (!is_string($result)) {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($result, true);
|
||||
if (!is_array($dec)) {
|
||||
// 无法解析 JSON 时,直接返回原始字符串
|
||||
return $targetType === 'face' ? '人脸分析' : trim($result);
|
||||
}
|
||||
|
||||
// 人脸分析:有记录就返回固定标签
|
||||
if ($targetType === 'face') {
|
||||
return '人脸分析';
|
||||
}
|
||||
|
||||
// MBTI:直接读 mbtiType/type
|
||||
if ($targetType === 'mbti') {
|
||||
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
|
||||
}
|
||||
|
||||
// DISC:优先 description.type,然后 dominantType
|
||||
if ($targetType === 'disc') {
|
||||
$desc = $dec['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($dec['dominantType'])) {
|
||||
return (string) $dec['dominantType'];
|
||||
}
|
||||
return (string) ($dec['disc'] ?? '');
|
||||
}
|
||||
|
||||
// PDP:优先 description.type,然后 dominantType
|
||||
if ($targetType === 'pdp') {
|
||||
$desc = $dec['description']['type'] ?? null;
|
||||
if (is_string($desc) && $desc !== '') {
|
||||
return $desc;
|
||||
}
|
||||
if (!empty($dec['dominantType'])) {
|
||||
return (string) $dec['dominantType'];
|
||||
}
|
||||
return (string) ($dec['pdp'] ?? '');
|
||||
}
|
||||
|
||||
// 兜底:尝试常见字段
|
||||
return (string) ($dec['type'] ?? $dec['result'] ?? '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
|
||||
*/
|
||||
private function extractFaceSubType(array $tests, string $subType): string
|
||||
{
|
||||
$target = strtolower($subType);
|
||||
foreach ($tests as $t) {
|
||||
if (strtolower($t['testType'] ?? '') !== 'face') {
|
||||
continue;
|
||||
}
|
||||
$result = $t['result'] ?? '';
|
||||
if (!is_string($result)) {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($result, true);
|
||||
if (!is_array($dec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($target === 'mbti') {
|
||||
if (!empty($dec['mbti']['type'])) {
|
||||
return (string) $dec['mbti']['type'];
|
||||
}
|
||||
if (!empty($dec['mbtiType'])) {
|
||||
return (string) $dec['mbtiType'];
|
||||
}
|
||||
} elseif ($target === 'disc') {
|
||||
if (!empty($dec['disc']['primary'])) {
|
||||
return (string) $dec['disc']['primary'];
|
||||
}
|
||||
if (!empty($dec['disc'])) {
|
||||
return (string) $dec['disc'];
|
||||
}
|
||||
} elseif ($target === 'pdp') {
|
||||
if (!empty($dec['pdp']['primary'])) {
|
||||
return (string) $dec['pdp']['primary'];
|
||||
}
|
||||
if (!empty($dec['pdp'])) {
|
||||
return (string) $dec['pdp'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
38
api/database/migrations/_gen_restore_mbti_test_results.py
Normal file
38
api/database/migrations/_gen_restore_mbti_test_results.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Generate restore_mbti_test_results_1_145.sql from api/mbti_data.sql."""
|
||||
import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
src = ROOT / "mbti_data.sql"
|
||||
out = pathlib.Path(__file__).resolve().parent / "restore_mbti_test_results_1_145.sql"
|
||||
|
||||
lines = src.read_text(encoding="utf-8").splitlines()
|
||||
starts = None
|
||||
ends = None
|
||||
for i, L in enumerate(lines):
|
||||
if starts is None and L.startswith("INSERT INTO `mbti_test_results`") and "VALUES (1," in L:
|
||||
starts = i
|
||||
if L.startswith("INSERT INTO `mbti_test_results`") and "VALUES (145," in L:
|
||||
ends = i + 1
|
||||
break
|
||||
|
||||
if starts is None or ends is None:
|
||||
raise SystemExit(f"range not found: starts={starts} ends={ends}")
|
||||
|
||||
chunk = lines[starts:ends]
|
||||
head = """-- 恢复 mbti_test_results 表 id 1~145(与 api/mbti_data.sql 中导出一致)
|
||||
-- 用法:mysql -u... -p... 数据库名 < restore_mbti_test_results_1_145.sql
|
||||
-- REPLACE 会按主键删除旧行再插入,执行前请备份。
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
START TRANSACTION;
|
||||
|
||||
"""
|
||||
body = "\n".join(L.replace("INSERT INTO", "REPLACE INTO", 1) for L in chunk)
|
||||
tail = """
|
||||
|
||||
COMMIT;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
"""
|
||||
out.write_text(head + body + tail, encoding="utf-8")
|
||||
print("wrote", out, "statements", len(chunk))
|
||||
155
api/database/migrations/restore_mbti_test_results_1_145.sql
Normal file
155
api/database/migrations/restore_mbti_test_results_1_145.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -104,6 +104,7 @@ Route::group('api/v1/admin', function () {
|
||||
Route::post('upload/image', 'admin.Upload/image');
|
||||
|
||||
// 测试用户(小程序用户,只读列表与详情)
|
||||
Route::get('test-records/:id', 'admin.AppUser/testRecord');
|
||||
Route::get('app-users/:id', 'admin.AppUser/detail');
|
||||
Route::get('app-users', 'admin.AppUser/index');
|
||||
// 订单列表(含用户与关联测试数据)
|
||||
@@ -244,6 +245,7 @@ Route::group('api/v1/superadmin', function () {
|
||||
|
||||
// 测试用户(超管专用)
|
||||
Route::get('app-users/overview', 'superadmin.AppUser/overview');
|
||||
Route::get('test-records/:id', 'superadmin.AppUser/testRecord');
|
||||
Route::get('app-users/:id', 'superadmin.AppUser/detail');
|
||||
Route::get('app-users', 'superadmin.AppUser/index');
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ App({
|
||||
// 超管配置的默认企业 ID(无 scene/eid 等入口参数时回落)
|
||||
defaultEnterpriseId: null,
|
||||
// API基础地址(开发时用本地,生产环境替换为实际域名)
|
||||
//apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
apiBase: 'http://mbti.com',
|
||||
apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
//apiBase: 'http://mbti.com',
|
||||
// VIP信息
|
||||
vipInfo: null,
|
||||
// 测试次数
|
||||
|
||||
Reference in New Issue
Block a user