feat: 企业权限上限与迁移脚本;管理端/超管概览与财务;双端小程序 tab/订单/历史/测评选择等同步

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-30 17:12:59 +08:00
parent f589b5d50c
commit 3af7bb35dd
49 changed files with 2570 additions and 920 deletions

View File

@@ -2,9 +2,7 @@
<div class="dashboard-viewport" v-loading="loading"> <div class="dashboard-viewport" v-loading="loading">
<header class="dash-head"> <header class="dash-head">
<h1 class="dash-title">数据概览</h1> <h1 class="dash-title">数据概览</h1>
<p class="dash-tagline"> <p class="dash-tagline"></p>
企业侧测评闭环小程序面相与 MBTI / DISC / PDP 答题 MySQL 落库 本页看用户与测试趋势拉新邀请码全链路见开发文档小程序全链路功能与接口
</p>
</header> </header>
<div class="dash-kpis"> <div class="dash-kpis">

View File

@@ -1,5 +1,13 @@
<template> <template>
<div class="finance-page" :class="{ 'is-embedded': embedded }"> <div class="finance-page" :class="{ 'is-embedded': embedded }">
<!-- 嵌入系统设置企业余额时单独展示操作区独立页仍有完整 page-header -->
<div v-if="embedded" class="embedded-toolbar">
<div class="embedded-toolbar-actions">
<el-button @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
</div>
</div>
<div v-if="!embedded" class="page-header"> <div v-if="!embedded" class="page-header">
<div> <div>
<h2>企业余额</h2> <h2>企业余额</h2>
@@ -383,4 +391,17 @@ onMounted(() => {
padding: 0; padding: 0;
min-height: auto; min-height: auto;
} }
.embedded-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.embedded-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
</style> </style>

View File

@@ -22,6 +22,31 @@
</div> </div>
<div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }"> <div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }">
<div v-if="activeTab === 'features'" class="tab-content" v-loading="permLoading">
<div class="content-header">
<h3>终端功能开关</h3>
<p class="content-description">
仅可在超管为贵司授权的范围内调整若超管未开放某项如人脸分析此处不显示对应开关
</p>
</div>
<div class="form-section">
<template v-if="visibleAdminPermItems.length === 0">
<p class="hint-muted">当前无超管授权项可配置请联系平台管理员</p>
</template>
<template v-else>
<div class="perm-admin-row" v-for="p in visibleAdminPermItems" :key="p.key">
<span class="perm-admin-label">{{ p.label }}</span>
<el-switch v-model="adminPerms[p.key]" />
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAdminPermissions" :loading="permSaving">
保存功能开关
</el-button>
</div>
</template>
</div>
</div>
<div v-if="activeTab === 'account'" class="tab-content"> <div v-if="activeTab === 'account'" class="tab-content">
<div class="content-header"> <div class="content-header">
<h3>管理员账号设置</h3> <h3>管理员账号设置</h3>
@@ -82,14 +107,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { DocumentCopy } from '@element-plus/icons-vue' import { DocumentCopy } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import { getAdminRole } from '@/utils/authStorage'
import Finance from './Finance.vue' import Finance from './Finance.vue'
const TAB_IDS = ['account', 'finance'] as const const TAB_IDS = ['account', 'features', 'finance'] as const
type TabId = (typeof TAB_IDS)[number] type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId { function isTabId(s: string): s is TabId {
@@ -101,15 +127,25 @@ const router = useRouter()
const activeTab = ref<TabId>('account') const activeTab = ref<TabId>('account')
const loading = ref(false) const loading = ref(false)
const tabs: { label: string; value: TabId }[] = [ const isEnterpriseAdmin = () => getAdminRole() === 'enterprise_admin'
{ label: '账号设置', value: 'account' },
{ label: '企业余额', value: 'finance' } const tabs = computed(() => {
] const rows: { label: string; value: TabId }[] = [{ label: '账号设置', value: 'account' }]
if (isEnterpriseAdmin()) {
rows.push({ label: '功能开关', value: 'features' })
}
rows.push({ label: '企业余额', value: 'finance' })
return rows
})
const applyRouteTab = () => { const applyRouteTab = () => {
const t = route.query.tab const t = route.query.tab
if (typeof t === 'string' && isTabId(t)) { if (typeof t === 'string' && isTabId(t)) {
activeTab.value = t if (t === 'features' && !isEnterpriseAdmin()) {
activeTab.value = 'account'
} else {
activeTab.value = t
}
} else { } else {
activeTab.value = 'account' activeTab.value = 'account'
} }
@@ -136,6 +172,65 @@ const accountConfig = reactive({
confirmPassword: '' confirmPassword: ''
}) })
const permItems = [
{ key: 'face', label: '人脸分析' },
{ key: 'mbti', label: 'MBTI' },
{ key: 'pdp', label: 'PDP' },
{ key: 'disc', label: 'DISC' },
{ key: 'distribution', label: '分销推广' }
] as const
const defaultAdminPermissions = () =>
({ face: true, mbti: true, pdp: true, disc: true, distribution: true }) as Record<string, boolean>
const permLoading = ref(false)
const permSaving = ref(false)
const adminPermsCeiling = ref<Record<string, boolean>>(defaultAdminPermissions())
const adminPerms = reactive<Record<string, boolean>>(defaultAdminPermissions())
const visibleAdminPermItems = computed(() =>
permItems.filter((p) => adminPermsCeiling.value[p.key] !== false)
)
const loadAdminPermissions = async () => {
if (!isEnterpriseAdmin()) return
permLoading.value = true
try {
const res: any = await request.get('/admin/enterprise/permissions')
if (res.code === 200 && res.data) {
Object.assign(adminPerms, defaultAdminPermissions(), res.data.permissions || {})
adminPermsCeiling.value = { ...defaultAdminPermissions(), ...(res.data.permissionsCeiling || {}) }
}
} catch (e: any) {
console.error(e)
ElMessage.error(e?.message || '加载功能开关失败')
} finally {
permLoading.value = false
}
}
const saveAdminPermissions = async () => {
if (!isEnterpriseAdmin()) return
permSaving.value = true
try {
const body: Record<string, boolean> = { ...defaultAdminPermissions() }
for (const p of permItems) {
body[p.key] = adminPermsCeiling.value[p.key] === false ? false : !!adminPerms[p.key]
}
const res: any = await request.put('/admin/enterprise/permissions', { permissions: body })
if (res.code === 200) {
ElMessage.success('功能开关已保存')
await loadAdminPermissions()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
permSaving.value = false
}
}
const loadSettings = async () => { const loadSettings = async () => {
loading.value = true loading.value = true
try { try {
@@ -186,9 +281,21 @@ watch(
() => applyRouteTab() () => applyRouteTab()
) )
watch(
() => activeTab.value,
(tab) => {
if (tab === 'features' && isEnterpriseAdmin()) {
loadAdminPermissions()
}
}
)
onMounted(() => { onMounted(() => {
applyRouteTab() applyRouteTab()
loadSettings() loadSettings()
if (activeTab.value === 'features' && isEnterpriseAdmin()) {
loadAdminPermissions()
}
}) })
</script> </script>
@@ -217,6 +324,26 @@ onMounted(() => {
gap: 0; gap: 0;
} }
.hint-muted {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.perm-admin-row {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 360px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.perm-admin-label {
font-size: 14px;
color: #374151;
}
.custom-tabs-container { .custom-tabs-container {
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 4px; padding: 4px;

View File

@@ -106,9 +106,26 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" fixed="right"> <el-table-column label="超管授权" min-width="200">
<template #default="{ row }">
<div class="perm-tags">
<el-tag v-for="p in permItems" :key="p.key" size="small"
:type="permCeilingVal(row, p.key) !== false ? 'success' : 'info'"
:effect="permCeilingVal(row, p.key) !== false ? 'light' : 'plain'"
class="perm-tag"
>{{ p.label }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="176" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-tooltip content="邀请小程序码" placement="top">
<el-button link class="action-invite" @click="openInviteQrcodeDialog(row)">
<el-icon><Picture /></el-icon>
</el-button>
</el-tooltip>
<el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button> <el-button link @click="handleView(row)"><el-icon><View /></el-icon></el-button>
<el-button link @click="handleEdit(row)"><el-icon><Edit /></el-icon></el-button> <el-button link @click="handleEdit(row)"><el-icon><Edit /></el-icon></el-button>
<el-button link type="danger" @click="handleDelete(row)"><el-icon><Delete /></el-icon></el-button> <el-button link type="danger" @click="handleDelete(row)"><el-icon><Delete /></el-icon></el-button>
@@ -222,6 +239,16 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="功能授权(超管)" class="mt-12">
<p class="perm-form-hint">决定企业管理员能否在后台为终端用户打开该项关闭后管理端不会出现对应开关</p>
<div class="perm-switch-group">
<div class="perm-switch-item" v-for="p in permItems" :key="p.key">
<span class="perm-label">{{ p.label }}</span>
<el-switch v-model="newEnterprise.permissions[p.key]" />
</div>
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -294,6 +321,16 @@
class="w-full" class="w-full"
/> />
</el-form-item> </el-form-item>
<el-form-item label="功能授权(超管)">
<p class="perm-form-hint">保存后将收紧企业管理员可调范围对已关闭项终端与企业后台同步关闭</p>
<div class="perm-switch-group">
<div class="perm-switch-item" v-for="p in permItems" :key="p.key">
<span class="perm-label">{{ p.label }}</span>
<el-switch v-model="editEnterprise.permissions[p.key]" />
</div>
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -660,6 +697,54 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 邀请小程序码按当前行企业 ID -->
<el-dialog
v-model="showInviteQrcodeDialog"
width="440px"
class="custom-dialog invite-qrcode-dialog"
align-center
destroy-on-close
@closed="resetInviteQrcodeDialog"
>
<template #header>
<div class="dialog-header">
<h3 class="dialog-title">邀请小程序码</h3>
<p class="dialog-subtitle">
{{ inviteDialogEnterprise?.name || '企业' }} · 企业版进企业测评个人版进小程序首页
</p>
</div>
</template>
<div v-loading="inviteQrcodeLoading" class="invite-qrcode-dialog-body">
<div
v-if="inviteQrcodeEnterpriseB64 || inviteQrcodePersonalB64"
class="invite-qrcode-pair"
>
<div v-if="inviteQrcodeEnterpriseB64" class="invite-qrcode-card">
<span class="invite-qrcode-label">企业版</span>
<img :src="inviteQrcodeEnterpriseB64" alt="企业版太阳码" class="invite-qrcode-img" />
</div>
<div v-if="inviteQrcodePersonalB64" class="invite-qrcode-card">
<span class="invite-qrcode-label">个人版</span>
<img :src="inviteQrcodePersonalB64" alt="个人版太阳码" class="invite-qrcode-img" />
</div>
</div>
<p v-else class="invite-qrcode-placeholder">{{ inviteQrcodeError || '加载中…' }}</p>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showInviteQrcodeDialog = false" class="cancel-btn">关闭</el-button>
<el-button
type="primary"
color="#3b82f6"
:loading="inviteQrcodeLoading"
@click="loadInviteQrcodeForDialog"
>
刷新
</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -680,7 +765,8 @@ import {
DataAnalysis, DataAnalysis,
Document, Document,
Wallet, Wallet,
Postcard Postcard,
Picture
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/utils/request' import { request } from '@/utils/request'
@@ -715,6 +801,61 @@ const detailOrderPageSize = ref(10)
const enterpriseDetailTab = ref('overview') const enterpriseDetailTab = ref('overview')
const creating = ref(false) const creating = ref(false)
/** 邀请小程序码(企业列表操作列) */
const showInviteQrcodeDialog = ref(false)
const inviteDialogEnterprise = ref<{ id: number; name: string } | null>(null)
const inviteQrcodeLoading = ref(false)
const inviteQrcodeEnterpriseB64 = ref('')
const inviteQrcodePersonalB64 = ref('')
const inviteQrcodeError = ref('')
const resetInviteQrcodeDialog = () => {
inviteDialogEnterprise.value = null
inviteQrcodeEnterpriseB64.value = ''
inviteQrcodePersonalB64.value = ''
inviteQrcodeError.value = ''
}
const loadInviteQrcodeForDialog = async () => {
const ent = inviteDialogEnterprise.value
if (!ent?.id) return
inviteQrcodeLoading.value = true
inviteQrcodeError.value = ''
try {
const res: any = await request.get('/superadmin/invite/qrcode', {
params: { enterpriseId: ent.id }
})
const d = res?.data
const qEnt = d?.enterprise?.qrcode ?? d?.qrcode
const qPer = d?.personal?.qrcode
inviteQrcodeEnterpriseB64.value = typeof qEnt === 'string' && qEnt ? qEnt : ''
inviteQrcodePersonalB64.value = typeof qPer === 'string' && qPer ? qPer : ''
if (!inviteQrcodeEnterpriseB64.value && !inviteQrcodePersonalB64.value) {
inviteQrcodeError.value = res?.message || res?.msg || '生成失败,请检查小程序配置'
ElMessage.error(inviteQrcodeError.value)
}
} catch (e: any) {
inviteQrcodeError.value = e?.message || '加载失败'
ElMessage.error(inviteQrcodeError.value)
} finally {
inviteQrcodeLoading.value = false
}
}
const openInviteQrcodeDialog = (row: any) => {
inviteDialogEnterprise.value = {
id: Number(row.id),
name: row.name ? String(row.name) : `企业#${row.id}`
}
inviteQrcodeEnterpriseB64.value = ''
inviteQrcodePersonalB64.value = ''
inviteQrcodeError.value = ''
showInviteQrcodeDialog.value = true
nextTick(() => {
loadInviteQrcodeForDialog()
})
}
/** 企业侧栏头像首字 */ /** 企业侧栏头像首字 */
const enterpriseAvatarLetter = computed(() => { const enterpriseAvatarLetter = computed(() => {
const n = viewEnterpriseData.value?.name const n = viewEnterpriseData.value?.name
@@ -773,6 +914,25 @@ const statusOptions = [
{ label: '已停用', value: 'disabled' } { label: '已停用', value: 'disabled' }
] ]
const permItems = [
{ key: 'face', label: '人脸分析' },
{ key: 'mbti', label: 'MBTI' },
{ key: 'pdp', label: 'PDP' },
{ key: 'disc', label: 'DISC' },
{ key: 'distribution', label: '分销' },
]
const defaultPermissions = () => ({ face: true, mbti: true, pdp: true, disc: true, distribution: true })
/** 列表/展示:超管授权上限(兼容未返回 permissionsCeiling 的旧接口) */
const permCeilingVal = (row: Record<string, any>, key: string) => {
const c = row.permissionsCeiling
if (c && typeof c === 'object' && key in c) return c[key]
const p = row.permissions
if (p && typeof p === 'object' && key in p) return p[key]
return true
}
const enterprises = ref<any[]>([]) const enterprises = ref<any[]>([])
const newEnterprise = reactive({ const newEnterprise = reactive({
@@ -785,7 +945,8 @@ const newEnterprise = reactive({
contactPhone: '', contactPhone: '',
contactEmail: '', contactEmail: '',
status: 'operating', status: 'operating',
trialExpireAt: '' as string trialExpireAt: '' as string,
permissions: defaultPermissions() as Record<string, boolean>
}) })
const editEnterprise = reactive({ const editEnterprise = reactive({
@@ -795,7 +956,8 @@ const editEnterprise = reactive({
contactPhone: '', contactPhone: '',
contactEmail: '', contactEmail: '',
status: 'operating', status: 'operating',
trialExpireAt: '' trialExpireAt: '',
permissions: defaultPermissions() as Record<string, boolean>
}) })
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
@@ -925,7 +1087,8 @@ const handleCreateEnterprise = async () => {
contactName: newEnterprise.contactName, contactName: newEnterprise.contactName,
contactPhone: newEnterprise.contactPhone, contactPhone: newEnterprise.contactPhone,
contactEmail: newEnterprise.contactEmail, contactEmail: newEnterprise.contactEmail,
status: newEnterprise.status status: newEnterprise.status,
permissions: { ...newEnterprise.permissions }
}) })
if (response.code === 200) { if (response.code === 200) {
@@ -941,7 +1104,8 @@ const handleCreateEnterprise = async () => {
contactPhone: '', contactPhone: '',
contactEmail: '', contactEmail: '',
status: 'operating', status: 'operating',
trialExpireAt: '' trialExpireAt: '',
permissions: defaultPermissions()
}) })
loadEnterprises() loadEnterprises()
} }
@@ -1059,11 +1223,18 @@ const handleEditDialogOpen = async () => {
editEnterprise.contactEmail = '' editEnterprise.contactEmail = ''
editEnterprise.status = 'operating' editEnterprise.status = 'operating'
editEnterprise.trialExpireAt = '' editEnterprise.trialExpireAt = ''
editEnterprise.permissions = defaultPermissions()
// 等待一个tick确保清空完成 // 等待一个tick确保清空完成
await nextTick() await nextTick()
// 使用 Object.assign 一次性更新所有字段 // 使用 Object.assign 一次性更新所有字段
const ceilingFromApi =
data.permissionsCeiling && typeof data.permissionsCeiling === 'object'
? { ...defaultPermissions(), ...data.permissionsCeiling }
: data.permissions && typeof data.permissions === 'object'
? { ...defaultPermissions(), ...data.permissions }
: defaultPermissions()
Object.assign(editEnterprise, { Object.assign(editEnterprise, {
name: data.name || '', name: data.name || '',
code: data.code || '', code: data.code || '',
@@ -1071,7 +1242,8 @@ const handleEditDialogOpen = async () => {
contactPhone: data.contactPhone || '', contactPhone: data.contactPhone || '',
contactEmail: data.contactEmail || '', contactEmail: data.contactEmail || '',
status: data.status || 'operating', status: data.status || 'operating',
trialExpireAt: trialExpireAtStr trialExpireAt: trialExpireAtStr,
permissions: ceilingFromApi
}) })
console.log('表单数据已填充:', editEnterprise) console.log('表单数据已填充:', editEnterprise)
@@ -1101,7 +1273,8 @@ const handleSaveEdit = async () => {
contactName: editEnterprise.contactName, contactName: editEnterprise.contactName,
contactPhone: editEnterprise.contactPhone, contactPhone: editEnterprise.contactPhone,
contactEmail: editEnterprise.contactEmail, contactEmail: editEnterprise.contactEmail,
status: editEnterprise.status status: editEnterprise.status,
permissions: { ...editEnterprise.permissions }
} }
// 如果选择试用,添加到期时间(转换为时间戳) // 如果选择试用,添加到期时间(转换为时间戳)
@@ -1336,10 +1509,56 @@ watch([searchTerm, statusFilter], () => {
&.el-button--danger:hover { &.el-button--danger:hover {
color: #ef4444; color: #ef4444;
} }
&.action-invite:hover {
color: #3b82f6;
}
} }
} }
} }
.invite-qrcode-dialog-body {
min-height: 140px;
}
.invite-qrcode-pair {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
padding: 8px 0;
}
.invite-qrcode-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.invite-qrcode-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.invite-qrcode-img {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1px solid #e5e7eb;
object-fit: contain;
background: #fff;
}
.invite-qrcode-placeholder {
margin: 0;
font-size: 13px;
color: #9ca3af;
text-align: center;
padding: 24px 12px;
}
.empty-state { .empty-state {
padding: 60px; padding: 60px;
text-align: center; text-align: center;
@@ -1962,6 +2181,48 @@ watch([searchTerm, statusFilter], () => {
min-height: auto; min-height: auto;
} }
/* 权限标签 */
.perm-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.perm-tag {
font-size: 11px;
border-radius: 4px;
}
.perm-form-hint {
font-size: 12px;
color: #6b7280;
margin: 0 0 10px 0;
line-height: 1.45;
}
/* 权限开关组(编辑 / 创建弹窗) */
.perm-switch-group {
display: flex;
flex-wrap: wrap;
gap: 14px 24px;
}
.perm-switch-item {
display: flex;
align-items: center;
gap: 8px;
}
.perm-label {
font-size: 13px;
color: #374151;
white-space: nowrap;
}
.mt-12 {
margin-top: 12px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.ud-wrap { .ud-wrap {
flex-direction: column; flex-direction: column;

View File

@@ -133,6 +133,50 @@
</div> </div>
</div> </div>
<!-- 邀请小程序码与企业后台一致企业版 + 个人版 -->
<div class="invite-section">
<div class="section-header invite-header">
<div>
<h2 class="section-title">邀请小程序码</h2>
<p class="section-subtitle">企业版进企业测评个人版进小程序首页企业版太阳码对应下方所选企业</p>
</div>
<div class="invite-header-actions">
<el-select
v-model="inviteSelectedEnterpriseId"
placeholder="选择企业(生成企业版码)"
filterable
clearable
style="width: 220px"
size="small"
@change="loadInviteQrcode"
>
<el-option
v-for="opt in inviteEnterpriseOptions"
:key="opt.id"
:label="opt.name"
:value="opt.id"
/>
</el-select>
<el-button type="primary" size="small" :loading="inviteLoading" @click="loadInviteQrcode">
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
</el-button>
</div>
</div>
<div class="invite-body">
<template v-if="inviteQrcodeEnterprise || inviteQrcodePersonal">
<div v-if="inviteQrcodeEnterprise" class="invite-card">
<span class="invite-label">企业版</span>
<img :src="inviteQrcodeEnterprise" alt="企业版太阳码" class="invite-img" />
</div>
<div v-if="inviteQrcodePersonal" class="invite-card">
<span class="invite-label">个人版</span>
<img :src="inviteQrcodePersonal" alt="个人版太阳码" class="invite-img" />
</div>
</template>
<span v-else class="invite-placeholder">{{ inviteLoadError || '选择企业后点击生成,或未选企业时使用系统默认企业' }}</span>
</div>
</div>
<!-- 测试趋势折线图 --> <!-- 测试趋势折线图 -->
<div class="trend-section"> <div class="trend-section">
<div class="section-header"> <div class="section-header">
@@ -238,6 +282,7 @@ import {
Setting Setting
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { request } from '@/utils/request' import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts' import { LineChart } from 'echarts/charts'
@@ -272,10 +317,18 @@ const stats = reactive({
interface DynamicItem { icon: string; text: string; time: string } interface DynamicItem { icon: string; text: string; time: string }
interface RankingItem { name: string; tests: number; amount: number } interface RankingItem { name: string; tests: number; amount: number }
interface InviteEnterpriseOption { id: number; name: string }
const recentDynamics = ref<DynamicItem[]>([]) const recentDynamics = ref<DynamicItem[]>([])
const enterpriseRanking = ref<RankingItem[]>([]) const enterpriseRanking = ref<RankingItem[]>([])
const inviteLoading = ref(false)
const inviteQrcodeEnterprise = ref<string>('')
const inviteQrcodePersonal = ref<string>('')
const inviteLoadError = ref<string>('')
const inviteEnterpriseOptions = ref<InviteEnterpriseOption[]>([])
const inviteSelectedEnterpriseId = ref<number | null>(null)
type TrendPoint = { date: string; face: number; mbti: number; pdp: number; disc: number; total: number } type TrendPoint = { date: string; face: number; mbti: number; pdp: number; disc: number; total: number }
const testTrends = ref<TrendPoint[]>([]) const testTrends = ref<TrendPoint[]>([])
@@ -430,11 +483,76 @@ const handleViewAll = () => {
router.push('/superadmin/enterprises') router.push('/superadmin/enterprises')
} }
onMounted(() => { const loadInviteEnterprisesAndSettings = async () => {
try {
const [settingsRes, entRes]: any[] = await Promise.all([
request.get('/superadmin/settings'),
request.get('/superadmin/enterprises', { params: { page: 1, pageSize: 500 } })
])
const list = entRes?.data?.list ?? []
inviteEnterpriseOptions.value = list.map((r: any) => ({
id: Number(r.id),
name: r.name ? String(r.name) : `企业#${r.id}`
}))
const defRaw = settingsRes?.data?.system?.defaultEnterpriseId
const defNum =
defRaw != null && defRaw !== '' && !Number.isNaN(Number(defRaw)) ? Number(defRaw) : null
const idSet = new Set(inviteEnterpriseOptions.value.map(o => o.id))
if (defNum != null && defNum > 0 && idSet.has(defNum)) {
inviteSelectedEnterpriseId.value = defNum
} else if (inviteEnterpriseOptions.value.length > 0) {
inviteSelectedEnterpriseId.value = inviteEnterpriseOptions.value[0].id
} else {
inviteSelectedEnterpriseId.value = null
}
} catch (e) {
console.error('加载邀请码企业列表失败:', e)
inviteEnterpriseOptions.value = []
inviteSelectedEnterpriseId.value = null
}
}
const loadInviteQrcode = async () => {
if (inviteLoading.value) return
inviteLoading.value = true
inviteLoadError.value = ''
try {
const params: Record<string, number> = {}
if (inviteSelectedEnterpriseId.value != null && inviteSelectedEnterpriseId.value > 0) {
params.enterpriseId = inviteSelectedEnterpriseId.value
}
const res: any = await request.get('/superadmin/invite/qrcode', {
params: Object.keys(params).length ? params : undefined
})
const d = res?.data
const ent = d?.enterprise?.qrcode ?? d?.qrcode
const per = d?.personal?.qrcode
if (typeof ent === 'string' && ent) inviteQrcodeEnterprise.value = ent
else inviteQrcodeEnterprise.value = ''
if (typeof per === 'string' && per) inviteQrcodePersonal.value = per
else inviteQrcodePersonal.value = ''
if (!inviteQrcodeEnterprise.value && !inviteQrcodePersonal.value) {
const msg = res?.message || res?.msg || '生成失败,请确认小程序配置与企业'
inviteLoadError.value = msg
ElMessage.error(msg)
}
} catch (error: any) {
const msg = error?.message || '生成失败'
inviteLoadError.value = msg
ElMessage.error(msg)
} finally {
inviteLoading.value = false
}
}
onMounted(async () => {
loadOverview() loadOverview()
loadRecentDynamics() loadRecentDynamics()
loadEnterpriseRanking() loadEnterpriseRanking()
loadTestTrends() loadTestTrends()
await loadInviteEnterprisesAndSettings()
loadInviteQrcode()
}) })
</script> </script>
@@ -622,6 +740,68 @@ onMounted(() => {
} }
} }
.invite-section {
margin-bottom: 28px;
background: #fff;
border-radius: 10px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
.invite-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.invite-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.invite-body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
min-height: 112px;
padding: 8px 0;
}
.invite-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.invite-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.invite-img {
width: 112px;
height: 112px;
border-radius: 8px;
border: 1px solid #e5e7eb;
object-fit: contain;
background: #fff;
}
.invite-placeholder {
font-size: 12px;
color: #9ca3af;
}
}
.trend-section { .trend-section {
margin-bottom: 28px; margin-bottom: 28px;
background: #fff; background: #fff;

View File

@@ -45,46 +45,77 @@ class AppUser extends BaseController
$enterpriseId = $adminRow['enterpriseId'] ?? null; $enterpriseId = $adminRow['enterpriseId'] ?? null;
} }
// 若有企业ID先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表) $baseQuery = null;
$profileUserIds = [];
// 企业场景:禁止 column 全量 userId + 超大 whereIn改为「画像池子 + openid 去重」子查询 JOIN全在库内完成
if ($enterpriseId) { if ($enterpriseId) {
$profileUserIds = Db::name('user_profile') $eid = (int) $enterpriseId;
->where('enterpriseId', $enterpriseId) // 子查询会在 SQL 中出现两次,条件用字面量避免占位符绑定重复/错位的风险
->column('userId'); $poolSql = Db::name('user_profile')
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : []; ->whereRaw('enterpriseId = ' . $eid)
if (empty($profileUserIds)) { ->group('userId')
->field('userId')
->buildSql(true);
try {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->join([$poolSql => 'p2'], 'w2.id = p2.userId')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
} catch (\Throwable $e) {
return paginate_response([], 0, $page, $pageSize); return paginate_response([], 0, $page, $pageSize);
} }
$baseQuery = Db::name('wechat_users')->alias('w')
->join([$poolSql => 'p'], 'w.id = p.userId')
->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);
});
}
} else {
// 无企业归属(极少):沿用全表 openid 去重 + IN 列表
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
if ($where) {
$baseQuery->where($where);
}
} }
// 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重 if ($enterpriseId) {
try { $total = (int) (clone $baseQuery)->distinct(true)->count('w.id');
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid'); $list = (clone $baseQuery)
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : []; ->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')
} catch (\Throwable $e) { ->order('w.createdAt', 'desc')
$dedupIds = Db::name('wechat_users')->column('id'); ->page($page, $pageSize)
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : []; ->select()
->toArray();
} else {
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
} }
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
if (!empty($profileUserIds)) {
$baseQuery->whereIn('id', $profileUserIds);
}
if ($where) {
$baseQuery->where($where);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 为每条用户附加测试统计test_results.userId 对应 wechat_users.id // 为每条用户附加测试统计test_results.userId 对应 wechat_users.id
$ids = array_column($list, 'id'); $ids = array_column($list, 'id');
@@ -103,20 +134,49 @@ class AppUser extends BaseController
if ($enterpriseId) { if ($enterpriseId) {
$trBase->where('enterpriseId', $enterpriseId); $trBase->where('enterpriseId', $enterpriseId);
} }
$counts = (clone $trBase) // 次数与最近测试时间一次 GROUP 查完,减少往返
$trAggRows = (clone $trBase)
->field('userId, COUNT(*) AS cnt, MAX(createdAt) AS lastAt')
->group('userId') ->group('userId')
->column('COUNT(*) as cnt', 'userId'); ->select()
$testCounts = $counts ?: []; ->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) 消解同一时间戳多行),禁止 select 全量记录
try {
$aggSub = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$aggSub->where('enterpriseId', $enterpriseId);
}
$aggSql = $aggSub
->field('userId, testType, MAX(createdAt) as mc, MAX(id) as mid')
->group('userId, testType')
->buildSql(true);
$lastDetailQuery = 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');
if ($enterpriseId) {
$lastDetailQuery->where('t.enterpriseId', $enterpriseId);
}
$lastRows = $lastDetailQuery->select()->toArray();
} catch (\Throwable $e) {
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->limit(2000)
->select()
->toArray();
}
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->select();
foreach ($lastRows as $row) { foreach ($lastRows as $row) {
$uid = $row['userId']; $uid = $row['userId'];
if (!isset($lastTestAt[$uid])) {
$lastTestAt[$uid] = $row['createdAt'];
}
if (!isset($testTypes[$uid])) { if (!isset($testTypes[$uid])) {
$testTypes[$uid] = []; $testTypes[$uid] = [];
} }
@@ -127,6 +187,12 @@ class AppUser extends BaseController
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal', '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);
// 付款统计user_profile按当前企业过滤 // 付款统计user_profile按当前企业过滤
try { try {
$profilesQuery = Db::name('user_profile') $profilesQuery = Db::name('user_profile')

View File

@@ -32,9 +32,20 @@ class Dashboard extends BaseController
try { try {
// admin / enterprise_admin 均只统计本企业数据 // admin / enterprise_admin 均只统计本企业数据
$enterpriseId = $user['enterpriseId'] ?? null; $enterpriseId = $user['enterpriseId'] ?? null;
if (is_array($enterpriseId)) {
$enterpriseId = null;
}
$enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
$enterpriseId = null;
}
if (!$enterpriseId) { if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find(); $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null; $eid = $adminRow['enterpriseId'] ?? null;
$enterpriseId = ($eid !== null && $eid !== '') ? (int) $eid : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
$enterpriseId = null;
}
} }
// 企业用户 ID 集合(用于后续统计个人版测试) // 企业用户 ID 集合(用于后续统计个人版测试)

View File

@@ -0,0 +1,88 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Db;
use think\facade\Request;
/**
* 企业管理员在超管授权上限内配置各功能开关PUT 不可突破 permissionsCeiling
*/
class EnterprisePermissions extends BaseController
{
public function index()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'enterprise_admin') {
return error('仅企业管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
$enterprise = EnterpriseModel::find($eid);
if (!$enterprise) {
return error('企业不存在', 404);
}
$ceiling = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$effective = EnterpriseModel::normalizePermissionsValue($enterprise->permissions ?? null);
return success([
'permissions' => $effective,
'permissionsCeiling' => $ceiling,
]);
}
public function update()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'enterprise_admin') {
return error('仅企业管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
$enterprise = EnterpriseModel::find($eid);
if (!$enterprise) {
return error('企业不存在', 404);
}
$body = Request::put();
if (!is_array($body)) {
$body = [];
}
$incoming = $body['permissions'] ?? null;
if (!is_array($incoming)) {
return error('请提交 permissions 对象', 400);
}
$ceiling = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$effective = EnterpriseModel::clampPermissionsToCeiling($ceiling, $incoming);
$enterprise->permissions = $effective;
$enterprise->save();
return success([
'permissions' => $effective,
'permissionsCeiling' => $ceiling,
], '已保存');
}
private function resolveEnterpriseId(array $user): ?int
{
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$eid = $adminRow['enterpriseId'] ?? null;
if ($eid === null || $eid === '') {
return null;
}
return (int) $eid;
}
}

View File

@@ -19,14 +19,23 @@ class Invite extends BaseController
public function qrcode() public function qrcode()
{ {
$admin = $this->request->user ?? null; $admin = $this->request->user ?? null;
if (!$admin || !in_array($admin['role'] ?? '', ['admin', 'enterprise_admin'])) { $role = $admin['role'] ?? '';
if (!$admin || !in_array($role, ['admin', 'enterprise_admin', 'superadmin'], true)) {
return error('无权限访问', 403); return error('无权限访问', 403);
} }
$enterpriseId = null; $enterpriseId = null;
if (($admin['role'] ?? '') === 'enterprise_admin') { if ($role === 'enterprise_admin') {
$row = Db::name('users')->where('id', (int) ($admin['userId'] ?? 0))->find(); $row = Db::name('users')->where('id', (int) ($admin['userId'] ?? 0))->find();
$enterpriseId = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : null; $enterpriseId = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : null;
} elseif ($role === 'superadmin') {
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
if ($enterpriseId <= 0) {
$enterpriseId = self::resolveDefaultEnterpriseIdForPlatform();
}
if ($enterpriseId <= 0) {
return error('请传 enterpriseId或在系统设置中配置默认企业', 400);
}
} else { } else {
$enterpriseId = (int) $this->request->param('enterpriseId', 0); $enterpriseId = (int) $this->request->param('enterpriseId', 0);
if ($enterpriseId <= 0) { if ($enterpriseId <= 0) {
@@ -80,5 +89,46 @@ class Invite extends BaseController
], ],
]); ]);
} }
/**
* 超管生成企业版太阳码时:请求未带 enterpriseId 则按系统 defaultEnterpriseId → 首家运营企业
*/
private static function resolveDefaultEnterpriseIdForPlatform(): int
{
try {
$systemRow = Db::name('system_config')->where('key', 'system')->where('enterprise_id', 0)->find();
if ($systemRow && !empty($systemRow['value'])) {
$sysVal = is_string($systemRow['value']) ? json_decode($systemRow['value'], true) : $systemRow['value'];
if (is_array($sysVal) && !empty($sysVal['defaultEnterpriseId'])) {
$de = (int) $sysVal['defaultEnterpriseId'];
if ($de > 0) {
return $de;
}
}
}
} catch (\Throwable $e) {
// ignore
}
try {
$first = Db::name('enterprises')
->whereNull('deletedAt')
->where('status', 'operating')
->order('id', 'asc')
->value('id');
if ($first) {
return (int) $first;
}
} catch (\Throwable $e) {
// ignore
}
try {
$any = Db::name('enterprises')->whereNull('deletedAt')->order('id', 'asc')->value('id');
return $any ? (int) $any : 0;
} catch (\Throwable $e) {
return 0;
}
}
} }

View File

@@ -6,6 +6,38 @@ namespace app\controller\admin\concern;
*/ */
trait ExtractsTestResults trait ExtractsTestResults
{ {
/**
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
*/
private function coerceResultLabel($value): string
{
if ($value === null || $value === '') {
return '';
}
if (is_string($value)) {
return trim($value);
}
if (is_numeric($value)) {
return (string) $value;
}
if (is_array($value)) {
if (isset($value['type']) && is_string($value['type'])) {
return trim($value['type']);
}
if (isset($value['primary']) && is_string($value['primary'])) {
return trim($value['primary']);
}
foreach (['name', 'label', 'code'] as $k) {
if (isset($value[$k]) && is_string($value[$k]) && $value[$k] !== '') {
return trim($value[$k]);
}
}
return '';
}
return '';
}
private function extractResultType(array $tests, string $type): string private function extractResultType(array $tests, string $type): string
{ {
$targetType = strtolower($type); $targetType = strtolower($type);
@@ -27,7 +59,13 @@ trait ExtractsTestResults
} }
if ($targetType === 'mbti') { if ($targetType === 'mbti') {
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? ''); foreach (['mbtiType', 'type', 'result'] as $k) {
$s = $this->coerceResultLabel($dec[$k] ?? null);
if ($s !== '') {
return $s;
}
}
return '';
} }
if ($targetType === 'disc') { if ($targetType === 'disc') {
@@ -35,10 +73,11 @@ trait ExtractsTestResults
if (is_string($desc) && $desc !== '') { if (is_string($desc) && $desc !== '') {
return $desc; return $desc;
} }
if (!empty($dec['dominantType'])) { $dom = $this->coerceResultLabel($dec['dominantType'] ?? null);
return (string) $dec['dominantType']; if ($dom !== '') {
return $dom;
} }
return (string) ($dec['disc'] ?? ''); return $this->coerceResultLabel($dec['disc'] ?? null);
} }
if ($targetType === 'pdp') { if ($targetType === 'pdp') {
@@ -46,13 +85,18 @@ trait ExtractsTestResults
if (is_string($desc) && $desc !== '') { if (is_string($desc) && $desc !== '') {
return $desc; return $desc;
} }
if (!empty($dec['dominantType'])) { $dom = $this->coerceResultLabel($dec['dominantType'] ?? null);
return (string) $dec['dominantType']; if ($dom !== '') {
return $dom;
} }
return (string) ($dec['pdp'] ?? ''); return $this->coerceResultLabel($dec['pdp'] ?? null);
} }
return (string) ($dec['type'] ?? $dec['result'] ?? ''); $fallback = $this->coerceResultLabel($dec['type'] ?? null);
if ($fallback !== '') {
return $fallback;
}
return $this->coerceResultLabel($dec['result'] ?? null);
} }
return ''; return '';
} }
@@ -82,17 +126,25 @@ trait ExtractsTestResults
} }
} elseif ($target === 'disc') { } elseif ($target === 'disc') {
if (!empty($dec['disc']['primary'])) { if (!empty($dec['disc']['primary'])) {
return (string) $dec['disc']['primary']; return $this->coerceResultLabel($dec['disc']['primary']);
} }
if (!empty($dec['disc'])) { $d = $dec['disc'] ?? null;
return (string) $dec['disc']; if (is_array($d)) {
$s = $this->coerceResultLabel($d);
if ($s !== '') {
return $s;
}
} }
} elseif ($target === 'pdp') { } elseif ($target === 'pdp') {
if (!empty($dec['pdp']['primary'])) { if (!empty($dec['pdp']['primary'])) {
return (string) $dec['pdp']['primary']; return $this->coerceResultLabel($dec['pdp']['primary']);
} }
if (!empty($dec['pdp'])) { $p = $dec['pdp'] ?? null;
return (string) $dec['pdp']; if (is_array($p)) {
$s = $this->coerceResultLabel($p);
if ($s !== '') {
return $s;
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@
namespace app\controller\api; namespace app\controller\api;
use app\BaseController; use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel; use app\model\PricingConfig as PricingConfigModel;
use app\model\AiProvider as AiProviderModel; use app\model\AiProvider as AiProviderModel;
use app\common\service\JwtService; use app\common\service\JwtService;
@@ -39,16 +40,22 @@ class AppConfig extends BaseController
} }
} }
} }
if ($scope !== 'personal' && $user && ($user['source'] ?? '') === 'wechat') { // 绑定企业 ID任意 scope 都要能读到(个人首页也是 scope=personal否则 enterprisePermissions 永远为 null
$userBoundEnterpriseId = null;
if ($user && ($user['source'] ?? '') === 'wechat') {
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId > 0) { if ($userId > 0) {
$row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find(); $row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
if (!empty($row['enterpriseId'])) { if (!empty($row['enterpriseId'])) {
$enterpriseId = (int) $row['enterpriseId']; $userBoundEnterpriseId = (int) $row['enterpriseId'];
$pricingType = 'enterprise';
} }
} }
} }
// 定价/文案用的 enterpriseId仅在非个人 scope 时走企业定价(与个人页 scope=personal 仍可返回企业权限)
if ($scope !== 'personal' && $userBoundEnterpriseId !== null && $userBoundEnterpriseId > 0) {
$enterpriseId = $userBoundEnterpriseId;
$pricingType = 'enterprise';
}
// 系统配置:审核模式、默认企业(无带参入口时小程序回落) // 系统配置:审核模式、默认企业(无带参入口时小程序回落)
$maintenanceMode = false; $maintenanceMode = false;
@@ -161,6 +168,20 @@ class AppConfig extends BaseController
// 与 maintenanceMode 同源:小程序旧逻辑读 reviewMode // 与 maintenanceMode 同源:小程序旧逻辑读 reviewMode
$reviewMode = $maintenanceMode; $reviewMode = $maintenanceMode;
// 企业功能权限:与定价 scope 解耦;库中 permissions 为空/异常时仍返回默认值,避免前端判空异常
$eidForPermissions = ($enterpriseId > 0) ? $enterpriseId : (($userBoundEnterpriseId ?? 0) > 0 ? $userBoundEnterpriseId : null);
$enterprisePermissions = null;
if ($eidForPermissions > 0) {
try {
$ent = Db::name('enterprises')->where('id', $eidForPermissions)->field('permissions')->find();
if (is_array($ent)) {
$enterprisePermissions = EnterpriseModel::normalizePermissionsValue($ent['permissions'] ?? null);
}
} catch (\Throwable $e) {
$enterprisePermissions = null;
}
}
return success([ return success([
'pricingType' => $pricingType, 'pricingType' => $pricingType,
'pricing' => $pricing, 'pricing' => $pricing,
@@ -174,6 +195,7 @@ class AppConfig extends BaseController
'maintenanceMode' => $maintenanceMode, 'maintenanceMode' => $maintenanceMode,
'reviewMode' => $reviewMode, 'reviewMode' => $reviewMode,
'defaultEnterpriseId' => $defaultEnterpriseId, 'defaultEnterpriseId' => $defaultEnterpriseId,
'enterprisePermissions' => $enterprisePermissions,
]); ]);
} }

View File

@@ -2,6 +2,7 @@
namespace app\controller\api; namespace app\controller\api;
use app\BaseController; use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel; use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel; use app\model\UserProfile as UserProfileModel;
use think\facade\Db; use think\facade\Db;
@@ -28,24 +29,45 @@ class Test extends BaseController
return error('未登录', 401); return error('未登录', 401);
} }
$type = Request::param('type', 'all'); // all|mbti|disc|pdp|face $type = Request::param('type', 'all'); // all|mbti|disc|pdp|face|ai|resume
$scope = Request::param('scope', 'all'); // all|personal|enterprise $scope = Request::param('scope', 'all'); // all|personal|enterprise
$page = max(1, (int) Request::param('page', 1)); $page = max(1, (int) Request::param('page', 1));
$pageSize = (int) Request::param('pageSize', 0); $pageSize = (int) Request::param('pageSize', 0);
if ($pageSize <= 0) { if ($pageSize <= 0) {
$pageSize = 500; $pageSize = 10;
} }
$pageSize = min(500, max(1, $pageSize)); $pageSize = min(100, max(1, $pageSize));
$allowedTestTypes = $this->wechatAllowedTestTypes($userId);
// 列表只展示摘要:禁止 tr.* 拉全表 resultData单条可达数 MB×500=卡死);仅截取 JSON 前若干字符用于解析标题
$listJsonSliceLen = 32768;
$base = Db::name('test_results') $base = Db::name('test_results')
->alias('tr') ->alias('tr')
->leftJoin('wechat_users wu', 'tr.userId = wu.id') ->leftJoin('wechat_users wu', 'tr.userId = wu.id')
->leftJoin('enterprises e_tr', 'tr.enterpriseId = e_tr.id') ->leftJoin('enterprises e_tr', 'tr.enterpriseId = e_tr.id')
->leftJoin('enterprises e_wu', 'wu.enterpriseId = e_wu.id') ->leftJoin('enterprises e_wu', 'wu.enterpriseId = e_wu.id')
->where('tr.userId', $userId) ->where('tr.userId', $userId)
->field('tr.*, e_tr.name as enterpriseName, wu.enterpriseId as bindEnterpriseId, e_wu.name as bindEnterpriseName') ->field(
'tr.id,tr.testType,tr.createdAt,tr.enterpriseId,tr.requiresPayment,tr.isPaid,tr.orderId,tr.paidAmount,' .
'e_tr.name as enterpriseName,wu.enterpriseId as bindEnterpriseId,e_wu.name as bindEnterpriseName,' .
'SUBSTRING(COALESCE(CAST(tr.resultData AS CHAR(65532)),\'\'),1,' . $listJsonSliceLen . ') as resultDataLite'
)
->order('tr.createdAt', 'desc'); ->order('tr.createdAt', 'desc');
$profileIncomplete = !self::isWechatProfileComplete($userId);
if ($allowedTestTypes === []) {
return success([
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'hasMore' => false,
]);
}
$base->whereIn('tr.testType', $allowedTestTypes);
if ($type !== 'all') { if ($type !== 'all') {
if (in_array($type, ['face', 'ai'], true)) { if (in_array($type, ['face', 'ai'], true)) {
$base->whereIn('tr.testType', ['face', 'ai']); $base->whereIn('tr.testType', ['face', 'ai']);
@@ -82,9 +104,8 @@ class Test extends BaseController
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null; $orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0; $paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0; $needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
$profileIncomplete = !self::isWechatProfileComplete($userId);
$raw = $row['resultData'] ?? ($row['result'] ?? null); $raw = $row['resultDataLite'] ?? ($row['resultData'] ?? null);
$data = null; $data = null;
if ($raw !== null && $raw !== '') { if ($raw !== null && $raw !== '') {
$decoded = json_decode($raw, true); $decoded = json_decode($raw, true);
@@ -107,37 +128,37 @@ class Test extends BaseController
'enterpriseName' => $enterpriseName, 'enterpriseName' => $enterpriseName,
]; ];
// 映射为小程序 history 页需要的结构 // 映射为小程序 history 页列表结构;完整报告走 GET /api/test/detail?id= 减轻响应体积
switch ($testType) { switch ($testType) {
case 'mbti': case 'mbti':
$mbtiType = $data['mbtiType'] ?? $data['mbti'] ?? '未知'; $mbtiType = is_array($data) ? ($data['mbtiType'] ?? $data['mbti'] ?? '未知') : '未知';
$list[] = array_merge([ $list[] = array_merge([
'id' => $id, 'id' => $id,
'type' => 'mbti', 'type' => 'mbti',
'key' => 'mbti_' . $id, 'key' => 'mbti_' . $id,
'emoji' => '🧠', 'emoji' => '🧠',
'typeName' => 'MBTI性格测试', 'typeName' => 'MBTI性格测试',
'resultText'=> $mbtiType, 'resultText'=> is_string($mbtiType) || is_numeric($mbtiType) ? (string) $mbtiType : '未知',
'testTime' => $timeLabel, 'testTime' => $timeLabel,
'data' => $data, 'data' => null,
], $paymentFields); ], $paymentFields);
break; break;
case 'disc': case 'disc':
$discType = $data['dominantType'] ?? $data['disc'] ?? '未知'; $discType = is_array($data) ? ($data['dominantType'] ?? $data['disc'] ?? '未知') : '未知';
$list[] = array_merge([ $list[] = array_merge([
'id' => $id, 'id' => $id,
'type' => 'disc', 'type' => 'disc',
'key' => 'disc_' . $id, 'key' => 'disc_' . $id,
'emoji' => '📊', 'emoji' => '📊',
'typeName' => 'DISC性格测试', 'typeName' => 'DISC性格测试',
'resultText'=> $discType . '型', 'resultText'=> (is_string($discType) || is_numeric($discType) ? (string) $discType : '未知') . '型',
'testTime' => $timeLabel, 'testTime' => $timeLabel,
'data' => $data, 'data' => null,
], $paymentFields); ], $paymentFields);
break; break;
case 'pdp': case 'pdp':
$primary = $data['description']['type'] ?? $data['pdp'] ?? '未知'; $primary = is_array($data) ? ($data['description']['type'] ?? $data['pdp'] ?? '未知') : '未知';
$emoji = $data['description']['emoji'] ?? '🦁'; $emoji = (is_array($data) && isset($data['description']['emoji'])) ? $data['description']['emoji'] : '🦁';
$list[] = array_merge([ $list[] = array_merge([
'id' => $id, 'id' => $id,
'type' => 'pdp', 'type' => 'pdp',
@@ -146,7 +167,7 @@ class Test extends BaseController
'typeName' => 'PDP行为偏好测试', 'typeName' => 'PDP行为偏好测试',
'resultText'=> $primary, 'resultText'=> $primary,
'testTime' => $timeLabel, 'testTime' => $timeLabel,
'data' => $data, 'data' => null,
], $paymentFields); ], $paymentFields);
break; break;
case 'face': case 'face':
@@ -167,7 +188,7 @@ class Test extends BaseController
'typeName' => '面相分析', 'typeName' => '面相分析',
'resultText'=> $mbtiShort ?: '未知', 'resultText'=> $mbtiShort ?: '未知',
'testTime' => $timeLabel, 'testTime' => $timeLabel,
'data' => $data, 'data' => null,
], $paymentFields); ], $paymentFields);
break; break;
case 'resume': case 'resume':
@@ -186,7 +207,7 @@ class Test extends BaseController
'typeName' => '简历综合分析', 'typeName' => '简历综合分析',
'resultText'=> $summary ?: '简历综合分析', 'resultText'=> $summary ?: '简历综合分析',
'testTime' => $timeLabel, 'testTime' => $timeLabel,
'data' => $data, 'data' => null,
], $paymentFields); ], $paymentFields);
break; break;
default: default:
@@ -222,20 +243,28 @@ class Test extends BaseController
$scope = Request::param('scope', 'all'); // all|personal|enterprise $scope = Request::param('scope', 'all'); // all|personal|enterprise
$allowedAll = $this->wechatAllowedTestTypes($userId);
// 「我的」卡片不含简历,但 totalCount 与列表需与 history 权限一致
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'pdp', 'disc', 'face', 'ai']));
if ($allowedForRecent === []) {
return success([
'records' => new \stdClass(),
'totalCount' => 0,
]);
}
$records = []; $records = [];
// 优化:一次性查询所有需要的最新记录,减少数据库连接和查询次数
$query = Db::name('test_results') $query = Db::name('test_results')
->where('userId', $userId); ->where('userId', $userId)
->whereIn('testType', $allowedForRecent);
if ($scope === 'personal') { if ($scope === 'personal') {
$query->whereNull('enterpriseId'); $query->whereNull('enterpriseId');
} elseif ($scope === 'enterprise') { } elseif ($scope === 'enterprise') {
$query->whereNotNull('enterpriseId'); $query->whereNotNull('enterpriseId');
} }
// 使用子查询或 Union 可能更复杂,这里采用分组取最新的优化思路
// 但 ThinkPHP 中最简单有效的优化是先查出所有类型,再处理
$allRows = $query->order('createdAt', 'desc')->select()->toArray(); $allRows = $query->order('createdAt', 'desc')->select()->toArray();
$foundTypes = []; $foundTypes = [];
@@ -264,6 +293,50 @@ class Test extends BaseController
]); ]);
} }
/**
* 微信用户当前允许的 test_results.testType绑定企业 permissions + 审核模式;简历仅绑定企业可见)
* @return string[]
*/
protected function wechatAllowedTestTypes(int $userId): array
{
$bindRow = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
$boundEnterpriseId = (int) ($bindRow['enterpriseId'] ?? 0);
$enterprisePerms = EnterpriseModel::permissionDefaults();
if ($boundEnterpriseId > 0) {
$entPermRow = Db::name('enterprises')->where('id', $boundEnterpriseId)->field('permissions')->find();
if (is_array($entPermRow)) {
$enterprisePerms = EnterpriseModel::normalizePermissionsValue($entPermRow['permissions'] ?? null);
}
}
$reviewMode = false;
$systemRow = Db::name('system_config')->where('key', 'system')->find();
if ($systemRow && !empty($systemRow['value'])) {
$sysVal = is_string($systemRow['value']) ? json_decode($systemRow['value'], true) : $systemRow['value'];
if (is_array($sysVal) && !empty($sysVal['maintenanceMode'])) {
$reviewMode = true;
}
}
$allowed = [];
if (!empty($enterprisePerms['mbti'])) {
$allowed[] = 'mbti';
}
if (!empty($enterprisePerms['pdp'])) {
$allowed[] = 'pdp';
}
if (!empty($enterprisePerms['disc'])) {
$allowed[] = 'disc';
}
if (!empty($enterprisePerms['face']) && !$reviewMode) {
$allowed[] = 'face';
$allowed[] = 'ai';
}
if ($boundEnterpriseId > 0) {
$allowed[] = 'resume';
}
return array_values(array_unique($allowed));
}
/** /**
* 格式化单条记录为 recent 接口返回结构 * 格式化单条记录为 recent 接口返回结构
*/ */

View File

@@ -16,6 +16,11 @@ class Enterprise extends BaseController
* 获取企业列表 * 获取企业列表
* @return \think\response\Json * @return \think\response\Json
*/ */
private function normalizePerms($raw): array
{
return EnterpriseModel::normalizePermissionsValue($raw);
}
public function index() public function index()
{ {
// 验证是否为超级管理员 // 验证是否为超级管理员
@@ -48,6 +53,12 @@ class Enterprise extends BaseController
->select() ->select()
->toArray(); ->toArray();
foreach ($list as &$item) {
$item['permissions'] = $this->normalizePerms($item['permissions'] ?? null);
$item['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($item);
}
unset($item);
// 统计每个企业的用户数和测试用量 // 统计每个企业的用户数和测试用量
foreach ($list as &$item) { foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户) // 统计用户数(只统计未删除的用户)
@@ -121,15 +132,16 @@ class Enterprise extends BaseController
$orderPageSize = min(100, max(1, (int) Request::param('orderPageSize', 10))); $orderPageSize = min(100, max(1, (int) Request::param('orderPageSize', 10)));
$data = $enterprise->toArray(); $data = $enterprise->toArray();
$data['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
// 获取企业下的所有用户ID只统计未删除的用户 $data['permissions'] = $this->normalizePerms($data['permissions'] ?? null);
$userIds = Db::name('users')
->where('enterpriseId', $id) $eid = (int) $id;
// 统计后台账号用户数(避免 column 全量 id + 巨大 whereIn
$data['userCount'] = (int) Db::name('users')
->where('enterpriseId', $eid)
->where('deletedAt', null) ->where('deletedAt', null)
->column('id'); ->count();
// 统计用户数
$data['userCount'] = count($userIds);
// 获取管理员账号列表(企业管理员角色,只获取未删除的) // 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users') $adminAccounts = Db::name('users')
@@ -152,42 +164,45 @@ class Enterprise extends BaseController
->toArray(); ->toArray();
$data['users'] = $users; $data['users'] = $users;
// 获取测试结果列表 // 测试结果(后台 users 归属JOIN 企业,禁止 whereIn 海量 userId
$testResults = []; $testResults = [];
if (!empty($userIds)) { try {
$testResults = Db::name('test_results') if ($data['userCount'] > 0) {
->alias('tr') $testResults = Db::name('test_results')
->leftJoin('users u', 'tr.userId = u.id') ->alias('tr')
->where('tr.userId', 'in', $userIds) ->join('users u', 'tr.userId = u.id')
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username') ->where('u.enterpriseId', $eid)
->order('tr.createdAt', 'desc') ->where('u.deletedAt', null)
->limit(50) // 限制返回数量 ->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username')
->select() ->order('tr.createdAt', 'desc')
->toArray(); ->limit(50)
->select()
->toArray();
}
} catch (\Throwable $e) {
$testResults = [];
} }
$data['testResults'] = $testResults; $data['testResults'] = $testResults;
$this->attachResultSummaries($data['testResults']); $this->attachResultSummaries($data['testResults']);
// 统计测试用量 try {
if (!empty($userIds)) { $data['testUsage'] = $data['userCount'] > 0
$data['testUsage'] = Db::name('test_results') ? (int) Db::name('test_results')
->where('userId', 'in', $userIds) ->alias('tr')
->count(); ->join('users u', 'tr.userId = u.id')
} else { ->where('u.enterpriseId', $eid)
->where('u.deletedAt', null)
->count()
: 0;
} catch (\Throwable $e) {
$data['testUsage'] = 0; $data['testUsage'] = 0;
} }
// —— 小程序侧用户wechat_users.enterpriseId—— // —— 小程序侧用户wechat_users.enterpriseId——
$wechatIds = []; // 禁止加载全量 wechat user id 到 PHP 再 IN(...) 查 analytics_events大数据必超时
try {
$wechatIds = Db::name('wechat_users')->where('enterpriseId', $id)->column('id');
$wechatIds = array_values(array_filter($wechatIds));
} catch (\Throwable $e) {
$wechatIds = [];
}
$wechatUserTotalCount = 0; $wechatUserTotalCount = 0;
try { try {
$wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $id)->count(); $wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $eid)->count();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$wechatUserTotalCount = 0; $wechatUserTotalCount = 0;
} }
@@ -196,7 +211,7 @@ class Enterprise extends BaseController
$data['wechatUsers'] = []; $data['wechatUsers'] = [];
try { try {
$data['wechatUsers'] = Db::name('wechat_users') $data['wechatUsers'] = Db::name('wechat_users')
->where('enterpriseId', $id) ->where('enterpriseId', $eid)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt') ->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc') ->order('createdAt', 'desc')
->page($wechatPage, $wechatPageSize) ->page($wechatPage, $wechatPageSize)
@@ -210,11 +225,11 @@ class Enterprise extends BaseController
$data['miniprogramTestResults'] = []; $data['miniprogramTestResults'] = [];
$data['miniprogramTestResultsTotal'] = 0; $data['miniprogramTestResultsTotal'] = 0;
try { try {
$data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $id)->count(); $data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $eid)->count();
$data['miniprogramTestResults'] = Db::name('test_results') $data['miniprogramTestResults'] = Db::name('test_results')
->alias('tr') ->alias('tr')
->leftJoin('wechat_users w', 'tr.userId = w.id') ->leftJoin('wechat_users w', 'tr.userId = w.id')
->where('tr.enterpriseId', $id) ->where('tr.enterpriseId', $eid)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,w.nickname as wechatNickname') ->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,w.nickname as wechatNickname')
->order('tr.createdAt', 'desc') ->order('tr.createdAt', 'desc')
->page($testPage, $testPageSize) ->page($testPage, $testPageSize)
@@ -224,17 +239,17 @@ class Enterprise extends BaseController
} catch (\Throwable $e) { } catch (\Throwable $e) {
$data['miniprogramTestResults'] = []; $data['miniprogramTestResults'] = [];
} }
// 订单与消耗(金额分) // 订单与消耗(金额分)
$paidStatuses = ['paid', 'completed']; $paidStatuses = ['paid', 'completed'];
try { try {
$data['orderStats'] = [ $data['orderStats'] = [
'totalCount' => (int) Db::name('orders')->where('enterpriseId', $id)->count(), 'totalCount' => (int) Db::name('orders')->where('enterpriseId', $eid)->count(),
'paidCount' => (int) Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->count(), 'paidCount' => (int) Db::name('orders')->where('enterpriseId', $eid)->whereIn('status', $paidStatuses)->count(),
'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->sum('amount') ?? 0), 'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $eid)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
]; ];
$data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $id)->count(); $data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $eid)->count();
$data['recentOrders'] = Db::name('orders') $data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id) ->where('enterpriseId', $eid)
->order('createdAt', 'desc') ->order('createdAt', 'desc')
->page($orderPage, $orderPageSize) ->page($orderPage, $orderPageSize)
->field('id,orderNo,status,amount,productType,userId,createdAt') ->field('id,orderNo,status,amount,productType,userId,createdAt')
@@ -258,23 +273,27 @@ class Enterprise extends BaseController
'hint' => null, 'hint' => null,
'windowDays' => 30, 'windowDays' => 30,
]; ];
if (empty($wechatIds)) { if ($wechatUserTotalCount <= 0) {
$data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点'; $data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点';
} else { } else {
try { try {
$wechatIdSub = Db::name('wechat_users')
->whereRaw('enterpriseId = ' . $eid)
->field('id')
->buildSql(true);
$since = date('Y-m-d H:i:s', time() - 30 * 86400); $since = date('Y-m-d H:i:s', time() - 30 * 86400);
$data['analyticsStats']['eventTotal'] = (int) Db::name('analytics_events') $data['analyticsStats']['eventTotal'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since) ->where('createdAt', '>=', $since)
->whereRaw('userId IN ' . $wechatIdSub)
->count(); ->count();
$data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events') $data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since) ->where('createdAt', '>=', $since)
->where('eventName', 'page_view') ->where('eventName', 'page_view')
->whereRaw('userId IN ' . $wechatIdSub)
->count(); ->count();
$byEvent = Db::name('analytics_events') $byEvent = Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since) ->where('createdAt', '>=', $since)
->whereRaw('userId IN ' . $wechatIdSub)
->field('eventName, COUNT(*) AS cnt') ->field('eventName, COUNT(*) AS cnt')
->group('eventName') ->group('eventName')
->order('cnt', 'desc') ->order('cnt', 'desc')
@@ -376,6 +395,13 @@ class Enterprise extends BaseController
$enterprise->balance = $data['balance'] ?? 0.00; $enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status; $enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null; $enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
if (isset($data['permissions']) && is_array($data['permissions'])) {
$norm = $this->normalizePerms($data['permissions']);
} else {
$norm = EnterpriseModel::permissionDefaults();
}
$enterprise->permissions = $norm;
$enterprise->permissionsCeiling = $norm;
$enterprise->save(); $enterprise->save();
$enterpriseId = $enterprise->id; $enterpriseId = $enterprise->id;
@@ -460,6 +486,14 @@ class Enterprise extends BaseController
$enterprise->trialExpireAt = null; $enterprise->trialExpireAt = null;
} }
if (isset($data['permissions']) && is_array($data['permissions'])) {
$newCeiling = $this->normalizePerms($data['permissions']);
$oldEff = EnterpriseModel::normalizePermissionsValue($enterprise->permissions ?? null);
$merged = EnterpriseModel::clampEffectiveToNewCeiling($newCeiling, $oldEff);
$enterprise->permissions = $merged;
$enterprise->permissionsCeiling = $newCeiling;
unset($data['permissions']);
}
$enterprise->save($data); $enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0); $newBalance = (float) ($enterprise->balance ?? 0);
@@ -472,7 +506,9 @@ class Enterprise extends BaseController
} }
$enterpriseData = $enterprise->toArray(); $enterpriseData = $enterprise->toArray();
$enterpriseData['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$enterpriseData['permissions'] = $this->normalizePerms($enterpriseData['permissions'] ?? null);
// 统计用户数和测试用量(只统计未删除的用户) // 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users') $enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id) ->where('enterpriseId', $id)

View File

@@ -1,51 +1,166 @@
<?php <?php
namespace app\model; namespace app\model;
use think\Model; use think\Model;
use think\model\concern\SoftDelete; use think\model\concern\SoftDelete;
/** /**
* 企业模型 * 企业模型
*/ */
class Enterprise extends Model class Enterprise extends Model
{ {
use SoftDelete; use SoftDelete;
// 设置表名(不带前缀,前缀在数据库配置中设置) // 设置表名(不带前缀,前缀在数据库配置中设置)
protected $name = 'enterprises'; protected $name = 'enterprises';
// 软删除字段(驼峰命名,时间戳格式) // 软删除字段(驼峰命名,时间戳格式)
protected $deleteTime = 'deletedAt'; protected $deleteTime = 'deletedAt';
// 设置字段信息(匹配数据库字段命名:驼峰命名) // 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [ protected $schema = [
'id' => 'int', 'id' => 'int',
'name' => 'string', 'name' => 'string',
'code' => 'string', 'code' => 'string',
'contactName' => 'string', 'contactName' => 'string',
'contactPhone' => 'string', 'contactPhone' => 'string',
'contactEmail' => 'string', 'contactEmail' => 'string',
'balance' => 'float', 'balance' => 'float',
'status' => 'string', 'status' => 'string',
'trialExpireAt' => 'int', 'permissions' => 'json',
'deletedAt' => 'int', 'permissionsCeiling' => 'json',
'createdAt' => 'int', 'trialExpireAt' => 'int',
'updatedAt' => 'int', 'deletedAt' => 'int',
]; 'createdAt' => 'int',
'updatedAt' => 'int',
// 自动时间戳(使用驼峰命名,时间戳格式) ];
protected $autoWriteTimestamp = 'int';
// 自动时间戳(使用驼峰命名,时间戳格式)
// 时间戳字段名(驼峰命名,匹配数据库) protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt'; // 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
// 时间字段类型(时间戳格式) protected $updateTime = 'updatedAt';
protected $type = [
'trialExpireAt' => 'integer', // 时间字段类型(时间戳格式)
'deletedAt' => 'integer', protected $type = [
'createdAt' => 'integer', 'permissions' => 'json',
'updatedAt' => 'integer', 'permissionsCeiling' => 'json',
]; 'trialExpireAt' => 'integer',
} 'deletedAt' => 'integer',
'createdAt' => 'integer',
'updatedAt' => 'integer',
];
/** 权限键默认值(库中为 null / 空 / 缺列 / 解析失败时一律回落至此) */
public static function permissionDefaults(): array
{
return [
'face' => true,
'mbti' => true,
'pdp' => true,
'disc' => true,
'distribution' => true,
];
}
/**
* 将库中任意形态的 permissions 规范化为固定键数组(兼容 null、''、{}、[]、非法 JSON、双重 JSON 字符串)
* @param mixed $value 原始列值
*/
public static function normalizePermissionsValue($value): array
{
$defaults = self::permissionDefaults();
if ($value === null) {
return $defaults;
}
if (is_string($value)) {
$trim = trim($value);
if ($trim === '' || strcasecmp($trim, 'null') === 0) {
return $defaults;
}
$decoded = json_decode($trim, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $defaults;
}
$value = $decoded;
}
// 双重编码:第一次 decode 后仍是 JSON 字符串
if (is_string($value)) {
$trim = trim($value);
$decoded = $trim === '' ? [] : json_decode($trim, true);
$value = (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) ? $decoded : [];
}
if (is_object($value)) {
$value = json_decode(json_encode($value, JSON_UNESCAPED_UNICODE), true);
}
if (!is_array($value)) {
return $defaults;
}
return array_merge($defaults, array_intersect_key($value, $defaults));
}
/**
* 获取规范化权限(未设置的键默认 true
*/
public function getNormalizedPermissions(): array
{
return self::normalizePermissionsValue($this->permissions);
}
/**
* 超管授权上限(未单独配置时回落为当前 permissions兼容未跑迁移库
*
* @param self|array<string,mixed>|null $enterprise 模型或 toArray 行
*/
public static function normalizedPermissionsCeiling($enterprise): array
{
if ($enterprise instanceof self) {
$raw = $enterprise->getAttr('permissionsCeiling');
if ($raw === null || $raw === '') {
$raw = $enterprise->getAttr('permissions');
}
return self::normalizePermissionsValue($raw);
}
if (is_array($enterprise)) {
$raw = $enterprise['permissionsCeiling'] ?? null;
if ($raw === null || $raw === '') {
$raw = $enterprise['permissions'] ?? null;
}
return self::normalizePermissionsValue($raw);
}
return self::permissionDefaults();
}
/**
* 在超管上限内合并企业管理员提交的开关
*/
public static function clampPermissionsToCeiling(array $ceiling, array $requested): array
{
$req = self::normalizePermissionsValue($requested);
$out = [];
foreach (array_keys(self::permissionDefaults()) as $k) {
$out[$k] = !empty($ceiling[$k]) && !empty($req[$k]);
}
return $out;
}
/**
* 超管收紧上限后,同步收缩已生效 permissions关掉的项强制 false
*/
public static function clampEffectiveToNewCeiling(array $newCeiling, array $oldEffective): array
{
$old = self::normalizePermissionsValue($oldEffective);
$out = [];
foreach (array_keys(self::permissionDefaults()) as $k) {
$out[$k] = !empty($newCeiling[$k]) && !empty($old[$k]);
}
return $out;
}
}

View File

@@ -0,0 +1,15 @@
<?php
$pdo = new PDO(
'mysql:host=56b4c23f6853c.gz.cdb.myqcloud.com;port=14413;dbname=mbti;charset=utf8mb4',
'mbti',
'Zhiqun1984',
[PDO::ATTR_TIMEOUT => 10]
);
$r = $pdo->query("SHOW COLUMNS FROM mbti_enterprises LIKE 'permissions'");
if ($r->rowCount() > 0) {
echo "Column 'permissions' already exists.\n";
} else {
$pdo->exec("ALTER TABLE mbti_enterprises ADD COLUMN permissions json NULL COMMENT '功能权限开关' AFTER status");
echo "Column 'permissions' added.\n";
}

View File

@@ -0,0 +1,27 @@
import pymysql
conn = pymysql.connect(
host='56b4c23f6853c.gz.cdb.myqcloud.com',
port=14413,
user='mbti',
password='Zhiqun1984',
database='mbti',
charset='utf8mb4'
)
cur = conn.cursor()
cur.execute("SHOW COLUMNS FROM mbti_enterprises LIKE 'permissions'")
row = cur.fetchone()
if row:
print('Column permissions already exists, skipping.')
else:
cur.execute("""
ALTER TABLE mbti_enterprises
ADD COLUMN permissions json NULL
COMMENT '功能权限开关 {face,mbti,pdp,disc,distribution}'
AFTER status
""")
conn.commit()
print('Column permissions added successfully.')
conn.close()

View File

@@ -0,0 +1,5 @@
-- 企业权限字段JSON 存储各功能开关face/mbti/pdp/disc/distribution
-- 默认全开,未设置时后端视为全部允许
ALTER TABLE `mbti_enterprises`
ADD COLUMN `permissions` json NULL COMMENT '功能权限开关 {"face":true,"mbti":true,"pdp":true,"disc":true,"distribution":true}'
AFTER `status`;

View File

@@ -0,0 +1,7 @@
-- 企业permissions = 终端生效permissionsCeiling = 仅超管可改的上限
ALTER TABLE mbti_enterprises
ADD COLUMN permissionsCeiling json NULL COMMENT '超管功能授权上限' AFTER permissions;
UPDATE mbti_enterprises
SET permissionsCeiling = permissions
WHERE permissionsCeiling IS NULL AND permissions IS NOT NULL;

View File

@@ -137,6 +137,10 @@ Route::group('api/v1/admin', function () {
Route::get('settings/fonts', 'admin.Settings/getFonts'); Route::get('settings/fonts', 'admin.Settings/getFonts');
Route::put('settings/credentials', 'admin.Settings/updateCredentials'); Route::put('settings/credentials', 'admin.Settings/updateCredentials');
Route::get('settings', 'admin.Settings/index'); Route::get('settings', 'admin.Settings/index');
// 企业功能开关(企业管理员,受超管 permissionsCeiling 约束)
Route::get('enterprise/permissions', 'admin.EnterprisePermissions/index');
Route::put('enterprise/permissions', 'admin.EnterprisePermissions/update');
// 分销管理(企业管理员) // 分销管理(企业管理员)
Route::get('distribution/overview', 'admin.Distribution/overview'); Route::get('distribution/overview', 'admin.Distribution/overview');
@@ -248,7 +252,9 @@ Route::group('api/v1/superadmin', function () {
Route::get('overview/enterprise-ranking', 'superadmin.Overview/enterpriseRanking'); Route::get('overview/enterprise-ranking', 'superadmin.Overview/enterpriseRanking');
Route::get('overview/test-trends', 'superadmin.Overview/testTrends'); Route::get('overview/test-trends', 'superadmin.Overview/testTrends');
Route::get('overview', 'superadmin.Overview/index'); Route::get('overview', 'superadmin.Overview/index');
// 超管邀请小程序码(与企业后台同一控制层逻辑,企业版需 enterpriseId 或系统默认企业)
Route::get('invite/qrcode', 'admin.Invite/qrcode');
// 财务管理(超管专用) // 财务管理(超管专用)
Route::get('finance/overview', 'superadmin.Finance/overview'); Route::get('finance/overview', 'superadmin.Finance/overview');
Route::get('finance/revenue-details', 'superadmin.Finance/revenueDetails'); Route::get('finance/revenue-details', 'superadmin.Finance/revenueDetails');

View File

@@ -42,30 +42,88 @@ App({
pdpResult: null, pdpResult: null,
aiResult: null, aiResult: null,
maintenanceMode: undefined, maintenanceMode: undefined,
reviewMode: undefined reviewMode: undefined,
enterprisePermissions: null
}, },
onLaunch(launchOptions) { onLaunch(launchOptions) {
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : '' this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
this.loadStoredData() this.loadStoredData()
this.silentLogin()
try { try {
const analytics = require('./utils/analytics.js') const analytics = require('./utils/analytics.js')
analytics.reportAppLaunch(launchOptions) analytics.reportAppLaunch(launchOptions)
} catch (e) {} } catch (e) {}
this.getRuntimeConfig().then((cfg) => { this.silentLogin()
if (cfg) { .catch(() => {})
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle .then(() => this.getRuntimeConfig())
if (cfg.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!cfg.maintenanceMode .then(() => {
if (cfg.reviewMode !== undefined) { this._afterRuntimeSynced()
this.globalData.reviewMode = !!cfg.reviewMode })
} else if (cfg.maintenanceMode !== undefined) { .catch(() => {})
this.globalData.reviewMode = !!cfg.maintenanceMode },
_afterRuntimeSynced() {
try {
const pages = getCurrentPages()
if (!pages || pages.length === 0) return
const top = pages[pages.length - 1]
if (!top) return
const gd = this.globalData
const ep = gd.enterprisePermissions
const audit = !!(gd.reviewMode || gd.maintenanceMode)
const permFace = !ep || ep.face !== false
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
const route = top.route || ''
if (route === 'pages/index/index' && typeof top.setData === 'function') {
top.setData({
reviewMode: audit,
permFace,
siteTitle: audit ? String(gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: (audit || (ep && ep.face === false)) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: audit ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析')
})
} else if (route === 'pages/profile/index' && typeof top.setData === 'function') {
if (typeof top._syncPerms === 'function') top._syncPerms.call(top)
top.setData({ reviewMode: audit })
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
} else if (route === 'pages/enterprise/index' && typeof top.setData === 'function') {
const maintenanceMode = audit
const pf = permFace
top.setData({
maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf,
siteTitle: gd.siteTitle || '神仙团队性格测试',
startButtonEnterprise: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始性格测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'
})
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
} else if (route === 'pages/index/camera' && typeof top.setData === 'function') {
top.setData({ reviewMode: audit })
if (ep && ep.face === false) {
tt.navigateTo({ url: '/pages/test-select/index' })
}
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
} }
} }
}).catch(() => {}) } catch (e) {
console.error('_afterRuntimeSynced', e)
}
}, },
onShow() { onShow() {
@@ -342,6 +400,7 @@ App({
}, },
getRuntimeConfig() { getRuntimeConfig() {
const reqId = (this._runtimeReqSeq = (this._runtimeReqSeq || 0) + 1)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const scope = this.globalData.appScope || 'personal' const scope = this.globalData.appScope || 'personal'
const base = this.globalData.apiBase.replace(/\/$/, '') const base = this.globalData.apiBase.replace(/\/$/, '')
@@ -352,6 +411,10 @@ App({
method: 'GET', method: 'GET',
header: token ? { Authorization: 'Bearer ' + token } : {}, header: token ? { Authorization: 'Bearer ' + token } : {},
success: (res) => { success: (res) => {
if (reqId !== this._runtimeReqSeq) {
resolve(null)
return
}
if (res.statusCode === 200 && res.data && res.data.code === 200) { if (res.statusCode === 200 && res.data && res.data.code === 200) {
const data = res.data.data || {} const data = res.data.data || {}
if (data.siteTitle) this.globalData.siteTitle = data.siteTitle if (data.siteTitle) this.globalData.siteTitle = data.siteTitle
@@ -362,6 +425,16 @@ App({
} else if (data.maintenanceMode !== undefined) { } else if (data.maintenanceMode !== undefined) {
this.globalData.reviewMode = !!data.maintenanceMode this.globalData.reviewMode = !!data.maintenanceMode
} }
if (data.defaultEnterpriseId != null && Number(data.defaultEnterpriseId) > 0) {
this.globalData.defaultEnterpriseId = Number(data.defaultEnterpriseId)
} else {
this.globalData.defaultEnterpriseId = null
}
if (data.enterprisePermissions && typeof data.enterprisePermissions === 'object') {
this.globalData.enterprisePermissions = data.enterprisePermissions
} else {
this.globalData.enterprisePermissions = null
}
resolve(data) resolve(data)
} else { } else {
reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败')) reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败'))

View File

@@ -3,6 +3,7 @@ Component({
data: { data: {
selected: 0, selected: 0,
reviewMode: false, reviewMode: false,
hideMiddleFab: false,
list: [ list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' }, { pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' }, { pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
@@ -29,18 +30,20 @@ Component({
const url = currentPage.route const url = currentPage.route
const gd = getApp().globalData || {} const gd = getApp().globalData || {}
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode) const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const hideMiddleFab = reviewMode || !!(ep && ep.face === false)
let selected = 0 let selected = 0
if (url === 'pages/index/index' || url === 'pages/enterprise/index') { if (url === 'pages/index/index' || url === 'pages/enterprise/index') {
selected = 0 selected = 0
} else if (url === 'pages/index/camera') { } else if (url === 'pages/index/camera') {
selected = 1 selected = hideMiddleFab ? 0 : 1
} else if (url === 'pages/profile/index') { } else if (url === 'pages/profile/index') {
selected = 2 selected = 2
} }
this.setData({ selected, reviewMode }) this.setData({ selected, reviewMode, hideMiddleFab })
} catch (error) { } catch (error) {
console.error('updateSelected error:', error) console.error('updateSelected error:', error)
this.setData({ selected: 0, reviewMode: false }) this.setData({ selected: 0, reviewMode: false, hideMiddleFab: false })
} }
}, },
switchTab(e) { switchTab(e) {
@@ -48,7 +51,8 @@ Component({
let url = e.currentTarget.dataset.path let url = e.currentTarget.dataset.path
const gd = getApp().globalData || {} const gd = getApp().globalData || {}
if (index === 1 && !!(gd.reviewMode || gd.maintenanceMode)) { const faceOff = !!(gd.enterprisePermissions && gd.enterprisePermissions.face === false)
if (index === 1 && (!!(gd.reviewMode || gd.maintenanceMode) || faceOff)) {
return return
} }

View File

@@ -1,7 +1,7 @@
<!-- 自定义 tabBar与微信端一致中间固定「拍摄」 --> <!-- 自定义 tabBar与微信端一致中间固定「拍摄」 -->
<view class="tab-bar"> <view class="tab-bar">
<view class="tab-bar-line"></view> <view class="tab-bar-line"></view>
<view class="tab-bar-inner {{reviewMode ? 'tab-bar-inner--audit' : ''}}"> <view class="tab-bar-inner {{hideMiddleFab ? 'tab-bar-inner--audit' : ''}}">
<view <view
class="tab-item {{selected === 0 ? 'active' : ''}}" class="tab-item {{selected === 0 ? 'active' : ''}}"
data-index="0" data-index="0"
@@ -12,7 +12,7 @@
<text class="tab-text">首页</text> <text class="tab-text">首页</text>
</view> </view>
<view class="tab-slot-middle" tt:if="{{!reviewMode}}"> <view class="tab-slot-middle" tt:if="{{!hideMiddleFab}}">
<view <view
class="middle-fab {{selected === 1 ? 'active' : ''}}" class="middle-fab {{selected === 1 ? 'active' : ''}}"
data-index="1" data-index="1"

View File

@@ -1,227 +1,238 @@
// pages/enterprise/index.js - 企业版首页 // pages/enterprise/index.js - 企业版首页
const app = getApp() const app = getApp()
const { request } = require('../../utils/request') const { request } = require('../../utils/request')
Page({ Page({
data: { data: {
statusBarHeight: 0, statusBarHeight: 0,
navbarHeight: 88, navbarHeight: 88,
siteTitle: '神仙团队性格测试', siteTitle: '神仙团队性格测试',
startButtonEnterprise: '开始性格测试', startButtonEnterprise: '开始性格测试',
aiAnalysisText: '分析', aiAnalysisText: '分析',
reviewMode: true reviewMode: true,
}, permFace: true
},
onLoad(options) {
// 企业版首页:固定 scope=enterprise onLoad(options) {
const app = getApp() // 企业版首页:固定 scope=enterprise
try { app.globalData.appScope = 'enterprise' } catch (e) {} const app = getApp()
try { app.globalData.appScope = 'enterprise' } catch (e) {}
// ── 解析入参(兼容两种来源:扫码 scene / 分享链接 options──
const rawScene = (options && options.scene) ? decodeURIComponent(options.scene) : '' // ── 解析入参(兼容两种来源:扫码 scene / 分享链接 options──
// 解析 scene 中的 key=value 对(如 uid=1&eid=6 const rawScene = (options && options.scene) ? decodeURIComponent(options.scene) : ''
const sceneParams = {} // 解析 scene 中的 key=value 对(如 uid=1&eid=6
if (rawScene) { const sceneParams = {}
rawScene.split('&').forEach(pair => { if (rawScene) {
const [k, v] = pair.split('=') rawScene.split('&').forEach(pair => {
if (k) sceneParams[k] = v || '' const [k, v] = pair.split('=')
}) if (k) sceneParams[k] = v || ''
} })
}
// 合并所有来源scene > options分享链接
const uid = parseInt(sceneParams.uid || options.uid || 0, 10) // 合并所有来源scene > options分享链接
let eid = parseInt(sceneParams.eid || options.eid || 0, 10) const uid = parseInt(sceneParams.uid || options.uid || 0, 10)
let eid = parseInt(sceneParams.eid || options.eid || 0, 10)
// 兼容旧格式 scene: e_企业ID
if (!eid && rawScene && rawScene.indexOf('e_') === 0) { // 兼容旧格式 scene: e_企业ID
eid = parseInt(rawScene.slice(2), 10) || 0 if (!eid && rawScene && rawScene.indexOf('e_') === 0) {
} eid = parseInt(rawScene.slice(2), 10) || 0
}
console.log('[enterprise/onLoad] 解码参数 =>', {
rawScene, console.log('[enterprise/onLoad] 解码参数 =>', {
sceneParams, rawScene,
options, sceneParams,
uid, options,
eid uid,
}) eid
})
if (eid > 0) app.globalData.enterpriseIdFromScene = eid
if (eid > 0) app.globalData.enterpriseIdFromScene = eid
// 企业版分销绑定uid > 0 且 eid > 0 时触发
if (uid > 0 && eid > 0) { // 企业版分销绑定uid > 0 且 eid > 0 时触发
app.globalData._pendingInviterId = uid if (uid > 0 && eid > 0) {
app.globalData._pendingInviterScope = 'enterprise' app.globalData._pendingInviterId = uid
app.globalData._pendingInviterEid = eid app.globalData._pendingInviterScope = 'enterprise'
} app.globalData._pendingInviterEid = eid
// 获取状态栏高度和屏幕信息 }
const systemInfo = tt.getSystemInfoSync() // 获取状态栏高度和屏幕信息
const statusBarHeight = systemInfo.statusBarHeight || 0 const systemInfo = tt.getSystemInfoSync()
const screenWidth = systemInfo.screenWidth || 375 const statusBarHeight = systemInfo.statusBarHeight || 0
const statusBarHeightRpx = (statusBarHeight * 750) / screenWidth const screenWidth = systemInfo.screenWidth || 375
const navbarHeightRpx = statusBarHeightRpx + 88 const statusBarHeightRpx = (statusBarHeight * 750) / screenWidth
const gd = app.globalData const navbarHeightRpx = statusBarHeightRpx + 88
const rm = !!gd.reviewMode const gd = app.globalData
this.setData({ const rm = !!(gd.reviewMode || gd.maintenanceMode)
statusBarHeight: statusBarHeightRpx, const ep = gd.enterprisePermissions
navbarHeight: navbarHeightRpx, const pf = !ep || ep.face !== false
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'), this.setData({
startButtonEnterprise: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始性格测试'), statusBarHeight: statusBarHeightRpx,
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'), navbarHeight: navbarHeightRpx,
reviewMode: rm siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
}) startButtonEnterprise: (rm || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始性格测试'),
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
// 未绑定企业的用户:若从邀请码扫码进入(有 enterpriseIdFromScene也允许使用企业版 reviewMode: rm,
const userInfo = app.globalData.userInfo || tt.getStorageSync('userInfo') || {} permFace: pf
const fromInvite = !!app.globalData.enterpriseIdFromScene })
const redirectBack = () => {
tt.showToast({ title: '您尚未绑定任何企业,无法使用企业版', icon: 'none', duration: 2500 }) // 未绑定企业的用户:若从邀请码扫码进入(有 enterpriseIdFromScene也允许使用企业版
setTimeout(() => tt.switchTab({ url: '/pages/index/index' }), 600) const userInfo = app.globalData.userInfo || tt.getStorageSync('userInfo') || {}
} const fromInvite = !!app.globalData.enterpriseIdFromScene
// 如果不是通过企业邀请码进入:沿用原有 hasEnterprise 判定 const redirectBack = () => {
if (!fromInvite) { tt.showToast({ title: '您尚未绑定任何企业,无法使用企业版', icon: 'none', duration: 2500 })
if (userInfo.hasEnterprise === true) { setTimeout(() => tt.switchTab({ url: '/pages/index/index' }), 600)
app.getRuntimeConfig().then((cfg) => { }
if (cfg && cfg.siteTitle) { // 如果不是通过企业邀请码进入:沿用原有 hasEnterprise 判定
app.globalData.siteTitle = cfg.siteTitle if (!fromInvite) {
this.setData({ siteTitle: cfg.siteTitle }) if (userInfo.hasEnterprise === true) {
} app.getRuntimeConfig().then((cfg) => {
}).catch(() => {}) if (cfg && cfg.siteTitle) {
return app.globalData.siteTitle = cfg.siteTitle
} this.setData({ siteTitle: cfg.siteTitle })
if (userInfo.hasEnterprise === false) { }
redirectBack() }).catch(() => {})
return return
} }
} if (userInfo.hasEnterprise === false) {
app.ensureLogin() redirectBack()
.then(() => app.getRuntimeConfig()) return
.then((cfg) => { }
if (cfg) { }
if (cfg.siteTitle) { app.ensureLogin()
app.globalData.siteTitle = cfg.siteTitle .then(() => app.getRuntimeConfig())
this.setData({ siteTitle: cfg.siteTitle }) .then((cfg) => {
} if (cfg) {
if (cfg.textConfig) { if (cfg.siteTitle) {
app.globalData.textConfig = cfg.textConfig app.globalData.siteTitle = cfg.siteTitle
const rm2 = !!app.globalData.reviewMode this.setData({ siteTitle: cfg.siteTitle })
this.setData({ }
startButtonEnterprise: rm2 ? '开始性格测试' : (cfg.textConfig.startButtonEnterprise || '开始性格测试'), if (cfg.textConfig) {
aiAnalysisText: rm2 ? '分析' : (cfg.textConfig.aiAnalysisText || '分析') app.globalData.textConfig = cfg.textConfig
}) const rm2 = !!(app.globalData.reviewMode || app.globalData.maintenanceMode)
} const ep2 = app.globalData.enterprisePermissions
} const pf2 = !ep2 || ep2.face !== false
if ((cfg && cfg.pricingType) !== 'enterprise' && !app.globalData.enterpriseIdFromScene) { this.setData({
redirectBack() startButtonEnterprise: (rm2 || !pf2) ? '开始性格测试' : (cfg.textConfig.startButtonEnterprise || '开始性格测试'),
return aiAnalysisText: rm2 ? '分析' : (cfg.textConfig.aiAnalysisText || '分析'),
} reviewMode: rm2,
// 若通过企业邀请码进入(带 enterpriseIdFromScene登录后绑定到 wechat_users.enterpriseId permFace: pf2
const eid = app.globalData.enterpriseIdFromScene })
if (eid) { }
request({ }
url: '/api/enterprise/bind', if ((cfg && cfg.pricingType) !== 'enterprise' && !app.globalData.enterpriseIdFromScene) {
method: 'POST', redirectBack()
data: { enterpriseId: eid }, return
success(res) { }
if (res.statusCode === 200 && res.data && res.data.code === 200) { // 若通过企业邀请码进入(带 enterpriseIdFromScene登录后绑定到 wechat_users.enterpriseId
const data = res.data.data || {} const eid = app.globalData.enterpriseIdFromScene
const merged = { ...(app.globalData.userInfo || {}), ...data } if (eid) {
app.globalData.userInfo = merged request({
tt.setStorageSync('userInfo', merged) url: '/api/enterprise/bind',
} method: 'POST',
} data: { enterpriseId: eid },
}) success(res) {
} if (res.statusCode === 200 && res.data && res.data.code === 200) {
}) const data = res.data.data || {}
.catch(() => { const merged = { ...(app.globalData.userInfo || {}), ...data }
tt.showToast({ title: '请先登录', icon: 'none' }) app.globalData.userInfo = merged
setTimeout(() => tt.switchTab({ url: '/pages/index/index' }), 600) tt.setStorageSync('userInfo', merged)
}) }
}, }
})
onShow() { }
const gtab = getApp().globalData || {} })
const auditTb = !!(gtab.reviewMode || gtab.maintenanceMode) .catch(() => {
if (typeof this.getTabBar === 'function' && this.getTabBar()) { tt.showToast({ title: '请先登录', icon: 'none' })
this.getTabBar().setData({ selected: 0, reviewMode: auditTb }) setTimeout(() => tt.switchTab({ url: '/pages/index/index' }), 600)
} })
try { getApp().globalData.appScope = 'enterprise' } catch (e) {} },
const gd = getApp().globalData
const rm = !!(gd.reviewMode || gd.maintenanceMode) onShow() {
this.setData({ if (typeof this.getTabBar === 'function' && this.getTabBar()) {
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'), const tb = this.getTabBar()
startButtonEnterprise: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始性格测试'), if (typeof tb.updateSelected === 'function') tb.updateSelected()
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'), }
reviewMode: rm try { getApp().globalData.appScope = 'enterprise' } catch (e) {}
}) const gd = getApp().globalData
}, const rm = !!(gd.reviewMode || gd.maintenanceMode)
const ep4 = gd.enterprisePermissions
// 切换到个人版 const pf4 = !ep4 || ep4.face !== false
switchToPersonal() { this.setData({
tt.switchTab({ siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
url: '/pages/index/index' startButtonEnterprise: (rm || !pf4) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始性格测试'),
}) aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
}, reviewMode: rm,
permFace: pf4
startAITest() { })
if (this.data.reviewMode) { },
tt.navigateTo({ url: '/pages/test-select/index' })
return // 切换到个人版
} switchToPersonal() {
const eid = (app.globalData && app.globalData.enterpriseIdFromScene) || (app.globalData && app.globalData.userInfo && app.globalData.userInfo.enterpriseId) || (tt.getStorageSync('userInfo') || {}).enterpriseId || null tt.switchTab({
const query = eid ? `?enterpriseId=${eid}&pageSize=1` : '?pageSize=1' url: '/pages/index/index'
request({ })
url: '/api/enterprise/resume-uploads' + query, },
method: 'GET',
needAuth: true, startAITest() {
success: (res) => { const ep = app.globalData.enterprisePermissions
const list = (res.data && res.data.code === 200 && res.data.data && res.data.data.list) ? res.data.data.list : [] if (this.data.reviewMode || (ep && ep.face === false)) {
if (!list.length) { tt.navigateTo({ url: '/pages/test-select/index' })
tt.showModal({ return
title: '提示', }
content: '需要先上传简历后再开始测试,请到「我的」-「我的简历」中上传', const eid = (app.globalData && app.globalData.enterpriseIdFromScene) || (app.globalData && app.globalData.userInfo && app.globalData.userInfo.enterpriseId) || (tt.getStorageSync('userInfo') || {}).enterpriseId || null
showCancel: true, const query = eid ? `?enterpriseId=${eid}&pageSize=1` : '?pageSize=1'
confirmText: '去上传', request({
success: (r) => { url: '/api/enterprise/resume-uploads' + query,
if (r.confirm) { method: 'GET',
tt.navigateTo({ url: '/pages/enterprise/resume-history' }) needAuth: true,
} success: (res) => {
} const list = (res.data && res.data.code === 200 && res.data.data && res.data.data.list) ? res.data.data.list : []
}) if (!list.length) {
return tt.showModal({
} title: '提示',
tt.switchTab({ url: '/pages/index/camera' }) content: '需要先上传简历后再开始测试,请到「我的」-「我的简历」中上传',
}, showCancel: true,
fail: () => { confirmText: '去上传',
tt.showModal({ success: (r) => {
title: '提示', if (r.confirm) {
content: '需要先上传简历后再开始测试,请到「我的」-「我的简历」中上传', tt.navigateTo({ url: '/pages/enterprise/resume-history' })
showCancel: true, }
confirmText: '去上传', }
success: (r) => { })
if (r.confirm) { return
tt.navigateTo({ url: '/pages/enterprise/resume-history' }) }
} tt.switchTab({ url: '/pages/index/camera' })
} },
}) fail: () => {
} tt.showModal({
}) title: '提示',
}, content: '需要先上传简历后再开始测试,请到「我的」-「我的简历」中上传',
showCancel: true,
onShareAppMessage() { confirmText: '去上传',
const { getSharePath } = require('../../utils/share') success: (r) => {
const rm = this.data.reviewMode if (r.confirm) {
return { tt.navigateTo({ url: '/pages/enterprise/resume-history' })
title: rm ? '神仙团队性格测试 (企业版) - 团队分析与优化' : '神仙团队性格测试 (企业版) - 团队分析与优化', }
path: getSharePath('/pages/enterprise/index') }
} })
}, }
})
onShareTimeline() { },
const { buildShareQuery } = require('../../utils/share')
const rm = this.data.reviewMode onShareAppMessage() {
return { const { getSharePath } = require('../../utils/share')
title: rm ? '神仙团队性格测试 (企业版) - 团队分析与优化' : '神仙团队性格测试 (企业版) - 团队分析与优化', const rm = this.data.reviewMode
query: buildShareQuery() return {
} title: rm ? '神仙团队性格测试 (企业版) - 团队分析与优化' : '神仙团队性格测试 (企业版) - 团队分析与优化',
} path: getSharePath('/pages/enterprise/index')
}) }
},
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
const rm = this.data.reviewMode
return {
title: rm ? '神仙团队性格测试 (企业版) - 团队分析与优化' : '神仙团队性格测试 (企业版) - 团队分析与优化',
query: buildShareQuery()
}
}
})

View File

@@ -1,80 +1,80 @@
<!--pages/enterprise/index.wxml - 企业版首页--> <!--pages/enterprise/index.wxml - 企业版首页-->
<view class="container" style="padding-top: {{navbarHeight}}px;"> <view class="container" style="padding-top: {{navbarHeight}}px;">
<!-- 自定义导航栏 --> <!-- 自定义导航栏 -->
<view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;"> <view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;">
<view class="navbar-content"> <view class="navbar-content">
<view class="switch-personal-btn" bindtap="switchToPersonal"> <view class="switch-personal-btn" bindtap="switchToPersonal">
<text class="personal-icon">👤</text> <text class="personal-icon">👤</text>
<text class="personal-text">个人版</text> <text class="personal-text">个人版</text>
</view> </view>
<view class="navbar-title">{{siteTitle || '神仙团队性格测试'}}</view> <view class="navbar-title">{{siteTitle || '神仙团队性格测试'}}</view>
<view class="navbar-placeholder"></view> <view class="navbar-placeholder"></view>
</view> </view>
</view> </view>
<!-- 背景装饰 --> <!-- 背景装饰 -->
<view class="bg-decoration bg-top-right"></view> <view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view> <view class="bg-decoration bg-bottom-left"></view>
<view class="top-image-section"> <view class="top-image-section">
<view class="image-container"> <view class="image-container">
<view class="image-wrapper"> <view class="image-wrapper">
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image> <image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view> </view>
<view class="float-tag tag-1" tt:if="{{!reviewMode}}">面相分析</view> <view class="float-tag tag-1" tt:if="{{!reviewMode && permFace}}">面相分析</view>
<view class="float-tag tag-2" tt:if="{{!reviewMode}}">骨相分析</view> <view class="float-tag tag-2" tt:if="{{!reviewMode && permFace}}">骨相分析</view>
<view class="float-tag tag-3">性格测评</view> <view class="float-tag tag-3">性格测评</view>
<view class="float-tag tag-1" tt:if="{{reviewMode}}">MBTI</view> <view class="float-tag tag-1" tt:if="{{reviewMode}}">MBTI</view>
<view class="float-tag tag-2" tt:if="{{reviewMode}}">DISC</view> <view class="float-tag tag-2" tt:if="{{reviewMode}}">DISC</view>
</view> </view>
</view> </view>
<view class="process-section"> <view class="process-section">
<view class="section-title">测试流程</view> <view class="section-title">测试流程</view>
<view class="process-steps" tt:if="{{!reviewMode}}"> <view class="process-steps" tt:if="{{!reviewMode && permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
<text class="step-text">拍摄照片</text> <text class="step-text">拍摄照片</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">2</view> <view class="step-circle">2</view>
<text class="step-label">STEP2</text> <text class="step-label">STEP2</text>
<text class="step-text">{{aiAnalysisText || '分析'}}</text> <text class="step-text">{{aiAnalysisText || '分析'}}</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">3</view> <view class="step-circle">3</view>
<text class="step-label">STEP3</text> <text class="step-label">STEP3</text>
<text class="step-text">生成报告</text> <text class="step-text">生成报告</text>
</view> </view>
</view> </view>
<view class="process-steps" tt:if="{{reviewMode}}"> <view class="process-steps" tt:if="{{reviewMode || !permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
<text class="step-text">选择测试</text> <text class="step-text">选择测试</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">2</view> <view class="step-circle">2</view>
<text class="step-label">STEP2</text> <text class="step-label">STEP2</text>
<text class="step-text">回答问题</text> <text class="step-text">回答问题</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">3</view> <view class="step-circle">3</view>
<text class="step-label">STEP3</text> <text class="step-label">STEP3</text>
<text class="step-text">查看结果</text> <text class="step-text">查看结果</text>
</view> </view>
</view> </view>
</view> </view>
<view class="start-button" bindtap="startAITest"> <view class="start-button" bindtap="startAITest">
<text class="button-text">{{startButtonEnterprise || '开始性格测试'}}</text> <text class="button-text">{{startButtonEnterprise || '开始性格测试'}}</text>
</view> </view>
<!-- 自定义底部导航 --> <!-- 自定义底部导航 -->
<custom-tab-bar /> <custom-tab-bar />
</view> </view>

View File

@@ -7,9 +7,16 @@ Page({
tabName: '', tabName: '',
list: [], list: [],
total: 0, total: 0,
page: 1,
hasMore: false,
loading: false, loading: false,
loadingMore: false,
isEnterprise: false, isEnterprise: false,
reviewMode: false reviewMode: false,
permMbti: true,
permPdp: true,
permDisc: true,
permFace: true
}, },
_checkIsEnterprise() { _checkIsEnterprise() {
@@ -23,54 +30,110 @@ Page({
return scope === 'enterprise' || !!enterpriseId return scope === 'enterprise' || !!enterpriseId
}, },
_syncPermsAndTab() {
const p = app.globalData.enterprisePermissions
const permMbti = !p || p.mbti !== false
const permPdp = !p || p.pdp !== false
const permDisc = !p || p.disc !== false
const permFace = !p || p.face !== false
const reviewMode = !!app.globalData.reviewMode
const isEnterprise = this._checkIsEnterprise()
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
let { activeTab } = this.data
if (activeTab === 'mbti' && !permMbti) activeTab = 'all'
if (activeTab === 'pdp' && !permPdp) activeTab = 'all'
if (activeTab === 'disc' && !permDisc) activeTab = 'all'
if (activeTab === 'ai' && (!permFace || reviewMode)) activeTab = 'all'
if (activeTab === 'resume' && !isEnterprise) activeTab = 'all'
this.setData({
isEnterprise,
permMbti, permPdp, permDisc, permFace, reviewMode,
activeTab,
tabName: names[activeTab] || ''
})
},
onLoad() { onLoad() {
this.setData({ isEnterprise: this._checkIsEnterprise() }) this._syncPermsAndTab()
this.loadAll() this.loadAll()
}, },
onShow() { onShow() {
this.setData({ isEnterprise: this._checkIsEnterprise(), reviewMode: !!app.globalData.reviewMode }) this._syncPermsAndTab()
this.loadAll() this.loadAll()
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
}, },
// 一次拉取全部历史pageSize=500
loadAll() { loadAll() {
if (this.data.loading) return if (this.data.loading) return
this.setData({ loading: true, list: [] }) this.setData({ loading: true, list: [], page: 1, hasMore: false, loadingMore: false })
this._fetchHistoryPage(1, false)
},
loadMore() {
if (!this.data.hasMore || this.data.loadingMore || this.data.loading) return
const next = this.data.page + 1
this.setData({ loadingMore: true })
this._fetchHistoryPage(next, true)
},
onReachBottom() {
this.loadMore()
},
_fetchHistoryPage(pageNum, append) {
const token = app.globalData.token || tt.getStorageSync('token') const token = app.globalData.token || tt.getStorageSync('token')
const apiBase = app.globalData.apiBase const apiBase = app.globalData.apiBase
const { activeTab } = this.data const { activeTab } = this.data
const typeParam = activeTab === 'all' ? '' : `&type=${activeTab}` const typeParam = activeTab === 'all' ? '' : `&type=${activeTab}`
if (!token || !apiBase) { if (!token || !apiBase) {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() this.loadFromStorage()
return return
} }
tt.request({ tt.request({
url: `${apiBase}/api/test/history?page=1&pageSize=500${typeParam}&scope=all`, url: `${apiBase}/api/test/history?page=${pageNum}&pageSize=10${typeParam}&scope=all`,
method: 'GET', method: 'GET',
header: { Authorization: `Bearer ${token}` }, header: { Authorization: `Bearer ${token}` },
success: (res) => { success: (res) => {
if (res.statusCode === 200 && res.data && res.data.data) { if (res.statusCode === 200 && res.data && res.data.data) {
const payload = res.data.data const payload = res.data.data
const rawList = Array.isArray(payload) ? payload : (payload.list || []) const rawList = payload.list || []
const total = Array.isArray(payload) ? rawList.length : (payload.total || 0)
const formatted = this.formatList(rawList) const formatted = this.formatList(rawList)
this.setData({ list: formatted, total, loading: false }) const total = typeof payload.total === 'number' ? payload.total : formatted.length
const hasMore = !!payload.hasMore
if (append) {
this.setData({
list: this.data.list.concat(formatted),
total,
hasMore,
page: pageNum,
loadingMore: false,
loading: false
})
} else {
this.setData({
list: formatted,
total,
hasMore,
page: pageNum,
loading: false,
loadingMore: false
})
}
} else { } else {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() if (!append) this.loadFromStorage()
} }
}, },
fail: () => { fail: () => {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() if (!append) this.loadFromStorage()
} }
}) })
}, },
@@ -81,7 +144,8 @@ Page({
return rawList.map((item, idx) => { return rawList.map((item, idx) => {
if (item.typeName) { if (item.typeName) {
return { ...item, enterpriseName: item.enterpriseName || '' } const t = String(item.testType || item.type || 'mbti').toLowerCase()
return { ...item, type: t, enterpriseName: item.enterpriseName || '' }
} }
const testType = (item.testType || item.type || 'mbti').toLowerCase() const testType = (item.testType || item.type || 'mbti').toLowerCase()
const ts = item.createdAt || item.testTime || item.timestamp const ts = item.createdAt || item.testTime || item.timestamp
@@ -99,16 +163,17 @@ Page({
// 本地缓存回退 // 本地缓存回退
loadFromStorage() { loadFromStorage() {
const { permMbti, permPdp, permDisc, permFace, reviewMode } = this.data
const mbtiResult = tt.getStorageSync('mbtiResult') const mbtiResult = tt.getStorageSync('mbtiResult')
const discResult = tt.getStorageSync('discResult') const discResult = tt.getStorageSync('discResult')
const pdpResult = tt.getStorageSync('pdpResult') const pdpResult = tt.getStorageSync('pdpResult')
const aiResult = tt.getStorageSync('aiResult') const aiResult = tt.getStorageSync('aiResult')
const list = [] const list = []
if (mbtiResult) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult }) if (mbtiResult && permMbti) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult })
if (pdpResult) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult }) if (pdpResult && permPdp) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult })
if (discResult) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult }) if (discResult && permDisc) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult })
if (aiResult && !app.globalData.reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult }) if (aiResult && permFace && !reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult })
this.setData({ list, total: list.length, loading: false }) this.setData({ list, total: list.length, page: 1, hasMore: false, loading: false, loadingMore: false })
}, },
changeTab(e) { changeTab(e) {

View File

@@ -7,16 +7,16 @@
<view class="tab-item {{activeTab === 'all' ? 'active' : ''}}" bindtap="changeTab" data-tab="all"> <view class="tab-item {{activeTab === 'all' ? 'active' : ''}}" bindtap="changeTab" data-tab="all">
<text class="tab-text">全部</text> <text class="tab-text">全部</text>
</view> </view>
<view class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti"> <view tt:if="{{permMbti}}" class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti">
<text class="tab-text">MBTI</text> <text class="tab-text">MBTI</text>
</view> </view>
<view class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp"> <view tt:if="{{permPdp}}" class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp">
<text class="tab-text">PDP</text> <text class="tab-text">PDP</text>
</view> </view>
<view class="tab-item {{activeTab === 'disc' ? 'active' : ''}}" bindtap="changeTab" data-tab="disc"> <view tt:if="{{permDisc}}" class="tab-item {{activeTab === 'disc' ? 'active' : ''}}" bindtap="changeTab" data-tab="disc">
<text class="tab-text">DISC</text> <text class="tab-text">DISC</text>
</view> </view>
<view tt:if="{{!reviewMode}}" class="tab-item {{activeTab === 'ai' ? 'active' : ''}}" bindtap="changeTab" data-tab="ai"> <view tt:if="{{!reviewMode && permFace}}" class="tab-item {{activeTab === 'ai' ? 'active' : ''}}" bindtap="changeTab" data-tab="ai">
<text class="tab-text">面相</text> <text class="tab-text">面相</text>
</view> </view>
<view tt:if="{{isEnterprise}}" class="tab-item {{activeTab === 'resume' ? 'active' : ''}}" bindtap="changeTab" data-tab="resume"> <view tt:if="{{isEnterprise}}" class="tab-item {{activeTab === 'resume' ? 'active' : ''}}" bindtap="changeTab" data-tab="resume">
@@ -57,11 +57,12 @@
</view> </view>
</view> </view>
<!-- 加载中 --> <view class="load-more-row" tt:if="{{loadingMore}}">
<view class="load-more-row" tt:if="{{loading}}"> <text class="load-more-text">加载更多...</text>
<text class="load-more-text">加载中...</text> </view>
<view tt:elif="{{hasMore && list.length > 0}}" class="load-more-row">
<text class="load-more-text">上拉或继续滑动加载更多</text>
</view> </view>
<view tt:elif="{{list.length > 0}}" class="load-more-row"> <view tt:elif="{{list.length > 0}}" class="load-more-row">
<text class="load-more-text no-more">共 {{total}} 条</text> <text class="load-more-text no-more">共 {{total}} 条</text>
</view> </view>

View File

@@ -71,15 +71,21 @@ Page({
onShow() { onShow() {
const rm = _auditFlag() const rm = _auditFlag()
const faceOff = !!(app.globalData.enterprisePermissions && app.globalData.enterprisePermissions.face === false)
this.setData({ reviewMode: rm }) this.setData({ reviewMode: rm })
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1, reviewMode: rm }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
if (rm) { if (rm) {
return return
} }
if (faceOff) {
tt.navigateTo({ url: '/pages/test-select/index' })
return
}
if (!ensureProfileCompleteAndRedirect()) return if (!ensureProfileCompleteAndRedirect()) return
if (this.data.photos.length < 3) { if (this.data.photos.length < 3) {
this.initCameraContext() this.initCameraContext()

View File

@@ -10,7 +10,8 @@ Page({
siteTitle: '神仙团队性格测试', siteTitle: '神仙团队性格测试',
startButtonText: '开始性格测试', startButtonText: '开始性格测试',
aiAnalysisText: '分析', aiAnalysisText: '分析',
reviewMode: true reviewMode: true,
permFace: true
}, },
onLoad(options) { onLoad(options) {
@@ -25,6 +26,8 @@ Page({
const userInfo = getApp().globalData.userInfo || tt.getStorageSync('userInfo') || {} const userInfo = getApp().globalData.userInfo || tt.getStorageSync('userInfo') || {}
const gd = getApp().globalData const gd = getApp().globalData
const rm0 = !!(gd.reviewMode || gd.maintenanceMode) const rm0 = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const pf = !ep || ep.face !== false
this.setData({ this.setData({
statusBarHeight: statusBarHeightRpx, statusBarHeight: statusBarHeightRpx,
navbarHeight: navbarHeightRpx, navbarHeight: navbarHeightRpx,
@@ -32,7 +35,8 @@ Page({
siteTitle: rm0 ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'), siteTitle: rm0 ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'), startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm0 ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'), aiAnalysisText: rm0 ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm0 reviewMode: rm0,
permFace: pf
}) })
// 预加载站点名称与文案配置 // 预加载站点名称与文案配置
app.getRuntimeConfig().then((cfg) => { app.getRuntimeConfig().then((cfg) => {
@@ -53,7 +57,8 @@ Page({
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析') aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
}) })
} }
this.setData({ reviewMode: rm }) const ep2 = getApp().globalData.enterprisePermissions
this.setData({ reviewMode: rm, permFace: !ep2 || ep2.face !== false })
try { try {
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
@@ -97,7 +102,8 @@ Page({
onShow() { onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0 }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
// 个人版首页:固定 scope=personal并清除企业来源上下文 // 个人版首页:固定 scope=personal并清除企业来源上下文
try { try {
@@ -106,11 +112,13 @@ Page({
} catch (e) {} } catch (e) {}
const g = getApp().globalData const g = getApp().globalData
const rm = !!(g.reviewMode || g.maintenanceMode) const rm = !!(g.reviewMode || g.maintenanceMode)
const ep3 = g.enterprisePermissions
this.setData({ this.setData({
siteTitle: rm ? (g.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (g.siteTitle || '神仙团队性格测试'), siteTitle: rm ? (g.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (g.siteTitle || '神仙团队性格测试'),
startButtonText: rm ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '拍摄'), startButtonText: (rm || (ep3 && ep3.face === false)) ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((g.textConfig && g.textConfig.aiAnalysisText) || '分析'), aiAnalysisText: rm ? '分析' : ((g.textConfig && g.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm reviewMode: rm,
permFace: !ep3 || ep3.face !== false
}) })
const userInfo = getApp().globalData.userInfo || tt.getStorageSync('userInfo') || {} const userInfo = getApp().globalData.userInfo || tt.getStorageSync('userInfo') || {}
this.setData({ showEnterpriseEntry: userInfo.hasEnterprise === true }) this.setData({ showEnterpriseEntry: userInfo.hasEnterprise === true })
@@ -135,7 +143,9 @@ Page({
startCamera() { startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {} try { getApp().globalData.appScope = 'personal' } catch (e) {}
const gd = getApp().globalData const gd = getApp().globalData
if (gd.reviewMode || gd.maintenanceMode) { const ep = gd.enterprisePermissions
const faceOff = ep && ep.face === false
if (gd.reviewMode || gd.maintenanceMode || faceOff) {
tt.navigateTo({ url: '/pages/test-select/index' }) tt.navigateTo({ url: '/pages/test-select/index' })
return return
} }

View File

@@ -22,8 +22,8 @@
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image> <image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view> </view>
<!-- 审核模式:只显示性格测评标签 --> <!-- 审核模式:只显示性格测评标签 -->
<view class="float-tag tag-1" tt:if="{{!reviewMode}}">面相分析</view> <view class="float-tag tag-1" tt:if="{{!reviewMode && permFace}}">面相分析</view>
<view class="float-tag tag-2" tt:if="{{!reviewMode}}">骨相分析</view> <view class="float-tag tag-2" tt:if="{{!reviewMode && permFace}}">骨相分析</view>
<view class="float-tag tag-3">性格测评</view> <view class="float-tag tag-3">性格测评</view>
<view class="float-tag tag-1" tt:if="{{reviewMode}}">MBTI</view> <view class="float-tag tag-1" tt:if="{{reviewMode}}">MBTI</view>
<view class="float-tag tag-2" tt:if="{{reviewMode}}">DISC</view> <view class="float-tag tag-2" tt:if="{{reviewMode}}">DISC</view>
@@ -32,7 +32,7 @@
<view class="process-section"> <view class="process-section">
<view class="section-title">测试流程</view> <view class="section-title">测试流程</view>
<view class="process-steps" tt:if="{{!reviewMode}}"> <view class="process-steps" tt:if="{{!reviewMode && permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
@@ -51,8 +51,8 @@
<text class="step-text">生成报告</text> <text class="step-text">生成报告</text>
</view> </view>
</view> </view>
<!-- 审核模式:纯问卷流程 --> <!-- 审核模式或 face 权限关闭:纯问卷流程 -->
<view class="process-steps" tt:if="{{reviewMode}}"> <view class="process-steps" tt:if="{{reviewMode || !permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>

View File

@@ -35,13 +35,15 @@ Page({
onShow() { onShow() {
const audit = !!(app.globalData.reviewMode || app.globalData.maintenanceMode) const audit = !!(app.globalData.reviewMode || app.globalData.maintenanceMode)
if (audit) { const faceOff = !!(app.globalData.enterprisePermissions && app.globalData.enterprisePermissions.face === false)
if (audit || faceOff) {
tt.navigateTo({ url: '/pages/test-select/index' }) tt.navigateTo({ url: '/pages/test-select/index' })
return return
} }
if (!ensureProfileCompleteAndRedirect()) return if (!ensureProfileCompleteAndRedirect()) return
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1, reviewMode: audit }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
this.setData({ needPhoneAuth: !hasPhone() }) this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig const tc = app.globalData.textConfig

View File

@@ -39,24 +39,62 @@ Page({
promoTotalEarned: '0.00', promoTotalEarned: '0.00',
promoWithdrawable: '0.00', promoWithdrawable: '0.00',
/** 是否有企业权限(绑定企业):有则显示「我的简历」 */ /** 是否有企业权限(绑定企业):有则显示「我的简历」 */
hasEnterprise: false hasEnterprise: false,
permFace: true,
permMbti: true,
permPdp: true,
permDisc: true,
permDistribution: true,
showLatestTestCards: false,
showEmptyPersonalityTags: true
},
_computeShowLatestTestCards(d) {
const rm = !!(d.reviewMode)
if (d.permMbti && d.mbtiType) return true
if (d.permPdp && d.pdpType) return true
if (d.permDisc && d.discType) return true
if (!rm && d.permFace && (d.gallupPreview || d.aiType)) return true
return false
},
_computeShowEmptyPersonalityTags(d) {
return !((d.mbtiType && d.permMbti) || (d.discType && d.permDisc) || (d.pdpType && d.permPdp))
}, },
onLoad() { onLoad() {
// 仅在 onLoad 执行一次onShow 里的 runLoginThenLoad 会导致重复请求 // 仅在 onLoad 执行一次onShow 里的 runLoginThenLoad 会导致重复请求
this.runLoginThenLoad() this.runLoginThenLoad()
}, },
_syncPerms(overrides = {}) {
const p = app.globalData.enterprisePermissions
const next = {
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false,
permDistribution: !p || p.distribution !== false,
...overrides
}
const d = { ...this.data, ...next }
this.setData({
...next,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
})
},
onShow() { onShow() {
const gd = app.globalData const gd = app.globalData
this.setData({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) }) this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
// 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程 // 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程
if (this.data.hasLogin) { if (this.data.hasLogin) {
this.loadData() this.loadData()
} }
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const audit = !!(gd.reviewMode || gd.maintenanceMode) const tb = this.getTabBar()
this.getTabBar().setData({ selected: 2, reviewMode: audit }) if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
}, },
@@ -106,6 +144,9 @@ Page({
}, },
loadData() { loadData() {
const gd = app.globalData
this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
const userInfo = app.globalData.userInfo || tt.getStorageSync('userInfo') const userInfo = app.globalData.userInfo || tt.getStorageSync('userInfo')
const token = app.globalData.token || tt.getStorageSync('token') const token = app.globalData.token || tt.getStorageSync('token')
@@ -160,7 +201,7 @@ Page({
const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : '' const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : ''
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : '' const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
this.setData({ const patch = {
testCount: totalCount, testCount: totalCount,
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai), hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
mbtiType: r.mbti ? r.mbti.resultText : '', mbtiType: r.mbti ? r.mbti.resultText : '',
@@ -176,6 +217,12 @@ Page({
discResultId: r.disc ? r.disc.id : null, discResultId: r.disc ? r.disc.id : null,
pdpResultId: r.pdp ? r.pdp.id : null, pdpResultId: r.pdp ? r.pdp.id : null,
aiResultId: r.ai ? r.ai.id : null, aiResultId: r.ai ? r.ai.id : null,
}
const d = { ...this.data, ...patch }
this.setData({
...patch,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
}) })
}, },
fail: () => { fail: () => {
@@ -230,7 +277,7 @@ Page({
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
this.setData({ const patch = {
testCount, testCount,
hasResults: testCount > 0, hasResults: testCount > 0,
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '', mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
@@ -246,6 +293,12 @@ Page({
discResultId: null, discResultId: null,
pdpResultId: null, pdpResultId: null,
aiResultId: null, aiResultId: null,
}
const d = { ...this.data, ...patch }
this.setData({
...patch,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
}) })
}, },

View File

@@ -49,16 +49,16 @@
<!-- 类型标签 --> <!-- 类型标签 -->
<scroll-view scroll-x class="tags-scroll"> <scroll-view scroll-x class="tags-scroll">
<view class="tags-row"> <view class="tags-row">
<view class="tag tag-purple" tt:if="{{mbtiType}}"> <view class="tag tag-purple" tt:if="{{mbtiType && permMbti}}">
<text class="tag-text">{{mbtiType}}</text> <text class="tag-text">{{mbtiType}}</text>
</view> </view>
<view class="tag tag-blue" tt:if="{{discType}}"> <view class="tag tag-blue" tt:if="{{discType && permDisc}}">
<text class="tag-text">{{discType}}型</text> <text class="tag-text">{{discType}}型</text>
</view> </view>
<view class="tag tag-orange" tt:if="{{pdpType}}"> <view class="tag tag-orange" tt:if="{{pdpType && permPdp}}">
<text class="tag-text">{{pdpType}}</text> <text class="tag-text">{{pdpType}}</text>
</view> </view>
<view class="tag tag-gray" tt:if="{{!mbtiType && !discType && !pdpType}}"> <view class="tag tag-gray" tt:if="{{showEmptyPersonalityTags}}">
<text class="tag-text">暂无测试记录</text> <text class="tag-text">暂无测试记录</text>
</view> </view>
</view> </view>
@@ -78,57 +78,57 @@
<text class="depth-header-chevron"></text> <text class="depth-header-chevron"></text>
</view> </view>
</view> </view>
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}"> <scroll-view tt:if="{{showLatestTestCards}}" scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card"> <view class="cards-row cards-row--in-card">
<view class="result-card card-purple {{mbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewMBTI"> <view class="result-card card-purple" bindtap="viewMBTI" tt:if="{{permMbti && mbtiType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple"> <view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text> <text class="card-icon">🧠</text>
</view> </view>
<text class="card-label">MBTI性格</text> <text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType || '未测评'}}</text> <text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiType ? mbtiTime : '点击进入问卷'}}</text> <text class="card-time">{{mbtiTime}}</text>
</view> </view>
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP"> <view class="result-card card-orange" bindtap="viewPDP" tt:if="{{permPdp && pdpType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange"> <view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text> <text class="card-icon">🦁</text>
</view> </view>
<text class="card-label">PDP行为</text> <text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType || '未测评'}}</text> <text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpType ? pdpTime : '点击进入问卷'}}</text> <text class="card-time">{{pdpTime}}</text>
</view> </view>
<view class="result-card card-blue {{discType ? '' : 'result-card--placeholder'}}" bindtap="viewDISC"> <view class="result-card card-blue" bindtap="viewDISC" tt:if="{{permDisc && discType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue"> <view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text> <text class="card-icon">📊</text>
</view> </view>
<text class="card-label">DISC测评</text> <text class="card-label">DISC测评</text>
<text class="card-value">{{discType ? discType + '型' : '未测评'}}</text> <text class="card-value">{{discType + '型'}}</text>
<text class="card-time">{{discType ? discTime : '点击进入问卷'}}</text> <text class="card-time">{{discTime}}</text>
</view> </view>
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" tt:if="{{!reviewMode}}"> <view class="result-card card-teal" bindtap="viewGallup" tt:if="{{!reviewMode && permFace && (gallupPreview || aiType)}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-teal"> <view class="card-icon-wrap card-icon-teal">
<text class="card-icon">⭐</text> <text class="card-icon">⭐</text>
</view> </view>
<text class="card-label">盖洛普优势</text> <text class="card-label">盖洛普优势</text>
<text class="card-value card-value--small">{{gallupPreview || (aiType ? '见面相报告' : '未测评')}}</text> <text class="card-value card-value--small">{{gallupPreview || '见面相报告'}}</text>
<text class="card-time">{{gallupPreview ? aiTime : (aiType ? '已拍面相可查看' : '先完成面相拍摄')}}</text> <text class="card-time">{{aiTime}}</text>
</view> </view>
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" tt:if="{{!reviewMode}}"> <view class="result-card card-rose" bindtap="viewAI" tt:if="{{!reviewMode && permFace && aiType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose"> <view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text> <text class="card-icon">👁️</text>
</view> </view>
<text class="card-label">面相分析</text> <text class="card-label">面相分析</text>
<text class="card-value">{{aiType || '未测评'}}</text> <text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiType ? aiTime : '去拍摄分析'}}</text> <text class="card-time">{{aiTime}}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view class="depth-empty-hint depth-empty-hint--compact"> <view tt:if="{{showLatestTestCards}}" class="depth-empty-hint depth-empty-hint--compact">
<text>灰色为未完成项,点击即可进入测评;右上方可查看全部历史。</text> <text>点击卡片查看详情;右上方可查看全部测试记录。</text>
</view> </view>
<view class="depth-inner-divider"></view> <view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToTestSelect"> <view class="menu-item menu-item--flat" bindtap="goToTestSelect">
@@ -178,7 +178,7 @@
</view> </view>
<!-- 推广中心(根据管理端开关显示/隐藏,标题可配置) --> <!-- 推广中心(根据管理端开关显示/隐藏,标题可配置) -->
<view class="section px-section" tt:if="{{hasLogin && promoDistributionEnabled && !reviewMode}}"> <view class="section px-section" tt:if="{{hasLogin && promoDistributionEnabled && !reviewMode && permDistribution}}">
<view class="promo-card" bindtap="goToPromo"> <view class="promo-card" bindtap="goToPromo">
<view class="promo-header"> <view class="promo-header">
<view class="promo-title-wrap"> <view class="promo-title-wrap">

View File

@@ -1,21 +1,41 @@
// pages/test-select/index.js - 人脸测试后的详情性格测试入口MBTI / PDP / DISC // pages/test-select/index.js
Page({ const app = getApp()
data: {},
Page({
onLoad() {}, data: {
permFace: true,
// 进入 MBTI 测试 permMbti: true,
goMBTI() { permPdp: true,
tt.navigateTo({ url: '/pages/test/mbti' }) permDisc: true
}, },
// 进入 PDP 测试 onLoad() {
goPDP() { this._syncPerms()
tt.navigateTo({ url: '/pages/test/pdp' }) },
},
onShow() {
// 进入 DISC 测试 this._syncPerms()
goDISC() { },
tt.navigateTo({ url: '/pages/test/disc' })
} _syncPerms() {
}) const p = app.globalData.enterprisePermissions
this.setData({
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false
})
},
goMBTI() {
tt.navigateTo({ url: '/pages/test/mbti' })
},
goPDP() {
tt.navigateTo({ url: '/pages/test/pdp' })
},
goDISC() {
tt.navigateTo({ url: '/pages/test/disc' })
}
})

View File

@@ -1,40 +1,40 @@
<!--pages/test-select/index.wxml - 详细性格测试入口MBTI / PDP / DISC--> <!--pages/test-select/index.wxml - 详细性格测试入口MBTI / PDP / DISC-->
<view class="container"> <view class="container">
<view class="card intro-card"> <view class="card intro-card">
<text class="intro-title">选择一项详细性格测试</text> <text class="intro-title">选择一项详细性格测试</text>
<text class="intro-desc">完成问卷即可获得对应维度的专业性格报告</text> <text class="intro-desc">完成问卷即可获得对应维度的专业性格报告</text>
</view> </view>
<view class="card entry-card" bindtap="goMBTI"> <view class="card entry-card" bindtap="goMBTI" tt:if="{{permMbti}}">
<view class="entry-icon-wrap mbti"> <view class="entry-icon-wrap mbti">
<text class="entry-emoji">🧠</text> <text class="entry-emoji">🧠</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">MBTI 性格测试</text> <text class="entry-name">MBTI 性格测试</text>
<text class="entry-brief">16型人格 · 发现你的认知与决策风格</text> <text class="entry-brief">16型人格 · 发现你的认知与决策风格</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
<view class="card entry-card" bindtap="goPDP"> <view class="card entry-card" bindtap="goPDP" tt:if="{{permPdp}}">
<view class="entry-icon-wrap pdp"> <view class="entry-icon-wrap pdp">
<text class="entry-emoji">🦁</text> <text class="entry-emoji">🦁</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">PDP 行为偏好测试</text> <text class="entry-name">PDP 行为偏好测试</text>
<text class="entry-brief">老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格</text> <text class="entry-brief">老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
<view class="card entry-card" bindtap="goDISC"> <view class="card entry-card" bindtap="goDISC" tt:if="{{permDisc}}">
<view class="entry-icon-wrap disc"> <view class="entry-icon-wrap disc">
<text class="entry-emoji">📊</text> <text class="entry-emoji">📊</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">DISC 性格测试</text> <text class="entry-name">DISC 性格测试</text>
<text class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</text> <text class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
</view> </view>

View File

@@ -47,8 +47,8 @@ App({
// 超管配置的默认企业 ID无 scene/eid 等入口参数时回落) // 超管配置的默认企业 ID无 scene/eid 等入口参数时回落)
defaultEnterpriseId: null, defaultEnterpriseId: null,
// API基础地址开发时用本地生产环境替换为实际域名 // API基础地址开发时用本地生产环境替换为实际域名
apiBase: 'https://mbtiapi.quwanzhi.com', //apiBase: 'https://mbtiapi.quwanzhi.com',
//apiBase: 'http://mbti.com', apiBase: 'http://mbti.com',
// VIP信息 // VIP信息
vipInfo: null, vipInfo: null,
// 测试次数 // 测试次数
@@ -59,7 +59,9 @@ App({
mbtiResult: null, mbtiResult: null,
discResult: null, discResult: null,
pdpResult: null, pdpResult: null,
aiResult: null aiResult: null,
// 企业功能权限超管在企业管理中配置null=无限制(个人版),对象时按 key 控制
enterprisePermissions: null
}, },
onLaunch(launchOptions) { onLaunch(launchOptions) {
@@ -69,30 +71,85 @@ App({
// 加载本地存储的数据 // 加载本地存储的数据
this.loadStoredData() this.loadStoredData()
// 静默登录获取openId
this.silentLogin()
// 上报应用启动事件 // 上报应用启动事件
try { try {
const analytics = require('./utils/analytics.js') const analytics = require('./utils/analytics.js')
analytics.reportAppLaunch(launchOptions) analytics.reportAppLaunch(launchOptions)
} catch (e) {} } catch (e) {}
// 预加载站点/小程序名称、维护模式与面相审核模式camera/首页文案) // 必须先完成静默登录再拉 runtime否则无 token 时 enterprisePermissions 永远 null清除缓存后必现「要二次刷新」
this.getRuntimeConfig().then((cfg) => { this.silentLogin()
if (cfg) { .catch(() => {})
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle .then(() => this.getRuntimeConfig())
if (cfg.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!cfg.maintenanceMode .then(() => {
if (typeof cfg.reviewMode === 'boolean') { this._afterRuntimeSynced()
this.globalData.reviewMode = cfg.reviewMode })
.catch(() => {})
},
/**
* runtime 写入 globalData 后,同步当前栈顶页面与 tabBar避免首屏用旧 null 权限)
*/
_afterRuntimeSynced() {
try {
const pages = getCurrentPages()
if (!pages || pages.length === 0) return
const top = pages[pages.length - 1]
if (!top) return
const gd = this.globalData
const ep = gd.enterprisePermissions
const audit = !!(gd.reviewMode || gd.maintenanceMode)
const permFace = !ep || ep.face !== false
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
const route = top.route || ''
if (route === 'pages/index/index' && typeof top.setData === 'function') {
top.setData({
reviewMode: audit,
permFace,
siteTitle: audit ? String(gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: (audit || (ep && ep.face === false)) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: audit ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析')
})
} else if (route === 'pages/profile/index' && typeof top.setData === 'function') {
if (typeof top._syncPerms === 'function') top._syncPerms.call(top)
top.setData({ reviewMode: audit })
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
} }
if (cfg.defaultEnterpriseId != null && Number(cfg.defaultEnterpriseId) > 0) { } else if (route === 'pages/enterprise/index' && typeof top.setData === 'function') {
this.globalData.defaultEnterpriseId = Number(cfg.defaultEnterpriseId) const maintenanceMode = audit
} else { const pf = permFace
this.globalData.defaultEnterpriseId = null top.setData({
maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf,
siteTitle: gd.siteTitle || '神仙团队AI性格测试',
startButtonEnterprise: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析'
})
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
} else if (route === 'pages/index/camera' && typeof top.setData === 'function') {
top.setData({ reviewMode: audit })
if (ep && ep.face === false) {
wx.navigateTo({ url: '/pages/test-select/index' })
}
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
} }
} }
}).catch(() => {}) } catch (e) {
console.error('_afterRuntimeSynced', e)
}
}, },
onShow() { onShow() {
@@ -386,6 +443,7 @@ App({
* @returns {Promise<{pricingType, pricing, aiProviderId, aiProviderName}>} * @returns {Promise<{pricingType, pricing, aiProviderId, aiProviderName}>}
*/ */
getRuntimeConfig() { getRuntimeConfig() {
const reqId = (this._runtimeReqSeq = (this._runtimeReqSeq || 0) + 1)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const scope = this.globalData.appScope || 'personal' const scope = this.globalData.appScope || 'personal'
const base = this.globalData.apiBase.replace(/\/$/, '') const base = this.globalData.apiBase.replace(/\/$/, '')
@@ -396,6 +454,10 @@ App({
method: 'GET', method: 'GET',
header: token ? { Authorization: 'Bearer ' + token } : {}, header: token ? { Authorization: 'Bearer ' + token } : {},
success: (res) => { success: (res) => {
if (reqId !== this._runtimeReqSeq) {
resolve(null)
return
}
if (res.statusCode === 200 && res.data && res.data.code === 200) { if (res.statusCode === 200 && res.data && res.data.code === 200) {
const data = res.data.data || {} const data = res.data.data || {}
if (data.siteTitle) this.globalData.siteTitle = data.siteTitle if (data.siteTitle) this.globalData.siteTitle = data.siteTitle
@@ -411,6 +473,11 @@ App({
} else { } else {
this.globalData.defaultEnterpriseId = null this.globalData.defaultEnterpriseId = null
} }
if (data.enterprisePermissions && typeof data.enterprisePermissions === 'object') {
this.globalData.enterprisePermissions = data.enterprisePermissions
} else {
this.globalData.enterprisePermissions = null
}
resolve(data) resolve(data)
} else { } else {
reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败')) reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败'))

View File

@@ -2,8 +2,10 @@
Component({ Component({
data: { data: {
selected: 0, selected: 0,
/** 审核模式:为 true 时隐藏中间「拍摄」入口(与 globalData.reviewMode / maintenanceMode 一致) */ /** 审核模式 */
reviewMode: false, reviewMode: false,
/** 为 true 时隐藏中间「拍摄」:审核模式 或 企业权限关闭 face */
hideMiddleFab: false,
list: [ list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' }, { pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
{ pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' }, { pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
@@ -30,18 +32,20 @@ Component({
const url = currentPage.route const url = currentPage.route
const gd = getApp().globalData || {} const gd = getApp().globalData || {}
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode) const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const hideMiddleFab = reviewMode || !!(ep && ep.face === false)
let selected = 0 let selected = 0
if (url === 'pages/index/index' || url === 'pages/enterprise/index') { if (url === 'pages/index/index' || url === 'pages/enterprise/index') {
selected = 0 selected = 0
} else if (url === 'pages/index/camera') { } else if (url === 'pages/index/camera') {
selected = 1 selected = hideMiddleFab ? 0 : 1
} else if (url === 'pages/profile/index') { } else if (url === 'pages/profile/index') {
selected = 2 selected = 2
} }
this.setData({ selected, reviewMode }) this.setData({ selected, reviewMode, hideMiddleFab })
} catch (error) { } catch (error) {
console.error('updateSelected error:', error) console.error('updateSelected error:', error)
this.setData({ selected: 0, reviewMode: false }) this.setData({ selected: 0, reviewMode: false, hideMiddleFab: false })
} }
}, },
switchTab(e) { switchTab(e) {
@@ -49,7 +53,8 @@ Component({
let url = e.currentTarget.dataset.path let url = e.currentTarget.dataset.path
const gd = getApp().globalData || {} const gd = getApp().globalData || {}
if (index === 1 && !!(gd.reviewMode || gd.maintenanceMode)) { const faceOff = !!(gd.enterprisePermissions && gd.enterprisePermissions.face === false)
if (index === 1 && (!!(gd.reviewMode || gd.maintenanceMode) || faceOff)) {
return return
} }

View File

@@ -1,7 +1,7 @@
<!-- 自定义 tabBar顶部分割线 + 三栏;中间单独一列避免浮钮与左右叠层 --> <!-- 自定义 tabBar顶部分割线 + 三栏;中间单独一列避免浮钮与左右叠层 -->
<view class="tab-bar"> <view class="tab-bar">
<view class="tab-bar-line"></view> <view class="tab-bar-line"></view>
<view class="tab-bar-inner {{reviewMode ? 'tab-bar-inner--audit' : ''}}"> <view class="tab-bar-inner {{hideMiddleFab ? 'tab-bar-inner--audit' : ''}}">
<view <view
class="tab-item {{selected === 0 ? 'active' : ''}}" class="tab-item {{selected === 0 ? 'active' : ''}}"
data-index="0" data-index="0"
@@ -13,7 +13,7 @@
</view> </view>
<!-- 中间列:非审核模式显示「拍摄」 --> <!-- 中间列:非审核模式显示「拍摄」 -->
<view class="tab-slot-middle" wx:if="{{!reviewMode}}"> <view class="tab-slot-middle" wx:if="{{!hideMiddleFab}}">
<view <view
class="middle-fab {{selected === 1 ? 'active' : ''}}" class="middle-fab {{selected === 1 ? 'active' : ''}}"
data-index="1" data-index="1"

View File

@@ -10,7 +10,9 @@ Page({
siteTitle: '神仙团队AI性格测试', siteTitle: '神仙团队AI性格测试',
startButtonEnterprise: '开始面部测试', startButtonEnterprise: '开始面部测试',
aiAnalysisText: '智能分析', aiAnalysisText: '智能分析',
maintenanceMode: false maintenanceMode: false,
reviewMode: false,
permFace: true
}, },
onLoad(options) { onLoad(options) {
@@ -62,13 +64,17 @@ Page({
const navbarHeightRpx = statusBarHeightRpx + 88 const navbarHeightRpx = statusBarHeightRpx + 88
const gd = app.globalData const gd = app.globalData
const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode) const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const pf = !ep || ep.face !== false
this.setData({ this.setData({
statusBarHeight: statusBarHeightRpx, statusBarHeight: statusBarHeightRpx,
navbarHeight: navbarHeightRpx, navbarHeight: navbarHeightRpx,
siteTitle: gd.siteTitle || '神仙团队AI性格测试', siteTitle: gd.siteTitle || '神仙团队AI性格测试',
startButtonEnterprise: maintenanceMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'), startButtonEnterprise: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析', aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf
}) })
// 未绑定企业的用户:扫码带 eid 或超管配置了默认企业时也允许使用企业版 // 未绑定企业的用户:扫码带 eid 或超管配置了默认企业时也允许使用企业版
@@ -92,10 +98,14 @@ Page({
if (cfg.reviewMode !== undefined || cfg.maintenanceMode !== undefined) { if (cfg.reviewMode !== undefined || cfg.maintenanceMode !== undefined) {
app.globalData.reviewMode = !!(cfg.reviewMode || cfg.maintenanceMode) app.globalData.reviewMode = !!(cfg.reviewMode || cfg.maintenanceMode)
} }
const ep2 = app.globalData.enterprisePermissions
const pf2 = !ep2 || ep2.face !== false
this.setData({ this.setData({
startButtonEnterprise: maintenanceMode ? '开始性格测试' : (cfg.textConfig && cfg.textConfig.startButtonEnterprise || '开始面部测试'), startButtonEnterprise: (maintenanceMode || !pf2) ? '开始性格测试' : (cfg.textConfig && cfg.textConfig.startButtonEnterprise || '开始面部测试'),
aiAnalysisText: (cfg.textConfig && cfg.textConfig.aiAnalysisText) || '智能分析', aiAnalysisText: (cfg.textConfig && cfg.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf2
}) })
} }
}).catch(() => {}) }).catch(() => {})
@@ -120,10 +130,14 @@ Page({
if (cfg.reviewMode !== undefined || cfg.maintenanceMode !== undefined) { if (cfg.reviewMode !== undefined || cfg.maintenanceMode !== undefined) {
app.globalData.reviewMode = !!(cfg.reviewMode || cfg.maintenanceMode) app.globalData.reviewMode = !!(cfg.reviewMode || cfg.maintenanceMode)
} }
const ep3 = app.globalData.enterprisePermissions
const pf3 = !ep3 || ep3.face !== false
this.setData({ this.setData({
startButtonEnterprise: maintenanceMode ? '开始性格测试' : (cfg.textConfig && cfg.textConfig.startButtonEnterprise || '开始面部测试'), startButtonEnterprise: (maintenanceMode || !pf3) ? '开始性格测试' : (cfg.textConfig && cfg.textConfig.startButtonEnterprise || '开始面部测试'),
aiAnalysisText: (cfg.textConfig && cfg.textConfig.aiAnalysisText) || '智能分析', aiAnalysisText: (cfg.textConfig && cfg.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf3
}) })
} }
if ((cfg && cfg.pricingType) !== 'enterprise' && !app.globalData.enterpriseIdFromScene && !(app.globalData.defaultEnterpriseId && Number(app.globalData.defaultEnterpriseId) > 0)) { if ((cfg && cfg.pricingType) !== 'enterprise' && !app.globalData.enterpriseIdFromScene && !(app.globalData.defaultEnterpriseId && Number(app.globalData.defaultEnterpriseId) > 0)) {
@@ -157,18 +171,20 @@ Page({
onShow() { onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar() const tabBar = this.getTabBar()
const gd = getApp().globalData || {} if (typeof tabBar.updateSelected === 'function') tabBar.updateSelected()
const audit = !!(gd.reviewMode || gd.maintenanceMode)
tabBar.setData({ selected: 0, reviewMode: audit })
} }
try { getApp().globalData.appScope = 'enterprise' } catch (e) {} try { getApp().globalData.appScope = 'enterprise' } catch (e) {}
const gd = getApp().globalData const gd = getApp().globalData
const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode) const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep4 = gd.enterprisePermissions
const pf4 = !ep4 || ep4.face !== false
this.setData({ this.setData({
siteTitle: gd.siteTitle || '神仙团队AI性格测试', siteTitle: gd.siteTitle || '神仙团队AI性格测试',
startButtonEnterprise: maintenanceMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'), startButtonEnterprise: (maintenanceMode || !pf4) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析', aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf4
}) })
}, },
@@ -181,7 +197,8 @@ Page({
// 开始AI面部测试先校验是否已上传简历再跳转相机审核模式下直接跳转 test-select // 开始AI面部测试先校验是否已上传简历再跳转相机审核模式下直接跳转 test-select
startAITest() { startAITest() {
if (this.data.maintenanceMode) { const ep = getApp().globalData.enterprisePermissions
if (this.data.maintenanceMode || (ep && ep.face === false)) {
wx.navigateTo({ url: '/pages/test-select/index' }) wx.navigateTo({ url: '/pages/test-select/index' })
return return
} }

View File

@@ -1,80 +1,80 @@
<!--pages/enterprise/index.wxml - 企业版首页--> <!--pages/enterprise/index.wxml - 企业版首页-->
<view class="container" style="padding-top: {{navbarHeight}}px;"> <view class="container" style="padding-top: {{navbarHeight}}px;">
<!-- 自定义导航栏 --> <!-- 自定义导航栏 -->
<view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;"> <view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;">
<view class="navbar-content"> <view class="navbar-content">
<view class="switch-personal-btn" bindtap="switchToPersonal"> <view class="switch-personal-btn" bindtap="switchToPersonal">
<text class="personal-icon">👤</text> <text class="personal-icon">👤</text>
<text class="personal-text">个人版</text> <text class="personal-text">个人版</text>
</view> </view>
<view class="navbar-title">{{siteTitle || '神仙团队性格测试'}}</view> <view class="navbar-title">{{siteTitle || '神仙团队性格测试'}}</view>
<view class="navbar-placeholder"></view> <view class="navbar-placeholder"></view>
</view> </view>
</view> </view>
<!-- 背景装饰 --> <!-- 背景装饰 -->
<view class="bg-decoration bg-top-right"></view> <view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view> <view class="bg-decoration bg-bottom-left"></view>
<view class="top-image-section"> <view class="top-image-section">
<view class="image-container"> <view class="image-container">
<view class="image-wrapper"> <view class="image-wrapper">
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image> <image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view> </view>
<view class="float-tag tag-1" wx:if="{{!reviewMode}}">面相分析</view> <view class="float-tag tag-1" wx:if="{{!reviewMode && permFace}}">面相分析</view>
<view class="float-tag tag-2" wx:if="{{!reviewMode}}">骨相分析</view> <view class="float-tag tag-2" wx:if="{{!reviewMode && permFace}}">骨相分析</view>
<view class="float-tag tag-3">性格测评</view> <view class="float-tag tag-3">性格测评</view>
<view class="float-tag tag-1" wx:if="{{reviewMode}}">MBTI</view> <view class="float-tag tag-1" wx:if="{{reviewMode}}">MBTI</view>
<view class="float-tag tag-2" wx:if="{{reviewMode}}">DISC</view> <view class="float-tag tag-2" wx:if="{{reviewMode}}">DISC</view>
</view> </view>
</view> </view>
<view class="process-section"> <view class="process-section">
<view class="section-title">测试流程</view> <view class="section-title">测试流程</view>
<view class="process-steps" wx:if="{{!reviewMode}}"> <view class="process-steps" wx:if="{{!reviewMode && permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
<text class="step-text">拍摄照片</text> <text class="step-text">拍摄照片</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">2</view> <view class="step-circle">2</view>
<text class="step-label">STEP2</text> <text class="step-label">STEP2</text>
<text class="step-text">{{aiAnalysisText || '分析'}}</text> <text class="step-text">{{aiAnalysisText || '分析'}}</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">3</view> <view class="step-circle">3</view>
<text class="step-label">STEP3</text> <text class="step-label">STEP3</text>
<text class="step-text">生成报告</text> <text class="step-text">生成报告</text>
</view> </view>
</view> </view>
<view class="process-steps" wx:if="{{reviewMode}}"> <view class="process-steps" wx:if="{{reviewMode || !permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
<text class="step-text">选择测试</text> <text class="step-text">选择测试</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">2</view> <view class="step-circle">2</view>
<text class="step-label">STEP2</text> <text class="step-label">STEP2</text>
<text class="step-text">回答问题</text> <text class="step-text">回答问题</text>
</view> </view>
<view class="step-line"></view> <view class="step-line"></view>
<view class="step-item"> <view class="step-item">
<view class="step-circle">3</view> <view class="step-circle">3</view>
<text class="step-label">STEP3</text> <text class="step-label">STEP3</text>
<text class="step-text">查看结果</text> <text class="step-text">查看结果</text>
</view> </view>
</view> </view>
</view> </view>
<view class="start-button" bindtap="startAITest"> <view class="start-button" bindtap="startAITest">
<text class="button-text">{{startButtonEnterprise || '开始性格测试'}}</text> <text class="button-text">{{startButtonEnterprise || '开始性格测试'}}</text>
</view> </view>
<!-- 自定义底部导航 --> <!-- 自定义底部导航 -->
<custom-tab-bar /> <custom-tab-bar />
</view> </view>

View File

@@ -8,9 +8,16 @@ Page({
tabName: '', tabName: '',
list: [], list: [],
total: 0, total: 0,
page: 1,
hasMore: false,
loading: false, loading: false,
loadingMore: false,
isEnterprise: false, isEnterprise: false,
reviewMode: false reviewMode: false,
permMbti: true,
permPdp: true,
permDisc: true,
permFace: true
}, },
_checkIsEnterprise() { _checkIsEnterprise() {
@@ -21,54 +28,111 @@ Page({
return scope === 'enterprise' || !!enterpriseId return scope === 'enterprise' || !!enterpriseId
}, },
_syncPermsAndTab() {
const p = app.globalData.enterprisePermissions
const permMbti = !p || p.mbti !== false
const permPdp = !p || p.pdp !== false
const permDisc = !p || p.disc !== false
const permFace = !p || p.face !== false
const reviewMode = !!app.globalData.reviewMode
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
let { activeTab } = this.data
if (activeTab === 'mbti' && !permMbti) activeTab = 'all'
if (activeTab === 'pdp' && !permPdp) activeTab = 'all'
if (activeTab === 'disc' && !permDisc) activeTab = 'all'
if (activeTab === 'ai' && (!permFace || reviewMode)) activeTab = 'all'
this.setData({
permMbti, permPdp, permDisc, permFace, reviewMode,
activeTab,
tabName: names[activeTab] || ''
})
},
onLoad() { onLoad() {
this.setData({ isEnterprise: this._checkIsEnterprise() }) this.setData({ isEnterprise: this._checkIsEnterprise() })
this._syncPermsAndTab()
this.loadAll() this.loadAll()
}, },
onShow() { onShow() {
this.setData({ isEnterprise: this._checkIsEnterprise(), reviewMode: !!app.globalData.reviewMode }) this.setData({ isEnterprise: this._checkIsEnterprise() })
this._syncPermsAndTab()
this.loadAll() this.loadAll()
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
}, },
// 一次拉取全部历史pageSize=500 /** 首屏/切换 Tab接口默认 pageSize=10更多走 loadMore */
loadAll() { loadAll() {
if (this.data.loading) return if (this.data.loading) return
this.setData({ loading: true, list: [] }) this.setData({ loading: true, list: [], page: 1, hasMore: false, loadingMore: false })
this._fetchHistoryPage(1, false)
},
/** 触底加载下一页 */
loadMore() {
if (!this.data.hasMore || this.data.loadingMore || this.data.loading) return
const next = this.data.page + 1
this.setData({ loadingMore: true })
this._fetchHistoryPage(next, true)
},
onReachBottom() {
this.loadMore()
},
_fetchHistoryPage(pageNum, append) {
const token = app.globalData.token || wx.getStorageSync('token') const token = app.globalData.token || wx.getStorageSync('token')
const apiBase = app.globalData.apiBase const apiBase = app.globalData.apiBase
const { activeTab } = this.data const { activeTab } = this.data
const typeParam = activeTab === 'all' ? '' : `&type=${activeTab}` const typeParam = activeTab === 'all' ? '' : `&type=${activeTab}`
if (!token || !apiBase) { if (!token || !apiBase) {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() this.loadFromStorage()
return return
} }
wx.request({ wx.request({
url: `${apiBase}/api/test/history?page=1&pageSize=500${typeParam}&scope=all`, url: `${apiBase}/api/test/history?page=${pageNum}&pageSize=10${typeParam}&scope=all`,
method: 'GET', method: 'GET',
header: { Authorization: `Bearer ${token}` }, header: { Authorization: `Bearer ${token}` },
success: (res) => { success: (res) => {
if (res.statusCode === 200 && res.data && res.data.data) { if (res.statusCode === 200 && res.data && res.data.data) {
const payload = res.data.data const payload = res.data.data
const rawList = Array.isArray(payload) ? payload : (payload.list || []) const rawList = payload.list || []
const total = Array.isArray(payload) ? rawList.length : (payload.total || 0)
const formatted = this.formatList(rawList) const formatted = this.formatList(rawList)
this.setData({ list: formatted, total, loading: false }) const total = typeof payload.total === 'number' ? payload.total : formatted.length
const hasMore = !!payload.hasMore
if (append) {
this.setData({
list: this.data.list.concat(formatted),
total,
hasMore,
page: pageNum,
loadingMore: false,
loading: false
})
} else {
this.setData({
list: formatted,
total,
hasMore,
page: pageNum,
loading: false,
loadingMore: false
})
}
} else { } else {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() if (!append) this.loadFromStorage()
} }
}, },
fail: () => { fail: () => {
this.setData({ loading: false }) this.setData({ loading: false, loadingMore: false })
this.loadFromStorage() if (!append) this.loadFromStorage()
} }
}) })
}, },
@@ -79,7 +143,8 @@ Page({
return rawList.map((item, idx) => { return rawList.map((item, idx) => {
if (item.typeName) { if (item.typeName) {
return { ...item, enterpriseName: item.enterpriseName || '' } const t = String(item.testType || item.type || 'mbti').toLowerCase()
return { ...item, type: t, enterpriseName: item.enterpriseName || '' }
} }
const testType = (item.testType || item.type || 'mbti').toLowerCase() const testType = (item.testType || item.type || 'mbti').toLowerCase()
const ts = item.createdAt || item.testTime || item.timestamp const ts = item.createdAt || item.testTime || item.timestamp
@@ -97,16 +162,17 @@ Page({
// 本地缓存回退 // 本地缓存回退
loadFromStorage() { loadFromStorage() {
const { permMbti, permPdp, permDisc, permFace, reviewMode } = this.data
const mbtiResult = wx.getStorageSync('mbtiResult') const mbtiResult = wx.getStorageSync('mbtiResult')
const discResult = wx.getStorageSync('discResult') const discResult = wx.getStorageSync('discResult')
const pdpResult = wx.getStorageSync('pdpResult') const pdpResult = wx.getStorageSync('pdpResult')
const aiResult = wx.getStorageSync('aiResult') const aiResult = wx.getStorageSync('aiResult')
const list = [] const list = []
if (mbtiResult) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult }) if (mbtiResult && permMbti) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult })
if (pdpResult) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult }) if (pdpResult && permPdp) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult })
if (discResult) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult }) if (discResult && permDisc) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult })
if (aiResult && !app.globalData.reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult }) if (aiResult && permFace && !reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult })
this.setData({ list, total: list.length, loading: false }) this.setData({ list, total: list.length, page: 1, hasMore: false, loading: false, loadingMore: false })
}, },
changeTab(e) { changeTab(e) {

View File

@@ -7,21 +7,18 @@
<view class="tab-item {{activeTab === 'all' ? 'active' : ''}}" bindtap="changeTab" data-tab="all"> <view class="tab-item {{activeTab === 'all' ? 'active' : ''}}" bindtap="changeTab" data-tab="all">
<text class="tab-text">全部</text> <text class="tab-text">全部</text>
</view> </view>
<view class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti"> <view wx:if="{{permMbti}}" class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti">
<text class="tab-text">MBTI</text> <text class="tab-text">MBTI</text>
</view> </view>
<view class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp"> <view wx:if="{{permPdp}}" class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp">
<text class="tab-text">PDP</text> <text class="tab-text">PDP</text>
</view> </view>
<view class="tab-item {{activeTab === 'disc' ? 'active' : ''}}" bindtap="changeTab" data-tab="disc"> <view wx:if="{{permDisc}}" class="tab-item {{activeTab === 'disc' ? 'active' : ''}}" bindtap="changeTab" data-tab="disc">
<text class="tab-text">DISC</text> <text class="tab-text">DISC</text>
</view> </view>
<view wx:if="{{!reviewMode}}" class="tab-item {{activeTab === 'ai' ? 'active' : ''}}" bindtap="changeTab" data-tab="ai"> <view wx:if="{{!reviewMode && permFace}}" class="tab-item {{activeTab === 'ai' ? 'active' : ''}}" bindtap="changeTab" data-tab="ai">
<text class="tab-text">面相</text> <text class="tab-text">面相</text>
</view> </view>
<view wx:if="{{isEnterprise}}" class="tab-item {{activeTab === 'resume' ? 'active' : ''}}" bindtap="changeTab" data-tab="resume">
<text class="tab-text">简历</text>
</view>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
@@ -57,11 +54,12 @@
</view> </view>
</view> </view>
<!-- 加载中 --> <view class="load-more-row" wx:if="{{loadingMore}}">
<view class="load-more-row" wx:if="{{loading}}"> <text class="load-more-text">加载更多...</text>
<text class="load-more-text">加载中...</text> </view>
<view wx:elif="{{hasMore && list.length > 0}}" class="load-more-row">
<text class="load-more-text">上拉或继续滑动加载更多</text>
</view> </view>
<view wx:elif="{{list.length > 0}}" class="load-more-row"> <view wx:elif="{{list.length > 0}}" class="load-more-row">
<text class="load-more-text no-more">共 {{total}} 条</text> <text class="load-more-text no-more">共 {{total}} 条</text>
</view> </view>

View File

@@ -72,16 +72,22 @@ Page({
onShow() { onShow() {
const rm = this._audit() const rm = this._audit()
const faceOff = !!(app.globalData.enterprisePermissions && app.globalData.enterprisePermissions.face === false)
this.setData({ reviewMode: rm }) this.setData({ reviewMode: rm })
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1, reviewMode: rm }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
// 审核模式:只展示本页引导,不再强跳 navigateTo失败时曾导致白屏且无拍摄区 // 审核模式:只展示本页引导,不再强跳 navigateTo失败时曾导致白屏且无拍摄区
if (rm) { if (rm) {
return return
} }
if (faceOff) {
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
if (!ensureProfileCompleteAndRedirect()) { if (!ensureProfileCompleteAndRedirect()) {
return return

View File

@@ -10,7 +10,8 @@ Page({
siteTitle: '神仙团队性格测试', siteTitle: '神仙团队性格测试',
startButtonText: '开始性格测试', startButtonText: '开始性格测试',
aiAnalysisText: '分析', aiAnalysisText: '分析',
reviewMode: true reviewMode: true,
permFace: true
}, },
onLoad(options) { onLoad(options) {
@@ -25,6 +26,8 @@ Page({
const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {} const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {}
const gd = getApp().globalData const gd = getApp().globalData
const rm0 = !!(gd.reviewMode || gd.maintenanceMode) const rm0 = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const pf = !ep || ep.face !== false
this.setData({ this.setData({
statusBarHeight: statusBarHeightRpx, statusBarHeight: statusBarHeightRpx,
navbarHeight: navbarHeightRpx, navbarHeight: navbarHeightRpx,
@@ -32,7 +35,8 @@ Page({
siteTitle: rm0 ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'), siteTitle: rm0 ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'), startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm0 ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'), aiAnalysisText: rm0 ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm0 reviewMode: rm0,
permFace: pf
}) })
// 预加载站点名称与文案配置 // 预加载站点名称与文案配置
app.getRuntimeConfig().then((cfg) => { app.getRuntimeConfig().then((cfg) => {
@@ -53,7 +57,8 @@ Page({
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析') aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
}) })
} }
this.setData({ reviewMode: rm }) const ep2 = getApp().globalData.enterprisePermissions
this.setData({ reviewMode: rm, permFace: !ep2 || ep2.face !== false })
try { try {
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
@@ -97,9 +102,9 @@ Page({
onShow() { onShow() {
const gd0 = getApp().globalData || {} const gd0 = getApp().globalData || {}
const audit = !!(gd0.reviewMode || gd0.maintenanceMode)
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0, reviewMode: audit }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
// 个人版首页:固定 scope=personal并清除企业来源上下文 // 个人版首页:固定 scope=personal并清除企业来源上下文
try { try {
@@ -108,11 +113,13 @@ Page({
} catch (e) {} } catch (e) {}
const g = getApp().globalData const g = getApp().globalData
const rm = !!(g.reviewMode || g.maintenanceMode) const rm = !!(g.reviewMode || g.maintenanceMode)
const ep3 = g.enterprisePermissions
this.setData({ this.setData({
siteTitle: rm ? (g.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (g.siteTitle || '神仙团队性格测试'), siteTitle: rm ? (g.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (g.siteTitle || '神仙团队性格测试'),
startButtonText: rm ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '拍摄'), startButtonText: (rm || (ep3 && ep3.face === false)) ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((g.textConfig && g.textConfig.aiAnalysisText) || '分析'), aiAnalysisText: rm ? '分析' : ((g.textConfig && g.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm reviewMode: rm,
permFace: !ep3 || ep3.face !== false
}) })
const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {} const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {}
this.setData({ showEnterpriseEntry: userInfo.hasEnterprise === true }) this.setData({ showEnterpriseEntry: userInfo.hasEnterprise === true })
@@ -138,7 +145,9 @@ Page({
startCamera() { startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {} try { getApp().globalData.appScope = 'personal' } catch (e) {}
const gd = getApp().globalData const gd = getApp().globalData
if (gd.reviewMode || gd.maintenanceMode) { const ep = gd.enterprisePermissions
const faceOff = ep && ep.face === false
if (gd.reviewMode || gd.maintenanceMode || faceOff) {
wx.navigateTo({ url: '/pages/test-select/index' }) wx.navigateTo({ url: '/pages/test-select/index' })
return return
} }

View File

@@ -22,8 +22,8 @@
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image> <image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view> </view>
<!-- 审核模式:只显示性格测评标签 --> <!-- 审核模式:只显示性格测评标签 -->
<view class="float-tag tag-1" wx:if="{{!reviewMode}}">面相分析</view> <view class="float-tag tag-1" wx:if="{{!reviewMode && permFace}}">面相分析</view>
<view class="float-tag tag-2" wx:if="{{!reviewMode}}">骨相分析</view> <view class="float-tag tag-2" wx:if="{{!reviewMode && permFace}}">骨相分析</view>
<view class="float-tag tag-3">性格测评</view> <view class="float-tag tag-3">性格测评</view>
<view class="float-tag tag-1" wx:if="{{reviewMode}}">MBTI</view> <view class="float-tag tag-1" wx:if="{{reviewMode}}">MBTI</view>
<view class="float-tag tag-2" wx:if="{{reviewMode}}">DISC</view> <view class="float-tag tag-2" wx:if="{{reviewMode}}">DISC</view>
@@ -32,7 +32,7 @@
<view class="process-section"> <view class="process-section">
<view class="section-title">测试流程</view> <view class="section-title">测试流程</view>
<view class="process-steps" wx:if="{{!reviewMode}}"> <view class="process-steps" wx:if="{{!reviewMode && permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>
@@ -51,8 +51,8 @@
<text class="step-text">生成报告</text> <text class="step-text">生成报告</text>
</view> </view>
</view> </view>
<!-- 审核模式:纯问卷流程 --> <!-- 审核模式或 face 权限关闭:纯问卷流程 -->
<view class="process-steps" wx:if="{{reviewMode}}"> <view class="process-steps" wx:if="{{reviewMode || !permFace}}">
<view class="step-item"> <view class="step-item">
<view class="step-circle active">1</view> <view class="step-circle active">1</view>
<text class="step-label">STEP1</text> <text class="step-label">STEP1</text>

View File

@@ -35,7 +35,8 @@ Page({
onShow() { onShow() {
const audit = !!(app.globalData.reviewMode || app.globalData.maintenanceMode) const audit = !!(app.globalData.reviewMode || app.globalData.maintenanceMode)
if (audit) { const faceOff = !!(app.globalData.enterprisePermissions && app.globalData.enterprisePermissions.face === false)
if (audit || faceOff) {
wx.navigateTo({ url: '/pages/test-select/index' }) wx.navigateTo({ url: '/pages/test-select/index' })
return return
} }
@@ -43,7 +44,8 @@ Page({
return return
} }
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1, reviewMode: audit }) const tb = this.getTabBar()
if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
this.setData({ needPhoneAuth: !hasPhone() }) this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig const tc = app.globalData.textConfig

View File

@@ -40,25 +40,65 @@ Page({
promoTotalEarned: '0.00', promoTotalEarned: '0.00',
promoWithdrawable: '0.00', promoWithdrawable: '0.00',
/** 是否有企业权限(绑定企业):有则显示「我的简历」 */ /** 是否有企业权限(绑定企业):有则显示「我的简历」 */
hasEnterprise: false hasEnterprise: false,
// 企业功能权限:默认全开(个人版 / 未配置时)
permFace: true,
permMbti: true,
permPdp: true,
permDisc: true,
permDistribution: true,
/** 最新测试横滑区:仅有已出结果的卡片时才显示,避免禁权或未测评占位 */
showLatestTestCards: false,
/** 用户卡片下性格标签:在「当前权限下无任何问卷结果」时显示灰色提示 */
showEmptyPersonalityTags: true
},
_computeShowLatestTestCards(d) {
const rm = !!(d.reviewMode)
if (d.permMbti && d.mbtiType) return true
if (d.permPdp && d.pdpType) return true
if (d.permDisc && d.discType) return true
if (!rm && d.permFace && (d.gallupPreview || d.aiType)) return true
return false
},
_computeShowEmptyPersonalityTags(d) {
return !((d.mbtiType && d.permMbti) || (d.discType && d.permDisc) || (d.pdpType && d.permPdp))
}, },
onLoad() { onLoad() {
// 仅在 onLoad 执行一次onShow 里的 runLoginThenLoad 会导致重复请求 // 仅在 onLoad 执行一次onShow 里的 runLoginThenLoad 会导致重复请求
this.runLoginThenLoad() this.runLoginThenLoad()
}, },
_syncPerms(overrides = {}) {
const p = app.globalData.enterprisePermissions
const next = {
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false,
permDistribution: !p || p.distribution !== false,
...overrides
}
const d = { ...this.data, ...next }
this.setData({
...next,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
})
},
onShow() { onShow() {
// 同步审核模式(与 runtime maintenanceMode / reviewMode 一致)
const gd = app.globalData const gd = app.globalData
this.setData({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) }) this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
// 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程 // 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程
if (this.data.hasLogin) { if (this.data.hasLogin) {
this.loadData() this.loadData()
} }
if (typeof this.getTabBar === 'function' && this.getTabBar()) { if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const audit = !!(gd.reviewMode || gd.maintenanceMode) const tb = this.getTabBar()
this.getTabBar().setData({ selected: 2, reviewMode: audit }) if (typeof tb.updateSelected === 'function') tb.updateSelected()
} }
}, },
@@ -108,6 +148,9 @@ Page({
}, },
loadData() { loadData() {
const gd = app.globalData
this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo')
const token = app.globalData.token || wx.getStorageSync('token') const token = app.globalData.token || wx.getStorageSync('token')
@@ -164,7 +207,7 @@ Page({
const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : '' const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : ''
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : '' const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
this.setData({ const patch = {
testCount: totalCount, testCount: totalCount,
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai), hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
mbtiType: r.mbti ? r.mbti.resultText : '', mbtiType: r.mbti ? r.mbti.resultText : '',
@@ -180,6 +223,12 @@ Page({
discResultId: r.disc ? r.disc.id : null, discResultId: r.disc ? r.disc.id : null,
pdpResultId: r.pdp ? r.pdp.id : null, pdpResultId: r.pdp ? r.pdp.id : null,
aiResultId: r.ai ? r.ai.id : null, aiResultId: r.ai ? r.ai.id : null,
}
const d = { ...this.data, ...patch }
this.setData({
...patch,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
}) })
}, },
fail: () => { fail: () => {
@@ -234,7 +283,7 @@ Page({
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
this.setData({ const patch = {
testCount, testCount,
hasResults: testCount > 0, hasResults: testCount > 0,
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '', mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
@@ -250,6 +299,12 @@ Page({
discResultId: null, discResultId: null,
pdpResultId: null, pdpResultId: null,
aiResultId: null, aiResultId: null,
}
const d = { ...this.data, ...patch }
this.setData({
...patch,
showLatestTestCards: this._computeShowLatestTestCards(d),
showEmptyPersonalityTags: this._computeShowEmptyPersonalityTags(d)
}) })
}, },

View File

@@ -49,16 +49,16 @@
<!-- 类型标签 --> <!-- 类型标签 -->
<scroll-view scroll-x class="tags-scroll"> <scroll-view scroll-x class="tags-scroll">
<view class="tags-row"> <view class="tags-row">
<view class="tag tag-purple" wx:if="{{mbtiType}}"> <view class="tag tag-purple" wx:if="{{mbtiType && permMbti}}">
<text class="tag-text">{{mbtiType}}</text> <text class="tag-text">{{mbtiType}}</text>
</view> </view>
<view class="tag tag-blue" wx:if="{{discType}}"> <view class="tag tag-blue" wx:if="{{discType && permDisc}}">
<text class="tag-text">{{discType}}型</text> <text class="tag-text">{{discType}}型</text>
</view> </view>
<view class="tag tag-orange" wx:if="{{pdpType}}"> <view class="tag tag-orange" wx:if="{{pdpType && permPdp}}">
<text class="tag-text">{{pdpType}}</text> <text class="tag-text">{{pdpType}}</text>
</view> </view>
<view class="tag tag-gray" wx:if="{{!mbtiType && !discType && !pdpType}}"> <view class="tag tag-gray" wx:if="{{showEmptyPersonalityTags}}">
<text class="tag-text">暂无测试记录</text> <text class="tag-text">暂无测试记录</text>
</view> </view>
</view> </view>
@@ -79,57 +79,57 @@
<text class="depth-header-chevron"></text> <text class="depth-header-chevron"></text>
</view> </view>
</view> </view>
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}"> <scroll-view wx:if="{{showLatestTestCards}}" scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card"> <view class="cards-row cards-row--in-card">
<view class="result-card card-purple {{mbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewMBTI"> <view class="result-card card-purple" bindtap="viewMBTI" wx:if="{{permMbti && mbtiType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple"> <view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text> <text class="card-icon">🧠</text>
</view> </view>
<text class="card-label">MBTI性格</text> <text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType || '未测评'}}</text> <text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiType ? mbtiTime : '点击进入问卷'}}</text> <text class="card-time">{{mbtiTime}}</text>
</view> </view>
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP"> <view class="result-card card-orange" bindtap="viewPDP" wx:if="{{permPdp && pdpType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange"> <view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text> <text class="card-icon">🦁</text>
</view> </view>
<text class="card-label">PDP行为</text> <text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType || '未测评'}}</text> <text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpType ? pdpTime : '点击进入问卷'}}</text> <text class="card-time">{{pdpTime}}</text>
</view> </view>
<view class="result-card card-blue {{discType ? '' : 'result-card--placeholder'}}" bindtap="viewDISC"> <view class="result-card card-blue" bindtap="viewDISC" wx:if="{{permDisc && discType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue"> <view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text> <text class="card-icon">📊</text>
</view> </view>
<text class="card-label">DISC测评</text> <text class="card-label">DISC测评</text>
<text class="card-value">{{discType ? discType + '型' : '未测评'}}</text> <text class="card-value">{{discType + '型'}}</text>
<text class="card-time">{{discType ? discTime : '点击进入问卷'}}</text> <text class="card-time">{{discTime}}</text>
</view> </view>
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" wx:if="{{!reviewMode}}"> <view class="result-card card-teal" bindtap="viewGallup" wx:if="{{!reviewMode && permFace && (gallupPreview || aiType)}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-teal"> <view class="card-icon-wrap card-icon-teal">
<text class="card-icon">⭐</text> <text class="card-icon">⭐</text>
</view> </view>
<text class="card-label">盖洛普优势</text> <text class="card-label">盖洛普优势</text>
<text class="card-value card-value--small">{{gallupPreview || (aiType ? '见面相报告' : '未测评')}}</text> <text class="card-value card-value--small">{{gallupPreview || '见面相报告'}}</text>
<text class="card-time">{{gallupPreview ? aiTime : (aiType ? '已拍面相可查看' : '先完成面相拍摄')}}</text> <text class="card-time">{{aiTime}}</text>
</view> </view>
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" wx:if="{{!reviewMode}}"> <view class="result-card card-rose" bindtap="viewAI" wx:if="{{!reviewMode && permFace && aiType}}">
<view class="card-deco"></view> <view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose"> <view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text> <text class="card-icon">👁️</text>
</view> </view>
<text class="card-label">面相分析</text> <text class="card-label">面相分析</text>
<text class="card-value">{{aiType || '未测评'}}</text> <text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiType ? aiTime : '去拍摄分析'}}</text> <text class="card-time">{{aiTime}}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view class="depth-empty-hint depth-empty-hint--compact"> <view wx:if="{{showLatestTestCards}}" class="depth-empty-hint depth-empty-hint--compact">
<text>灰色为未完成项,点击即可进入测评;右上方可查看全部历史。</text> <text>点击卡片查看详情;右上方可查看全部测试记录。</text>
</view> </view>
<view class="depth-inner-divider"></view> <view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToTestSelect"> <view class="menu-item menu-item--flat" bindtap="goToTestSelect">
@@ -179,7 +179,7 @@
</view> </view>
<!-- 推广中心(与管理端开关、标题配置一致;卡片样式与其他区块统一) --> <!-- 推广中心(与管理端开关、标题配置一致;卡片样式与其他区块统一) -->
<view class="section px-section" wx:if="{{hasLogin && promoDistributionEnabled && !reviewMode}}"> <view class="section px-section" wx:if="{{hasLogin && promoDistributionEnabled && !reviewMode && permDistribution}}">
<view class="promo-card" bindtap="goToPromo"> <view class="promo-card" bindtap="goToPromo">
<view class="promo-header"> <view class="promo-header">
<view class="promo-title-wrap"> <view class="promo-title-wrap">

View File

@@ -1,21 +1,41 @@
// pages/test-select/index.js - 人脸测试后的详情性格测试入口MBTI / PDP / DISC // pages/test-select/index.js
Page({ const app = getApp()
data: {},
Page({
onLoad() {}, data: {
permFace: true,
// 进入 MBTI 测试 permMbti: true,
goMBTI() { permPdp: true,
wx.navigateTo({ url: '/pages/test/mbti' }) permDisc: true
}, },
// 进入 PDP 测试 onLoad() {
goPDP() { this._syncPerms()
wx.navigateTo({ url: '/pages/test/pdp' }) },
},
onShow() {
// 进入 DISC 测试 this._syncPerms()
goDISC() { },
wx.navigateTo({ url: '/pages/test/disc' })
} _syncPerms() {
}) const p = app.globalData.enterprisePermissions
this.setData({
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false
})
},
goMBTI() {
wx.navigateTo({ url: '/pages/test/mbti' })
},
goPDP() {
wx.navigateTo({ url: '/pages/test/pdp' })
},
goDISC() {
wx.navigateTo({ url: '/pages/test/disc' })
}
})

View File

@@ -1,40 +1,40 @@
<!--pages/test-select/index.wxml - 详细性格测试入口MBTI / PDP / DISC--> <!--pages/test-select/index.wxml - 详细性格测试入口MBTI / PDP / DISC-->
<view class="container"> <view class="container">
<view class="card intro-card"> <view class="card intro-card">
<text class="intro-title">选择一项详细性格测试</text> <text class="intro-title">选择一项详细性格测试</text>
<text class="intro-desc">完成问卷即可获得对应维度的专业性格报告</text> <text class="intro-desc">完成问卷即可获得对应维度的专业性格报告</text>
</view> </view>
<view class="card entry-card" bindtap="goMBTI"> <view class="card entry-card" bindtap="goMBTI" wx:if="{{permMbti}}">
<view class="entry-icon-wrap mbti"> <view class="entry-icon-wrap mbti">
<text class="entry-emoji">🧠</text> <text class="entry-emoji">🧠</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">MBTI 性格测试</text> <text class="entry-name">MBTI 性格测试</text>
<text class="entry-brief">16型人格 · 发现你的认知与决策风格</text> <text class="entry-brief">16型人格 · 发现你的认知与决策风格</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
<view class="card entry-card" bindtap="goPDP"> <view class="card entry-card" bindtap="goPDP" wx:if="{{permPdp}}">
<view class="entry-icon-wrap pdp"> <view class="entry-icon-wrap pdp">
<text class="entry-emoji">🦁</text> <text class="entry-emoji">🦁</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">PDP 行为偏好测试</text> <text class="entry-name">PDP 行为偏好测试</text>
<text class="entry-brief">老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格</text> <text class="entry-brief">老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
<view class="card entry-card" bindtap="goDISC"> <view class="card entry-card" bindtap="goDISC" wx:if="{{permDisc}}">
<view class="entry-icon-wrap disc"> <view class="entry-icon-wrap disc">
<text class="entry-emoji">📊</text> <text class="entry-emoji">📊</text>
</view> </view>
<view class="entry-content"> <view class="entry-content">
<text class="entry-name">DISC 性格测试</text> <text class="entry-name">DISC 性格测试</text>
<text class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</text> <text class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</text>
</view> </view>
<text class="entry-arrow">→</text> <text class="entry-arrow">→</text>
</view> </view>
</view> </view>