From d7137f9349636614c495f751441576928e8fdf83 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Thu, 23 Apr 2026 11:28:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=EF=BC=9A=E8=87=AA=E5=AE=9A=E4=B9=89=E5=BA=95=E6=A0=8F=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20AI=20=E5=85=A5=E5=8F=A3=EF=BC=88=E5=90=AB=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E4=B8=8E=E6=A0=B7=E5=BC=8F=EF=BC=89=E3=80=81AI=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E5=8E=86=E5=8F=B2/=E6=8A=A5=E5=91=8A=E7=AD=89=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=EF=BC=9B=E5=AE=A1=E6=A0=B8=E6=A8=A1=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=81=94=E5=8A=A8=EF=BC=9B=E8=B5=84=E6=96=99?= =?UTF-8?q?/=E4=BC=81=E4=B8=9A/=E9=A6=96=E9=A1=B5/=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=AD=89=E9=A1=B5=E9=9D=A2=E4=B8=8E=20app=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E9=85=8D=E5=90=88=E8=B0=83=E6=95=B4=E3=80=82=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20API=EF=BC=9AAI=20=E5=AF=B9=E8=AF=9D/=E6=8A=A5?= =?UTF-8?q?=E5=91=8A/=E4=BD=99=E9=A2=9D=E5=91=8A=E8=AD=A6=20=E7=AD=89?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=8E=E8=B7=AF=E7=94=B1=EF=BC=9B=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=20tabBar=20=E9=85=8D=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E6=B6=A6=E7=9B=B8=E5=85=B3=E8=83=BD=E5=8A=9B=EF=BC=9B?= =?UTF-8?q?=E5=87=BA=E7=AB=99=E6=8E=A8=E9=80=81=20Webhook=20=E4=B8=8E?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E8=B0=83=E8=AF=95=EF=BC=9B=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E7=95=99=E8=B5=84=E3=80=81=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=86=E6=9E=90=20=E7=AD=89=E6=94=AF?= =?UTF-8?q?=E6=92=91=EF=BC=9B=E5=A4=9A=E4=BB=BD=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=BF=81=E7=A7=BB=EF=BC=88AI=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E3=80=81=E7=81=B5=E9=AD=82=E6=96=87=E7=AB=A0=E3=80=81tabBar?= =?UTF-8?q?=E3=80=81=E5=88=86=E6=B6=A6=E7=AD=89=EF=BC=89=E3=80=82=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=20/=20=E8=B6=85=E7=AE=A1=EF=BC=9A?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E3=80=81=E7=94=A8=E6=88=B7/=E8=AE=A2?= =?UTF-8?q?=E5=8D=95/=E5=88=86=E9=94=80/=E8=B4=A2=E5=8A=A1=E3=80=81?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=8E=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=20tab?= =?UTF-8?q?Bar=20=E7=AE=A1=E7=90=86=E3=80=81=E5=88=86=E6=B6=A6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E3=80=81=E7=81=B5=E9=AD=82=E6=96=87=E7=AB=A0=E3=80=81?= =?UTF-8?q?AI=20=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=9B=91=E6=8E=A7=20=E7=AD=89?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E9=A1=B5=E9=9D=A2=E4=B8=8E=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E6=89=A9=E5=B1=95=EF=BC=9B=E4=B8=BB=E9=A2=98=E4=B8=8E=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=9B=B4=E6=96=B0=E3=80=82=20=E6=8A=96=E9=9F=B3?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=EF=BC=9A=E4=B8=8E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E4=BE=A7=E5=9C=A8=20=E7=BB=93=E6=9E=9C=E9=A1=B5/=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0/=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=20=E7=AD=89?= =?UTF-8?q?=E6=96=B9=E5=90=91=E5=81=9A=E4=BA=86=E5=90=8C=E6=AD=A5=E6=88=96?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E3=80=82=20=E5=8F=A6=E5=90=AB=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2/=E8=84=9A=E6=9C=AC=E3=80=81=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=96=87=E6=A1=A3=20=E7=AD=89=E9=85=8D=E5=A5=97=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E3=80=82=20=E9=82=80=E8=AF=B7=E7=A0=81=E4=B8=8E?= =?UTF-8?q?=E8=A7=A3=E9=94=81=EF=BC=9A=E5=90=8E=E7=AB=AF=20=E5=88=86?= =?UTF-8?q?=E9=94=80/=E9=82=80=E8=AF=B7=E7=A0=81=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=20add=5Finvite=5Fcodes=20=E7=AD=89=20SQL=EF=BC=9B?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=BE=A7=20=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E7=A0=81=E5=BC=B9=E7=AA=97=E7=BB=84=E4=BB=B6=E3=80=81=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E4=B8=AD=E5=BF=83/=E6=8E=A8=E5=B9=BF/=E5=A4=9A?= =?UTF-8?q?=E7=B1=BB=E7=BB=93=E6=9E=9C=E9=A1=B5=20=E7=9A=84=E5=A1=AB?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=8F=A3=E4=B8=8E=20=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=EF=BC=88inviteCodeGate=E3=80=81unlockGate?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=B9=B6=E8=A1=A5=E5=85=85=20=E3=80=8A?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E8=A7=A3=E9=94=81=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E4=B8=8E=E9=82=80=E8=AF=B7=E7=A0=81=E6=96=B9=E6=A1=88=E3=80=8B?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=E3=80=82=20=E8=B6=85=E7=AE=A1=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=EF=BC=9A=E8=B7=AF=E7=94=B1=E4=B8=8E=20=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E4=B8=AD=E5=BF=83=EF=BC=88EnterpriseHub=EF=BC=89=20?= =?UTF-8?q?=E7=AD=89=E4=BA=A4=E4=BA=92=E4=B8=8E=E7=BB=93=E6=9E=84=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=82=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=EF=BC=9A=E5=9C=A8=20=E9=A6=96=E9=A1=B5=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E3=80=81=E6=8E=A8=E5=B9=BF=E3=80=81=E5=90=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=84=E7=BB=93=E6=9E=9C=E9=A1=B5=20=E4=B8=8A=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E6=89=93=E7=A3=A8=EF=BC=9B=E4=B8=8E=20=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=A0=81=E9=97=A8=E6=8E=A7=E3=80=81=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=20=E5=B0=8F=E6=AD=A5=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/mbti-milestone-feishu.mdc | 11 -- .gitignore | 1 + admin/src/layouts/SuperAdminLayout.vue | 12 +- admin/src/router/index.ts | 18 ++- admin/src/views/superadmin/EnterpriseHub.vue | 33 ++-- api/app/common.php | 14 ++ api/app/controller/api/AiChat.php | 17 +- api/app/controller/api/AiReport.php | 9 +- api/app/controller/api/Analyze.php | 5 +- api/app/controller/api/AppConfig.php | 3 +- api/app/controller/api/Distribution.php | 146 ++++++++++++++++++ api/app/controller/api/Payment.php | 3 +- api/app/controller/api/Test.php | 15 +- api/database/add_invite_codes.sql | 17 ++ api/route/api.php | 17 ++ miniprogram/app.wxss | 15 ++ .../invite-code-dialog/invite-code-dialog.js | 87 +++++++++++ .../invite-code-dialog.json | 4 + .../invite-code-dialog.wxml | 21 +++ .../invite-code-dialog.wxss | 89 +++++++++++ miniprogram/pages/index/result.js | 73 ++++++--- miniprogram/pages/index/result.json | 4 +- miniprogram/pages/index/result.wxml | 21 ++- miniprogram/pages/profile/index.js | 119 +++++++++++++- miniprogram/pages/profile/index.json | 3 +- miniprogram/pages/profile/index.wxml | 39 +++++ miniprogram/pages/profile/index.wxss | 100 ++++++++++++ miniprogram/pages/promo/index.js | 114 +++++++++++++- miniprogram/pages/promo/index.json | 5 +- miniprogram/pages/promo/index.wxml | 40 +++++ miniprogram/pages/promo/index.wxss | 64 ++++++++ miniprogram/pages/result/disc.js | 64 ++++++-- miniprogram/pages/result/disc.json | 5 +- miniprogram/pages/result/disc.wxml | 25 ++- miniprogram/pages/result/mbti.js | 65 ++++++-- miniprogram/pages/result/mbti.json | 5 +- miniprogram/pages/result/mbti.wxml | 53 ++++--- miniprogram/pages/result/mbti.wxss | 2 +- miniprogram/pages/result/pdp.js | 64 ++++++-- miniprogram/pages/result/pdp.json | 5 +- miniprogram/pages/result/pdp.wxml | 25 ++- miniprogram/pages/result/resume.js | 57 +++++-- miniprogram/pages/result/resume.json | 5 +- miniprogram/pages/result/resume.wxml | 15 ++ miniprogram/pages/result/sbti.js | 64 ++++++-- miniprogram/pages/result/sbti.json | 5 +- miniprogram/pages/result/sbti.wxml | 53 ++++--- miniprogram/pages/user-profile/index.js | 11 +- miniprogram/styles/invite-fill-entry.wxss | 52 +++++++ miniprogram/utils/inviteCodeGate.js | 87 +++++++++++ miniprogram/utils/miniprogramAuditGate.js | 11 ++ miniprogram/utils/unlockGate.js | 110 +++++++++++++ 开发文档/小程序解锁引导与邀请码方案.md | 141 +++++++++++++++++ 53 files changed, 1817 insertions(+), 226 deletions(-) delete mode 100644 .cursor/rules/mbti-milestone-feishu.mdc create mode 100644 api/database/add_invite_codes.sql create mode 100644 miniprogram/components/invite-code-dialog/invite-code-dialog.js create mode 100644 miniprogram/components/invite-code-dialog/invite-code-dialog.json create mode 100644 miniprogram/components/invite-code-dialog/invite-code-dialog.wxml create mode 100644 miniprogram/components/invite-code-dialog/invite-code-dialog.wxss create mode 100644 miniprogram/styles/invite-fill-entry.wxss create mode 100644 miniprogram/utils/inviteCodeGate.js create mode 100644 miniprogram/utils/unlockGate.js create mode 100644 开发文档/小程序解锁引导与邀请码方案.md diff --git a/.cursor/rules/mbti-milestone-feishu.mdc b/.cursor/rules/mbti-milestone-feishu.mdc deleted file mode 100644 index 889f41f..0000000 --- a/.cursor/rules/mbti-milestone-feishu.mdc +++ /dev/null @@ -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. 仅在「完整功能可验收」时推送,禁止每次小改动都推。 diff --git a/.gitignore b/.gitignore index 3482632..11fc3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ miniprogram/project.private.config.json .git222/ .cursor/ .git2222/ +.git22/ diff --git a/admin/src/layouts/SuperAdminLayout.vue b/admin/src/layouts/SuperAdminLayout.vue index a78c35e..904a59e 100644 --- a/admin/src/layouts/SuperAdminLayout.vue +++ b/admin/src/layouts/SuperAdminLayout.vue @@ -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: '智能算力' } diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts index 19a0ca4..9bc8004 100644 --- a/admin/src/router/index.ts +++ b/admin/src/router/index.ts @@ -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 + 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 + delete q.tab + return { path: '/superadmin/enterprises', query: q } + } }, { path: 'settings', diff --git a/admin/src/views/superadmin/EnterpriseHub.vue b/admin/src/views/superadmin/EnterpriseHub.vue index 964b62b..8588b57 100644 --- a/admin/src/views/superadmin/EnterpriseHub.vue +++ b/admin/src/views/superadmin/EnterpriseHub.vue @@ -27,8 +27,6 @@ - - @@ -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('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 { + const q: Record = {} + 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' diff --git a/api/app/common.php b/api/app/common.php index 41f3dc3..5a65724 100644 --- a/api/app/common.php +++ b/api/app/common.php @@ -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(); + } +} diff --git a/api/app/controller/api/AiChat.php b/api/app/controller/api/AiChat.php index 055ded8..5af71b0 100644 --- a/api/app/controller/api/AiChat.php +++ b/api/app/controller/api/AiChat.php @@ -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); } diff --git a/api/app/controller/api/AiReport.php b/api/app/controller/api/AiReport.php index 7534d2e..94acba4 100644 --- a/api/app/controller/api/AiReport.php +++ b/api/app/controller/api/AiReport.php @@ -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); diff --git a/api/app/controller/api/Analyze.php b/api/app/controller/api/Analyze.php index 80a0a88..261081b 100644 --- a/api/app/controller/api/Analyze.php +++ b/api/app/controller/api/Analyze.php @@ -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; } diff --git a/api/app/controller/api/AppConfig.php b/api/app/controller/api/AppConfig.php index a3b8349..1989453 100644 --- a/api/app/controller/api/AppConfig.php +++ b/api/app/controller/api/AppConfig.php @@ -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); diff --git a/api/app/controller/api/Distribution.php b/api/app/controller/api/Distribution.php index 0c69ef5..dd38725 100644 --- a/api/app/controller/api/Distribution.php +++ b/api/app/controller/api/Distribution.php @@ -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, + ]; + } } diff --git a/api/app/controller/api/Payment.php b/api/app/controller/api/Payment.php index 799adf3..710383d 100644 --- a/api/app/controller/api/Payment.php +++ b/api/app/controller/api/Payment.php @@ -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); } diff --git a/api/app/controller/api/Test.php b/api/app/controller/api/Test.php index 2344061..ee6c481 100644 --- a/api/app/controller/api/Test.php +++ b/api/app/controller/api/Test.php @@ -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; } diff --git a/api/database/add_invite_codes.sql b/api/database/add_invite_codes.sql new file mode 100644 index 0000000..d9e9f33 --- /dev/null +++ b/api/database/add_invite_codes.sql @@ -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='小程序填写邀请码解析用'; diff --git a/api/route/api.php b/api/route/api.php index 8f3aa2c..00a5c42 100644 --- a/api/route/api.php +++ b/api/route/api.php @@ -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 组 diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index 9e49151..4fff1bf 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -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%; +} diff --git a/miniprogram/components/invite-code-dialog/invite-code-dialog.js b/miniprogram/components/invite-code-dialog/invite-code-dialog.js new file mode 100644 index 0000000..30eca44 --- /dev/null +++ b/miniprogram/components/invite-code-dialog/invite-code-dialog.js @@ -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 }) + } + }) + } + } +}) diff --git a/miniprogram/components/invite-code-dialog/invite-code-dialog.json b/miniprogram/components/invite-code-dialog/invite-code-dialog.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/miniprogram/components/invite-code-dialog/invite-code-dialog.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/components/invite-code-dialog/invite-code-dialog.wxml b/miniprogram/components/invite-code-dialog/invite-code-dialog.wxml new file mode 100644 index 0000000..5729e83 --- /dev/null +++ b/miniprogram/components/invite-code-dialog/invite-code-dialog.wxml @@ -0,0 +1,21 @@ + + + 填写邀请码 + 选填。用于参与推广活动或企业活动;可稍后在「推广中心」了解规则。 + + + + + + + diff --git a/miniprogram/components/invite-code-dialog/invite-code-dialog.wxss b/miniprogram/components/invite-code-dialog/invite-code-dialog.wxss new file mode 100644 index 0000000..1c13858 --- /dev/null +++ b/miniprogram/components/invite-code-dialog/invite-code-dialog.wxss @@ -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; +} diff --git a/miniprogram/pages/index/result.js b/miniprogram/pages/index/result.js index 0bf2153..6a56b55 100644 --- a/miniprogram/pages/index/result.js +++ b/miniprogram/pages/index/result.js @@ -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 || {} diff --git a/miniprogram/pages/index/result.json b/miniprogram/pages/index/result.json index 5b80209..2f548a1 100644 --- a/miniprogram/pages/index/result.json +++ b/miniprogram/pages/index/result.json @@ -4,5 +4,7 @@ "navigationBarTextStyle": "black", "enableShareAppMessage": true, "enableShareTimeline": true, - "usingComponents": {} + "usingComponents": { + "invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog" + } } diff --git a/miniprogram/pages/index/result.wxml b/miniprogram/pages/index/result.wxml index 5b8fb28..730fb63 100644 --- a/miniprogram/pages/index/result.wxml +++ b/miniprogram/pages/index/result.wxml @@ -44,6 +44,15 @@ {{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}} + + 🔗 + + 填写好友邀请码 + 绑定推广关系 · 可选 + + + + 错误信息 @@ -88,7 +97,7 @@ - @@ -137,7 +148,7 @@ - + 优势摘录(预览约 30%) @@ -262,4 +273,10 @@ + + diff --git a/miniprogram/pages/result/mbti.js b/miniprogram/pages/result/mbti.js index aa239f5..210d84c 100644 --- a/miniprogram/pages/result/mbti.js +++ b/miniprogram/pages/result/mbti.js @@ -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 || {} diff --git a/miniprogram/pages/result/mbti.json b/miniprogram/pages/result/mbti.json index d234689..4653c82 100644 --- a/miniprogram/pages/result/mbti.json +++ b/miniprogram/pages/result/mbti.json @@ -1,5 +1,8 @@ { "navigationBarTitleText": "MBTI结果", "navigationBarBackgroundColor": "#FF6B8A", - "navigationBarTextStyle": "white" + "navigationBarTextStyle": "white", + "usingComponents": { + "invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog" + } } diff --git a/miniprogram/pages/result/mbti.wxml b/miniprogram/pages/result/mbti.wxml index d13d120..2de681d 100644 --- a/miniprogram/pages/result/mbti.wxml +++ b/miniprogram/pages/result/mbti.wxml @@ -32,6 +32,15 @@ + + 🔗 + + 填写好友邀请码 + 绑定推广关系 · 可选 + + + + @@ -94,23 +103,25 @@ • 职业匹配与人际关系建议 - - + + + + @@ -154,7 +165,7 @@ - + 优势摘录(预览约 30%) @@ -319,4 +330,10 @@ + + diff --git a/miniprogram/pages/result/mbti.wxss b/miniprogram/pages/result/mbti.wxss index 8d1a255..e7e02d4 100644 --- a/miniprogram/pages/result/mbti.wxss +++ b/miniprogram/pages/result/mbti.wxss @@ -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%; diff --git a/miniprogram/pages/result/pdp.js b/miniprogram/pages/result/pdp.js index bdd817c..d8226e3 100644 --- a/miniprogram/pages/result/pdp.js +++ b/miniprogram/pages/result/pdp.js @@ -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') diff --git a/miniprogram/pages/result/pdp.json b/miniprogram/pages/result/pdp.json index 30d9928..870a20a 100644 --- a/miniprogram/pages/result/pdp.json +++ b/miniprogram/pages/result/pdp.json @@ -1,5 +1,8 @@ { "navigationBarTitleText": "PDP结果", "navigationBarBackgroundColor": "#F59E0B", - "navigationBarTextStyle": "white" + "navigationBarTextStyle": "white", + "usingComponents": { + "invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog" + } } diff --git a/miniprogram/pages/result/pdp.wxml b/miniprogram/pages/result/pdp.wxml index 587eae2..5a9e8b2 100644 --- a/miniprogram/pages/result/pdp.wxml +++ b/miniprogram/pages/result/pdp.wxml @@ -33,6 +33,15 @@ + + 🔗 + + 填写好友邀请码 + 绑定推广关系 · 可选 + + + + @@ -95,9 +104,11 @@ • 推荐职业 - - 解锁完整报告 - ¥{{payInfo.amountYuan}} / 次 + + + 解锁完整报告 + ¥{{payInfo.amountYuan}} / 次 + @@ -137,7 +148,7 @@ - + 优势摘录(预览约 30%) @@ -275,4 +286,10 @@ + + diff --git a/miniprogram/pages/result/resume.js b/miniprogram/pages/result/resume.js index 643505f..e5650f4 100644 --- a/miniprogram/pages/result/resume.js +++ b/miniprogram/pages/result/resume.js @@ -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') { diff --git a/miniprogram/pages/result/resume.json b/miniprogram/pages/result/resume.json index 8d9d593..75882a2 100644 --- a/miniprogram/pages/result/resume.json +++ b/miniprogram/pages/result/resume.json @@ -1,4 +1,7 @@ { - "navigationBarTitleText": "简历综合分析" + "navigationBarTitleText": "简历综合分析", + "usingComponents": { + "invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog" + } } diff --git a/miniprogram/pages/result/resume.wxml b/miniprogram/pages/result/resume.wxml index 5bfb4d6..48dcc79 100644 --- a/miniprogram/pages/result/resume.wxml +++ b/miniprogram/pages/result/resume.wxml @@ -27,6 +27,15 @@ + + 🔗 + + 填写好友邀请码 + 绑定推广关系 · 可选 + + + + 失败原因 @@ -92,5 +101,11 @@ + + diff --git a/miniprogram/pages/result/sbti.js b/miniprogram/pages/result/sbti.js index 858fef7..1a24db0 100644 --- a/miniprogram/pages/result/sbti.js +++ b/miniprogram/pages/result/sbti.js @@ -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) { diff --git a/miniprogram/pages/result/sbti.json b/miniprogram/pages/result/sbti.json index 3d1409e..80a26e9 100644 --- a/miniprogram/pages/result/sbti.json +++ b/miniprogram/pages/result/sbti.json @@ -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" + } } diff --git a/miniprogram/pages/result/sbti.wxml b/miniprogram/pages/result/sbti.wxml index e08dd37..29df4c3 100644 --- a/miniprogram/pages/result/sbti.wxml +++ b/miniprogram/pages/result/sbti.wxml @@ -33,6 +33,15 @@ + + 🔗 + + 填写好友邀请码 + 绑定推广关系 · 可选 + + + + • 匹配度与兜底说明 - - + + + + @@ -140,7 +151,7 @@ - + 授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。 + + diff --git a/miniprogram/pages/user-profile/index.js b/miniprogram/pages/user-profile/index.js index e0ec373..60c889f 100644 --- a/miniprogram/pages/user-profile/index.js +++ b/miniprogram/pages/user-profile/index.js @@ -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() }, diff --git a/miniprogram/styles/invite-fill-entry.wxss b/miniprogram/styles/invite-fill-entry.wxss new file mode 100644 index 0000000..b5f95e4 --- /dev/null +++ b/miniprogram/styles/invite-fill-entry.wxss @@ -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; +} diff --git a/miniprogram/utils/inviteCodeGate.js b/miniprogram/utils/inviteCodeGate.js new file mode 100644 index 0000000..f270c55 --- /dev/null +++ b/miniprogram/utils/inviteCodeGate.js @@ -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} 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 +} diff --git a/miniprogram/utils/miniprogramAuditGate.js b/miniprogram/utils/miniprogramAuditGate.js index 22ab0d7..b1f1459 100644 --- a/miniprogram/utils/miniprogramAuditGate.js +++ b/miniprogram/utils/miniprogramAuditGate.js @@ -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 } diff --git a/miniprogram/utils/unlockGate.js b/miniprogram/utils/unlockGate.js new file mode 100644 index 0000000..561904c --- /dev/null +++ b/miniprogram/utils/unlockGate.js @@ -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} + */ +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 +} diff --git a/开发文档/小程序解锁引导与邀请码方案.md b/开发文档/小程序解锁引导与邀请码方案.md new file mode 100644 index 0000000..3740374 --- /dev/null +++ b/开发文档/小程序解锁引导与邀请码方案.md @@ -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 | 初稿:解锁分流 + 邀请码弹框需求与实现要点 |