first commit

This commit is contained in:
zzc
2026-05-07 11:05:30 +08:00
commit 596de7b73c
96 changed files with 10688 additions and 0 deletions

92
utils/ability.js Normal file
View File

@@ -0,0 +1,92 @@
import { abilityCheck } from "@/api/system";
import adManager from "@/utils/adManager";
/**
* Checks if a user has the ability to perform an action (e.g., download).
* Handles common blocking scenarios like "need_share" or "need_ad".
*
* @param {string} scene - The scene identifier for the ability check (e.g., "wallpaper_download").
* @returns {Promise<boolean>} - Returns true if the action can proceed, false otherwise.
*/
export const checkAbilityAndHandle = async (scene) => {
try {
const abilityRes = await abilityCheck(scene);
if (abilityRes.canUse) {
return true;
}
if (
abilityRes?.blockType === "need_share" &&
abilityRes?.message === "分享可继续"
) {
uni.showToast({
title: "分享给好友即可下载",
icon: "none",
});
return false;
}
if (
abilityRes?.blockType === "need_ad"
) {
uni.showModal({
title: "积分不足",
content: "观看广告可获得50积分继续下载",
success: (res) => {
if (res.confirm) {
adManager.showVideoAd();
}
},
});
return false;
}
if (abilityRes?.blockType === "need_vip") {
uni.showModal({
title: "会员激活",
content: "会员激活即可继续使用该功能,是否前往开通?",
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: "/pages/mine/vip",
});
}
},
});
return false;
}
uni.showToast({
title: "您今日下载次数已用完,明日再试",
icon: "none",
});
return false;
} catch (e) {
console.error("Ability check failed", e);
uni.showToast({ title: "系统繁忙,请稍后重试", icon: "none" });
return false;
}
};
/**
* Gets the display label for an unlock type.
* @param {string} unlockType - The unlock type identifier.
* @returns {string} - The display label.
*/
export const getUnlockLabel = (unlockType) => {
if (!unlockType) return "解锁";
switch (unlockType) {
case "sing3":
return "连续签到3天";
case "sing5":
return "连续签到5天";
case "sing7":
return "连续签到7天";
case "ad":
return "广告";
case "vip":
return "VIP";
default:
return "解锁";
}
};

199
utils/adManager.js Normal file
View File

@@ -0,0 +1,199 @@
import { watchAdStart, watchAdReward } from "@/api/system.js";
import { useUserStore } from "@/stores/user";
class AdManager {
constructor() {
this.videoAd = null;
this.rewardToken = "";
this.isLoaded = false;
this.isAdShowing = false; // 新增:标记广告是否正在展示
this.adUnitId = "adunit-d7a28e0357d98947"; // Default ID from RewardAd.vue
// 彻底废除预初始化。
// 在微信小程序中,如果在页面未完全就绪时调用 createRewardedVideoAd
// 会导致底层寻找挂载点失败,抛出 `insertTextView:fail` 等严重渲染错误。
// 因此改为:只在用户真正点击“看广告”时才进行初始化。
}
/**
* Initializes the ad instance synchronously when needed.
* @param {string} adUnitId
*/
_initSync(adUnitId) {
if (this.videoAd && this.adUnitId === adUnitId) {
return true; // Already initialized for this ID
}
this.adUnitId = adUnitId || this.adUnitId;
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-TOUTIAO || MP-QQ
if (uni.createRewardedVideoAd) {
try {
// Destroy old instance if ID changed
if (this.videoAd) {
if (this.videoAd.destroy) {
try {
this.videoAd.destroy();
} catch (destroyErr) {
console.error("Ad Manager: destroy old ad failed", destroyErr);
}
}
this.videoAd = null;
}
// 核心修复:微信小程序官方建议使用单例,且多次调用 createRewardedVideoAd 传入相同的 adUnitId 会返回同一个实例。
// 但如果在短时间内销毁又立即创建,底层可能会发生不可预知的渲染报错(如找不到 parent 节点)。
// 因此,我们只需安全地获取实例,并重新绑定事件。
this.videoAd = uni.createRewardedVideoAd({
adUnitId: this.adUnitId
});
if (this.videoAd) {
// 清理之前可能存在的残留事件,防止重复触发和内存泄漏导致底层崩溃
if (this.videoAd.offLoad) this.videoAd.offLoad();
if (this.videoAd.offError) this.videoAd.offError();
if (this.videoAd.offClose) this.videoAd.offClose();
this.videoAd.onLoad(() => {
console.log("Ad Manager: Ad Loaded");
this.isLoaded = true;
});
this.videoAd.onError((err) => {
console.error("Ad Manager: Ad Load Error", err);
this.isLoaded = false;
});
return true;
}
} catch (e) {
console.error("Ad Manager Init Error", e);
return false;
}
}
// #endif
return false;
}
/**
* Show the rewarded video ad.
* Handles the full flow: start session -> show ad -> verify completion -> claim reward -> refresh assets.
* @param {string} [adUnitId] - Optional adUnitId to override the default.
* @param {string} [providedToken] - Optional token if watchAdStart was already called externally.
* @returns {Promise<boolean>} Resolves with true if reward was claimed, false otherwise.
*/
async showVideoAd(adUnitId, providedToken) {
if (this.isAdShowing || this.isStarting) {
console.log("Ad Manager: Ad is already starting or showing");
return false;
}
this.isStarting = true;
// 延迟到这一步才初始化,避开页面渲染期
const canInit = this._initSync(adUnitId);
if (!canInit || !this.videoAd) {
uni.showToast({ title: "当前环境不支持广告", icon: "none" });
this.isStarting = false;
return false;
}
try {
// Step 1: Start Ad Session (if not provided)
if (providedToken) {
this.rewardToken = providedToken;
} else {
const startRes = await watchAdStart();
if (!startRes || !startRes.rewardToken) {
uni.showToast({ title: "广告启动失败,请稍后再试", icon: "none" });
this.isStarting = false;
return false;
}
this.rewardToken = startRes.rewardToken;
}
// Step 2: Show Ad
try {
this.isAdShowing = true;
this.isStarting = false;
await this.videoAd.show();
} catch (e) {
// Retry load and show
try {
await this.videoAd.load();
await this.videoAd.show();
} catch (loadErr) {
this.isAdShowing = false;
console.error("Ad Manager: Retry load failed", loadErr);
throw loadErr;
}
}
// Step 3: Wait for Close
return new Promise((resolve) => {
const onCloseHandler = async (res) => {
// 延迟 500ms 释放状态防止从广告返回页面时onShow立即触发 DOM 大量更新,导致底层 insertTextView:fail 崩溃
setTimeout(() => {
this.isAdShowing = false;
}, 500);
// 安全地解绑事件
try {
if (this.videoAd && this.videoAd.offClose) {
this.videoAd.offClose(onCloseHandler); // Clean up listener
}
} catch (offErr) {
console.error("Ad Manager: offClose error", offErr);
}
if (res && res.isEnded) {
// Step 4: Claim Reward
// Only claim if token wasn't provided externally (if provided, external component claims it)
if (!providedToken) {
await this._claimReward(this.rewardToken);
}
resolve(true);
} else {
uni.showToast({ title: "观看完整广告才能获取奖励哦", icon: "none" });
resolve(false);
}
};
try {
this.videoAd.onClose(onCloseHandler);
} catch (onErr) {
console.error("Ad Manager: onClose error", onErr);
resolve(false);
}
});
} catch (e) {
console.error("Ad Manager: Show failed", e);
uni.showToast({ title: "广告加载失败,请稍后再试", icon: "none" });
return false;
}
}
async _claimReward(token) {
try {
uni.showLoading({ title: "发放奖励中..." });
const res = await watchAdReward(token);
if (res) {
uni.showToast({
title: "获得50积分",
icon: "success",
});
// Refresh user assets
const userStore = useUserStore();
await userStore.fetchUserAssets();
}
} catch (e) {
console.error("Ad Manager: Reward claim failed", e);
uni.showToast({ title: "奖励发放失败", icon: "none" });
} finally {
uni.hideLoading();
}
}
}
export default new AdManager();

127
utils/common.js Normal file
View File

@@ -0,0 +1,127 @@
import { getDeviceInfo } from "@/utils/system";
import {
saveRecord,
viewRecord,
createShareToken,
createTracking,
} from "@/api/system";
export const generateObjectId = (
m = Math,
d = Date,
h = 16,
s = (s) => m.floor(s).toString(h),
) => s(d.now() / 1000) + " ".repeat(h).replace(/./g, () => s(m.random() * h));
export const uploadImage = (filePath) => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: "https://api.ai-meng.com/api/common/upload",
filePath: filePath,
name: "file",
header: {
"x-app-id": "69665538a49b8ae3be50fe5d",
},
success: (res) => {
if (res.statusCode < 400) {
try {
const keyJson = JSON.parse(res.data);
if (keyJson?.data?.result === "auditFailed") {
reject("图片不符合发布规范,请稍作修改后再试");
} else {
const url = `https://file.lihailezzc.com/${keyJson?.data.key}`;
resolve(url);
}
} catch (e) {
reject(e);
}
} else {
reject(new Error("Upload failed"));
}
},
fail: (err) => {
reject(err);
},
});
});
};
export const saveRecordRequest = async (path, targetId, scene, imageUrl) => {
if (!imageUrl) {
imageUrl = await uploadImage(path);
}
const deviceInfo = getDeviceInfo();
saveRecord({
scene,
targetId,
imageUrl,
deviceInfo,
});
};
export const saveViewRequest = async (shareToken, scene, targetId = "") => {
const deviceInfo = getDeviceInfo();
viewRecord({
shareToken,
scene,
targetId,
deviceInfo,
});
};
export const saveRemoteImageToLocal = (imageUrl) => {
return new Promise((resolve, reject) => {
uni.downloadFile({
url: imageUrl,
success: (res) => {
if (res.statusCode === 200) {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
resolve(true);
},
fail: (err) => {
reject(err);
},
});
} else {
reject(
new Error("Download failed with status code: " + res.statusCode),
);
}
},
fail: (err) => {
reject(err);
},
});
});
};
export const getShareToken = async (scene, targetId = "") => {
const deviceInfo = getDeviceInfo();
const shareTokenRes = await createShareToken({
scene,
targetId,
...deviceInfo,
});
return shareTokenRes?.shareToken || "";
};
export const trackRecord = (event) => {
const pages = getCurrentPages();
// 核心修复在小程序刚启动或页面尚未压栈时pages 可能为空数组
const currentPage = pages && pages.length > 0 ? pages[pages.length - 1] : null;
const route = currentPage ? currentPage.route : "app_launch";
const deviceInfo = getDeviceInfo();
createTracking({
...event,
page: route,
deviceInfo,
});
};
export const getThumbUrl = (url) => {
if (!url) return "";
return `${url}?imageView2/1/w/200/h/200/q/80`;
};

18
utils/date.js Normal file
View File

@@ -0,0 +1,18 @@
export function formatDate(isoString, format = 'YYYY-MM-DD HH:mm:ss') {
if (!isoString) return ''
const date = new Date(isoString)
if (isNaN(date.getTime())) return ''
const pad = (n) => n.toString().padStart(2, '0')
const map = {
YYYY: date.getFullYear().toString(),
MM: pad(date.getMonth() + 1),
DD: pad(date.getDate()),
HH: pad(date.getHours()),
mm: pad(date.getMinutes()),
ss: pad(date.getSeconds())
}
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, token => map[token])
}

53
utils/login.js Normal file
View File

@@ -0,0 +1,53 @@
export const wxLogin = () => {
return new Promise((resolve, reject) => {
wx.login({
success: (res) => {
if (res.code) {
resolve(res.code);
} else {
reject("登录失败code为空");
}
},
fail: (err) => {
reject(err);
},
});
});
};
export const alipayLogin = () => {
return new Promise((resolve, reject) => {
// #ifdef MP-ALIPAY
my.getAuthCode({
scopes: "auth_user",
success: (res) => {
if (res.authCode) {
resolve(res.authCode);
} else {
reject("登录失败authCode为空");
}
},
fail: (err) => {
reject(err);
},
});
// #endif
// #ifndef MP-ALIPAY
reject("当前非支付宝环境");
// #endif
});
};
export const wxGetUserProfile = () => {
return new Promise((resolve, reject) => {
wx.getUserProfile({
desc: "用于完善用户信息",
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
},
});
});
};

143
utils/request.js Normal file
View File

@@ -0,0 +1,143 @@
const BASE_URL = "https://api.ai-meng.com";
// const BASE_URL = 'http://127.0.0.1:3999'
// const BASE_URL = "http://192.168.1.2:3999";
// const BASE_URL = "http://192.168.31.253:3999";
import { useUserStore } from "@/stores/user";
import { getPlatform } from "./system.js";
const platform = getPlatform();
// 环境区分
// const BASE_URL =
// process.env.NODE_ENV === 'production'
// ? 'https://apis.lihailezzc.com'
// : 'http://127.0.0.1:3999'
// 192.168.2.186
// 192.168.31.253
// const BASE_URL = 'https://apis.lihailezzc.com'
let isLoadingCount = 0; // 计数多次请求loading
function showLoadingFlag(loadingText) {
if (isLoadingCount === 0) {
uni.showLoading({ title: loadingText });
}
isLoadingCount++;
}
function hideLoading() {
isLoadingCount--;
if (isLoadingCount <= 0) {
isLoadingCount = 0;
uni.hideLoading();
}
}
function getHeaders() {
const userStore = useUserStore();
const headers = {
"x-app-id": "69665538a49b8ae3be50fe5d",
"x-platform": platform,
"x-user-id": userStore?.userInfo?.id || "",
};
if (userStore.token) {
headers["Authorization"] = userStore.token;
}
return headers;
}
function handleError(err, showError = true) {
if (!showError) return;
let msg = "网络异常,请稍后重试";
if (typeof err === "string") {
msg = err;
} else if (err?.msg) {
msg = err.msg;
} else if (err?.message) {
msg = err.message;
}
uni.showModal({
title: "错误提示",
content: msg,
showCancel: false,
});
}
/**
* 请求函数支持自动加载状态、token、错误弹窗、重试等
* @param {Object} config 请求配置
* @param {string} config.url 请求路径(必填)
* @param {string} [config.method='GET'] 请求方法
* @param {Object} [config.data={}] 请求参数
* @param {Object} [config.header={}] 额外 headers
* @param {boolean} [config.showLoading=true] 是否自动显示加载提示
* @param {boolean} [config.showError=true] 是否自动弹错误提示
* @param {number} [config.timeout=10000] 请求超时,单位毫秒
* @param {number} [config.retry=1] 失败后自动重试次数
* @returns {Promise<any>} 返回接口 data
*/
export function request(config) {
if (!config || !config.url) {
return Promise.reject(new Error("请求缺少 url 参数"));
}
const {
url,
method = "GET",
data = {},
header = {},
showLoading = false,
showError = true,
retry = 1,
loadingText = "加载中",
} = config;
const finalHeader = { ...getHeaders(), ...header };
const upperMethod = method.toUpperCase();
let attempt = 0;
const doRequest = () =>
new Promise((resolve, reject) => {
if (showLoading) showLoadingFlag(loadingText);
uni.request({
url: BASE_URL + url,
method: upperMethod,
data,
header: finalHeader,
success: (res) => {
if (showLoading) hideLoading();
const { statusCode, data: body } = res;
if (
(statusCode === 200 || statusCode === 201) &&
body?.code === 200
) {
resolve(body.data);
} else {
reject({
type: "business",
msg: body?.msg || "服务器错误",
data: body,
});
}
},
fail: (err) => {
if (showLoading) hideLoading();
reject({ type: "network", msg: "网络请求失败", error: err });
},
});
});
// 自动重试
const tryRequest = () =>
doRequest().catch((err) => {
if (attempt < retry) {
attempt++;
return tryRequest();
}
if (showError) handleError(err.msg || err);
return Promise.reject(err);
});
return tryRequest();
}

122
utils/system.js Normal file
View File

@@ -0,0 +1,122 @@
function getSafeSystemInfo() {
try {
// #ifdef MP-WEIXIN
// 优先使用原生 wx 新 API避开 uni 包装层内部对 getSystemInfoSync 的兼容调用
if (
typeof wx !== "undefined" &&
wx.getWindowInfo &&
wx.getAppBaseInfo &&
wx.getDeviceInfo
) {
return {
...wx.getWindowInfo(),
...wx.getAppBaseInfo(),
...wx.getDeviceInfo(),
};
}
// #endif
if (uni.getWindowInfo && uni.getAppBaseInfo && uni.getDeviceInfo) {
return {
...uni.getWindowInfo(),
...uni.getAppBaseInfo(),
...uni.getDeviceInfo(),
};
}
} catch (e) {
// fall through to legacy fallback
}
return uni.getSystemInfoSync();
}
let SYSTEM = getSafeSystemInfo();
export const getStatusBarHeight = () => SYSTEM.statusBarHeight || 15;
export const getTitleBarHeight = () => {
// #ifdef MP-ALIPAY
return 44;
// #endif
if (uni.getMenuButtonBoundingClientRect) {
try {
const rect = uni.getMenuButtonBoundingClientRect();
if (rect && rect.top && rect.height) {
return rect.height + (rect.top - getStatusBarHeight()) * 2;
}
} catch (e) {
console.error(e);
}
}
return 44;
};
export const getBavBarHeight = () => getStatusBarHeight() + getTitleBarHeight();
export const getLeftIconLeft = () => {
// if(tt?.getMenuButtonBoundingClientRect) {
// const { leftIcon: {left, width}} = tt.getMenuButtonBoundingClientRect
// return left + parseInt(width) + 5
// } else {
// return 0
// }
// #ifdef MP-TOUTIAO
const {
leftIcon: { left, width },
} = tt.getCustomButtonBoundingClientRect();
return left + parseInt(width);
// #endif
// #ifndef MP-TOUTIAO
return 0;
// #endif
};
export function getPlatform() {
return getPlatformProvider().replace("mp-", "");
}
export function getPlatformProvider() {
const platform = process.env.UNI_PLATFORM;
return platform || "mp-weixin";
// const platform = uni.getSystemInfoSync().platform;
// console.log(1111111, platform)
// // H5 模拟器调试时使用 __wxConfig 环境变量判断
// if (typeof __wxConfig !== 'undefined') return 'weixin';
// if (platform === 'devtools') {
// // 可以根据小程序环境变量判断
// // 抖音开发工具会定义 tt 对象
// return typeof tt !== 'undefined' ? 'toutiao' : 'weixin';
// }
// return typeof tt !== 'undefined' ? 'toutiao' : 'weixin';
}
export function getDeviceInfo() {
const info = getSafeSystemInfo();
return {
platform: info.platform,
brand: info.brand,
model: info.model,
system: info.system,
screenWidth: info.screenWidth,
screenHeight: info.screenHeight,
pixelRatio: info.pixelRatio,
language: info.language,
version: info.version,
SDKVersion: info.SDKVersion,
appId: "69665538a49b8ae3be50fe5d",
};
}
/**
* 判断是否处于朋友圈单页模式
*/
export function isSinglePageMode() {
// #ifdef MP-WEIXIN
const launchOptions = uni.getLaunchOptionsSync();
return launchOptions.scene === 1154;
// #endif
return false;
}

121
utils/track.js Normal file
View File

@@ -0,0 +1,121 @@
import { request } from "@/utils/request";
// 生成并缓存 device_id
function getOrCreateDeviceId() {
let deviceId = uni.getStorageSync("device_id");
if (!deviceId) {
deviceId = Date.now() + "_" + Math.random().toString(36).substr(2, 9);
uni.setStorageSync("device_id", deviceId);
}
return deviceId;
}
// 获取网络类型
function getNetworkType() {
return new Promise((resolve) => {
uni.getNetworkType({
success: (res) => resolve(res.networkType || "unknown"),
fail: () => resolve("unknown"),
});
});
}
// 获取系统平台
function getSystemInfo() {
return new Promise((resolve) => {
uni.getSystemInfo({
success: (res) => resolve(res),
fail: () => resolve({}),
});
});
}
// 获取当前页面路径
function getCurrentPagePath() {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
return currentPage?.route || "";
}
function getMiniProgramVersion() {
const platform = process.env.UNI_PLATFORM;
let version = "";
let envVersion = "";
try {
if (platform === "mp-weixin") {
// 微信小程序
const info = wx.getAccountInfoSync();
version = info?.miniProgram?.version || "";
envVersion = info?.miniProgram?.envVersion || "";
} else if (platform === "mp-alipay") {
// 支付宝小程序
const info = my.getAppInfoSync();
version = info?.version || "";
} else if (platform === "mp-toutiao" || platform === "mp-jd") {
// 抖音/头条/京东小程序
const info = tt.getAccountInfoSync?.();
version = info?.miniProgram?.version || "";
envVersion = info?.miniProgram?.envVersion || "";
} else if (platform === "mp-baidu") {
// 百度小程序(无标准方法获取版本号)
version = ""; // 百度不支持获取版本号
} else {
version = "";
}
} catch (e) {
version = "";
envVersion = "";
}
return {
platform, // 当前小程序平台
version, // 小程序版本号
envVersion, // develop / trial / release微信等支持
};
}
// 构造埋点对象
async function buildEventData(
eventName,
eventType = "click",
customParams = {}
) {
const deviceId = getOrCreateDeviceId();
const systemInfo = await getSystemInfo();
const networkType = await getNetworkType();
// const location = await getLocation()
const appVersion = typeof plus !== "undefined" ? plus?.runtime?.version : "";
const { envVersion, platform, version } = getMiniProgramVersion();
return {
userId: uni.getStorageSync("user_id") || null,
deviceId: deviceId,
eventName: eventName,
eventType: eventType,
eventTime: new Date(),
page: getCurrentPagePath(),
elementId: customParams.element_id || null,
elementContent: customParams.element_content || null,
customParams: customParams,
networkType: networkType,
os: systemInfo.platform || "unknown",
appVersion: version || "unknown",
envVersion: envVersion || "",
platform: platform || "",
};
}
// 发送埋点数据到服务器
async function sendTrack(eventName, eventType = "click", customParams = {}) {
const eventData = await buildEventData(eventName, eventType, customParams);
request({
url: "/api/common/tracking/create",
method: "POST",
data: eventData,
});
}
export default {
sendTrack,
};