upload project source code

This commit is contained in:
2026-04-30 18:49:43 +08:00
commit 9b394ba682
2277 changed files with 660945 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
/**
* 登录状态管理工具
*/
import router from '@/router';
const TOKEN_KEY = 'token';
const USER_INFO_KEY = 'userInfo';
const TOKEN_EXPIRE_TIME_KEY = 'tokenExpireTime';
const normalizeAvatarUrl = (avatar: any): any => {
if (typeof avatar !== 'string') return avatar;
const url = avatar.trim();
if (!url) return url;
if (!url.startsWith('http://')) return url;
const host = url.slice('http://'.length).split('/')[0] || '';
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
const isPrivateIp =
host.startsWith('10.') ||
host.startsWith('192.168.') ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
if (isLocalhost || isPrivateIp) return url;
return 'https://' + url.slice('http://'.length);
};
const normalizeUserInfo = (userInfo: any): any => {
if (!userInfo || typeof userInfo !== 'object') return userInfo;
const next = { ...userInfo };
if ('avatar' in next) {
next.avatar = normalizeAvatarUrl((next as any).avatar);
}
return next;
};
/**
* 获取 token
*/
export const getToken = (): string | null => {
try {
return localStorage.getItem(TOKEN_KEY) || null;
} catch (e) {
console.error('获取 token 失败:', e);
return null;
}
};
/**
* 设置 token
*/
export const setToken = (token: string, expireTime?: number): void => {
try {
localStorage.setItem(TOKEN_KEY, token);
if (expireTime) {
localStorage.setItem(TOKEN_EXPIRE_TIME_KEY, String(expireTime));
}
} catch (e) {
console.error('设置 token 失败:', e);
}
};
/**
* 清除 token
*/
export const clearToken = (): void => {
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRE_TIME_KEY);
localStorage.removeItem(USER_INFO_KEY);
} catch (e) {
console.error('清除 token 失败:', e);
}
};
/**
* 检查 token 是否存在
*/
export const hasToken = (): boolean => {
return !!getToken();
};
/**
* 检查 token 是否过期
* @param expireTime token 过期时间戳(毫秒),如果不传则从存储中读取
*/
export const isTokenExpired = (expireTime?: number): boolean => {
try {
const expireStr = expireTime ? String(expireTime) : localStorage.getItem(TOKEN_EXPIRE_TIME_KEY);
if (!expireStr) {
// 如果没有过期时间,认为 token 有效(由后端验证)
return false;
}
const expire = Number(expireStr);
return Date.now() >= expire;
} catch (e) {
console.error('检查 token 过期失败:', e);
return false;
}
};
/**
* 检查是否已登录token 存在且未过期)
*/
export const isLoggedIn = (): boolean => {
return hasToken() && !isTokenExpired();
};
/**
* 获取用户信息
*/
export const getUserInfo = (): any | null => {
try {
const v = localStorage.getItem(USER_INFO_KEY);
if (!v) return null;
return normalizeUserInfo(JSON.parse(v));
} catch (e) {
console.error('获取用户信息失败:', e);
return null;
}
};
/**
* 设置用户信息
*/
export const setUserInfo = (userInfo: any): void => {
try {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(normalizeUserInfo(userInfo)));
} catch (e) {
console.error('设置用户信息失败:', e);
}
};
/**
* 跳转到登录页
* @param force 为 true 时即使当前已在 /login 也 replace 一次(用于退出登录后刷新登录态)
*/
export const navigateToLogin = (options?: { redirect?: string; force?: boolean }): void => {
try {
const currentRoute = router.currentRoute.value.path;
if (options?.force || currentRoute !== '/login') {
const redirect = options?.redirect ?? (currentRoute !== '/login' ? currentRoute : '/');
router.replace({
path: '/login',
query: { redirect: encodeURIComponent(redirect) }
});
}
} catch (e) {
console.error('跳转登录页失败:', e);
}
};
/**
* 退出登录:仅前端清空 token / 用户信息并进入登录页(不请求后端)
*/
export const logout = (): void => {
clearToken();
navigateToLogin({ force: true, redirect: '/' });
};
/**
* 需要登录的页面路径列表(白名单外的页面都需要登录)
*/
const LOGIN_WHITELIST = ['/login'];
/**
* 检查页面是否需要登录
*/
export const isPageRequireLogin = (path: string): boolean => {
return !LOGIN_WHITELIST.some((whitelistPath) => path.includes(whitelistPath));
};

View File

@@ -0,0 +1,85 @@
/**
* 测名 / 起名 / 改名:五行生克与属相适配 —— 后端约定与生成提示词
*
* 前端已在 Report JSON 根级支持(与测名详解 TestNameDetail 同字段名):
* - bazi_name_fit八字喜用 vs 姓名五行,含补益/克泄简述
* - wuxing_bagua姓名内部五行态势与生克互助
* - zodiac_sign出生年生肖与名字用字的合冲刑害、适配结论
*
* 若起名/改名当前只返回扁平字段 wuxing_analysis / zodiac_analysis建议后端合并为上述结构化模块或同时返回便于全端一致展示。
*/
/** 建议后端在「个人」相关请求中补充的上下文(用于算八字、喜用、生肖年柱) */
export const RECOMMENDED_PERSONAL_REQUEST_FIELDS = {
/** 已有surname, given_name, gender, birthday */
extra_suggested: {
/** 精确到时辰,格式与现有 birthday 一致即可,如 YYYY-MM-DD HH:mm:ss */
birth_time: "string (optional, 排盘用)",
/** 出生地或时区,用于真太阳时 / 节气(可选) */
birth_place: "string (optional)",
/** solar | lunar若只传公历可省略 */
calendar_type: "'solar' | 'lunar' (optional)",
/** 原名(改名场景必填,起名可为空) */
original_name: "string (optional, 改名)",
},
} as const;
/** 建议后端在「公司/商号」相关请求中补充的上下文 */
export const RECOMMENDED_COMPANY_REQUEST_FIELDS = {
/** 已有company_name, industry, address, target_audience, members */
extra_suggested: {
/** 核心成员生辰已可定生肖与八字;可明确每人角色便于报告人岗描述 */
member_roles: "Array<{ name: string; birthday: string; role?: string }> (optional)",
},
} as const;
/** 建议在方案详情 / 测名结果 JSON 根级返回的扩展模块(与 api/types.ts 中 TestNameDetail* 对齐) */
export const REPORT_JSON_ROOT_KEYS = {
bazi_name_fit: {
xiyongshen: "喜用神简述",
name_wuxing_profile: "姓名各字五行及组合简述",
complement_summary: "相对八字是补益还是偏枯、需注意的克泄",
fit_score: "0100与八字契合度可选",
details: "{ nodes: TextNode[] } 详文",
},
wuxing_bagua: {
wuxing_sketch: "姓名五行态势",
bagua_profile: "与卦象/宫位关联简述",
mutual_sketch: "相生 / 相克 / 制化要点(核心满足「生克」说明)",
summary: "一句话结论",
details: "{ nodes }",
},
zodiac_sign: {
animal: "生肖名,如 龙",
earthly_branch: "地支,如 辰",
trait_summary: "属相特性简述",
name_harmony: "与名之合冲刑害、是否相宜(核心满足「合不合」)",
details: "{ nodes }",
},
/** 单字五行细节:用于在“五行八卦”中展示每个字的阴阳五行与卦象 */
char_detail: {
items: "Array<{ char, element, yin_yang, yin_yang_element, bagua_trigram, bagua_trigram_symbol, hexagram_name, hexagram_code, note, details? }>",
details: "{ nodes } (optional)",
},
} as const;
/** 给后端或 LLM 的一次性中文提示:生成报告时必须覆盖的要点 */
export function buildBackendWuxingZodiacPromptZh(): string {
return [
"你是国学起名/测名报告生成服务。请在每一份「个人测名、公司测名、个人起名方案、个人改名方案、公司起名/改名方案」输出中,明确包含以下两块(可单独成章,也可写入既有模块,但内容必须可被前端独立字段映射):",
"",
"一、名字五行相生相克",
"1给出名字各字五行金木水火土及组合后的强弱、偏枯。",
"2用简短条文说明哪些五行相生相助、哪些相克或需制化可引用「金生水」「火克金」等但不要长篇堆砌。",
"3若有八字/喜用神上下文:说明姓名五行是否补益喜用、或加重忌神;给出 0100 契合度时可填 fit_score。",
"4输出 JSON 时优先使用根字段 bazi_name_fit八字与姓名五行与 wuxing_bagua五行态势与生克互助与现有 wuxing_analysis 并存时,请以结构化字段为准。",
"",
"二、属相与名字合不合",
"1根据用户或核心成员出生年确定生肖与地支。",
"2说明名字用字、读音或意象与该生肖的宜忌相合、相冲、相刑、相害等通俗表达即可。",
"3给出明确结论句如「整体相宜」「需注意某字与属相存在一定冲克建议……」。",
"4输出 JSON 时使用根字段 zodiac_sign若与 meaning_and_zodiac 并存,不要互相矛盾。",
"",
"三、请求侧参数:个人场景务必带齐 gender、birthday若有时辰请一并解析。改名须带 original_name。公司场景 members 每人须有 birthday 以便定生肖与团队五行互动。",
].join("\n");
}

View File

@@ -0,0 +1,20 @@
/**
* 判断当前环境是否使用「电脑端网页」布局(/test-name-live 等页面)。
* - 典型手机 UA始终视为手机端
* - 其余:视口宽度 >= 992px 为电脑端,否则为手机端(含平板竖屏、窄窗口)
*/
export function getIsDesktopLayout(): boolean {
if (typeof window === 'undefined') {
return true;
}
const ua = navigator.userAgent || '';
const isPhoneUA =
/iPhone|iPod|Android.*Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
if (isPhoneUA) {
return false;
}
return window.innerWidth >= 992;
}

View File

@@ -0,0 +1,7 @@
/**
* 全局功能开关
* false: 不隐藏充值功能(显示)
* true: 隐藏充值功能
*/
export const HIDE_RECHARGE_FEATURE: boolean = true;

View File

@@ -0,0 +1,285 @@
/**
* 微信支付集成使用示例
* 展示如何在不同场景下使用新的微信支付功能
*/
import { payWithCallbacks, quickPay, securePay } from './payment';
import { quickWechatPay, secureWechatPay, handlePaymentResult } from './wechat-payment';
import type { CreateOrderParams } from '@/api/types';
declare const uni: any;
/**
* 示例1: 财运解析解锁支付
*/
export const payForWealthAnalysisUnlock = async (reportId: number, price: number) => {
const params: CreateOrderParams = {
description: '财运解析详细报告解锁',
total_amount: Math.round(price * 100), // 转换为分
business_type: 'wealth_analysis',
business_id: reportId,
pay_type: 'jsapi'
};
return await payWithCallbacks(params, {
onSuccess: (result) => {
console.log('财运解析支付成功:', result);
uni.showToast({ title: '解锁成功,正在刷新数据...', icon: 'success' });
// 这里可以触发数据刷新或页面跳转
},
onFail: (result) => {
console.error('财运解析支付失败:', result);
uni.showModal({
title: '支付失败',
content: result.msg || '支付失败,请重试',
showCancel: false
});
},
onCancel: (result) => {
console.log('用户取消财运解析支付:', result);
// 用户取消支付,可以不做任何提示
}
}, true); // 使用安全模式
};
/**
* 示例2: 企业起名服务支付
*/
export const payForCompanyNaming = async (companyInfo: any, serviceLevel: 'basic' | 'premium') => {
const prices = {
basic: 99,
premium: 299
};
const descriptions = {
basic: '企业起名基础服务',
premium: '企业起名高级服务'
};
const params: CreateOrderParams = {
description: descriptions[serviceLevel],
total_amount: prices[serviceLevel] * 100, // 转换为分
business_type: 'company_naming',
business_id: companyInfo.id,
pay_type: 'jsapi'
};
try {
const result = await secureWechatPay(params);
handlePaymentResult(result, {
onSuccess: (res) => {
uni.showToast({ title: '支付成功,正在生成方案...', icon: 'success' });
// 跳转到结果页面或刷新数据
uni.navigateTo({
url: `/pages/naming-result/index?orderId=${res.outTradeNo}`
});
},
onFail: (res) => {
uni.showModal({
title: '支付失败',
content: '支付过程中出现问题,请重试或联系客服',
showCancel: true,
confirmText: '重试',
cancelText: '取消',
success: (modalRes) => {
if (modalRes.confirm) {
// 重新发起支付
payForCompanyNaming(companyInfo, serviceLevel);
}
}
});
},
onCancel: () => {
// 用户取消支付,返回上一页或停留在当前页
}
});
return result;
} catch (error) {
console.error('企业起名支付异常:', error);
uni.showToast({ title: '支付异常,请重试', icon: 'none' });
return { success: false, msg: '支付异常' };
}
};
/**
* 示例3: 个人测名服务支付
*/
export const payForPersonalNameAnalysis = async (nameInfo: any) => {
const params: CreateOrderParams = {
description: `个人测名服务-${nameInfo.name}`,
total_amount: 1980, // 19.8元
business_type: 'personal_name_analysis',
business_id: nameInfo.id,
pay_type: 'jsapi'
};
// 使用快速支付模式(不验证支付状态)
const result = await quickPay(params);
if (result.success) {
// 支付成功后的处理
uni.showToast({ title: '支付成功', icon: 'success' });
// 可以立即显示结果或跳转
setTimeout(() => {
uni.navigateTo({
url: `/pages/name-analysis-result/index?orderId=${result.outTradeNo}`
});
}, 1500);
} else {
// 支付失败的处理
if (result.msg && !result.msg.includes('取消')) {
uni.showToast({ title: result.msg, icon: 'none' });
}
}
return result;
};
/**
* 示例4: 批量支付处理(多个服务一起购买)
*/
export const payForMultipleServices = async (services: Array<{
type: string;
description: string;
amount: number;
businessId: number;
}>) => {
const totalAmount = services.reduce((sum, service) => sum + service.amount, 0);
const description = services.map(s => s.description).join('、');
const params: CreateOrderParams = {
description: `套餐服务:${description}`,
total_amount: totalAmount * 100, // 转换为分
business_type: 'company_naming', // 主要业务类型
business_id: services[0].businessId, // 使用第一个服务的ID
pay_type: 'jsapi'
};
return await payWithCallbacks(params, {
onSuccess: (result) => {
uni.showModal({
title: '支付成功',
content: '您的套餐服务已购买成功,正在为您生成报告...',
showCancel: false,
confirmText: '查看详情',
success: () => {
uni.navigateTo({
url: `/pages/service-package-result/index?orderId=${result.outTradeNo}`
});
}
});
},
onFail: (result) => {
uni.showModal({
title: '支付失败',
content: `支付失败:${result.msg}`,
showCancel: true,
confirmText: '重试',
success: (modalRes) => {
if (modalRes.confirm) {
payForMultipleServices(services);
}
}
});
},
onCancel: () => {
console.log('用户取消套餐支付');
}
}, true); // 使用安全模式验证支付
};
/**
* 示例5: 支付状态查询和处理
*/
export const checkAndHandlePaymentStatus = async (outTradeNo: string) => {
try {
uni.showLoading({ title: '查询支付状态...' });
const { queryOrderStatus } = await import('./payment');
const orderInfo = await queryOrderStatus(outTradeNo);
uni.hideLoading();
switch (orderInfo.status) {
case 'paid':
uni.showToast({ title: '支付已完成', icon: 'success' });
return true;
case 'pending':
uni.showModal({
title: '支付处理中',
content: '您的支付正在处理中,请稍后查看',
showCancel: false
});
return false;
case 'cancelled':
uni.showToast({ title: '订单已取消', icon: 'none' });
return false;
case 'refunded':
uni.showToast({ title: '订单已退款', icon: 'none' });
return false;
default:
uni.showToast({ title: '支付状态异常', icon: 'none' });
return false;
}
} catch (error) {
uni.hideLoading();
console.error('查询支付状态失败:', error);
uni.showToast({ title: '查询失败,请重试', icon: 'none' });
return false;
}
};
/**
* 通用支付工具函数
*/
export const PaymentHelper = {
/**
* 格式化金额显示(分转元)
*/
formatAmount: (amountInCents: number): string => {
return (amountInCents / 100).toFixed(2);
},
/**
* 元转分
*/
yuanToCents: (yuan: number): number => {
return Math.round(yuan * 100);
},
/**
* 获取业务类型的中文名称
*/
getBusinessTypeName: (type: string): string => {
const typeNames: Record<string, string> = {
'company_naming': '企业起名',
'company_renaming': '企业改名',
'company_name_analysis': '企业测名',
'personal_naming': '个人起名',
'personal_renaming': '个人改名',
'personal_name_analysis': '个人测名',
'wealth_analysis': '财运解析'
};
return typeNames[type] || '未知服务';
},
/**
* 创建标准的支付参数
*/
createPaymentParams: (
businessType: string,
businessId: number,
description: string,
amountInYuan: number
): CreateOrderParams => {
return {
description,
total_amount: PaymentHelper.yuanToCents(amountInYuan),
business_type: businessType as any,
business_id: businessId,
pay_type: 'jsapi'
};
}
};

View File

@@ -0,0 +1,175 @@
/**
* 支付工具类 - uni-app 版本
*/
import { paymentApi } from '@/api';
import type { CreateOrderParams } from '@/api/types';
import { showToast } from './uni-compat';
import { quickWechatPay, secureWechatPay, handlePaymentResult, type PaymentResult } from './wechat-payment';
declare const uni: any;
/**
* 发起微信支付(推荐使用新的微信支付方法)
* @param params 订单参数
* @param secure 是否使用安全模式(启用支付状态验证)
* @returns Promise<支付结果>
*/
export const wxPay = async (
params: CreateOrderParams,
secure: boolean = false
): Promise<{
success: boolean;
outTradeNo?: string;
msg?: string;
}> => {
try {
// 使用新的微信支付集成
const result: PaymentResult = secure
? await secureWechatPay(params)
: await quickWechatPay(params);
return {
success: result.success,
outTradeNo: result.outTradeNo,
msg: result.msg
};
} catch (error: any) {
console.error('支付流程失败:', error);
const errorMsg = error.msg || error.message || '支付失败,请重试';
showToast({ title: errorMsg, icon: 'none' });
return {
success: false,
msg: errorMsg
};
}
};
/**
* 发起快速微信支付(不验证支付状态)
* @param params 订单参数
* @returns Promise<支付结果>
*/
export const quickPay = async (params: CreateOrderParams) => {
return wxPay(params, false);
};
/**
* 发起安全微信支付(验证支付状态)
* @param params 订单参数
* @returns Promise<支付结果>
*/
export const securePay = async (params: CreateOrderParams) => {
return wxPay(params, true);
};
/**
* 通用支付方法(带回调处理)
* @param params 订单参数
* @param callbacks 支付结果回调
* @param secure 是否使用安全模式
*/
export const payWithCallbacks = async (
params: CreateOrderParams,
callbacks: {
onSuccess?: (result: any) => void;
onFail?: (result: any) => void;
onCancel?: (result: any) => void;
},
secure: boolean = false
) => {
try {
const result: PaymentResult = secure
? await secureWechatPay(params)
: await quickWechatPay(params);
handlePaymentResult(result, {
onSuccess: callbacks.onSuccess,
onFail: callbacks.onFail,
onCancel: callbacks.onCancel
});
return result;
} catch (error: any) {
console.error('支付流程失败:', error);
const errorMsg = error.msg || error.message || '支付失败,请重试';
const failResult = {
success: false,
msg: errorMsg
};
callbacks.onFail?.(failResult);
return failResult;
}
};
/**
* 查询订单状态
* @param outTradeNo 商户订单号
* @returns Promise<订单信息>
*/
export const queryOrderStatus = async (outTradeNo: string) => {
try {
const res = await paymentApi.queryOrder(outTradeNo);
return res;
} catch (error: any) {
console.error('查询订单失败:', error);
throw error;
}
};
/**
* 轮询查询订单状态(用于支付后确认)
* @param outTradeNo 商户订单号
* @param maxAttempts 最大尝试次数
* @param interval 查询间隔(毫秒)
* @returns Promise<是否支付成功>
*/
export const pollOrderStatus = async (
outTradeNo: string,
maxAttempts: number = 10,
interval: number = 2000
): Promise<boolean> => {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await queryOrderStatus(outTradeNo);
if (res.status === 'paid') {
return true;
} else if (res.status === 'cancelled' || res.status === 'refunded') {
return false;
}
// 等待后继续查询
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
console.error(`${i + 1}次查询订单失败:`, error);
if (i === maxAttempts - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
}
return false;
};
/**
* 关闭订单
* @param outTradeNo 商户订单号
* @returns Promise<是否成功>
*/
export const closeOrder = async (outTradeNo: string): Promise<boolean> => {
try {
await paymentApi.closeOrder(outTradeNo);
showToast({ title: '订单已关闭', icon: 'success' });
return true;
} catch (error: any) {
console.error('关闭订单失败:', error);
showToast({ title: error.msg || '关闭订单失败', icon: 'none' });
return false;
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -0,0 +1,277 @@
import { namingApi } from '@/api/naming';
export function parseMaybeJson(value: any): any {
if (value == null) return value;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return value;
const raw = value.trim();
if (!raw) return value;
try {
return JSON.parse(raw);
} catch {
return value;
}
}
/** 公司测名详解 JSON 形态(与个人测名部分字段重名,需优先识别) */
export function isCompanyTestDetailShape(data: any): boolean {
const d = parseMaybeJson(data);
if (!d || typeof d !== 'object') return false;
return (
(!!d.businessPattern && !!d.characterAnalysis) ||
(!!d.header && !!d.team && !!d.years && !!d.execution)
);
}
/**
* 与个人测名详解页一致header + 任一「个人专用」模块。
* 注意:公司测名也会带 liuyao / zodiac_sign / lucky_* 等,不能用这些单独判定个人,否则会误判。
*/
export function isPersonalTestDetailReady(data: any): boolean {
const d = parseMaybeJson(data);
if (!d || typeof d !== 'object' || !d.header) return false;
if (isCompanyTestDetailShape(d)) return false;
const hasPersonalCore = !!(
d.meaning_and_zodiac ||
d.strokes_wuge_sancai ||
d.six_dimension ||
d.master_message ||
d.phonetics ||
d.bazi_name_fit ||
d.name_popularity
);
if (hasPersonalCore) return true;
return !!(
d.liuyao ||
d.wuxing_bagua ||
d.zodiac_sign ||
d.career_plan ||
d.lucky_numbers ||
d.lucky_colors
);
}
/** 与公司测名详解页一致 */
export function isCompanyTestDetailReady(data: any): boolean {
const d = parseMaybeJson(data);
if (!d || typeof d !== 'object') return false;
return isCompanyTestDetailShape(d);
}
export type LiveTestMode = 'personal' | 'company';
/**
* 与 index.vue handleMyPlanDetail 分流逻辑对齐(测名场景)
*/
export function classifyLiveTestDetail(
detail: any,
mode: LiveTestMode,
): { kind: 'personal' | 'company'; showBusinessFortune: boolean } {
const d = parseMaybeJson(detail);
const st = String(d?.service_type || '').trim().toLowerCase();
const isNamingOrRenamingReport =
st === 'naming' ||
st === 'renaming' ||
st === 'company_naming' ||
st === 'company_renaming';
const isCompanyTestByShape = isCompanyTestDetailShape(d);
const isCompanyTestByServiceType = st === 'test';
const showBusinessFortune = !(isCompanyTestByServiceType || isCompanyTestByShape);
// 必须先分流公司测名:公司与个人详情可能共用 header且公司也常有 liuyao / 属相 / 幸运色等
if (mode === 'company' || isCompanyTestByShape) {
return { kind: 'company', showBusinessFortune };
}
const isTestNameDetailData =
!!d &&
typeof d === 'object' &&
!!d.header &&
!!(
d.meaning_and_zodiac ||
d.strokes_wuge_sancai ||
d.six_dimension ||
d.master_message ||
d.phonetics ||
d.bazi_name_fit ||
d.name_popularity ||
d.liuyao ||
d.wuxing_bagua ||
d.zodiac_sign ||
d.career_plan ||
d.lucky_numbers ||
d.lucky_colors
);
if (!isNamingOrRenamingReport && isTestNameDetailData) {
return { kind: 'personal', showBusinessFortune: true };
}
return { kind: 'personal', showBusinessFortune: true };
}
/** 兼容 axios 未解包或嵌套 data 的测名返回 */
export function normalizeScoringPayload(res: any): any {
if (!res || typeof res !== 'object') return res;
const d = (res as any).data;
if (d && typeof d === 'object') {
if (
d.report_id != null ||
d.reportId != null ||
d.solution_id != null ||
d.solutionId != null ||
(Array.isArray(d.solutions) && d.solutions.length > 0)
) {
return d;
}
}
return res;
}
export async function extractSolutionIdFromScoring(res: any): Promise<number | null> {
res = normalizeScoringPayload(res);
if (!res || typeof res !== 'object') return null;
const direct = (res as any).solution_id ?? (res as any).solutionId;
if (direct != null) {
const n = Number(direct);
if (Number.isFinite(n) && n > 0) return n;
}
const sols = (res as any).solutions;
if (Array.isArray(sols) && sols[0]) {
const id = sols[0].id ?? sols[0].solution_id;
if (id != null) {
const n = Number(id);
if (Number.isFinite(n) && n > 0) return n;
}
}
const ridRaw =
(res as any).report_id ??
(res as any).reportId ??
((res as any).report && typeof (res as any).report === 'object'
? (res as any).report.id
: null);
if (ridRaw == null || ridRaw === '') return null;
const ridNum = Number(ridRaw);
if (!Number.isFinite(ridNum) || ridNum <= 0) return null;
try {
const solutionsResult = await namingApi.getSolutionsByReportId(ridNum);
const solutions =
solutionsResult?.solutions || solutionsResult?.items || solutionsResult;
if (Array.isArray(solutions) && solutions[0]) {
const id = solutions[0].id ?? solutions[0].solution_id;
if (id != null) {
const n = Number(id);
if (Number.isFinite(n) && n > 0) return n;
}
}
} catch {
return null;
}
return null;
}
/**
* 电脑端测名:方案可能尚未落库,按间隔重复解析 solution_id / 拉取报告下方案列表,
* 在成功前不结束 loading、不提示「未获取到方案编号」。
*/
export async function pollUntilSolutionIdFromScoring(
scoringRes: any,
options?: { intervalMs?: number; maxAttempts?: number; signal?: AbortSignal },
): Promise<number> {
const intervalMs = options?.intervalMs ?? 5000;
const maxAttempts = options?.maxAttempts ?? 120;
const signal = options?.signal;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal?.aborted) {
throw new Error('aborted');
}
const id = await extractSolutionIdFromScoring(scoringRes);
if (id) return id;
if (signal?.aborted) {
throw new Error('aborted');
}
await sleep(intervalMs);
}
throw new Error('详情生成超时,请稍后在「我的方案」中查看');
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/**
* 首次立即请求 full_detail未就绪则每隔 intervalMs 再请求,与电脑端测名 loading 配合使用。
*/
export async function pollTestSolutionDetail(
solutionId: number,
mode: LiveTestMode,
options?: { intervalMs?: number; maxAttempts?: number; signal?: AbortSignal },
): Promise<any> {
const intervalMs = options?.intervalMs ?? 5000;
const maxAttempts = options?.maxAttempts ?? 120;
const signal = options?.signal;
const isReady =
mode === 'personal' ? isPersonalTestDetailReady : isCompanyTestDetailReady;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal?.aborted) {
throw new Error('aborted');
}
let detail: any;
try {
detail = await namingApi.getSolutionDetail(solutionId);
} catch {
await sleep(intervalMs);
continue;
}
const parsed = parseMaybeJson(detail);
if (isReady(parsed)) {
return parsed;
}
if (signal?.aborted) {
throw new Error('aborted');
}
await sleep(intervalMs);
}
throw new Error('详情生成超时,请稍后在「我的方案」中查看');
}
/**
* 起名/改名通用轮询:只要 full_detail 返回可用对象即视为就绪。
* 适用于非测名报告(起名、改名)详情。
*/
export async function pollSolutionDetailUntilReady(
solutionId: number,
options?: { intervalMs?: number; maxAttempts?: number; signal?: AbortSignal },
): Promise<any> {
const intervalMs = options?.intervalMs ?? 5000;
const maxAttempts = options?.maxAttempts ?? 120;
const signal = options?.signal;
const isUsableObject = (v: any): boolean => {
if (!v || typeof v !== 'object') return false;
const keys = Object.keys(v);
if (keys.length === 0) return false;
// 兼容后端「处理中」文案
const msg = String((v as any).msg || (v as any).message || '').toLowerCase();
if (msg.includes('处理中') || msg.includes('processing') || msg.includes('pending')) return false;
return true;
};
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal?.aborted) throw new Error('aborted');
try {
const detail = await namingApi.getSolutionDetail(solutionId);
const parsed = parseMaybeJson(detail);
if (isUsableObject(parsed)) return parsed;
} catch {
// ignore and retry
}
if (signal?.aborted) throw new Error('aborted');
await sleep(intervalMs);
}
throw new Error('详情生成超时,请稍后在「我的方案」中查看');
}

View File

@@ -0,0 +1,600 @@
const ECL = { L: 0, M: 1, Q: 2, H: 3 };
const MODE = {
Numeric: 0b0001,
Alphanumeric: 0b0010,
Byte: 0b0100,
Kanji: 0b1000
};
const ALPHANUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
const CAPACITIES = [
[17, 14, 11, 7],
[32, 26, 20, 14],
[53, 42, 32, 24],
[78, 62, 46, 34],
[106, 84, 60, 44],
[134, 106, 74, 58],
[154, 122, 86, 64],
[192, 152, 108, 84],
[230, 180, 130, 98],
[271, 213, 151, 119],
[321, 251, 177, 137],
[367, 287, 203, 155],
[425, 331, 241, 177],
[458, 362, 258, 194],
[520, 412, 292, 220],
[586, 450, 322, 250],
[644, 504, 364, 280],
[718, 560, 394, 310],
[792, 624, 442, 338],
[858, 666, 482, 382],
[929, 711, 509, 403],
[1003, 779, 565, 439],
[1091, 857, 611, 461],
[1171, 911, 661, 511],
[1273, 997, 715, 535],
[1367, 1059, 751, 593],
[1465, 1125, 805, 625],
[1528, 1190, 868, 658],
[1628, 1264, 908, 698],
[1732, 1370, 982, 742],
[1840, 1452, 1030, 790],
[1952, 1538, 1112, 842],
[2068, 1628, 1168, 898],
[2188, 1722, 1228, 958],
[2303, 1809, 1283, 983],
[2431, 1911, 1351, 1051],
[2563, 1989, 1423, 1093],
[2699, 2099, 1499, 1139],
[2809, 2213, 1579, 1219],
[2953, 2331, 1663, 1273]
];
const EC_PARAMS = [
[[7, 19, 1, 0, 0], [10, 16, 1, 0, 0], [13, 13, 1, 0, 0], [17, 9, 1, 0, 0]],
[[10, 34, 1, 0, 0], [16, 28, 1, 0, 0], [22, 22, 1, 0, 0], [28, 16, 1, 0, 0]],
[[15, 55, 1, 0, 0], [26, 44, 1, 0, 0], [18, 17, 2, 0, 0], [22, 13, 2, 0, 0]],
[[20, 80, 1, 0, 0], [18, 32, 2, 0, 0], [26, 24, 2, 0, 0], [16, 9, 4, 0, 0]],
[[26, 108, 1, 0, 0], [24, 43, 2, 0, 0], [18, 15, 2, 16, 2], [22, 11, 2, 12, 2]],
[[18, 68, 2, 0, 0], [16, 27, 4, 0, 0], [24, 19, 4, 0, 0], [28, 15, 4, 0, 0]],
[[20, 78, 2, 0, 0], [18, 31, 4, 0, 0], [18, 14, 2, 15, 4], [26, 13, 4, 14, 1]],
[[24, 97, 2, 0, 0], [22, 38, 2, 39, 2], [22, 18, 4, 19, 2], [26, 14, 4, 15, 2]],
[[30, 116, 2, 0, 0], [22, 36, 3, 37, 2], [20, 16, 4, 17, 4], [24, 12, 4, 13, 4]],
[[18, 68, 2, 69, 2], [26, 43, 4, 44, 1], [24, 19, 6, 20, 2], [28, 15, 6, 16, 2]],
[[20, 81, 4, 0, 0], [30, 50, 1, 51, 4], [28, 22, 4, 23, 4], [24, 12, 3, 13, 8]],
[[24, 92, 2, 93, 2], [22, 36, 6, 37, 2], [26, 20, 4, 21, 6], [28, 14, 7, 15, 4]],
[[26, 107, 4, 0, 0], [22, 37, 8, 38, 1], [24, 20, 8, 21, 4], [22, 11, 12, 12, 4]],
[[30, 115, 3, 116, 1], [24, 40, 4, 41, 5], [20, 16, 11, 17, 5], [24, 12, 11, 13, 5]],
[[22, 87, 5, 88, 1], [24, 41, 5, 42, 5], [30, 24, 5, 25, 7], [24, 12, 11, 13, 7]],
[[24, 98, 5, 99, 1], [28, 45, 7, 46, 3], [24, 19, 15, 20, 2], [30, 15, 3, 16, 13]],
[[28, 107, 1, 108, 5], [28, 46, 10, 47, 1], [28, 22, 1, 23, 15], [28, 14, 2, 15, 17]],
[[30, 120, 5, 121, 1], [26, 43, 9, 44, 4], [28, 22, 17, 23, 1], [28, 14, 2, 15, 19]],
[[28, 113, 3, 114, 4], [26, 44, 3, 45, 11], [26, 21, 17, 22, 4], [26, 13, 9, 14, 16]],
[[28, 107, 3, 108, 5], [26, 41, 3, 42, 13], [30, 24, 15, 25, 5], [28, 15, 15, 16, 10]],
[[28, 116, 4, 117, 4], [26, 42, 17, 0, 0], [28, 22, 17, 23, 6], [30, 16, 19, 17, 6]],
[[28, 111, 2, 112, 7], [28, 46, 17, 0, 0], [30, 24, 7, 25, 16], [24, 13, 34, 0, 0]],
[[30, 121, 4, 122, 5], [28, 47, 4, 48, 14], [30, 24, 11, 25, 14], [30, 15, 16, 16, 14]],
[[30, 117, 6, 118, 4], [28, 45, 6, 46, 14], [30, 24, 11, 25, 16], [30, 16, 30, 17, 2]],
[[26, 106, 8, 107, 4], [28, 47, 8, 48, 13], [30, 24, 7, 25, 22], [30, 15, 22, 16, 13]],
[[28, 114, 10, 115, 2], [28, 46, 19, 47, 4], [28, 22, 28, 23, 6], [30, 16, 33, 17, 4]],
[[30, 122, 8, 123, 4], [28, 45, 22, 46, 3], [30, 23, 8, 24, 26], [30, 15, 12, 16, 28]],
[[30, 117, 3, 118, 10], [28, 45, 3, 46, 23], [30, 24, 4, 25, 31], [30, 15, 11, 16, 31]],
[[30, 116, 7, 117, 7], [28, 45, 21, 46, 7], [30, 23, 1, 24, 37], [30, 15, 19, 16, 26]],
[[30, 115, 5, 116, 10], [28, 47, 19, 48, 10], [30, 24, 15, 25, 25], [30, 15, 23, 16, 25]],
[[30, 115, 13, 116, 3], [28, 46, 2, 47, 29], [30, 24, 42, 25, 1], [30, 15, 23, 16, 28]],
[[30, 115, 17, 0, 0], [28, 46, 10, 47, 23], [30, 24, 10, 25, 35], [30, 15, 19, 16, 35]],
[[30, 115, 17, 116, 1], [28, 46, 14, 47, 21], [30, 24, 29, 25, 19], [30, 15, 11, 16, 46]],
[[30, 115, 13, 116, 6], [28, 46, 14, 47, 23], [30, 24, 44, 25, 7], [30, 16, 59, 17, 1]],
[[30, 121, 12, 122, 7], [28, 47, 12, 48, 26], [30, 24, 39, 25, 14], [30, 15, 22, 16, 41]],
[[30, 121, 6, 122, 14], [28, 47, 6, 48, 34], [30, 24, 46, 25, 10], [30, 15, 2, 16, 64]],
[[30, 122, 17, 123, 4], [28, 46, 29, 47, 14], [30, 24, 49, 25, 10], [30, 15, 24, 16, 46]],
[[30, 122, 4, 123, 18], [28, 46, 13, 47, 32], [30, 24, 48, 25, 14], [30, 15, 42, 16, 32]],
[[30, 117, 20, 118, 4], [28, 47, 40, 48, 7], [30, 24, 43, 25, 22], [30, 15, 10, 16, 67]],
[[30, 118, 19, 119, 6], [28, 47, 18, 48, 31], [30, 24, 34, 25, 34], [30, 15, 20, 16, 61]]
];
const ALIGN_POS = [
[],
[6, 18],
[6, 22],
[6, 26],
[6, 30],
[6, 34],
[6, 22, 38],
[6, 24, 42],
[6, 26, 46],
[6, 28, 50],
[6, 30, 54],
[6, 32, 58],
[6, 34, 62],
[6, 26, 46, 66],
[6, 26, 48, 70],
[6, 26, 50, 74],
[6, 30, 54, 78],
[6, 30, 56, 82],
[6, 30, 58, 86],
[6, 34, 62, 90],
[6, 28, 50, 72, 94],
[6, 26, 50, 74, 98],
[6, 30, 54, 78, 102],
[6, 28, 54, 80, 106],
[6, 32, 58, 84, 110],
[6, 30, 58, 86, 114],
[6, 34, 62, 90, 118],
[6, 26, 50, 74, 98, 122],
[6, 30, 54, 78, 102, 126],
[6, 26, 52, 78, 104, 130],
[6, 30, 56, 82, 108, 134],
[6, 34, 60, 86, 112, 138],
[6, 30, 58, 86, 114, 142],
[6, 34, 62, 90, 118, 146],
[6, 30, 54, 78, 102, 126, 150],
[6, 24, 50, 76, 102, 128, 154],
[6, 28, 54, 80, 106, 132, 158],
[6, 32, 58, 84, 110, 136, 162],
[6, 26, 54, 82, 110, 138, 166],
[6, 30, 58, 86, 114, 142, 170]
];
const FORMAT_BITS = [
0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976,
0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0,
0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183, 0x2eda, 0x2bed,
0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b
];
const VERSION_BITS = [
0x07c94, 0x085bc, 0x09a99, 0x0a4d3, 0x0bbf6, 0x0c762, 0x0d847, 0x0e60d,
0x0f928, 0x10b78, 0x1145d, 0x12a17, 0x13532, 0x149a6, 0x15683, 0x168c9,
0x177ec, 0x18ec4, 0x191e1, 0x1afab, 0x1b08e, 0x1cc1a, 0x1d33f, 0x1ed75,
0x1f250, 0x209d5, 0x216f0, 0x228ba, 0x2379f, 0x24b0b, 0x2542e, 0x26a64,
0x27541, 0x28c69
];
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x = (x << 1) ^ (x >= 128 ? 0x11d : 0);
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
function rsEncode(data, ecLen) {
const gen = new Uint8Array(ecLen + 1);
gen[0] = 1;
for (let i = 0; i < ecLen; i++) {
for (let j = i + 1; j >= 1; j--) {
gen[j] = gen[j] ? EXP[LOG[gen[j]] + i] ^ gen[j - 1] : gen[j - 1];
}
gen[0] = EXP[LOG[gen[0]] + i];
}
const result = new Uint8Array(ecLen);
for (let i = 0; i < data.length; i++) {
const coef = data[i] ^ result[0];
result.copyWithin(0, 1);
result[ecLen - 1] = 0;
if (coef) {
for (let j = 0; j < ecLen; j++) {
result[j] ^= EXP[LOG[gen[ecLen - 1 - j]] + LOG[coef]];
}
}
}
return result;
}
function getMode(text) {
if (/^\d+$/.test(text)) return MODE.Numeric;
if (/^[0-9A-Z $%*+\-./:]+$/.test(text)) return MODE.Alphanumeric;
return MODE.Byte;
}
function getCharCountBits(ver, mode) {
const idx = ver < 10 ? 0 : ver < 27 ? 1 : 2;
return [[10, 9, 8, 8], [12, 11, 16, 10], [14, 13, 16, 12]][idx][[MODE.Numeric, MODE.Alphanumeric, MODE.Byte, MODE.Kanji].indexOf(mode)];
}
function toUtf8(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
let c = str.charCodeAt(i);
if (c < 0x80) {
bytes.push(c);
} else if (c < 0x800) {
bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
} else if (c >= 0xd800 && c < 0xdc00 && i + 1 < str.length) {
const c2 = str.charCodeAt(++i);
c = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff);
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
} else {
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
}
}
return bytes;
}
function getMinVersion(text, ecl) {
const mode = getMode(text);
const len = mode === MODE.Byte ? toUtf8(text).length : text.length;
for (let v = 1; v <= 40; v++) {
if (len <= CAPACITIES[v - 1][ecl]) return v;
}
return -1;
}
function encodeData(text, ver, ecl) {
const mode = getMode(text);
const bits = [];
const write = (val, len) => {
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
};
write(mode, 4);
const utf8 = mode === MODE.Byte ? toUtf8(text) : null;
const charCount = utf8 ? utf8.length : text.length;
write(charCount, getCharCountBits(ver, mode));
if (mode === MODE.Numeric) {
for (let i = 0; i < text.length; i += 3) {
const chunk = text.substr(i, 3);
write(parseInt(chunk, 10), chunk.length * 3 + 1);
}
} else if (mode === MODE.Alphanumeric) {
for (let i = 0; i < text.length; i += 2) {
if (i + 1 < text.length) {
write(ALPHANUM.indexOf(text[i]) * 45 + ALPHANUM.indexOf(text[i + 1]), 11);
} else {
write(ALPHANUM.indexOf(text[i]), 6);
}
}
} else {
for (const b of utf8) write(b, 8);
}
const params = EC_PARAMS[ver - 1][ecl];
const [ecPerBlock, dc1, bc1, dc2, bc2] = params;
const totalDC = dc1 * bc1 + dc2 * bc2;
const capacity = totalDC * 8;
const termLen = Math.min(4, capacity - bits.length);
for (let i = 0; i < termLen; i++) bits.push(0);
while (bits.length % 8) bits.push(0);
const pads = [0xec, 0x11];
let padIdx = 0;
while (bits.length < capacity) {
write(pads[padIdx++ % 2], 8);
}
const bytes = [];
for (let i = 0; i < bits.length; i += 8) {
let b = 0;
for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
bytes.push(b);
}
const blocks = [];
const ecBlocks = [];
let offset = 0;
for (let i = 0; i < bc1; i++) {
const block = bytes.slice(offset, offset + dc1);
blocks.push(block);
ecBlocks.push(rsEncode(new Uint8Array(block), ecPerBlock));
offset += dc1;
}
for (let i = 0; i < bc2; i++) {
const block = bytes.slice(offset, offset + dc2);
blocks.push(block);
ecBlocks.push(rsEncode(new Uint8Array(block), ecPerBlock));
offset += dc2;
}
const result = [];
const maxDC = Math.max(dc1, dc2);
for (let i = 0; i < maxDC; i++) {
for (const block of blocks) {
if (i < block.length) result.push(block[i]);
}
}
for (let i = 0; i < ecPerBlock; i++) {
for (const ec of ecBlocks) {
result.push(ec[i]);
}
}
return result;
}
function createMatrix(ver) {
const size = ver * 4 + 17;
const matrix = [];
const reserved = [];
for (let i = 0; i < size; i++) {
matrix.push(new Array(size).fill(0));
reserved.push(new Array(size).fill(false));
}
const mark = (r, c) => {
if (r >= 0 && r < size && c >= 0 && c < size) reserved[r][c] = true;
};
const placeFinder = (r, c) => {
for (let dr = -1; dr <= 7; dr++) {
for (let dc = -1; dc <= 7; dc++) {
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue;
mark(nr, nc);
if (dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6) {
const isBlack = dr === 0 || dr === 6 || dc === 0 || dc === 6 || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4);
matrix[nr][nc] = isBlack ? 1 : 0;
} else {
matrix[nr][nc] = 0;
}
}
}
};
placeFinder(0, 0);
placeFinder(0, size - 7);
placeFinder(size - 7, 0);
if (ver >= 2) {
const positions = ALIGN_POS[ver - 1];
for (const r of positions) {
for (const c of positions) {
if (reserved[r][c]) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
mark(r + dr, c + dc);
const isBlack = Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0);
matrix[r + dr][c + dc] = isBlack ? 1 : 0;
}
}
}
}
}
for (let i = 8; i < size - 8; i++) {
const v = i % 2 === 0 ? 1 : 0;
if (!reserved[6][i]) {
matrix[6][i] = v;
mark(6, i);
}
if (!reserved[i][6]) {
matrix[i][6] = v;
mark(i, 6);
}
}
matrix[size - 8][8] = 1;
mark(size - 8, 8);
for (let i = 0; i < 9; i++) {
mark(8, i);
mark(i, 8);
}
for (let i = 0; i < 8; i++) {
mark(8, size - 1 - i);
mark(size - 1 - i, 8);
}
if (ver >= 7) {
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 3; j++) {
mark(i, size - 11 + j);
mark(size - 11 + j, i);
}
}
}
return { matrix, reserved, size };
}
function placeData(matrix, reserved, data) {
const size = matrix.length;
let bitIdx = 0;
let upward = true;
for (let col = size - 1; col >= 1; col -= 2) {
if (col === 6) col = 5;
for (let i = 0; i < size; i++) {
const row = upward ? size - 1 - i : i;
for (let dc = 0; dc < 2; dc++) {
const c = col - dc;
if (!reserved[row][c]) {
const bit = bitIdx < data.length * 8 ? (data[Math.floor(bitIdx / 8)] >> (7 - bitIdx % 8)) & 1 : 0;
matrix[row][c] = bit;
bitIdx++;
}
}
}
upward = !upward;
}
}
function applyMask(matrix, reserved, mask) {
const size = matrix.length;
const result = matrix.map((row) => [...row]);
const masks = [
(r, c) => (r + c) % 2 === 0,
(r, c) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => (r * c) % 2 + (r * c) % 3 === 0,
(r, c) => ((r * c) % 2 + (r * c) % 3) % 2 === 0,
(r, c) => ((r + c) % 2 + (r * c) % 3) % 2 === 0
];
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (!reserved[r][c] && masks[mask](r, c)) {
result[r][c] ^= 1;
}
}
}
return result;
}
function placeFormatInfo(matrix, ecl, mask) {
const size = matrix.length;
const bits = FORMAT_BITS[ecl * 8 + mask];
for (let i = 0; i <= 5; i++) matrix[8][i] = (bits >> (14 - i)) & 1;
matrix[8][7] = (bits >> 8) & 1;
matrix[8][8] = (bits >> 7) & 1;
matrix[7][8] = (bits >> 6) & 1;
for (let i = 0; i <= 5; i++) matrix[i][8] = (bits >> i) & 1;
for (let i = 0; i <= 7; i++) matrix[8][size - 1 - i] = (bits >> i) & 1;
for (let i = 0; i <= 6; i++) matrix[size - 1 - i][8] = (bits >> (14 - i)) & 1;
}
function placeVersionInfo(matrix, ver) {
if (ver < 7) return;
const size = matrix.length;
const bits = VERSION_BITS[ver - 7];
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 3; j++) {
const bit = (bits >> (i * 3 + j)) & 1;
matrix[i][size - 11 + j] = bit;
matrix[size - 11 + j][i] = bit;
}
}
}
function calcPenalty(matrix) {
const size = matrix.length;
let penalty = 0;
for (let r = 0; r < size; r++) {
let cnt = 1;
for (let c = 1; c < size; c++) {
if (matrix[r][c] === matrix[r][c - 1]) cnt++;
else {
if (cnt >= 5) penalty += cnt - 2;
cnt = 1;
}
}
if (cnt >= 5) penalty += cnt - 2;
}
for (let c = 0; c < size; c++) {
let cnt = 1;
for (let r = 1; r < size; r++) {
if (matrix[r][c] === matrix[r - 1][c]) cnt++;
else {
if (cnt >= 5) penalty += cnt - 2;
cnt = 1;
}
}
if (cnt >= 5) penalty += cnt - 2;
}
for (let r = 0; r < size - 1; r++) {
for (let c = 0; c < size - 1; c++) {
const v = matrix[r][c];
if (v === matrix[r][c + 1] && v === matrix[r + 1][c] && v === matrix[r + 1][c + 1]) {
penalty += 3;
}
}
}
const p1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const p2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
for (let r = 0; r < size; r++) {
for (let c = 0; c <= size - 11; c++) {
let m1 = true;
let m2 = true;
for (let i = 0; i < 11; i++) {
if (matrix[r][c + i] !== p1[i]) m1 = false;
if (matrix[r][c + i] !== p2[i]) m2 = false;
}
if (m1 || m2) penalty += 40;
}
}
for (let c = 0; c < size; c++) {
for (let r = 0; r <= size - 11; r++) {
let m1 = true;
let m2 = true;
for (let i = 0; i < 11; i++) {
if (matrix[r + i][c] !== p1[i]) m1 = false;
if (matrix[r + i][c] !== p2[i]) m2 = false;
}
if (m1 || m2) penalty += 40;
}
}
let dark = 0;
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (matrix[r][c]) dark++;
}
}
const ratio = dark / (size * size);
penalty += Math.floor(Math.abs(ratio - 0.5) / 0.05) * 10;
return penalty;
}
function selectMask(matrix, reserved, ecl, ver) {
let bestMask = 0;
let bestPenalty = Infinity;
for (let mask = 0; mask < 8; mask++) {
const masked = applyMask(matrix, reserved, mask);
placeFormatInfo(masked, ecl, mask);
placeVersionInfo(masked, ver);
const p = calcPenalty(masked);
if (p < bestPenalty) {
bestPenalty = p;
bestMask = mask;
}
}
return bestMask;
}
function generate(text, options = {}) {
const eclName = String(options.errorCorrectionLevel || 'M').toUpperCase();
const ecl = ECL[eclName] !== undefined ? ECL[eclName] : ECL.M;
let ver = options.version || getMinVersion(text, ecl);
if (ver < 1) throw new Error('数据过长');
if (ver > 40) ver = 40;
const data = encodeData(text, ver, ecl);
const { matrix, reserved, size } = createMatrix(ver);
placeData(matrix, reserved, data);
const mask = selectMask(matrix, reserved, ecl, ver);
const final = applyMask(matrix, reserved, mask);
placeFormatInfo(final, ecl, mask);
placeVersionInfo(final, ver);
return {
version: ver,
size,
modules: final,
errorCorrectionLevel: eclName
};
}
export default { generate, ECL, MODE };
export { generate, ECL, MODE };

View File

@@ -0,0 +1,352 @@
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
// 基础配置
export const BASE_URL = 'https://yifan.action-ai.cn/api/v1/yifan';
// export const BASE_URL = 'http://192.168.1.24:8001/api/v1/yifan';
// export const BASE_URL = 'http://localhost:8001/api/v1/yifan';
const TIMEOUT = 60000; // 60秒超时
// 开发模式配置(简化版本,避免 TypeScript 类型问题)
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const enableDebugLog = isDev; // 开发环境启用详细日志
// 导入登录相关工具
import { navigateToLogin, clearToken } from './auth';
import { showLoading as uniShowLoading, hideLoading as uniHideLoading, showToast as uniShowToast } from './uni-compat';
// 响应数据类型
interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 请求配置类型
export interface RequestConfig {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: object;
params?: object;
headers?: object;
showLoading?: boolean;
showError?: boolean;
timeout?: number;
/** 为 false 时,业务/HTTP 鉴权类错误不执行 clearToken + 跳转登录(用于仅更新资料等场景) */
clearAuthOnError?: boolean;
}
// 创建 axios 实例
const axiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
// 禁用自动 JSON 转换,我们将手动处理以避免解析错误
transformResponse: [(data) => {
// 如果数据为空,直接返回
if (!data) return data;
// 如果已经是对象,直接返回
if (typeof data === 'object') return data;
// 如果是字符串,手动解析
if (typeof data === 'string') {
try {
// 清理 BOM 和其他不可见字符
const cleanData = data.trim().replace(/^\uFEFF/, '');
// 检查是否是 HTML
if (cleanData.startsWith('<') || cleanData.startsWith('<!DOCTYPE')) {
throw new Error('服务器返回了 HTML 页面');
}
// 尝试解析 JSON
if (cleanData.startsWith('{') || cleanData.startsWith('[')) {
return JSON.parse(cleanData);
}
return cleanData;
} catch (e) {
console.error('transformResponse JSON 解析失败:', e);
// 返回原始数据,让后续处理
return data;
}
}
return data;
}],
});
// 请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
// 获取 token
const token = localStorage.getItem('token');
if (token) {
try {
// 尝试解析 JSON如果 token 是 JSON 字符串格式存储的)
const parsedToken = JSON.parse(token);
config.headers.Authorization = `Bearer ${parsedToken}`;
} catch (e) {
// 如果不是 JSON直接使用原始值
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器(统一处理)
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// transformResponse 已经处理了 JSON 解析
// 这里只做最后的检查
if (typeof response.data === 'string') {
const cleanData = response.data.trim();
// 如果是 HTML拒绝
if (cleanData.startsWith('<') || cleanData.startsWith('<!DOCTYPE')) {
console.error('收到 HTML 响应:', cleanData.substring(0, 200));
return Promise.reject(new Error('服务器返回了 HTML 页面'));
}
// 如果是 JSON 字符串但未被解析,尝试解析
if (cleanData.startsWith('{') || cleanData.startsWith('[')) {
try {
response.data = JSON.parse(cleanData);
} catch (e) {
console.error('响应拦截器 JSON 解析失败:', e);
return Promise.reject(new Error('服务器返回了无效的 JSON 格式'));
}
}
}
return response;
},
(error) => {
if (enableDebugLog) {
console.error('响应拦截器错误:', error);
}
return Promise.reject(error);
}
);
// 封装请求方法
const request = <T = any>(config: RequestConfig): Promise<T> => {
const {
url,
method = 'GET',
data,
params,
headers = {},
showLoading = false,
showError = true,
timeout = TIMEOUT,
clearAuthOnError = true,
} = config;
// 显示加载
if (showLoading) {
uniShowLoading({ title: '加载中...', mask: true });
}
const axiosConfig: AxiosRequestConfig = {
url,
method,
data,
params,
headers,
timeout,
};
return axiosInstance
.request(axiosConfig)
.then((res: AxiosResponse) => {
if (showLoading) {
uniHideLoading();
}
// axios 已经通过响应拦截器处理了数据
// 这里 res.data 应该已经是解析后的对象
if (res.data === null || res.data === undefined) {
console.warn('响应数据为空');
return null as T;
}
const response = res.data as ApiResponse<T> & { status_code?: number; success?: boolean };
// 业务状态码检查
if (
response.code === undefined ||
response.code === 200 ||
response.code === 0 ||
response.success === true
) {
return response.data !== undefined
? response.data
: (response as unknown as T);
} else {
// 检查是否是 token / 认证相关错误(无论当前是否有 token都统一跳转登录
const msg = response.msg || '';
const bizCode = response.code;
const statusCode = (response as any).status_code;
if (
clearAuthOnError &&
(bizCode === 401 ||
bizCode === 10401 || // 业务约定10401 代表认证失败
bizCode === 10001 ||
statusCode === 401 ||
msg.includes('token') ||
msg.includes('登录') ||
msg.includes('认证失败'))
) {
clearToken();
setTimeout(() => {
navigateToLogin();
}, 500);
}
if (showError) {
uniShowToast({ title: response.msg || '请求失败', icon: 'none' });
}
const err: any = new Error(response.msg || '请求失败');
err.msg = response.msg || '请求失败';
err.code = response.code;
throw err;
}
})
.catch((error) => {
if (showLoading) {
uniHideLoading();
}
let msg = '请求失败';
// 记录详细错误信息用于调试
console.error('请求错误详情:', {
url: config.url,
method: config.method,
error: error.message,
response: error.response?.data,
status: error.response?.status,
});
if (error.response) {
// 服务器返回了错误状态码
const { status, data } = error.response;
const dataMessage =
typeof data === 'string' ? data : data?.msg || data?.message || data?.detail;
switch (status) {
case 401:
msg = dataMessage || '未授权,请重新登录';
if (clearAuthOnError) {
clearToken();
setTimeout(() => {
navigateToLogin();
}, 500);
}
break;
case 403:
msg = dataMessage || '拒绝访问';
if (
clearAuthOnError &&
(data?.code === 401 ||
data?.code === 10401 ||
data?.msg?.includes('token') ||
data?.msg?.includes('登录') ||
data?.msg?.includes('认证失败'))
) {
clearToken();
setTimeout(() => {
navigateToLogin();
}, 500);
}
break;
case 404:
msg = dataMessage || '请求地址不存在';
break;
case 500:
msg = dataMessage || '服务器内部错误';
break;
case 502:
msg = '网关错误';
break;
case 503:
msg = '服务暂时不可用';
break;
case 504:
msg = '网关超时';
break;
default:
msg = dataMessage || `请求失败 (${status})`;
}
} else if (error.request) {
// 请求已发出但没有收到响应
msg = '网络错误,请检查网络连接';
} else {
// 其他错误(包括 JSON 解析错误)
msg = error.message || '请求失败';
// 特殊处理 JSON 解析错误
if (msg.includes('JSON') || msg.includes('parse')) {
msg = '服务器响应格式错误';
}
}
if (showError) {
uniShowToast({ title: msg, icon: 'none' });
}
const err: any = new Error(msg);
err.msg = msg;
err.originalError = error;
throw err;
});
};
// 导出便捷方法
export const http = {
get<T = any>(
url: string,
params?: object,
options?: Partial<RequestConfig>
): Promise<T> {
return request<T>({ url, method: 'GET', params, ...options });
},
post<T = any>(
url: string,
data?: object,
options?: Partial<RequestConfig>
): Promise<T> {
return request<T>({ url, method: 'POST', data, ...options });
},
put<T = any>(
url: string,
data?: object,
options?: Partial<RequestConfig>
): Promise<T> {
return request<T>({ url, method: 'PUT', data, ...options });
},
delete<T = any>(
url: string,
params?: object,
options?: Partial<RequestConfig>
): Promise<T> {
return request<T>({ url, method: 'DELETE', params, ...options });
},
};
export default http;

View File

@@ -0,0 +1,101 @@
/**
* 微信分享配置工具
*/
declare const uni: any;
/**
* 获取分享配置
* @param userId 用户ID用于邀请追踪
* @returns 分享配置对象
*/
export const getShareConfig = (userId?: number) => {
return {
title: '壹梵起名 - 传承国学智慧,赋予美好寓意',
path: userId ? `/pages/index/index?inviteCode=${userId}` : '/pages/index/index',
imageUrl: '', // 可以设置分享图片,留空使用默认截图
};
};
/**
* 设置页面分享配置
* 在页面的 onLoad 或 onMounted 中调用
* @param userId 用户ID
*/
export const setupPageShare = (userId?: number) => {
// 显示分享按钮
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
});
};
/**
* 分享给好友的配置
* 需要在页面中定义 onShareAppMessage 生命周期
*/
export const onShareAppMessage = (userId?: number) => {
return {
title: '壹梵起名 - 传承国学智慧,赋予美好寓意',
path: userId ? `/pages/index/index?inviteCode=${userId}` : '/pages/index/index',
imageUrl: '', // 可以设置分享图片
};
};
/**
* 分享到朋友圈的配置
* 需要在页面中定义 onShareTimeline 生命周期
*/
export const onShareTimeline = (userId?: number) => {
return {
title: '壹梵起名 - 传承国学智慧,赋予美好寓意',
query: userId ? `inviteCode=${userId}` : '',
imageUrl: '', // 可以设置分享图片
};
};
/**
* 从URL参数中获取邀请码
* @param options 页面参数
* @returns 邀请码用户ID
*/
export const getInviteCodeFromUrl = (options: any): string | null => {
return options?.inviteCode || null;
};
/**
* 保存邀请码到本地存储
* @param inviteCode 邀请码
*/
export const saveInviteCode = (inviteCode: string) => {
try {
uni.setStorageSync('invite_code', inviteCode);
console.log('保存邀请码:', inviteCode);
} catch (e) {
console.error('保存邀请码失败:', e);
}
};
/**
* 获取本地存储的邀请码
* @returns 邀请码
*/
export const getStoredInviteCode = (): string | null => {
try {
return uni.getStorageSync('invite_code');
} catch (e) {
console.error('获取邀请码失败:', e);
return null;
}
};
/**
* 清除本地存储的邀请码
*/
export const clearInviteCode = () => {
try {
uni.removeStorageSync('invite_code');
} catch (e) {
console.error('清除邀请码失败:', e);
}
};

View File

@@ -0,0 +1,254 @@
/**
* uni-app API 兼容层 - 用于 H5 环境
* 提供最小的 API 实现,保持代码不变
*/
// Toast 提示
export function showToast(options: { title: string; icon?: string; duration?: number }) {
const toast = document.createElement('div')
toast.className = 'uni-toast'
toast.textContent = options.title
toast.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 10000;
max-width: 80%;
text-align: center;
`
document.body.appendChild(toast)
setTimeout(() => {
document.body.removeChild(toast)
}, options.duration || 2000)
}
// Loading 提示
let loadingElement: HTMLElement | null = null
export function showLoading(options: { title?: string; mask?: boolean }) {
hideLoading()
loadingElement = document.createElement('div')
loadingElement.className = 'uni-loading'
loadingElement.innerHTML = `
<div style="
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${options.mask ? 'rgba(0, 0, 0, 0.3)' : 'transparent'};
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
">
<div style="
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
">
<div style="
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
"></div>
<div>${options.title || '加载中...'}</div>
</div>
</div>
`
// 添加旋转动画
if (!document.getElementById('uni-loading-style')) {
const style = document.createElement('style')
style.id = 'uni-loading-style'
style.textContent = `
@keyframes spin {
to { transform: rotate(360deg); }
}
`
document.head.appendChild(style)
}
document.body.appendChild(loadingElement)
}
export function hideLoading() {
if (loadingElement) {
document.body.removeChild(loadingElement)
loadingElement = null
}
}
// 本地存储
export function setStorageSync(key: string, data: any) {
try {
localStorage.setItem(key, JSON.stringify(data))
} catch (e) {
console.error('setStorageSync error:', e)
}
}
export function getStorageSync(key: string) {
try {
const data = localStorage.getItem(key)
return data ? JSON.parse(data) : null
} catch (e) {
console.error('getStorageSync error:', e)
return null
}
}
export function removeStorageSync(key: string) {
try {
localStorage.removeItem(key)
} catch (e) {
console.error('removeStorageSync error:', e)
}
}
// 导航
export function navigateTo(options: { url: string }) {
const path = options.url.split('?')[0]
const query = options.url.split('?')[1]
const params = new URLSearchParams(query)
if (window.vueRouter) {
window.vueRouter.push({
path,
query: Object.fromEntries(params)
})
}
}
export function redirectTo(options: { url: string }) {
const path = options.url.split('?')[0]
const query = options.url.split('?')[1]
const params = new URLSearchParams(query)
if (window.vueRouter) {
window.vueRouter.replace({
path,
query: Object.fromEntries(params)
})
}
}
export function navigateBack(options?: { delta?: number }) {
if (window.vueRouter) {
window.vueRouter.go(-(options?.delta || 1))
}
}
export function switchTab(options: { url: string }) {
navigateTo(options)
}
// 系统信息
export function getSystemInfoSync() {
return {
statusBarHeight: 0,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
platform: 'h5'
}
}
// 请求
export function request(options: any) {
return fetch(options.url, {
method: options.method || 'GET',
headers: options.header || {},
body: options.data ? JSON.stringify(options.data) : undefined
})
.then(res => res.json())
.then(data => {
options.success?.({ data })
return { data }
})
.catch(err => {
options.fail?.(err)
throw err
})
}
// 上传文件
export function uploadFile(options: any) {
const formData = new FormData()
formData.append(options.name, options.filePath)
if (options.formData) {
Object.keys(options.formData).forEach(key => {
formData.append(key, options.formData[key])
})
}
return fetch(options.url, {
method: 'POST',
headers: options.header || {},
body: formData
})
.then(res => res.json())
.then(data => {
options.success?.({ data })
return { data }
})
.catch(err => {
options.fail?.(err)
throw err
})
}
// 选择图片
export function chooseImage(options: any) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.multiple = options.count > 1
input.onchange = (e: any) => {
const files = Array.from(e.target.files || [])
const tempFilePaths = files.map((file: any) => URL.createObjectURL(file))
options.success?.({ tempFilePaths, tempFiles: files })
}
input.click()
}
// 全局 uni 对象
export const uni = {
showToast,
showLoading,
hideLoading,
setStorageSync,
getStorageSync,
removeStorageSync,
navigateTo,
redirectTo,
navigateBack,
switchTab,
getSystemInfoSync,
request,
uploadFile,
chooseImage
}
// 挂载到全局
if (typeof window !== 'undefined') {
(window as any).uni = uni
}
export default uni

View File

@@ -0,0 +1,260 @@
/**
* 财运解析专用支付工具
* 处理财运解析相关的支付和解锁功能
*/
import { payWithCallbacks } from './payment';
import type { CreateOrderParams } from '@/api/types';
declare const uni: any;
/**
* 财运解析解锁类型
*/
export enum WealthUnlockType {
MONTHLY = 'monthly',
DAILY = 'daily',
FULL = 'full'
}
/**
* 解锁配置接口
*/
export interface WealthUnlockConfig {
reportId: number;
unlockType: WealthUnlockType;
title: string;
price: number;
description?: string;
}
/**
* 解锁结果接口
*/
export interface WealthUnlockResult {
success: boolean;
unlockType: WealthUnlockType;
reportId: number;
msg?: string;
outTradeNo?: string;
}
/**
* 财运解析月度详批支付解锁
* @param config 解锁配置
* @returns Promise<解锁结果>
*/
export const payForWealthUnlock = async (config: WealthUnlockConfig): Promise<WealthUnlockResult> => {
const { reportId, unlockType, title, price, description } = config;
// 构建支付参数
const paymentParams: CreateOrderParams = {
description: description || `财运解析-${title}`,
total_amount: Math.round(price * 100), // 转换为分
business_type: 'wealth_analysis',
business_id: reportId,
pay_type: 'jsapi'
};
return new Promise((resolve) => {
payWithCallbacks(paymentParams, {
onSuccess: async (result) => {
uni.showToast({ title: '支付成功,正在解锁...', icon: 'success' });
// 保存解锁状态到本地存储
const storageKey = getUnlockStorageKey(reportId, unlockType);
uni.setStorageSync(storageKey, true);
// 延迟显示解锁成功提示
setTimeout(() => {
uni.showToast({
title: '解锁成功!请查看详细内容',
icon: 'success',
duration: 2000
});
}, 500);
resolve({
success: true,
unlockType,
reportId,
msg: '解锁成功',
outTradeNo: result.outTradeNo
});
},
onFail: (result) => {
uni.showModal({
title: '支付失败',
content: result.msg || '支付失败,请重试',
showCancel: true,
confirmText: '重试',
cancelText: '取消',
success: (res: any) => {
if (res.confirm) {
// 重新发起支付
payForWealthUnlock(config).then(resolve);
} else {
resolve({
success: false,
unlockType,
reportId,
msg: result.msg || '支付失败'
});
}
}
});
},
onCancel: () => {
console.log('用户取消财运解析支付解锁');
resolve({
success: false,
unlockType,
reportId,
msg: '用户取消支付'
});
}
}, true); // 使用安全模式
});
};
/**
* 检查财运解析解锁状态
* @param reportId 报告ID
* @param unlockType 解锁类型
* @returns 是否已解锁
*/
export const checkWealthUnlockStatus = (reportId: number, unlockType: WealthUnlockType): boolean => {
const storageKey = getUnlockStorageKey(reportId, unlockType);
return !!uni.getStorageSync(storageKey);
};
/**
* 批量检查财运解析解锁状态
* @param reportId 报告ID
* @returns 解锁状态对象
*/
export const checkAllWealthUnlockStatus = (reportId: number) => {
return {
monthly: checkWealthUnlockStatus(reportId, WealthUnlockType.MONTHLY),
daily: checkWealthUnlockStatus(reportId, WealthUnlockType.DAILY),
full: checkWealthUnlockStatus(reportId, WealthUnlockType.FULL)
};
};
/**
* 清除财运解析解锁状态(用于测试或重置)
* @param reportId 报告ID
* @param unlockType 解锁类型,不传则清除所有
*/
export const clearWealthUnlockStatus = (reportId: number, unlockType?: WealthUnlockType) => {
if (unlockType) {
const storageKey = getUnlockStorageKey(reportId, unlockType);
uni.removeStorageSync(storageKey);
} else {
// 清除所有解锁状态
Object.values(WealthUnlockType).forEach(type => {
const storageKey = getUnlockStorageKey(reportId, type);
uni.removeStorageSync(storageKey);
});
}
};
/**
* 获取解锁状态存储键
* @param reportId 报告ID
* @param unlockType 解锁类型
* @returns 存储键
*/
const getUnlockStorageKey = (reportId: number, unlockType: WealthUnlockType): string => {
return `wealth_unlock_${unlockType}_${reportId}`;
};
/**
* 财运解析月度详批快速支付
* @param reportId 报告ID
* @param price 价格(元)
* @returns Promise<解锁结果>
*/
export const payForMonthlyWealth = async (reportId: number, price: number): Promise<WealthUnlockResult> => {
return payForWealthUnlock({
reportId,
unlockType: WealthUnlockType.MONTHLY,
title: '12个月运势详批',
price,
description: '财运解析-12个月运势详批'
});
};
/**
* 财运解析每日运程快速支付
* @param reportId 报告ID
* @param price 价格(元)
* @returns Promise<解锁结果>
*/
export const payForDailyWealth = async (reportId: number, price: number): Promise<WealthUnlockResult> => {
return payForWealthUnlock({
reportId,
unlockType: WealthUnlockType.DAILY,
title: '365天每日吉凶指南',
price,
description: '财运解析-365天每日吉凶指南'
});
};
/**
* 财运解析完整版支付
* @param reportId 报告ID
* @param price 价格(元)
* @returns Promise<解锁结果>
*/
export const payForFullWealth = async (reportId: number, price: number): Promise<WealthUnlockResult> => {
return payForWealthUnlock({
reportId,
unlockType: WealthUnlockType.FULL,
title: '完整财运解析报告',
price,
description: '财运解析-完整版解析报告'
});
};
/**
* 财运解析支付工具类
*/
export const WealthPaymentHelper = {
/**
* 根据解锁类型获取默认标题
*/
getDefaultTitle: (unlockType: WealthUnlockType): string => {
const titles = {
[WealthUnlockType.MONTHLY]: '12个月运势详批',
[WealthUnlockType.DAILY]: '365天每日吉凶指南',
[WealthUnlockType.FULL]: '完整财运解析报告'
};
return titles[unlockType];
},
/**
* 根据解锁类型获取默认描述
*/
getDefaultDescription: (unlockType: WealthUnlockType): string => {
const descriptions = {
[WealthUnlockType.MONTHLY]: '财运解析-12个月运势详批',
[WealthUnlockType.DAILY]: '财运解析-365天每日吉凶指南',
[WealthUnlockType.FULL]: '财运解析-完整版解析报告'
};
return descriptions[unlockType];
},
/**
* 格式化价格显示
*/
formatPrice: (price: number): string => {
return `¥${price.toFixed(2)}`;
},
/**
* 验证解锁配置
*/
validateConfig: (config: WealthUnlockConfig): boolean => {
return !!(config.reportId && config.unlockType && config.title && config.price > 0);
}
};

View File

@@ -0,0 +1,199 @@
/**
* 微信 H5 内 JSAPI 支付(与 PersonalWealthAnalysis 月度详批 doDirectPay 一致)
* oauth2 code → createOrder → WeixinJSBridge.getBrandWCPayRequest
*/
import { paymentApi } from '@/api';
const APPID = 'wx1ca1ac7ad12123ac';
const PENDING_KEY = 'wx_pending_partner_apply';
declare const uni: any;
export function isWechatBrowser(): boolean {
if (typeof window === 'undefined') return false;
return /MicroMessenger/i.test(navigator.userAgent || '');
}
function getUrlCode(): string | null {
if (typeof window === 'undefined') return null;
let search = window.location.search;
if (search === '' && window.location.hash.indexOf('?') > -1) {
search = '?' + (window.location.hash.split('?')[1] || '');
}
if (!search) return null;
const reg = new RegExp('(^|&)code=([^&]*)(&|$)');
const r = search.substr(1).match(reg);
return r ? decodeURIComponent(r[2]) : null;
}
function cleanCodeFromUrl() {
try {
const url = new URL(window.location.href);
let changed = false;
if (url.searchParams.has('code') || url.searchParams.has('state')) {
url.searchParams.delete('code');
url.searchParams.delete('state');
changed = true;
}
if (url.hash.includes('?')) {
const [hashPath, hashQuery] = url.hash.split('?');
const hashParams = new URLSearchParams(hashQuery || '');
if (hashParams.has('code') || hashParams.has('state')) {
hashParams.delete('code');
hashParams.delete('state');
const nextHashQuery = hashParams.toString();
url.hash = nextHashQuery ? `${hashPath}?${nextHashQuery}` : hashPath;
changed = true;
}
}
if (changed) {
window.history.replaceState(null, '', url.toString());
}
} catch {}
}
export interface WechatJsapiPayParams {
description: string;
/** 与财运详批接口一致:元(如 99、18.8 */
totalAmountYuan: number;
businessType: string;
businessId: number;
}
export type WechatJsapiPayResult =
| { ok: true; outTradeNo?: string }
| { ok: false; redirected?: boolean; msg?: string };
function savePartnerPending(state: WechatJsapiPayParams) {
try {
localStorage.setItem(PENDING_KEY, JSON.stringify({ ...state, ts: Date.now() }));
} catch {}
}
function getPartnerPending(): (WechatJsapiPayParams & { ts?: number }) | null {
try {
const raw = localStorage.getItem(PENDING_KEY);
if (!raw) return null;
const s = JSON.parse(raw);
if (Date.now() - (s.ts || 0) > 5 * 60 * 1000) {
localStorage.removeItem(PENDING_KEY);
return null;
}
return s;
} catch {
return null;
}
}
function clearPartnerPending() {
try {
localStorage.removeItem(PENDING_KEY);
} catch {}
}
/**
* 合伙人 / 支付弹窗:微信内走 JSAPI无 code 时写 pending 并跳转授权页
*/
export async function payWithWechatJsapiH5(params: WechatJsapiPayParams): Promise<WechatJsapiPayResult> {
if (!isWechatBrowser()) {
uni.showToast({ title: '请在微信中打开', icon: 'none' });
return { ok: false, msg: 'not_wechat' };
}
const code = getUrlCode();
if (!code) {
savePartnerPending(params);
const redirectUri = encodeURIComponent(window.location.href);
window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APPID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect`;
return { ok: false, redirected: true };
}
const stored = getPartnerPending();
const merged: WechatJsapiPayParams = {
description: params.description || stored?.description || '推广合伙人权益',
totalAmountYuan: Number(params.totalAmountYuan ?? stored?.totalAmountYuan ?? 0),
businessType: params.businessType || stored?.businessType || 'partner_apply',
businessId: Number(params.businessId || stored?.businessId || 0),
};
cleanCodeFromUrl();
clearPartnerPending();
if (!merged.businessId) {
uni.showToast({ title: '订单信息丢失,请重新发起支付', icon: 'none' });
return { ok: false, msg: 'no_business_id' };
}
try {
uni.showLoading({ title: '创建订单中...' });
const orderRes = await paymentApi.createOrder({
description: merged.description,
total_amount: merged.totalAmountYuan,
business_type: merged.businessType as any,
business_id: merged.businessId,
pay_type: 'jsapi',
code,
});
uni.hideLoading();
if (!orderRes?.appId || !orderRes?.paySign) {
uni.showToast({ title: '获取支付参数失败', icon: 'none' });
return { ok: false, msg: 'no_pay_params' };
}
const pp = orderRes;
return await new Promise<WechatJsapiPayResult>((resolve) => {
const invoke = () => {
(window as any).WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: pp.appId,
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType || 'RSA',
paySign: pp.paySign,
},
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
uni.showToast({ title: '支付成功', icon: 'success' });
resolve({ ok: true, outTradeNo: orderRes.out_trade_no });
} else {
uni.showToast({
title: res.err_msg === 'get_brand_wcpay_request:cancel' ? '已取消' : '支付失败',
icon: 'none',
});
resolve({ ok: false, msg: res.err_msg });
}
}
);
};
if (typeof (window as any).WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', invoke, false);
} else {
invoke();
}
});
} catch (error: any) {
uni.hideLoading();
uni.showToast({ title: error.msg || '创建订单失败', icon: 'none' });
return { ok: false, msg: error?.msg || 'create_order_failed' };
}
}
/**
* OAuth 回跳后由入口页调用一次:有 code + pending 时自动下单并调起支付
*/
export async function tryResumePartnerPaymentAfterOAuth(): Promise<WechatJsapiPayResult> {
if (typeof window === 'undefined') return { ok: false };
if (!isWechatBrowser()) return { ok: false };
const pending = getPartnerPending();
if (!pending?.businessId) return { ok: false };
if (!getUrlCode()) return { ok: false };
return payWithWechatJsapiH5({
description: pending.description,
totalAmountYuan: pending.totalAmountYuan,
businessType: pending.businessType,
businessId: pending.businessId,
});
}

View File

@@ -0,0 +1,457 @@
/**
* 微信支付集成工具类
* 专门处理微信支付相关功能
*/
import { paymentApi } from '@/api';
import type { CreateOrderParams } from '@/api/types';
import { showToast } from './uni-compat';
declare const uni: any;
const PENDING_PAYMENT_KEY = 'wx_pending_payment';
export interface PendingPaymentState {
activeTab?: string;
reportId?: number;
unlockType?: string;
price?: number;
timestamp: number;
}
export const savePendingPayment = (state: Omit<PendingPaymentState, 'timestamp'>): void => {
try {
uni.setStorageSync(PENDING_PAYMENT_KEY, JSON.stringify({ ...state, timestamp: Date.now() }));
} catch {}
};
export const getPendingPayment = (): PendingPaymentState | null => {
try {
const raw = uni.getStorageSync(PENDING_PAYMENT_KEY);
if (!raw) return null;
const state = JSON.parse(raw) as PendingPaymentState;
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
uni.removeStorageSync(PENDING_PAYMENT_KEY);
return null;
}
return state;
} catch {
return null;
}
};
export const clearPendingPayment = (): void => {
try { uni.removeStorageSync(PENDING_PAYMENT_KEY); } catch {}
};
export const cleanH5WechatCodeFromUrl = (): void => {
try {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (url.searchParams.has('code')) {
url.searchParams.delete('code');
url.searchParams.delete('state');
window.history.replaceState(null, '', url.toString());
}
if (url.hash && url.hash.includes('code=')) {
const [hashPath, hashQuery] = url.hash.split('?');
if (hashQuery) {
const hp = new URLSearchParams(hashQuery);
hp.delete('code');
hp.delete('state');
const newHash = hp.toString() ? `${hashPath}?${hp.toString()}` : hashPath;
window.history.replaceState(null, '', `${url.origin}${url.pathname}${url.search}${newHash}`);
}
}
} catch {}
};
export const isH5WechatBrowser = (): boolean => {
try {
if (typeof window === 'undefined') return false;
const ua = window.navigator?.userAgent || '';
return /MicroMessenger/i.test(ua);
} catch {
return false;
}
};
const getH5UrlParam = (name: string): string | undefined => {
try {
if (typeof window === 'undefined') return undefined;
let search = window.location.search || '';
if (!search && window.location.hash && window.location.hash.includes('?')) {
const hashQuery = window.location.hash.split('?')[1] || '';
search = hashQuery ? `?${hashQuery}` : '';
}
if (!search) return undefined;
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
const value = params.get(name) || '';
return value || undefined;
} catch {
return undefined;
}
};
export const getH5WechatCode = (): string | undefined => {
try {
if (!isH5WechatBrowser()) return undefined;
return getH5UrlParam('code');
} catch {
return undefined;
}
};
export const redirectToH5WechatOAuthForCode = (): void => {
if (typeof window === 'undefined') return;
if (!isH5WechatBrowser()) return;
const existingCode = getH5WechatCode();
if (existingCode) return;
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || /^192\.168\./.test(host) || /^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
uni.showToast({ title: '本地环境无法微信授权,请使用线上域名', icon: 'none' });
return;
}
const appId = 'wx1ca1ac7ad12123ac';
if (!appId) {
uni.showToast({ title: '缺少VITE_WECHAT_H5_APPID', icon: 'none' });
return;
}
const href = window.location.href;
const cleanUrl = new URL(href);
cleanUrl.searchParams.delete('code');
cleanUrl.searchParams.delete('state');
const redirectUri = encodeURIComponent(cleanUrl.toString());
const scope = 'snsapi_base';
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=STATE#wechat_redirect`;
window.location.replace(url);
};
/**
* 支付状态枚举
*/
export enum PaymentStatus {
PENDING = 'pending',
PAID = 'paid',
CANCELLED = 'cancelled',
FAILED = 'failed',
REFUNDED = 'refunded'
}
/**
* 支付结果接口
*/
export interface PaymentResult {
success: boolean;
outTradeNo?: string;
msg?: string;
status?: PaymentStatus;
}
/**
* 微信支付配置
*/
export interface WechatPayConfig {
enablePolling?: boolean; // 是否启用轮询查询
maxPollAttempts?: number; // 最大轮询次数
pollInterval?: number; // 轮询间隔(毫秒)
}
/**
* 发起微信支付(完整流程)
* @param params 订单参数
* @param config 支付配置
* @returns Promise<支付结果>
*/
export const initiateWechatPayment = async (
params: CreateOrderParams,
config: WechatPayConfig = {}
): Promise<PaymentResult> => {
const {
enablePolling = true,
maxPollAttempts = 10,
pollInterval = 2000
} = config;
try {
// 1. 创建支付订单
const orderResult = await createPaymentOrder(params);
if (!orderResult.success) {
return orderResult;
}
// 2. 调起微信支付
const paymentResult = await callWechatPay(orderResult.orderData!);
// 3. 如果支付成功且启用轮询,则验证支付状态
if (paymentResult.success && enablePolling && paymentResult.outTradeNo) {
const verified = await verifyPaymentStatus(
paymentResult.outTradeNo,
maxPollAttempts,
pollInterval
);
if (!verified) {
return {
success: false,
outTradeNo: paymentResult.outTradeNo,
msg: '支付状态验证失败,请联系客服',
status: PaymentStatus.FAILED
};
}
}
return paymentResult;
} catch (error: any) {
console.error('微信支付流程失败:', error);
return {
success: false,
msg: error.msg || error.message || '支付失败,请重试',
status: PaymentStatus.FAILED
};
}
};
/**
* 创建支付订单
* @param params 订单参数
* @returns Promise<订单创建结果>
*/
const createPaymentOrder = async (params: CreateOrderParams): Promise<{
success: boolean;
orderData?: any;
msg?: string;
}> => {
try {
uni.showLoading({ title: '创建订单中...' });
// 确保支付参数完整
const h5Code = params.code || getH5WechatCode();
if (isH5WechatBrowser() && !h5Code) {
uni.hideLoading();
redirectToH5WechatOAuthForCode();
return {
success: false,
msg: '微信授权中',
};
}
const paymentParams: CreateOrderParams = {
...params,
pay_type: 'jsapi',
code: h5Code,
};
const orderRes = await paymentApi.createOrder(paymentParams);
uni.hideLoading();
cleanH5WechatCodeFromUrl();
if (!orderRes.appId && !orderRes.payment_params) {
throw new Error('获取支付参数失败');
}
return {
success: true,
orderData: orderRes
};
} catch (error: any) {
uni.hideLoading();
console.error('创建订单失败:', error);
// invalid code 时清理状态,避免无限循环
const errorMsg = error.msg || error.message || '创建订单失败';
if (errorMsg.includes('invalid code') || errorMsg.includes('code')) {
cleanH5WechatCodeFromUrl();
clearPendingPayment();
}
showToast({ title: errorMsg, icon: 'none' });
return {
success: false,
msg: errorMsg
};
}
};
/**
* 调起微信支付H5使用WeixinJSBridge小程序使用uni.requestPayment
* @param orderData 订单数据
* @returns Promise<支付结果>
*/
const callWechatPay = async (orderData: any): Promise<PaymentResult> => {
// 支持平铺和嵌套两种格式
const pp = orderData.payment_params || orderData;
if (!pp?.paySign) {
return { success: false, msg: '缺少支付参数', status: PaymentStatus.FAILED };
}
// H5微信浏览器使用 WeixinJSBridge
if (isH5WechatBrowser() && typeof window !== 'undefined') {
return callWechatPayH5(orderData);
}
// 小程序环境使用 uni.requestPayment
return new Promise((resolve) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType || 'MD5',
paySign: pp.paySign,
success: () => {
showToast({ title: '支付成功', icon: 'success' });
resolve({ success: true, outTradeNo: orderData.out_trade_no, msg: '支付成功', status: PaymentStatus.PAID });
},
fail: (err: any) => {
let msg = '支付失败';
let status = PaymentStatus.FAILED;
if (err.errMsg?.includes('cancel')) { msg = '用户取消支付'; status = PaymentStatus.CANCELLED; }
if (status !== PaymentStatus.CANCELLED) showToast({ title: msg, icon: 'none' });
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg, status });
}
});
});
};
/**
* H5微信浏览器内调起JSAPI支付
*/
const callWechatPayH5 = (orderData: any): Promise<PaymentResult> => {
const pp = orderData.payment_params || orderData;
return new Promise((resolve) => {
const invoke = () => {
(window as any).WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: pp.appId,
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType || 'RSA',
paySign: pp.paySign,
},
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
showToast({ title: '支付成功', icon: 'success' });
resolve({ success: true, outTradeNo: orderData.out_trade_no, msg: '支付成功', status: PaymentStatus.PAID });
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg: '用户取消支付', status: PaymentStatus.CANCELLED });
} else {
showToast({ title: '支付失败', icon: 'none' });
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg: res.err_msg || '支付失败', status: PaymentStatus.FAILED });
}
}
);
};
if (typeof (window as any).WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', invoke, false);
} else {
invoke();
}
});
};
/**
* 验证支付状态(轮询查询)
* @param outTradeNo 商户订单号
* @param maxAttempts 最大尝试次数
* @param interval 查询间隔(毫秒)
* @returns Promise<是否支付成功>
*/
const verifyPaymentStatus = async (
outTradeNo: string,
maxAttempts: number = 10,
interval: number = 2000
): Promise<boolean> => {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await paymentApi.queryOrder(outTradeNo);
if (res.status === 'paid') {
return true;
} else if (res.status === 'cancelled' || res.status === 'refunded') {
return false;
}
// 等待后继续查询
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
console.error(`${i + 1}次查询订单状态失败:`, error);
if (i === maxAttempts - 1) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
}
return false;
};
/**
* 快速支付(简化版本,适用于大多数场景)
* @param params 订单参数
* @returns Promise<支付结果>
*/
export const quickWechatPay = async (params: CreateOrderParams): Promise<PaymentResult> => {
return initiateWechatPayment(params, {
enablePolling: false // 不启用轮询,支付成功后立即返回
});
};
/**
* 安全支付(完整验证版本,适用于重要交易)
* @param params 订单参数
* @returns Promise<支付结果>
*/
export const secureWechatPay = async (params: CreateOrderParams): Promise<PaymentResult> => {
return initiateWechatPayment(params, {
enablePolling: true,
maxPollAttempts: 15,
pollInterval: 1500
});
};
/**
* 处理支付结果的通用方法
* @param result 支付结果
* @param onSuccess 成功回调
* @param onFail 失败回调
* @param onCancel 取消回调
*/
export const handlePaymentResult = (
result: PaymentResult,
callbacks: {
onSuccess?: (result: PaymentResult) => void;
onFail?: (result: PaymentResult) => void;
onCancel?: (result: PaymentResult) => void;
}
) => {
const { onSuccess, onFail, onCancel } = callbacks;
if (result.success) {
onSuccess?.(result);
} else {
switch (result.status) {
case PaymentStatus.CANCELLED:
onCancel?.(result);
break;
case PaymentStatus.FAILED:
default:
onFail?.(result);
break;
}
}
};