feat: 高考志愿与定价分销链路更新(管理端/API/小程序)
- 新增高考志愿核心服务、模型、控制器与迁移脚本 - 同步管理端定价/分销/设置与小程序入口、历史、测试选择和支付逻辑 - 补充相关开发文档 Made-with: Cursor
This commit is contained in:
@@ -579,6 +579,7 @@ const testTypeItems = [
|
||||
{ key: 'sbti', label: 'SBTI 测试' },
|
||||
{ key: 'disc', label: 'DISC 测试' },
|
||||
{ key: 'pdp', label: 'PDP 测试' },
|
||||
{ key: 'gaokao', label: '高考志愿报告' },
|
||||
]
|
||||
type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean }
|
||||
const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false })
|
||||
@@ -588,6 +589,7 @@ const testSettings = reactive<Record<string, TestSetting>>({
|
||||
sbti: makeDefaultTs(),
|
||||
disc: makeDefaultTs(),
|
||||
pdp: makeDefaultTs(),
|
||||
gaokao: makeDefaultTs(),
|
||||
})
|
||||
|
||||
// 加载数据概览
|
||||
@@ -1175,7 +1177,10 @@ onMounted(() => {
|
||||
.card-desc { font-size: 12px; color: #6b7280; margin: 0 0 20px; }
|
||||
|
||||
.ts-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
|
||||
display: grid;
|
||||
/* 一行最多 4 个测试佣金卡片 */
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
.ts-card {
|
||||
background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
.ts-head {
|
||||
@@ -1270,5 +1275,10 @@ onMounted(() => {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
.settings-card .form-grid.three-cols { grid-template-columns: 1fr; }
|
||||
.settings-card .ts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-card .ts-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="personal.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>高考志愿报告 (元/次)</label>
|
||||
<el-input-number v-model="personal.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
@@ -83,6 +87,10 @@
|
||||
<label>SBTI测试价格 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>高考志愿报告 (元/次)</label>
|
||||
<el-input-number v-model="enterprise.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
|
||||
<el-icon class="notice-icon"><InfoFilled /></el-icon>
|
||||
@@ -115,8 +123,8 @@ const tabs = [
|
||||
]
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 })
|
||||
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 })
|
||||
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 })
|
||||
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 })
|
||||
|
||||
const loading = ref(false)
|
||||
const isUsingSuperAdminPersonalConfig = ref(false)
|
||||
@@ -170,7 +178,8 @@ const saveEnterprise = async () => {
|
||||
mbti: enterprise.mbti,
|
||||
disc: enterprise.disc,
|
||||
pdp: enterprise.pdp,
|
||||
sbti: enterprise.sbti
|
||||
sbti: enterprise.sbti,
|
||||
gaokao: enterprise.gaokao
|
||||
}
|
||||
})
|
||||
if (response.code === 200) {
|
||||
|
||||
@@ -319,11 +319,12 @@ const permItems = [
|
||||
{ key: 'sbti', label: 'SBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'gaokao', label: '高考志愿' },
|
||||
{ key: 'distribution', label: '分销推广' }
|
||||
] as const
|
||||
|
||||
const defaultAdminPermissions = () =>
|
||||
({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true }) as Record<string, boolean>
|
||||
({ face: true, mbti: true, sbti: true, pdp: true, disc: true, gaokao: true, distribution: true }) as Record<string, boolean>
|
||||
|
||||
const permLoading = ref(false)
|
||||
const permSaving = ref(false)
|
||||
|
||||
@@ -397,6 +397,7 @@ const testTypeItems = [
|
||||
{ key: 'sbti', label: 'SBTI 测试' },
|
||||
{ key: 'disc', label: 'DISC 测试' },
|
||||
{ key: 'pdp', label: 'PDP 测试' },
|
||||
{ key: 'gaokao', label: '高考志愿报告' },
|
||||
]
|
||||
type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean }
|
||||
const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false })
|
||||
@@ -406,6 +407,7 @@ const testSettings = reactive<Record<string, TestSetting>>({
|
||||
sbti: makeDefaultTs(),
|
||||
disc: makeDefaultTs(),
|
||||
pdp: makeDefaultTs(),
|
||||
gaokao: makeDefaultTs(),
|
||||
})
|
||||
|
||||
// 拒绝弹窗
|
||||
@@ -703,7 +705,10 @@ onMounted(() => { loadOverview(); loadTopAgents() })
|
||||
&.full-width-card { grid-column: 1 / -1; }
|
||||
.setting-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ts-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;
|
||||
display: grid;
|
||||
/* 一行最多 4 个测试佣金卡片 */
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
.ts-card {
|
||||
background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
.ts-head {
|
||||
@@ -752,6 +757,11 @@ onMounted(() => { loadOverview(); loadTopAgents() })
|
||||
@media (max-width: 1200px) {
|
||||
.overview-section .stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.overview-section .two-cols { grid-template-columns: 1fr; }
|
||||
.settings-card .ts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-card .ts-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.page-container.is-embedded {
|
||||
|
||||
@@ -1088,10 +1088,12 @@ const permItems = [
|
||||
{ key: 'sbti', label: 'SBTI' },
|
||||
{ key: 'pdp', label: 'PDP' },
|
||||
{ key: 'disc', label: 'DISC' },
|
||||
{ key: 'gaokao', label: '高考志愿' },
|
||||
{ key: 'distribution', label: '分销' },
|
||||
]
|
||||
|
||||
const defaultPermissions = () => ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true })
|
||||
const defaultPermissions = () =>
|
||||
({ face: true, mbti: true, sbti: true, pdp: true, disc: true, gaokao: true, distribution: true })
|
||||
|
||||
/** 列表/展示:超管授权上限(兼容未返回 permissionsCeiling 的旧接口) */
|
||||
const permCeilingVal = (row: Record<string, any>, key: string) => {
|
||||
|
||||
@@ -85,6 +85,16 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>高考志愿报告 (元/次)</label>
|
||||
<el-input-number
|
||||
v-model="personal.gaokao"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-actions">
|
||||
<el-button type="primary" class="save-btn" @click="savePersonal">
|
||||
@@ -149,6 +159,16 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>高考志愿报告 (元/次)</label>
|
||||
<el-input-number
|
||||
v-model="enterprise.gaokao"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item form-item--span-full">
|
||||
<label>最低充值金额 (元)</label>
|
||||
<el-input-number
|
||||
v-model="enterprise.minRecharge"
|
||||
@@ -468,7 +488,8 @@ const personal = reactive({
|
||||
mbti: 9.9,
|
||||
disc: 9.9,
|
||||
pdp: 9.9,
|
||||
sbti: 9.9
|
||||
sbti: 9.9,
|
||||
gaokao: 0
|
||||
})
|
||||
|
||||
const enterprise = reactive({
|
||||
@@ -477,6 +498,7 @@ const enterprise = reactive({
|
||||
pdp: 8.0,
|
||||
disc: 8.0,
|
||||
sbti: 8.0,
|
||||
gaokao: 0,
|
||||
minRecharge: 1000.0
|
||||
})
|
||||
|
||||
@@ -569,12 +591,12 @@ const loadPricing = async () => {
|
||||
if (response.code === 200 && response.data) {
|
||||
// 更新个人版配置
|
||||
if (response.data.personal) {
|
||||
Object.assign(personal, response.data.personal)
|
||||
Object.assign(personal, { gaokao: 0 }, response.data.personal)
|
||||
}
|
||||
|
||||
|
||||
// 更新企业版配置
|
||||
if (response.data.enterprise) {
|
||||
Object.assign(enterprise, response.data.enterprise)
|
||||
Object.assign(enterprise, { gaokao: 0, minRecharge: enterprise.minRecharge }, response.data.enterprise)
|
||||
}
|
||||
|
||||
// 更新深度服务配置(个人/企业类目)
|
||||
@@ -624,7 +646,14 @@ const savePersonal = async () => {
|
||||
try {
|
||||
const response: any = await request.put('/superadmin/pricing', {
|
||||
type: 'personal',
|
||||
config: personal
|
||||
config: {
|
||||
face: personal.face,
|
||||
mbti: personal.mbti,
|
||||
disc: personal.disc,
|
||||
pdp: personal.pdp,
|
||||
sbti: personal.sbti,
|
||||
gaokao: personal.gaokao
|
||||
}
|
||||
})
|
||||
|
||||
if (response.code === 200) {
|
||||
@@ -648,6 +677,7 @@ const saveEnterprise = async () => {
|
||||
pdp: enterprise.pdp,
|
||||
disc: enterprise.disc,
|
||||
sbti: enterprise.sbti,
|
||||
gaokao: enterprise.gaokao,
|
||||
minRecharge: enterprise.minRecharge
|
||||
}
|
||||
})
|
||||
@@ -865,6 +895,10 @@ onMounted(() => {
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-item--span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
|
||||
@@ -112,12 +112,13 @@ class EnterpriseBillingService
|
||||
private static function getTestTypeLabel(string $testType): string
|
||||
{
|
||||
$map = [
|
||||
'face' => '人脸',
|
||||
'mbti' => 'MBTI',
|
||||
'sbti' => 'SBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
'resume' => '简历',
|
||||
'face' => '人脸',
|
||||
'mbti' => 'MBTI',
|
||||
'sbti' => 'SBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
'resume' => '简历',
|
||||
'gaokao' => '高考志愿',
|
||||
];
|
||||
|
||||
return $map[$testType] ?? strtoupper($testType);
|
||||
|
||||
1143
api/app/common/service/GaokaoService.php
Normal file
1143
api/app/common/service/GaokaoService.php
Normal file
File diff suppressed because it is too large
Load Diff
54
api/app/common/service/TestProductPricing.php
Normal file
54
api/app/common/service/TestProductPricing.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 与 Payment::calculateAmount 中「测试类产品」分支一致,供非 Controller 复用(如高考定价)
|
||||
*/
|
||||
class TestProductPricing
|
||||
{
|
||||
/** @var string[] */
|
||||
public const TEST_PRODUCT_TYPES = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis', 'gaokao'];
|
||||
|
||||
/**
|
||||
* @param int|null $enterpriseId 用于查价的企业 ID:personal 时走 admin_personal+eid;enterprise 时走 admin_enterprise+eid
|
||||
* @param string $pricingTier personal=用户侧个人版定价(含企业后台「个人版」专属价);enterprise=企业版定价
|
||||
* @return array{0:int,1:string} [amountFen, pricingType personal|enterprise]
|
||||
*/
|
||||
public static function amountFenForTestProduct(
|
||||
string $productType,
|
||||
int $wechatUserId,
|
||||
?int $enterpriseId = null,
|
||||
int $quantity = 1,
|
||||
string $pricingTier = 'personal'
|
||||
): array {
|
||||
$pricingType = $pricingTier === 'enterprise' ? 'enterprise' : 'personal';
|
||||
$quantity = $quantity > 0 ? $quantity : 1;
|
||||
|
||||
$pricingEnterpriseId = null;
|
||||
if ($enterpriseId !== null && (int) $enterpriseId > 0) {
|
||||
$pricingEnterpriseId = (int) $enterpriseId;
|
||||
} elseif ($wechatUserId > 0) {
|
||||
$userEid = (int) Db::name('wechat_users')->where('id', $wechatUserId)->value('enterpriseId');
|
||||
if ($userEid > 0) {
|
||||
$pricingEnterpriseId = $userEid;
|
||||
}
|
||||
}
|
||||
|
||||
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId);
|
||||
$config = [];
|
||||
if ($pricingConfig && !empty($pricingConfig->config)) {
|
||||
$raw = $pricingConfig->config;
|
||||
$config = is_array($raw) ? $raw : (array) $raw;
|
||||
}
|
||||
|
||||
$keyMap = ['team_analysis' => 'teamAnalysis'];
|
||||
$key = $keyMap[$productType] ?? $productType;
|
||||
$unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0;
|
||||
$amountFen = (int) round($unitPriceYuan * 100 * $quantity);
|
||||
|
||||
return [$amountFen, $pricingType];
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,8 @@ class AppUser extends BaseController
|
||||
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
|
||||
}
|
||||
$coopMap = [];
|
||||
$gaokaoMap = [];
|
||||
$gaokaoReportMap = [];
|
||||
if (!empty($ids)) {
|
||||
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
|
||||
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
@@ -313,6 +315,47 @@ class AppUser extends BaseController
|
||||
$coopMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 高考任务状态与最近报告摘要
|
||||
try {
|
||||
$gqRows = Db::name('gaokao_user_profile')
|
||||
->whereIn('userId', $ids)
|
||||
->where('tenantId', (int) $enterpriseId)
|
||||
->field('id,userId,entryStatus,mbtiStatus,pdpStatus,discStatus,formStatus,analyzeStatus,lastAnalyzeAt,latestReportId,tagsJson')
|
||||
->select()
|
||||
->toArray();
|
||||
$reportIds = [];
|
||||
foreach ($gqRows as $gr) {
|
||||
$uid = (int) ($gr['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$gaokaoMap[$uid] = $gr;
|
||||
if (!empty($gr['latestReportId'])) {
|
||||
$reportIds[] = (int) $gr['latestReportId'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$reportIds = array_values(array_unique(array_filter($reportIds)));
|
||||
if ($reportIds) {
|
||||
$rRows = Db::name('test_results')
|
||||
->whereIn('id', $reportIds)
|
||||
->where('testType', 'gaokao')
|
||||
->field('id,resultData')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rRows as $rr) {
|
||||
$raw = $rr['resultData'] ?? '';
|
||||
$rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []);
|
||||
$ov = (string) ($rd['overview'] ?? '');
|
||||
if ($ov === '' && isset($rd['report']['overview'])) {
|
||||
$ov = (string) $rd['report']['overview'];
|
||||
}
|
||||
$gaokaoReportMap[(int) $rr['id']] = $ov;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$gaokaoMap = [];
|
||||
$gaokaoReportMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
@@ -358,6 +401,19 @@ class AppUser extends BaseController
|
||||
$row['cooperationModeTitle'] = null;
|
||||
$row['cooperationChosenAt'] = null;
|
||||
}
|
||||
|
||||
$gq = $gaokaoMap[$id] ?? null;
|
||||
$row['gaokaoEntryStatus'] = $gq ? (int) ($gq['entryStatus'] ?? 0) : 0;
|
||||
$row['gaokaoAnalyzeStatus'] = $gq ? (int) ($gq['analyzeStatus'] ?? 0) : 0;
|
||||
$row['gaokaoFormStatus'] = $gq ? (int) ($gq['formStatus'] ?? 0) : 0;
|
||||
$row['gaokaoTaskStatus'] = [
|
||||
'mbti' => $gq ? (int) ($gq['mbtiStatus'] ?? 0) : 0,
|
||||
'pdp' => $gq ? (int) ($gq['pdpStatus'] ?? 0) : 0,
|
||||
'disc' => $gq ? (int) ($gq['discStatus'] ?? 0) : 0,
|
||||
];
|
||||
$row['gaokaoLastAnalyzeAt'] = $gq ? (int) ($gq['lastAnalyzeAt'] ?? 0) : 0;
|
||||
$rid = $gq ? (int) ($gq['latestReportId'] ?? 0) : 0;
|
||||
$row['gaokaoOverview'] = $rid > 0 ? (string) ($gaokaoReportMap[$rid] ?? '') : '';
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
@@ -501,6 +557,28 @@ class AppUser extends BaseController
|
||||
? ResumeUploadsAdminService::listForWechatUser((int) $id, (int) $enterpriseId)
|
||||
: [];
|
||||
|
||||
// 高考结果信息
|
||||
try {
|
||||
$gq = Db::name('gaokao_user_profile')
|
||||
->where('userId', (int) $id)
|
||||
->where('tenantId', (int) $enterpriseId)
|
||||
->find();
|
||||
if ($gq) {
|
||||
$data['gaokaoProfile'] = $gq;
|
||||
$rid = (int) ($gq['latestReportId'] ?? 0);
|
||||
if ($rid > 0) {
|
||||
$data['gaokaoLatestReport'] = Db::name('test_results')
|
||||
->where('id', $rid)
|
||||
->where('testType', 'gaokao')
|
||||
->find();
|
||||
}
|
||||
} else {
|
||||
$data['gaokaoProfile'] = null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$data['gaokaoProfile'] = null;
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
|
||||
@@ -753,7 +753,7 @@ class Distribution extends BaseController
|
||||
private static function defaultTestSettings(): array
|
||||
{
|
||||
$item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false];
|
||||
return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item];
|
||||
return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item, 'gaokao' => $item];
|
||||
}
|
||||
|
||||
private static function sanitizeTestSettings($raw): array
|
||||
@@ -832,12 +832,13 @@ class Distribution extends BaseController
|
||||
}
|
||||
|
||||
$totals = [
|
||||
'face' => 0,
|
||||
'mbti' => 0,
|
||||
'sbti' => 0,
|
||||
'disc' => 0,
|
||||
'pdp' => 0,
|
||||
'other' => 0,
|
||||
'face' => 0,
|
||||
'mbti' => 0,
|
||||
'sbti' => 0,
|
||||
'disc' => 0,
|
||||
'pdp' => 0,
|
||||
'gaokao' => 0,
|
||||
'other' => 0,
|
||||
];
|
||||
|
||||
foreach ($records as $record) {
|
||||
@@ -860,6 +861,7 @@ class Distribution extends BaseController
|
||||
['label' => 'SBTI', 'value' => round($totals['sbti'] / 100, 2)],
|
||||
['label' => 'DISC', 'value' => round($totals['disc'] / 100, 2)],
|
||||
['label' => 'PDP', 'value' => round($totals['pdp'] / 100, 2)],
|
||||
['label' => '高考志愿', 'value' => round($totals['gaokao'] / 100, 2)],
|
||||
['label' => '其他', 'value' => round($totals['other'] / 100, 2)],
|
||||
];
|
||||
}
|
||||
@@ -870,7 +872,7 @@ class Distribution extends BaseController
|
||||
if ($normalized === 'ai') {
|
||||
return 'face';
|
||||
}
|
||||
if (in_array($normalized, ['face', 'mbti', 'sbti', 'disc', 'pdp'], true)) {
|
||||
if (in_array($normalized, ['face', 'mbti', 'sbti', 'disc', 'pdp', 'gaokao'], true)) {
|
||||
return $normalized;
|
||||
}
|
||||
return 'other';
|
||||
@@ -879,12 +881,13 @@ class Distribution extends BaseController
|
||||
private static function getTestTypeLabel(string $testType): string
|
||||
{
|
||||
$map = [
|
||||
'face' => '人脸',
|
||||
'mbti' => 'MBTI',
|
||||
'sbti' => 'SBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
'other' => '其他',
|
||||
'face' => '人脸',
|
||||
'mbti' => 'MBTI',
|
||||
'sbti' => 'SBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
'gaokao' => '高考志愿',
|
||||
'other' => '其他',
|
||||
];
|
||||
|
||||
return $map[$testType] ?? strtoupper($testType ?: '其他');
|
||||
|
||||
96
api/app/controller/admin/GaokaoUser.php
Normal file
96
api/app/controller/admin/GaokaoUser.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业后台:高考用户管理
|
||||
*/
|
||||
class GaokaoUser extends BaseController
|
||||
{
|
||||
private function currentEnterpriseId(): int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array(($user['role'] ?? ''), ['admin', 'enterprise_admin'], true)) {
|
||||
return 0;
|
||||
}
|
||||
$eid = (int) ($user['enterpriseId'] ?? 0);
|
||||
if ($eid > 0) {
|
||||
return $eid;
|
||||
}
|
||||
$adminRow = Db::name('users')->where('id', (int) ($user['userId'] ?? 0))->find();
|
||||
return (int) ($adminRow['enterpriseId'] ?? 0);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$eid = $this->currentEnterpriseId();
|
||||
if ($eid <= 0) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
$keyword = trim((string) Request::param('keyword', ''));
|
||||
$analyzeStatus = Request::param('analyzeStatus', '');
|
||||
|
||||
$q = Db::name('gaokao_user_profile')->alias('g')
|
||||
->join('wechat_users w', 'w.id = g.userId')
|
||||
->where('g.tenantId', $eid);
|
||||
if ($keyword !== '') {
|
||||
$q->whereRaw('(w.nickname LIKE ? OR w.phone LIKE ? OR g.name LIKE ?)', ['%' . $keyword . '%', '%' . $keyword . '%', '%' . $keyword . '%']);
|
||||
}
|
||||
if ($analyzeStatus !== '' && $analyzeStatus !== null) {
|
||||
$q->where('g.analyzeStatus', (int) $analyzeStatus);
|
||||
}
|
||||
|
||||
$total = (int) (clone $q)->count();
|
||||
$rows = (clone $q)->field('g.*,w.nickname,w.phone,w.avatar')
|
||||
->order('g.id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
return paginate_response($rows, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
$eid = $this->currentEnterpriseId();
|
||||
if ($eid <= 0) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$row = Db::name('gaokao_user_profile')->alias('g')
|
||||
->join('wechat_users w', 'w.id = g.userId')
|
||||
->where('g.id', (int) $id)
|
||||
->where('g.tenantId', $eid)
|
||||
->field('g.*,w.nickname,w.phone,w.avatar')
|
||||
->find();
|
||||
if (!$row) {
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
$report = null;
|
||||
if (!empty($row['latestReportId'])) {
|
||||
$tr = Db::name('test_results')
|
||||
->where('id', (int) $row['latestReportId'])
|
||||
->where('testType', 'gaokao')
|
||||
->find();
|
||||
if ($tr) {
|
||||
$report = $tr;
|
||||
}
|
||||
}
|
||||
$orders = Db::name('orders')
|
||||
->where('userId', (int) $row['userId'])
|
||||
->where('productType', 'gaokao')
|
||||
->order('id', 'desc')
|
||||
->limit(20)
|
||||
->select()
|
||||
->toArray();
|
||||
return success([
|
||||
'profile' => $row,
|
||||
'latestReport' => $report,
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ class Pricing extends BaseController
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class Pricing extends BaseController
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
|
||||
@@ -154,6 +154,101 @@ class Distribution extends BaseController
|
||||
return success(['expireAt' => $expireAt], '绑定成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 高考入口等:与 POST /api/distribution/bind 一致地写入 distribution_bindings(不含邀请码解析)
|
||||
*/
|
||||
public static function applyInviteBindingFromGaokao(int $inviteeId, int $inviterId, ?int $enterpriseId): void
|
||||
{
|
||||
if ($inviteeId <= 0 || $inviterId <= 0 || $inviterId === $inviteeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope = $enterpriseId ? 'enterprise' : 'personal';
|
||||
|
||||
if ($scope === 'enterprise') {
|
||||
$inviter = Db::name('wechat_users')
|
||||
->where('id', $inviterId)
|
||||
->field('id, enterpriseId')
|
||||
->find();
|
||||
if (!$inviter || (int) $inviter['enterpriseId'] !== $enterpriseId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
$reverseExists = Db::name('distribution_bindings')
|
||||
->where('inviterId', $inviteeId)
|
||||
->where('inviteeId', $inviterId)
|
||||
->where('scope', $scope)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', $now)
|
||||
->where(function ($query) use ($enterpriseId) {
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$query->whereNull('enterpriseId');
|
||||
}
|
||||
})
|
||||
->find();
|
||||
if ($reverseExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expireAt = $now + self::BINDING_TTL;
|
||||
|
||||
$existing = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $inviteeId)
|
||||
->where('scope', $scope)
|
||||
->where(function ($query) use ($enterpriseId) {
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
} else {
|
||||
$query->whereNull('enterpriseId');
|
||||
}
|
||||
})
|
||||
->find();
|
||||
|
||||
if (!$existing) {
|
||||
Db::name('distribution_bindings')->insert([
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'scope' => $scope,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'expireAt' => $expireAt,
|
||||
'status' => 'active',
|
||||
'prevInviterId' => null,
|
||||
'overriddenAt' => null,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
} elseif ((int) $existing['inviterId'] === $inviterId) {
|
||||
Db::name('distribution_bindings')
|
||||
->where('id', $existing['id'])
|
||||
->update([
|
||||
'expireAt' => $expireAt,
|
||||
'status' => 'active',
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
} else {
|
||||
$exExpire = (int) ($existing['expireAt'] ?? 0);
|
||||
$exStatus = (string) ($existing['status'] ?? '');
|
||||
if ($exStatus === 'active' && $exExpire > $now) {
|
||||
return;
|
||||
}
|
||||
Db::name('distribution_bindings')
|
||||
->where('id', $existing['id'])
|
||||
->update([
|
||||
'prevInviterId' => (int) $existing['inviterId'],
|
||||
'inviterId' => $inviterId,
|
||||
'expireAt' => $expireAt,
|
||||
'status' => 'active',
|
||||
'overriddenAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/distribution/my-invite-code
|
||||
* 返回当前用户名下可用邀请码;若无记录则自动生成一条(便于前端直接展示)
|
||||
|
||||
120
api/app/controller/api/Gaokao.php
Normal file
120
api/app/controller/api/Gaokao.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\GaokaoService;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 高考志愿功能 API
|
||||
*/
|
||||
class Gaokao extends BaseController
|
||||
{
|
||||
private function wechatUserId(): int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['source'] ?? '') !== 'wechat') {
|
||||
return 0;
|
||||
}
|
||||
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
|
||||
}
|
||||
|
||||
public function taskStatus()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$entry = [
|
||||
'referrerId' => (int) Request::param('referrerId', 0),
|
||||
'channelCode' => (string) Request::param('channelCode', ''),
|
||||
'scene' => (string) Request::param('scene', 'entry'),
|
||||
];
|
||||
GaokaoService::markEntry($uid, $entry);
|
||||
$pricingScope = trim((string) Request::param('pricingScope', 'personal'));
|
||||
$eidParam = (int) Request::param('enterpriseId', 0);
|
||||
|
||||
return success(GaokaoService::loadTaskStatusWithPricing(
|
||||
$uid,
|
||||
$pricingScope === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$eidParam > 0 ? $eidParam : null
|
||||
));
|
||||
}
|
||||
|
||||
public function saveForm()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$form = Request::post();
|
||||
if (!is_array($form)) {
|
||||
$form = [];
|
||||
}
|
||||
GaokaoService::saveForm($uid, $form);
|
||||
return success(GaokaoService::loadTaskStatus($uid), '保存成功');
|
||||
}
|
||||
|
||||
public function myForm()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$profile = GaokaoService::getOrInitProfile($uid);
|
||||
return success([
|
||||
'form' => GaokaoService::formJsonAsArray($profile),
|
||||
'status' => (int) ($profile->formStatus ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
public function analyze()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$pricingScope = trim((string) Request::param('pricingScope', 'personal'));
|
||||
$eidParam = (int) Request::param('enterpriseId', 0);
|
||||
$res = GaokaoService::createAnalysis(
|
||||
$uid,
|
||||
$pricingScope === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$eidParam > 0 ? $eidParam : null
|
||||
);
|
||||
if (empty($res['ok'])) {
|
||||
return error((string) ($res['message'] ?? '分析失败'), 400);
|
||||
}
|
||||
return success($res, '分析成功');
|
||||
}
|
||||
|
||||
public function latestReport()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$row = GaokaoService::myLatestReport($uid);
|
||||
if (!$row) {
|
||||
return error('暂无报告', 404);
|
||||
}
|
||||
return success($row);
|
||||
}
|
||||
|
||||
public function pricing()
|
||||
{
|
||||
$uid = $this->wechatUserId();
|
||||
if ($uid <= 0) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
$productCode = trim((string) Request::param('productCode', 'gaokao_single_report'));
|
||||
$pricingScope = trim((string) Request::param('pricingScope', 'personal'));
|
||||
$eidParam = (int) Request::param('enterpriseId', 0);
|
||||
|
||||
return success(GaokaoService::resolvePricing(
|
||||
$uid,
|
||||
$productCode,
|
||||
$pricingScope === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$eidParam > 0 ? $eidParam : null
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\GaokaoService;
|
||||
use app\common\service\TestProductPricing;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
@@ -77,6 +79,7 @@ class Payment extends BaseController
|
||||
'disc' => 'disc',
|
||||
'pdp' => 'pdp',
|
||||
'resume' => 'resume',
|
||||
'gaokao' => 'gaokao',
|
||||
];
|
||||
if (isset($testTypeMap[$productType])) {
|
||||
$latestTest = Db::name('test_results')
|
||||
@@ -90,6 +93,17 @@ class Payment extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 高考:按当前请求 Tab 刷新未付记录的 paidAmount/enterpriseId,再读库定价(与小程序切换一致)
|
||||
if ($productType === 'gaokao' && $testResultId > 0) {
|
||||
$ps = trim((string) Request::param('pricingScope', 'personal'));
|
||||
GaokaoService::refreshGaokaoTestResultForPayment(
|
||||
$userId,
|
||||
$testResultId,
|
||||
$ps === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$enterpriseIdParam > 0 ? $enterpriseIdParam : null
|
||||
);
|
||||
}
|
||||
|
||||
if ($testResultId > 0) {
|
||||
$tr = Db::name('test_results')
|
||||
->where('id', $testResultId)
|
||||
@@ -138,7 +152,7 @@ class Payment extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
if ($amountFenCalculated <= 0) {
|
||||
if ($amountFenCalculated < 0 || ($amountFenCalculated === 0 && $productType !== 'gaokao')) {
|
||||
return error('订单金额无效,请检查定价配置或请求参数', 400);
|
||||
}
|
||||
|
||||
@@ -201,6 +215,7 @@ class Payment extends BaseController
|
||||
'disc' => 'disc',
|
||||
'pdp' => 'pdp',
|
||||
'resume' => 'resume',
|
||||
'gaokao' => 'gaokao',
|
||||
];
|
||||
if (isset($testTypeMap[$productType])) {
|
||||
$testType = $testTypeMap[$productType];
|
||||
@@ -221,6 +236,35 @@ class Payment extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
if ($productType === 'gaokao') {
|
||||
$this->ensureGaokaoTestResultForOrder($userId, $orderIdDb, $enterpriseId, $now);
|
||||
}
|
||||
|
||||
// 高考志愿 0 元:不调微信统一下单,直接标记已付(避免「金额无效」又卡在任务中心)
|
||||
if ($productType === 'gaokao' && $amountFenCalculated === 0) {
|
||||
$orderRow = Db::name('orders')->where('id', $orderIdDb)->find();
|
||||
if ($orderRow) {
|
||||
$this->completeGaokaoZeroAmountOrder((int) $orderIdDb, $orderRow, $now);
|
||||
}
|
||||
|
||||
return success([
|
||||
'skipWxPay' => true,
|
||||
'orderId' => $orderId,
|
||||
'orderDbId' => $orderIdDb,
|
||||
'amount' => 0,
|
||||
'productType' => $productType,
|
||||
'pricingType' => $pricingType,
|
||||
'description' => $description,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'timeStamp' => (string) time(),
|
||||
'nonceStr' => '',
|
||||
'package' => '',
|
||||
'signType' => 'MD5',
|
||||
'paySign' => '',
|
||||
'prepayId' => '',
|
||||
], '订单已自动完成(0元)');
|
||||
}
|
||||
|
||||
// 真实对接微信统一下单,生成 prepay_id 等参数
|
||||
$wechatConfig = [
|
||||
'appid' => env('WECHAT_APPID', ''), // 小程序 AppID
|
||||
@@ -617,6 +661,102 @@ class Payment extends BaseController
|
||||
return (int) $row['enterpriseId'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 高考 0 元单:标记订单与关联 test_results 已付(不调微信)
|
||||
*
|
||||
* @param array<string,mixed> $orderRow
|
||||
*/
|
||||
protected function completeGaokaoZeroAmountOrder(int $orderIdDb, array $orderRow, int $now): void
|
||||
{
|
||||
Db::name('orders')
|
||||
->where('id', $orderIdDb)
|
||||
->update([
|
||||
'status' => 'paid',
|
||||
'payTime' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
|
||||
Db::name('test_results')
|
||||
->where('orderId', $orderIdDb)
|
||||
->update([
|
||||
'isPaid' => 1,
|
||||
'paidAmount' => 0,
|
||||
'paidAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
'requiresPayment' => 0,
|
||||
]);
|
||||
|
||||
$order = array_merge($orderRow, ['status' => 'paid', 'payTime' => $now]);
|
||||
try {
|
||||
FeishuLeadWebhookService::onOrderPaid($orderIdDb, (int) ($order['userId'] ?? 0));
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$this->creditEnterpriseBalanceForOrder($order, 0, $now);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
try {
|
||||
\app\controller\api\Distribution::settleCommission($orderIdDb);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高考订单:保证存在 testType=gaokao 且 orderId 已绑定的占位行,供支付回调与分销按 orderId 解析 testType
|
||||
*/
|
||||
protected function ensureGaokaoTestResultForOrder(int $userId, int $orderIdDb, ?int $enterpriseId, int $now): void
|
||||
{
|
||||
if ($userId <= 0 || $orderIdDb <= 0) {
|
||||
return;
|
||||
}
|
||||
$exists = Db::name('test_results')
|
||||
->where('orderId', $orderIdDb)
|
||||
->where('testType', 'gaokao')
|
||||
->find();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
$bind = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', 'gaokao')
|
||||
->where('isPaid', 0)
|
||||
->whereRaw('(orderId IS NULL OR orderId = 0)')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($bind) {
|
||||
Db::name('test_results')
|
||||
->where('id', (int) $bind['id'])
|
||||
->where('userId', $userId)
|
||||
->update([
|
||||
'orderId' => $orderIdDb,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$scope = $enterpriseId ? 'enterprise' : 'personal';
|
||||
$placeholder = [
|
||||
'kind' => 'gaokao',
|
||||
'state' => 'awaiting_report',
|
||||
'version' => 'v1',
|
||||
];
|
||||
Db::name('test_results')->insert([
|
||||
'userId' => $userId,
|
||||
'testType' => 'gaokao',
|
||||
'resultData' => json_encode($placeholder, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'score' => null,
|
||||
'orderId' => $orderIdDb,
|
||||
'requiresPayment' => 1,
|
||||
'isPaid' => 0,
|
||||
'paidAmount' => null,
|
||||
'paidAt' => null,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'testScope' => $scope,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业四项测试支付成功后,将订单金额计入企业余额。
|
||||
* 使用 finance_records + orderId 做幂等,避免 notify/query 重复入账。
|
||||
@@ -725,20 +865,30 @@ class Payment extends BaseController
|
||||
$quantity = $quantity > 0 ? $quantity : 1;
|
||||
|
||||
// 1)测试类产品:定价配置中为元,转为分(企业用户按企业ID取价)
|
||||
$testProductTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis'];
|
||||
if (in_array($productType, $testProductTypes, true)) {
|
||||
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId ?? $enterpriseId);
|
||||
$config = [];
|
||||
if ($pricingConfig && !empty($pricingConfig->config)) {
|
||||
$raw = $pricingConfig->config;
|
||||
$config = is_array($raw) ? $raw : (array) $raw;
|
||||
if (in_array($productType, TestProductPricing::TEST_PRODUCT_TYPES, true)) {
|
||||
$userId = 0;
|
||||
if ($user && ($user['source'] ?? '') === 'wechat') {
|
||||
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
|
||||
}
|
||||
// 高考:与 GaokaoService 一致,按子测评是否带 enterpriseId 区分个人档/企业档售价,避免仅因订单带 eid 误用企业版价
|
||||
if ($productType === 'gaokao' && $userId > 0) {
|
||||
$ps = trim((string) Request::param('pricingScope', 'personal'));
|
||||
$pe = (int) Request::param('enterpriseId', 0);
|
||||
|
||||
return GaokaoService::gaokaoSaleAmountForPaymentRecalc(
|
||||
$userId,
|
||||
$ps === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$pe > 0 ? $pe : null
|
||||
);
|
||||
}
|
||||
|
||||
$keyMap = ['team_analysis' => 'teamAnalysis'];
|
||||
$key = $keyMap[$productType] ?? $productType;
|
||||
$unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0;
|
||||
$amountFen = (int) round($unitPriceYuan * 100 * $quantity);
|
||||
return [$amountFen, $pricingType];
|
||||
return TestProductPricing::amountFenForTestProduct(
|
||||
$productType,
|
||||
$userId,
|
||||
$pricingEnterpriseId ?? $enterpriseId,
|
||||
$quantity,
|
||||
$pricingType
|
||||
);
|
||||
}
|
||||
|
||||
// 2)深度服务:定价配置为元,转为分
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\controller\api;
|
||||
use app\BaseController;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\common\service\EnterpriseBillingService;
|
||||
use app\common\service\GaokaoService;
|
||||
use app\model\Enterprise as EnterpriseModel;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\Question as QuestionModel;
|
||||
@@ -250,6 +251,28 @@ class Test extends BaseController
|
||||
'data' => null,
|
||||
], $paymentFields);
|
||||
break;
|
||||
case 'gaokao':
|
||||
$gOverview = '';
|
||||
if (is_array($data)) {
|
||||
$gOverview = (string) ($data['overview'] ?? '');
|
||||
if ($gOverview === '' && isset($data['report']['overview'])) {
|
||||
$gOverview = (string) $data['report']['overview'];
|
||||
}
|
||||
}
|
||||
$hasAnalysis = $gOverview !== ''
|
||||
|| (is_array($data) && isset($data['report']) && is_array($data['report']) && ($data['report'] ?? []) !== []);
|
||||
$list[] = array_merge([
|
||||
'id' => $id,
|
||||
'testType' => 'gaokao',
|
||||
'type' => 'gaokao',
|
||||
'key' => 'gaokao_' . $id,
|
||||
'emoji' => '🎓',
|
||||
'typeName' => '高考志愿',
|
||||
'resultText' => $hasAnalysis ? '已生成' : '志愿报告',
|
||||
'testTime' => $timeLabel,
|
||||
'data' => null,
|
||||
], $paymentFields);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -285,7 +308,7 @@ class Test extends BaseController
|
||||
|
||||
$allowedAll = $this->wechatAllowedTestTypes($userId);
|
||||
// 「我的」卡片不含简历,但 totalCount 与列表需与 history 权限一致
|
||||
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'sbti', 'pdp', 'disc', 'face', 'ai']));
|
||||
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'sbti', 'pdp', 'disc', 'face', 'ai', 'gaokao']));
|
||||
if ($allowedForRecent === []) {
|
||||
return success([
|
||||
'records' => new \stdClass(),
|
||||
@@ -315,13 +338,13 @@ class Test extends BaseController
|
||||
// face 和 ai 视为同一种类型
|
||||
$effectiveType = in_array($type, ['face', 'ai']) ? 'ai' : $type;
|
||||
|
||||
if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'sbti', 'disc', 'pdp', 'ai'])) {
|
||||
if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'sbti', 'disc', 'pdp', 'ai', 'gaokao'])) {
|
||||
$records[$effectiveType] = $this->_formatRecentRow($row);
|
||||
$foundTypes[$effectiveType] = true;
|
||||
}
|
||||
|
||||
// 如果四个类型都找到了,且不需要总数(或者已经有了),可以提前结束
|
||||
if (count($foundTypes) >= 4) {
|
||||
// 如果主要类型都找到了,且不需要总数(或者已经有了),可以提前结束
|
||||
if (count($foundTypes) >= 5) {
|
||||
// 如果不需要精确的总数统计,这里可以 break
|
||||
// 但为了保持接口兼容性,我们继续循环或者已经拿到了 count
|
||||
}
|
||||
@@ -376,6 +399,9 @@ class Test extends BaseController
|
||||
if ($boundEnterpriseId > 0) {
|
||||
$allowed[] = 'resume';
|
||||
}
|
||||
if (!in_array('gaokao', $allowed, true)) {
|
||||
$allowed[] = 'gaokao';
|
||||
}
|
||||
|
||||
return array_values(array_unique($allowed));
|
||||
}
|
||||
@@ -489,6 +515,17 @@ class Test extends BaseController
|
||||
$emoji = '👁️';
|
||||
$typeName = '面相分析';
|
||||
break;
|
||||
case 'gaokao':
|
||||
$ov = (string) ($data['overview'] ?? '');
|
||||
if ($ov === '' && isset($data['report']['overview'])) {
|
||||
$ov = (string) $data['report']['overview'];
|
||||
}
|
||||
$hasReport = $ov !== ''
|
||||
|| (isset($data['report']) && is_array($data['report']) && ($data['report'] ?? []) !== []);
|
||||
$resultText = $hasReport ? '已生成' : '志愿报告';
|
||||
$emoji = '🎓';
|
||||
$typeName = '高考志愿';
|
||||
break;
|
||||
}
|
||||
|
||||
if (in_array($testType, ['face', 'ai'], true)) {
|
||||
@@ -579,6 +616,24 @@ class Test extends BaseController
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
|
||||
if (($row['testType'] ?? '') === 'gaokao' && (int) ($row['isPaid'] ?? 0) === 0) {
|
||||
$ps = trim((string) Request::param('pricingScope', 'personal'));
|
||||
$eid = (int) Request::param('enterpriseId', 0);
|
||||
GaokaoService::refreshGaokaoTestResultForPayment(
|
||||
$userId,
|
||||
$id,
|
||||
$ps === 'enterprise' ? 'enterprise' : 'personal',
|
||||
$eid > 0 ? $eid : null
|
||||
);
|
||||
$row = Db::name('test_results')
|
||||
->where('id', $id)
|
||||
->where('userId', $userId)
|
||||
->find();
|
||||
if (!$row) {
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$out = $this->buildTestDetailPayload($row);
|
||||
|
||||
return success(array_merge($out, [
|
||||
@@ -610,6 +665,9 @@ class Test extends BaseController
|
||||
if ($rowTestType === 'resume') {
|
||||
return error('该类型不支持分享查看', 403);
|
||||
}
|
||||
if ($rowTestType === 'gaokao' && $st === '') {
|
||||
return error('该类型不支持公开分享', 403);
|
||||
}
|
||||
|
||||
if ($st !== '') {
|
||||
if (!$this->verifyShareToken($row, $st)) {
|
||||
@@ -982,6 +1040,19 @@ class Test extends BaseController
|
||||
'_structured' => false,
|
||||
];
|
||||
}
|
||||
if ($testType === 'gaokao') {
|
||||
$ov = (string) ($data['overview'] ?? '');
|
||||
if ($ov === '' && isset($data['report']['overview'])) {
|
||||
$ov = (string) $data['report']['overview'];
|
||||
}
|
||||
$inputSnap = is_array($data['inputSnapshot'] ?? null) ? $data['inputSnapshot'] : [];
|
||||
|
||||
return [
|
||||
'overview' => $ov,
|
||||
'inputSnapshot' => $inputSnap,
|
||||
'locked' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -377,6 +377,8 @@ class AppUser extends BaseController
|
||||
$testTypes = [];
|
||||
$payStats = [];
|
||||
$enterpriseNames = [];
|
||||
$gaokaoMap = [];
|
||||
$gaokaoReportMap = [];
|
||||
if (!empty($ids)) {
|
||||
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
|
||||
$trAggRows = (clone $trBase)
|
||||
@@ -466,6 +468,45 @@ class AppUser extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
$payStats = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$gqRows = Db::name('gaokao_user_profile')
|
||||
->whereIn('userId', $ids)
|
||||
->field('id,userId,tenantId,entryStatus,mbtiStatus,pdpStatus,discStatus,formStatus,analyzeStatus,lastAnalyzeAt,latestReportId')
|
||||
->select()
|
||||
->toArray();
|
||||
$reportIds = [];
|
||||
foreach ($gqRows as $gr) {
|
||||
$uid = (int) ($gr['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$gaokaoMap[$uid] = $gr;
|
||||
if (!empty($gr['latestReportId'])) {
|
||||
$reportIds[] = (int) $gr['latestReportId'];
|
||||
}
|
||||
}
|
||||
}
|
||||
$reportIds = array_values(array_unique(array_filter($reportIds)));
|
||||
if ($reportIds) {
|
||||
$rRows = Db::name('test_results')
|
||||
->whereIn('id', $reportIds)
|
||||
->where('testType', 'gaokao')
|
||||
->field('id,resultData')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rRows as $rr) {
|
||||
$raw = $rr['resultData'] ?? '';
|
||||
$rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []);
|
||||
$ov = (string) ($rd['overview'] ?? '');
|
||||
if ($ov === '' && isset($rd['report']['overview'])) {
|
||||
$ov = (string) $rd['report']['overview'];
|
||||
}
|
||||
$gaokaoReportMap[(int) $rr['id']] = $ov;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$gaokaoMap = [];
|
||||
$gaokaoReportMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
@@ -493,6 +534,15 @@ class AppUser extends BaseController
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $totalPaidFen;
|
||||
$row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0;
|
||||
|
||||
$gq = $gaokaoMap[$id] ?? null;
|
||||
$row['gaokaoEntryStatus'] = $gq ? (int) ($gq['entryStatus'] ?? 0) : 0;
|
||||
$row['gaokaoAnalyzeStatus'] = $gq ? (int) ($gq['analyzeStatus'] ?? 0) : 0;
|
||||
$row['gaokaoFormStatus'] = $gq ? (int) ($gq['formStatus'] ?? 0) : 0;
|
||||
$row['gaokaoTenantId'] = $gq ? (int) ($gq['tenantId'] ?? 0) : 0;
|
||||
$row['gaokaoLastAnalyzeAt'] = $gq ? (int) ($gq['lastAnalyzeAt'] ?? 0) : 0;
|
||||
$rid = $gq ? (int) ($gq['latestReportId'] ?? 0) : 0;
|
||||
$row['gaokaoOverview'] = $rid > 0 ? (string) ($gaokaoReportMap[$rid] ?? '') : '';
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
@@ -564,6 +614,33 @@ class AppUser extends BaseController
|
||||
|
||||
$data['resumeUploads'] = ResumeUploadsAdminService::listForWechatUser((int) $id, null);
|
||||
|
||||
try {
|
||||
$gq = Db::name('gaokao_user_profile')->where('userId', (int) $id)->find();
|
||||
if ($gq) {
|
||||
$data['gaokaoProfile'] = $gq;
|
||||
$rid = (int) ($gq['latestReportId'] ?? 0);
|
||||
if ($rid > 0) {
|
||||
$data['gaokaoLatestReport'] = Db::name('test_results')
|
||||
->where('id', $rid)
|
||||
->where('testType', 'gaokao')
|
||||
->find();
|
||||
}
|
||||
$data['gaokaoOrders'] = Db::name('orders')
|
||||
->where('userId', (int) $id)
|
||||
->where('productType', 'gaokao')
|
||||
->order('id', 'desc')
|
||||
->limit(20)
|
||||
->select()
|
||||
->toArray();
|
||||
} else {
|
||||
$data['gaokaoProfile'] = null;
|
||||
$data['gaokaoOrders'] = [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$data['gaokaoProfile'] = null;
|
||||
$data['gaokaoOrders'] = [];
|
||||
}
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ class Distribution extends BaseController
|
||||
private static function defaultTestSettings(): array
|
||||
{
|
||||
$item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false];
|
||||
return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item];
|
||||
return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item, 'gaokao' => $item];
|
||||
}
|
||||
|
||||
private static function sanitizeTestSettings($raw): array
|
||||
|
||||
105
api/app/controller/superadmin/GaokaoUser.php
Normal file
105
api/app/controller/superadmin/GaokaoUser.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管:高考用户管理(全平台)
|
||||
*/
|
||||
class GaokaoUser extends BaseController
|
||||
{
|
||||
private function authOk(): bool
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
return !!($user && ($user['role'] ?? '') === 'superadmin');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if (!$this->authOk()) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
$keyword = trim((string) Request::param('keyword', ''));
|
||||
$tenantId = (int) Request::param('tenantId', 0);
|
||||
|
||||
$q = Db::name('gaokao_user_profile')->alias('g')
|
||||
->join('wechat_users w', 'w.id = g.userId')
|
||||
->leftJoin('enterprises e', 'e.id = g.tenantId');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$q->whereRaw('(w.nickname LIKE ? OR w.phone LIKE ? OR g.name LIKE ?)', ['%' . $keyword . '%', '%' . $keyword . '%', '%' . $keyword . '%']);
|
||||
}
|
||||
if ($tenantId > 0) {
|
||||
$q->where('g.tenantId', $tenantId);
|
||||
}
|
||||
$total = (int) (clone $q)->count();
|
||||
$rows = (clone $q)
|
||||
->field('g.*,w.nickname,w.phone,w.avatar,e.name as tenantName')
|
||||
->order('g.id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
return paginate_response($rows, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function detail($id)
|
||||
{
|
||||
if (!$this->authOk()) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$row = Db::name('gaokao_user_profile')->alias('g')
|
||||
->join('wechat_users w', 'w.id = g.userId')
|
||||
->leftJoin('enterprises e', 'e.id = g.tenantId')
|
||||
->where('g.id', (int) $id)
|
||||
->field('g.*,w.nickname,w.phone,w.avatar,e.name as tenantName')
|
||||
->find();
|
||||
if (!$row) {
|
||||
return error('记录不存在', 404);
|
||||
}
|
||||
$uid = (int) $row['userId'];
|
||||
$reports = Db::name('test_results')
|
||||
->where('userId', $uid)
|
||||
->where('testType', 'gaokao')
|
||||
->order('id', 'desc')
|
||||
->limit(20)
|
||||
->select()
|
||||
->toArray();
|
||||
$orders = Db::name('orders')
|
||||
->where('userId', $uid)
|
||||
->where('productType', 'gaokao')
|
||||
->order('id', 'desc')
|
||||
->limit(20)
|
||||
->select()
|
||||
->toArray();
|
||||
$orderIds = array_map(static function ($o) {
|
||||
return (int) ($o['id'] ?? 0);
|
||||
}, $orders);
|
||||
$orderIds = array_values(array_filter($orderIds));
|
||||
$commissions = [];
|
||||
if ($orderIds !== []) {
|
||||
$commissions = Db::name('commission_records')
|
||||
->whereIn('orderId', $orderIds)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$binding = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $uid)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', time())
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
return success([
|
||||
'profile' => $row,
|
||||
'reports' => $reports,
|
||||
'orders' => $orders,
|
||||
'commissions' => $commissions,
|
||||
'distributionBinding' => $binding,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,19 @@ class Pricing extends BaseController
|
||||
if (!$config) {
|
||||
return error('定价配置不存在', 404);
|
||||
}
|
||||
$cfg = $config->config;
|
||||
if (is_array($cfg)) {
|
||||
if ($type === 'personal') {
|
||||
$cfg = self::normalizePersonalPricingConfig($cfg);
|
||||
} elseif ($type === 'enterprise') {
|
||||
$cfg = self::normalizeEnterprisePricingConfig($cfg);
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'type' => $config->type,
|
||||
'enterpriseId' => $config->enterpriseId,
|
||||
'config' => $config->config
|
||||
'config' => $cfg
|
||||
]);
|
||||
} else {
|
||||
// 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表
|
||||
@@ -60,6 +69,18 @@ class Pricing extends BaseController
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_array($result['personal'])) {
|
||||
$result['personal'] = self::normalizePersonalPricingConfig($result['personal']);
|
||||
}
|
||||
if (is_array($result['enterprise'])) {
|
||||
$result['enterprise'] = self::normalizeEnterprisePricingConfig($result['enterprise']);
|
||||
}
|
||||
foreach ($result['enterpriseList'] as $i => $entRow) {
|
||||
if (isset($entRow['config']) && is_array($entRow['config'])) {
|
||||
$result['enterpriseList'][$i]['config'] = self::normalizeEnterprisePricingConfig($entRow['config']);
|
||||
}
|
||||
}
|
||||
|
||||
return success($result);
|
||||
}
|
||||
}
|
||||
@@ -196,5 +217,44 @@ class Pricing extends BaseController
|
||||
|
||||
return success(null, "成功保存 {$successCount} 个配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧库 JSON 可能缺少高考等字段,合并默认值便于管理端展示与保存
|
||||
*
|
||||
* @param array<string,mixed> $cfg
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalizePersonalPricingConfig(array $cfg): array
|
||||
{
|
||||
$defaults = [
|
||||
'face' => 0,
|
||||
'mbti' => 0,
|
||||
'disc' => 0,
|
||||
'pdp' => 0,
|
||||
'sbti' => 0,
|
||||
'gaokao' => 0,
|
||||
];
|
||||
|
||||
return array_merge($defaults, $cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $cfg
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalizeEnterprisePricingConfig(array $cfg): array
|
||||
{
|
||||
$defaults = [
|
||||
'face' => 0,
|
||||
'mbti' => 0,
|
||||
'pdp' => 0,
|
||||
'disc' => 0,
|
||||
'sbti' => 0,
|
||||
'gaokao' => 0,
|
||||
'minRecharge' => 0,
|
||||
];
|
||||
|
||||
return array_merge($defaults, $cfg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ class Enterprise extends Model
|
||||
'sbti' => true,
|
||||
'pdp' => true,
|
||||
'disc' => true,
|
||||
/** 高考志愿任务中心 / 分析报告 */
|
||||
'gaokao' => true,
|
||||
/** 详细测试列表中的「AI 性格测试」聚合入口 */
|
||||
'aiHub' => true,
|
||||
'distribution' => true,
|
||||
|
||||
31
api/app/model/GaokaoDistributionAttribution.php
Normal file
31
api/app/model/GaokaoDistributionAttribution.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考分销归因
|
||||
*/
|
||||
class GaokaoDistributionAttribution extends Model
|
||||
{
|
||||
protected $name = 'gaokao_distribution_attribution';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'referrerUserId' => 'int',
|
||||
'channelCode' => 'string',
|
||||
'scene' => 'string',
|
||||
'attributedAt' => 'int',
|
||||
'expireAt' => 'int',
|
||||
'isLocked' => 'int',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
}
|
||||
|
||||
35
api/app/model/GaokaoDistributionCommission.php
Normal file
35
api/app/model/GaokaoDistributionCommission.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考分销佣金流水
|
||||
*/
|
||||
class GaokaoDistributionCommission extends Model
|
||||
{
|
||||
protected $name = 'gaokao_distribution_commission';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'orderId' => 'int',
|
||||
'orderNo' => 'string',
|
||||
'userId' => 'int',
|
||||
'referrerUserId' => 'int',
|
||||
'commissionRuleType' => 'string',
|
||||
'commissionRuleValue' => 'float',
|
||||
'commissionAmount' => 'int',
|
||||
'status' => 'int',
|
||||
'settledAt' => 'int',
|
||||
'reversedAt' => 'int',
|
||||
'remark' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
}
|
||||
|
||||
38
api/app/model/GaokaoOrder.php
Normal file
38
api/app/model/GaokaoOrder.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考业务订单表
|
||||
*/
|
||||
class GaokaoOrder extends Model
|
||||
{
|
||||
protected $name = 'gaokao_order';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'orderNo' => 'string',
|
||||
'userId' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'productCode' => 'string',
|
||||
'pricingId' => 'int',
|
||||
'amountOriginal' => 'int',
|
||||
'amountPayable' => 'int',
|
||||
'amountPaid' => 'int',
|
||||
'currency' => 'string',
|
||||
'payStatus' => 'int',
|
||||
'payChannel' => 'string',
|
||||
'paidAt' => 'int',
|
||||
'refundAt' => 'int',
|
||||
'extJson' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
protected $json = ['extJson'];
|
||||
}
|
||||
|
||||
60
api/app/model/GaokaoPricing.php
Normal file
60
api/app/model/GaokaoPricing.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考定价配置
|
||||
*/
|
||||
class GaokaoPricing extends Model
|
||||
{
|
||||
protected $name = 'gaokao_pricing';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'productCode' => 'string',
|
||||
'productName' => 'string',
|
||||
'priceOriginal' => 'int',
|
||||
'priceSale' => 'int',
|
||||
'priceChannel' => 'int',
|
||||
'currency' => 'string',
|
||||
'status' => 'int',
|
||||
'effectiveAt' => 'int',
|
||||
'expiredAt' => 'int',
|
||||
'extraJson' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
|
||||
protected $json = ['extraJson'];
|
||||
|
||||
/**
|
||||
* 命中某租户、商品在当前时间生效的定价(租户优先,平台兜底)
|
||||
*/
|
||||
public static function resolveByTenantAndProduct(int $tenantId, string $productCode): ?self
|
||||
{
|
||||
$now = time();
|
||||
$base = self::where('productCode', $productCode)
|
||||
->where('status', 1)
|
||||
->where('effectiveAt', '<=', $now)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('expiredAt')->whereOr('expiredAt', 0)->whereOr('expiredAt', '>', $now);
|
||||
})
|
||||
->order('id', 'desc');
|
||||
|
||||
if ($tenantId > 0) {
|
||||
$row = (clone $base)->where('tenantId', $tenantId)->find();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return (clone $base)->where('tenantId', 0)->find();
|
||||
}
|
||||
}
|
||||
|
||||
30
api/app/model/GaokaoReport.php
Normal file
30
api/app/model/GaokaoReport.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考分析报告快照
|
||||
*/
|
||||
class GaokaoReport extends Model
|
||||
{
|
||||
protected $name = 'gaokao_report';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'version' => 'string',
|
||||
'inputSnapshotJson' => 'string',
|
||||
'reportJson' => 'string',
|
||||
'overview' => 'string',
|
||||
'searchMetaJson' => 'string',
|
||||
'status' => 'int',
|
||||
'errorMsg' => 'string',
|
||||
'createdAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
protected $json = ['inputSnapshotJson', 'reportJson', 'searchMetaJson'];
|
||||
}
|
||||
|
||||
40
api/app/model/GaokaoUserProfile.php
Normal file
40
api/app/model/GaokaoUserProfile.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace app\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 高考用户档案
|
||||
*/
|
||||
class GaokaoUserProfile extends Model
|
||||
{
|
||||
protected $name = 'gaokao_user_profile';
|
||||
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'userId' => 'int',
|
||||
'tenantId' => 'int',
|
||||
'entryStatus' => 'int',
|
||||
'mbtiStatus' => 'int',
|
||||
'pdpStatus' => 'int',
|
||||
'discStatus' => 'int',
|
||||
'formStatus' => 'int',
|
||||
'analyzeStatus' => 'int',
|
||||
'lastAnalyzeAt' => 'int',
|
||||
'latestReportId' => 'int',
|
||||
'name' => 'string',
|
||||
'province' => 'string',
|
||||
'streamSubjects' => 'string',
|
||||
'estimatedScore' => 'int',
|
||||
'formJson' => 'string',
|
||||
'tagsJson' => 'string',
|
||||
'createdAt' => 'int',
|
||||
'updatedAt' => 'int',
|
||||
];
|
||||
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'createdAt';
|
||||
protected $updateTime = 'updatedAt';
|
||||
protected $json = ['formJson', 'tagsJson'];
|
||||
}
|
||||
|
||||
30
api/database/migrations/add_gaokao_core_tables.sql
Normal file
30
api/database/migrations/add_gaokao_core_tables.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- 高考志愿:用户任务与表单档案(定价/订单/报告已统一至 PricingConfig + orders + test_results.testType=gaokao)
|
||||
-- 执行前请确认表前缀,以下以 mbti_ 为例
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mbti_gaokao_user_profile` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`userId` BIGINT NOT NULL COMMENT '用户 ID',
|
||||
`tenantId` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
|
||||
`entryStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '入口/任务流:0未进入 1进行中 2已完成',
|
||||
`mbtiStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'MBTI 完成状态,0 未完成 1 已完成等',
|
||||
`pdpStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'PDP 完成状态',
|
||||
`discStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'DISC 完成状态',
|
||||
`formStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '志愿表单,0 未填/未保存 1 已保存等',
|
||||
`analyzeStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '综合分析:0未生成 1已生成 2失败',
|
||||
`lastAnalyzeAt` INT NULL DEFAULT NULL COMMENT '最近一次分析时间,Unix 时间戳',
|
||||
`latestReportId` BIGINT NULL DEFAULT NULL COMMENT '最近一份高考报告对应 mbti_test_results.id(testType=gaokao)',
|
||||
`name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '学生姓名,冗余自表单',
|
||||
`province` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生源省份,冗余自表单',
|
||||
`streamSubjects` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '科类/选科,冗余自表单',
|
||||
`estimatedScore` INT NULL DEFAULT NULL COMMENT '估分',
|
||||
`formJson` JSON NULL COMMENT '志愿表单全量 JSON',
|
||||
`tagsJson` JSON NULL COMMENT '业务标签等 JSON',
|
||||
`createdAt` INT NOT NULL DEFAULT 0 COMMENT '创建时间,Unix 时间戳',
|
||||
`updatedAt` INT NOT NULL DEFAULT 0 COMMENT '更新时间,Unix 时间戳',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user` (`userId`),
|
||||
KEY `idx_tenant_status` (`tenantId`, `entryStatus`, `analyzeStatus`),
|
||||
KEY `idx_last_analyze_at` (`lastAnalyzeAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高考-用户业务档案';
|
||||
|
||||
-- 已部署旧版迁移、仍存在独立高考表时,请线下执行 DROP 或见 migrate_gaokao_legacy_to_unified.sql / rollback_gaokao_core_tables.sql 说明
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 可选:将旧版 mbti_gaokao_report 迁入 mbti_test_results(testType=gaokao)
|
||||
-- 执行前请备份数据库;按实际 MySQL 版本与 JSON 函数编写 INSERT…SELECT。
|
||||
-- 迁移完成后:用新 test_results.id 回写 mbti_gaokao_user_profile.latestReportId,再择机 DROP 旧高考业务表。
|
||||
4
api/database/migrations/rollback_gaokao_core_tables.sql
Normal file
4
api/database/migrations/rollback_gaokao_core_tables.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 回滚:仅删除高考用户档案表(新版迁移仅创建此表)
|
||||
-- 若库中仍有旧版 mbti_gaokao_report / mbti_gaokao_order 等表,请按需手动 DROP
|
||||
|
||||
DROP TABLE IF EXISTS `mbti_gaokao_user_profile`;
|
||||
@@ -116,6 +116,13 @@ Route::group('api', function () {
|
||||
Route::get('ai/report/:id', 'api.AiReport/show')->pattern(['id' => '\d+']);
|
||||
Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev');
|
||||
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
|
||||
// 高考志愿(小程序登录后)
|
||||
Route::get('gaokao/task-status', 'api.Gaokao/taskStatus');
|
||||
Route::get('gaokao/form', 'api.Gaokao/myForm');
|
||||
Route::post('gaokao/form', 'api.Gaokao/saveForm');
|
||||
Route::post('gaokao/analyze', 'api.Gaokao/analyze');
|
||||
Route::get('gaokao/report/my-latest', 'api.Gaokao/latestReport');
|
||||
Route::get('gaokao/pricing', 'api.Gaokao/pricing');
|
||||
})->middleware(['cors', 'auth']);
|
||||
|
||||
// ==================== 兼容 /api/v1 前缀(与上方 api 组同权、同中间件)====================
|
||||
@@ -166,6 +173,8 @@ Route::group('api/v1/admin', function () {
|
||||
Route::get('test-records/:id', 'admin.AppUser/testRecord');
|
||||
Route::get('app-users/:id', 'admin.AppUser/detail');
|
||||
Route::get('app-users', 'admin.AppUser/index');
|
||||
Route::get('gaokao-users/:id', 'admin.GaokaoUser/detail');
|
||||
Route::get('gaokao-users', 'admin.GaokaoUser/index');
|
||||
// 订单列表(含用户与关联测试数据)
|
||||
Route::get('orders', 'admin.Order/index');
|
||||
// 用户管理(普通管理员和企业管理员,后台账号)
|
||||
@@ -326,6 +335,8 @@ Route::group('api/v1/superadmin', function () {
|
||||
Route::get('test-records/:id', 'superadmin.AppUser/testRecord');
|
||||
Route::get('app-users/:id', 'superadmin.AppUser/detail');
|
||||
Route::get('app-users', 'superadmin.AppUser/index');
|
||||
Route::get('gaokao-users/:id', 'superadmin.GaokaoUser/detail');
|
||||
Route::get('gaokao-users', 'superadmin.GaokaoUser/index');
|
||||
|
||||
// 数据概览(超管专用,子路径放前面避免被 overview 吞掉)
|
||||
Route::get('overview/recent-dynamics', 'superadmin.Overview/recentDynamics');
|
||||
|
||||
@@ -58,8 +58,8 @@ App({
|
||||
defaultEnterpriseId: null,
|
||||
// API 基础地址:默认走线上;本机/内网调试可在开发者工具执行
|
||||
// wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序
|
||||
apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
//apiBase: 'http://mbti.com',
|
||||
//apiBase: 'https://mbtiapi.quwanzhi.com',
|
||||
apiBase: 'http://mbti.com',
|
||||
// VIP信息
|
||||
vipInfo: null,
|
||||
// 测试次数
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"pages/promo/poster",
|
||||
"pages/promo/withdrawals",
|
||||
"pages/match-job/index",
|
||||
"pages/gaokao/index",
|
||||
"pages/gaokao/form",
|
||||
"pages/gaokao/report",
|
||||
"pages/ai-chat/index",
|
||||
"pages/ai-chat/report",
|
||||
"pages/ai-chat/history",
|
||||
|
||||
387
miniprogram/pages/gaokao/form.js
Normal file
387
miniprogram/pages/gaokao/form.js
Normal file
@@ -0,0 +1,387 @@
|
||||
const gaokaoApi = require('../../utils/gaokao')
|
||||
|
||||
/** 科类/选科备选项:首项为占位,不可作为有效保存值 */
|
||||
const SUBJECT_PLACEHOLDER = '请选择科类/选科'
|
||||
const SUBJECT_CHOICES = [
|
||||
SUBJECT_PLACEHOLDER,
|
||||
'文科',
|
||||
'理科',
|
||||
'物化生',
|
||||
'物化地',
|
||||
'物化政',
|
||||
'物生地',
|
||||
'物生政',
|
||||
'物政地',
|
||||
'化生地',
|
||||
'化政地',
|
||||
'生政地',
|
||||
'史政地',
|
||||
'史化政',
|
||||
'史化生',
|
||||
'物化技',
|
||||
'物生技',
|
||||
'史地技',
|
||||
'艺术类(物理向)',
|
||||
'艺术类(历史向)',
|
||||
'体育类(物理向)',
|
||||
'体育类(历史向)',
|
||||
'中职/对口/单招'
|
||||
]
|
||||
|
||||
/** 在意向专业中展示/保存的选项:首项表示不填 */
|
||||
const MAJOR_PLACEHOLDER = '(可选)不填'
|
||||
const MAJOR_CHOICES = [
|
||||
MAJOR_PLACEHOLDER,
|
||||
'哲学',
|
||||
'经济学 / 金融',
|
||||
'法学',
|
||||
'教育学 / 师范',
|
||||
'文学',
|
||||
'外语 / 新传',
|
||||
'理学',
|
||||
'工学 / 工程',
|
||||
'计算机 / 软件 / 人工智能',
|
||||
'电子 / 通信 / 信息',
|
||||
'医学 / 临床 / 公卫 / 中医',
|
||||
'农学 / 林学 / 生科',
|
||||
'历史学',
|
||||
'管理学 / 商学',
|
||||
'艺术学',
|
||||
'交叉学科 / 暂未确定'
|
||||
]
|
||||
|
||||
/** 将微信 region 结果格式化为只到「市」的文案(不含区) */
|
||||
function regionToCityText(v) {
|
||||
if (!v || !v.length) return ''
|
||||
const p = (v[0] || '').trim()
|
||||
const c = (v[1] || '').trim()
|
||||
if (p && c) return p + ' ' + c
|
||||
return p || c
|
||||
}
|
||||
|
||||
/** 展示用:只取省+市,直辖市 p===c 时只显示一个 */
|
||||
function formatRegionLine(v) {
|
||||
if (!v || !v.length) return ''
|
||||
const p = (v[0] || '').trim()
|
||||
const c = (v[1] || '').trim()
|
||||
if (!p && !c) return ''
|
||||
if (c && c !== p) return p + ' · ' + c
|
||||
return p || c
|
||||
}
|
||||
|
||||
/** 从 bindchange 取 value(部分环境 detail 结构异常时兜底) */
|
||||
function regionValueFromEvent(e) {
|
||||
const d = (e && e.detail) || {}
|
||||
let v = d.value
|
||||
if (Array.isArray(v) && v.length) return v
|
||||
return []
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
form: {
|
||||
name: '',
|
||||
province: '',
|
||||
/** 高考生源地所在市(与省同一套 region 选择结果,用于展示/扩展) */
|
||||
city: '',
|
||||
streamOrSubjects: '',
|
||||
/** 与微信 region picker 联动;level=city 时为 [省,市];老数据或省级可能为 3 项 */
|
||||
region: [],
|
||||
/**
|
||||
* 意向地区:仅省为 [p];老数据 可能 为 [p,c];与 preferredRegions 对应
|
||||
*/
|
||||
preferredRegion: [],
|
||||
estimatedScore: '',
|
||||
scoreText: '',
|
||||
wishListText: '',
|
||||
preferredRegions: '',
|
||||
preferredFields: ''
|
||||
},
|
||||
/** 高考生源:与 level=city 一致,为 [省,市] */
|
||||
regionPickerValue: [],
|
||||
/** 意向:只省 */
|
||||
intendedProvPicker: [],
|
||||
/** 意向:省+市 */
|
||||
intendedCityPicker: [],
|
||||
/** 0=只到省份 1=到省+市 */
|
||||
intendedModeOptions: ['只到省份', '到省+市'],
|
||||
intendedModeIndex: 1,
|
||||
intendedModeLine: '到省+市',
|
||||
/** 地区展示文案(不依赖 wxml 里对 length 的比较,避免真机/模拟器不渲染) */
|
||||
regionLine: '',
|
||||
intendedRegionLine: '',
|
||||
subjectOptions: SUBJECT_CHOICES,
|
||||
streamOrSubjectsIndex: 0,
|
||||
majorOptions: MAJOR_CHOICES,
|
||||
preferredFieldsIndex: 0,
|
||||
saving: false
|
||||
},
|
||||
|
||||
/** 每次页面展示拉取(含从上级页返回),避免栈内页面不触发 onLoad 时看不到已保存内容 */
|
||||
onShow() {
|
||||
this.loadFormFromServer()
|
||||
},
|
||||
|
||||
loadFormFromServer() {
|
||||
gaokaoApi
|
||||
.getForm()
|
||||
.then((res) => {
|
||||
const form = res.form || {}
|
||||
const pr = (() => {
|
||||
const a = form.preferredRegion
|
||||
if (!Array.isArray(a) || a.length < 1) return []
|
||||
return a
|
||||
})()
|
||||
const baseForm = {
|
||||
...this.data.form,
|
||||
...form,
|
||||
city: (form.city != null && form.city !== '') ? String(form.city) : (this.data.form.city || ''),
|
||||
region: (() => {
|
||||
const a = form.region
|
||||
if (!Array.isArray(a) || a.length < 2) return []
|
||||
return a
|
||||
})(),
|
||||
preferredRegion: pr,
|
||||
preferredRegions: (() => {
|
||||
if (pr && pr.length >= 2) {
|
||||
return regionToCityText([pr[0], pr[1]])
|
||||
}
|
||||
if (pr && pr.length === 1) {
|
||||
return (pr[0] || '').trim()
|
||||
}
|
||||
return form.preferredRegions != null ? String(form.preferredRegions) : ''
|
||||
})(),
|
||||
estimatedScore: form.estimatedScore != null ? String(form.estimatedScore) : ''
|
||||
}
|
||||
const rpv = baseForm.region
|
||||
if (rpv && rpv.length >= 2) {
|
||||
baseForm.province = rpv[0] || baseForm.province
|
||||
baseForm.city = rpv[1] || baseForm.city
|
||||
}
|
||||
const rForPicker = (() => {
|
||||
if (Array.isArray(baseForm.region) && baseForm.region.length >= 2) {
|
||||
const a0 = (baseForm.region[0] || '').trim()
|
||||
const a1 = (baseForm.region[1] || a0).trim()
|
||||
return [a0, a1]
|
||||
}
|
||||
if (baseForm.province) {
|
||||
const a0 = String(baseForm.province).trim()
|
||||
const a1 = (baseForm.city && String(baseForm.city).trim()) || a0
|
||||
return [a0, a1]
|
||||
}
|
||||
return []
|
||||
})()
|
||||
const regionLine = rForPicker.length
|
||||
? formatRegionLine(rForPicker)
|
||||
: ''
|
||||
const intendedModeOptions = this.data.intendedModeOptions
|
||||
const modeIdx = (() => {
|
||||
if (pr && pr.length >= 2) return 1
|
||||
if (pr && pr.length === 1) return 0
|
||||
return 1
|
||||
})()
|
||||
const intendedModeLine = intendedModeOptions[modeIdx] || '到省+市'
|
||||
const intendedProvPicker = (() => {
|
||||
if (pr && pr.length >= 1) return [(String(pr[0] || '')).trim()]
|
||||
return []
|
||||
})()
|
||||
const intendedCityPicker = (() => {
|
||||
if (pr && pr.length >= 2) {
|
||||
return [
|
||||
(String(pr[0] || '')).trim(),
|
||||
(String(pr[1] || '')).trim()
|
||||
]
|
||||
}
|
||||
if (pr && pr.length === 1 && modeIdx === 1) {
|
||||
const p0 = (String(pr[0] || '')).trim()
|
||||
return p0 ? [p0, p0] : []
|
||||
}
|
||||
return []
|
||||
})()
|
||||
let intendedRegionLine = (() => {
|
||||
if (pr && pr.length >= 2) {
|
||||
return formatRegionLine([(pr[0] || '').trim(), (pr[1] || '').trim()])
|
||||
}
|
||||
if (pr && pr.length === 1) {
|
||||
return (pr[0] || '').trim()
|
||||
}
|
||||
if (form.preferredRegions) return String(form.preferredRegions)
|
||||
return ''
|
||||
})()
|
||||
const stream = baseForm.streamOrSubjects || ''
|
||||
const opts = (() => {
|
||||
if (stream && SUBJECT_CHOICES.indexOf(stream) < 0) {
|
||||
return [SUBJECT_CHOICES[0], stream, ...SUBJECT_CHOICES.slice(1)]
|
||||
}
|
||||
return SUBJECT_CHOICES
|
||||
})()
|
||||
let sIdx = opts.indexOf(stream)
|
||||
if (sIdx < 0) sIdx = 0
|
||||
const pField = baseForm.preferredFields || ''
|
||||
const mOpts = (() => {
|
||||
if (pField && MAJOR_CHOICES.indexOf(pField) < 0) {
|
||||
return [MAJOR_CHOICES[0], pField, ...MAJOR_CHOICES.slice(1)]
|
||||
}
|
||||
return MAJOR_CHOICES
|
||||
})()
|
||||
let mIdx = mOpts.indexOf(pField)
|
||||
if (mIdx < 0) mIdx = 0
|
||||
this.setData({
|
||||
form: baseForm,
|
||||
regionPickerValue: rForPicker,
|
||||
regionLine,
|
||||
intendedModeIndex: modeIdx,
|
||||
intendedModeLine,
|
||||
intendedProvPicker,
|
||||
intendedCityPicker,
|
||||
intendedRegionLine,
|
||||
subjectOptions: opts,
|
||||
streamOrSubjectsIndex: sIdx,
|
||||
majorOptions: mOpts,
|
||||
preferredFieldsIndex: mIdx
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
wx.showToast({ title: '加载表单失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onInput(e) {
|
||||
const key = e.currentTarget.dataset.key
|
||||
this.setData({ [`form.${key}`]: e.detail.value })
|
||||
},
|
||||
|
||||
onRegionChange(e) {
|
||||
const v = regionValueFromEvent(e)
|
||||
if (!v || !v.length) return
|
||||
const p = (v[0] || '').trim()
|
||||
if (!p) return
|
||||
const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : ''
|
||||
const pair = [p, c2 || p]
|
||||
this.setData({
|
||||
regionPickerValue: pair,
|
||||
regionLine: formatRegionLine(pair),
|
||||
'form.province': p,
|
||||
'form.city': c2 || p,
|
||||
'form.region': pair
|
||||
})
|
||||
},
|
||||
|
||||
onSubjectChange(e) {
|
||||
const idx = parseInt(e.detail.value, 10) || 0
|
||||
const opts = this.data.subjectOptions
|
||||
const raw = opts[idx] || ''
|
||||
const val =
|
||||
raw && raw !== SUBJECT_PLACEHOLDER
|
||||
? raw
|
||||
: ''
|
||||
this.setData({
|
||||
streamOrSubjectsIndex: idx,
|
||||
'form.streamOrSubjects': val
|
||||
})
|
||||
},
|
||||
|
||||
onIntendedModeChange(e) {
|
||||
const idx = parseInt(e.detail.value, 10) || 0
|
||||
const opts = this.data.intendedModeOptions
|
||||
const pr = this.data.form.preferredRegion
|
||||
const arr = Array.isArray(pr) ? pr : []
|
||||
if (idx === 0) {
|
||||
const p = arr[0] ? String(arr[0]).trim() : ''
|
||||
const next = p ? [p] : []
|
||||
this.setData({
|
||||
intendedModeIndex: idx,
|
||||
intendedModeLine: opts[idx],
|
||||
intendedProvPicker: next,
|
||||
intendedRegionLine: p,
|
||||
'form.preferredRegion': next,
|
||||
'form.preferredRegions': p
|
||||
})
|
||||
return
|
||||
}
|
||||
let cityPick = []
|
||||
if (arr.length >= 2) {
|
||||
cityPick = [String(arr[0] || '').trim(), String(arr[1] || '').trim()]
|
||||
} else if (arr.length === 1) {
|
||||
const p0 = String(arr[0] || '').trim()
|
||||
cityPick = p0 ? [p0, p0] : []
|
||||
}
|
||||
const hasPair = arr.length >= 2
|
||||
this.setData({
|
||||
intendedModeIndex: idx,
|
||||
intendedModeLine: opts[idx],
|
||||
intendedCityPicker: cityPick
|
||||
})
|
||||
if (hasPair) {
|
||||
const line = formatRegionLine(cityPick)
|
||||
this.setData({
|
||||
intendedRegionLine: line,
|
||||
'form.preferredRegion': [cityPick[0], cityPick[1]],
|
||||
'form.preferredRegions': regionToCityText(cityPick)
|
||||
})
|
||||
} else {
|
||||
this.setData({
|
||||
intendedRegionLine: arr[0] ? String(arr[0]).trim() : '',
|
||||
'form.preferredRegion': arr
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onIntendedRegionProv(e) {
|
||||
const v = regionValueFromEvent(e)
|
||||
if (!v || !v.length) return
|
||||
const p = (v[0] || '').trim()
|
||||
if (!p) return
|
||||
this.setData({
|
||||
intendedProvPicker: [p],
|
||||
intendedRegionLine: p,
|
||||
'form.preferredRegion': [p],
|
||||
'form.preferredRegions': p
|
||||
})
|
||||
},
|
||||
|
||||
onIntendedRegionCity(e) {
|
||||
const v = regionValueFromEvent(e)
|
||||
if (!v || !v.length) return
|
||||
const p = (v[0] || '').trim()
|
||||
if (!p) return
|
||||
const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : ''
|
||||
const pair = [p, c2 || p]
|
||||
this.setData({
|
||||
intendedCityPicker: pair,
|
||||
intendedRegionLine: formatRegionLine(pair),
|
||||
'form.preferredRegion': pair,
|
||||
'form.preferredRegions': regionToCityText(pair)
|
||||
})
|
||||
},
|
||||
|
||||
onPreferredFieldChange(e) {
|
||||
const idx = parseInt(e.detail.value, 10) || 0
|
||||
const opts = this.data.majorOptions
|
||||
const raw = opts[idx] || ''
|
||||
const val = raw && raw !== MAJOR_PLACEHOLDER ? raw : ''
|
||||
this.setData({
|
||||
preferredFieldsIndex: idx,
|
||||
'form.preferredFields': val
|
||||
})
|
||||
},
|
||||
|
||||
onSave() {
|
||||
const f = this.data.form
|
||||
if (!f.name || !f.province || !f.streamOrSubjects) {
|
||||
wx.showToast({ title: '请选择姓名、所在地区与科类/选科', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ saving: true })
|
||||
gaokaoApi.saveForm({
|
||||
...f,
|
||||
estimatedScore: f.estimatedScore ? Number(f.estimatedScore) : null
|
||||
}).then(() => {
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 400)
|
||||
}).catch((e) => {
|
||||
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||
}).finally(() => this.setData({ saving: false }))
|
||||
}
|
||||
})
|
||||
|
||||
4
miniprogram/pages/gaokao/form.json
Normal file
4
miniprogram/pages/gaokao/form.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "高考信息表单"
|
||||
}
|
||||
|
||||
72
miniprogram/pages/gaokao/form.wxml
Normal file
72
miniprogram/pages/gaokao/form.wxml
Normal file
@@ -0,0 +1,72 @@
|
||||
<view class="container">
|
||||
<view class="card">
|
||||
<view class="item">
|
||||
<view class="label">姓名</view>
|
||||
<input class="input" data-key="name" value="{{form.name}}" bindinput="onInput" placeholder="请输入姓名" />
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">所在地区</view>
|
||||
<picker mode="region" level="city" value="{{regionPickerValue}}" bindchange="onRegionChange">
|
||||
<view class="input input-picker {{!regionLine ? 'input-picker-empty' : ''}}">
|
||||
<text>{{regionLine ? regionLine : '点选高考生源省、市'}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">科类/选科</view>
|
||||
<picker mode="selector" range="{{subjectOptions}}" value="{{streamOrSubjectsIndex}}" bindchange="onSubjectChange">
|
||||
<view class="input input-picker">
|
||||
<text wx:if="{{form.streamOrSubjects}}">{{form.streamOrSubjects}}</text>
|
||||
<text wx:else class="ph">请点选科类或选科组合</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">估分</view>
|
||||
<input class="input" type="number" data-key="estimatedScore" value="{{form.estimatedScore}}" bindinput="onInput" placeholder="选填" />
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">成绩说明</view>
|
||||
<textarea class="textarea" data-key="scoreText" value="{{form.scoreText}}" bindinput="onInput" placeholder="可选,填写模考成绩说明"></textarea>
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">志愿草表</view>
|
||||
<textarea class="textarea" data-key="wishListText" value="{{form.wishListText}}" bindinput="onInput" placeholder="可选,填写当前志愿方案"></textarea>
|
||||
</view>
|
||||
<view class="item item-intended">
|
||||
<view class="label">意向地区</view>
|
||||
<picker class="intended-mode-picker" mode="selector" range="{{intendedModeOptions}}" value="{{intendedModeIndex}}" bindchange="onIntendedModeChange">
|
||||
<view class="input input-picker input-sub">
|
||||
<text>粒度:{{intendedModeLine}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="intended-region-pickers">
|
||||
<block wx:if="{{intendedModeIndex == 0}}">
|
||||
<picker mode="region" level="province" value="{{intendedProvPicker}}" bindchange="onIntendedRegionProv">
|
||||
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
|
||||
<text>{{intendedRegionLine ? intendedRegionLine : '点选省份(可只到省)'}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<picker mode="region" level="city" value="{{intendedCityPicker}}" bindchange="onIntendedRegionCity">
|
||||
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
|
||||
<text>{{intendedRegionLine ? intendedRegionLine : '点选省、市(含直辖市)'}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item">
|
||||
<view class="label">意向专业</view>
|
||||
<picker mode="selector" range="{{majorOptions}}" value="{{preferredFieldsIndex}}" bindchange="onPreferredFieldChange">
|
||||
<view class="input input-picker">
|
||||
<text wx:if="{{form.preferredFields}}">{{form.preferredFields}}</text>
|
||||
<text wx:else class="ph">可选,点选专业方向</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<button class="save-btn" loading="{{saving}}" bindtap="onSave">保存表单</button>
|
||||
</view>
|
||||
|
||||
86
miniprogram/pages/gaokao/form.wxss
Normal file
86
miniprogram/pages/gaokao/form.wxss
Normal file
@@ -0,0 +1,86 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fc;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 74rpx;
|
||||
border: 1rpx solid #e6e8ee;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.input-picker {
|
||||
line-height: 72rpx;
|
||||
box-sizing: border-box;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 避免内联 picker 与下一行表单项左右串版(如市名跑到「科类」旁) */
|
||||
.item > picker,
|
||||
.item picker,
|
||||
.intended-region-pickers picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-picker-empty text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.input-picker .ph {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 140rpx;
|
||||
border: 1rpx solid #e6e8ee;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
font-size: 26rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 意向地区:先选粒度,再选省或省+市;第二行与第一行间留白 */
|
||||
.intended-mode-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-intended .intended-region-pickers {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.input-sub text {
|
||||
font-size: 24rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 24rpx;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
107
miniprogram/pages/gaokao/index.js
Normal file
107
miniprogram/pages/gaokao/index.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const gaokaoApi = require('../../utils/gaokao')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
tasks: {
|
||||
mbti: { code: 'mbti', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'mbti', typeName: 'MBTI性格', emoji: '🧠', testTime: '' },
|
||||
pdp: { code: 'pdp', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'pdp', typeName: 'PDP行为', emoji: '🦁', testTime: '' },
|
||||
disc: { code: 'disc', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'disc', typeName: 'DISC测评', emoji: '📊', testTime: '' },
|
||||
face: { code: 'face', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'ai', typeName: '拍照面相', emoji: '📷', testTime: '', recordTestType: 'ai' },
|
||||
form: { code: 'form', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'form', typeName: '高考信息表单', emoji: '📝', testTime: '' }
|
||||
},
|
||||
canAnalyze: false,
|
||||
missingItems: [],
|
||||
analyzing: false
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.refreshStatus()
|
||||
},
|
||||
|
||||
refreshStatus() {
|
||||
this.setData({ loading: true })
|
||||
gaokaoApi
|
||||
.getTaskStatus({ scene: 'gaokao_hub' })
|
||||
.then((data) => {
|
||||
this.setData({
|
||||
tasks: data.tasks || this.data.tasks,
|
||||
canAnalyze: !!data.canAnalyze,
|
||||
missingItems: data.missingItems || []
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
wx.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
})
|
||||
.finally(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
goTask(e) {
|
||||
const code = e.currentTarget.dataset.code
|
||||
const resultId = Number(e.currentTarget.dataset.resultId || 0)
|
||||
const done = (e.currentTarget.dataset.status || '') === 'done'
|
||||
if (code === 'mbti') {
|
||||
if (done && resultId > 0) {
|
||||
wx.navigateTo({ url: `/pages/result/mbti?id=${resultId}&type=mbti` })
|
||||
} else {
|
||||
wx.navigateTo({ url: '/pages/test/mbti' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (code === 'pdp') {
|
||||
if (done && resultId > 0) {
|
||||
wx.navigateTo({ url: `/pages/result/pdp?id=${resultId}&type=pdp` })
|
||||
} else {
|
||||
wx.navigateTo({ url: '/pages/test/pdp' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (code === 'disc') {
|
||||
if (done && resultId > 0) {
|
||||
wx.navigateTo({ url: `/pages/result/disc?id=${resultId}&type=disc` })
|
||||
} else {
|
||||
wx.navigateTo({ url: '/pages/test/disc' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (code === 'face') {
|
||||
const recType = String(e.currentTarget.dataset.recordType || 'ai').toLowerCase()
|
||||
const typeParam = recType === 'face' ? 'face' : 'ai'
|
||||
if (done && resultId > 0) {
|
||||
wx.navigateTo({ url: `/pages/index/result?id=${resultId}&type=${typeParam}` })
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/index/camera' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (code === 'form') {
|
||||
wx.navigateTo({ url: '/pages/gaokao/form' })
|
||||
}
|
||||
},
|
||||
|
||||
onAnalyzeTap() {
|
||||
if (!this.data.canAnalyze) {
|
||||
const nameMap = {
|
||||
mbti: 'MBTI测试',
|
||||
pdp: 'PDP测试',
|
||||
disc: 'DISC测试',
|
||||
face: '拍照面相',
|
||||
form: '高考信息表单'
|
||||
}
|
||||
const msg = (this.data.missingItems || []).map((k) => nameMap[k] || k).join('、')
|
||||
wx.showToast({
|
||||
title: msg ? `请先完成:${msg}` : '请先完成全部任务',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
})
|
||||
return
|
||||
}
|
||||
this.setData({ analyzing: true })
|
||||
wx.navigateTo({
|
||||
url: '/pages/gaokao/report?pendingAnalyze=1',
|
||||
complete: () => {
|
||||
this.setData({ analyzing: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/gaokao/index.json
Normal file
4
miniprogram/pages/gaokao/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "高考志愿"
|
||||
}
|
||||
|
||||
90
miniprogram/pages/gaokao/index.wxml
Normal file
90
miniprogram/pages/gaokao/index.wxml
Normal file
@@ -0,0 +1,90 @@
|
||||
<view class="page">
|
||||
<view class="container">
|
||||
<view class="header">
|
||||
<view class="title">高考志愿任务中心</view>
|
||||
<view class="desc">完成 MBTI、PDP、DISC、拍照面相与高考信息表单后,即可生成综合分析;若定价非 0,请在报告页解锁全文(与问卷类测评一致)。</view>
|
||||
</view>
|
||||
|
||||
<view class="task-list">
|
||||
<view class="task-card" data-code="mbti" data-status="{{tasks.mbti.status}}" data-result-id="{{tasks.mbti.testResultId}}" bindtap="goTask">
|
||||
<view class="left">
|
||||
<view class="task-title-row">
|
||||
<text class="task-emoji">{{tasks.mbti.emoji}}</text>
|
||||
<text class="name">{{tasks.mbti.typeName}}</text>
|
||||
</view>
|
||||
<view class="result result--value" wx:if="{{tasks.mbti.status==='done'}}">{{tasks.mbti.resultText || '已完成'}}</view>
|
||||
<view class="result todo" wx:else>未完成,请去测试</view>
|
||||
<view class="task-time" wx:if="{{tasks.mbti.status==='done' && tasks.mbti.testTime}}">{{tasks.mbti.testTime}}</view>
|
||||
</view>
|
||||
<view class="status {{tasks.mbti.status==='done'?'done':''}}">{{tasks.mbti.status==='done'?'查看结果':'去完成'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="task-card" data-code="pdp" data-status="{{tasks.pdp.status}}" data-result-id="{{tasks.pdp.testResultId}}" bindtap="goTask">
|
||||
<view class="left">
|
||||
<view class="task-title-row">
|
||||
<text class="task-emoji">{{tasks.pdp.emoji}}</text>
|
||||
<text class="name">{{tasks.pdp.typeName}}</text>
|
||||
</view>
|
||||
<view class="result result--value" wx:if="{{tasks.pdp.status==='done'}}">{{tasks.pdp.resultText || '已完成'}}</view>
|
||||
<view class="result todo" wx:else>未完成,请去测试</view>
|
||||
<view class="task-time" wx:if="{{tasks.pdp.status==='done' && tasks.pdp.testTime}}">{{tasks.pdp.testTime}}</view>
|
||||
</view>
|
||||
<view class="status {{tasks.pdp.status==='done'?'done':''}}">{{tasks.pdp.status==='done'?'查看结果':'去完成'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="task-card" data-code="disc" data-status="{{tasks.disc.status}}" data-result-id="{{tasks.disc.testResultId}}" bindtap="goTask">
|
||||
<view class="left">
|
||||
<view class="task-title-row">
|
||||
<text class="task-emoji">{{tasks.disc.emoji}}</text>
|
||||
<text class="name">{{tasks.disc.typeName}}</text>
|
||||
</view>
|
||||
<view class="result result--value" wx:if="{{tasks.disc.status==='done'}}">{{tasks.disc.resultText || '已完成'}}</view>
|
||||
<view class="result todo" wx:else>未完成,请去测试</view>
|
||||
<view class="task-time" wx:if="{{tasks.disc.status==='done' && tasks.disc.testTime}}">{{tasks.disc.testTime}}</view>
|
||||
</view>
|
||||
<view class="status {{tasks.disc.status==='done'?'done':''}}">{{tasks.disc.status==='done'?'查看结果':'去完成'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="task-card" data-code="face" data-status="{{tasks.face.status}}" data-result-id="{{tasks.face.testResultId}}" data-record-type="{{tasks.face.recordTestType}}" bindtap="goTask">
|
||||
<view class="left">
|
||||
<view class="task-title-row">
|
||||
<text class="task-emoji">{{tasks.face.emoji}}</text>
|
||||
<text class="name">{{tasks.face.typeName}}</text>
|
||||
</view>
|
||||
<view class="result result--value" wx:if="{{tasks.face.status==='done'}}">{{tasks.face.resultText || '已完成'}}</view>
|
||||
<view class="result todo" wx:else>未完成,请去拍摄</view>
|
||||
<view class="task-time" wx:if="{{tasks.face.status==='done' && tasks.face.testTime}}">{{tasks.face.testTime}}</view>
|
||||
</view>
|
||||
<view class="status {{tasks.face.status==='done'?'done':''}}">{{tasks.face.status==='done'?'查看结果':'去完成'}}</view>
|
||||
</view>
|
||||
|
||||
<view class="task-card" data-code="form" data-status="{{tasks.form.status}}" data-result-id="{{tasks.form.testResultId}}" bindtap="goTask">
|
||||
<view class="left">
|
||||
<view class="task-title-row">
|
||||
<text class="task-emoji">{{tasks.form.emoji}}</text>
|
||||
<text class="name">{{tasks.form.typeName}}</text>
|
||||
</view>
|
||||
<view class="result result--value" wx:if="{{tasks.form.status==='done'}}">
|
||||
<block wx:if="{{tasks.form.resultText}}">已填写:{{tasks.form.resultText}}</block>
|
||||
<block wx:else>已完成</block>
|
||||
</view>
|
||||
<view class="result todo" wx:else>未完成,请先填写</view>
|
||||
<view class="task-time" wx:if="{{tasks.form.status==='done' && tasks.form.testTime}}">{{tasks.form.testTime}}</view>
|
||||
</view>
|
||||
<view class="status {{tasks.form.status==='done'?'done':''}}">{{tasks.form.status==='done'?'查看结果':'去完成'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-actions">
|
||||
<button
|
||||
class="action-btn {{canAnalyze ? 'action-btn--primary' : 'action-btn--disabled'}}"
|
||||
loading="{{analyzing}}"
|
||||
bindtap="onAnalyzeTap"
|
||||
disabled="{{analyzing}}"
|
||||
>
|
||||
{{canAnalyze ? '开始综合分析' : '综合分析(未完成)'}}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
142
miniprogram/pages/gaokao/index.wxss
Normal file
142
miniprogram/pages/gaokao/index.wxss
Normal file
@@ -0,0 +1,142 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100%;
|
||||
background: #f7f8fc;
|
||||
padding: 32rpx 24rpx;
|
||||
/* 底部固定操作条占位(单主按钮 + 安全区) */
|
||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 22rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.task-emoji {
|
||||
font-size: 36rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 30rpx;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result--value {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.result.todo {
|
||||
color: #d04848;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 24rpx;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.status.done {
|
||||
color: #1f9d55;
|
||||
}
|
||||
|
||||
/* 底部悬浮:全宽主操作 */
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16rpx 24rpx;
|
||||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 -8rpx 32rpx rgba(15, 23, 42, 0.08);
|
||||
border-top: 1rpx solid #eef0f4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100% !important;
|
||||
margin: 0;
|
||||
padding: 24rpx 32rpx;
|
||||
line-height: 1.35;
|
||||
border-radius: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn--primary {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn--disabled {
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
690
miniprogram/pages/gaokao/report.js
Normal file
690
miniprogram/pages/gaokao/report.js
Normal file
@@ -0,0 +1,690 @@
|
||||
const app = getApp()
|
||||
const gaokaoApi = require('../../utils/gaokao')
|
||||
const { requestPromise } = require('../../utils/request')
|
||||
const payment = require('../../utils/payment')
|
||||
const {
|
||||
hasPhone,
|
||||
bindPhoneByCode,
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = require('../../utils/phoneAuth.js')
|
||||
const unlockGate = require('../../utils/unlockGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { openTimelineShareHint } = require('../../utils/resultProfileGate.js')
|
||||
const { computeJourney, markShared } = require('../../utils/gaokaoJourneyState.js')
|
||||
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
|
||||
|
||||
/** GET /api/test/detail | share-detail 返回体 -> mergeApiReport 入参 */
|
||||
function mapTestDetailToReportPayload(detail) {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (String(detail.testType || '').toLowerCase() !== 'gaokao') {
|
||||
return null
|
||||
}
|
||||
const d = detail.data
|
||||
if (!d || typeof d !== 'object') {
|
||||
return null
|
||||
}
|
||||
const locked = !!d.locked
|
||||
let report = d.report
|
||||
if (typeof report === 'string') {
|
||||
try {
|
||||
report = JSON.parse(report)
|
||||
} catch (e) {
|
||||
report = null
|
||||
}
|
||||
}
|
||||
const inputSnap =
|
||||
d.inputSnapshot && typeof d.inputSnapshot === 'object' && !Array.isArray(d.inputSnapshot)
|
||||
? d.inputSnapshot
|
||||
: {}
|
||||
|
||||
if (locked) {
|
||||
const ov =
|
||||
typeof d.overview === 'string' && d.overview !== ''
|
||||
? d.overview
|
||||
: String(d.overview || '')
|
||||
report = {
|
||||
overview: ov,
|
||||
personalityReason: '',
|
||||
disclaimers: '',
|
||||
majorRecommend: [],
|
||||
schoolRecommend: {},
|
||||
inputEcho: {
|
||||
name: String(inputSnap.name || ''),
|
||||
province: String(inputSnap.province || ''),
|
||||
streamSubjects: String(inputSnap.streamSubjects || ''),
|
||||
estimatedScore: inputSnap.estimatedScore != null ? Number(inputSnap.estimatedScore) : 0,
|
||||
mbti: String(inputSnap.mbti || ''),
|
||||
pdp: String(inputSnap.pdp || ''),
|
||||
disc: String(inputSnap.disc || '')
|
||||
},
|
||||
locked: true
|
||||
}
|
||||
} else if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const topOv = typeof d.overview === 'string' ? d.overview : ''
|
||||
return {
|
||||
id: detail.id,
|
||||
createdAt: detail.createdAt,
|
||||
overview: topOv,
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口返回: { id, createdAt, overview, report: reportJson }
|
||||
* 展示用合并为一层,便于 wxml 绑定
|
||||
*/
|
||||
function mergeApiReport(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
let br = payload.report
|
||||
if (typeof br === 'string') {
|
||||
try {
|
||||
br = JSON.parse(br)
|
||||
} catch (e) {
|
||||
br = null
|
||||
}
|
||||
}
|
||||
const block = br && typeof br === 'object' && !Array.isArray(br) ? br : {}
|
||||
const hasBlock = Object.keys(block).length > 0
|
||||
const hasOverview = typeof payload.overview === 'string' && payload.overview !== ''
|
||||
if (!hasBlock && !hasOverview && !(payload.id > 0)) {
|
||||
return null
|
||||
}
|
||||
const overview = hasOverview ? payload.overview : block.overview || ''
|
||||
return Object.assign({}, block, { overview })
|
||||
}
|
||||
|
||||
function normalizeSchoolRow(x, band) {
|
||||
if (!x || typeof x !== 'object') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
band: band || '',
|
||||
schoolName: String(x.schoolName || x.name || '').trim() || '未命名院校',
|
||||
city: String(x.city || '').trim(),
|
||||
level: String(x.level || '').trim(),
|
||||
reason: String(x.reason || x.desc || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function buildSchoolListFlat(rawSr) {
|
||||
if (Array.isArray(rawSr)) {
|
||||
return rawSr.map((x) => normalizeSchoolRow(x, '')).filter(Boolean)
|
||||
}
|
||||
if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) {
|
||||
const chong = Array.isArray(rawSr.chong) ? rawSr.chong : []
|
||||
const wen = Array.isArray(rawSr.wen) ? rawSr.wen : []
|
||||
const bao = Array.isArray(rawSr.bao) ? rawSr.bao : []
|
||||
return [
|
||||
...chong.map((x) => normalizeSchoolRow(x, '冲')),
|
||||
...wen.map((x) => normalizeSchoolRow(x, '稳')),
|
||||
...bao.map((x) => normalizeSchoolRow(x, '保'))
|
||||
].filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function buildViewModel(payload) {
|
||||
const report = mergeApiReport(payload)
|
||||
if (!report) {
|
||||
return {
|
||||
report: null,
|
||||
inputEcho: {},
|
||||
majorList: [],
|
||||
schoolListFlat: [],
|
||||
schoolChongCount: 0,
|
||||
schoolWenCount: 0,
|
||||
schoolBaoCount: 0,
|
||||
hasSchoolFlat: false,
|
||||
hasNoMajors: true
|
||||
}
|
||||
}
|
||||
const rawSr = report.schoolRecommend
|
||||
const schoolListFlat = buildSchoolListFlat(rawSr)
|
||||
|
||||
let schoolChongCount = 0
|
||||
let schoolWenCount = 0
|
||||
let schoolBaoCount = 0
|
||||
if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) {
|
||||
schoolChongCount = Array.isArray(rawSr.chong) ? rawSr.chong.length : 0
|
||||
schoolWenCount = Array.isArray(rawSr.wen) ? rawSr.wen.length : 0
|
||||
schoolBaoCount = Array.isArray(rawSr.bao) ? rawSr.bao.length : 0
|
||||
}
|
||||
|
||||
const majors = Array.isArray(report.majorRecommend) ? report.majorRecommend : []
|
||||
const inputEcho = report.inputEcho || {}
|
||||
|
||||
return {
|
||||
report,
|
||||
inputEcho,
|
||||
majorList: majors.map((m) => {
|
||||
const rawName =
|
||||
m &&
|
||||
(m.majorName ||
|
||||
m.name ||
|
||||
m.title ||
|
||||
m.major ||
|
||||
m.major_name ||
|
||||
m.majorChinese ||
|
||||
m['专业'] ||
|
||||
m['专业名称'])
|
||||
const name = rawName != null && rawName !== '' ? String(rawName).trim() : ''
|
||||
const displayName = name || '未命名专业'
|
||||
const score = m && (m.fitScore != null ? m.fitScore : m.matchScore)
|
||||
const fitLabel = score != null && score !== '' ? '(' + String(score) + ')' : ''
|
||||
return { name: displayName, fitLabel }
|
||||
}),
|
||||
schoolListFlat,
|
||||
schoolChongCount,
|
||||
schoolWenCount,
|
||||
schoolBaoCount,
|
||||
hasSchoolFlat: schoolListFlat.length > 0,
|
||||
hasNoMajors: majors.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
function payInfoFromDetail(detail) {
|
||||
const isPaid = !!(detail && (detail.isPaid === 1 || detail.isPaid === true))
|
||||
const paidAmount = detail && detail.paidAmount != null ? Number(detail.paidAmount) : 0
|
||||
const amountYuan =
|
||||
detail && detail.amountYuan != null
|
||||
? Number(detail.amountYuan)
|
||||
: paidAmount > 0
|
||||
? paidAmount / 100
|
||||
: 0
|
||||
const needPaymentToUnlock =
|
||||
detail &&
|
||||
(detail.needPaymentToUnlock === true ||
|
||||
(!!detail.requiresPayment && !isPaid && paidAmount > 0))
|
||||
return {
|
||||
requiresPayment: needPaymentToUnlock,
|
||||
isPaid,
|
||||
amountYuan: needPaymentToUnlock ? amountYuan : 0
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
report: null,
|
||||
inputEcho: {},
|
||||
majorList: [],
|
||||
schoolListFlat: [],
|
||||
schoolChongCount: 0,
|
||||
schoolWenCount: 0,
|
||||
schoolBaoCount: 0,
|
||||
hasSchoolFlat: false,
|
||||
hasNoMajors: true,
|
||||
journey: { step1Unlocked: false, step2Unlocked: false, activeStep: 1 },
|
||||
payInfo: {
|
||||
requiresPayment: false,
|
||||
isPaid: false,
|
||||
amountYuan: 0
|
||||
},
|
||||
testResultId: '',
|
||||
shareToken: '',
|
||||
hasReloadedAfterPay: false,
|
||||
hasPhone: false,
|
||||
fromShare: false,
|
||||
profileGate: false,
|
||||
showInviteCodeDialog: false,
|
||||
isPendingAnalyze: false,
|
||||
analyzingTitle: '正在生成高考志愿分析报告',
|
||||
analyzingTip: '',
|
||||
analyzeProgress: 0
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
try {
|
||||
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
|
||||
} catch (e) {}
|
||||
|
||||
const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share')
|
||||
const sid = options && options.id != null && options.id !== '' ? String(options.id) : ''
|
||||
const st = options && options.st ? String(options.st).trim() : ''
|
||||
|
||||
if (sid && st) {
|
||||
this.setData({ fromShare: true })
|
||||
this.loadShareDetail(sid, st)
|
||||
return
|
||||
}
|
||||
|
||||
const ec =
|
||||
typeof this.getOpenerEventChannel === 'function' ? this.getOpenerEventChannel() : null
|
||||
if (ec && typeof ec.once === 'function') {
|
||||
ec.once('gaokaoAnalyzeReport', (payload) => {
|
||||
if (payload && payload.report) {
|
||||
this.applyPayloadOnly({
|
||||
id: payload.id,
|
||||
createdAt: payload.createdAt || 0,
|
||||
overview: payload.overview || '',
|
||||
report: payload.report
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pendingAnalyze = options && String(options.pendingAnalyze) === '1'
|
||||
if (pendingAnalyze) {
|
||||
this._pendingAnalyze = true
|
||||
return
|
||||
}
|
||||
|
||||
const rid = options && options.id != null ? parseInt(String(options.id), 10) : 0
|
||||
this._detailReportId = rid > 0 && !Number.isNaN(rid) ? rid : 0
|
||||
if (fromShareFs) {
|
||||
this.setData({ fromShare: true })
|
||||
}
|
||||
|
||||
const delay = options && options.fromAnalyze === '1' ? 400 : 0
|
||||
setTimeout(() => this.load(), delay)
|
||||
},
|
||||
|
||||
onReady() {
|
||||
if (this._pendingAnalyze) {
|
||||
this._pendingAnalyze = false
|
||||
this.beginAnalyzeFlow()
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this._clearAnalyzeTimers()
|
||||
},
|
||||
|
||||
_clearAnalyzeTimers() {
|
||||
if (this._analyzeProgressTimer) {
|
||||
clearInterval(this._analyzeProgressTimer)
|
||||
this._analyzeProgressTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
beginAnalyzeFlow() {
|
||||
this._clearAnalyzeTimers()
|
||||
const tips = [
|
||||
'正在读取您的 MBTI 与测评数据…',
|
||||
'正在匹配专业维度与性格倾向…',
|
||||
'正在根据分数与省份生成志愿建议…',
|
||||
'正在润色报告与安全合规校验…',
|
||||
'生成综合报告…'
|
||||
]
|
||||
let progress = 0
|
||||
let tipIndex = 0
|
||||
this.setData({
|
||||
isPendingAnalyze: true,
|
||||
analyzingTitle: '正在生成高考志愿分析报告',
|
||||
analyzingTip: tips[0],
|
||||
analyzeProgress: 0
|
||||
})
|
||||
this._analyzeProgressTimer = setInterval(() => {
|
||||
progress += 3
|
||||
if (progress > 95) progress = 95
|
||||
if (progress > (tipIndex + 1) * 18 && tipIndex < tips.length - 1) tipIndex++
|
||||
this.setData({
|
||||
analyzeProgress: Math.floor(progress),
|
||||
analyzingTip: tips[tipIndex]
|
||||
})
|
||||
}, 200)
|
||||
|
||||
gaokaoApi
|
||||
.analyze()
|
||||
.then((res) => {
|
||||
this._clearAnalyzeTimers()
|
||||
const rawId = res && (res.reportId != null ? res.reportId : res.id)
|
||||
const numId = parseInt(String(rawId), 10)
|
||||
if (!rawId || Number.isNaN(numId) || numId <= 0) {
|
||||
throw new Error('未返回报告')
|
||||
}
|
||||
this._detailReportId = numId
|
||||
this.setData({
|
||||
analyzeProgress: 100,
|
||||
analyzingTip: '分析完成!'
|
||||
})
|
||||
return new Promise((r) => setTimeout(r, 400)).then(() =>
|
||||
this.loadDetail(numId, { silent: true }).catch(() => {
|
||||
const rep = res && res.report
|
||||
if (rep && typeof rep === 'object') {
|
||||
this.applyPayloadOnly({
|
||||
id: numId,
|
||||
createdAt: res.createdAt || 0,
|
||||
overview:
|
||||
(typeof res.overview === 'string' && res.overview) ||
|
||||
(rep.overview && String(rep.overview)) ||
|
||||
'',
|
||||
report: rep
|
||||
})
|
||||
return
|
||||
}
|
||||
return Promise.reject(new Error('报告已生成,但加载详情失败'))
|
||||
})
|
||||
)
|
||||
})
|
||||
.then(() => {
|
||||
this.setData({ isPendingAnalyze: false })
|
||||
})
|
||||
.catch((e) => {
|
||||
this._clearAnalyzeTimers()
|
||||
this.setData({ isPendingAnalyze: false, analyzeProgress: 0 })
|
||||
wx.showToast({ title: (e && e.message) || '分析失败', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
wx.navigateBack({ delta: 1 })
|
||||
}, 1600)
|
||||
})
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
if (this.data.report && !this.data.fromShare) {
|
||||
const profileGate = needsResultProfileGate(!!this.data.fromShare)
|
||||
this.setData({ profileGate })
|
||||
this._syncJourney()
|
||||
}
|
||||
// 切换个人/企业 Tab 后回到报告页:静默重拉详情以同步 paidAmount(与当前 Tab 定价一致)
|
||||
if (!this.data.fromShare && !this.data.isPendingAnalyze) {
|
||||
const rid = parseInt(String(this.data.testResultId || ''), 10)
|
||||
if (rid > 0) {
|
||||
this.loadDetail(rid, { silent: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const id = this.data.testResultId
|
||||
const st = this.data.shareToken
|
||||
if (!id || !st) {
|
||||
return { title: '高考志愿分析报告', path: '/pages/gaokao/index' }
|
||||
}
|
||||
return {
|
||||
title: '高考志愿分析报告',
|
||||
path: `/pages/gaokao/report?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}&fs=1`
|
||||
}
|
||||
},
|
||||
|
||||
_syncJourney() {
|
||||
const j = computeJourney(
|
||||
{
|
||||
profileGate: !!this.data.profileGate,
|
||||
payRequired: !!(this.data.payInfo && this.data.payInfo.requiresPayment),
|
||||
isPaid: !!(this.data.payInfo && this.data.payInfo.isPaid)
|
||||
},
|
||||
this.data.testResultId || '0'
|
||||
)
|
||||
this.setData({ journey: j })
|
||||
},
|
||||
|
||||
_reportPaywallOnce(payInfo) {
|
||||
if (!payInfo || !payInfo.requiresPayment || payInfo.isPaid) return
|
||||
if (this._paywallReported) return
|
||||
this._paywallReported = true
|
||||
try {
|
||||
require('../../utils/analytics').track('paywall_view', {
|
||||
type: 'gaokao',
|
||||
amountYuan: payInfo.amountYuan
|
||||
})
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
applyPayloadOnly(payload) {
|
||||
const vm = buildViewModel(payload)
|
||||
const profileGate = needsResultProfileGate(!!this.data.fromShare)
|
||||
const patch = Object.assign(vm, { profileGate })
|
||||
if (payload && payload.id != null) {
|
||||
patch.testResultId = String(payload.id)
|
||||
}
|
||||
this.setData(patch)
|
||||
this._syncJourney()
|
||||
},
|
||||
|
||||
applyDetailPayload(detail) {
|
||||
const mapped = mapTestDetailToReportPayload(detail)
|
||||
if (!mapped) {
|
||||
wx.showToast({ title: '报告数据无效', icon: 'none' })
|
||||
this.setData(buildViewModel(null))
|
||||
return
|
||||
}
|
||||
const payInfo = payInfoFromDetail(detail)
|
||||
const profileGate = needsResultProfileGate(!!this.data.fromShare)
|
||||
const vm = buildViewModel(mapped)
|
||||
const patch = Object.assign(vm, {
|
||||
payInfo,
|
||||
profileGate,
|
||||
shareToken: (detail && detail.shareToken) || '',
|
||||
testResultId: detail.id != null ? String(detail.id) : ''
|
||||
})
|
||||
this.setData(patch)
|
||||
this._reportPaywallOnce(payInfo)
|
||||
this._syncJourney()
|
||||
},
|
||||
|
||||
loadShareDetail(id, st) {
|
||||
wx.showLoading({ title: '加载中...' })
|
||||
requestPromise({
|
||||
url: `/api/test/share-detail?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}`,
|
||||
method: 'GET'
|
||||
})
|
||||
.then((res) => {
|
||||
const body = res.data || {}
|
||||
if (body.code !== 200) {
|
||||
throw new Error(body.message || '加载失败')
|
||||
}
|
||||
this.applyDetailPayload(body.data || {})
|
||||
})
|
||||
.catch((e) => {
|
||||
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
|
||||
this.setData(buildViewModel(null))
|
||||
})
|
||||
.finally(() => wx.hideLoading())
|
||||
},
|
||||
|
||||
loadDetail(id, opts) {
|
||||
const silent = !!(opts && opts.silent)
|
||||
const numId = parseInt(String(id), 10)
|
||||
if (!numId || Number.isNaN(numId)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (!silent) wx.showLoading({ title: '加载中...' })
|
||||
const gd = app.globalData || {}
|
||||
const pricingScope = gd.appScope === 'enterprise' ? 'enterprise' : 'personal'
|
||||
let detailUrl = `/api/test/detail?id=${encodeURIComponent(numId)}&pricingScope=${encodeURIComponent(pricingScope)}`
|
||||
try {
|
||||
const eid = getEnterpriseIdForApiPayload()
|
||||
if (eid != null && Number(eid) > 0) {
|
||||
detailUrl += `&enterpriseId=${encodeURIComponent(String(eid))}`
|
||||
}
|
||||
} catch (e) {}
|
||||
return requestPromise({
|
||||
url: detailUrl,
|
||||
method: 'GET'
|
||||
})
|
||||
.then((res) => {
|
||||
const body = res.data || {}
|
||||
if (body.code !== 200) {
|
||||
throw new Error(body.message || '加载失败')
|
||||
}
|
||||
this.applyDetailPayload(body.data || {})
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!silent) {
|
||||
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
|
||||
this.setData(buildViewModel(null))
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!silent) wx.hideLoading()
|
||||
})
|
||||
},
|
||||
|
||||
load() {
|
||||
const rid = this._detailReportId || 0
|
||||
if (rid > 0) {
|
||||
this.loadDetail(rid)
|
||||
return
|
||||
}
|
||||
gaokaoApi
|
||||
.latestReport()
|
||||
.then((payload) => {
|
||||
const id = payload && payload.id
|
||||
if (!id) {
|
||||
throw new Error('暂无报告')
|
||||
}
|
||||
return this.loadDetail(id)
|
||||
})
|
||||
.catch((e) => {
|
||||
wx.showToast({ title: e.message || '暂无报告', icon: 'none' })
|
||||
this.setData(buildViewModel(null))
|
||||
})
|
||||
},
|
||||
|
||||
goCompleteProfile() {
|
||||
try {
|
||||
require('../../utils/analytics').track('tap_complete_profile', { from: 'gaokao_report' })
|
||||
} catch (e) {}
|
||||
wx.navigateTo({ url: '/pages/user-profile/index' })
|
||||
},
|
||||
|
||||
goWantTest() {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
},
|
||||
|
||||
goReadFullFromShare() {
|
||||
wx.switchTab({ url: '/pages/profile/index' })
|
||||
},
|
||||
|
||||
onTapReadFull() {
|
||||
try {
|
||||
require('../../utils/analytics').track('tap_read_full', { type: 'gaokao' })
|
||||
} catch (e) {}
|
||||
if (this.data.profileGate) {
|
||||
unlockGate.scrollToUnlockAnchor(this)
|
||||
wx.showToast({
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {
|
||||
this.unlockFullReport()
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: '当前已是完整报告', icon: 'none' })
|
||||
},
|
||||
|
||||
onTapShareMoment() {
|
||||
try {
|
||||
require('../../utils/analytics').track('tap_share_moment', { type: 'gaokao' })
|
||||
} catch (e) {}
|
||||
if (!this.data.journey.step1Unlocked) {
|
||||
wx.showToast({ title: '请先解锁全文', icon: 'none' })
|
||||
this.onTapReadFull()
|
||||
return
|
||||
}
|
||||
markShared(this.data.testResultId || '0')
|
||||
this._syncJourney()
|
||||
openTimelineShareHint()
|
||||
},
|
||||
|
||||
unlockFullReport() {
|
||||
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
|
||||
if (!payInfo.requiresPayment || payInfo.isPaid) return
|
||||
try {
|
||||
require('../../utils/analytics').track('tap_unlock_full', {
|
||||
type: 'gaokao',
|
||||
amountYuan: payInfo.amountYuan
|
||||
})
|
||||
} catch (e) {}
|
||||
const run =
|
||||
typeof app.ensureLogin === 'function'
|
||||
? app.ensureLogin()
|
||||
: Promise.resolve(!!(app.globalData && app.globalData.token) || !!wx.getStorageSync('token'))
|
||||
run.then((logged) => {
|
||||
if (!logged) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchaseGaokaoReport({
|
||||
testResultId: testResultId ? parseInt(String(testResultId), 10) || undefined : undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onGetPhoneNumberForGaokaoPay(e) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
|
||||
if (!hasPhone()) {
|
||||
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.unlockFullReport()
|
||||
return
|
||||
}
|
||||
if (!code) {
|
||||
if (hasPhone()) {
|
||||
this.unlockFullReport()
|
||||
} else {
|
||||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
bindPhoneByCode(code)
|
||||
.then(() => {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const profileGate = needsResultProfileGate(!!this.data.fromShare)
|
||||
this.setData({ profileGate })
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded()
|
||||
this._syncJourney()
|
||||
this.unlockFullReport()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onPhoneLoginForResultGate(e) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
|
||||
wx.showToast({ title: '需要授权手机号才能查看完整报告', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!code) {
|
||||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
bindPhoneByCode(code)
|
||||
.then(() => {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const profileGate = needsResultProfileGate(!!this.data.fromShare)
|
||||
this.setData({ profileGate })
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded()
|
||||
this._syncJourney()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onInviteCodeSkip() {
|
||||
inviteCodeGate.finishInviteCodeGate(this, true)
|
||||
},
|
||||
|
||||
onInviteCodeSuccess() {
|
||||
inviteCodeGate.finishInviteCodeGate(this, true)
|
||||
}
|
||||
})
|
||||
6
miniprogram/pages/gaokao/report.json
Normal file
6
miniprogram/pages/gaokao/report.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "高考分析报告",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
262
miniprogram/pages/gaokao/report.wxml
Normal file
262
miniprogram/pages/gaokao/report.wxml
Normal file
@@ -0,0 +1,262 @@
|
||||
<view class="gaokao-report-page">
|
||||
<!-- 综合分析:与人脸结果页类似的加载态 -->
|
||||
<view class="analyzing-modal" wx:if="{{isPendingAnalyze}}">
|
||||
<view class="analyzing-content">
|
||||
<view class="analyzing-icon">
|
||||
<view class="analyzing-spinner"></view>
|
||||
</view>
|
||||
<text class="analyzing-title">{{analyzingTitle}}</text>
|
||||
<text class="analyzing-desc">{{analyzingTip}}</text>
|
||||
<view class="analyzing-bar">
|
||||
<view class="analyzing-bar-fill" style="width: {{analyzeProgress}}%"></view>
|
||||
</view>
|
||||
<text class="analyzing-hint">AI 生成可能需要 30 秒~1 分钟,请稍候…</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="container {{report && !fromShare ? 'container--with-bottom-tools' : ''}} {{report && fromShare ? 'container--with-share-footer' : ''}}"
|
||||
wx:if="{{report}}"
|
||||
>
|
||||
<view class="header-card">
|
||||
<view class="header-hero">
|
||||
<view class="header-top">
|
||||
<view class="user-info">
|
||||
<view class="user-name">{{inputEcho.name || '同学'}}</view>
|
||||
<view class="user-tags">
|
||||
<text class="tag tag-province" wx:if="{{inputEcho.province}}">{{inputEcho.province}}</text>
|
||||
<text class="tag tag-stream" wx:if="{{inputEcho.streamSubjects}}">{{inputEcho.streamSubjects}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="score-box">
|
||||
<text class="score-num">{{inputEcho.estimatedScore || 0}}</text>
|
||||
<text class="score-unit">分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-body" wx:if="{{inputEcho.mbti || inputEcho.pdp || inputEcho.disc}}">
|
||||
<view class="personality-tags">
|
||||
<view class="p-tag" wx:if="{{inputEcho.mbti}}">
|
||||
<text class="p-label">MBTI</text>
|
||||
<text class="p-value">{{inputEcho.mbti}}</text>
|
||||
</view>
|
||||
<view class="p-tag" wx:if="{{inputEcho.pdp}}">
|
||||
<text class="p-label">PDP</text>
|
||||
<text class="p-value">{{inputEcho.pdp}}</text>
|
||||
</view>
|
||||
<view class="p-tag" wx:if="{{inputEcho.disc}}">
|
||||
<text class="p-label">DISC</text>
|
||||
<text class="p-value">{{inputEcho.disc}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid && !fromShare}}">
|
||||
<view class="paywall-content paywall-content--preview40">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整高考志愿分析报告</text>
|
||||
<text class="paywall-fake-line paywall-fake-line--compact">解锁后可查看冲稳保院校、专业推荐与性格解读全文。</text>
|
||||
<view class="paywall-preview-wrap" wx:if="{{report.overview}}">
|
||||
<text class="paywall-preview-label">综合总评(预览)</text>
|
||||
<view class="paywall-preview-inner">
|
||||
<text class="paywall-preview-text">{{report.overview}}</text>
|
||||
<view class="paywall-preview-fade"></view>
|
||||
</view>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="paywall-fake-line">• 冲稳保院校与推荐理由</text>
|
||||
<text class="paywall-fake-line">• 专业推荐与匹配说明</text>
|
||||
<text class="paywall-fake-line">• 性格与志愿方向解读</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="paywall-mask paywall-mask--from40"></view>
|
||||
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForGaokaoPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button class="paywall-btn" wx:elif="{{hasPhone}}" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card paywall-card" wx:elif="{{report.locked && !fromShare && !profileGate}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告暂不可查看</text>
|
||||
<text class="paywall-fake-line">请稍后在「我的」重试或联系客服;若刚授权手机,可下拉刷新本页。</text>
|
||||
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去个人资料</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{profileGate && report && (!payInfo.requiresPayment || payInfo.isPaid) && !fromShare}}"
|
||||
id="unlock-gate-anchor"
|
||||
class="unlock-gate-profile-block"
|
||||
>
|
||||
<view class="preview-teaser-card" wx:if="{{report.overview}}">
|
||||
<text class="preview-teaser-title">综合总评(预览)</text>
|
||||
<text class="preview-teaser-desc">{{report.overview}}</text>
|
||||
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文。</text>
|
||||
</view>
|
||||
<button
|
||||
wx:if="{{!hasPhone}}"
|
||||
class="paywall-btn paywall-btn--inline-profile"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onPhoneLoginForResultGate"
|
||||
>
|
||||
<text class="paywall-btn-main">登录解锁全文</text>
|
||||
</button>
|
||||
<button wx:else class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">完善资料 · 查看全文</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view id="unlock-scroll-tail"></view>
|
||||
|
||||
<view class="card" wx:if="{{!report.locked}}">
|
||||
<view class="title-wrap">
|
||||
<view class="title-icon">📊</view>
|
||||
<view class="title">综合总评</view>
|
||||
</view>
|
||||
<view class="content">{{report.overview}}</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{!report.locked}}">
|
||||
<view class="title-wrap">
|
||||
<view class="title-icon">🎯</view>
|
||||
<view class="title">冲稳保建议</view>
|
||||
</view>
|
||||
<block wx:if="{{hasSchoolFlat}}">
|
||||
<view class="sub-counts" wx:if="{{schoolChongCount || schoolWenCount || schoolBaoCount}}">
|
||||
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
|
||||
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
|
||||
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
|
||||
</view>
|
||||
<view class="sub-counts" wx:else>
|
||||
<view class="count-tag count-total">院校参考(共 {{schoolListFlat.length}} 所)</view>
|
||||
</view>
|
||||
|
||||
<view class="school-list">
|
||||
<block wx:for="{{schoolListFlat}}" wx:for-item="sch" wx:for-index="sidx" wx:key="sidx">
|
||||
<view class="school-item">
|
||||
<view class="school-header">
|
||||
<view
|
||||
class="school-band-tag band-{{sch.band === '冲' ? 'chong' : (sch.band === '稳' ? 'wen' : (sch.band === '保' ? 'bao' : 'none'))}}"
|
||||
wx:if="{{sch.band}}"
|
||||
>{{sch.band}}</view>
|
||||
<view class="school-name">{{sch.schoolName}}</view>
|
||||
</view>
|
||||
<view class="school-meta-row" wx:if="{{sch.city || sch.level}}">
|
||||
<text class="meta-item" wx:if="{{sch.city}}"><text class="meta-icon">📍</text>{{sch.city}}</text>
|
||||
<text class="meta-item" wx:if="{{sch.level}}"><text class="meta-icon">🎓</text>{{sch.level}}</text>
|
||||
</view>
|
||||
<view class="school-reason" wx:if="{{sch.reason}}">
|
||||
<text class="quote-mark">“</text>
|
||||
{{sch.reason}}
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</block>
|
||||
<view wx:else class="sub-counts">
|
||||
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
|
||||
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
|
||||
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{!report.locked}}">
|
||||
<view class="title-wrap">
|
||||
<view class="title-icon">💼</view>
|
||||
<view class="title">专业建议</view>
|
||||
</view>
|
||||
<view class="major-list" wx:if="{{!hasNoMajors}}">
|
||||
<block wx:for="{{majorList}}" wx:for-item="row" wx:for-index="idx" wx:key="idx">
|
||||
<view class="major-item">
|
||||
<view class="major-rank rank-{{idx < 3 ? idx + 1 : 'other'}}">{{idx + 1}}</view>
|
||||
<view class="major-name">{{row.name}}</view>
|
||||
<view class="major-score" wx:if="{{row.fitLabel}}">{{row.fitLabel}}</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<view wx:if="{{hasNoMajors}}" class="sub">暂无专业推荐</view>
|
||||
</view>
|
||||
|
||||
<view class="card" wx:if="{{!report.locked}}">
|
||||
<view class="title-wrap">
|
||||
<view class="title-icon">🧠</view>
|
||||
<view class="title">性格匹配说明</view>
|
||||
</view>
|
||||
<view class="content">{{report.personalityReason}}</view>
|
||||
</view>
|
||||
|
||||
<view class="card disclaimer-card" wx:if="{{!report.locked}}">
|
||||
<view class="title-wrap">
|
||||
<view class="title-icon">⚠️</view>
|
||||
<view class="title">免责声明</view>
|
||||
</view>
|
||||
<view class="content disclaimer-text">{{report.disclaimers}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:elif="{{!isPendingAnalyze}}">
|
||||
<view class="empty-icon">📄</view>
|
||||
<view class="empty-text">暂无报告,请先完成综合分析</view>
|
||||
</view>
|
||||
|
||||
<view class="result-bottom-tools" wx:if="{{report && !fromShare}}">
|
||||
<view class="journey-stepper journey-stepper--two">
|
||||
<view class="journey-stepper__item">
|
||||
<view
|
||||
class="journey-stepper__dot {{journey.step1Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===1 ? 'journey-stepper__dot--active' : '')}}"
|
||||
>1</view>
|
||||
<text class="journey-stepper__label {{journey.activeStep===1 ? 'journey-stepper__label--active' : ''}}">看全文</text>
|
||||
</view>
|
||||
<view class="journey-stepper__bar {{journey.step1Unlocked ? 'journey-stepper__bar--done' : ''}}"></view>
|
||||
<view class="journey-stepper__item">
|
||||
<view
|
||||
class="journey-stepper__dot {{journey.step2Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===2 ? 'journey-stepper__dot--active' : '')}}"
|
||||
>2</view>
|
||||
<text class="journey-stepper__label {{journey.activeStep===2 ? 'journey-stepper__label--active' : ''}}">分享朋友圈</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="result-bottom-tools__row result-bottom-tools__row--two">
|
||||
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
|
||||
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
|
||||
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料后解锁' : '登录并完善资料') : '点击解锁')}}</text>
|
||||
</view>
|
||||
<view
|
||||
class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}"
|
||||
bindtap="onTapShareMoment"
|
||||
>
|
||||
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>
|
||||
<text class="result-tool-sub">{{journey.step1Unlocked ? (journey.step2Unlocked ? '已分享' : '点击分享') : '先解锁全文'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="result-share-footer result-share-footer--stack" wx:if="{{report && fromShare}}">
|
||||
<button class="result-share-footer-btn" bindtap="goWantTest">我也要测试</button>
|
||||
<view class="result-share-footer-row2">
|
||||
<button class="result-share-footer-btn result-share-footer-btn--ghost" bindtap="goReadFullFromShare">看全文</button>
|
||||
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
698
miniprogram/pages/gaokao/report.wxss
Normal file
698
miniprogram/pages/gaokao/report.wxss
Normal file
@@ -0,0 +1,698 @@
|
||||
@import '../../styles/result-page-dashboard.wxss';
|
||||
|
||||
.gaokao-report-page {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f9;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f9;
|
||||
padding: 24rpx;
|
||||
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container.container--with-bottom-tools {
|
||||
padding-bottom: calc(120rpx + 400rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.container.container--with-share-footer {
|
||||
padding-bottom: calc(220rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.journey-stepper--two {
|
||||
max-width: 520rpx;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.result-bottom-tools__row--two .result-tool-btn:nth-child(1)::before {
|
||||
content: '01';
|
||||
}
|
||||
.result-bottom-tools__row--two .result-tool-btn:nth-child(2)::before {
|
||||
content: '02';
|
||||
}
|
||||
|
||||
/* 付费墙(与 MBTI 结果页同款布局,配色贴近高考页主色) */
|
||||
.paywall-card {
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.paywall-content {
|
||||
position: relative;
|
||||
min-height: 360rpx;
|
||||
}
|
||||
/* 有总评预览时拉高容器,便于展示约 40vh 正文 */
|
||||
.paywall-content--preview40 {
|
||||
min-height: 48vh;
|
||||
}
|
||||
.paywall-blur {
|
||||
padding: 32rpx 24rpx 200rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(6rpx);
|
||||
}
|
||||
.paywall-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 24rpx;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.28) 38%,
|
||||
rgba(255, 255, 255, 0.58) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 顶部约 40% 区域不压暗,以下渐强引导至解锁按钮 */
|
||||
.paywall-mask--from40 {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 40%,
|
||||
rgba(255, 255, 255, 0.35) 58%,
|
||||
rgba(255, 255, 255, 0.72) 78%,
|
||||
rgba(255, 255, 255, 0.94) 100%
|
||||
);
|
||||
}
|
||||
.paywall-fake-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.paywall-fake-line {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #888;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.paywall-fake-line--compact {
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
.paywall-preview-wrap {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.paywall-preview-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #6366f1;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.paywall-preview-inner {
|
||||
position: relative;
|
||||
max-height: 40vh;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background: #f8fafc;
|
||||
padding: 20rpx 20rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.paywall-preview-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.paywall-preview-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100rpx;
|
||||
background: linear-gradient(to bottom, rgba(248, 250, 252, 0), rgba(248, 250, 252, 1));
|
||||
pointer-events: none;
|
||||
}
|
||||
.paywall-btn {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
width: 90%;
|
||||
bottom: 132rpx;
|
||||
z-index: 3;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.paywall-btn-main {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.paywall-btn-price {
|
||||
font-size: 24rpx;
|
||||
color: #e0e7ff;
|
||||
}
|
||||
.paywall-btn--inline-profile {
|
||||
position: relative !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
width: 100% !important;
|
||||
margin-top: 28rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.result-share-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #f5f5f5;
|
||||
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.result-share-footer-btn {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border: none;
|
||||
}
|
||||
.result-share-footer-btn::after {
|
||||
border: none;
|
||||
}
|
||||
.result-share-footer-row2 .result-share-footer-btn {
|
||||
flex: 1;
|
||||
min-height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 26rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Header Card:渐变区随内容增高,避免省/科类标签落到白底上被「裁切」看不见 */
|
||||
.header-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.header-hero {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
padding: 32rpx 32rpx 36rpx;
|
||||
}
|
||||
|
||||
.header-body {
|
||||
padding: 24rpx 32rpx 32rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 16rpx;
|
||||
line-height: 1.35;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 18rpx;
|
||||
line-height: 1.3;
|
||||
border-radius: 100rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-province {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.tag-stream {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.score-box {
|
||||
background: #fff;
|
||||
padding: 16rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 8rpx 16rpx rgba(99, 102, 241, 0.15);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #6366f1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-left: 4rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personality-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
padding-top: 4rpx;
|
||||
border-top: 1rpx dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.p-tag {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
background: #f8fafc;
|
||||
border-radius: 12rpx;
|
||||
overflow: visible;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.p-label {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 20rpx;
|
||||
font-weight: bold;
|
||||
padding: 6rpx 12rpx;
|
||||
}
|
||||
|
||||
.p-value {
|
||||
color: #334155;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 16rpx;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 460rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Common Card */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 28rpx;
|
||||
color: #475569;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* Sub Counts (冲稳保) */
|
||||
.sub-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.count-tag {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.count-chong {
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.count-wen {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.count-bao {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.count-total {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* School List */
|
||||
.school-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.school-item {
|
||||
background: #f8fafc;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
border: 1rpx solid #f1f5f9;
|
||||
}
|
||||
|
||||
.school-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.school-band-tag {
|
||||
font-size: 22rpx;
|
||||
font-weight: bold;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.band-chong {
|
||||
background: #ffe4e6;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.band-wen {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.band-bao {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.band-none {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.school-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
margin-right: 6rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.school-reason {
|
||||
font-size: 26rpx;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
background: #fff;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quote-mark {
|
||||
color: #cbd5e1;
|
||||
font-size: 40rpx;
|
||||
font-family: serif;
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
left: 12rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.school-reason {
|
||||
padding-left: 40rpx;
|
||||
}
|
||||
|
||||
/* Major List */
|
||||
.major-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.major-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.major-rank {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
background: #fef08a;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.rank-other {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.major-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.major-score {
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
background: #fff;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 100rpx;
|
||||
}
|
||||
|
||||
/* Disclaimer */
|
||||
.disclaimer-card {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.disclaimer-card .title {
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.disclaimer-text {
|
||||
color: #be123c;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 200rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 100rpx;
|
||||
margin-bottom: 32rpx;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 30rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 综合分析加载(与人脸结果页同款结构,配色贴近高考页) */
|
||||
.analyzing-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(15, 23, 42, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.analyzing-content {
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 80rpx 56rpx;
|
||||
width: 560rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 16rpx 48rpx rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.analyzing-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.analyzing-spinner {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border: 8rpx solid #e2e8f0;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: gaokao-analyzing-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gaokao-analyzing-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.analyzing-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.analyzing-desc {
|
||||
font-size: 28rpx;
|
||||
color: #6366f1;
|
||||
margin-bottom: 24rpx;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.analyzing-bar {
|
||||
width: 400rpx;
|
||||
height: 12rpx;
|
||||
background: #e2e8f0;
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.analyzing-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #a855f7);
|
||||
border-radius: 6rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.analyzing-hint {
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -141,15 +141,40 @@ Page({
|
||||
},
|
||||
|
||||
formatList(rawList) {
|
||||
const typeNames = { mbti: 'MBTI性格测试', sbti: 'SBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' }
|
||||
const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' }
|
||||
const typeNames = {
|
||||
mbti: 'MBTI性格测试',
|
||||
sbti: 'SBTI性格测试',
|
||||
disc: 'DISC性格测试',
|
||||
pdp: 'PDP行为偏好测试',
|
||||
ai: '面相分析',
|
||||
resume: '简历综合分析',
|
||||
gaokao: '高考志愿'
|
||||
}
|
||||
const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋', gaokao: '🎓' }
|
||||
|
||||
return rawList.map((item, idx) => {
|
||||
const testType = String(item.testType || item.type || 'mbti').toLowerCase()
|
||||
if (testType === 'gaokao') {
|
||||
const rawBadge = String(item.resultText || '').trim()
|
||||
const shortBadge =
|
||||
rawBadge === '已生成' || rawBadge === '志愿报告' || rawBadge === '查看报告'
|
||||
? rawBadge
|
||||
: '已生成'
|
||||
return {
|
||||
...item,
|
||||
type: 'gaokao',
|
||||
testType: 'gaokao',
|
||||
key: item.key || 'gaokao_' + (item.id || idx),
|
||||
emoji: item.emoji || '🎓',
|
||||
typeName: '高考志愿',
|
||||
resultText: shortBadge,
|
||||
enterpriseName: item.enterpriseName || ''
|
||||
}
|
||||
}
|
||||
if (item.typeName) {
|
||||
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 ts = item.createdAt || item.testTime || item.timestamp
|
||||
return {
|
||||
...item,
|
||||
@@ -219,11 +244,12 @@ Page({
|
||||
disc: '/pages/result/disc',
|
||||
pdp: '/pages/result/pdp',
|
||||
ai: '/pages/index/result',
|
||||
resume: '/pages/result/resume'
|
||||
resume: '/pages/result/resume',
|
||||
gaokao: '/pages/gaokao/report'
|
||||
}
|
||||
const base = routes[type]
|
||||
if (!base) return
|
||||
if ((type === 'ai' || type === 'resume') && !id) return
|
||||
if ((type === 'ai' || type === 'resume' || type === 'gaokao') && !id) return
|
||||
const query = id ? `?id=${id}&type=${type}` : ''
|
||||
wx.navigateTo({ url: query ? base + query : base })
|
||||
},
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
.icon-pdp { background-color: #dbeafe; }
|
||||
.icon-disc { background-color: #dcfce7; }
|
||||
.icon-ai { background-color: #fce7f3; }
|
||||
.icon-resume { background-color: #f1f5f9; }
|
||||
.icon-gaokao { background: linear-gradient(135deg, #eef2ff, #e0e7ff); }
|
||||
|
||||
.icon-emoji {
|
||||
font-size: 32rpx;
|
||||
@@ -148,6 +150,9 @@
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
flex-shrink: 0;
|
||||
max-width: 46%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.badge-mbti { background-color: #f3e8ff; }
|
||||
@@ -165,10 +170,17 @@
|
||||
.badge-ai { background-color: #fce7f3; }
|
||||
.badge-ai .badge-text { color: #db2777; }
|
||||
|
||||
.badge-gaokao { background-color: #eef2ff; }
|
||||
.badge-gaokao .badge-text { color: #4f46e5; }
|
||||
|
||||
.badge-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.test-time-row {
|
||||
|
||||
@@ -9,6 +9,8 @@ Page({
|
||||
permSbti: true,
|
||||
permPdp: true,
|
||||
permDisc: true,
|
||||
/** 高考志愿任务中心;企业关闭 gaokao 时隐藏 */
|
||||
permGaokao: true,
|
||||
/** AI 对话(神仙 AI);企业关闭 aiHub 时隐藏 */
|
||||
permAiHub: true,
|
||||
/** 四类问卷入口均被企业权限关闭时提示 */
|
||||
@@ -43,6 +45,7 @@ Page({
|
||||
const permSbti = !p || p.sbti !== false
|
||||
const permPdp = !p || p.pdp !== false
|
||||
const permDisc = !p || p.disc !== false
|
||||
const permGaokao = !p || p.gaokao !== false
|
||||
const permAiHub = (!p || p.aiHub !== false) && !hideAi
|
||||
this.setData({
|
||||
permFace,
|
||||
@@ -50,8 +53,9 @@ Page({
|
||||
permSbti,
|
||||
permPdp,
|
||||
permDisc,
|
||||
permGaokao,
|
||||
permAiHub,
|
||||
allTestsDisabled: p && !permMbti && !permSbti && !permPdp && !permDisc
|
||||
allTestsDisabled: p && !permMbti && !permSbti && !permPdp && !permDisc && !permGaokao
|
||||
})
|
||||
},
|
||||
|
||||
@@ -79,6 +83,17 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/test/disc' })
|
||||
},
|
||||
|
||||
goGaokaoHub() {
|
||||
if (!this.data.permGaokao) {
|
||||
wx.showToast({ title: '当前企业未开放高考志愿功能', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
require('../../utils/analytics').track('tap_test_select_gaokao', {})
|
||||
} catch (e) {}
|
||||
wx.navigateTo({ url: '/pages/gaokao/index' })
|
||||
},
|
||||
|
||||
goAIChatInterpretation() {
|
||||
if (isAuditHideAiMode(getApp().globalData)) {
|
||||
wx.showToast({ title: '功能升级中', icon: 'none' })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!--pages/test-select/index.wxml - 详细性格测试入口(MBTI / SBTI / PDP / DISC / AI)-->
|
||||
<!--pages/test-select/index.wxml - 详细性格测试 + 高考志愿等入口-->
|
||||
<view class="container">
|
||||
<view class="card intro-card">
|
||||
<text class="intro-title">选择一项详细性格测试</text>
|
||||
<text class="intro-desc">问卷测评与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告</text>
|
||||
<text class="intro-desc">问卷测评、高考志愿规划与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告</text>
|
||||
</view>
|
||||
|
||||
<view class="card entry-card" bindtap="goMBTI" wx:if="{{permMbti}}">
|
||||
@@ -49,6 +49,17 @@
|
||||
<text class="entry-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="card entry-card" bindtap="goGaokaoHub" wx:if="{{permGaokao}}">
|
||||
<view class="entry-icon-wrap gaokao">
|
||||
<text class="entry-emoji">🎓</text>
|
||||
</view>
|
||||
<view class="entry-content">
|
||||
<text class="entry-name">高考志愿任务中心</text>
|
||||
<text class="entry-brief">完成 MBTI / PDP / DISC / 拍照面相与信息表单 · 购买报告后生成志愿分析</text>
|
||||
</view>
|
||||
<text class="entry-arrow">→</text>
|
||||
</view>
|
||||
|
||||
<view class="card entry-card" bindtap="goAIChatInterpretation" wx:if="{{permAiHub}}">
|
||||
<view class="entry-icon-wrap ai-chat">
|
||||
<text class="entry-emoji">💬</text>
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(96, 165, 250, 0.18) 100%);
|
||||
}
|
||||
|
||||
.entry-icon-wrap.gaokao {
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.18) 0%, rgba(129, 140, 248, 0.22) 100%);
|
||||
border: 1rpx solid rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.entry-icon-wrap.ai-chat {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.16) 0%, rgba(99, 102, 241, 0.2) 100%);
|
||||
}
|
||||
|
||||
87
miniprogram/utils/gaokao.js
Normal file
87
miniprogram/utils/gaokao.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { requestPromise } = require('./request')
|
||||
|
||||
function gaokaoPricingQueryParts(extraParams) {
|
||||
const app = getApp()
|
||||
const gd = (app && app.globalData) || {}
|
||||
const scope =
|
||||
(extraParams && extraParams.pricingScope) ||
|
||||
(gd.appScope === 'enterprise' ? 'enterprise' : 'personal')
|
||||
const parts = [`pricingScope=${encodeURIComponent(scope)}`]
|
||||
try {
|
||||
const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js')
|
||||
const eid = getEnterpriseIdForApiPayload()
|
||||
if (eid != null && Number(eid) > 0) {
|
||||
parts.push(`enterpriseId=${encodeURIComponent(String(eid))}`)
|
||||
}
|
||||
} catch (e) {}
|
||||
return parts
|
||||
}
|
||||
|
||||
function getTaskStatus(params = {}) {
|
||||
const query = []
|
||||
if (params.referrerId) query.push(`referrerId=${encodeURIComponent(params.referrerId)}`)
|
||||
if (params.channelCode) query.push(`channelCode=${encodeURIComponent(params.channelCode)}`)
|
||||
if (params.scene) query.push(`scene=${encodeURIComponent(params.scene)}`)
|
||||
gaokaoPricingQueryParts(params).forEach((p) => query.push(p))
|
||||
const qs = query.length ? `?${query.join('&')}` : ''
|
||||
return requestPromise({
|
||||
url: `/api/gaokao/task-status${qs}`,
|
||||
method: 'GET'
|
||||
}).then((res) => (res.data && res.data.data) || {})
|
||||
}
|
||||
|
||||
function getForm() {
|
||||
return requestPromise({
|
||||
url: '/api/gaokao/form',
|
||||
method: 'GET'
|
||||
}).then((res) => (res.data && res.data.data) || {})
|
||||
}
|
||||
|
||||
function saveForm(data) {
|
||||
return requestPromise({
|
||||
url: '/api/gaokao/form',
|
||||
method: 'POST',
|
||||
data
|
||||
}).then((res) => (res.data && res.data.data) || {})
|
||||
}
|
||||
|
||||
function analyze(extra = {}) {
|
||||
const app = getApp()
|
||||
const gd = (app && app.globalData) || {}
|
||||
const pricingScope =
|
||||
(extra && extra.pricingScope) || (gd.appScope === 'enterprise' ? 'enterprise' : 'personal')
|
||||
let enterpriseId = 0
|
||||
try {
|
||||
const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js')
|
||||
const eid = getEnterpriseIdForApiPayload()
|
||||
if (eid != null && Number(eid) > 0) enterpriseId = Number(eid)
|
||||
} catch (e) {}
|
||||
|
||||
return requestPromise({
|
||||
url: '/api/gaokao/analyze',
|
||||
method: 'POST',
|
||||
data: Object.assign({}, extra, { pricingScope, enterpriseId }),
|
||||
timeout: 120000
|
||||
}).then((res) => {
|
||||
const body = res.data || {}
|
||||
if (body.code !== 200) {
|
||||
throw new Error(body.message || '分析失败')
|
||||
}
|
||||
return body.data || {}
|
||||
})
|
||||
}
|
||||
|
||||
function latestReport() {
|
||||
return requestPromise({
|
||||
url: '/api/gaokao/report/my-latest',
|
||||
method: 'GET'
|
||||
}).then((res) => (res.data && res.data.data) || {})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTaskStatus,
|
||||
getForm,
|
||||
saveForm,
|
||||
analyze,
|
||||
latestReport
|
||||
}
|
||||
63
miniprogram/utils/gaokaoJourneyState.js
Normal file
63
miniprogram/utils/gaokaoJourneyState.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 高考报告页「两步解锁」:看全文 → 分享朋友圈(不与 MBTI 的 mbti_journey_unlocks 混用)
|
||||
* storage: gaokao_journey_<testResultId> = { sharedMoment: ts }
|
||||
*/
|
||||
|
||||
function storageKey(testResultId) {
|
||||
const id =
|
||||
testResultId != null && String(testResultId) !== '' && String(testResultId) !== '0'
|
||||
? String(testResultId)
|
||||
: '0'
|
||||
return 'gaokao_journey_' + id
|
||||
}
|
||||
|
||||
function read(testResultId) {
|
||||
try {
|
||||
const v = wx.getStorageSync(storageKey(testResultId))
|
||||
if (v && typeof v === 'object') return v
|
||||
} catch (e) {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function write(testResultId, obj) {
|
||||
try {
|
||||
wx.setStorageSync(storageKey(testResultId), obj || {})
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function isStep1Unlocked({ profileGate, payRequired, isPaid }) {
|
||||
if (profileGate) return false
|
||||
if (payRequired && !isPaid) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function markShared(testResultId) {
|
||||
const v = read(testResultId)
|
||||
v.sharedMoment = Date.now()
|
||||
write(testResultId, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ profileGate: boolean, payRequired: boolean, isPaid: boolean }} ctx
|
||||
* @param {string|number} testResultId
|
||||
* @returns {{ step1Unlocked: boolean, step2Unlocked: boolean, activeStep: number }}
|
||||
*/
|
||||
function computeJourney(ctx, testResultId) {
|
||||
const s1 = isStep1Unlocked(ctx)
|
||||
const s2 = s1 && !!read(testResultId).sharedMoment
|
||||
let activeStep = 1
|
||||
if (!s1) activeStep = 1
|
||||
else if (!s2) activeStep = 2
|
||||
else activeStep = 0
|
||||
return {
|
||||
step1Unlocked: s1,
|
||||
step2Unlocked: s2,
|
||||
activeStep
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeJourney,
|
||||
markShared,
|
||||
isStep1Unlocked
|
||||
}
|
||||
@@ -36,7 +36,8 @@ function generateOrderId(productType) {
|
||||
single_test: 'TSGL',
|
||||
recharge: 'RCG',
|
||||
deep_personal: 'DPER',
|
||||
deep_team: 'DTEAM'
|
||||
deep_team: 'DTEAM',
|
||||
gaokao: 'GAOKAO'
|
||||
}
|
||||
|
||||
const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6)
|
||||
@@ -69,7 +70,18 @@ function generateOrderId(productType) {
|
||||
* @param {Function} options.fail - 失败回调
|
||||
*/
|
||||
function wxPay(options) {
|
||||
const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
|
||||
const {
|
||||
orderId,
|
||||
amount = 0,
|
||||
description,
|
||||
productType,
|
||||
testResultId,
|
||||
deepProductId,
|
||||
enterpriseId,
|
||||
pricingScope,
|
||||
success,
|
||||
fail
|
||||
} = options
|
||||
|
||||
if (app.globalData && app.globalData.miniprogramAuditMode) {
|
||||
const msg = '版本审核期间不可发起支付'
|
||||
@@ -116,14 +128,34 @@ function wxPay(options) {
|
||||
// 创建订单时将本次测试记录ID传给后端,避免每次都只更新“最新一条”
|
||||
testResultId: testResultId || 0,
|
||||
// 深度服务使用的具体套餐ID/产品Key(用于从 categories 中选择价格)
|
||||
deepProductId: deepProductId || ''
|
||||
deepProductId: deepProductId || '',
|
||||
// 高考:与 Tab appScope 一致,后端按个人/企业档刷新 paidAmount
|
||||
pricingScope:
|
||||
productType === 'gaokao'
|
||||
? pricingScope ||
|
||||
((app.globalData && app.globalData.appScope) || 'personal')
|
||||
: pricingScope || ''
|
||||
},
|
||||
success: (res) => {
|
||||
wx.hideLoading()
|
||||
|
||||
if (res.statusCode === 200 && res.data.code === 200) {
|
||||
const paymentData = res.data.data
|
||||
|
||||
|
||||
// 后端 0 元单(如高考志愿定价为 0):不调 wx.requestPayment,直接视为成功
|
||||
if (paymentData && paymentData.skipWxPay) {
|
||||
wx.hideLoading()
|
||||
try {
|
||||
triggerOrderPaid(orderId)
|
||||
} catch (e) {}
|
||||
try {
|
||||
require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount: 0, note: 'zero_skip_wx' })
|
||||
} catch (e) {}
|
||||
wx.showToast({ title: '已解锁', icon: 'success', duration: 2000 })
|
||||
success && success({ skipWxPay: true, order: paymentData })
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调起微信支付
|
||||
wx.requestPayment({
|
||||
timeStamp: paymentData.timeStamp,
|
||||
@@ -421,7 +453,7 @@ function purchaseByPricing(productType, description, extra, maybeFail) {
|
||||
opts = extra || {}
|
||||
}
|
||||
|
||||
const { testResultId, success, fail } = opts
|
||||
const { testResultId, success, fail, pricingScope } = opts
|
||||
const orderId = generateOrderId(productType)
|
||||
|
||||
wxPay({
|
||||
@@ -431,6 +463,7 @@ function purchaseByPricing(productType, description, extra, maybeFail) {
|
||||
productType,
|
||||
testResultId,
|
||||
enterpriseId: enterpriseIdForOrder(),
|
||||
pricingScope,
|
||||
success,
|
||||
fail
|
||||
})
|
||||
@@ -499,6 +532,26 @@ function purchaseTeamAnalysis(success, fail) {
|
||||
purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail)
|
||||
}
|
||||
|
||||
/** 高考志愿分析报告(统一定价键 gaokao,走 orders + test_results) */
|
||||
function purchaseGaokaoReport(extra, maybeFail) {
|
||||
let opts = {}
|
||||
if (typeof extra === 'function' || extra === undefined) {
|
||||
opts.success = extra
|
||||
opts.fail = maybeFail
|
||||
} else {
|
||||
opts = extra || {}
|
||||
}
|
||||
const { success, fail, testResultId } = opts
|
||||
const pricingScope =
|
||||
(app.globalData && app.globalData.appScope) === 'enterprise' ? 'enterprise' : 'personal'
|
||||
purchaseByPricing('gaokao', '高考志愿分析报告', {
|
||||
success,
|
||||
fail,
|
||||
testResultId,
|
||||
pricingScope
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业充值
|
||||
* 支持两种调用:
|
||||
@@ -692,6 +745,7 @@ module.exports = {
|
||||
purchaseResumeAnalysis,
|
||||
purchaseFullReport,
|
||||
purchaseTeamAnalysis,
|
||||
purchaseGaokaoReport,
|
||||
recharge,
|
||||
purchasePersonalDeepService,
|
||||
purchaseTeamDeepService,
|
||||
|
||||
228
开发文档/MBTI定价支付与分销逻辑.md
Normal file
228
开发文档/MBTI定价支付与分销逻辑.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 平台定价、用户付款与分销逻辑说明
|
||||
|
||||
> **说明**:文件名保留 `MBTI…` 仅为历史习惯;本文描述的是 **全平台同一套规则**,适用于 **MBTI、人脸(face)、PDP、DISC、SBTI、简历(resume)、高考(gaokao)** 等在 `Test::saveResult`、`Payment`、`TestProductPricing`、`Distribution`、`EnterpriseBillingService` 中有分支的测评/产品。差异主要在于:`pricing_config` / 管理后台里的 **JSON 键名**(如 `mbti`、`face`、`disc`…)、`test_results.testType`、以及个别白名单(例如企业余额入账 `creditEnterpriseBalanceForOrder` 不含 `gaokao`,见 §5)。
|
||||
>
|
||||
> 下文在举例、防混淆时仍常以 **MBTI** 写具体字段名,换其他测评时把 **`mbti` 键 / `mbti` 产品类型** 换成对应键即可。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目相关位置(速查)
|
||||
|
||||
| 层级 | 路径 / 说明 |
|
||||
|------|----------------|
|
||||
| 定价表 | `pricing_config`(模型 `app\model\PricingConfig`),`type` + `enterpriseId` 唯一 |
|
||||
| 超管定价 API | `api/app/controller/superadmin/Pricing.php` |
|
||||
| 企业管理定价 API | `api/app/controller/admin/Pricing.php` |
|
||||
| 超管前端 | `admin/src/views/superadmin/Pricing.vue` |
|
||||
| 企业管理员前端 | `admin/src/views/admin/Pricing.vue` |
|
||||
| 提交测评 / 写 `test_results` | `api/app/controller/api/Test.php`(`saveResult` 等) |
|
||||
| 统一下单与回调 | `api/app/controller/api/Payment.php` |
|
||||
| 订单金额计算 | `Payment::calculateAmount` → `app\common\service\TestProductPricing::amountFenForTestProduct` |
|
||||
| 分销 | `api/app/controller/api/Distribution.php`(`settleCommission`、`settleTestCommission`) |
|
||||
| 企业平台费(与用户付款分列) | `api/app/common/service/EnterpriseBillingService.php` |
|
||||
|
||||
---
|
||||
|
||||
## 2. `pricing_config` 中的几类「价格」
|
||||
|
||||
配置存在 JSON 字段 `config` 里,单项测评单价单位为 **元**(代码里会 `×100` 转为 **分** 落库/支付)。常见键名与 `testType` / 支付 `productType` 对齐,例如:**`mbti`、`face`、`disc`、`pdp`、`sbti`**;企业版里还可能有 **`report`、`teamAnalysis`**(团队分析)等扩展字段,以管理端表单与 `Test.php` 解析为准。
|
||||
|
||||
### 2.1 超管后台(全局默认)
|
||||
|
||||
| `type` | `enterpriseId` | 含义 |
|
||||
|--------|------------------|------|
|
||||
| `personal` | `NULL` | **全局个人版**默认价:无企业管理端覆盖、且用户走个人链路时使用 |
|
||||
| `enterprise` | `NULL` | **全局企业版**兜底价(用户走「企业档」计价、且该企业未配 `admin_enterprise` 时用);**同一行 JSON 里各测评键(`mbti`、`face`、`disc`…)也会被 `EnterpriseBillingService` 当作对应类型的「企业平台费」单价**(测评提交成功且 `test_results` 带企业归属时从企业余额扣,与用户实付分列) |
|
||||
| `enterprise` | 某企业 ID | (可选)超管可为单个企业单独配一条企业版价(仍走 `getByTypeAndEnterprise('enterprise', eid)`,**平台费仍读全局 enterprise 行**,见 §7) |
|
||||
|
||||
接口:`GET/PUT /superadmin/pricing`(见 `superadmin/Pricing.php`)。
|
||||
|
||||
### 2.2 企业管理后台(企业专属)
|
||||
|
||||
仅 **`enterprise_admin`** 会解析出 `enterpriseId`;角色为普通 `admin` 且无企业绑定时,`enterpriseId` 为 `null`,此时个人版配置落在 **`admin_personal` + `enterpriseId = NULL`** 行(与「平台运营」共用一套逻辑,见 `admin/Pricing::resolveEnterpriseId`)。
|
||||
|
||||
| `type` | `enterpriseId` | 含义 |
|
||||
|--------|------------------|------|
|
||||
| `admin_personal` | 企业 ID 或 NULL | **企业侧「个人版」价**:用户绑定该企业、且走 **个人档** 定价时优先读取(见 `PricingConfig::getByTypeAndEnterprise('personal', eid)`) |
|
||||
| `admin_enterprise` | 企业 ID | **企业侧「企业版」价**:订单/测评上下文带 **企业测试** 时用(见下文章节 4、5) |
|
||||
|
||||
接口:`GET/PUT /api/v1/admin/pricing`(见 `admin/Pricing.php`)。
|
||||
|
||||
### 2.3 读取优先级(与模型注释一致)
|
||||
|
||||
- **个人档 `personal`**:`admin_personal`(有 eid 则带 eid)→ 否则 `personal` 全局。实现见 `PricingConfig::getByTypeAndEnterprise`。
|
||||
- **企业档 `enterprise`**:`admin_enterprise` + eid → 否则 `enterprise` 全局。
|
||||
|
||||
---
|
||||
|
||||
## 3. 测评提交时:是否需要付费、应付多少分
|
||||
|
||||
在 **`Test::saveResult`**(提交问卷 / 人脸等结果)中,对允许的 **`testType`**(如 `mbti`、`face`、`disc`、`pdp`;`ai` 入口通常按人脸处理等,以接口校验为准):
|
||||
|
||||
1. 根据请求是否带 **`enterpriseId`**、以及用户 **`wechat_users.enterpriseId`**,解析出 `pricingEnterpriseId` / `writeEnterpriseId`(与 `Payment::calculateAmount` 注释一致:企业分享带 eid、个人测可仍有绑定企业用于定价)。
|
||||
2. **`getRequiresPaymentByTestType($testType, …)`**:若解析到的配置里 **该类型对应键**(如 `mbti`、`face`…)单价 **> 0**,则 `requiresPayment = 1`。
|
||||
3. **`getStandardAmountFenByTestType`**:把配置里的 **元** 转为 **分**,写入 `test_results.paidAmount`(表示「应付标准价」)。
|
||||
|
||||
定价配置来源 **`Test::resolvePricingConfig`**(与 `PricingConfig` 一致):
|
||||
|
||||
- 若本次是 **企业测试**(`enterpriseId` 非空)→ 用 **`enterprise`** 档 → `admin_enterprise` / 全局 `enterprise`。
|
||||
- 否则若用户 **绑定企业** → 用 **`personal`** 档 + 该企业 → `admin_personal` / 全局 `personal`。
|
||||
- 否则 → 全局 **`personal`**。
|
||||
|
||||
代码:`api/app/controller/api/Test.php` 中 `getRequiresPaymentByTestType`、`getStandardAmountFenByTestType`、`resolvePricingConfig` 及 `saveResult` 内 `insertGetId`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户发起微信支付:`Payment::create`
|
||||
|
||||
### 4.1 金额从哪里来
|
||||
|
||||
1. **优先固定价**:若传入 **`testResultId`**(或未传但自动绑定到最近一条同 `productType` 的 `test_results`),且该记录 **`paidAmount > 0`**,则订单金额 **`fixedAmountFen = paidAmount`**,不再重新算价(与提交测评时写入的标准价一致)。
|
||||
2. **否则**:调用 **`calculateAmount`**,对测试类产品走 **`TestProductPricing::amountFenForTestProduct`**。
|
||||
|
||||
### 4.2 `calculateAmount` 与「个人 / 企业」两档
|
||||
|
||||
在 `Payment::calculateAmount` 中:
|
||||
|
||||
- 若推断出的 **`$enterpriseId` 非空**(来自 `test_results.enterpriseId` 或请求参数 `enterpriseId`)→ **`pricingType = 'enterprise'`** → 读 **企业版** 配置。
|
||||
- 否则 → **`pricingType = 'personal'`**;若用户绑定了企业,会把 **`pricingEnterpriseId`** 设为该用户 `wechat_users.enterpriseId`,用于读 **`admin_personal`**。
|
||||
|
||||
第五参数传入 `TestProductPricing::amountFenForTestProduct(..., $pricingType)`,避免「只因为有企业 ID 就误用企业版价」的问题(高考 `gaokao` 等场景曾修复过同类逻辑;各 `productType` 与此共用 `TestProductPricing`)。
|
||||
|
||||
代码:`api/app/controller/api/Payment.php` 的 `create`、`calculateAmount`;`api/app/common/service/TestProductPricing.php`。
|
||||
|
||||
### 4.3 订单与测评绑定
|
||||
|
||||
创建 **`orders`** 后,若存在 `test_results`,会把 **`test_results.orderId`** 更新为当前订单 id,便于支付回调按订单反查测评类型、做分销。
|
||||
|
||||
---
|
||||
|
||||
## 5. 支付成功之后
|
||||
|
||||
在 **`Payment::notify`** / **`Payment::query`** 确认支付成功后(非 `recharge`):
|
||||
|
||||
1. **`test_results`**:`isPaid = 1`,写入 `paidAmount`、`paidAt`(与微信 `total_fee` 或本地订单金额一致)。
|
||||
2. **`creditEnterpriseBalanceForOrder`**:若订单带 **`enterpriseId`** 且 `productType` 属于 `face|mbti|sbti|disc|pdp|resume|recharge`,把 **用户实付金额** 记一条 **`finance_records`(type=`recharge` 命名历史原因)**,**增加** 该企业 `enterprises.balance`(注释:企业测试收入)。
|
||||
3. **`Distribution::settleCommission($orderId)`**:按分销规则从 **企业余额**(有企业订单时)或平台侧给推荐人结算佣金(见下一节)。
|
||||
|
||||
注意:**`gaokao`** 不在 `creditEnterpriseBalanceForOrder` 的白名单内;高考另有业务处理。
|
||||
|
||||
---
|
||||
|
||||
## 6. 分销(与各测评订单 / 测完事件相关)
|
||||
|
||||
### 6.1 订单支付佣金:`settleCommission`
|
||||
|
||||
- **触发**:测试类订单(`productType` 与 `testType` 映射一致,如 **`mbti`、`face`、`disc`、`pdp`、`sbti`、`resume`、`gaokao`** 等)支付成功,`commission_records` 按 **`orderId` 防重**。
|
||||
- **绑定查找**:根据订单的 `enterpriseId` 决定 `scope`(`enterprise` / `personal`),在 **`distribution_bindings`** 中匹配邀请关系;企业单未命中时可 **回退** 到 personal 绑定(代码注释:扣款企业仍以订单为准)。
|
||||
- **佣金数值**:从 **`getTestCommissionConfig($testType, $scope, $configEnterpriseId)`** 读取:
|
||||
- 优先 **`distribution` 配置里的 `testSettings[$testType]`**(如 `testSettings.mbti`、`testSettings.face`…里的 `enabled`、比例或固定分);
|
||||
- 否则回退全局 **`commissionRate` / `commissionAmountFen`**。
|
||||
- **企业订单**:优先从 **`billingEnterpriseId = 订单 enterpriseId`** 的余额扣佣金;不足则记入推荐人 **`walletPending`(冻结)**;无企业则平台直接给推荐人钱包入账。
|
||||
|
||||
代码:`Distribution::settleCommission` 及 `getTestCommissionConfig` / `resolveTestSetting`。
|
||||
|
||||
### 6.2 测评完成佣金(免单 / 未付款场景):`settleTestCommission`
|
||||
|
||||
- **触发**:`Test::saveResult` 在写入 `test_results` 成功后调用(与各问卷 / 人脸提交同源)。
|
||||
- **适用类型**:代码里 **`$allowedTypes = ['face','mbti','sbti','disc','pdp']`**(`ai` 会归一成 `face`),需 **`testSettings` 里开启 `noPayment`** 等条件;金额来自分销配置。
|
||||
- **与订单佣金区别**:无 `orderId`,防重维度为 **`testResultId` + `commissionSource = test_completion`**;仍可能从企业余额扣款给推荐人。
|
||||
|
||||
代码:`Distribution::settleTestCommission`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 企业平台费(与用户付款、分销并列的另一条线)
|
||||
|
||||
**`EnterpriseBillingService::chargePlatformFeeForTestResult`**
|
||||
|
||||
- **时机**:`Test::saveResult` 成功插入 `test_results` 后(各 `testType`),与是否已微信支付 **无关**。
|
||||
- **金额**:读 **超管全局** `PricingConfig` 中 **`type = enterprise` 且 `enterpriseId = NULL`** 的 JSON 里 **各测评键** 的 **平台单价(元)→ 分**;描述里带 `testResultId` **幂等**。
|
||||
- **作用**:从 **`enterprises.balance` 扣减**(`finance_records` type=`consume`),表示 **平台向企业收的单次测评费**;与用户微信实付、分销佣金是 **不同科目**。
|
||||
|
||||
代码:`api/app/common/service/EnterpriseBillingService.php`,调用处 `api/app/controller/api/Test.php`(`saveResult` 内)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 心智模型小结(避免混淆)
|
||||
|
||||
| 概念 | 谁配置 | 影响什么 |
|
||||
|------|--------|----------|
|
||||
| 个人版价 / 企业版价(用户侧售价) | 超管 `personal`/`enterprise` + 企业 `admin_personal`/`admin_enterprise` | `requiresPayment`、`paidAmount`、微信支付金额 |
|
||||
| 分销佣金比例或固定分 | 分销配置 **`testSettings` 下按类型分块**(如 `mbti`、`face`、`disc`…) | 支付成功后 `settleCommission`;免单完成时 `settleTestCommission` |
|
||||
| 企业平台费单价 | 超管 **全局 enterprise**(`enterpriseId = NULL`)配置里 **与各测评键同名** 的单价 | 测评提交成功即从 **企业余额** 扣平台费(`test_results` 写入企业归属时);**与上表「企业版用户价」共用同一配置源,账目语义不同** |
|
||||
| 企业余额「加一笔」 | 用户支付成功且订单有 `enterpriseId` | `creditEnterpriseBalanceForOrder`:用户实付进企业余额(测试类产品白名单) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 业务场景示例(与你描述的逻辑对齐)
|
||||
|
||||
以下以 **MBTI**(配置键 **`mbti`**)举例;**人脸 / PDP / DISC / SBTI** 等仅把键名与 `testType` 替换即可,**规则相同**。数值均为说明用。约定:
|
||||
|
||||
- **超管**:`personal` 全局 `mbti = 1` 元;`enterprise` 全局 `mbti = 2` 元(该 2 元在代码里同时作为 **企业版用户价兜底** 与 **MBTI 平台费单价** 的数据来源,见 §2.1、§7)。
|
||||
- **A 企业管理员**:`admin_personal` 里 `mbti = 0.5` 元;`admin_enterprise` 里 `mbti = 0` 元。
|
||||
|
||||
### 9.1 小王(已绑定 A 企业)— 做「个人版」链路测评
|
||||
|
||||
- **用户侧应付**:走 **`personal` 档** → 优先 A 的 **`admin_personal`** → **0.5 元**(写入 `test_results.paidAmount` / 微信支付按此,除非另有逻辑)。
|
||||
- **企业被平台扣费(平台费)**:测评提交写库成功后,`EnterpriseBillingService` 按超管全局 **`enterprise.mbti = 2` 元** 从 **A 企业余额** 扣 **2 元**(`finance_records` consume,幂等按 `testResultId`)。
|
||||
- **企业「测试收入」**:用户付完款后,`creditEnterpriseBalanceForOrder` 把 **用户实付 0.5 元** 记入 A 企业余额(与平台费不同科目)。你描述的 **「企业收入 0.5」** 指这笔 **用户实付入账**;**「企业需要扣费 2 元」** 指 **平台费** 另扣。
|
||||
|
||||
### 9.2 小张(已绑定 A 企业)— 做「企业版」链路测评
|
||||
|
||||
- **用户侧应付**:走 **`enterprise` 档** → 优先 A 的 **`admin_enterprise`** → **0 元**(可能 `requiresPayment = 0`,无需微信付)。
|
||||
- **企业平台费**:同上,提交测评成功后仍按全局 **`enterprise.mbti = 2` 元** 从 A 余额扣 **2 元**(只要写入了企业归属的 `test_results` 且平台费单价大于 0)。
|
||||
- **企业测试收入**:用户实付 **0** → **`creditEnterpriseBalanceForOrder` 入账 0**。你描述的 **「企业收入 0」** 即无用户实付进账。
|
||||
|
||||
### 9.3 小李(未绑定任何企业)— 仅个人版
|
||||
|
||||
- **用户侧应付**:无企业 → **`personal` 全局** → **1 元**(超管个人版默认)。
|
||||
- **企业平台费 / 企业测试收入**:无 `test_results.enterpriseId`(或企业 ID 为 0)时,**不按企业扣平台费**(`EnterpriseBillingService` 直接 return);也无企业余额入账。
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端管理端对应关系
|
||||
|
||||
| 后台 | 页面 | 保存到后端的含义(简写) |
|
||||
|------|------|---------------------------|
|
||||
| 超级管理 | `admin/src/views/superadmin/Pricing.vue` | 全局 `personal` / `enterprise`(及 deep 等) |
|
||||
| 企业管理 | `admin/src/views/admin/Pricing.vue` | `admin_personal`、(企业管理员)`admin_enterprise` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 分销两种结佣(对话整理)
|
||||
|
||||
管理后台里 **各测评类型**(MBTI、人脸、PDP、DISC、SBTI…)在分销配置中均可单独一块 **`testSettings[testType]`**:可配 **佣金类型(比例 / 固定金额)**、**固定金额(元)**,以及 **「无需付款触发」**(用户完成测试即发放佣金,无需付款)。对应代码里两条独立链路如下(与具体是哪一种测评无关,仅 `testType` 不同)。
|
||||
|
||||
### 11.1 两种情况对照
|
||||
|
||||
| 情况 | 名称(口语) | 什么时候结佣 | 主要代码入口 |
|
||||
|------|----------------|-------------|----------------|
|
||||
| **①** | **跟单结佣**(有微信支付) | 用户 **订单已支付**(微信回调 / 查询确认) | `Distribution::settleCommission(orderId)`,由 `Payment` 支付成功后调用 |
|
||||
| **②** | **测完结佣**(可不依赖付款) | 用户 **测评结果已提交落库**(`Test::saveResult` 成功),且 `testSettings` 中 **`enabled` + `noPayment`** 等条件满足 | `Distribution::settleTestCommission(testResultId, …)`,由 `Test::saveResult` 内调用 |
|
||||
|
||||
**「无需付款触发」** 打开时,对应 **②**:不要求用户先微信付款,只要 **测完** 且邀请绑定、佣金规则满足,即可尝试结佣。
|
||||
|
||||
### 11.2 对话摘要
|
||||
|
||||
**运营**:两种结佣怎么区分?
|
||||
|
||||
**产品**:**①** 永远跟 **微信已付订单** 走;**②** 跟 **测评提交成功** 走,和是否付款脱钩(故叫「无需付款触发」)。
|
||||
|
||||
**运营**:小王付 0.5 元、小张 0 元,和 ①② 怎么叠?
|
||||
|
||||
**产品**(与 §9 场景对齐,实际仍受绑定、`enabled`、防重等约束):
|
||||
|
||||
| 用户 | 用户侧 | ① 跟单结佣 | ② 测完结佣 |
|
||||
|------|--------|------------|------------|
|
||||
| 小王 | 个人链路付 0.5 元 | 付完款可触发:订单金额参与 **比例**;或按配置 **固定分** | 若打开「无需付款触发」:**测完** 也可能触发 **②**(防重用 `testResultId`,与订单防重不同) |
|
||||
| 小张 | 企业链路 0 元 | 订单金额为 0:**比例佣金为 0**;若 ① 为 **固定金额且大于 0** 仍可能有一笔(看配置) | **测完** 若满足 `noPayment`:**② 仍可发**(适合「用户没付钱也要给邀请人记一笔」) |
|
||||
| 小李 | 未绑企业、个人 1 元 | 通常以 **①** 为主(有付款才有订单) | **②** 若绑定/企业上下文不满足则可能不触发 |
|
||||
|
||||
**运营**:两种会重复拿两次吗?
|
||||
|
||||
**产品**:**不是同一笔账**:① 按 **`orderId`** 防重;② 按 **`testResultId` + `commissionSource = test_completion`** 防重。若业务上 **同时** 依赖跟单与测完,可能出现 **两笔不同记录**,是否都要由运营在后台把开关与金额想清楚。
|
||||
|
||||
---
|
||||
|
||||
*文档根据当前仓库代码整理;若路由前缀以实际 `api/route` 部署为准。*
|
||||
577
高考志愿MBTI开发文档.md
Normal file
577
高考志愿MBTI开发文档.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# 高考志愿 MBTI 功能开发文档(V1)
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
基于 `测高考.txt` 的会议纪要,整理一版可直接执行的开发文档,覆盖:
|
||||
|
||||
- 业务目标与范围
|
||||
- 前后端功能拆解
|
||||
- AI 分析输入输出规范
|
||||
- 提示词与规则(AI 可直接理解)
|
||||
- 里程碑与验收标准
|
||||
|
||||
---
|
||||
|
||||
## 2. 背景与业务目标
|
||||
|
||||
高考季上线「高考志愿分析」专栏,结合考生的性格测评与分数信息,输出可读、可执行、可追溯的志愿建议,支持后续与教育机构合作场景(客资沉淀、加微信、入群转化)。
|
||||
|
||||
核心目标:
|
||||
|
||||
1. 给考生与家长一个“先有方向”的初版建议(不是最终填报结论)。
|
||||
2. 让机构老师可基于报告继续人工精细化服务。
|
||||
3. 保持与现有团队版产品架构一致,支持按公司配置切换首页主入口。
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
## 3.1 本期(V1)要做
|
||||
|
||||
- 新增**独立高考志愿入口**(不是复用原团队入口),首页可配置为高考版优先。
|
||||
- 点击该入口后进入「高考任务中心页」,集中展示:
|
||||
- MBTI 测试入口
|
||||
- PDP 测试入口
|
||||
- DISC 测试入口
|
||||
- 高考信息表单入口
|
||||
- 综合分析按钮
|
||||
- 收集基础信息:姓名、地区、科类/选科、估分/模拟分(可选)、志愿草表(可选文本或图片 OCR)。
|
||||
- 复用现有性格测评体系,取结果做综合分析:
|
||||
- MBTI(必用)
|
||||
- PDP(用)
|
||||
- DISC(用)
|
||||
- 面相分析(如已有能力可融合)
|
||||
- 生成综合报告:学校梯度(冲/稳/保)+ 专业匹配 + 风险提示 + 下一步动作。
|
||||
- 支持机构合作链路:报告后提示加微信/进群(已有能力对接)。
|
||||
|
||||
## 3.2 本期不做
|
||||
|
||||
- 直接替代人工老师完成最终志愿填报。
|
||||
- 复杂到省级全量规则引擎(如超细颗粒组合政策全自动推演)。
|
||||
- 多轮深度问答式咨询(先提供单次报告)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户流程(V1)
|
||||
|
||||
1. 用户点击首页「高考志愿」新入口,进入任务中心页。
|
||||
2. 任务中心展示 4 个任务入口(MBTI、PDP、DISC、高考信息表单)+ 1 个综合分析按钮。
|
||||
3. 用户按任意顺序完成测试与表单。
|
||||
4. 任务中心实时显示每个任务状态:
|
||||
- 未完成:显示“未完成/去测试”提示;
|
||||
- 已完成:显示“已完成/查看结果”并可点击查看已有结果。
|
||||
5. 仅当 MBTI、PDP、DISC 和表单全部完成时,才可点击「AI 综合分析」。
|
||||
6. 未完成前,综合分析按钮保持灰色禁用状态,并提示缺失项(如“请先完成 DISC 与高考信息表单”)。
|
||||
7. 全部完成后按钮点亮,点击后进入 AI 分析流程。
|
||||
8. AI 先联网检索当年权威数据,再生成 JSON 报告并渲染结果页。
|
||||
9. 报告末尾引导加微信/进群(机构合作链路)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 产品与交互要求
|
||||
|
||||
## 5.1 首页与入口
|
||||
|
||||
- 支持“固定首页”能力:按租户/公司配置,首页主入口可切为高考版或团队版。
|
||||
- 支持可见性配置:个人版入口可显示或隐藏。
|
||||
- 高考志愿必须为单独入口,命名建议:`高考志愿分析`(可配置文案)。
|
||||
|
||||
## 5.2 表单字段(建议)
|
||||
|
||||
必填:
|
||||
|
||||
- 姓名
|
||||
- 省份
|
||||
- 科类(新高考省份可改为选科组合)
|
||||
- 至少一个有效测评结果(MBTI 必须有)
|
||||
|
||||
选填(V1 建议都可选,避免流失):
|
||||
|
||||
- 估分/模拟成绩
|
||||
- 语数英及综合分项
|
||||
- 志愿草表文本
|
||||
- 志愿草表图片 OCR
|
||||
- 意向地区
|
||||
- 意向专业大类
|
||||
|
||||
## 5.3 任务中心状态与按钮规则(新增)
|
||||
|
||||
任务中心卡片状态定义:
|
||||
|
||||
- `todo`(未完成):文案“去完成”,卡片右侧显示提示点。
|
||||
- `done`(已完成):文案“查看结果”,卡片右侧显示完成标识。
|
||||
|
||||
综合分析按钮状态:
|
||||
|
||||
- 默认灰色禁用:存在任一 `todo` 时保持禁用;
|
||||
- 激活可点击:4 个任务全部 `done` 后点亮;
|
||||
- 点击禁用按钮时需给出明确提示:列出未完成项名称,不可只提示“请先完成”。
|
||||
|
||||
任务入口点击行为:
|
||||
|
||||
- 未完成任务:进入对应测试/表单页面;
|
||||
- 已完成任务:进入对应结果页或回填编辑页(表单可编辑再保存);
|
||||
- 测试结果以“最近一次有效提交”为准。
|
||||
|
||||
## 5.4 分析结果页
|
||||
|
||||
必须包含:
|
||||
|
||||
- 总评(120~200 字)
|
||||
- 性格画像摘要
|
||||
- 分数定位(区间表达)
|
||||
- 学校推荐:冲/稳/保(每档建议 3 所)
|
||||
- 专业推荐(建议 5 个)
|
||||
- 志愿草表点评(若用户提供)
|
||||
- 风险与免责声明
|
||||
- 后续行动建议(学生 3 条 + 家长 3 条)
|
||||
|
||||
## 5.5 全局定价(新增)
|
||||
|
||||
高考志愿功能需纳入全局定价体系,支持按租户统一配置,不在页面写死价格。
|
||||
|
||||
- 定价对象:
|
||||
- `gaokao_single_report`(单次综合分析)
|
||||
- `gaokao_package_basic`(基础包,可含测评 + 1 次综合分析)
|
||||
- `gaokao_package_pro`(进阶包,可含报告解读服务)
|
||||
- 定价维度:
|
||||
- 原价、活动价、渠道价(可选)
|
||||
- 生效时间、失效时间
|
||||
- 适用租户(平台默认/企业覆盖)
|
||||
- 价格展示规则:
|
||||
- 任务中心和支付确认页展示同一价格源;
|
||||
- 若有活动价,显示“划线原价 + 活动价”;
|
||||
- 若未配置价格,不允许下单并给出配置缺失提示。
|
||||
|
||||
## 5.6 分销能力(新增)
|
||||
|
||||
高考志愿功能接入分销体系,支持渠道推广和佣金结算。
|
||||
|
||||
- 分销基础:
|
||||
- 支持分享入口(海报/链接)携带 `referrerId`;
|
||||
- 用户首次进入高考入口时绑定分销关系(按现有平台规则)。
|
||||
- 佣金规则:
|
||||
- 支持按商品维度配置佣金比例或固定金额;
|
||||
- 支持一级分销(V1 必做),多级分销后续迭代;
|
||||
- 退款后佣金自动冲正。
|
||||
- 订单归因:
|
||||
- 高考相关订单需记录来源渠道、分销员、归因时间;
|
||||
- 用户管理后台可按分销员查看转化人数、支付金额、佣金金额。
|
||||
|
||||
## 5.7 用户管理展示(新增)
|
||||
|
||||
用户管理后台需新增“高考志愿”视图与字段,便于机构跟进。
|
||||
|
||||
- 列表新增字段:
|
||||
- 高考入口状态(未进入/进行中/已完成)
|
||||
- 测试完成状态(MBTI、PDP、DISC)
|
||||
- 表单完成状态
|
||||
- 综合分析状态(未生成/已生成/生成失败)
|
||||
- 最近分析时间
|
||||
- 报告摘要标签(如“工科倾向”“省内优先”)
|
||||
- 详情页新增模块:
|
||||
- 最近一次综合分析结果(可查看摘要和完整 JSON)
|
||||
- 推荐学校/专业快照
|
||||
- 风险提示与免责声明
|
||||
- 分销归因与订单记录(来源、成交、佣金)
|
||||
- 筛选与导出:
|
||||
- 支持按“是否完成综合分析”“是否付费”“分销员”筛选;
|
||||
- 支持导出高考用户跟进清单(CSV/Excel)。
|
||||
|
||||
---
|
||||
|
||||
## 6. AI 生成规则(必须执行)
|
||||
|
||||
本节是给模型与后端共同遵守的“硬规则”。
|
||||
|
||||
1. 命中率只能给区间(如 `10-25%`),不能给点估。
|
||||
2. 严禁“保录”“稳上”“一定能进”。
|
||||
3. 所有学校/专业推荐都要有理由,至少包含:分数梯度、性格匹配、地域或就业其一。
|
||||
4. MBTI 解释至少覆盖四维中的 3 维:I/E、N/S、T/F、J/P。
|
||||
5. 若有志愿草表,点评需引用用户原文不少于 3 条。
|
||||
6. 输出必须是严格 JSON(不能返回 Markdown 包裹)。
|
||||
7. 缺失信息必须留空(空字符串或空数组),不能编造。
|
||||
8. 先检索再结论:每次调用至少执行 5 组当年检索。
|
||||
9. 每个关键结论都要附 `sources`(标题、URL、时间、摘要)。
|
||||
10. 检索失败时必须显式降级,降低置信区间并写明“仅供方向参考”。
|
||||
|
||||
---
|
||||
|
||||
## 7. AI 可直接使用的提示词模板
|
||||
|
||||
以下模板可直接放入后端 `system` / `user` 消息中,已做工程化约束。
|
||||
|
||||
## 7.1 System Prompt(精简可执行版)
|
||||
|
||||
```text
|
||||
你是高考志愿AI分析师。你的任务是结合考生测评结果、分数信息和志愿草表,输出一份严格JSON格式的志愿建议。
|
||||
|
||||
硬性规则:
|
||||
1) 任何录取概率都使用区间,如10-25%,禁止点估;
|
||||
2) 禁止“保录取/一定录取/稳上”等承诺语;
|
||||
3) 学校与专业推荐必须说明理由(分数梯度+性格匹配+就业/地域);
|
||||
4) MBTI解释至少覆盖I/E、N/S、T/F、J/P中的3个维度;
|
||||
5) 若用户提供志愿草表,wishReview.evidence至少3条用户原文;
|
||||
6) 输出必须是严格JSON,不要Markdown、不要注释;
|
||||
7) 缺失数据留空,禁止编造;
|
||||
8) 生成结论前必须联网检索当年高考/招生/专业/就业数据,至少5组query;
|
||||
9) 关键结论必须带sources(title,url,publishedAt,snippet);
|
||||
10) 检索失败时,必须降级:schoolRecommend置空或降档,并在sources中写note说明。
|
||||
|
||||
语气要求:理性、克制、有温度;结论是“建议”而非“承诺”。
|
||||
```
|
||||
|
||||
## 7.2 User Prompt(模板)
|
||||
|
||||
```text
|
||||
请基于以下考生数据生成高考志愿分析:
|
||||
|
||||
【性格测评】
|
||||
MBTI: {{mbti}}
|
||||
PDP: {{pdp}}
|
||||
DISC: {{disc}}
|
||||
面相分析: {{faceText}}
|
||||
|
||||
【基础信息】
|
||||
姓名: {{name}}
|
||||
省份: {{province}}
|
||||
科类/选科: {{streamOrSubjects}}
|
||||
目标层次: {{targetTier}}
|
||||
意向地区: {{preferredRegions}}
|
||||
意向专业: {{preferredFields}}
|
||||
|
||||
【成绩信息】
|
||||
估分: {{estimatedScore}}
|
||||
分数文本: {{scoreText}}
|
||||
分数OCR: {{scoreImagesOcr}}
|
||||
|
||||
【志愿草表】
|
||||
文本: {{wishListText}}
|
||||
OCR: {{wishListOcr}}
|
||||
|
||||
请严格输出JSON,结构必须完整,缺失字段用空值,不要编造数据。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 输出 JSON 结构(建议标准)
|
||||
|
||||
```json
|
||||
{
|
||||
"overview": "",
|
||||
"personalityProfile": {
|
||||
"mbti": "",
|
||||
"pdp": "",
|
||||
"disc": "",
|
||||
"face": ""
|
||||
},
|
||||
"scoreProfile": {
|
||||
"estimated": null,
|
||||
"tierFit": "",
|
||||
"percentileGuess": "",
|
||||
"sources": []
|
||||
},
|
||||
"schoolRecommend": {
|
||||
"chong": [],
|
||||
"wen": [],
|
||||
"bao": []
|
||||
},
|
||||
"majorRecommend": [],
|
||||
"wishReview": {
|
||||
"strengths": [],
|
||||
"risks": [],
|
||||
"rebalance": [],
|
||||
"evidence": []
|
||||
},
|
||||
"personalityReason": "",
|
||||
"nextSteps": [],
|
||||
"disclaimers": "",
|
||||
"searchMeta": {
|
||||
"queryCount": 0,
|
||||
"queries": [],
|
||||
"fetchedAt": "",
|
||||
"coverage": "full"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 检索策略(后端/Agent)
|
||||
|
||||
每次分析必须新检索,不复用历史。
|
||||
|
||||
最低 5 组 query(示例):
|
||||
|
||||
1. `{{year}} {{province}} 高考 一分一段 投档线`
|
||||
2. `{{schoolName}} {{province}} 近三年 投档线 位次`
|
||||
3. `{{majorName}} 就业率 学科评估 {{year}}`
|
||||
4. `{{subjects}} 选科要求 专业目录 {{year}}`
|
||||
5. `{{targetTier}} {{preferredRegions}} 大学名单 {{year}}`
|
||||
|
||||
信源优先级:
|
||||
|
||||
- 高:`gov.cn`、`edu.cn`、阳光高考、学校招生网
|
||||
- 中:主流媒体教育频道、官方报告
|
||||
- 低:聚合站/转载站(仅兜底,不作为主依据)
|
||||
|
||||
---
|
||||
|
||||
## 10. 接口与工程建议
|
||||
|
||||
## 10.1 后端接口(建议)
|
||||
|
||||
- `POST /api/gaokao/analyze`
|
||||
- 入参:表单 + 测评结果 + 可选 OCR 文本
|
||||
- 出参:标准 JSON 报告
|
||||
- `GET /api/gaokao/pricing`
|
||||
- 入参:租户、渠道、用户身份
|
||||
- 出参:高考功能可售商品与当前生效价格
|
||||
- `POST /api/gaokao/order/create`
|
||||
- 入参:商品、支付方式、分销归因参数
|
||||
- 出参:订单信息与支付参数
|
||||
- `GET /api/admin/gaokao/users`
|
||||
- 入参:状态筛选(分析状态/付费状态/分销员)
|
||||
- 出参:高考用户列表及任务完成状态
|
||||
- `GET /api/admin/gaokao/users/{id}`
|
||||
- 出参:用户高考档案、报告摘要、分销与订单信息
|
||||
|
||||
## 10.2 关键工程约束
|
||||
|
||||
- 开启模型工具调用(`web_search` 或自建搜索代理)。
|
||||
- 强制 `response_format = json_object`(按服务商能力适配)。
|
||||
- 返回前执行结构校验与规则校验,不通过则重试或降级。
|
||||
- 定价与分销统一走平台全局配置中心,禁止各端写死。
|
||||
- 价格读取、下单、支付回调、佣金结算使用同一商品编码,避免账务不一致。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准(UAT)
|
||||
|
||||
功能验收:
|
||||
|
||||
- 能完整跑通“新入口 -> 任务中心 -> 测试/表单 -> 分析 -> 报告 -> 加微信引导”链路。
|
||||
- 在信息不全场景下仍能返回可读报告,不报错。
|
||||
- 任务中心状态正确:未完成任务有提示,已完成任务可查看结果。
|
||||
- 综合分析按钮规则正确:未完成时灰色禁用,全部完成后可点击。
|
||||
- 全局定价生效正确:不同租户/渠道命中对应价格,前后端展示一致。
|
||||
- 分销归因正确:分享进入、下单、退款、佣金冲正链路可核对。
|
||||
- 用户管理可见高考结果:列表与详情可查看任务状态和分析结果摘要。
|
||||
|
||||
质量验收:
|
||||
|
||||
- JSON 100% 可解析。
|
||||
- 检索覆盖:`searchMeta.queryCount >= 5`(或 `coverage=none` 且走降级)。
|
||||
- 概率全部为区间,且无承诺词。
|
||||
- 学校与专业条目均可追溯到来源。
|
||||
|
||||
业务验收:
|
||||
|
||||
- 报告可供机构老师二次解读,不与人工流程冲突。
|
||||
- 能体现“初版可用,后续可迭代”。
|
||||
|
||||
---
|
||||
|
||||
## 12. 版本与迭代建议
|
||||
|
||||
- V1:先上线可用版(本文件范围)。
|
||||
- V1.1:补省份规则细化(3+1+2 / 3+3 全量映射)。
|
||||
- V1.2:加入“按机构策略模板”输出(不同机构不同话术与重点)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 数据库表结构草案(可直接给研发)
|
||||
|
||||
以下为 V1 建议的最小可用表结构,命名可按现有项目规范调整。
|
||||
|
||||
## 13.1 高考商品定价表 `gaokao_pricing`
|
||||
|
||||
用途:高考相关商品的全局定价配置(支持租户覆盖、时间生效)。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `tenant_id` bigint,租户 ID(`0` 表示平台默认)
|
||||
- `product_code` varchar(64),如 `gaokao_single_report`
|
||||
- `product_name` varchar(100)
|
||||
- `price_original` decimal(10,2)
|
||||
- `price_sale` decimal(10,2)
|
||||
- `price_channel` decimal(10,2) NULL
|
||||
- `currency` varchar(16) DEFAULT `CNY`
|
||||
- `status` tinyint(0=停用,1=启用)
|
||||
- `effective_at` datetime
|
||||
- `expired_at` datetime NULL
|
||||
- `extra_json` json NULL(活动标签、展示文案等)
|
||||
- `created_at` datetime
|
||||
- `updated_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_tenant_product_status` (`tenant_id`, `product_code`, `status`)
|
||||
- `idx_effective_time` (`effective_at`, `expired_at`)
|
||||
- 唯一约束(可选):同租户同商品同时间段不允许重叠生效
|
||||
|
||||
## 13.2 高考用户档案表 `gaokao_user_profile`
|
||||
|
||||
用途:存储用户在高考功能内的进度状态、表单信息和最新报告指针。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `user_id` bigint UNIQUE
|
||||
- `tenant_id` bigint
|
||||
- `entry_status` tinyint(0=未进入,1=进行中,2=已完成)
|
||||
- `mbti_status` tinyint(0=未测,1=已测)
|
||||
- `pdp_status` tinyint(0=未测,1=已测)
|
||||
- `disc_status` tinyint(0=未测,1=已测)
|
||||
- `form_status` tinyint(0=未填,1=已填)
|
||||
- `analyze_status` tinyint(0=未生成,1=已生成,2=失败)
|
||||
- `last_analyze_at` datetime NULL
|
||||
- `latest_report_id` bigint NULL(关联报告表)
|
||||
- `name` varchar(64)
|
||||
- `province` varchar(32)
|
||||
- `stream_or_subjects` varchar(128)
|
||||
- `estimated_score` int NULL
|
||||
- `form_json` json NULL(完整表单回填)
|
||||
- `tags_json` json NULL(如“工科倾向”“省内优先”)
|
||||
- `created_at` datetime
|
||||
- `updated_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `uk_user_id` (`user_id`)
|
||||
- `idx_tenant_status` (`tenant_id`, `entry_status`, `analyze_status`)
|
||||
- `idx_last_analyze_at` (`last_analyze_at`)
|
||||
|
||||
## 13.3 高考分析报告表 `gaokao_report`
|
||||
|
||||
用途:保存每次 AI 分析结果与来源信息,支持后台查看和审计追溯。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `user_id` bigint
|
||||
- `tenant_id` bigint
|
||||
- `version` varchar(20)(prompt/version)
|
||||
- `input_snapshot_json` json(入参快照)
|
||||
- `report_json` json(完整输出)
|
||||
- `overview` text
|
||||
- `search_meta_json` json
|
||||
- `status` tinyint(0=失败,1=成功)
|
||||
- `error_msg` varchar(500) NULL
|
||||
- `created_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_user_created` (`user_id`, `created_at`)
|
||||
- `idx_tenant_created` (`tenant_id`, `created_at`)
|
||||
|
||||
## 13.4 高考订单表 `gaokao_order`
|
||||
|
||||
用途:记录高考功能付费订单,与平台支付及分销结算对齐。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `order_no` varchar(64) UNIQUE
|
||||
- `user_id` bigint
|
||||
- `tenant_id` bigint
|
||||
- `product_code` varchar(64)
|
||||
- `pricing_id` bigint
|
||||
- `amount_original` decimal(10,2)
|
||||
- `amount_payable` decimal(10,2)
|
||||
- `amount_paid` decimal(10,2) NULL
|
||||
- `currency` varchar(16)
|
||||
- `pay_status` tinyint(0=待支付,1=已支付,2=已退款,3=关闭)
|
||||
- `pay_channel` varchar(32)
|
||||
- `paid_at` datetime NULL
|
||||
- `refund_at` datetime NULL
|
||||
- `ext_json` json NULL
|
||||
- `created_at` datetime
|
||||
- `updated_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `uk_order_no` (`order_no`)
|
||||
- `idx_user_pay_status` (`user_id`, `pay_status`)
|
||||
- `idx_tenant_created` (`tenant_id`, `created_at`)
|
||||
|
||||
## 13.5 分销归因表 `gaokao_distribution_attribution`
|
||||
|
||||
用途:记录用户来源、分销员关系、归因窗口,服务佣金计算。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `user_id` bigint
|
||||
- `tenant_id` bigint
|
||||
- `referrer_user_id` bigint(分销员)
|
||||
- `channel_code` varchar(64)(海报/链接/机构码)
|
||||
- `scene` varchar(64)(share_link/poster/qr)
|
||||
- `attributed_at` datetime
|
||||
- `expire_at` datetime NULL(归因窗口)
|
||||
- `is_locked` tinyint(0=可变更,1=锁定)
|
||||
- `created_at` datetime
|
||||
- `updated_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_user_tenant` (`user_id`, `tenant_id`)
|
||||
- `idx_referrer` (`referrer_user_id`, `created_at`)
|
||||
- 唯一约束(建议):同 `user_id + tenant_id` 仅保留 1 条生效归因
|
||||
|
||||
## 13.6 分销佣金流水表 `gaokao_distribution_commission`
|
||||
|
||||
用途:订单成交后记录应结/已结/冲正的佣金流水。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `id` bigint PK
|
||||
- `tenant_id` bigint
|
||||
- `order_id` bigint
|
||||
- `order_no` varchar(64)
|
||||
- `user_id` bigint(购买用户)
|
||||
- `referrer_user_id` bigint(分销员)
|
||||
- `commission_rule_type` varchar(20)(ratio/fixed)
|
||||
- `commission_rule_value` decimal(10,4)
|
||||
- `commission_amount` decimal(10,2)
|
||||
- `status` tinyint(0=待结算,1=已结算,2=已冲正)
|
||||
- `settled_at` datetime NULL
|
||||
- `reversed_at` datetime NULL
|
||||
- `remark` varchar(255) NULL
|
||||
- `created_at` datetime
|
||||
- `updated_at` datetime
|
||||
|
||||
索引建议:
|
||||
|
||||
- `idx_referrer_status` (`referrer_user_id`, `status`)
|
||||
- `idx_order_id` (`order_id`)
|
||||
- `idx_tenant_created` (`tenant_id`, `created_at`)
|
||||
|
||||
## 13.7 用户管理聚合查询建议(非新表)
|
||||
|
||||
后台“高考用户管理”建议通过以下聚合视图/查询实现:
|
||||
|
||||
- 主表:`gaokao_user_profile`
|
||||
- 左连接:`gaokao_report`(最新一条)
|
||||
- 左连接:`gaokao_order`(最近支付状态)
|
||||
- 左连接:`gaokao_distribution_attribution` + `gaokao_distribution_commission`
|
||||
|
||||
建议输出字段:
|
||||
|
||||
- 用户基础信息 + 任务完成状态 + 最近分析时间
|
||||
- 报告摘要(overview)与标签(tags_json)
|
||||
- 订单金额/支付状态
|
||||
- 分销员/渠道/累计佣金
|
||||
|
||||
---
|
||||
|
||||
## 14. 与现有提示词文件关系
|
||||
|
||||
你提供的 `高考志愿MBTI推荐_prompt.md` 已非常完整,建议作为“详细版 Prompt 规范”;本文件作为“开发执行版 PRD + Prompt 摘要规范”。
|
||||
|
||||
推荐落地方式:
|
||||
|
||||
1. 本文件给产品/前后端/测试对齐需求。
|
||||
2. `高考志愿MBTI推荐_prompt.md` 作为 AI 服务最终系统提示词来源。
|
||||
3. 后端将两者版本号写入 runtime 配置,便于追踪效果。
|
||||
Reference in New Issue
Block a user