upload project source code
This commit is contained in:
174
前端源码/uni-app/utils/auth.ts
Normal file
174
前端源码/uni-app/utils/auth.ts
Normal 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));
|
||||
};
|
||||
|
||||
|
||||
85
前端源码/uni-app/utils/backend-wuxing-zodiac-spec.ts
Normal file
85
前端源码/uni-app/utils/backend-wuxing-zodiac-spec.ts
Normal 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: "0–100,与八字契合度(可选)",
|
||||
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)若有八字/喜用神上下文:说明姓名五行是否补益喜用、或加重忌神;给出 0–100 契合度时可填 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");
|
||||
}
|
||||
20
前端源码/uni-app/utils/device-layout.ts
Normal file
20
前端源码/uni-app/utils/device-layout.ts
Normal 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;
|
||||
}
|
||||
7
前端源码/uni-app/utils/feature-flags.ts
Normal file
7
前端源码/uni-app/utils/feature-flags.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 全局功能开关
|
||||
* false: 不隐藏充值功能(显示)
|
||||
* true: 隐藏充值功能
|
||||
*/
|
||||
export const HIDE_RECHARGE_FEATURE: boolean = true;
|
||||
|
||||
285
前端源码/uni-app/utils/payment-examples.ts
Normal file
285
前端源码/uni-app/utils/payment-examples.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
};
|
||||
175
前端源码/uni-app/utils/payment.ts
Normal file
175
前端源码/uni-app/utils/payment.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
BIN
前端源码/uni-app/utils/pdf/background.png
Normal file
BIN
前端源码/uni-app/utils/pdf/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
277
前端源码/uni-app/utils/poll-test-solution-detail.ts
Normal file
277
前端源码/uni-app/utils/poll-test-solution-detail.ts
Normal 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('详情生成超时,请稍后在「我的方案」中查看');
|
||||
}
|
||||
600
前端源码/uni-app/utils/qrcode.js
Normal file
600
前端源码/uni-app/utils/qrcode.js
Normal 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 };
|
||||
352
前端源码/uni-app/utils/request.ts
Normal file
352
前端源码/uni-app/utils/request.ts
Normal 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;
|
||||
101
前端源码/uni-app/utils/share.ts
Normal file
101
前端源码/uni-app/utils/share.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
254
前端源码/uni-app/utils/uni-compat.ts
Normal file
254
前端源码/uni-app/utils/uni-compat.ts
Normal 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
|
||||
260
前端源码/uni-app/utils/wealth-payment.ts
Normal file
260
前端源码/uni-app/utils/wealth-payment.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
199
前端源码/uni-app/utils/wechat-h5-jsapi-pay.ts
Normal file
199
前端源码/uni-app/utils/wechat-h5-jsapi-pay.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
457
前端源码/uni-app/utils/wechat-payment.ts
Normal file
457
前端源码/uni-app/utils/wechat-payment.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user