1468 lines
49 KiB
JavaScript
1468 lines
49 KiB
JavaScript
// 神仙 AI · 对话页
|
||
const { request } = require('../../utils/request.js')
|
||
const analytics = require('../../utils/analytics.js')
|
||
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
|
||
const aiChatIntent = require('../../utils/aiChatIntent.js')
|
||
const { isAuditHideAiMode, redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
|
||
const { buildDeepHeroDesc, filterCategories } = require('../../utils/deepPricingFilter.js')
|
||
const payment = require('../../utils/payment')
|
||
const { hasPhone, bindPhoneByCode } = require('../../utils/phoneAuth.js')
|
||
|
||
/** 功能类推荐卡「阅读数」按发布日起算时的锚点日期 */
|
||
const FEATURE_RECO_PUBLISHED_AT = '2026-01-01'
|
||
|
||
/** 底部「深度画像报告」条:用户发言超过该条数才可能展示 */
|
||
const REPORT_CTA_MIN_USER_TURNS = 10
|
||
/** 报告条仅展示一次(点过或离开带条页面后不再出现) */
|
||
const STORAGE_REPORT_CTA_DISMISSED = 'ai_chat_deep_report_cta_dismissed'
|
||
|
||
function embeddedDeepCategories(scope) {
|
||
const app = getApp()
|
||
const gd = (app && app.globalData) || {}
|
||
const list = scope === 'enterprise' ? gd.deepPricingEnterprise : gd.deepPricingPersonal
|
||
return Array.isArray(list) && list.length ? list : null
|
||
}
|
||
|
||
function rememberCategoryKeys(cat, seen) {
|
||
const id = String((cat && cat.id) || '')
|
||
const pk = String((cat && cat.productKey) || '')
|
||
if (id) seen.add('id:' + id)
|
||
if (pk) seen.add('pk:' + pk)
|
||
}
|
||
|
||
function categorySeenInSet(cat, seen) {
|
||
const id = String((cat && cat.id) || '')
|
||
const pk = String((cat && cat.productKey) || '')
|
||
if (id && seen.has('id:' + id)) return true
|
||
if (pk && seen.has('pk:' + pk)) return true
|
||
return false
|
||
}
|
||
|
||
function mergeDeepPricingFromRuntime(scope, apiList) {
|
||
let list = Array.isArray(apiList) ? apiList.slice() : []
|
||
const emb = embeddedDeepCategories(scope)
|
||
if (!emb || !emb.length) return list
|
||
if (!list.length) return emb.slice()
|
||
if (list.length === 1 && emb.length > list.length) return emb.slice()
|
||
const seen = new Set()
|
||
list.forEach((c) => rememberCategoryKeys(c, seen))
|
||
emb.forEach((c) => {
|
||
if (!categorySeenInSet(c, seen)) {
|
||
list.push(c)
|
||
rememberCategoryKeys(c, seen)
|
||
}
|
||
})
|
||
return list
|
||
}
|
||
|
||
// 兜底封面(base64 SVG,紫色渐变 + sparkle)
|
||
const DEFAULT_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 220">' +
|
||
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' +
|
||
'<stop offset="0%" stop-color="#a78bfa"/><stop offset="100%" stop-color="#7c3aed"/>' +
|
||
'</linearGradient></defs>' +
|
||
'<rect width="440" height="220" fill="url(%23g)"/>' +
|
||
'<text x="50%" y="55%" font-size="42" fill="white" text-anchor="middle" font-family="PingFang SC" font-weight="700">MBTI</text>' +
|
||
'<text x="50%" y="80%" font-size="20" fill="rgba(255,255,255,0.9)" text-anchor="middle">精选推荐阅读</text>' +
|
||
'</svg>'
|
||
)
|
||
|
||
/** Fisher–Yates:生成 0..n-1 的随机排列,用于多篇文章轮播时均衡曝光 */
|
||
function shuffleIndexOrder(n) {
|
||
const arr = []
|
||
for (let i = 0; i < n; i++) arr.push(i)
|
||
for (let i = n - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
const t = arr[i]
|
||
arr[i] = arr[j]
|
||
arr[j] = t
|
||
}
|
||
return arr
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
articles: [],
|
||
/** 与 articles 等长的下标排列,第 k 次内嵌推荐取 articles[order[k % n]] */
|
||
articleRecoPickOrder: [],
|
||
defaultCover: DEFAULT_COVER,
|
||
/** 后台「神仙 AI 页展示」:为 true 时允许在第 2 轮及以后助手气泡内嵌精选(GET /api/ai/articles/recommended) */
|
||
articlesDisplayEnabled: false,
|
||
/** 后台配置的跳转小程序(有 appId 时优先 navigateToMiniProgram) */
|
||
articlesRecoJump: null,
|
||
articlesLoaded: false,
|
||
mbtiType: '',
|
||
nickname: '',
|
||
quickQuestions: [],
|
||
messages: [],
|
||
conversationId: 0,
|
||
draft: '',
|
||
sending: false,
|
||
scrollTarget: '',
|
||
showReportCta: false,
|
||
myReportStatus: '',
|
||
/** 对话内精选推荐:与后台 ai_chat_articles 同源(缺省 3~5 轮量级) */
|
||
inlineRecoMinUserTurns: 2,
|
||
inlineRecoInterval: 3,
|
||
inlineRecoRoll: 0.5,
|
||
inlineRecoIconCount: 3,
|
||
inlineRecoIconsPool: ['✨', '💬', '📌'],
|
||
/** 聊天区固定高度(px),与底部固定 composer 对齐,避免 scroll-view 被内容撑开压到输入条下 */
|
||
chatScrollHeightPx: 500,
|
||
auditPurchaseMode: false,
|
||
auditPurchaseLoading: false,
|
||
auditPurchaseError: '',
|
||
auditPurchaseTab: 'personal',
|
||
auditPurchasePersonal: [],
|
||
auditPurchaseEnterprise: [],
|
||
auditDeepHeroDesc: '',
|
||
auditHasPhone: false,
|
||
auditPurchasing: false,
|
||
auditSuccessModal: {
|
||
visible: false,
|
||
title: '',
|
||
content: ''
|
||
}
|
||
},
|
||
|
||
onLoad(options) {
|
||
this._fallbackChatScrollHeight()
|
||
const app = getApp()
|
||
const boot = () => {
|
||
if (isAuditHideAiMode(app && app.globalData)) {
|
||
redirectIfMiniprogramAudit('功能升级中')
|
||
return
|
||
}
|
||
const cid = parseInt(options && options.cid, 10)
|
||
if (cid > 0) {
|
||
this.setData({ conversationId: cid })
|
||
this.loadHistory(cid)
|
||
}
|
||
this.loadArticles()
|
||
this.tryBindReferral(options)
|
||
analytics.track('page_view', { pagePath: 'pages/ai-chat/index' })
|
||
const app = getApp()
|
||
const afterRuntime = () => {
|
||
const rt = (app.globalData && app.globalData.aiChatRuntime) || {}
|
||
this.applyInlineRecoDisplay(rt)
|
||
this.loadQuickQuestions()
|
||
this.loadMyReportStatus()
|
||
}
|
||
const pullRuntime = () => {
|
||
if (app && typeof app.getRuntimeConfig === 'function') {
|
||
app.getRuntimeConfig().then(afterRuntime).catch(afterRuntime)
|
||
} else {
|
||
afterRuntime()
|
||
}
|
||
}
|
||
// 先进 ensureLogin(含从 storage 回补 token),再拉 runtime,避免首进对话页发消息时仍无 Bearer
|
||
if (app && typeof app.ensureLogin === 'function') {
|
||
app.ensureLogin().finally(pullRuntime)
|
||
} else {
|
||
pullRuntime()
|
||
}
|
||
}
|
||
if (app && typeof app.getRuntimeConfig === 'function') {
|
||
app.getRuntimeConfig().then(boot).catch(boot)
|
||
} else {
|
||
boot()
|
||
}
|
||
},
|
||
|
||
/** 分享链接里带 inviterId 时,绑定分销关系(复用现有分销系统) */
|
||
tryBindReferral(options) {
|
||
const inviterId = parseInt(options && options.inviterId, 10)
|
||
if (!inviterId || inviterId <= 0) return
|
||
request({
|
||
url: '/api/distribution/bind',
|
||
method: 'POST',
|
||
data: { inviterId },
|
||
success() {}, fail() {}
|
||
})
|
||
},
|
||
|
||
/** 查询我最近的 AI 报告状态(决定是否展示 CTA) */
|
||
loadMyReportStatus() {
|
||
const token =
|
||
(getApp().globalData && getApp().globalData.token) || wx.getStorageSync('token') || ''
|
||
if (!token) return
|
||
const applyOk = (res) => {
|
||
const body = (res && res.data) || {}
|
||
const d = body.data || {}
|
||
this.setData({ myReportStatus: d.status || '' }, () => this._refreshReportCta())
|
||
}
|
||
const tryUrl = (url, next) => {
|
||
request({
|
||
url,
|
||
method: 'GET',
|
||
allow401: false,
|
||
success: (res) => {
|
||
const body = (res && res.data) || {}
|
||
if (res.statusCode === 200 && body.code === 200) {
|
||
applyOk(res)
|
||
return
|
||
}
|
||
if (typeof next === 'function') next()
|
||
},
|
||
fail: () => {
|
||
if (typeof next === 'function') next()
|
||
}
|
||
})
|
||
}
|
||
tryUrl('/api/ai/my-report/latest', () => tryUrl('/api/ai/report/my-latest'))
|
||
},
|
||
|
||
onReady() {
|
||
this._measureChatScrollHeight()
|
||
},
|
||
|
||
onShow() {
|
||
const app = getApp()
|
||
if (isAuditHideAiMode(app && app.globalData)) {
|
||
redirectIfMiniprogramAudit('功能升级中')
|
||
return
|
||
}
|
||
if (this.data.auditPurchaseMode) {
|
||
this.setData({ auditPurchaseMode: false })
|
||
}
|
||
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
|
||
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
|
||
try {
|
||
const st = wx.getStorageSync('token')
|
||
if (st && app && app.globalData && !app.globalData.token) {
|
||
app.globalData.token = st
|
||
}
|
||
} catch (e) {}
|
||
this.loadQuickQuestions()
|
||
this.loadMyReportStatus()
|
||
this._refreshReportCta()
|
||
if (typeof wx.onWindowResize === 'function' && !this._chatResizeListening) {
|
||
this._winResizeHandler = () => this._scheduleMeasureChatScroll()
|
||
wx.onWindowResize(this._winResizeHandler)
|
||
this._chatResizeListening = true
|
||
}
|
||
this._scheduleMeasureChatScroll()
|
||
},
|
||
|
||
/** 离开对话页时若报告条正在展示,记为已曝光,避免反复出现 */
|
||
onHide() {
|
||
if (this._chatResizeListening && this._winResizeHandler && typeof wx.offWindowResize === 'function') {
|
||
wx.offWindowResize(this._winResizeHandler)
|
||
this._chatResizeListening = false
|
||
this._winResizeHandler = null
|
||
}
|
||
if (this.data.showReportCta) {
|
||
this._dismissReportCtaPermanently()
|
||
}
|
||
},
|
||
|
||
onInput(e) {
|
||
this.setData({ draft: e.detail.value })
|
||
},
|
||
|
||
onInputFocus() {
|
||
this._scheduleMeasureChatScroll()
|
||
},
|
||
|
||
onAuditPurchaseSwitchTab(e) {
|
||
const tab = (e.currentTarget.dataset.tab || '') === 'enterprise' ? 'enterprise' : 'personal'
|
||
this.setData({ auditPurchaseTab: tab })
|
||
},
|
||
|
||
_enterAuditPurchaseMode(options) {
|
||
const tab = options && options.tab === 'enterprise' ? 'enterprise' : this.data.auditPurchaseTab
|
||
const app = getApp()
|
||
const ep = (app && app.globalData && app.globalData.enterprisePermissions) || null
|
||
this.setData({
|
||
auditPurchaseMode: true,
|
||
auditPurchaseTab: tab,
|
||
auditDeepHeroDesc: buildDeepHeroDesc(ep),
|
||
auditHasPhone: hasPhone()
|
||
})
|
||
this._loadAuditPurchasePricing()
|
||
},
|
||
|
||
_loadAuditPurchasePricing() {
|
||
this.setData({ auditPurchaseLoading: true, auditPurchaseError: '' })
|
||
Promise.all([this._requestAuditDeepPricing('personal'), this._requestAuditDeepPricing('enterprise')])
|
||
.then(([personal, enterprise]) => {
|
||
const pErr = personal && personal.__error
|
||
const eErr = enterprise && enterprise.__error
|
||
const pList = Array.isArray(personal) ? personal : []
|
||
const eList = Array.isArray(enterprise) ? enterprise : []
|
||
const app = getApp()
|
||
const ep = (app && app.globalData && app.globalData.enterprisePermissions) || null
|
||
this.setData({
|
||
auditPurchasePersonal: filterCategories(pList, ep),
|
||
auditPurchaseEnterprise: filterCategories(eList, ep),
|
||
auditDeepHeroDesc: buildDeepHeroDesc(ep),
|
||
auditPurchaseLoading: false,
|
||
auditPurchaseError: pErr && eErr
|
||
? (personal.__errorMsg || enterprise.__errorMsg || '加载失败,请稍后重试')
|
||
: ((!pList.length && !eList.length) ? '暂无可展示的套餐' : '')
|
||
})
|
||
})
|
||
.catch(() => {
|
||
this.setData({
|
||
auditPurchaseLoading: false,
|
||
auditPurchaseError: '加载失败,请稍后重试'
|
||
})
|
||
})
|
||
},
|
||
|
||
_requestAuditDeepPricing(scope, triedRuntime) {
|
||
const app = getApp()
|
||
const apiBase = (((app && app.globalData && app.globalData.apiBase) || '') + '').replace(/\/$/, '')
|
||
if (!apiBase) {
|
||
if (!triedRuntime && app && typeof app.getRuntimeConfig === 'function') {
|
||
return app
|
||
.getRuntimeConfig()
|
||
.catch(() => null)
|
||
.then(() => this._requestAuditDeepPricing(scope, true))
|
||
}
|
||
return Promise.resolve({ __error: true, __errorMsg: '服务地址未配置' })
|
||
}
|
||
return new Promise((resolve) => {
|
||
wx.request({
|
||
url: `${apiBase}/api/config/deep-pricing`,
|
||
method: 'GET',
|
||
data: { scope },
|
||
timeout: 25000,
|
||
success: (res) => {
|
||
if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
|
||
const merged = mergeDeepPricingFromRuntime(scope, res.data.data.categories)
|
||
resolve(merged)
|
||
} else {
|
||
const emb = embeddedDeepCategories(scope)
|
||
if (emb && emb.length) {
|
||
resolve(emb.slice())
|
||
return
|
||
}
|
||
resolve({
|
||
__error: true,
|
||
__errorMsg: (res && res.data && res.data.message) || '响应异常'
|
||
})
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
const emb = embeddedDeepCategories(scope)
|
||
if (emb && emb.length) {
|
||
resolve(emb.slice())
|
||
return
|
||
}
|
||
resolve({
|
||
__error: true,
|
||
__errorMsg: (err && err.errMsg) || '网络异常'
|
||
})
|
||
}
|
||
})
|
||
})
|
||
},
|
||
|
||
handleAuditPurchaseTap(e) {
|
||
const tab = e.currentTarget.dataset.tab
|
||
const index = e.currentTarget.dataset.index
|
||
this._handleAuditPurchase(tab, index)
|
||
},
|
||
|
||
onGetPhoneNumberForAuditPurchase(e) {
|
||
const tab = e.currentTarget.dataset.tab
|
||
const index = e.currentTarget.dataset.index
|
||
const { code, errMsg } = e.detail || {}
|
||
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
|
||
if (!hasPhone()) {
|
||
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
|
||
return
|
||
}
|
||
this._handleAuditPurchase(tab, index)
|
||
return
|
||
}
|
||
if (!code) {
|
||
if (hasPhone()) {
|
||
this._handleAuditPurchase(tab, index)
|
||
} else {
|
||
wx.showToast({ title: '获取手机号失败', icon: 'none' })
|
||
}
|
||
return
|
||
}
|
||
bindPhoneByCode(code)
|
||
.then(() => {
|
||
this.setData({ auditHasPhone: true })
|
||
this._handleAuditPurchase(tab, index)
|
||
})
|
||
.catch(() => {})
|
||
},
|
||
|
||
_handleAuditPurchase(tab, index) {
|
||
if (index === undefined || index === null) return
|
||
const isEnterprise = tab === 'enterprise'
|
||
const list = isEnterprise ? this.data.auditPurchaseEnterprise : this.data.auditPurchasePersonal
|
||
const category = list[index]
|
||
if (!category) return
|
||
this.setData({ auditPurchaseTab: isEnterprise ? 'enterprise' : 'personal' })
|
||
if (category.actionType === 'buy' && category.productKey) {
|
||
this._purchaseAuditPersonal(category)
|
||
} else {
|
||
this._applyAuditConsult(category)
|
||
}
|
||
},
|
||
|
||
_purchaseAuditPersonal(category) {
|
||
if (this.data.auditPurchasing) return
|
||
this.setData({ auditPurchasing: true })
|
||
wx.showLoading({ title: '处理中...', mask: true })
|
||
const deepProductId = category.id || category.productKey || ''
|
||
const title = category.title || '个人深度服务(1v1深度解读)'
|
||
payment.purchasePersonalDeepService({
|
||
deepProductId,
|
||
description: title,
|
||
success: () => {
|
||
wx.hideLoading()
|
||
this.setData({ auditPurchasing: false })
|
||
this._reportAuditCrmLead(category, 'buy')
|
||
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
|
||
this._showAuditSuccessModal('购买成功', successMsg)
|
||
},
|
||
fail: () => {
|
||
wx.hideLoading()
|
||
this.setData({ auditPurchasing: false })
|
||
}
|
||
})
|
||
},
|
||
|
||
_applyAuditConsult(category) {
|
||
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
|
||
wx.showLoading({ title: '提交中...', mask: true })
|
||
this._reportAuditCrmLead(category, 'consult', () => {
|
||
wx.hideLoading()
|
||
this._showAuditSuccessModal('申请成功', successMsg)
|
||
})
|
||
},
|
||
|
||
_showAuditSuccessModal(title, content) {
|
||
this.setData({
|
||
auditSuccessModal: {
|
||
visible: true,
|
||
title: title || '成功',
|
||
content: content || ''
|
||
}
|
||
})
|
||
},
|
||
|
||
catchTapAuditSuccess() {},
|
||
|
||
closeAuditSuccessModal() {
|
||
this.setData({ 'auditSuccessModal.visible': false })
|
||
},
|
||
|
||
_reportAuditCrmLead(category, actionType, onDone) {
|
||
const app = getApp()
|
||
const apiBase = (((app && app.globalData && app.globalData.apiBase) || '') + '').replace(/\/$/, '')
|
||
const apiKey = (category.consultWechat || '').trim()
|
||
const isConsult = actionType === 'consult'
|
||
const deepConsult = isConsult
|
||
const finish = () => {
|
||
if (typeof onDone === 'function') onDone()
|
||
}
|
||
if (!apiBase) {
|
||
finish()
|
||
return
|
||
}
|
||
if (!deepConsult && !apiKey) {
|
||
finish()
|
||
return
|
||
}
|
||
const isEnterprise = this.data.auditPurchaseTab === 'enterprise'
|
||
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
|
||
const remark =
|
||
actionType === 'buy'
|
||
? '完成付款'
|
||
: isEnterprise
|
||
? '申请咨询并降低30%成本'
|
||
: '申请咨询'
|
||
wx.request({
|
||
url: `${apiBase}/api/crm/report`,
|
||
method: 'POST',
|
||
header: {
|
||
Authorization: `Bearer ${wx.getStorageSync('token') || ''}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
data: {
|
||
apiKey,
|
||
source,
|
||
remark,
|
||
siteTags: category.title || '',
|
||
deepConsult: deepConsult ? true : false
|
||
},
|
||
success() {},
|
||
fail() {},
|
||
complete: finish
|
||
})
|
||
},
|
||
|
||
// ---------- 数据加载 ----------
|
||
|
||
loadArticles() {
|
||
const t = Date.now()
|
||
request({
|
||
url: `/api/ai/articles/recommended?_t=${t}`,
|
||
method: 'GET',
|
||
needAuth: false,
|
||
success: (res) => {
|
||
const body = (res && res.data) || {}
|
||
if (body.code !== 200) return
|
||
const rawList = (body.data && body.data.list) || []
|
||
const disp = (body.data && body.data.display) || {}
|
||
const enabled = !!disp.enabled
|
||
const maxShow = Math.max(1, Math.min(3, parseInt(disp.maxShow, 10) || 1))
|
||
const list = rawList.slice(0, maxShow)
|
||
const appId = ((disp.recoJumpMiniAppId || '') + '').trim()
|
||
const recoJump =
|
||
appId && /^wx[0-9a-f]{16}$/i.test(appId)
|
||
? {
|
||
appId: appId.toLowerCase(),
|
||
path: ((disp.recoJumpMiniPath || 'pages/index/index') + '').trim().replace(/^\//, '') || 'pages/index/index',
|
||
envVersion: ((disp.recoJumpMiniEnvVersion || 'release') + '').trim().toLowerCase() || 'release'
|
||
}
|
||
: null
|
||
const order = list.length ? shuffleIndexOrder(list.length) : []
|
||
this.applyInlineRecoDisplay(disp)
|
||
this.setData({
|
||
articles: list,
|
||
articleRecoPickOrder: order,
|
||
articlesDisplayEnabled: enabled,
|
||
articlesRecoJump: recoJump,
|
||
articlesLoaded: true
|
||
})
|
||
}
|
||
})
|
||
},
|
||
|
||
/** 合并后台下发的内嵌推荐参数(articles/display 或 runtime.aiChat) */
|
||
applyInlineRecoDisplay(disp) {
|
||
if (!disp || typeof disp !== 'object') return
|
||
const minTurns = Math.max(1, Math.min(10, parseInt(disp.inlineRecoMinUserTurns, 10) || 2))
|
||
const interval = Math.max(2, Math.min(10, parseInt(disp.inlineRecoInterval, 10) || 3))
|
||
let roll = parseFloat(disp.inlineRecoRoll)
|
||
if (Number.isNaN(roll)) roll = 0.5
|
||
roll = Math.max(0.05, Math.min(1, roll))
|
||
const iconCount = Math.max(1, Math.min(3, parseInt(disp.inlineRecoIconCount, 10) || 3))
|
||
let pool = ['✨', '💬', '📌']
|
||
if (Array.isArray(disp.inlineRecoIcons) && disp.inlineRecoIcons.length) {
|
||
pool = disp.inlineRecoIcons.map((x) => String(x || '').trim()).filter(Boolean)
|
||
if (!pool.length) pool = ['✨', '💬', '📌']
|
||
}
|
||
this.setData({
|
||
inlineRecoMinUserTurns: minTurns,
|
||
inlineRecoInterval: interval,
|
||
inlineRecoRoll: roll,
|
||
inlineRecoIconCount: iconCount,
|
||
inlineRecoIconsPool: pool
|
||
})
|
||
},
|
||
|
||
/** 每条推荐卡角标 emoji,总数不超过 3;可按意图前置一个相关符号 */
|
||
_pickIconsForReco(intent) {
|
||
const max = Math.max(
|
||
1,
|
||
Math.min(3, parseInt(this.data.inlineRecoIconCount, 10) || 3)
|
||
)
|
||
const base =
|
||
(this.data.inlineRecoIconsPool && this.data.inlineRecoIconsPool.length
|
||
? this.data.inlineRecoIconsPool.map(String)
|
||
: ['✨', '💬', '📌'])
|
||
const lead =
|
||
intent === 'job' ? '💼' : intent === 'test' ? '📝' : intent === 'self' ? '💎' : ''
|
||
const out = []
|
||
if (lead && base.indexOf(lead) < 0) out.push(lead)
|
||
for (let i = 0; i < base.length && out.length < max; i++) {
|
||
if (out.indexOf(base[i]) < 0) out.push(base[i])
|
||
}
|
||
const pad = ['✨', '💬', '📌', '💼', '📝', '💎']
|
||
for (let i = 0; i < pad.length && out.length < max; i++) {
|
||
if (out.indexOf(pad[i]) < 0) out.push(pad[i])
|
||
}
|
||
return out.slice(0, max)
|
||
},
|
||
|
||
/**
|
||
* 内嵌精选:后台 1~3 条在拉取时打乱为 articleRecoPickOrder,之后按对话轮次循环取,
|
||
* 保证多条时曝光均衡、又不会连续两条总盯同一篇(比每轮纯 Math.random 更稳)。
|
||
* 返回 { inlineArticle, articleRecoPickOrder? },后者仅在补全顺序时带上,供与 messages 同次 setData。
|
||
*/
|
||
_buildInlineRecoPayload(userTurns, showInlineReco) {
|
||
const out = { inlineArticle: null }
|
||
if (!showInlineReco) return out
|
||
const list = this.data.articles || []
|
||
if (!list.length) return out
|
||
let order = this.data.articleRecoPickOrder || []
|
||
if (!Array.isArray(order) || order.length !== list.length) {
|
||
order = shuffleIndexOrder(list.length)
|
||
out.articleRecoPickOrder = order
|
||
}
|
||
const n = list.length
|
||
const recoRound = Math.max(0, (userTurns || 0) - 2)
|
||
const listIdx = order[recoRound % n]
|
||
const a = list[listIdx]
|
||
if (!a || !a.id) return out
|
||
out.inlineArticle = {
|
||
id: a.id,
|
||
title: a.title || '',
|
||
url: a.url || '',
|
||
cover: a.cover || '',
|
||
tag: a.tag || '',
|
||
publishedAt: a.publishedAt || '',
|
||
sourceId: a.sourceId || '',
|
||
readCountLabel: aiChatIntent.formatInlineReadLabel(a.publishedAt || ''),
|
||
icons: this._pickIconsForReco('')
|
||
}
|
||
return out
|
||
},
|
||
|
||
/** 与「我的」同源的功能快捷推荐(仅展示卡片,点击再跳转) */
|
||
_buildFeatureInlineReco(intent, hasMbti) {
|
||
const readCountLabel = aiChatIntent.formatInlineReadLabel(FEATURE_RECO_PUBLISHED_AT)
|
||
if (intent === 'job') {
|
||
return {
|
||
kind: 'feature',
|
||
id: 'feat_match_job',
|
||
title: '匹配工作:按性格看岗位与发展方向',
|
||
url: '',
|
||
cover: '',
|
||
tag: '匹配工作',
|
||
publishedAt: '',
|
||
sourceId: '',
|
||
navPath: '/pages/match-job/index',
|
||
readCountLabel,
|
||
icons: this._pickIconsForReco('job')
|
||
}
|
||
}
|
||
if (intent === 'test') {
|
||
return {
|
||
kind: 'feature',
|
||
id: 'feat_test_select',
|
||
title: 'MBTI 性格测评 · 进入测试列表',
|
||
url: '',
|
||
cover: '',
|
||
tag: '性格测试',
|
||
publishedAt: '',
|
||
sourceId: '',
|
||
navPath: '/pages/test-select/index',
|
||
readCountLabel,
|
||
icons: this._pickIconsForReco('test')
|
||
}
|
||
}
|
||
if (intent === 'self') {
|
||
const path = aiChatIntent.pickSelfServicePath(!!hasMbti)
|
||
const toTest = path.indexOf('test-select') >= 0
|
||
return {
|
||
kind: 'feature',
|
||
id: toTest ? 'feat_self_test' : 'feat_self_know',
|
||
title: toTest ? '性格测试 · 更系统地认识自己' : '深度了解自己 · 专属服务入口',
|
||
url: '',
|
||
cover: '',
|
||
tag: toTest ? '性格测试' : '了解自己',
|
||
publishedAt: '',
|
||
sourceId: '',
|
||
navPath: path,
|
||
readCountLabel,
|
||
icons: this._pickIconsForReco('self')
|
||
}
|
||
}
|
||
return null
|
||
},
|
||
|
||
/** 去测 MBTI → 详细性格测试列表 */
|
||
onGoMbtiTest() {
|
||
try {
|
||
analytics.track('ai_chat_go_test_select', {})
|
||
} catch (e) {}
|
||
wx.navigateTo({
|
||
url: '/pages/test-select/index',
|
||
fail: () => {
|
||
wx.showToast({ title: '暂时无法打开测试列表', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
|
||
loadQuickQuestions() {
|
||
const apply = (body) => {
|
||
if (!body || body.code !== 200) return false
|
||
const d = body.data || {}
|
||
this.setData(
|
||
{
|
||
mbtiType: d.mbtiType || '',
|
||
nickname: d.nickname || '',
|
||
quickQuestions: this.buildQuickQuestions(d.questions || [])
|
||
},
|
||
() => this._scheduleMeasureChatScroll()
|
||
)
|
||
return true
|
||
}
|
||
const applyDirect = (d) => {
|
||
if (!d || !Array.isArray(d.questions) || d.questions.length === 0) return false
|
||
this.setData(
|
||
{
|
||
mbtiType: d.mbtiType || '',
|
||
nickname: d.nickname || '',
|
||
quickQuestions: this.buildQuickQuestions(d.questions)
|
||
},
|
||
() => this._scheduleMeasureChatScroll()
|
||
)
|
||
return true
|
||
}
|
||
const fallbackLocal = () => {
|
||
this.setData({ quickQuestions: this.buildQuickQuestions([]) }, () =>
|
||
this._scheduleMeasureChatScroll()
|
||
)
|
||
}
|
||
const tryLegacy = () => {
|
||
request({
|
||
url: '/api/ai/quick-questions',
|
||
method: 'GET',
|
||
needAuth: false,
|
||
optionalAuth: true,
|
||
allow401: false,
|
||
success: (res2) => {
|
||
if (!apply((res2 && res2.data) || {})) fallbackLocal()
|
||
},
|
||
fail: fallbackLocal
|
||
})
|
||
}
|
||
const tryConfigUrl = (path, next) => {
|
||
request({
|
||
url: path,
|
||
method: 'GET',
|
||
needAuth: false,
|
||
optionalAuth: true,
|
||
allow401: false,
|
||
success: (res) => {
|
||
const body = (res && res.data) || {}
|
||
if (apply(body)) return
|
||
next()
|
||
},
|
||
fail: next
|
||
})
|
||
}
|
||
// 线上网关常只放行 /api/config/runtime,快捷问句已内嵌其中
|
||
try {
|
||
const run = getApp().globalData && getApp().globalData.runtimeAiQuickQuestions
|
||
if (applyDirect(run)) return
|
||
} catch (e) {}
|
||
// 先试短路径 config/quick-questions,再试 ai-quick-questions,最后 /api/ai/quick-questions
|
||
tryConfigUrl('/api/config/quick-questions', () => {
|
||
tryConfigUrl('/api/config/ai-quick-questions', tryLegacy)
|
||
})
|
||
},
|
||
|
||
buildQuickQuestions(serverList) {
|
||
const preferred = [
|
||
'帮我了解并分析一下我现在的状态',
|
||
'我适合什么样的伴侣关系?',
|
||
'我应该找什么样的工作?',
|
||
'我下一步的职业发展方向是什么?',
|
||
'我的性格优势该怎么变现?',
|
||
'我现在最该改掉的一个习惯是什么?',
|
||
'我更适合独立做事还是团队协作?',
|
||
'给我一句今天就能做的小行动建议'
|
||
]
|
||
const all = []
|
||
;(Array.isArray(serverList) ? serverList : []).forEach((q) => {
|
||
if (typeof q === 'string' && q.trim()) all.push(q.trim())
|
||
})
|
||
preferred.forEach((q) => all.push(q))
|
||
const uniq = []
|
||
const seen = {}
|
||
all.forEach((q) => {
|
||
if (!seen[q]) {
|
||
seen[q] = true
|
||
uniq.push(q)
|
||
}
|
||
})
|
||
return uniq.slice(0, 8)
|
||
},
|
||
|
||
loadHistory(cid) {
|
||
const token =
|
||
(getApp().globalData && getApp().globalData.token) || wx.getStorageSync('token') || ''
|
||
if (!token) return
|
||
request({
|
||
url: `/api/ai/conversations/${cid}/messages`,
|
||
method: 'GET',
|
||
allow401: false,
|
||
success: (res) => {
|
||
const body = (res && res.data) || {}
|
||
if (body.code !== 200) return
|
||
const d = body.data || {}
|
||
const msgs = (d.messages || []).filter(m => m.role !== 'system')
|
||
this.setData({ messages: msgs }, () => {
|
||
this.scrollToBottom()
|
||
setTimeout(() => this.scrollToBottom(), 120)
|
||
this._scheduleMeasureChatScroll()
|
||
this._refreshReportCta()
|
||
})
|
||
}
|
||
})
|
||
},
|
||
|
||
// ---------- 交互 ----------
|
||
|
||
onTapQuick(e) {
|
||
const q = (e.currentTarget.dataset.q || '').trim()
|
||
if (!q || this.data.sending) return
|
||
analytics.track('ai_quick_question_click', { question: q })
|
||
this.setData({ draft: q }, () => this.onSend())
|
||
},
|
||
|
||
_openArticleWebview(url) {
|
||
const enc = encodeURIComponent(url)
|
||
wx.navigateTo({
|
||
url: `/pages/webview/index?url=${enc}`,
|
||
fail: () => {
|
||
wx.setClipboardData({
|
||
data: url,
|
||
success: () => wx.showToast({ title: '已复制链接', icon: 'none' })
|
||
})
|
||
}
|
||
})
|
||
},
|
||
|
||
onTapArticle(e) {
|
||
const ds = e.currentTarget.dataset || {}
|
||
const navPath = String(ds.navpath || ds.navPath || '').trim()
|
||
if (navPath) {
|
||
try {
|
||
analytics.track('ai_chat_feature_reco_tap', { path: navPath })
|
||
} catch (err) {}
|
||
const urlPath = navPath.startsWith('/') ? navPath : '/' + navPath
|
||
wx.navigateTo({
|
||
url: urlPath,
|
||
fail: () => wx.showToast({ title: '暂时无法打开该功能', icon: 'none' })
|
||
})
|
||
return
|
||
}
|
||
const id = ds.id
|
||
const url = ds.url
|
||
const title = ds.title
|
||
const sourceId = ds.sourceId
|
||
analytics.track('tap_ai_article', { articleId: id, url, title, sourceId })
|
||
// 轻量回传点击(异步);功能卡 id 非数字,跳过
|
||
if (id && String(id).indexOf('feat_') !== 0) {
|
||
request({
|
||
url: `/api/ai/articles/${id}/click`,
|
||
method: 'POST',
|
||
needAuth: false,
|
||
success() {}, fail() {}
|
||
})
|
||
}
|
||
const jump = this.data.articlesRecoJump
|
||
if (jump && jump.appId) {
|
||
const path = buildRecoMiniProgramPath(jump.path, {
|
||
id,
|
||
title,
|
||
sourceId,
|
||
fromTag: 'mbti_ai_reco'
|
||
})
|
||
const env = jump.envVersion === 'trial' || jump.envVersion === 'develop' ? jump.envVersion : 'release'
|
||
wx.navigateToMiniProgram({
|
||
appId: jump.appId,
|
||
path,
|
||
envVersion: env,
|
||
fail: (err) => {
|
||
if (url) {
|
||
this._openArticleWebview(url)
|
||
} else {
|
||
wx.showToast({
|
||
title: (err && err.errMsg) || '无法打开目标小程序',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
})
|
||
return
|
||
}
|
||
if (!url) return
|
||
this._openArticleWebview(url)
|
||
},
|
||
|
||
/**
|
||
* @param {{ bubbleText: string, apiMessage: string, intentText: string, rollbackDraft: string, resumeFileUrl?: string, resumeFileName?: string }} ctx
|
||
*/
|
||
_beginChatOutgoing(ctx) {
|
||
const bubbleText = (ctx.bubbleText || '').trim()
|
||
const apiMessage = ctx.apiMessage != null ? String(ctx.apiMessage) : ''
|
||
const intentText = (ctx.intentText || bubbleText || '').trim()
|
||
const rollbackDraft = ctx.rollbackDraft != null ? String(ctx.rollbackDraft) : ''
|
||
const resumeFileUrl = (ctx.resumeFileUrl || '').trim()
|
||
const resumeFileName = (ctx.resumeFileName || '').trim()
|
||
|
||
if (this.data.sending) return
|
||
if (!bubbleText && !resumeFileUrl) return
|
||
|
||
const prevMessages = this.data.messages.slice()
|
||
|
||
const now = Date.now()
|
||
const userMsg = {
|
||
id: 'u_' + now,
|
||
role: 'user',
|
||
content: bubbleText || '📎 已上传简历,请神仙 AI 分析',
|
||
createdAt: Math.floor(now / 1000)
|
||
}
|
||
const messagesWithUser = this.data.messages.concat([userMsg])
|
||
const clearDraft = !resumeFileUrl
|
||
this.setData(
|
||
{
|
||
messages: messagesWithUser,
|
||
draft: clearDraft ? '' : this.data.draft,
|
||
sending: true
|
||
},
|
||
() => {
|
||
this.scrollToBottom()
|
||
}
|
||
)
|
||
|
||
try {
|
||
analytics.track('ai_chat_send', {
|
||
length: (apiMessage || bubbleText).length,
|
||
resume: resumeFileUrl ? 1 : 0
|
||
})
|
||
} catch (e) {}
|
||
|
||
const stripPending = (list) => (list || []).filter((m) => !m.isPending)
|
||
|
||
const applyAssistantPayload = (d) => {
|
||
clearSendWatchdog()
|
||
const aiMsg = (d && d.message) || {}
|
||
const base = stripPending(this.data.messages)
|
||
const userTurns = base.filter((m) => m.role === 'user').length
|
||
const assistantText = (aiMsg.content || '').trim()
|
||
const isDegraded = !!aiMsg.isDegraded
|
||
|
||
const minT = Math.max(1, Math.min(10, parseInt(this.data.inlineRecoMinUserTurns, 10) || 2))
|
||
const recoN = Math.max(2, Math.min(10, parseInt(this.data.inlineRecoInterval, 10) || 3))
|
||
let recoRoll = parseFloat(this.data.inlineRecoRoll)
|
||
if (Number.isNaN(recoRoll)) recoRoll = 0.5
|
||
recoRoll = Math.max(0.05, Math.min(1, recoRoll))
|
||
const gateSlot =
|
||
userTurns >= minT &&
|
||
(userTurns - minT) % recoN === 0 &&
|
||
Math.random() < recoRoll
|
||
|
||
let inlineArticle = null
|
||
let recoPackExtra = null
|
||
|
||
if (gateSlot && !isDegraded) {
|
||
const hasMbti = !!(this.data.mbtiType && String(this.data.mbtiType).trim())
|
||
const featIntent = aiChatIntent.resolveChatFeatureIntent(intentText, hasMbti)
|
||
if (featIntent && aiChatIntent.replyMatchesFeatureIntent(featIntent, assistantText)) {
|
||
inlineArticle = this._buildFeatureInlineReco(featIntent, hasMbti)
|
||
} else if (
|
||
!featIntent &&
|
||
this.data.articlesDisplayEnabled &&
|
||
(this.data.articles || []).length > 0
|
||
) {
|
||
recoPackExtra = this._buildInlineRecoPayload(userTurns, true)
|
||
inlineArticle = recoPackExtra.inlineArticle
|
||
}
|
||
}
|
||
|
||
const next = base.concat([
|
||
{
|
||
id: aiMsg.id || 'a_' + Date.now(),
|
||
role: 'assistant',
|
||
content: aiMsg.content || '',
|
||
isDegraded: !!aiMsg.isDegraded,
|
||
providerId: aiMsg.providerId || '',
|
||
createdAt: aiMsg.createdAt || Math.floor(Date.now() / 1000),
|
||
inlineArticle
|
||
}
|
||
])
|
||
const setPayload = {
|
||
messages: next,
|
||
conversationId: (d && d.conversationId) || this.data.conversationId,
|
||
sending: false
|
||
}
|
||
if (recoPackExtra && recoPackExtra.articleRecoPickOrder) {
|
||
setPayload.articleRecoPickOrder = recoPackExtra.articleRecoPickOrder
|
||
}
|
||
this.setData(
|
||
setPayload,
|
||
() => {
|
||
this.scrollToBottom()
|
||
setTimeout(() => this.scrollToBottom(), 120)
|
||
setTimeout(() => this.scrollToBottom(), 380)
|
||
this._refreshReportCta()
|
||
}
|
||
)
|
||
analytics.track('ai_chat_receive', {
|
||
providerId: aiMsg.providerId || '',
|
||
length: (aiMsg.content || '').length,
|
||
inlineReco: inlineArticle ? 1 : 0
|
||
})
|
||
if (inlineArticle) {
|
||
try {
|
||
analytics.track('ai_chat_inline_reco_show', {
|
||
articleId: inlineArticle.id,
|
||
kind: inlineArticle.kind || 'article',
|
||
navPath: inlineArticle.navPath || ''
|
||
})
|
||
} catch (e) {}
|
||
}
|
||
if (aiMsg.isDegraded) {
|
||
analytics.track('ai_chat_degrade', { providerId: aiMsg.providerId || '' })
|
||
wx.showToast({
|
||
title: '当前为备用说明,非模型完整回复',
|
||
icon: 'none',
|
||
duration: 2800
|
||
})
|
||
}
|
||
}
|
||
|
||
let sendWatchdog = null
|
||
const clearSendWatchdog = () => {
|
||
if (sendWatchdog) {
|
||
clearTimeout(sendWatchdog)
|
||
sendWatchdog = null
|
||
}
|
||
}
|
||
|
||
const failSend = (toastTitle) => {
|
||
clearSendWatchdog()
|
||
if (toastTitle) {
|
||
wx.showToast({ title: toastTitle, icon: 'none', duration: 2800 })
|
||
}
|
||
this.setData({
|
||
messages: prevMessages,
|
||
draft: rollbackDraft,
|
||
sending: false
|
||
})
|
||
}
|
||
|
||
const pollChatJob = (jobId) => {
|
||
let attempts = 0
|
||
const maxAttempts = 150
|
||
const delayMs = 1200
|
||
let netFailStreak = 0
|
||
const tick = () => {
|
||
if (attempts++ >= maxAttempts) {
|
||
failSend('等待回复超时,请稍后在历史记录中查看或重发')
|
||
return
|
||
}
|
||
request({
|
||
url: '/api/ai/chat/job?jobId=' + encodeURIComponent(jobId),
|
||
method: 'GET',
|
||
timeout: 60000,
|
||
success: (res) => {
|
||
const sc = res && res.statusCode
|
||
if (sc && (sc < 200 || sc >= 300)) {
|
||
netFailStreak++
|
||
if (netFailStreak <= 5) {
|
||
setTimeout(tick, 500 * netFailStreak)
|
||
} else {
|
||
failSend('网络异常,稍后再试')
|
||
}
|
||
return
|
||
}
|
||
netFailStreak = 0
|
||
const raw = res && res.data
|
||
const body = raw && typeof raw === 'object' ? raw : {}
|
||
const c = body.code
|
||
const isBizOk = c === 200 || c === '200' || Number(c) === 200
|
||
if (!isBizOk) {
|
||
const msg =
|
||
(body.message && String(body.message).trim()) || '获取回复失败'
|
||
const codeNum = Number(body.code)
|
||
const maybeRace =
|
||
(codeNum === 404 || /任务不存在|已过期/i.test(String(msg))) &&
|
||
attempts <= 15
|
||
if (maybeRace) {
|
||
setTimeout(tick, 400)
|
||
return
|
||
}
|
||
failSend(msg.length > 40 ? msg.slice(0, 39) + '…' : msg)
|
||
return
|
||
}
|
||
const d = body.data || {}
|
||
if (d.pending) {
|
||
setTimeout(tick, delayMs)
|
||
return
|
||
}
|
||
applyAssistantPayload(d)
|
||
},
|
||
fail: () => {
|
||
netFailStreak++
|
||
if (netFailStreak <= 5) {
|
||
setTimeout(tick, 600 * netFailStreak)
|
||
} else {
|
||
failSend('网络异常,稍后再试')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
tick()
|
||
}
|
||
|
||
const postChat = (isTimeoutRetry) => {
|
||
request({
|
||
url: '/api/ai/chat',
|
||
method: 'POST',
|
||
// 微信客户端对 wx.request 超时上限多为 60s;首包应极快(后端已改为轻量上下文)
|
||
timeout: 60000,
|
||
data: {
|
||
conversationId: this.data.conversationId || 0,
|
||
message: apiMessage,
|
||
resumeFileUrl: resumeFileUrl || undefined,
|
||
resumeFileName: resumeFileName || undefined
|
||
},
|
||
success: (res) => {
|
||
const raw = res && res.data
|
||
const body = raw && typeof raw === 'object' ? raw : {}
|
||
const c = body.code
|
||
const isBizOk = c === 200 || c === '200' || Number(c) === 200
|
||
if (!isBizOk) {
|
||
const bizCode = Number(body.code)
|
||
let msg = (body.message && String(body.message).trim()) || '发送失败,请稍后再试'
|
||
if (bizCode === 401 || /未登录|Token无效|token无效|请先登录/i.test(String(msg))) {
|
||
msg = '需要先微信登录才能对话,请到「我的」授权后再试'
|
||
} else {
|
||
const legacyDaily =
|
||
bizCode === 429 ||
|
||
/今日对话次数已用完|今日对话次数|明天再来找我|次数已用完.*明天|对话次数已用完/i.test(
|
||
String(msg)
|
||
)
|
||
if (legacyDaily) {
|
||
const rt = (getApp().globalData && getApp().globalData.aiChatRuntime) || {}
|
||
const runtimeNew = rt.unlimited === true && Number(rt.dailyMessageLimit) === 0
|
||
msg = runtimeNew
|
||
? '对话接口仍是旧版,请在服务器部署最新 api/AiChat.php'
|
||
: '对话功能需更新后端;请部署最新代码并确认超管已启用 AI 服务商'
|
||
}
|
||
}
|
||
if (msg.length > 36) msg = msg.slice(0, 35) + '…'
|
||
failSend(msg)
|
||
return
|
||
}
|
||
|
||
const d = body.data || {}
|
||
if (d.async && d.jobId) {
|
||
const cid = d.conversationId || this.data.conversationId || 0
|
||
if (cid > 0 && this.data.conversationId !== cid) {
|
||
this.setData({ conversationId: cid })
|
||
}
|
||
pollChatJob(String(d.jobId))
|
||
return
|
||
}
|
||
|
||
// 兼容旧版同步响应(无 async)
|
||
applyAssistantPayload(d)
|
||
},
|
||
fail: (err) => {
|
||
const em = (err && err.errMsg) ? String(err.errMsg) : ''
|
||
const isTimeout = /timeout|超时/i.test(em)
|
||
if (isTimeout && !isTimeoutRetry) {
|
||
postChat(true)
|
||
return
|
||
}
|
||
failSend(
|
||
isTimeout ? '提交对话超时,请检查网络与合法域名后重试' : '网络异常,稍后再试'
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
const app = getApp()
|
||
let hasToken = !!(app.globalData && app.globalData.token)
|
||
try {
|
||
const st = wx.getStorageSync('token')
|
||
if (st && app && app.globalData && !app.globalData.token) {
|
||
app.globalData.token = st
|
||
}
|
||
hasToken = !!(app.globalData && app.globalData.token) || !!st
|
||
} catch (e) {
|
||
hasToken = !!(app.globalData && app.globalData.token)
|
||
}
|
||
sendWatchdog = setTimeout(() => {
|
||
sendWatchdog = null
|
||
if (!this.data.sending) return
|
||
failSend('对话请求过久,请检查网络、关闭代理或到开发者工具「不校验合法域名」后重试')
|
||
}, 125000)
|
||
|
||
if (hasToken) {
|
||
postChat(false)
|
||
return
|
||
}
|
||
app
|
||
.ensureLogin()
|
||
.then((ok) => {
|
||
if (ok) {
|
||
postChat(false)
|
||
return
|
||
}
|
||
clearSendWatchdog()
|
||
this.setData({ messages: prevMessages, draft: rollbackDraft, sending: false })
|
||
wx.showModal({
|
||
title: '需要登录',
|
||
content: '神仙 AI 对话需要先完成微信登录,是否前往「我的」?',
|
||
confirmText: '去登录',
|
||
cancelText: '取消',
|
||
success: (r) => {
|
||
if (r.confirm) wx.switchTab({ url: '/pages/profile/index' })
|
||
}
|
||
})
|
||
})
|
||
.catch(() => {
|
||
clearSendWatchdog()
|
||
this.setData({ messages: prevMessages, draft: rollbackDraft, sending: false })
|
||
wx.showToast({ title: '登录失败,请稍后再试', icon: 'none' })
|
||
})
|
||
},
|
||
|
||
onSend() {
|
||
const content = (this.data.draft || '').trim()
|
||
if (!content || this.data.sending) return
|
||
this._beginChatOutgoing({
|
||
bubbleText: content,
|
||
apiMessage: content,
|
||
intentText: content,
|
||
rollbackDraft: content
|
||
})
|
||
},
|
||
|
||
_fallbackChatScrollHeight() {
|
||
try {
|
||
const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
|
||
const wh = sys.windowHeight || 600
|
||
const sw = sys.screenWidth || 375
|
||
const rpx2px = (r) => (r / 750) * sw
|
||
const inset = sys.safeAreaInsets || {}
|
||
const safeBottom = typeof inset.bottom === 'number' ? inset.bottom : 0
|
||
// Tab 区 + 固定 composer(含快捷语行)在 rpx 上的经验预留
|
||
const reserve = rpx2px(168 + 300) + safeBottom
|
||
const h = Math.max(200, Math.floor(wh - reserve - rpx2px(20)))
|
||
this.setData({ chatScrollHeightPx: h })
|
||
} catch (e) {
|
||
this.setData({ chatScrollHeightPx: 500 })
|
||
}
|
||
},
|
||
|
||
_scheduleMeasureChatScroll() {
|
||
if (this._chatLayoutTimer) clearTimeout(this._chatLayoutTimer)
|
||
this._chatLayoutTimer = setTimeout(() => {
|
||
this._chatLayoutTimer = null
|
||
this._measureChatScrollHeight()
|
||
}, 80)
|
||
},
|
||
|
||
_measureChatScrollHeight(retry) {
|
||
wx.nextTick(() => {
|
||
wx.createSelectorQuery()
|
||
.in(this)
|
||
.select('.chat-scroll')
|
||
.boundingClientRect()
|
||
.select('.composer-wrap')
|
||
.boundingClientRect()
|
||
.exec((res) => {
|
||
const scrollRect = res && res[0]
|
||
const composerRect = res && res[1]
|
||
if (!scrollRect || !composerRect || typeof composerRect.top !== 'number') {
|
||
this._fallbackChatScrollHeight()
|
||
return
|
||
}
|
||
const gapPx = 6
|
||
const h = composerRect.top - scrollRect.top - gapPx
|
||
if (h > 120) {
|
||
this.setData({ chatScrollHeightPx: Math.round(h) }, () => {
|
||
this.scrollToBottom()
|
||
})
|
||
} else if (!retry) {
|
||
setTimeout(() => this._measureChatScrollHeight(true), 200)
|
||
} else {
|
||
this._fallbackChatScrollHeight()
|
||
}
|
||
})
|
||
})
|
||
},
|
||
|
||
/**
|
||
* 滚到底部(对齐微信聊天:新发/收信后总能看到最新消息)。
|
||
* scroll-view 的 scroll-into-view 在连续设为同一 id 时不会触发,需先清空再设;
|
||
* 锚点统一用 #bottom(位于打字条、报告条之后),避免滚到最后一条气泡 id 仍被底部输入区挡住。
|
||
*/
|
||
scrollToBottom() {
|
||
const go = () => {
|
||
this.setData({ scrollTarget: 'bottom' })
|
||
}
|
||
this.setData({ scrollTarget: '' }, () => {
|
||
if (typeof wx !== 'undefined' && typeof wx.nextTick === 'function') {
|
||
wx.nextTick(go)
|
||
} else {
|
||
setTimeout(go, 20)
|
||
}
|
||
})
|
||
},
|
||
|
||
/** 对话内上传简历:直传 OSS 后走 /api/ai/chat,由神仙 AI 结合测评与正文回复(不跳转匹配工作页) */
|
||
onTapUploadResume() {
|
||
if (this.data.sending) return
|
||
try {
|
||
analytics.track('ai_chat_resume_entry_tap', {})
|
||
} catch (e) {}
|
||
const app = getApp()
|
||
const goChoose = () => {
|
||
wx.chooseMessageFile({
|
||
count: 1,
|
||
type: 'file',
|
||
extension: ['pdf', 'doc', 'docx'],
|
||
success: (fileRes) => {
|
||
const files = fileRes.tempFiles || []
|
||
if (!files.length) return
|
||
const file = files[0]
|
||
const filePath = file.path || file.tempFilePath
|
||
const fileName = file.name || '简历.pdf'
|
||
this._uploadResumeAndChat(filePath, fileName)
|
||
},
|
||
fail: (err) => {
|
||
const msg = (err && err.errMsg) || ''
|
||
if (!/cancel/i.test(msg)) {
|
||
wx.showToast({ title: '请选择 pdf/doc/docx 简历', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
}
|
||
if (app && typeof app.ensureLogin === 'function') {
|
||
app.ensureLogin().then((ok) => {
|
||
if (ok) goChoose()
|
||
else wx.showToast({ title: '请先登录后再上传简历', icon: 'none' })
|
||
})
|
||
return
|
||
}
|
||
goChoose()
|
||
},
|
||
|
||
_uploadResumeAndChat(filePath, fileName) {
|
||
const app = getApp()
|
||
wx.showLoading({ title: '上传简历…', mask: true })
|
||
const apiBase =
|
||
app.globalData && app.globalData.apiBase ? app.globalData.apiBase.replace(/\/$/, '') : ''
|
||
const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
|
||
wx.uploadFile({
|
||
url: apiBase + '/api/upload/file',
|
||
filePath,
|
||
name: 'file',
|
||
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||
success: (res) => {
|
||
wx.hideLoading()
|
||
let url = ''
|
||
try {
|
||
const data = JSON.parse(res.data || '{}')
|
||
if (data.code === 200 && data.data && data.data.url) url = data.data.url
|
||
} catch (e) {}
|
||
if (!url) {
|
||
wx.showToast({ title: '上传失败,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
request({
|
||
url: '/api/enterprise/resume-uploads',
|
||
method: 'POST',
|
||
needAuth: true,
|
||
data: { url, fileName },
|
||
success: () => {},
|
||
fail: () => {}
|
||
})
|
||
const bubble =
|
||
'📎 已上传「' +
|
||
fileName +
|
||
'」,请结合我的性格测评,直接在这份对话里帮我分析简历与职业匹配。'
|
||
this._beginChatOutgoing({
|
||
bubbleText: bubble,
|
||
apiMessage: '',
|
||
intentText: bubble,
|
||
rollbackDraft: '',
|
||
resumeFileUrl: url,
|
||
resumeFileName: fileName
|
||
})
|
||
},
|
||
fail: () => {
|
||
wx.hideLoading()
|
||
wx.showToast({ title: '上传失败', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
|
||
/** 点击"邀请赚佣金"快捷方式:触发原生分享 */
|
||
onTapShare() {
|
||
analytics.track('ai_chat_share_invite_tap', {})
|
||
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
|
||
wx.showToast({ title: '点右上角 ··· 分享给好友', icon: 'none' })
|
||
},
|
||
|
||
/** 点击"生成深度报告 CTA" */
|
||
onTapReportCta() {
|
||
analytics.track('ai_report_cta_tap', { messageCount: this.data.messages.length })
|
||
const cid = this.data.conversationId || 0
|
||
const go = () => {
|
||
wx.navigateTo({
|
||
url: `/pages/ai-chat/report?cid=${cid}`,
|
||
success: () => this._dismissReportCtaPermanently(),
|
||
fail: () => {
|
||
wx.showToast({ title: '报告页暂时无法打开', icon: 'none' })
|
||
}
|
||
})
|
||
}
|
||
const app = getApp()
|
||
if (app && typeof app.ensureLogin === 'function') {
|
||
app.ensureLogin().then((ok) => {
|
||
if (ok) go()
|
||
else wx.showToast({ title: '请先登录后再购买报告', icon: 'none' })
|
||
})
|
||
return
|
||
}
|
||
go()
|
||
},
|
||
|
||
_dismissReportCtaPermanently() {
|
||
try {
|
||
wx.setStorageSync(STORAGE_REPORT_CTA_DISMISSED, 1)
|
||
} catch (e) {}
|
||
if (this.data.showReportCta) {
|
||
this.setData({ showReportCta: false })
|
||
}
|
||
},
|
||
|
||
/** 根据消息数刷新 CTA:用户发言 >10、未买过、且未点过/未离开曝光过 */
|
||
_refreshReportCta() {
|
||
let dismissed = false
|
||
try {
|
||
dismissed = !!wx.getStorageSync(STORAGE_REPORT_CTA_DISMISSED)
|
||
} catch (e) {}
|
||
const userTurns = (this.data.messages || []).filter((m) => m.role === 'user').length
|
||
const st = this.data.myReportStatus || ''
|
||
const paidBlock = st === 'done' || st === 'paid'
|
||
const show =
|
||
userTurns > REPORT_CTA_MIN_USER_TURNS && !paidBlock && !dismissed
|
||
if (show !== this.data.showReportCta) {
|
||
this.setData({ showReportCta: show })
|
||
}
|
||
},
|
||
|
||
onShareAppMessage() {
|
||
const app = getApp() || {}
|
||
const gd = app.globalData || {}
|
||
const inviterId = (gd.userInfo && (gd.userInfo.id || gd.userInfo.user_id)) || 0
|
||
const mbti = this.data.mbtiType || ''
|
||
const title = mbti
|
||
? `神仙 AI 帮我看懂了 ${mbti},你也来试试?`
|
||
: '和神仙 AI 聊聊你的 MBTI,超准!'
|
||
const path = `/pages/ai-chat/index?inviterId=${inviterId}&src=ai_chat`
|
||
analytics.track('ai_chat_share', { mbti })
|
||
return { title, path }
|
||
},
|
||
|
||
onShareTimeline() {
|
||
return {
|
||
title: '神仙 AI · 帮你看懂自己的 MBTI',
|
||
query: 'src=ai_chat_timeline'
|
||
}
|
||
}
|
||
})
|