微信小程序:自定义底栏接入 AI 入口(含图标与样式)、AI 聊天相关页面与历史/报告等能力;审核模式与配置联动;资料/企业/首页/授权等页面与 app 入口配合调整。
后端 API:AI 对话/报告/余额告警 等服务与路由;小程序 tabBar 配置与分润相关能力;出站推送 Webhook 与管理端调试;飞书留资、小程序数据分析 等支撑;多份 数据库迁移(AI 聊天、灵魂文章、tabBar、分润等)。 管理端 / 超管:看板、用户/订单/分销/财务、设置与 小程序 tabBar 管理、分润规则、灵魂文章、AI 配置与监控 等大量页面与能力扩展;主题与布局更新。 抖音小程序:与微信侧在 结果页/购买/个人中心 等方向做了同步或优化。 另含 部署/脚本、开发文档 等配套更新。 邀请码与解锁:后端 分销/邀请码 接口与 add_invite_codes 等 SQL;小程序侧 邀请码弹窗组件、个人中心/推广/多类结果页 的填写入口与 解锁门控(inviteCodeGate、unlockGate);并补充 《小程序解锁引导与邀请码方案》 文档。 超管后台:路由与 企业中心(EnterpriseHub) 等交互与结构优化。 小程序体验:在 首页结果、推广、各测评结果页 上继续打磨;与 邀请码门控、审核门控 小步联动。
This commit is contained in:
@@ -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
1
.gitignore
vendored
@@ -36,3 +36,4 @@ miniprogram/project.private.config.json
|
||||
.git222/
|
||||
.cursor/
|
||||
.git2222/
|
||||
.git22/
|
||||
|
||||
@@ -133,17 +133,7 @@ interface NavItem {
|
||||
|
||||
const navMainItems: NavItem[] = [
|
||||
{ path: '/superadmin/ops', icon: TrendCharts, 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/enterprises', icon: OfficeBuilding, label: '企业管理' },
|
||||
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
|
||||
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
|
||||
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }
|
||||
|
||||
@@ -149,18 +149,20 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'mp-tabbar',
|
||||
name: 'SuperAdminMpTabBar',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'mpTabbar' }
|
||||
})
|
||||
redirect: to => {
|
||||
const q = { ...to.query } as Record<string, unknown>
|
||||
delete q.tab
|
||||
return { path: '/superadmin/enterprises', query: q }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'profit-rules',
|
||||
name: 'SuperAdminProfitRules',
|
||||
redirect: to => ({
|
||||
path: '/superadmin/enterprises',
|
||||
query: { ...to.query, tab: 'profitRules' }
|
||||
})
|
||||
redirect: to => {
|
||||
const q = { ...to.query } as Record<string, unknown>
|
||||
delete q.tab
|
||||
return { path: '/superadmin/enterprises', query: q }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
<SoulArticles v-if="activeTab === 'soulArticles'" />
|
||||
<MpTabBar v-if="activeTab === 'mpTabbar'" />
|
||||
<ProfitRules v-if="activeTab === 'profitRules'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,18 +39,19 @@ import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
import SoulArticles from './SoulArticles.vue'
|
||||
import MpTabBar from './MpTabBar.vue'
|
||||
import ProfitRules from './ProfitRules.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles', 'mpTabbar', 'profitRules'] as const
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
return (TAB_IDS as readonly string[]).includes(s)
|
||||
}
|
||||
|
||||
/** 已从页签隐藏的 tab(旧链接兼容) */
|
||||
const LEGACY_TAB_IDS = ['mpTabbar', 'profitRules']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<TabId>('companies')
|
||||
@@ -97,14 +96,28 @@ async function runOrphanMigrate() {
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' },
|
||||
{ label: '引流文章', value: 'soulArticles' },
|
||||
{ label: '底部菜单', value: 'mpTabbar' },
|
||||
{ label: '分账规则', value: 'profitRules' }
|
||||
{ label: '引流文章', value: 'soulArticles' }
|
||||
]
|
||||
|
||||
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 t = route.query.tab
|
||||
if (typeof t === 'string' && isTabId(t)) {
|
||||
const t = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
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
|
||||
} else {
|
||||
activeTab.value = 'companies'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use app\BaseController;
|
||||
use app\common\service\AiCallService;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\OutboundPushHookService;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use app\common\service\ResumeFileExtractService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\AiConversation as AiConversationModel;
|
||||
@@ -176,7 +175,7 @@ class AiChat extends BaseController
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('对话功能升级中,请稍后再试', 503);
|
||||
}
|
||||
|
||||
@@ -418,7 +417,7 @@ class AiChat extends BaseController
|
||||
|
||||
$jobKey = self::chatJobCacheKey($userId, $jobId);
|
||||
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$msg = '当前为提审模式,对话暂不可用';
|
||||
self::jobTableTrySetError($userId, $jobId, $conversationId, $msg);
|
||||
Cache::set($jobKey, [
|
||||
@@ -621,7 +620,7 @@ class AiChat extends BaseController
|
||||
*/
|
||||
public function recommendedArticles()
|
||||
{
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return success([
|
||||
'list' => [],
|
||||
'recoCount' => 0,
|
||||
@@ -692,7 +691,7 @@ class AiChat extends BaseController
|
||||
*/
|
||||
public function profileArticleTeaser()
|
||||
{
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return success([
|
||||
'enabled' => false,
|
||||
'sectionLabel' => '',
|
||||
@@ -764,7 +763,7 @@ class AiChat extends BaseController
|
||||
*/
|
||||
public function quickQuestions()
|
||||
{
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return success([
|
||||
'mbtiType' => '',
|
||||
'nickname' => '',
|
||||
@@ -829,7 +828,7 @@ class AiChat extends BaseController
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
|
||||
@@ -857,7 +856,7 @@ class AiChat extends BaseController
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('会话不存在', 404);
|
||||
}
|
||||
|
||||
@@ -904,7 +903,7 @@ class AiChat extends BaseController
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('功能升级中', 503);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiReportService;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,7 @@ class AiReport extends BaseController
|
||||
{
|
||||
$userId = $this->jwtSubjectUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('功能升级中', 503);
|
||||
}
|
||||
$conversationId = (int) Request::param('conversationId', 0);
|
||||
@@ -35,7 +34,7 @@ class AiReport extends BaseController
|
||||
public function myLatest()
|
||||
{
|
||||
try {
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return success(['status' => '']);
|
||||
}
|
||||
$userId = $this->jwtSubjectUserId();
|
||||
@@ -57,7 +56,7 @@ class AiReport extends BaseController
|
||||
{
|
||||
$userId = $this->jwtSubjectUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('报告不存在', 404);
|
||||
}
|
||||
$r = AiReportService::get((int) $id, $userId);
|
||||
@@ -93,7 +92,7 @@ class AiReport extends BaseController
|
||||
{
|
||||
$userId = $this->jwtSubjectUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('功能升级中', 503);
|
||||
}
|
||||
$r = AiReportService::get((int) $id, $userId);
|
||||
|
||||
@@ -10,7 +10,6 @@ use app\model\SystemConfig as SystemConfigModel;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
@@ -141,7 +140,7 @@ class Analyze extends BaseController
|
||||
// $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personal)
|
||||
$standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId);
|
||||
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$standardAmountFen = 0;
|
||||
$requiresPayment = 0;
|
||||
}
|
||||
@@ -315,7 +314,7 @@ class Analyze extends BaseController
|
||||
$pricingEnterpriseId = $enterpriseId > 0 ? $enterpriseId : null;
|
||||
$standardAmountFen = $this->getStandardAmountByTestType('resume', $pricingEnterpriseId, $pricingEnterpriseId);
|
||||
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$standardAmountFen = 0;
|
||||
$requiresPayment = 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\common\service\AiCallService;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use app\common\service\MpTabbarService;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -278,7 +277,7 @@ class AppConfig extends BaseController
|
||||
public function deepPricing()
|
||||
{
|
||||
$scope = (string) ($this->request->param('scope', 'personal') ?? 'personal');
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return success(['scope' => $scope, 'categories' => []]);
|
||||
}
|
||||
$categories = $this->normalizeDeepPricingCategories($scope);
|
||||
|
||||
@@ -42,6 +42,19 @@ class Distribution extends BaseController
|
||||
$inviterId = (int) Request::param('inviterId', 0);
|
||||
$enterpriseId = Request::param('eid', 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';
|
||||
|
||||
// 自绑校验
|
||||
@@ -141,6 +154,77 @@ class Distribution extends BaseController
|
||||
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小时未确认收款的,自动退回余额并标记为已过期
|
||||
*/
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
@@ -61,7 +60,7 @@ class Payment extends BaseController
|
||||
return error('用户信息异常', 400);
|
||||
}
|
||||
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
return error('小程序版本审核期间暂不可发起虚拟商品支付,请审核结束后再试', 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use app\model\Enterprise as EnterpriseModel;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\Question as QuestionModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\MiniprogramAuditMode;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
@@ -108,7 +107,7 @@ class Test extends BaseController
|
||||
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
|
||||
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
|
||||
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$needPayUnlock = false;
|
||||
}
|
||||
|
||||
@@ -129,7 +128,7 @@ class Test extends BaseController
|
||||
}
|
||||
|
||||
$paymentFields = [
|
||||
'requiresPayment' => MiniprogramAuditMode::isOn() ? 0 : $requiresPayment,
|
||||
'requiresPayment' => miniprogram_audit_mode_on() ? 0 : $requiresPayment,
|
||||
'isPaid' => $isPaid,
|
||||
'orderId' => $orderId,
|
||||
'enterpriseName' => $enterpriseName,
|
||||
@@ -427,7 +426,7 @@ class Test extends BaseController
|
||||
$isPaid = (int) ($row['isPaid'] ?? 0);
|
||||
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
|
||||
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$needPayUnlock = false;
|
||||
}
|
||||
if ($data !== []) {
|
||||
@@ -538,7 +537,7 @@ class Test extends BaseController
|
||||
'resultText' => $resultText,
|
||||
'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '',
|
||||
'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 !== '') {
|
||||
$out['gallupPreview'] = $gallupPreview;
|
||||
@@ -669,7 +668,7 @@ class Test extends BaseController
|
||||
$paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
|
||||
$testType = $row['testType'] ?? '';
|
||||
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$needPaymentToUnlock = false;
|
||||
}
|
||||
$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;
|
||||
$respPaidAmount = $auditMp ? 0 : $paidAmount;
|
||||
|
||||
@@ -794,7 +793,7 @@ class Test extends BaseController
|
||||
}
|
||||
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
|
||||
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
|
||||
if (MiniprogramAuditMode::isOn()) {
|
||||
if (miniprogram_audit_mode_on()) {
|
||||
$requiresPayment = 0;
|
||||
$standardAmountFen = 0;
|
||||
}
|
||||
|
||||
17
api/database/add_invite_codes.sql
Normal file
17
api/database/add_invite_codes.sql
Normal 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='小程序填写邀请码解析用';
|
||||
@@ -80,6 +80,7 @@ Route::group('api', function () {
|
||||
Route::get('orders', 'api.Order/index');
|
||||
// 分销
|
||||
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/bindings', 'api.Distribution/bindings');
|
||||
Route::get('distribution/commissions', 'api.Distribution/commissions');
|
||||
@@ -113,6 +114,22 @@ Route::group('api', function () {
|
||||
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
|
||||
})->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 组
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* app.wxss - 全局样式 */
|
||||
|
||||
@import "./styles/result-page-dashboard.wxss";
|
||||
@import "./styles/invite-fill-entry.wxss";
|
||||
|
||||
/* 主题颜色 */
|
||||
page {
|
||||
@@ -194,3 +195,17 @@ page {
|
||||
.tabbar-pad-fixed {
|
||||
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%;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = 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 { triggerTestResultCompleted } = require('../../utils/pushHook')
|
||||
|
||||
@@ -114,7 +117,9 @@ Page({
|
||||
showSbtiFace: true,
|
||||
analyzingTitle: '正在分析中',
|
||||
reportTitle: '分析报告',
|
||||
aiAnalysisText: '智能分析'
|
||||
aiAnalysisText: '智能分析',
|
||||
showInviteCodeDialog: false,
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -255,12 +260,14 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const ep = app.globalData.enterprisePermissions
|
||||
const gd = app.globalData || {}
|
||||
const ep = gd.enterprisePermissions
|
||||
const showSbtiFace = !ep || ep.sbti !== false
|
||||
this.setData({
|
||||
hasPhone: hasPhone(),
|
||||
isProfileComplete: isProfileComplete(),
|
||||
showSbtiFace
|
||||
showSbtiFace,
|
||||
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
|
||||
})
|
||||
if (this.data.showResult) this._syncPhoneLoginGate()
|
||||
const tc = app.globalData.textConfig
|
||||
@@ -534,7 +541,7 @@ Page({
|
||||
this.unlockFullReport()
|
||||
},
|
||||
|
||||
// 解锁完整报告:发起人脸测试付费(不要求先完善资料)
|
||||
// 解锁完整报告:先留资门禁,再发起人脸测试付费
|
||||
unlockFullReport() {
|
||||
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
|
||||
if (!payInfo.requiresPayment || payInfo.isPaid) return
|
||||
@@ -544,29 +551,53 @@ Page({
|
||||
wx.showToast({ title: '请先登录后再解锁', icon: 'none' })
|
||||
return
|
||||
}
|
||||
payment.purchaseFaceTest({
|
||||
testResultId,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchaseFaceTest({
|
||||
testResultId,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
|
||||
this.setData({
|
||||
'payInfo.isPaid': true,
|
||||
hasPhone: hasPhone(),
|
||||
isProfileComplete: isProfileComplete()
|
||||
this.setData({
|
||||
'payInfo.isPaid': true,
|
||||
hasPhone: hasPhone(),
|
||||
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) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"navigationBarTextStyle": "black",
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true,
|
||||
"usingComponents": {}
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
|
||||
</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}}">
|
||||
<text class="error-title">错误信息</text>
|
||||
@@ -88,7 +97,7 @@
|
||||
</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">
|
||||
<text class="paywall-fake-title">查看完整报告</text>
|
||||
<text class="paywall-tip">授权手机号并设置头像、昵称后即可查看完整报告(与下方按钮为同一条流程)。</text>
|
||||
@@ -113,7 +122,7 @@
|
||||
|
||||
<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-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text>
|
||||
<view class="post-pay-guide-actions">
|
||||
@@ -229,7 +238,7 @@
|
||||
wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}"
|
||||
>
|
||||
<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">
|
||||
<text class="paywall-fake-title">完整版深度解析</text>
|
||||
<text class="paywall-tip">含面相、骨相、人际与职业 / 家庭 / 合伙三大场景</text>
|
||||
@@ -450,4 +459,10 @@
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -4,6 +4,7 @@ const { getTypeOnly } = require('../../utils/resultFormat')
|
||||
const { request } = require('../../utils/request')
|
||||
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
|
||||
const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
|
||||
/** 与超管「小程序 · 推荐文章展示」中「区块标题」一致;接口无字段时的兜底 */
|
||||
const PROFILE_RECO_LABEL_FALLBACK = '我的由来'
|
||||
@@ -79,7 +80,13 @@ Page({
|
||||
profileRecoSectionLabel: '',
|
||||
profileRecoArticle: null,
|
||||
/** 与神仙 AI 精选推荐同源:配置了 AppID 时优先跳转目标小程序 */
|
||||
profileRecoJump: null
|
||||
profileRecoJump: null,
|
||||
/** 拉取自 /api/distribution/my-invite-code,与推广中心一致 */
|
||||
myInviteCode: '',
|
||||
myInviteCodeLoadState: 'idle',
|
||||
myInviteCodeErrMsg: '',
|
||||
myInviteCodeHint: '',
|
||||
showInviteCodeDialog: false
|
||||
},
|
||||
|
||||
_computeShowLatestTestRow(d) {
|
||||
@@ -223,19 +230,127 @@ Page({
|
||||
|
||||
// 2. 已登录则从服务端拉取最近记录;失败降级读 localStorage
|
||||
if (token || userInfo) {
|
||||
if (token && this.data.permDistribution) {
|
||||
this.setData({ myInviteCodeLoadState: 'loading', myInviteCodeErrMsg: '' })
|
||||
}
|
||||
this._loadRecentFromAPI()
|
||||
this._loadPromoStats()
|
||||
this._loadMyInviteCode()
|
||||
this._loadProfileRecoTeaser()
|
||||
} else {
|
||||
this.setData({
|
||||
profileRecoShow: false,
|
||||
profileRecoArticle: null,
|
||||
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) {
|
||||
if (!d || typeof d !== 'object') return null
|
||||
const appId = ((d.recoJumpMiniAppId || '') + '').trim()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"navigationBarBackgroundColor": "#F9FAFB",
|
||||
"navigationBarTextStyle": "black",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,39 @@
|
||||
</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 风:图标网格代替长列表) -->
|
||||
<view class="section px-section" wx:if="{{hasLogin}}">
|
||||
<view class="sec-head">
|
||||
@@ -210,6 +243,12 @@
|
||||
|
||||
<!-- 分销/提现入口已上移至数据条下方(promo-quick-bar) -->
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
|
||||
<view class="bottom-safe"></view>
|
||||
<custom-tab-bar />
|
||||
</view>
|
||||
|
||||
@@ -783,6 +783,106 @@ custom-tab-bar {
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// pages/promo/index.js
|
||||
const app = getApp()
|
||||
const { request } = require('../../utils/request')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -30,7 +32,16 @@ Page({
|
||||
withdrawAmountInput: '',
|
||||
withdrawError: '',
|
||||
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() {
|
||||
@@ -41,7 +52,10 @@ Page({
|
||||
wx.showToast({ title: '请先登录后查看推广中心', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const hide = shouldHideInviteCodeEntry(app.globalData || {})
|
||||
this.setData({ hideInviteCodeEntry: hide })
|
||||
this.loadStats()
|
||||
if (!hide) this.loadMyInviteCode()
|
||||
this.loadBindings(true)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -50,8 +64,104 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const hide = shouldHideInviteCodeEntry(app.globalData || {})
|
||||
this.setData({ hideInviteCodeEntry: hide })
|
||||
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)
|
||||
},
|
||||
|
||||
/** 加载推广统计数据 */
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "推广中心",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black"
|
||||
"navigationBarTextStyle": "black",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,40 @@
|
||||
</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-dialog">
|
||||
@@ -216,4 +250,10 @@
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom"></view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -19,6 +19,70 @@ page {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = require('../../utils/phoneAuth.js')
|
||||
const unlockGate = require('../../utils/unlockGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
const {
|
||||
slicePreviewText,
|
||||
slicePreviewList,
|
||||
@@ -79,7 +82,9 @@ Page({
|
||||
{ id: 'sec-cta', label: '深度方案', emoji: '💎' }
|
||||
],
|
||||
scrollTarget: '',
|
||||
activeSection: ''
|
||||
activeSection: '',
|
||||
showInviteCodeDialog: false,
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onTapSectionNav(e) {
|
||||
@@ -208,7 +213,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const gd = app.globalData || {}
|
||||
this.setData({
|
||||
hasPhone: hasPhone(),
|
||||
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
|
||||
})
|
||||
if (this.data.testResultId && this.data.result) {
|
||||
this._syncDiscGate()
|
||||
} else if (!this.data.testResultId) {
|
||||
@@ -280,8 +289,9 @@ Page({
|
||||
onTapReadFull() {
|
||||
try { require('../../utils/analytics').track('tap_read_full', { type: 'disc' }) } catch (e) {}
|
||||
if (this.data.profileGate) {
|
||||
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
|
||||
wx.showToast({
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
@@ -356,22 +366,46 @@ Page({
|
||||
try { require('../../utils/analytics').track('tap_unlock_full', { type: 'disc', amountYuan: payInfo.amountYuan }) } catch (e) {}
|
||||
app.ensureLogin && app.ensureLogin().then((logged) => {
|
||||
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
payment.purchaseDiscTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchaseDiscTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
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() {
|
||||
if (!this.data.testResultId) {
|
||||
wx.removeStorageSync('discResult')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "DISC结果",
|
||||
"navigationBarBackgroundColor": "#3B82F6",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
</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}}">
|
||||
<view class="section-head" id="sec-insight">
|
||||
@@ -95,9 +104,11 @@
|
||||
<text class="paywall-fake-line">• 职业匹配建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -137,7 +148,7 @@
|
||||
</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}}">
|
||||
<text class="preview-teaser-title">优势摘录(预览约 30%)</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = require('../../utils/phoneAuth.js')
|
||||
const unlockGate = require('../../utils/unlockGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
const {
|
||||
slicePreviewText,
|
||||
slicePreviewList,
|
||||
@@ -71,7 +74,10 @@ Page({
|
||||
{ id: 'sec-cta', label: '深度方案', emoji: '💎' }
|
||||
],
|
||||
scrollTarget: '',
|
||||
activeSection: ''
|
||||
activeSection: '',
|
||||
showInviteCodeDialog: false,
|
||||
/** 审核/提审时隐藏填写邀请码入口(与 runtime 开关同步) */
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onTapSectionNav(e) {
|
||||
@@ -118,7 +124,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const gd = app.globalData || {}
|
||||
this.setData({
|
||||
hasPhone: hasPhone(),
|
||||
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
|
||||
})
|
||||
if (this.data.testResultId && this.data.result) {
|
||||
this.applyResult(this.data.result)
|
||||
} else if (!this.data.testResultId) {
|
||||
@@ -316,8 +326,9 @@ Page({
|
||||
onTapReadFull() {
|
||||
try { require('../../utils/analytics').track('tap_read_full', { type: 'mbti' }) } catch (e) {}
|
||||
if (this.data.profileGate) {
|
||||
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
|
||||
wx.showToast({
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
@@ -394,22 +405,46 @@ Page({
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
payment.purchaseMbtiTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchaseMbtiTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
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
|
||||
onGetPhoneNumberForMbtiPay(e) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "MBTI结果",
|
||||
"navigationBarBackgroundColor": "#FF6B8A",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
</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}}">
|
||||
<view class="section-head" id="sec-insight">
|
||||
@@ -94,23 +103,25 @@
|
||||
<text class="paywall-fake-line">• 职业匹配与人际关系建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForMbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForMbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -154,7 +165,7 @@
|
||||
</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}}">
|
||||
<text class="preview-teaser-title">优势摘录(预览约 30%)</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* pages/result/mbti.wxss(dash 样式见 app.wxss) */
|
||||
/* pages/result/mbti.wxss(dash 样式见 app.wxss;邀请码入口样式见 app.wxss → invite-fill-entry) */
|
||||
|
||||
.result-page {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = require('../../utils/phoneAuth.js')
|
||||
const unlockGate = require('../../utils/unlockGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
const {
|
||||
slicePreviewText,
|
||||
slicePreviewList,
|
||||
@@ -79,7 +82,9 @@ Page({
|
||||
{ id: 'sec-cta', label: '深度方案', emoji: '💎' }
|
||||
],
|
||||
scrollTarget: '',
|
||||
activeSection: ''
|
||||
activeSection: '',
|
||||
showInviteCodeDialog: false,
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onTapSectionNav(e) {
|
||||
@@ -209,7 +214,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const gd = app.globalData || {}
|
||||
this.setData({
|
||||
hasPhone: hasPhone(),
|
||||
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
|
||||
})
|
||||
if (this.data.testResultId && this.data.result) {
|
||||
this._syncPdpGate()
|
||||
} else if (!this.data.testResultId) {
|
||||
@@ -285,8 +294,9 @@ Page({
|
||||
onTapReadFull() {
|
||||
try { require('../../utils/analytics').track('tap_read_full', { type: 'pdp' }) } catch (e) {}
|
||||
if (this.data.profileGate) {
|
||||
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
|
||||
wx.showToast({
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
@@ -361,22 +371,46 @@ Page({
|
||||
try { require('../../utils/analytics').track('tap_unlock_full', { type: 'pdp', amountYuan: payInfo.amountYuan }) } catch (e) {}
|
||||
app.ensureLogin && app.ensureLogin().then((logged) => {
|
||||
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
payment.purchasePdpTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchasePdpTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
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() {
|
||||
if (!this.data.testResultId) {
|
||||
wx.removeStorageSync('pdpResult')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "PDP结果",
|
||||
"navigationBarBackgroundColor": "#F59E0B",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
</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}}">
|
||||
<view class="section-head" id="sec-insight">
|
||||
@@ -95,9 +104,11 @@
|
||||
<text class="paywall-fake-line">• 推荐职业</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -137,7 +148,7 @@
|
||||
</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}}">
|
||||
<text class="preview-teaser-title">优势摘录(预览约 30%)</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// pages/result/resume.js - 简历综合分析结果页
|
||||
const app = getApp()
|
||||
const payment = require('../../utils/payment')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
|
||||
const { triggerTestResultCompleted } = require('../../utils/pushHook')
|
||||
|
||||
@@ -23,7 +25,9 @@ Page({
|
||||
},
|
||||
testResultId: 0,
|
||||
paying: false,
|
||||
profileGate: false
|
||||
profileGate: false,
|
||||
showInviteCodeDialog: false,
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -56,6 +60,8 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
const gd = app.globalData || {}
|
||||
this.setData({ hideInviteCodeEntry: shouldHideInviteCodeEntry(gd) })
|
||||
const id = this.data.testResultId
|
||||
if (id && this.data.profileGate) {
|
||||
this.loadFromHistory(String(id))
|
||||
@@ -246,24 +252,45 @@ Page({
|
||||
// 支付解锁
|
||||
doPay() {
|
||||
if (this.data.paying) return
|
||||
this.setData({ paying: true })
|
||||
const testResultId = this.data.testResultId || 0
|
||||
payment.purchaseResumeAnalysis({
|
||||
testResultId: testResultId > 0 ? testResultId : undefined,
|
||||
success: () => {
|
||||
this.setData({ paying: false, 'payInfo.requiresPayment': false, 'payInfo.isPaid': true })
|
||||
// 支付成功后重新拉取完整内容
|
||||
if (testResultId > 0) {
|
||||
this.loadFromHistory(testResultId)
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
this.setData({ paying: true })
|
||||
const testResultId = this.data.testResultId || 0
|
||||
payment.purchaseResumeAnalysis({
|
||||
testResultId: testResultId > 0 ? testResultId : undefined,
|
||||
success: () => {
|
||||
this.setData({ paying: false, 'payInfo.requiresPayment': false, 'payInfo.isPaid': true })
|
||||
// 支付成功后重新拉取完整内容
|
||||
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() {
|
||||
const scope = (app.globalData && app.globalData.appScope) || 'personal'
|
||||
if (scope === 'enterprise') {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "简历综合分析"
|
||||
"navigationBarTitleText": "简历综合分析",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
</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}}">
|
||||
<text class="card-title error-title">失败原因</text>
|
||||
@@ -92,5 +101,11 @@
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
needsResultProfileGate,
|
||||
navigateToCompleteProfileAfterPhoneIfNeeded
|
||||
} = require('../../utils/phoneAuth.js')
|
||||
const unlockGate = require('../../utils/unlockGate.js')
|
||||
const inviteCodeGate = require('../../utils/inviteCodeGate.js')
|
||||
const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js')
|
||||
const {
|
||||
slicePreviewText,
|
||||
openTimelineShareHint
|
||||
@@ -82,7 +85,9 @@ Page({
|
||||
{ id: 'sec-cta', label: '深度方案', emoji: '💎' }
|
||||
],
|
||||
scrollTarget: '',
|
||||
activeSection: ''
|
||||
activeSection: '',
|
||||
showInviteCodeDialog: false,
|
||||
hideInviteCodeEntry: false
|
||||
},
|
||||
|
||||
onTapSectionNav(e) {
|
||||
@@ -141,7 +146,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ hasPhone: hasPhone() })
|
||||
const gd = app.globalData || {}
|
||||
this.setData({
|
||||
hasPhone: hasPhone(),
|
||||
hideInviteCodeEntry: shouldHideInviteCodeEntry(gd)
|
||||
})
|
||||
if (this.data.testResultId && this.data.result) {
|
||||
this.applyResult(this.data.result)
|
||||
} else if (!this.data.testResultId) {
|
||||
@@ -304,8 +313,9 @@ Page({
|
||||
onTapReadFull() {
|
||||
try { require('../../utils/analytics').track('tap_read_full', { type: 'sbti' }) } catch (e) {}
|
||||
if (this.data.profileGate) {
|
||||
unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true })
|
||||
wx.showToast({
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
|
||||
title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
@@ -390,22 +400,46 @@ Page({
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
payment.purchaseSbtiTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => {
|
||||
if (!ok) return
|
||||
inviteCodeGate.ensureInviteCodeGate(this).then((go) => {
|
||||
if (!go) return
|
||||
payment.purchaseSbtiTest({
|
||||
testResultId: testResultId || undefined,
|
||||
success: () => {
|
||||
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
|
||||
this.setData({ 'payInfo.isPaid': true })
|
||||
this._syncJourney()
|
||||
if (testResultId && !hasReloadedAfterPay) {
|
||||
this.setData({ hasReloadedAfterPay: true })
|
||||
setTimeout(() => this.loadDetail(testResultId), 500)
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
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) {
|
||||
const { code, errMsg } = e.detail || {}
|
||||
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
"navigationBarTitleText": "SBTI结果",
|
||||
"navigationBarBackgroundColor": "#f2f7f3",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f2f7f3"
|
||||
"backgroundColor": "#f2f7f3",
|
||||
"usingComponents": {
|
||||
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
</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}}">
|
||||
<image
|
||||
@@ -54,23 +63,25 @@
|
||||
<text class="paywall-fake-line">• 匹配度与兜底说明</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForSbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForSbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -140,7 +151,7 @@
|
||||
</view>
|
||||
</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>
|
||||
<button
|
||||
wx:if="{{!hasPhone}}"
|
||||
@@ -216,4 +227,10 @@
|
||||
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<invite-code-dialog
|
||||
visible="{{showInviteCodeDialog}}"
|
||||
bind:skip="onInviteCodeSkip"
|
||||
bind:success="onInviteCodeSuccess"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -20,7 +20,16 @@ Page({
|
||||
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()
|
||||
},
|
||||
|
||||
|
||||
52
miniprogram/styles/invite-fill-entry.wxss
Normal file
52
miniprogram/styles/invite-fill-entry.wxss
Normal 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;
|
||||
}
|
||||
87
miniprogram/utils/inviteCodeGate.js
Normal file
87
miniprogram/utils/inviteCodeGate.js
Normal 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
|
||||
}
|
||||
@@ -32,6 +32,16 @@ function isAuditHideAiMode(gd) {
|
||||
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) {
|
||||
const app = getApp()
|
||||
const gd = app && app.globalData
|
||||
@@ -67,6 +77,7 @@ module.exports = {
|
||||
applyAuditUiOverride,
|
||||
shouldHideTabBarHighlightFab,
|
||||
isAuditHideAiMode,
|
||||
shouldHideInviteCodeEntry,
|
||||
redirectIfMiniprogramAudit,
|
||||
ensureRuntimeThenGate
|
||||
}
|
||||
|
||||
110
miniprogram/utils/unlockGate.js
Normal file
110
miniprogram/utils/unlockGate.js
Normal 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
|
||||
}
|
||||
141
开发文档/小程序解锁引导与邀请码方案.md
Normal file
141
开发文档/小程序解锁引导与邀请码方案.md
Normal 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` 约 47–60 行),含「授权手机号」「去完善资料」。
|
||||
- 需求①本质是**无手机号时把用户视线带到该区域**;需求②与现有 `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 | 初稿:解锁分流 + 邀请码弹框需求与实现要点 |
|
||||
Reference in New Issue
Block a user