微信小程序:自定义底栏接入 AI 入口(含图标与样式)、AI 聊天相关页面与历史/报告等能力;审核模式与配置联动;资料/企业/首页/授权等页面与 app 入口配合调整。

后端 API:AI 对话/报告/余额告警 等服务与路由;小程序 tabBar 配置与分润相关能力;出站推送 Webhook 与管理端调试;飞书留资、小程序数据分析 等支撑;多份 数据库迁移(AI 聊天、灵魂文章、tabBar、分润等)。
管理端 / 超管:看板、用户/订单/分销/财务、设置与 小程序 tabBar 管理、分润规则、灵魂文章、AI 配置与监控 等大量页面与能力扩展;主题与布局更新。
抖音小程序:与微信侧在 结果页/购买/个人中心 等方向做了同步或优化。
另含 部署/脚本、开发文档 等配套更新。
邀请码与解锁:后端 分销/邀请码 接口与 add_invite_codes 等 SQL;小程序侧 邀请码弹窗组件、个人中心/推广/多类结果页 的填写入口与 解锁门控(inviteCodeGate、unlockGate);并补充 《小程序解锁引导与邀请码方案》 文档。
超管后台:路由与 企业中心(EnterpriseHub) 等交互与结构优化。
小程序体验:在 首页结果、推广、各测评结果页 上继续打磨;与 邀请码门控、审核门控 小步联动。
This commit is contained in:
Ghost
2026-04-23 11:28:16 +08:00
parent d07235b64c
commit d7137f9349
53 changed files with 1817 additions and 226 deletions

View File

@@ -1,11 +0,0 @@
---
description: MBTI王 飞书里程碑须读卡若AI F01e Skill勿在项目内复制 Skill 正文)
globs: "**/*"
alwaysApply: false
---
在本仓库mbti王涉及「五角色 / 飞书里程碑 / 完整功能通报」时:
1. **必读**卡若AI`/Users/karuo/Documents/个人/卡若AI/04_卡火/火炬_全栈消息/开发五角色与飞书里程碑/SKILL.md`**F01e**,间名五方演岗)。**不要**在本仓库 `.cursor/skills/` 维护该 Skill 正文。
2. 推送:环境变量 `FEISHU_WEBHOOK_MBTI` + 薄封装 `scripts/feishu_mbti_milestone_notify.py`内部调用卡若AI `feishu_milestone_notify.py`)。
3. 仅在「完整功能可验收」时推送,禁止每次小改动都推。

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ miniprogram/project.private.config.json
.git222/ .git222/
.cursor/ .cursor/
.git2222/ .git2222/
.git22/

View File

@@ -133,17 +133,7 @@ interface NavItem {
const navMainItems: NavItem[] = [ const navMainItems: NavItem[] = [
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' }, { path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
{ { path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
path: '/superadmin/enterprises',
icon: OfficeBuilding,
label: '企业管理',
children: [
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业列表' },
{ path: '/superadmin/soul-articles', icon: Share, label: 'Soul 引流文章' },
{ path: '/superadmin/mp-tabbar', icon: Setting, label: '小程序底部菜单' },
{ path: '/superadmin/profit-rules', icon: Share, label: '分账规则' }
]
},
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' }, { path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' }, { path: '/superadmin/distribution', icon: Share, label: '分销管理' },
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' } { path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }

View File

@@ -149,18 +149,20 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'mp-tabbar', path: 'mp-tabbar',
name: 'SuperAdminMpTabBar', name: 'SuperAdminMpTabBar',
redirect: to => ({ redirect: to => {
path: '/superadmin/enterprises', const q = { ...to.query } as Record<string, unknown>
query: { ...to.query, tab: 'mpTabbar' } delete q.tab
}) return { path: '/superadmin/enterprises', query: q }
}
}, },
{ {
path: 'profit-rules', path: 'profit-rules',
name: 'SuperAdminProfitRules', name: 'SuperAdminProfitRules',
redirect: to => ({ redirect: to => {
path: '/superadmin/enterprises', const q = { ...to.query } as Record<string, unknown>
query: { ...to.query, tab: 'profitRules' } delete q.tab
}) return { path: '/superadmin/enterprises', query: q }
}
}, },
{ {
path: 'settings', path: 'settings',

View File

@@ -27,8 +27,6 @@
<Enterprises v-if="activeTab === 'companies'" embedded /> <Enterprises v-if="activeTab === 'companies'" embedded />
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded /> <Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
<SoulArticles v-if="activeTab === 'soulArticles'" /> <SoulArticles v-if="activeTab === 'soulArticles'" />
<MpTabBar v-if="activeTab === 'mpTabbar'" />
<ProfitRules v-if="activeTab === 'profitRules'" />
</div> </div>
</div> </div>
</template> </template>
@@ -41,18 +39,19 @@ import { request } from '@/utils/request'
import Enterprises from './Enterprises.vue' import Enterprises from './Enterprises.vue'
import Users from './Users.vue' import Users from './Users.vue'
import SoulArticles from './SoulArticles.vue' import SoulArticles from './SoulArticles.vue'
import MpTabBar from './MpTabBar.vue'
import ProfitRules from './ProfitRules.vue'
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue' import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
import SaTabs from '@/components/superadmin/SaTabs.vue' import SaTabs from '@/components/superadmin/SaTabs.vue'
const TAB_IDS = ['companies', 'users', 'soulArticles', 'mpTabbar', 'profitRules'] as const const TAB_IDS = ['companies', 'users', 'soulArticles'] as const
type TabId = (typeof TAB_IDS)[number] type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId { function isTabId(s: string): s is TabId {
return (TAB_IDS as readonly string[]).includes(s) return (TAB_IDS as readonly string[]).includes(s)
} }
/** 已从页签隐藏的 tab旧链接兼容 */
const LEGACY_TAB_IDS = ['mpTabbar', 'profitRules']
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const activeTab = ref<TabId>('companies') const activeTab = ref<TabId>('companies')
@@ -97,14 +96,28 @@ async function runOrphanMigrate() {
const innerTabs: { label: string; value: TabId }[] = [ const innerTabs: { label: string; value: TabId }[] = [
{ label: '企业列表', value: 'companies' }, { label: '企业列表', value: 'companies' },
{ label: '用户总览', value: 'users' }, { label: '用户总览', value: 'users' },
{ label: '引流文章', value: 'soulArticles' }, { label: '引流文章', value: 'soulArticles' }
{ label: '底部菜单', value: 'mpTabbar' },
{ label: '分账规则', value: 'profitRules' }
] ]
function queryWithoutTab(): Record<string, string> {
const q: Record<string, string> = {}
Object.entries(route.query).forEach(([k, v]) => {
if (v !== undefined && v !== null && k !== 'tab') {
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
}
})
return q
}
const applyRouteTab = () => { const applyRouteTab = () => {
const t = route.query.tab const t = typeof route.query.tab === 'string' ? route.query.tab : ''
if (typeof t === 'string' && isTabId(t)) { if (t && LEGACY_TAB_IDS.includes(t)) {
activeTab.value = 'companies'
const q = queryWithoutTab()
router.replace({ path: '/superadmin/enterprises', query: Object.keys(q).length ? q : {} })
return
}
if (isTabId(t)) {
activeTab.value = t activeTab.value = t
} else { } else {
activeTab.value = 'companies' activeTab.value = 'companies'

View File

@@ -168,4 +168,18 @@ if (!function_exists('setHeader')) {
} }
} }
/**
* 小程序提审模式是否开启(读 system_config.system JSON 的 miniprogramAuditMode
* 正式环境若未部署 MiniprogramAuditMode.php视为未开启避免 Class not found 致命错误。
*/
if (!function_exists('miniprogram_audit_mode_on')) {
function miniprogram_audit_mode_on(): bool
{
if (!class_exists(\app\common\service\MiniprogramAuditMode::class, true)) {
return false;
}
return \app\common\service\MiniprogramAuditMode::isOn();
}
}

View File

@@ -5,7 +5,6 @@ use app\BaseController;
use app\common\service\AiCallService; use app\common\service\AiCallService;
use app\common\service\AiChatArticleDisplayService; use app\common\service\AiChatArticleDisplayService;
use app\common\service\OutboundPushHookService; use app\common\service\OutboundPushHookService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\ResumeFileExtractService; use app\common\service\ResumeFileExtractService;
use app\common\service\SoulArticleService; use app\common\service\SoulArticleService;
use app\model\AiConversation as AiConversationModel; use app\model\AiConversation as AiConversationModel;
@@ -176,7 +175,7 @@ class AiChat extends BaseController
{ {
$userId = $this->currentUserId(); $userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('对话功能升级中,请稍后再试', 503); return error('对话功能升级中,请稍后再试', 503);
} }
@@ -418,7 +417,7 @@ class AiChat extends BaseController
$jobKey = self::chatJobCacheKey($userId, $jobId); $jobKey = self::chatJobCacheKey($userId, $jobId);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$msg = '当前为提审模式,对话暂不可用'; $msg = '当前为提审模式,对话暂不可用';
self::jobTableTrySetError($userId, $jobId, $conversationId, $msg); self::jobTableTrySetError($userId, $jobId, $conversationId, $msg);
Cache::set($jobKey, [ Cache::set($jobKey, [
@@ -621,7 +620,7 @@ class AiChat extends BaseController
*/ */
public function recommendedArticles() public function recommendedArticles()
{ {
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return success([ return success([
'list' => [], 'list' => [],
'recoCount' => 0, 'recoCount' => 0,
@@ -692,7 +691,7 @@ class AiChat extends BaseController
*/ */
public function profileArticleTeaser() public function profileArticleTeaser()
{ {
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return success([ return success([
'enabled' => false, 'enabled' => false,
'sectionLabel' => '', 'sectionLabel' => '',
@@ -764,7 +763,7 @@ class AiChat extends BaseController
*/ */
public function quickQuestions() public function quickQuestions()
{ {
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return success([ return success([
'mbtiType' => '', 'mbtiType' => '',
'nickname' => '', 'nickname' => '',
@@ -829,7 +828,7 @@ class AiChat extends BaseController
{ {
$userId = $this->currentUserId(); $userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$page = max(1, (int) Request::get('page', 1)); $page = max(1, (int) Request::get('page', 1));
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20))); $pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
@@ -857,7 +856,7 @@ class AiChat extends BaseController
{ {
$userId = $this->currentUserId(); $userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('会话不存在', 404); return error('会话不存在', 404);
} }
@@ -904,7 +903,7 @@ class AiChat extends BaseController
{ {
$userId = $this->currentUserId(); $userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('功能升级中', 503); return error('功能升级中', 503);
} }

View File

@@ -3,7 +3,6 @@ namespace app\controller\api;
use app\BaseController; use app\BaseController;
use app\common\service\AiReportService; use app\common\service\AiReportService;
use app\common\service\MiniprogramAuditMode;
use think\facade\Request; use think\facade\Request;
/** /**
@@ -22,7 +21,7 @@ class AiReport extends BaseController
{ {
$userId = $this->jwtSubjectUserId(); $userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('功能升级中', 503); return error('功能升级中', 503);
} }
$conversationId = (int) Request::param('conversationId', 0); $conversationId = (int) Request::param('conversationId', 0);
@@ -35,7 +34,7 @@ class AiReport extends BaseController
public function myLatest() public function myLatest()
{ {
try { try {
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return success(['status' => '']); return success(['status' => '']);
} }
$userId = $this->jwtSubjectUserId(); $userId = $this->jwtSubjectUserId();
@@ -57,7 +56,7 @@ class AiReport extends BaseController
{ {
$userId = $this->jwtSubjectUserId(); $userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('报告不存在', 404); return error('报告不存在', 404);
} }
$r = AiReportService::get((int) $id, $userId); $r = AiReportService::get((int) $id, $userId);
@@ -93,7 +92,7 @@ class AiReport extends BaseController
{ {
$userId = $this->jwtSubjectUserId(); $userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401); if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('功能升级中', 503); return error('功能升级中', 503);
} }
$r = AiReportService::get((int) $id, $userId); $r = AiReportService::get((int) $id, $userId);

View File

@@ -10,7 +10,6 @@ use app\model\SystemConfig as SystemConfigModel;
use app\model\PricingConfig as PricingConfigModel; use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel; use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService; use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use think\facade\Db; use think\facade\Db;
use think\facade\Log; use think\facade\Log;
use think\facade\Request; use think\facade\Request;
@@ -141,7 +140,7 @@ class Analyze extends BaseController
// $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personal // $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personal
$standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId); $standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId);
$requiresPayment = $standardAmountFen > 0 ? 1 : 0; $requiresPayment = $standardAmountFen > 0 ? 1 : 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$standardAmountFen = 0; $standardAmountFen = 0;
$requiresPayment = 0; $requiresPayment = 0;
} }
@@ -315,7 +314,7 @@ class Analyze extends BaseController
$pricingEnterpriseId = $enterpriseId > 0 ? $enterpriseId : null; $pricingEnterpriseId = $enterpriseId > 0 ? $enterpriseId : null;
$standardAmountFen = $this->getStandardAmountByTestType('resume', $pricingEnterpriseId, $pricingEnterpriseId); $standardAmountFen = $this->getStandardAmountByTestType('resume', $pricingEnterpriseId, $pricingEnterpriseId);
$requiresPayment = $standardAmountFen > 0 ? 1 : 0; $requiresPayment = $standardAmountFen > 0 ? 1 : 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$standardAmountFen = 0; $standardAmountFen = 0;
$requiresPayment = 0; $requiresPayment = 0;
} }

View File

@@ -7,7 +7,6 @@ use app\model\PricingConfig as PricingConfigModel;
use app\common\service\AiCallService; use app\common\service\AiCallService;
use app\common\service\AiChatArticleDisplayService; use app\common\service\AiChatArticleDisplayService;
use app\common\service\JwtService; use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\MpTabbarService; use app\common\service\MpTabbarService;
use think\facade\Db; use think\facade\Db;
@@ -278,7 +277,7 @@ class AppConfig extends BaseController
public function deepPricing() public function deepPricing()
{ {
$scope = (string) ($this->request->param('scope', 'personal') ?? 'personal'); $scope = (string) ($this->request->param('scope', 'personal') ?? 'personal');
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return success(['scope' => $scope, 'categories' => []]); return success(['scope' => $scope, 'categories' => []]);
} }
$categories = $this->normalizeDeepPricingCategories($scope); $categories = $this->normalizeDeepPricingCategories($scope);

View File

@@ -42,6 +42,19 @@ class Distribution extends BaseController
$inviterId = (int) Request::param('inviterId', 0); $inviterId = (int) Request::param('inviterId', 0);
$enterpriseId = Request::param('eid', null); $enterpriseId = Request::param('eid', null);
$enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null; $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null;
$inviteCodeRaw = trim((string) Request::param('inviteCode', ''));
if ($inviteCodeRaw !== '') {
$parsed = $this->resolveInviterFromInviteCode($inviteCodeRaw, $enterpriseId);
if ($parsed === null || (int) ($parsed['inviterId'] ?? 0) <= 0) {
return error('邀请码无效或已失效', 400);
}
$inviterId = (int) $parsed['inviterId'];
if (array_key_exists('enterpriseId', $parsed) && $parsed['enterpriseId'] !== null && $parsed['enterpriseId'] > 0) {
$enterpriseId = (int) $parsed['enterpriseId'];
}
}
$scope = $enterpriseId ? 'enterprise' : 'personal'; $scope = $enterpriseId ? 'enterprise' : 'personal';
// 自绑校验 // 自绑校验
@@ -141,6 +154,77 @@ class Distribution extends BaseController
return success(['expireAt' => $expireAt], '绑定成功'); return success(['expireAt' => $expireAt], '绑定成功');
} }
/**
* GET /api/distribution/my-invite-code
* 返回当前用户名下可用邀请码;若无记录则自动生成一条(便于前端直接展示)
*/
public function myInviteCode()
{
$user = $this->resolveUser();
if (!$user) {
return error('未登录', 401);
}
$inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($inviterId <= 0) {
return error('用户异常', 400);
}
try {
$rows = Db::name('invite_codes')
->where('inviterId', $inviterId)
->where('status', 'active')
->order('id', 'asc')
->select()
->toArray();
} catch (\Throwable $e) {
Log::warning('myInviteCode select: ' . $e->getMessage());
return error('邀请码功能未就绪,请确认已执行数据库迁移', 503);
}
$now = time();
foreach ($rows as $r) {
$exp = isset($r['expiresAt']) ? (int) $r['expiresAt'] : 0;
if ($exp <= 0 || $exp > $now) {
$code = trim((string) ($r['code'] ?? ''));
if ($code !== '') {
return success([
'code' => strtoupper($code),
'createdNew' => false,
]);
}
}
}
try {
$newCode = $this->generateUniqueInviteCode();
Db::name('invite_codes')->insert([
'code' => $newCode,
'inviterId' => $inviterId,
'enterpriseId' => null,
'status' => 'active',
'expiresAt' => null,
'remark' => 'auto_my_invite_code',
'createdAt' => $now,
'updatedAt' => $now,
]);
} catch (\Throwable $e) {
Log::warning('myInviteCode insert: ' . $e->getMessage());
return success([
'code' => null,
'createdNew' => false,
'hint' => '暂时无法生成邀请码,请稍后重试或联系管理员',
]);
}
return success([
'code' => $newCode,
'createdNew' => true,
]);
}
/** /**
* 过期待收款提现status=2 超过24小时未确认收款的自动退回余额并标记为已过期 * 过期待收款提现status=2 超过24小时未确认收款的自动退回余额并标记为已过期
*/ */
@@ -1477,4 +1561,66 @@ class Distribution extends BaseController
]); ]);
} }
} }
/**
* 活动邀请码 → inviterId需表 invite_codes见 database/add_invite_codes.sql
*
* @param string $raw
* @param int|null $requestEnterpriseId 请求里已带的 eid用于与码上企业校验
* @return array{inviterId:int, enterpriseId:int|null}|null
*/
/**
* 生成不与 invite_codes.code 冲突的随机码(易辨认字符集)
*/
private function generateUniqueInviteCode(): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$max = strlen($chars) - 1;
for ($attempt = 0; $attempt < 24; $attempt++) {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $chars[random_int(0, $max)];
}
$exists = Db::name('invite_codes')->where('code', $code)->find();
if (!$exists) {
return $code;
}
}
throw new \RuntimeException('invite code collision');
}
private function resolveInviterFromInviteCode(string $raw, ?int $requestEnterpriseId): ?array
{
$norm = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $raw));
if ($norm === '') {
return null;
}
try {
$row = Db::name('invite_codes')
->where('code', $norm)
->where('status', 'active')
->find();
} catch (\Throwable $e) {
Log::warning('invite_codes lookup failed: ' . $e->getMessage());
return null;
}
if (!$row) {
return null;
}
$exp = isset($row['expiresAt']) ? (int) $row['expiresAt'] : 0;
if ($exp > 0 && $exp < time()) {
return null;
}
$codeEid = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : 0;
$codeEid = $codeEid > 0 ? $codeEid : null;
if ($requestEnterpriseId !== null && $requestEnterpriseId > 0 && $codeEid !== null && $codeEid !== $requestEnterpriseId) {
return null;
}
return [
'inviterId' => (int) ($row['inviterId'] ?? 0),
'enterpriseId' => $codeEid,
];
}
} }

View File

@@ -5,7 +5,6 @@ use app\BaseController;
use app\model\PricingConfig as PricingConfigModel; use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel; use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService; use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\FeishuLeadWebhookService; use app\common\service\FeishuLeadWebhookService;
use app\common\service\AiReportService; use app\common\service\AiReportService;
use think\facade\Request; use think\facade\Request;
@@ -61,7 +60,7 @@ class Payment extends BaseController
return error('用户信息异常', 400); return error('用户信息异常', 400);
} }
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
return error('小程序版本审核期间暂不可发起虚拟商品支付,请审核结束后再试', 400); return error('小程序版本审核期间暂不可发起虚拟商品支付,请审核结束后再试', 400);
} }

View File

@@ -8,7 +8,6 @@ use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel; use app\model\PricingConfig as PricingConfigModel;
use app\model\Question as QuestionModel; use app\model\Question as QuestionModel;
use app\model\UserProfile as UserProfileModel; use app\model\UserProfile as UserProfileModel;
use app\common\service\MiniprogramAuditMode;
use think\facade\Db; use think\facade\Db;
use think\facade\Request; use think\facade\Request;
@@ -108,7 +107,7 @@ class Test extends BaseController
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null; $orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0; $paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0; $needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$needPayUnlock = false; $needPayUnlock = false;
} }
@@ -129,7 +128,7 @@ class Test extends BaseController
} }
$paymentFields = [ $paymentFields = [
'requiresPayment' => MiniprogramAuditMode::isOn() ? 0 : $requiresPayment, 'requiresPayment' => miniprogram_audit_mode_on() ? 0 : $requiresPayment,
'isPaid' => $isPaid, 'isPaid' => $isPaid,
'orderId' => $orderId, 'orderId' => $orderId,
'enterpriseName' => $enterpriseName, 'enterpriseName' => $enterpriseName,
@@ -427,7 +426,7 @@ class Test extends BaseController
$isPaid = (int) ($row['isPaid'] ?? 0); $isPaid = (int) ($row['isPaid'] ?? 0);
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0; $paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0; $needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$needPayUnlock = false; $needPayUnlock = false;
} }
if ($data !== []) { if ($data !== []) {
@@ -538,7 +537,7 @@ class Test extends BaseController
'resultText' => $resultText, 'resultText' => $resultText,
'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '', 'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '',
'isPaid' => (int) ($row['isPaid'] ?? 0), 'isPaid' => (int) ($row['isPaid'] ?? 0),
'requiresPayment' => MiniprogramAuditMode::isOn() ? 0 : (int) ($row['requiresPayment'] ?? 0), 'requiresPayment' => miniprogram_audit_mode_on() ? 0 : (int) ($row['requiresPayment'] ?? 0),
]; ];
if ($gallupPreview !== '') { if ($gallupPreview !== '') {
$out['gallupPreview'] = $gallupPreview; $out['gallupPreview'] = $gallupPreview;
@@ -669,7 +668,7 @@ class Test extends BaseController
$paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0; $paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$testType = $row['testType'] ?? ''; $testType = $row['testType'] ?? '';
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0; $needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$needPaymentToUnlock = false; $needPaymentToUnlock = false;
} }
$subjectUserId = (int) ($row['userId'] ?? 0); $subjectUserId = (int) ($row['userId'] ?? 0);
@@ -688,7 +687,7 @@ class Test extends BaseController
} }
} }
$auditMp = MiniprogramAuditMode::isOn(); $auditMp = miniprogram_audit_mode_on();
$respRequires = $auditMp ? 0 : $requiresPayment; $respRequires = $auditMp ? 0 : $requiresPayment;
$respPaidAmount = $auditMp ? 0 : $paidAmount; $respPaidAmount = $auditMp ? 0 : $paidAmount;
@@ -794,7 +793,7 @@ class Test extends BaseController
} }
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId); $requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0; $standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
if (MiniprogramAuditMode::isOn()) { if (miniprogram_audit_mode_on()) {
$requiresPayment = 0; $requiresPayment = 0;
$standardAmountFen = 0; $standardAmountFen = 0;
} }

View File

@@ -0,0 +1,17 @@
-- 邀请码 → 推广员inviterId供小程序填码绑定分销关系
-- 执行前请将表名前缀与 api/config/database.php 中 prefix 对齐ThinkPHP Db::name('invite_codes') → {prefix}invite_codes
CREATE TABLE IF NOT EXISTS `mbti_invite_codes` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(64) NOT NULL COMMENT '邀请码(仅存大写字母数字)',
`inviterId` int unsigned NOT NULL COMMENT '推广员 wechat_users.id',
`enterpriseId` int unsigned NULL DEFAULT NULL COMMENT '企业上下文,与个人版并行时可空',
`status` varchar(20) NOT NULL DEFAULT 'active' COMMENT 'active|disabled',
`expiresAt` int unsigned NULL DEFAULT NULL COMMENT '过期unix时间戳NULL=不限',
`remark` varchar(255) NULL DEFAULT NULL COMMENT '备注',
`createdAt` int unsigned NULL DEFAULT NULL,
`updatedAt` int unsigned NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_inviterId` (`inviterId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='小程序填写邀请码解析用';

View File

@@ -80,6 +80,7 @@ Route::group('api', function () {
Route::get('orders', 'api.Order/index'); Route::get('orders', 'api.Order/index');
// 分销 // 分销
Route::post('distribution/bind', 'api.Distribution/bind'); Route::post('distribution/bind', 'api.Distribution/bind');
Route::get('distribution/my-invite-code', 'api.Distribution/myInviteCode');
Route::get('distribution/stats', 'api.Distribution/stats'); Route::get('distribution/stats', 'api.Distribution/stats');
Route::get('distribution/bindings', 'api.Distribution/bindings'); Route::get('distribution/bindings', 'api.Distribution/bindings');
Route::get('distribution/commissions', 'api.Distribution/commissions'); Route::get('distribution/commissions', 'api.Distribution/commissions');
@@ -113,6 +114,22 @@ Route::group('api', function () {
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate'); Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
})->middleware(['cors', 'auth']); })->middleware(['cors', 'auth']);
// ==================== 兼容 /api/v1 前缀(与上方 api 组同权、同中间件)====================
// 部分 CDN / 网关 / 旧客户端只放行 /api/v1/*,会导致 POST /api/ai/chat 404此处镜像神仙 AI 相关路由
Route::group('api/v1', function () {
Route::post('ai/chat', 'api.AiChat/chat');
Route::get('ai/chat/job', 'api.AiChat/chatJobStatus');
Route::get('ai/conversations', 'api.AiChat/conversations');
Route::get('ai/conversations/:id/messages', 'api.AiChat/messages');
Route::post('ai/transcribe', 'api.AiChat/transcribe');
Route::post('ai/report/create', 'api.AiReport/create');
Route::get('ai/report/my-latest', 'api.AiReport/myLatest');
Route::get('ai/my-report/latest', 'api.AiReport/myLatest');
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');
})->middleware(['cors', 'auth']);
// ==================== 小程序API路由匹配前端 /api 路径)==================== // ==================== 小程序API路由匹配前端 /api 路径)====================
// 小程序公开API路由不需要认证- 与前端共用上面 api 组,此处可不再重复 // 小程序公开API路由不需要认证- 与前端共用上面 api 组,此处可不再重复
// 小程序需要认证的API路由 - 与前端共用上面 api 组 // 小程序需要认证的API路由 - 与前端共用上面 api 组

View File

@@ -1,6 +1,7 @@
/* app.wxss - 全局样式 */ /* app.wxss - 全局样式 */
@import "./styles/result-page-dashboard.wxss"; @import "./styles/result-page-dashboard.wxss";
@import "./styles/invite-fill-entry.wxss";
/* 主题颜色 */ /* 主题颜色 */
page { page {
@@ -194,3 +195,17 @@ page {
.tabbar-pad-fixed { .tabbar-pad-fixed {
bottom: var(--tabbar-pad) !important; bottom: var(--tabbar-pad) !important;
} }
/* 解锁前滚动的留资区锚点 */
.unlock-gate-anchor {
height: 2rpx;
width: 100%;
}
.unlock-gate-anchor-btn-wrap {
width: 100%;
}
.unlock-gate-profile-block {
width: 100%;
}

View File

@@ -0,0 +1,87 @@
const { request } = require('../../utils/request.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
Component({
properties: {
visible: {
type: Boolean,
value: false
}
},
data: {
code: '',
submitting: false,
maxLen: 32
},
observers: {
visible(v) {
if (v) {
this.setData({ code: '', submitting: false })
}
}
},
methods: {
noop() {},
onInput(e) {
const v = (e.detail && e.detail.value) ? String(e.detail.value) : ''
this.setData({ code: v })
},
onMaskSkip() {
this.onSkip()
},
onSkip() {
inviteCodeGate.markInviteSkipped()
this.triggerEvent('skip', {})
},
onConfirmTap() {
const raw = (this.data.code || '').trim()
if (!raw) {
wx.showToast({ title: '请输入邀请码', icon: 'none' })
return
}
if (this.data.submitting) return
this.setData({ submitting: true })
const token = wx.getStorageSync('token')
if (!token) {
wx.showToast({ title: '请先登录', icon: 'none' })
this.setData({ submitting: false })
return
}
const payload = { inviteCode: raw }
const eid = getEnterpriseIdForApiPayload()
if (eid != null && Number(eid) > 0) {
payload.eid = Number(eid)
}
request({
url: '/api/distribution/bind',
method: 'POST',
data: payload,
success: (res) => {
const body = res.data || {}
if (res.statusCode === 200 && body.code === 200) {
inviteCodeGate.markInviteBound()
wx.showToast({ title: body.message || '已记录邀请关系', icon: 'success' })
this.triggerEvent('success', body.data || {})
} else {
const msg = body.message || '邀请码无效'
wx.showToast({ title: msg, icon: 'none' })
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' })
},
complete: () => {
this.setData({ submitting: false })
}
})
}
}
})

View File

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

View File

@@ -0,0 +1,21 @@
<view class="icd-mask" wx:if="{{visible}}" catchtouchmove="noop" bindtap="onMaskSkip">
<view class="icd-panel" catchtap="noop">
<text class="icd-title">填写邀请码</text>
<text class="icd-desc">选填。用于参与推广活动或企业活动;可稍后在「推广中心」了解规则。</text>
<input
class="icd-input"
type="text"
maxlength="{{maxLen}}"
placeholder="字母或数字"
placeholder-class="icd-ph"
value="{{code}}"
bindinput="onInput"
confirm-type="done"
bindconfirm="onConfirmTap"
/>
<view class="icd-actions">
<button class="icd-btn icd-btn--ghost" hover-class="none" bindtap="onSkip">暂不填写</button>
<button class="icd-btn icd-btn--primary" hover-class="none" loading="{{submitting}}" bindtap="onConfirmTap">确认</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,89 @@
.icd-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 12000;
background: rgba(15, 23, 42, 0.48);
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.icd-panel {
width: 100%;
max-width: 620rpx;
background: #fff;
border-radius: 28rpx;
padding: 40rpx 36rpx 36rpx;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.12);
}
.icd-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
}
.icd-desc {
display: block;
font-size: 24rpx;
line-height: 1.55;
color: #6b7280;
margin-bottom: 28rpx;
}
.icd-input {
width: 100%;
height: 88rpx;
line-height: 88rpx;
padding: 0 28rpx;
box-sizing: border-box;
font-size: 30rpx;
color: #111827;
background: #f9fafb;
border-radius: 16rpx;
border: 2rpx solid #e5e7eb;
}
.icd-ph {
color: #9ca3af;
}
.icd-actions {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 36rpx;
}
.icd-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
font-size: 28rpx;
font-weight: 600;
border-radius: 16rpx;
padding: 0;
margin: 0;
border: none;
}
.icd-btn::after {
border: none;
}
.icd-btn--ghost {
background: #f3f4f6;
color: #374151;
}
.icd-btn--primary {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8e53 100%);
color: #fff;
}

View File

@@ -8,6 +8,9 @@ const {
needsResultProfileGate, needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js') } = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { mbtiDescriptions } = require('../../utils/descriptions') const { mbtiDescriptions } = require('../../utils/descriptions')
const { triggerTestResultCompleted } = require('../../utils/pushHook') const { triggerTestResultCompleted } = require('../../utils/pushHook')
@@ -114,7 +117,9 @@ Page({
showSbtiFace: true, showSbtiFace: true,
analyzingTitle: '正在分析中', analyzingTitle: '正在分析中',
reportTitle: '分析报告', reportTitle: '分析报告',
aiAnalysisText: '智能分析' aiAnalysisText: '智能分析',
showInviteCodeDialog: false,
hideInviteCodeEntry: false
}, },
onLoad(options) { onLoad(options) {
@@ -255,12 +260,14 @@ Page({
}, },
onShow() { onShow() {
const ep = app.globalData.enterprisePermissions const gd = app.globalData || {}
const ep = gd.enterprisePermissions
const showSbtiFace = !ep || ep.sbti !== false const showSbtiFace = !ep || ep.sbti !== false
this.setData({ this.setData({
hasPhone: hasPhone(), hasPhone: hasPhone(),
isProfileComplete: isProfileComplete(), isProfileComplete: isProfileComplete(),
showSbtiFace showSbtiFace,
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
}) })
if (this.data.showResult) this._syncPhoneLoginGate() if (this.data.showResult) this._syncPhoneLoginGate()
const tc = app.globalData.textConfig const tc = app.globalData.textConfig
@@ -534,7 +541,7 @@ Page({
this.unlockFullReport() this.unlockFullReport()
}, },
// 解锁完整报告:发起人脸测试付费(不要求先完善资料) // 解锁完整报告:先留资门禁,再发起人脸测试付费
unlockFullReport() { unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return if (!payInfo.requiresPayment || payInfo.isPaid) return
@@ -544,29 +551,53 @@ Page({
wx.showToast({ title: '请先登录后再解锁', icon: 'none' }) wx.showToast({ title: '请先登录后再解锁', icon: 'none' })
return return
} }
payment.purchaseFaceTest({ unlockGate.ensureUnlockPrerequisitesBeforePay(this).then((ok) => {
testResultId, if (!ok) return
success: () => { inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' }) if (!go) return
payment.purchaseFaceTest({
testResultId,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ this.setData({
'payInfo.isPaid': true, 'payInfo.isPaid': true,
hasPhone: hasPhone(), hasPhone: hasPhone(),
isProfileComplete: isProfileComplete() isProfileComplete: isProfileComplete()
})
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => {
this.reloadFullDetail(testResultId)
}, 500)
}
},
fail: () => {}
}) })
})
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => {
this.reloadFullDetail(testResultId)
}, 500)
}
},
fail: () => {}
}) })
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
/** 支付成功后:仅绑定手机号,不重复发起支付 */ /** 支付成功后:仅绑定手机号,不重复发起支付 */
onPostPayBindPhone(e) { onPostPayBindPhone(e) {
const { code, errMsg } = e.detail || {} const { code, errMsg } = e.detail || {}

View File

@@ -4,5 +4,7 @@
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"enableShareAppMessage": true, "enableShareAppMessage": true,
"enableShareTimeline": true, "enableShareTimeline": true,
"usingComponents": {} "usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -44,6 +44,15 @@
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text> <text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
</view> </view>
<view class="invite-fill-entry invite-fill-entry--face" wx:if="{{!hasError && !fromShare && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 错误内容 --> <!-- 错误内容 -->
<view class="error-content" wx:if="{{hasError}}"> <view class="error-content" wx:if="{{hasError}}">
<text class="error-title">错误信息</text> <text class="error-title">错误信息</text>
@@ -88,7 +97,7 @@
</view> </view>
</view> </view>
</view> </view>
<view class="report-gate-cta-wrap phone-login-gate-cta"> <view class="report-gate-cta-wrap phone-login-gate-cta" id="unlock-gate-anchor">
<view class="report-gate-panel"> <view class="report-gate-panel">
<text class="paywall-fake-title">查看完整报告</text> <text class="paywall-fake-title">查看完整报告</text>
<text class="paywall-tip">授权手机号并设置头像、昵称后即可查看完整报告(与下方按钮为同一条流程)。</text> <text class="paywall-tip">授权手机号并设置头像、昵称后即可查看完整报告(与下方按钮为同一条流程)。</text>
@@ -113,7 +122,7 @@
<block wx:else> <block wx:else>
<!-- 支付成功后:引导手机号 → 完善资料(不挡报告阅读) --> <!-- 支付成功后:引导手机号 → 完善资料(不挡报告阅读) -->
<view class="post-pay-guide" wx:if="{{payInfo.isPaid && (!hasPhone || !isProfileComplete)}}"> <view class="post-pay-guide" id="unlock-gate-anchor" wx:if="{{payInfo.isPaid && (!hasPhone || !isProfileComplete)}}">
<text class="post-pay-guide-title">再完成两步,报告更好归属到您的账号</text> <text class="post-pay-guide-title">再完成两步,报告更好归属到您的账号</text>
<text class="post-pay-guide-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text> <text class="post-pay-guide-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text>
<view class="post-pay-guide-actions"> <view class="post-pay-guide-actions">
@@ -229,7 +238,7 @@
wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}"
> >
<view class="report-gate-gradient"></view> <view class="report-gate-gradient"></view>
<view class="report-gate-cta-wrap"> <view class="report-gate-cta-wrap" id="unlock-gate-anchor">
<view class="report-gate-panel"> <view class="report-gate-panel">
<text class="paywall-fake-title">完整版深度解析</text> <text class="paywall-fake-title">完整版深度解析</text>
<text class="paywall-tip">含面相、骨相、人际与职业 / 家庭 / 合伙三大场景</text> <text class="paywall-tip">含面相、骨相、人际与职业 / 家庭 / 合伙三大场景</text>
@@ -450,4 +459,10 @@
</view> </view>
</button> </button>
</view> </view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -4,6 +4,7 @@ const { getTypeOnly } = require('../../utils/resultFormat')
const { request } = require('../../utils/request') const { request } = require('../../utils/request')
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js') const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js') const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
/** 与超管「小程序 · 推荐文章展示」中「区块标题」一致;接口无字段时的兜底 */ /** 与超管「小程序 · 推荐文章展示」中「区块标题」一致;接口无字段时的兜底 */
const PROFILE_RECO_LABEL_FALLBACK = '我的由来' const PROFILE_RECO_LABEL_FALLBACK = '我的由来'
@@ -79,7 +80,13 @@ Page({
profileRecoSectionLabel: '', profileRecoSectionLabel: '',
profileRecoArticle: null, profileRecoArticle: null,
/** 与神仙 AI 精选推荐同源:配置了 AppID 时优先跳转目标小程序 */ /** 与神仙 AI 精选推荐同源:配置了 AppID 时优先跳转目标小程序 */
profileRecoJump: null profileRecoJump: null,
/** 拉取自 /api/distribution/my-invite-code与推广中心一致 */
myInviteCode: '',
myInviteCodeLoadState: 'idle',
myInviteCodeErrMsg: '',
myInviteCodeHint: '',
showInviteCodeDialog: false
}, },
_computeShowLatestTestRow(d) { _computeShowLatestTestRow(d) {
@@ -223,19 +230,127 @@ Page({
// 2. 已登录则从服务端拉取最近记录;失败降级读 localStorage // 2. 已登录则从服务端拉取最近记录;失败降级读 localStorage
if (token || userInfo) { if (token || userInfo) {
if (token && this.data.permDistribution) {
this.setData({ myInviteCodeLoadState: 'loading', myInviteCodeErrMsg: '' })
}
this._loadRecentFromAPI() this._loadRecentFromAPI()
this._loadPromoStats() this._loadPromoStats()
this._loadMyInviteCode()
this._loadProfileRecoTeaser() this._loadProfileRecoTeaser()
} else { } else {
this.setData({ this.setData({
profileRecoShow: false, profileRecoShow: false,
profileRecoArticle: null, profileRecoArticle: null,
profileRecoSectionLabel: '', profileRecoSectionLabel: '',
profileRecoJump: null profileRecoJump: null,
myInviteCode: '',
myInviteCodeLoadState: 'idle',
myInviteCodeErrMsg: '',
myInviteCodeHint: '',
showInviteCodeDialog: false
}) })
} }
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
_loadMyInviteCode() {
const token = app.globalData.token || wx.getStorageSync('token')
if (!token || !this.data.permDistribution) {
this.setData({
myInviteCode: '',
myInviteCodeLoadState: 'idle',
myInviteCodeErrMsg: '',
myInviteCodeHint: ''
})
return
}
this.setData({
myInviteCodeLoadState: 'loading',
myInviteCodeErrMsg: ''
})
request({
url: '/api/distribution/my-invite-code',
method: 'GET',
success: (res) => {
const payload = res && res.data
const httpOk = res.statusCode >= 200 && res.statusCode < 300
const bizOk = payload && Number(payload.code) === 200
if (!httpOk || !bizOk) {
const msg =
(payload && payload.message) ||
(typeof res.statusCode === 'number' ? `请求失败 (${res.statusCode})` : '加载失败')
this.setData({
myInviteCode: '',
myInviteCodeLoadState: 'error',
myInviteCodeErrMsg: msg,
myInviteCodeHint: ''
})
return
}
const data = payload.data || {}
const raw = data.code != null ? String(data.code).trim() : ''
const code = raw ? raw.toUpperCase() : ''
const hint = (data.hint && String(data.hint).trim()) || ''
if (code) {
this.setData({
myInviteCode: code,
myInviteCodeLoadState: 'ready',
myInviteCodeErrMsg: '',
myInviteCodeHint: ''
})
} else {
this.setData({
myInviteCode: '',
myInviteCodeLoadState: 'nodata',
myInviteCodeErrMsg: '',
myInviteCodeHint: hint || '暂无可用邀请码'
})
}
},
fail: () =>
this.setData({
myInviteCode: '',
myInviteCodeLoadState: 'error',
myInviteCodeErrMsg: '网络异常',
myInviteCodeHint: ''
})
})
},
onInviteStripTap() {
if (this.data.myInviteCodeLoadState === 'error') {
this._loadMyInviteCode()
return
}
this.goToPromo()
},
copyMyInviteCode() {
const code = (this.data.myInviteCode || '').trim()
if (!code) return
wx.setClipboardData({
data: code,
success: () => wx.showToast({ title: '已复制邀请码', icon: 'success' })
})
},
_parseRecoJumpFromDisplay(d) { _parseRecoJumpFromDisplay(d) {
if (!d || typeof d !== 'object') return null if (!d || typeof d !== 'object') return null
const appId = ((d.recoJumpMiniAppId || '') + '').trim() const appId = ((d.recoJumpMiniAppId || '') + '').trim()

View File

@@ -3,6 +3,7 @@
"navigationBarBackgroundColor": "#F9FAFB", "navigationBarBackgroundColor": "#F9FAFB",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"usingComponents": { "usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index" "custom-tab-bar": "/custom-tab-bar/index",
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
} }
} }

View File

@@ -96,6 +96,39 @@
</view> </view>
</view> </view>
<!-- <view
class="invite-strip"
wx:if="{{hasLogin && permDistribution && !reviewMode && !mpAuditMode}}"
bindtap="onInviteStripTap"
>
<view class="invite-strip__text">
<text class="invite-strip__label">我的邀请码</text>
<text wx:if="{{myInviteCodeLoadState === 'loading'}}" class="invite-strip__muted">加载中…</text>
<text wx:elif="{{myInviteCodeLoadState === 'error'}}" class="invite-strip__err">{{myInviteCodeErrMsg}}</text>
<text wx:elif="{{myInviteCodeLoadState === 'ready' && myInviteCode}}" class="invite-strip__code">{{myInviteCode}}</text>
<text wx:else class="invite-strip__err">{{myInviteCodeHint}}</text>
</view>
<view class="invite-strip__side">
<button
wx:if="{{myInviteCodeLoadState === 'ready' && myInviteCode}}"
class="invite-strip__copy"
hover-class="none"
catchtap="copyMyInviteCode"
>复制</button>
<text wx:elif="{{myInviteCodeLoadState === 'error'}}" class="invite-strip__retry-hint">点击重试</text>
<text wx:else class="invite-strip__hint">{{myInviteCodeLoadState === 'loading' ? '请稍候' : '解锁付费前填写 '}}</text>
</view>
</view> -->
<!-- <view
class="invite-fill-row"
wx:if="{{hasLogin && permDistribution && !reviewMode && !mpAuditMode}}"
bindtap="openInviteCodeFill"
>
<text class="invite-fill-row__label">我有好友邀请码</text>
<text class="invite-fill-row__arrow"></text>
</view> -->
<!-- 我的测评:最新测试横滑 + 功能图标网格Soul 风:图标网格代替长列表) --> <!-- 我的测评:最新测试横滑 + 功能图标网格Soul 风:图标网格代替长列表) -->
<view class="section px-section" wx:if="{{hasLogin}}"> <view class="section px-section" wx:if="{{hasLogin}}">
<view class="sec-head"> <view class="sec-head">
@@ -210,6 +243,12 @@
<!-- 分销/提现入口已上移至数据条下方promo-quick-bar --> <!-- 分销/提现入口已上移至数据条下方promo-quick-bar -->
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
<view class="bottom-safe"></view> <view class="bottom-safe"></view>
<custom-tab-bar /> <custom-tab-bar />
</view> </view>

View File

@@ -783,6 +783,106 @@ custom-tab-bar {
font-weight: 400; font-weight: 400;
} }
/* ===== 邀请码横条(与推广中心同源)===== */
.invite-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin: 8rpx 32rpx 16rpx;
padding: 22rpx 26rpx;
background: linear-gradient(135deg, #fdf2f8 0%, #fff 50%);
border-radius: 20rpx;
border: 1rpx solid #fbcfe8;
}
.invite-strip:active {
opacity: 0.92;
}
.invite-strip__text {
flex: 1;
min-width: 0;
}
.invite-strip__label {
display: block;
font-size: 22rpx;
color: #9d174d;
font-weight: 600;
margin-bottom: 6rpx;
}
.invite-strip__code {
display: block;
font-size: 32rpx;
font-weight: 800;
letter-spacing: 0.1em;
color: #0f172a;
}
.invite-strip__side {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.invite-strip__copy {
margin: 0;
padding: 0 22rpx;
height: 56rpx;
line-height: 56rpx;
font-size: 24rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #ff6b8a 0%, #ec4899 100%);
border-radius: 999rpx;
border: none;
}
.invite-strip__copy::after {
border: none;
}
.invite-strip__hint {
font-size: 20rpx;
color: #64748b;
}
.invite-strip__muted {
font-size: 26rpx;
font-weight: 600;
color: #94a3b8;
}
.invite-strip__err {
display: block;
font-size: 24rpx;
line-height: 1.4;
color: #b45309;
}
.invite-strip__retry-hint {
font-size: 22rpx;
color: #e11d48;
font-weight: 600;
}
.invite-fill-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 32rpx 12rpx;
padding: 20rpx 24rpx;
background: #fff;
border-radius: 16rpx;
border: 1rpx solid #e2e8f0;
box-shadow: 0 2rpx 12rpx rgba(15, 23, 42, 0.04);
}
.invite-fill-row:active {
opacity: 0.94;
}
.invite-fill-row__label {
font-size: 26rpx;
font-weight: 600;
color: #334155;
}
.invite-fill-row__arrow {
font-size: 32rpx;
color: #94a3b8;
}
/* ===== 小节标题(与数据条间距收紧)===== */ /* ===== 小节标题(与数据条间距收紧)===== */
.sec-head { .sec-head {
display: flex; display: flex;

View File

@@ -1,6 +1,8 @@
// pages/promo/index.js // pages/promo/index.js
const app = getApp() const app = getApp()
const { request } = require('../../utils/request') const { request } = require('../../utils/request')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
Page({ Page({
data: { data: {
@@ -30,7 +32,16 @@ Page({
withdrawAmountInput: '', withdrawAmountInput: '',
withdrawError: '', withdrawError: '',
withdrawFeeYuan: '0.00', withdrawFeeYuan: '0.00',
withdrawActualYuan: '0.00' withdrawActualYuan: '0.00',
myInviteCode: '',
myInviteCodeMeta: null,
/** loading | ready | error | nodata — 避免因接口失败整块不渲染 */
myInviteCodeLoadState: 'loading',
myInviteCodeErrMsg: '',
myInviteCodeHint: '',
showInviteCodeDialog: false,
/** 与结果页一致:审核/提审不展示邀请码相关块 */
hideInviteCodeEntry: false
}, },
onLoad() { onLoad() {
@@ -41,7 +52,10 @@ Page({
wx.showToast({ title: '请先登录后查看推广中心', icon: 'none' }) wx.showToast({ title: '请先登录后查看推广中心', icon: 'none' })
return return
} }
const hide = shouldHideInviteCodeEntry(app.globalData || {})
this.setData({ hideInviteCodeEntry: hide })
this.loadStats() this.loadStats()
if (!hide) this.loadMyInviteCode()
this.loadBindings(true) this.loadBindings(true)
}) })
.catch(() => { .catch(() => {
@@ -50,8 +64,104 @@ Page({
}, },
onShow() { onShow() {
const hide = shouldHideInviteCodeEntry(app.globalData || {})
this.setData({ hideInviteCodeEntry: hide })
const token = app.globalData.token || wx.getStorageSync('token') const token = app.globalData.token || wx.getStorageSync('token')
if (token) this.loadStats() if (token) {
this.loadStats()
if (!hide) this.loadMyInviteCode()
}
},
loadMyInviteCode() {
this.setData({
myInviteCodeLoadState: 'loading',
myInviteCodeErrMsg: ''
})
request({
url: '/api/distribution/my-invite-code',
method: 'GET',
success: (res) => {
const payload = res && res.data
const httpOk = res.statusCode >= 200 && res.statusCode < 300
const bizOk = payload && Number(payload.code) === 200
if (!httpOk || !bizOk) {
const msg =
(payload && payload.message) ||
(typeof res.statusCode === 'number' ? `请求失败 (${res.statusCode})` : '加载失败')
this.setData({
myInviteCode: '',
myInviteCodeMeta: null,
myInviteCodeLoadState: 'error',
myInviteCodeErrMsg: msg,
myInviteCodeHint: ''
})
return
}
const data = payload.data || {}
const raw = data.code != null ? String(data.code).trim() : ''
const code = raw ? raw.toUpperCase() : ''
const hint = (data.hint && String(data.hint).trim()) || ''
if (code) {
this.setData({
myInviteCode: code,
myInviteCodeMeta: data,
myInviteCodeLoadState: 'ready',
myInviteCodeErrMsg: '',
myInviteCodeHint: ''
})
} else {
this.setData({
myInviteCode: '',
myInviteCodeMeta: data,
myInviteCodeLoadState: 'nodata',
myInviteCodeErrMsg: '',
myInviteCodeHint: hint || '暂无可用邀请码'
})
}
},
fail: () => {
this.setData({
myInviteCode: '',
myInviteCodeMeta: null,
myInviteCodeLoadState: 'error',
myInviteCodeErrMsg: '网络异常,请稍后重试',
myInviteCodeHint: ''
})
}
})
},
retryMyInviteCode() {
this.loadMyInviteCode()
},
copyMyInviteCode() {
const code = (this.data.myInviteCode || '').trim()
if (!code) return
wx.setClipboardData({
data: code,
success: () => wx.showToast({ title: '已复制邀请码', icon: 'success' })
})
},
/** 填写他人邀请码(与结果页支付前弹框同一组件) */
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
}, },
/** 加载推广统计数据 */ /** 加载推广统计数据 */

View File

@@ -1,5 +1,8 @@
{ {
"navigationBarTitleText": "推广中心", "navigationBarTitleText": "推广中心",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black" "navigationBarTextStyle": "black",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -31,6 +31,40 @@
</view> </view>
</view> </view>
<block wx:if="{{!hideInviteCodeEntry}}">
<view class="my-invite-card">
<view class="my-invite-card__head">
<text class="my-invite-card__title">我的邀请码</text>
<view wx:if="{{myInviteCodeLoadState === 'ready' && myInviteCode}}" class="my-invite-card__copy" bindtap="copyMyInviteCode">复制</view>
<view wx:elif="{{myInviteCodeLoadState === 'error'}}" class="my-invite-card__retry" bindtap="retryMyInviteCode">重试</view>
</view>
<block wx:if="{{myInviteCodeLoadState === 'loading'}}">
<text class="my-invite-card__code my-invite-card__code--muted">加载中…</text>
</block>
<block wx:elif="{{myInviteCodeLoadState === 'error'}}">
<text class="my-invite-card__err">{{myInviteCodeErrMsg}}</text>
<text class="my-invite-card__tip">若提示功能未就绪,请在数据库执行 api/database/add_invite_codes.sql 建表后再试。</text>
</block>
<block wx:elif="{{myInviteCodeLoadState === 'ready' && myInviteCode}}">
<text class="my-invite-card__code" user-select="true">{{myInviteCode}}</text>
<text class="my-invite-card__tip">好友在付费解锁前填写此码,即可与你建立推广绑定</text>
</block>
<block wx:else>
<text class="my-invite-card__err">{{myInviteCodeHint}}</text>
<text wx:if="{{myInviteCodeLoadState === 'nodata'}}" class="my-invite-card__tip">你可稍后下拉刷新或联系管理员。</text>
</block>
</view>
<view class="invite-fill-entry" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 付费前也可在此填写</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
</block>
<!-- 自定义提现金额弹框 --> <!-- 自定义提现金额弹框 -->
<view class="withdraw-mask" wx:if="{{showWithdrawDialog}}"> <view class="withdraw-mask" wx:if="{{showWithdrawDialog}}">
<view class="withdraw-dialog"> <view class="withdraw-dialog">
@@ -216,4 +250,10 @@
</view> </view>
<view class="safe-bottom"></view> <view class="safe-bottom"></view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -19,6 +19,70 @@ page {
margin-bottom: 40rpx; margin-bottom: 40rpx;
} }
/* 我的邀请码 */
.my-invite-card {
background: #fff;
border-radius: 24rpx;
padding: 28rpx 32rpx;
margin-bottom: 32rpx;
border: 2rpx solid #fce7f3;
box-shadow: 0 4rpx 24rpx rgba(244, 63, 94, 0.06);
}
.my-invite-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.my-invite-card__title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.my-invite-card__copy {
font-size: 24rpx;
font-weight: 600;
color: #e11d48;
padding: 10rpx 24rpx;
background: #fff1f2;
border-radius: 999rpx;
}
.my-invite-card__code {
display: block;
font-size: 40rpx;
font-weight: 800;
letter-spacing: 0.12em;
color: #0f172a;
margin-bottom: 12rpx;
}
.my-invite-card__tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: #64748b;
}
.my-invite-card__code--muted {
font-size: 30rpx;
font-weight: 600;
letter-spacing: normal;
color: #94a3b8;
}
.my-invite-card__err {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: #b45309;
margin-bottom: 8rpx;
}
.my-invite-card__retry {
font-size: 24rpx;
font-weight: 600;
color: #e11d48;
padding: 10rpx 24rpx;
background: #fff1f2;
border-radius: 999rpx;
}
.hero-header-row { .hero-header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -8,6 +8,9 @@ const {
needsResultProfileGate, needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js') } = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { const {
slicePreviewText, slicePreviewText,
slicePreviewList, slicePreviewList,
@@ -79,7 +82,9 @@ Page({
{ id: 'sec-cta', label: '深度方案', emoji: '💎' } { id: 'sec-cta', label: '深度方案', emoji: '💎' }
], ],
scrollTarget: '', scrollTarget: '',
activeSection: '' activeSection: '',
showInviteCodeDialog: false,
hideInviteCodeEntry: false
}, },
onTapSectionNav(e) { onTapSectionNav(e) {
@@ -208,7 +213,11 @@ Page({
}, },
onShow() { onShow() {
this.setData({ hasPhone: hasPhone() }) const gd = app.globalData || {}
this.setData({
hasPhone: hasPhone(),
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
})
if (this.data.testResultId && this.data.result) { if (this.data.testResultId && this.data.result) {
this._syncDiscGate() this._syncDiscGate()
} else if (!this.data.testResultId) { } else if (!this.data.testResultId) {
@@ -280,8 +289,9 @@ Page({
onTapReadFull() { onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'disc' }) } catch (e) {} try { require('../../utils/analytics').track('tap_read_full', { type: 'disc' }) } catch (e) {}
if (this.data.profileGate) { if (this.data.profileGate) {
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
wx.showToast({ wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」', title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
icon: 'none' icon: 'none'
}) })
return return
@@ -356,22 +366,46 @@ Page({
try { require('../../utils/analytics').track('tap_unlock_full', { type: 'disc', amountYuan: payInfo.amountYuan }) } catch (e) {} try { require('../../utils/analytics').track('tap_unlock_full', { type: 'disc', amountYuan: payInfo.amountYuan }) } catch (e) {}
app.ensureLogin && app.ensureLogin().then((logged) => { app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return } if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchaseDiscTest({ unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
testResultId: testResultId || undefined, if (!ok) return
success: () => { inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' }) if (!go) return
this.setData({ 'payInfo.isPaid': true }) payment.purchaseDiscTest({
this._syncJourney() testResultId: testResultId || undefined,
if (testResultId && !hasReloadedAfterPay) { success: () => {
this.setData({ hasReloadedAfterPay: true }) wx.showToast({ title: '已解锁完整报告', icon: 'success' })
setTimeout(() => this.loadDetail(testResultId), 500) this.setData({ 'payInfo.isPaid': true })
} this._syncJourney()
}, if (testResultId && !hasReloadedAfterPay) {
fail: () => {} this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
}) })
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
retakeTest() { retakeTest() {
if (!this.data.testResultId) { if (!this.data.testResultId) {
wx.removeStorageSync('discResult') wx.removeStorageSync('discResult')

View File

@@ -1,5 +1,8 @@
{ {
"navigationBarTitleText": "DISC结果", "navigationBarTitleText": "DISC结果",
"navigationBarBackgroundColor": "#3B82F6", "navigationBarBackgroundColor": "#3B82F6",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -33,6 +33,15 @@
</view> </view>
</view> </view>
<view class="invite-fill-entry" wx:if="{{result && !fromShare && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 深度洞察 --> <!-- 深度洞察 -->
<block wx:if="{{discInsight}}"> <block wx:if="{{discInsight}}">
<view class="section-head" id="sec-insight"> <view class="section-head" id="sec-insight">
@@ -95,9 +104,11 @@
<text class="paywall-fake-line">• 职业匹配建议</text> <text class="paywall-fake-line">• 职业匹配建议</text>
</view> </view>
<view class="paywall-mask"></view> <view class="paywall-mask"></view>
<view class="paywall-btn" bindtap="unlockFullReport"> <view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
<text class="paywall-btn-main">解锁完整报告</text> <view class="paywall-btn" bindtap="unlockFullReport">
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -137,7 +148,7 @@
</block> </block>
<!-- 资料门禁预览 --> <!-- 资料门禁预览 -->
<view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}"> <view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}" id="unlock-gate-anchor" class="unlock-gate-profile-block">
<view class="preview-teaser-card" wx:if="{{previewDiscStrengths.length}}"> <view class="preview-teaser-card" wx:if="{{previewDiscStrengths.length}}">
<text class="preview-teaser-title">优势摘录(预览约 30%</text> <text class="preview-teaser-title">优势摘录(预览约 30%</text>
<view class="preview-strength-row" wx:for="{{previewDiscStrengths}}" wx:key="*this"> <view class="preview-strength-row" wx:for="{{previewDiscStrengths}}" wx:key="*this">
@@ -262,4 +273,10 @@
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button> <button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view> </view>
</view> </view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -7,6 +7,9 @@ const {
needsResultProfileGate, needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js') } = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { const {
slicePreviewText, slicePreviewText,
slicePreviewList, slicePreviewList,
@@ -71,7 +74,10 @@ Page({
{ id: 'sec-cta', label: '深度方案', emoji: '💎' } { id: 'sec-cta', label: '深度方案', emoji: '💎' }
], ],
scrollTarget: '', scrollTarget: '',
activeSection: '' activeSection: '',
showInviteCodeDialog: false,
/** 审核/提审时隐藏填写邀请码入口(与 runtime 开关同步) */
hideInviteCodeEntry: false
}, },
onTapSectionNav(e) { onTapSectionNav(e) {
@@ -118,7 +124,11 @@ Page({
}, },
onShow() { onShow() {
this.setData({ hasPhone: hasPhone() }) const gd = app.globalData || {}
this.setData({
hasPhone: hasPhone(),
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
})
if (this.data.testResultId && this.data.result) { if (this.data.testResultId && this.data.result) {
this.applyResult(this.data.result) this.applyResult(this.data.result)
} else if (!this.data.testResultId) { } else if (!this.data.testResultId) {
@@ -316,8 +326,9 @@ Page({
onTapReadFull() { onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'mbti' }) } catch (e) {} try { require('../../utils/analytics').track('tap_read_full', { type: 'mbti' }) } catch (e) {}
if (this.data.profileGate) { if (this.data.profileGate) {
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
wx.showToast({ wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」', title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
icon: 'none' icon: 'none'
}) })
return return
@@ -394,22 +405,46 @@ Page({
wx.showToast({ title: '请先登录', icon: 'none' }) wx.showToast({ title: '请先登录', icon: 'none' })
return return
} }
payment.purchaseMbtiTest({ unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
testResultId: testResultId || undefined, if (!ok) return
success: () => { inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' }) if (!go) return
this.setData({ 'payInfo.isPaid': true }) payment.purchaseMbtiTest({
this._syncJourney() testResultId: testResultId || undefined,
if (testResultId && !hasReloadedAfterPay) { success: () => {
this.setData({ hasReloadedAfterPay: true }) wx.showToast({ title: '已解锁完整报告', icon: 'success' })
setTimeout(() => this.loadDetail(testResultId), 500) this.setData({ 'payInfo.isPaid': true })
} this._syncJourney()
}, if (testResultId && !hasReloadedAfterPay) {
fail: () => {} this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
}) })
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
// 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport // 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport
onGetPhoneNumberForMbtiPay(e) { onGetPhoneNumberForMbtiPay(e) {
const { code, errMsg } = e.detail || {} const { code, errMsg } = e.detail || {}

View File

@@ -1,5 +1,8 @@
{ {
"navigationBarTitleText": "MBTI结果", "navigationBarTitleText": "MBTI结果",
"navigationBarBackgroundColor": "#FF6B8A", "navigationBarBackgroundColor": "#FF6B8A",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -32,6 +32,15 @@
</view> </view>
</view> </view>
<view class="invite-fill-entry" wx:if="{{result && !fromShare && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 深度洞察三宫格 --> <!-- 深度洞察三宫格 -->
<block wx:if="{{mbtiInsight}}"> <block wx:if="{{mbtiInsight}}">
<view class="section-head" id="sec-insight"> <view class="section-head" id="sec-insight">
@@ -94,23 +103,25 @@
<text class="paywall-fake-line">• 职业匹配与人际关系建议</text> <text class="paywall-fake-line">• 职业匹配与人际关系建议</text>
</view> </view>
<view class="paywall-mask"></view> <view class="paywall-mask"></view>
<button <view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
class="paywall-btn" <button
wx:if="{{!hasPhone}}" class="paywall-btn"
open-type="getPhoneNumber" wx:if="{{!hasPhone}}"
bindgetphonenumber="onGetPhoneNumberForMbtiPay" open-type="getPhoneNumber"
> bindgetphonenumber="onGetPhoneNumberForMbtiPay"
<text class="paywall-btn-main">解锁完整报告</text> >
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
</button> <text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
<button </button>
class="paywall-btn" <button
wx:elif="{{hasPhone}}" class="paywall-btn"
bindtap="unlockFullReport" wx:elif="{{hasPhone}}"
> bindtap="unlockFullReport"
<text class="paywall-btn-main">解锁完整报告</text> >
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
</button> <text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -154,7 +165,7 @@
</block> </block>
<!-- 资料门禁:预览摘要 --> <!-- 资料门禁:预览摘要 -->
<view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}"> <view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}" id="unlock-gate-anchor" class="unlock-gate-profile-block">
<view class="preview-teaser-card" wx:if="{{previewStrengths.length}}"> <view class="preview-teaser-card" wx:if="{{previewStrengths.length}}">
<text class="preview-teaser-title">优势摘录(预览约 30%</text> <text class="preview-teaser-title">优势摘录(预览约 30%</text>
<view class="preview-strength-row" wx:for="{{previewStrengths}}" wx:key="*this"> <view class="preview-strength-row" wx:for="{{previewStrengths}}" wx:key="*this">
@@ -319,4 +330,10 @@
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button> <button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view> </view>
</view> </view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -1,4 +1,4 @@
/* pages/result/mbti.wxssdash 样式见 app.wxss */ /* pages/result/mbti.wxssdash 样式见 app.wxss;邀请码入口样式见 app.wxss → invite-fill-entry */
.result-page { .result-page {
width: 100%; width: 100%;

View File

@@ -8,6 +8,9 @@ const {
needsResultProfileGate, needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js') } = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { const {
slicePreviewText, slicePreviewText,
slicePreviewList, slicePreviewList,
@@ -79,7 +82,9 @@ Page({
{ id: 'sec-cta', label: '深度方案', emoji: '💎' } { id: 'sec-cta', label: '深度方案', emoji: '💎' }
], ],
scrollTarget: '', scrollTarget: '',
activeSection: '' activeSection: '',
showInviteCodeDialog: false,
hideInviteCodeEntry: false
}, },
onTapSectionNav(e) { onTapSectionNav(e) {
@@ -209,7 +214,11 @@ Page({
}, },
onShow() { onShow() {
this.setData({ hasPhone: hasPhone() }) const gd = app.globalData || {}
this.setData({
hasPhone: hasPhone(),
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
})
if (this.data.testResultId && this.data.result) { if (this.data.testResultId && this.data.result) {
this._syncPdpGate() this._syncPdpGate()
} else if (!this.data.testResultId) { } else if (!this.data.testResultId) {
@@ -285,8 +294,9 @@ Page({
onTapReadFull() { onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'pdp' }) } catch (e) {} try { require('../../utils/analytics').track('tap_read_full', { type: 'pdp' }) } catch (e) {}
if (this.data.profileGate) { if (this.data.profileGate) {
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
wx.showToast({ wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」', title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
icon: 'none' icon: 'none'
}) })
return return
@@ -361,22 +371,46 @@ Page({
try { require('../../utils/analytics').track('tap_unlock_full', { type: 'pdp', amountYuan: payInfo.amountYuan }) } catch (e) {} try { require('../../utils/analytics').track('tap_unlock_full', { type: 'pdp', amountYuan: payInfo.amountYuan }) } catch (e) {}
app.ensureLogin && app.ensureLogin().then((logged) => { app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return } if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchasePdpTest({ unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
testResultId: testResultId || undefined, if (!ok) return
success: () => { inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' }) if (!go) return
this.setData({ 'payInfo.isPaid': true }) payment.purchasePdpTest({
this._syncJourney() testResultId: testResultId || undefined,
if (testResultId && !hasReloadedAfterPay) { success: () => {
this.setData({ hasReloadedAfterPay: true }) wx.showToast({ title: '已解锁完整报告', icon: 'success' })
setTimeout(() => this.loadDetail(testResultId), 500) this.setData({ 'payInfo.isPaid': true })
} this._syncJourney()
}, if (testResultId && !hasReloadedAfterPay) {
fail: () => {} this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
}) })
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
retakeTest() { retakeTest() {
if (!this.data.testResultId) { if (!this.data.testResultId) {
wx.removeStorageSync('pdpResult') wx.removeStorageSync('pdpResult')

View File

@@ -1,5 +1,8 @@
{ {
"navigationBarTitleText": "PDP结果", "navigationBarTitleText": "PDP结果",
"navigationBarBackgroundColor": "#F59E0B", "navigationBarBackgroundColor": "#F59E0B",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -33,6 +33,15 @@
</view> </view>
</view> </view>
<view class="invite-fill-entry" wx:if="{{result && !fromShare && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 深度洞察 --> <!-- 深度洞察 -->
<block wx:if="{{pdpInsight}}"> <block wx:if="{{pdpInsight}}">
<view class="section-head" id="sec-insight"> <view class="section-head" id="sec-insight">
@@ -95,9 +104,11 @@
<text class="paywall-fake-line">• 推荐职业</text> <text class="paywall-fake-line">• 推荐职业</text>
</view> </view>
<view class="paywall-mask"></view> <view class="paywall-mask"></view>
<view class="paywall-btn" bindtap="unlockFullReport"> <view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
<text class="paywall-btn-main">解锁完整报告</text> <view class="paywall-btn" bindtap="unlockFullReport">
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -137,7 +148,7 @@
</block> </block>
<!-- 资料门禁预览 --> <!-- 资料门禁预览 -->
<view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}"> <view wx:if="{{profileGate && result && (!payInfo.requiresPayment || payInfo.isPaid)}}" id="unlock-gate-anchor" class="unlock-gate-profile-block">
<view class="preview-teaser-card" wx:if="{{previewPdpStrengths.length}}"> <view class="preview-teaser-card" wx:if="{{previewPdpStrengths.length}}">
<text class="preview-teaser-title">优势摘录(预览约 30%</text> <text class="preview-teaser-title">优势摘录(预览约 30%</text>
<view class="preview-strength-row" wx:for="{{previewPdpStrengths}}" wx:key="*this"> <view class="preview-strength-row" wx:for="{{previewPdpStrengths}}" wx:key="*this">
@@ -275,4 +286,10 @@
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button> <button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view> </view>
</view> </view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -1,6 +1,8 @@
// pages/result/resume.js - 简历综合分析结果页 // pages/result/resume.js - 简历综合分析结果页
const app = getApp() const app = getApp()
const payment = require('../../utils/payment') const payment = require('../../utils/payment')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js') const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
const { triggerTestResultCompleted } = require('../../utils/pushHook') const { triggerTestResultCompleted } = require('../../utils/pushHook')
@@ -23,7 +25,9 @@ Page({
}, },
testResultId: 0, testResultId: 0,
paying: false, paying: false,
profileGate: false profileGate: false,
showInviteCodeDialog: false,
hideInviteCodeEntry: false
}, },
onLoad(options) { onLoad(options) {
@@ -56,6 +60,8 @@ Page({
}, },
onShow() { onShow() {
const gd = app.globalData || {}
this.setData({ hideInviteCodeEntry: shouldHideInviteCodeEntry(gd) })
const id = this.data.testResultId const id = this.data.testResultId
if (id && this.data.profileGate) { if (id && this.data.profileGate) {
this.loadFromHistory(String(id)) this.loadFromHistory(String(id))
@@ -246,24 +252,45 @@ Page({
// 支付解锁 // 支付解锁
doPay() { doPay() {
if (this.data.paying) return if (this.data.paying) return
this.setData({ paying: true }) inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
const testResultId = this.data.testResultId || 0 if (!go) return
payment.purchaseResumeAnalysis({ this.setData({ paying: true })
testResultId: testResultId > 0 ? testResultId : undefined, const testResultId = this.data.testResultId || 0
success: () => { payment.purchaseResumeAnalysis({
this.setData({ paying: false, 'payInfo.requiresPayment': false, 'payInfo.isPaid': true }) testResultId: testResultId > 0 ? testResultId : undefined,
// 支付成功后重新拉取完整内容 success: () => {
if (testResultId > 0) { this.setData({ paying: false, 'payInfo.requiresPayment': false, 'payInfo.isPaid': true })
this.loadFromHistory(testResultId) // 支付成功后重新拉取完整内容
if (testResultId > 0) {
this.loadFromHistory(testResultId)
}
},
fail: (err) => {
this.setData({ paying: false })
wx.showToast({ title: (err && err.message) || '支付失败,请重试', icon: 'none' })
} }
}, })
fail: (err) => {
this.setData({ paying: false })
wx.showToast({ title: (err && err.message) || '支付失败,请重试', icon: 'none' })
}
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
goHome() { goHome() {
const scope = (app.globalData && app.globalData.appScope) || 'personal' const scope = (app.globalData && app.globalData.appScope) || 'personal'
if (scope === 'enterprise') { if (scope === 'enterprise') {

View File

@@ -1,4 +1,7 @@
{ {
"navigationBarTitleText": "简历综合分析" "navigationBarTitleText": "简历综合分析",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -27,6 +27,15 @@
</view> </view>
</view> </view>
<view class="invite-fill-entry invite-fill-entry--resume" wx:if="{{!error && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 异常状态 --> <!-- 异常状态 -->
<view class="card error-card" wx:if="{{error}}"> <view class="card error-card" wx:if="{{error}}">
<text class="card-title error-title">失败原因</text> <text class="card-title error-title">失败原因</text>
@@ -92,5 +101,11 @@
</view> </view>
</view> </view>
</block> </block>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -7,6 +7,9 @@ const {
needsResultProfileGate, needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js') } = require('../../utils/phoneAuth.js')
const unlockGate = require('../../utils/unlockGate.js')
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
const { const {
slicePreviewText, slicePreviewText,
openTimelineShareHint openTimelineShareHint
@@ -82,7 +85,9 @@ Page({
{ id: 'sec-cta', label: '深度方案', emoji: '💎' } { id: 'sec-cta', label: '深度方案', emoji: '💎' }
], ],
scrollTarget: '', scrollTarget: '',
activeSection: '' activeSection: '',
showInviteCodeDialog: false,
hideInviteCodeEntry: false
}, },
onTapSectionNav(e) { onTapSectionNav(e) {
@@ -141,7 +146,11 @@ Page({
}, },
onShow() { onShow() {
this.setData({ hasPhone: hasPhone() }) const gd = app.globalData || {}
this.setData({
hasPhone: hasPhone(),
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
})
if (this.data.testResultId && this.data.result) { if (this.data.testResultId && this.data.result) {
this.applyResult(this.data.result) this.applyResult(this.data.result)
} else if (!this.data.testResultId) { } else if (!this.data.testResultId) {
@@ -304,8 +313,9 @@ Page({
onTapReadFull() { onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'sbti' }) } catch (e) {} try { require('../../utils/analytics').track('tap_read_full', { type: 'sbti' }) } catch (e) {}
if (this.data.profileGate) { if (this.data.profileGate) {
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
wx.showToast({ wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」', title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
icon: 'none' icon: 'none'
}) })
return return
@@ -390,22 +400,46 @@ Page({
wx.showToast({ title: '请先登录', icon: 'none' }) wx.showToast({ title: '请先登录', icon: 'none' })
return return
} }
payment.purchaseSbtiTest({ unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
testResultId: testResultId || undefined, if (!ok) return
success: () => { inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' }) if (!go) return
this.setData({ 'payInfo.isPaid': true }) payment.purchaseSbtiTest({
this._syncJourney() testResultId: testResultId || undefined,
if (testResultId && !hasReloadedAfterPay) { success: () => {
this.setData({ hasReloadedAfterPay: true }) wx.showToast({ title: '已解锁完整报告', icon: 'success' })
setTimeout(() => this.loadDetail(testResultId), 500) this.setData({ 'payInfo.isPaid': true })
} this._syncJourney()
}, if (testResultId && !hasReloadedAfterPay) {
fail: () => {} this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
}) })
}) })
}, },
openInviteCodeFill() {
app.ensureLogin().then((ok) => {
if (!ok) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
inviteCodeGate.openInviteCodeDialog(this)
})
},
onInviteCodeSkip() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onInviteCodeSuccess() {
inviteCodeGate.finishInviteCodeGate(this, true)
},
onGetPhoneNumberForSbtiPay(e) { onGetPhoneNumberForSbtiPay(e) {
const { code, errMsg } = e.detail || {} const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {

View File

@@ -2,5 +2,8 @@
"navigationBarTitleText": "SBTI结果", "navigationBarTitleText": "SBTI结果",
"navigationBarBackgroundColor": "#f2f7f3", "navigationBarBackgroundColor": "#f2f7f3",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"backgroundColor": "#f2f7f3" "backgroundColor": "#f2f7f3",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
} }

View File

@@ -33,6 +33,15 @@
</view> </view>
</view> </view>
<view class="invite-fill-entry" wx:if="{{result && !fromShare && !hideInviteCodeEntry}}" bindtap="openInviteCodeFill">
<text class="invite-fill-entry__icon">🔗</text>
<view class="invite-fill-entry__main">
<text class="invite-fill-entry__title">填写好友邀请码</text>
<text class="invite-fill-entry__sub">绑定推广关系 · 可选</text>
</view>
<text class="invite-fill-entry__arrow"></text>
</view>
<!-- 类型插画(若有) --> <!-- 类型插画(若有) -->
<view class="card main-type-card" wx:if="{{typeImageUrl && !typeImageLoadFailed}}"> <view class="card main-type-card" wx:if="{{typeImageUrl && !typeImageLoadFailed}}">
<image <image
@@ -54,23 +63,25 @@
<text class="paywall-fake-line">• 匹配度与兜底说明</text> <text class="paywall-fake-line">• 匹配度与兜底说明</text>
</view> </view>
<view class="paywall-mask"></view> <view class="paywall-mask"></view>
<button <view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
class="paywall-btn" <button
wx:if="{{!hasPhone}}" class="paywall-btn"
open-type="getPhoneNumber" wx:if="{{!hasPhone}}"
bindgetphonenumber="onGetPhoneNumberForSbtiPay" open-type="getPhoneNumber"
> bindgetphonenumber="onGetPhoneNumberForSbtiPay"
<text class="paywall-btn-main">解锁完整报告</text> >
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
</button> <text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
<button </button>
class="paywall-btn" <button
wx:elif="{{hasPhone}}" class="paywall-btn"
bindtap="unlockFullReport" wx:elif="{{hasPhone}}"
> bindtap="unlockFullReport"
<text class="paywall-btn-main">解锁完整报告</text> >
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text> <text class="paywall-btn-main">解锁完整报告</text>
</button> <text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
</view>
</view> </view>
</view> </view>
@@ -140,7 +151,7 @@
</view> </view>
</block> </block>
<view wx:if="{{profileGate}}" class="card profile-gate-cta-card"> <view wx:if="{{profileGate}}" id="unlock-gate-anchor" class="card profile-gate-cta-card">
<text class="preview-teaser-hint sbti-gate-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text> <text class="preview-teaser-hint sbti-gate-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text>
<button <button
wx:if="{{!hasPhone}}" wx:if="{{!hasPhone}}"
@@ -216,4 +227,10 @@
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button> <button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view> </view>
</view> </view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view> </view>

View File

@@ -20,7 +20,16 @@ Page({
saving: false saving: false
}, },
onLoad() { onLoad(options) {
const raw = options && options.redirect ? String(options.redirect) : ''
if (raw) {
try {
const redirect = decodeURIComponent(raw)
if (redirect.charAt(0) === '/') {
app.globalData._navigateAfterProfile = redirect
}
} catch (e) {}
}
this.loadUserInfo() this.loadUserInfo()
}, },

View File

@@ -0,0 +1,52 @@
/* 填写好友邀请码入口(推广中心与各结果页共用) */
.invite-fill-entry {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 32rpx;
padding: 26rpx 28rpx;
background: #f8fafc;
border-radius: 20rpx;
border: 1rpx solid #e2e8f0;
box-sizing: border-box;
}
.invite-fill-entry:active {
opacity: 0.92;
}
.invite-fill-entry__icon {
font-size: 40rpx;
line-height: 1;
}
.invite-fill-entry__main {
flex: 1;
min-width: 0;
}
.invite-fill-entry__title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #0f172a;
}
.invite-fill-entry__sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #64748b;
line-height: 1.35;
}
.invite-fill-entry__arrow {
font-size: 36rpx;
color: #94a3b8;
font-weight: 300;
}
/* 面相结果页 container 无 scroll 内边距时用 */
.invite-fill-entry--face {
margin-left: 32rpx;
margin-right: 32rpx;
}
/* 简历结果页 hero 下方 */
.invite-fill-entry--resume {
margin: 0 32rpx 24rpx;
}

View File

@@ -0,0 +1,87 @@
/**
* 支付前邀请码弹框闸门(与开发文档 §3 一致)
*/
const { shouldHideInviteCodeEntry } = require('./miniprogramAuditGate.js')
const STORAGE_BOUND = 'inviteCodeBound'
const STORAGE_SKIP = 'inviteCodeSkipped'
function shouldPromptInviteCode() {
try {
if (wx.getStorageSync(STORAGE_BOUND)) return false
if (wx.getStorageSync(STORAGE_SKIP)) return false
} catch (e) {}
return true
}
function markInviteSkipped() {
try {
wx.setStorageSync(STORAGE_SKIP, 1)
} catch (e) {}
}
function markInviteBound() {
try {
wx.setStorageSync(STORAGE_BOUND, 1)
} catch (e) {}
}
/**
* @param {WechatMiniprogram.Page.TrivialInstance | null} page
* @returns {Promise<boolean>} true 表示可继续发起支付
*/
function ensureInviteCodeGate(page) {
return new Promise((resolve) => {
try {
const app = getApp()
if (shouldHideInviteCodeEntry(app && app.globalData)) {
resolve(true)
return
}
} catch (e) {}
if (!shouldPromptInviteCode()) {
resolve(true)
return
}
if (!page || typeof page.setData !== 'function') {
resolve(true)
return
}
page._inviteCodeGateResolve = resolve
page.setData({ showInviteCodeDialog: true })
})
}
/**
* @param {WechatMiniprogram.Page.TrivialInstance | null} page
* @param {boolean} canProceed
*/
function finishInviteCodeGate(page, canProceed) {
if (page && typeof page.setData === 'function') {
page.setData({ showInviteCodeDialog: false })
}
const fn = page && page._inviteCodeGateResolve
if (page) {
page._inviteCodeGateResolve = null
}
if (typeof fn === 'function') {
fn(canProceed !== false)
}
}
/** 非支付闸门:从推广中心 / 我的 等入口主动打开填写弹框 */
function openInviteCodeDialog(page) {
if (!page || typeof page.setData !== 'function') return
page.setData({ showInviteCodeDialog: true })
}
module.exports = {
shouldPromptInviteCode,
markInviteSkipped,
markInviteBound,
ensureInviteCodeGate,
finishInviteCodeGate,
openInviteCodeDialog,
STORAGE_BOUND,
STORAGE_SKIP
}

View File

@@ -32,6 +32,16 @@ function isAuditHideAiMode(gd) {
return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode) return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode)
} }
/**
* 隐藏「填写邀请码 / 展示我的邀请码」等分销向入口(与「我的」页推广区策略一致:
* reviewMode / maintenanceMode / miniprogramAuditMode 任一开启即隐藏)
*/
function shouldHideInviteCodeEntry(gd) {
if (TEMP_FORCE_SHOW_ALL_HIDDEN_UI) return false
if (!gd) return false
return !!(gd.reviewMode || gd.maintenanceMode || gd.miniprogramAuditMode)
}
function redirectIfMiniprogramAudit(message) { function redirectIfMiniprogramAudit(message) {
const app = getApp() const app = getApp()
const gd = app && app.globalData const gd = app && app.globalData
@@ -67,6 +77,7 @@ module.exports = {
applyAuditUiOverride, applyAuditUiOverride,
shouldHideTabBarHighlightFab, shouldHideTabBarHighlightFab,
isAuditHideAiMode, isAuditHideAiMode,
shouldHideInviteCodeEntry,
redirectIfMiniprogramAudit, redirectIfMiniprogramAudit,
ensureRuntimeThenGate ensureRuntimeThenGate
} }

View File

@@ -0,0 +1,110 @@
/**
* 解锁完整报告前的留资门禁先手机号再与报告一致的资料齐isReportProfileComplete
*/
const phoneAuth = require('./phoneAuth.js')
/** scroll-view 的 scroll-into-view 目标:包住解锁/手机号按钮(勿用列表末尾 tail否则付费墙会被滚出视野 */
const GATE_ANCHOR_ID = 'unlock-gate-anchor'
/** 整页滚动时兜底:可选节点 */
const SCROLL_TAIL_ID = 'unlock-scroll-tail'
function getCurrentPathForRedirect() {
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
if (!cur || !cur.route) return '/pages/index/index'
let path = '/' + cur.route
const opts = cur.options || {}
const keys = Object.keys(opts)
if (keys.length === 0) return path
const q = keys
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(opts[k] == null ? '' : String(opts[k]))}`)
.join('&')
return `${path}?${q}`
}
function scrollToUnlockAnchor(page, opt) {
const scrollIntoView = !!(opt && opt.scrollIntoView)
if (scrollIntoView && page && typeof page.setData === 'function') {
const key = (opt && opt.scrollTargetDataKey) || 'scrollTarget'
const targetId = (opt && opt.scrollIntoViewTargetId) || GATE_ANCHOR_ID
page.setData({ [key]: '' }, () => {
wx.nextTick(() => {
page.setData({ [key]: targetId })
})
})
return
}
const customSelector = opt && opt.anchorSelector
wx.nextTick(() => {
const query =
page && typeof page.createSelectorQuery === 'function'
? page.createSelectorQuery()
: wx.createSelectorQuery()
if (customSelector) {
query.select(customSelector).boundingClientRect()
} else {
query.select('#unlock-gate-anchor').boundingClientRect()
query.select(`#${SCROLL_TAIL_ID}`).boundingClientRect()
}
query.selectViewport().scrollOffset()
query.exec((res) => {
const viewport = res && res[res.length - 1]
let rect = null
if (customSelector) {
rect = res && res[0]
} else {
const gate = res && res[0]
const tail = res && res[1]
const gateOk = gate && typeof gate.top === 'number' && gate.width > 0 && gate.height >= 0
const tailOk = tail && typeof tail.top === 'number' && tail.width > 0 && tail.height >= 0
rect = gateOk ? gate : tailOk ? tail : null
}
if (rect && viewport && typeof rect.top === 'number') {
const scrollTop = rect.top + viewport.scrollTop - 80
wx.pageScrollTo({
scrollTop: Math.max(0, scrollTop),
duration: 280
})
} else {
wx.pageScrollTo({ scrollTop: 99999, duration: 280 })
}
})
})
}
/**
* @param {WechatMiniprogram.Page.TrivialInstance} page
* @param {object} [opt]
* @param {boolean} [opt.scrollIntoView] 结果页用 scroll-view 时为 true
* @param {string} [opt.anchorId]
* @param {string} [opt.anchorSelector] 整页滚动时用 #id
* @param {string} [opt.scrollTargetDataKey]
* @returns {Promise<boolean>}
*/
function ensureUnlockPrerequisitesBeforePay(page, opt) {
if (phoneAuth.hasPhone() && phoneAuth.isReportProfileComplete()) {
return Promise.resolve(true)
}
if (!phoneAuth.hasPhone()) {
scrollToUnlockAnchor(page, opt)
wx.showToast({ title: '请滑动到解锁区完成手机号授权', icon: 'none' })
return Promise.resolve(false)
}
const redirect = getCurrentPathForRedirect()
const url = `/pages/user-profile/index?from=unlock&redirect=${encodeURIComponent(redirect)}`
wx.showToast({ title: '请先完善头像与昵称', icon: 'none' })
wx.navigateTo({
url,
fail: () => {
wx.showToast({ title: '无法打开资料页', icon: 'none' })
}
})
return Promise.resolve(false)
}
module.exports = {
ensureUnlockPrerequisitesBeforePay,
scrollToUnlockAnchor,
getCurrentPathForRedirect
}

View File

@@ -0,0 +1,141 @@
# 小程序:解锁全文引导 + 邀请码弹框 — 开发方案
> 状态:§一~二已落地;§三邀请码弹框 **已开发**(支付前弹窗 + `POST /api/distribution/bind` 扩展 `inviteCode`
> 范围:微信小程序(`miniprogram/`);抖音小程序(`douyin-miniprogram/`)需同步时按同方案复制实现。
> 关联页面:面相/分析结果 `pages/index/result`;个人资料 `pages/user-profile/index`;支付与登录 `utils/payment.js`、`app.js`。
---
## 一、需求概述
### 1.1 解锁完整报告(或「解锁全文本」)点击后的分流
用户完成测试(当前以**人脸面相结果页**付费解锁为主场景,见 `pages/index/result``onTapUnlockPay` / `unlockFullReport`)后,点击**解锁**类按钮时,在**未满足留资条件**的情况下不再直接进入支付或仅 Toast而是
| 条件 | 行为 |
|------|------|
| **① 未绑定手机号** | 将页面**滚动到最底部**(或滚动到页面内「授权手机号」引导区域),让用户一眼看到授权入口并完成绑定。 |
| **② 已绑定手机号,但个人资料未完善** | **跳转**到个人资料页 `pages/user-profile/index`,引导补全头像、昵称等(与现有「去完善资料」一致)。 |
> 说明:当前 `utils/phoneAuth.js` 中 `isProfileComplete()` 在「已有手机号」时直接返回 `true`,与「报告门禁」用的 `isReportProfileComplete()`(昵称+头像+手机均必填)不一致。本需求若强调「有手机仍要补资料」,实现时应**统一以 `isReportProfileComplete()`(或与后端 `Test::isWechatProfileComplete` 对齐的封装)** 作为「资料是否完善」的判断来源,避免与结果页绿色引导条逻辑冲突。
### 1.2 测试流程中增加「填写邀请码」弹框
在合适的时机弹出**邀请码输入框**Modal / 自定义弹层),用户可填写邀请码并提交;用于推广归因、企业活动或后续与分销绑定逻辑对接。
---
## 二、功能一:解锁点击后的引导(详细设计)
### 2.1 交互流程(建议)
1. 用户点击「解锁完整报告」等按钮(入口:`onTapUnlockPay` 或统一封装的 `beforeUnlock()`)。
2. 校验登录(沿用现有 `app.ensureLogin()`)。
3. **分支判断**(顺序建议:先手机,再资料,最后进入支付):
-`!hasPhone()`
- 调用 `wx.pageScrollTo` **或**`scroll-view` 上使用 `scroll-into-view` / `scroll-top` 滚动到页面底部锚点(见 2.2)。
- 可选:`wx.showToast({ title: '请先在页面底部授权手机号', icon: 'none' })`
- **不发起** `payment.purchaseFaceTest`(或按产品决定:仍允许先支付再在支付后引导绑手机——需与产品确认;本文按「先满足留资再支付」描述)。
-`hasPhone() && !isReportProfileComplete()`(或产品确认的资料规则):
- `wx.navigateTo({ url: '/pages/user-profile/index' })`
- 可选:带 query `?from=unlock&redirect=/pages/index/result` 便于资料保存后返回(需结果页 `onShow` 刷新状态)。
- 若均满足:进入现有 `unlockFullReport()``payment.purchaseFaceTest(...)`
### 2.2 「滚动到最下面」实现要点
- **若整页为普通 `view` 堆叠**:使用 `wx.createSelectorQuery()` 取底部锚点 `id="unlock-anchor"``boundingClientRect`,结合 `wx.pageScrollTo({ scrollTop })`(注意累加 `scrollTop` + `node.top` 的换算)。
- **若外层为 `scroll-view`**:需设置 `scroll-y``scroll-top` 数据绑定,将 `scrollTop` 设为较大值或计算出的内容高度;或使用 `scroll-into-view="{{scrollInto}}"` 绑定底部子元素 `id`
- 锚点建议放在**与 `post-pay-guide` 同区域或紧贴付费墙下方`**,与现有「授权手机号」按钮(`open-type="getPhoneNumber"`)同一视觉区域,避免用户滚动后仍找不到按钮。
### 2.3 与现有 UI 的关系
- 结果页已有支付后引导条:`post-pay-guide``result.wxml` 约 4760 行),含「授权手机号」「去完善资料」。
- 需求①本质是**无手机号时把用户视线带到该区域**;需求②与现有 `goToCompleteProfile()` 一致,可复用。
- 需在**点击解锁**路径上增加上述判断,避免与「已付费仅差手机」场景重复冲突(`payInfo.isPaid` 为 true 时仍以现有 `post-pay-guide` 为主)。
### 2.4 其他结果页扩展(可选)
MBTI / DISC / PDP / SBTI / 简历等结果页若也有「解锁完整报告」按钮(如 `pages/result/*.wxml``paywall-btn`),建议抽成**公共方法**(如 `utils/unlockGate.js``handleUnlockClick({ page, hasPhone, isProfileComplete, onPay })`),避免复制粘贴。
### 2.5 验收标准
- 未登录:仍提示先登录。
- 无手机号:点击解锁后页面自动滚动,底部「授权手机号」可见;不误触支付(除非产品改为先付后绑)。
- 有手机号、资料不全:进入个人资料页;保存后返回结果页能继续解锁流程。
- 手机+资料均齐:行为与线上一致,可正常支付解锁。
---
## 三、功能二:填写邀请码弹框(详细设计)
### 3.1 产品需确认项(落地前评审)
| 项 | 选项 |
|----|------|
| 弹出时机 | A进入首页/相机前一次B每次开始测试前C登录成功后首次D支付前 |
| 是否强制 | 可跳过 / 必须填写(企业活动可能强制) |
| 邀请码形态 | 纯数字 / 字母数字 / 与现有分销 `inviterId` 或企业 scene 解析并存 |
| 与后端关系 | 新接口「校验+绑定」 vs 扩展现有 `POST /api/distribution/bind`(当前为 `inviterId` + `eid` |
### 3.2 前端实现(已实现)
- **组件**`miniprogram/components/invite-code-dialog/`(属性 `visible`,事件 `skip` / `success`)。
- **闸门**`miniprogram/utils/inviteCodeGate.js``ensureInviteCodeGate(page)`**`unlockGate` 通过之后、`payment.purchase*` 之前** 弹出(对应产品选项 **D支付前**);「暂不填写」写入 `inviteCodeSkipped`,成功后写入 `inviteCodeBound`,二者任一存在则不再弹窗。
- **接入页面**:面相结果 `pages/index/result`,问卷结果 `pages/result/mbti|disc|pdp|sbti`,简历 `pages/result/resume``doPay`)。
- **登录顺序**:仅在已有登录态绑码(解锁路径已先 `ensureLogin`)。
### 3.3 后端接口(已实现:方案 B
扩展 **`POST /api/distribution/bind`**
- 新增可选参数 **`inviteCode`**(字符串);与原有 `inviterId` / `eid` 并存:填码时由服务端解析出 `inviterId` 再走既有分销绑定逻辑。
- 邀请码表:**`api/database/add_invite_codes.sql`**`mbti_invite_codes`,执行前核对库表前缀与 `api/config/database.php``prefix`)。
- 校验失败:`400` + 文案「邀请码无效或已失效」。鉴权JWT 必填。
(若后续需独立「活动报名」语义,仍可再拆 `POST /api/invite/claim`。)
### 3.4 安全与幂等
- 邀请码错误:明确错误文案,不计入绑定。
- 同一用户重复提交同一码:返回成功幂等或提示「已绑定」。
- 限流防刷接口IP + userId 频率限制)。
### 3.5 验收标准
- 弹框可输入、取消、确认(若允许跳过则跳过不阻塞主流程)。
- 确认后调用接口成功有反馈;失败有 Toast。
- 与分销/企业后台数据一致(以最终表结构为准)。
---
## 四、任务拆分(供排期)
| 序号 | 任务 | 备注 |
|------|------|------|
| T1 | 统一「资料是否完善」判定 | `phoneAuth` 导出与报告一致的方法,结果页与解锁逻辑共用 |
| T2 | 结果页 `onTapUnlockPay` / `unlockFullReport` 前置分支 | 滚动锚点 + `navigateTo` 个人资料 |
| T3 | (可选)抽象 `unlockGate` 工具 | 多结果页复用 |
| T4 | 邀请码弹框 UI 组件 + 全局/页面触发点 | ✅ 支付前;见 §3.2 |
| T5 | 后端邀请码校验与绑定接口 | ✅ `distribution/bind` + `invite_codes` 表 |
| T6 | 抖音小程序对齐 | 复制逻辑与 API |
---
## 五、相关文件索引(当前仓库)
| 路径 | 说明 |
|------|------|
| `miniprogram/pages/index/result.js` | `onTapUnlockPay``unlockFullReport``goToCompleteProfile` |
| `miniprogram/pages/index/result.wxml` | `post-pay-guide`、付费墙 `onTapUnlockPay` |
| `miniprogram/utils/phoneAuth.js` | `hasPhone``isProfileComplete``isReportProfileComplete``bindPhoneByCode` |
| `miniprogram/app.js` | 登录、`saveTestResult`、可挂邀请码 pending |
| `api/app/controller/api/Distribution.php` | `bind` 等分销绑定 |
| `api/route/api.php` | 路由注册(新接口需追加) |
---
## 六、修订记录
| 日期 | 版本 | 说明 |
|------|------|------|
| 2026-04-15 | v0.1 | 初稿:解锁分流 + 邀请码弹框需求与实现要点 |