You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1126 lines
44 KiB
1126 lines
44 KiB
import { EDITOR } from 'cc/env';
|
|
import { sys } from 'cc';
|
|
import { MiniGamePf } from '../../mx/tools/MiniGameHostInfo';
|
|
import { IUserInfo } from '../game/GameController';
|
|
import { TableNames } from '../game/ConfigTableData';
|
|
import { RankDataManager } from './RankDataManager';
|
|
import { DailyWinStats } from './DailyWinStats';
|
|
import { HeadIconManager } from './HeadIconManager';
|
|
import { OtherDataType } from '../game/GameDataEnums';
|
|
import { ProvinceLocator } from './ProvinceLocator';
|
|
|
|
/** 排行榜类型:省榜 / 总榜 / 每日胜场榜 */
|
|
export enum RankBoardScope {
|
|
Province = 1,
|
|
Total = 2,
|
|
DailyWins = 3,
|
|
}
|
|
|
|
/** 单条排行榜展示数据 */
|
|
export type RankBoardEntry = {
|
|
rank: number;
|
|
score: number;
|
|
nickname: string;
|
|
avatarUrl: string;
|
|
region: string;
|
|
userId: string;
|
|
isSelf?: boolean;
|
|
};
|
|
|
|
export type RankBoardResult = {
|
|
list: RankBoardEntry[];
|
|
self: RankBoardEntry | null;
|
|
};
|
|
|
|
const RANK_TOTAL_CATEGORY = '总榜';
|
|
const RANK_DAILY_WINS_CATEGORY = '每日胜场榜';
|
|
const RANK_TOP_COUNT = 100;
|
|
const RANK_PRESET = 'today';
|
|
/** 1=永久榜 2=日榜 */
|
|
const RANK_LIST_TYPE_PERMANENT = 1;
|
|
const RANK_LIST_TYPE_DAILY = 2;
|
|
/** 未授权玩家展示昵称 */
|
|
const RANK_NICK_ANONYMOUS = '匿名';
|
|
/** 未授权玩家展示地区 */
|
|
const RANK_REGION_UNKNOWN = '未知';
|
|
|
|
/** 排行榜用户信息授权选项 */
|
|
export type RankUserAuthOpts = {
|
|
/** 授权失败时不弹 Toast */
|
|
silent?: boolean;
|
|
};
|
|
|
|
type BoardCacheItem = {
|
|
data: RankBoardResult;
|
|
at: number;
|
|
};
|
|
|
|
export class RankBoardManager {
|
|
private static _selfUserId: string | null = null;
|
|
private static _authPromise: Promise<boolean> | null = null;
|
|
/** 日榜上传世代:跨天或新请求作废未完成的旧上传 */
|
|
private static _dailyWinsUploadGen = 0;
|
|
/** 三榜预拉取缓存(进大厅静默写入,打开面板直接读) */
|
|
private static _boardCache = new Map<RankBoardScope, BoardCacheItem>();
|
|
private static _fetchInflight = new Map<RankBoardScope, Promise<RankBoardResult>>();
|
|
private static _warmupInflight: Promise<void> | null = null;
|
|
private static _locateInflight: Promise<string> | null = null;
|
|
|
|
/** 首次进入排行榜后标记红点已读 */
|
|
static markRankEntered(): void {
|
|
if (gg.data.getkeyKeyValue(OtherDataType.RedDotState, 'rank') === '1') return;
|
|
gg.data.setkeyKeyValue(OtherDataType.RedDotState, 'rank', '1');
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
static hasRankProfile(): boolean {
|
|
const name = String(gg.data?.doc?.userName ?? '').trim();
|
|
const avatar = String(gg.data?.doc?.userAvatar ?? '').trim();
|
|
return !!name && !!avatar;
|
|
}
|
|
|
|
static getSavedNickname(): string {
|
|
return this._decodeNick(String(gg.data?.doc?.userName ?? '').trim());
|
|
}
|
|
|
|
static getSavedAvatar(): string {
|
|
return String(gg.data?.doc?.userAvatar ?? '').trim();
|
|
}
|
|
|
|
static getSavedProvince(): string {
|
|
return String(gg.data?.doc?.userProvince ?? '').trim();
|
|
}
|
|
|
|
/** 是否已弹过排行榜首次用户信息授权 */
|
|
static isRankAuthPrompted(): boolean {
|
|
return Math.floor(Number(gg.data?.doc?.rankUserAuthPrompted) || 0) === 1;
|
|
}
|
|
|
|
/** 标记排行榜首次用户信息授权已弹过 */
|
|
static markRankAuthPrompted(): void {
|
|
if (this.isRankAuthPrompted()) return;
|
|
gg.data.doc.rankUserAuthPrompted = 1;
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/**
|
|
* 首次打开排行榜时弹一次用户信息授权;之后打开不再判断授权。
|
|
* 须在 onInit 等同步路径调用(不能先 await),否则平台不弹授权窗。
|
|
* 授权成功后清预拉取缓存,面板侧可异步刷新「我」的资料。
|
|
*/
|
|
static tryPromptRankAuthOnFirstOpen(): Promise<boolean> {
|
|
if (this.isRankAuthPrompted()) return Promise.resolve(this.hasRankProfile());
|
|
this.markRankAuthPrompted();
|
|
if (this.hasRankProfile()) return Promise.resolve(true);
|
|
return this.ensureRankUserAuth({ silent: true }).then((ok) => {
|
|
if (ok) this.invalidateBoardCache();
|
|
return ok;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 拉取平台用户信息授权并写入存档,成功后同步榜单。
|
|
* 须在点击回调里同步调用(不能先 await),否则平台不弹授权窗。
|
|
*/
|
|
static ensureRankUserAuth(opts?: RankUserAuthOpts): Promise<boolean> {
|
|
if (this._authPromise) return this._authPromise;
|
|
|
|
if (!this._needPlatformAuth()) {
|
|
this._authPromise = Promise.all([
|
|
this.syncSelfScore(),
|
|
this.syncSelfDailyWins(),
|
|
]).then(() => true).finally(() => {
|
|
this._authPromise = null;
|
|
});
|
|
return this._authPromise;
|
|
}
|
|
|
|
if (this.hasRankProfile()) {
|
|
// 只做用户资料同步;定位授权由打开排行榜 / 点省榜单独触发
|
|
this._authPromise = (async () => {
|
|
await this.syncSelfScore();
|
|
await this.syncSelfDailyWins();
|
|
return true;
|
|
})().finally(() => {
|
|
this._authPromise = null;
|
|
});
|
|
return this._authPromise;
|
|
}
|
|
|
|
const infoP = this._pullRankUserInfo();
|
|
this._authPromise = this._finishUserInfoAuth(infoP, opts).finally(() => {
|
|
this._authPromise = null;
|
|
});
|
|
return this._authPromise;
|
|
}
|
|
|
|
/** @deprecated 使用 ensureRankUserAuth */
|
|
static ensureAuthToEnter(): Promise<boolean> {
|
|
return this.ensureRankUserAuth();
|
|
}
|
|
|
|
private static _pullRankUserInfo(): Promise<{ userInfo?: Record<string, unknown> } | null> {
|
|
return new Promise((resolve) => {
|
|
gg.sdk.pullRankUserInfo((info) => resolve(info ?? null));
|
|
});
|
|
}
|
|
|
|
/** 调试用:拉取结果摘要(昵称/头像截断) */
|
|
private static _describeInfo(info: { userInfo?: Record<string, unknown> } | null): string {
|
|
if (!info?.userInfo) return 'null(未拉到)';
|
|
const u = info.userInfo;
|
|
const nick = String(u.nickName ?? u.nickname ?? '').trim();
|
|
const avatar = String(u.avatarUrl ?? '').trim();
|
|
const nickShow = nick ? `"${nick.length > 20 ? nick.slice(0, 20) + '…' : nick}"` : '(空)';
|
|
const avatarShow = avatar ? `"${avatar.slice(0, 40)}${avatar.length > 40 ? '…' : ''}"` : '(空)';
|
|
return `nick=${nickShow} avatar=${avatarShow}`;
|
|
}
|
|
|
|
static saveUserProfile(userInfo: Record<string, unknown> | null | undefined): boolean {
|
|
return this._saveUserFromInfo(userInfo ? { userInfo } : null);
|
|
}
|
|
|
|
private static async _finishUserInfoAuth(
|
|
infoP: Promise<{ userInfo?: Record<string, unknown> } | null>,
|
|
opts?: RankUserAuthOpts,
|
|
): Promise<boolean> {
|
|
let info = await infoP;
|
|
console.warn(`[RankDebug] 首拉用户信息结果=${this._describeInfo(info)}`);
|
|
if (!this._saveUserFromInfo(info)) {
|
|
if (opts?.silent) return false;
|
|
// 先查授权状态:已授权(可能拉取抖动/中途取消)不弹设置直接重拉;未授权才打开设置
|
|
const granted = await this._isScopeGranted('scope.userInfo');
|
|
console.warn(`[RankDebug] authSetting.scope.userInfo=${granted} → ${granted ? '已授权,不开设置直接重拉' : '未授权,打开设置'}`);
|
|
if (!granted) {
|
|
await this._openSetting();
|
|
}
|
|
info = await this._pullRankUserInfo();
|
|
console.warn(`[RankDebug] 重拉用户信息结果=${this._describeInfo(info)}`);
|
|
if (!this._saveUserFromInfo(info)) {
|
|
console.warn('[RankDebug] 重拉仍无有效资料,toast 提示');
|
|
gg.ui.showToast(
|
|
gg.lang.get('需要授权用户信息才能查看省榜') || '需要授权用户信息才能查看省榜',
|
|
false,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
console.warn(`[RankDebug] 资料就绪 userName="${this.getSavedNickname()}" province="${this.getSavedProvince()}"`);
|
|
gg.data.saveToServer();
|
|
await this._syncAllBoardsAfterProfileUpdate();
|
|
this.invalidateBoardCache();
|
|
return this.hasRankProfile();
|
|
}
|
|
|
|
private static _saveUserFromInfo(info: { userInfo?: Record<string, unknown> } | null): boolean {
|
|
const user = info?.userInfo;
|
|
const nick = String(user?.nickName ?? user?.nickname ?? '').trim();
|
|
const avatar = String(user?.avatarUrl ?? '').trim();
|
|
if (!nick || !avatar) return false;
|
|
const encoded = this._encodeNick(nick);
|
|
if (!encoded) {
|
|
console.warn('[RankBoardManager] 昵称 Base64 编码失败,未写入存档');
|
|
return false;
|
|
}
|
|
gg.data.doc.userName = encoded;
|
|
gg.data.doc.userAvatar = avatar;
|
|
gg.data.saveLocal();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 同步星数到省榜 + 总榜。
|
|
* 首次进榜上传一次;之后仅星数或省份变化时再上传。
|
|
*/
|
|
static async syncSelfScore(force = false): Promise<void> {
|
|
await RankDataManager.ensureTable();
|
|
await gg.data.table.ensureTableLoaded(TableNames.Headicon);
|
|
const score = RankDataManager.getRankStars();
|
|
// 只读存档省份;定位授权不在对局结算/同步路径触发
|
|
const province = this.getSavedProvince();
|
|
const profile = this._getUploadProfile();
|
|
const region = province || profile.region;
|
|
if (!force && !this._needUploadRank(score, province, profile, region)) return;
|
|
|
|
const avatar = profile.avatar;
|
|
const nick = profile.nickname;
|
|
|
|
try {
|
|
if (province) {
|
|
this._setRankingData(score, province, avatar, nick, region, RANK_LIST_TYPE_PERMANENT);
|
|
}
|
|
this._setRankingData(score, RANK_TOTAL_CATEGORY, avatar, nick, region, RANK_LIST_TYPE_PERMANENT);
|
|
this._markRankUploaded(score, province, nick, avatar, region);
|
|
} catch (e) {
|
|
console.log('[RankBoardManager] syncSelfScore', String(e));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 同步今日胜场到每日胜场榜(日榜 rankingtype=2)。
|
|
* 胜场变化或跨天后重新上传;await 之后再按当天日期取值,禁止把昨日场次传到新日榜。
|
|
*/
|
|
static async syncSelfDailyWins(force = false): Promise<void> {
|
|
const uploadGen = ++this._dailyWinsUploadGen;
|
|
await gg.data.table.ensureTableLoaded(TableNames.Headicon);
|
|
if (uploadGen !== this._dailyWinsUploadGen) return;
|
|
|
|
// 只读存档省份;定位授权不在对局结算/同步路径触发
|
|
const province = this.getSavedProvince();
|
|
|
|
DailyWinStats.ensureToday();
|
|
const today = DailyWinStats.todayKey();
|
|
const wins = DailyWinStats.getTodayWins();
|
|
const profile = this._getUploadProfile();
|
|
const region = province || profile.region;
|
|
if (!force && !this._needUploadDailyWins(wins, today, profile, region)) return;
|
|
|
|
const avatar = profile.avatar;
|
|
const nick = profile.nickname;
|
|
const expiredate = DailyWinStats.expireDateToday();
|
|
|
|
try {
|
|
this._setRankingData(wins, RANK_DAILY_WINS_CATEGORY, avatar, nick, region, RANK_LIST_TYPE_DAILY, expiredate);
|
|
this._markDailyWinsUploaded(wins, today, nick, avatar, region);
|
|
} catch (e) {
|
|
console.log('[RankBoardManager] syncSelfDailyWins', String(e));
|
|
}
|
|
}
|
|
|
|
/** 首次、胜场变化、或上次上传不是今天则再传每日胜场榜 */
|
|
private static _needUploadDailyWins(
|
|
wins: number,
|
|
today: string,
|
|
profile: { nickname: string; avatar: string; region: string },
|
|
region: string,
|
|
): boolean {
|
|
const doc = gg.data?.doc;
|
|
if (!doc) return false;
|
|
const lastDate = String(doc.rankLastSyncDailyWinsDate ?? '').trim();
|
|
if (lastDate !== today) return true;
|
|
const last = Number(doc.rankLastSyncDailyWins);
|
|
if (last !== wins) return true;
|
|
return this._needUploadProfile(profile, region);
|
|
}
|
|
|
|
private static _markDailyWinsUploaded(
|
|
wins: number,
|
|
today: string,
|
|
nick: string,
|
|
avatar: string,
|
|
region: string,
|
|
): void {
|
|
const doc = gg.data?.doc;
|
|
if (!doc) return;
|
|
doc.rankLastSyncDailyWins = wins;
|
|
doc.rankLastSyncDailyWinsDate = today;
|
|
doc.rankLastSyncNick = nick;
|
|
doc.rankLastSyncAvatar = avatar;
|
|
doc.rankLastSyncRegion = region;
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/** score, category, avatar, nick 保持原逻辑;region=存档省份;日榜须带当日 expiredate */
|
|
private static _setRankingData(
|
|
score: number,
|
|
category: string,
|
|
avatar: string,
|
|
nick: string,
|
|
region: string,
|
|
rankingtype: number,
|
|
expiredate = '',
|
|
): void {
|
|
ZYSDK.ZYSDK.setRankingData(score, category, avatar, nick, region, expiredate, rankingtype);
|
|
}
|
|
|
|
/** 授权或资料变更后清除上传缓存,强制重传省榜/总榜/每日胜场榜 */
|
|
private static _invalidateRankUploadCache(): void {
|
|
const doc = gg.data?.doc;
|
|
if (!doc) return;
|
|
doc.rankLastSyncStars = -1;
|
|
doc.rankLastSyncDailyWins = -1;
|
|
doc.rankLastSyncDailyWinsDate = '';
|
|
doc.rankLastSyncProvince = '';
|
|
doc.rankLastSyncNick = '';
|
|
doc.rankLastSyncAvatar = '';
|
|
doc.rankLastSyncRegion = '';
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/** 授权成功后强制同步三榜资料 */
|
|
private static async _syncAllBoardsAfterProfileUpdate(): Promise<void> {
|
|
this._invalidateRankUploadCache();
|
|
await this.syncSelfScore(true);
|
|
await this.syncSelfDailyWins(true);
|
|
}
|
|
|
|
/** 昵称 / 头像 / 地区变化时需重传 */
|
|
private static _needUploadProfile(
|
|
profile: { nickname: string; avatar: string; region: string },
|
|
region: string,
|
|
): boolean {
|
|
const doc = gg.data?.doc;
|
|
if (!doc) return false;
|
|
const syncRegion = region || profile.region;
|
|
if (String(doc.rankLastSyncNick ?? '') !== profile.nickname) return true;
|
|
if (String(doc.rankLastSyncAvatar ?? '') !== profile.avatar) return true;
|
|
return String(doc.rankLastSyncRegion ?? '') !== syncRegion;
|
|
}
|
|
|
|
/** 首次进榜必传;已传过后星数 / 省份 / 资料变化再传 */
|
|
private static _needUploadRank(
|
|
score: number,
|
|
province: string,
|
|
profile: { nickname: string; avatar: string; region: string },
|
|
region: string,
|
|
): boolean {
|
|
const doc = gg.data?.doc;
|
|
if (!doc?.rankSyncedOnce) return true;
|
|
const last = Number(doc.rankLastSyncStars);
|
|
if (last !== score) return true;
|
|
const lastProv = String(doc.rankLastSyncProvince ?? '');
|
|
if (!!province && province !== lastProv) return true;
|
|
return this._needUploadProfile(profile, region);
|
|
}
|
|
|
|
private static _markRankUploaded(
|
|
score: number,
|
|
province: string,
|
|
nick: string,
|
|
avatar: string,
|
|
region: string,
|
|
): void {
|
|
const doc = gg.data?.doc;
|
|
if (!doc) return;
|
|
doc.rankSyncedOnce = 1;
|
|
doc.rankLastSyncStars = score;
|
|
if (province) doc.rankLastSyncProvince = province;
|
|
doc.rankLastSyncNick = nick;
|
|
doc.rankLastSyncAvatar = avatar;
|
|
doc.rankLastSyncRegion = region;
|
|
gg.data.saveLocal();
|
|
}
|
|
|
|
/**
|
|
* 进入首页/大厅静默预拉取不依赖定位的榜(总榜/日榜)并缓存。
|
|
* 不申请定位权限——定位统一放到打开排行榜之后。
|
|
*/
|
|
static warmupOnHallEnter(): void {
|
|
if (this._warmupInflight) return;
|
|
this._warmupInflight = this._doWarmup().finally(() => {
|
|
this._warmupInflight = null;
|
|
});
|
|
}
|
|
|
|
private static async _doWarmup(): Promise<void> {
|
|
try {
|
|
this._boardCache.clear();
|
|
await Promise.all([
|
|
this.getBoard(RankBoardScope.Total),
|
|
this.getBoard(RankBoardScope.DailyWins),
|
|
]);
|
|
} catch (e) {
|
|
console.log('[RankBoardManager] warmupOnHallEnter', String(e));
|
|
}
|
|
}
|
|
|
|
static getBoardCache(scope: RankBoardScope): RankBoardResult | null {
|
|
return this._boardCache.get(scope)?.data ?? null;
|
|
}
|
|
|
|
static invalidateBoardCache(scope?: RankBoardScope): void {
|
|
if (scope == null) {
|
|
this._boardCache.clear();
|
|
return;
|
|
}
|
|
this._boardCache.delete(scope);
|
|
}
|
|
|
|
/**
|
|
* 取榜单:优先预拉取缓存;无缓存则复用进行中的请求。
|
|
* @param opts.force 忽略缓存重新拉取(授权成功后刷新等场景)
|
|
*/
|
|
static getBoard(scope: RankBoardScope, opts?: { force?: boolean }): Promise<RankBoardResult> {
|
|
if (!opts?.force) {
|
|
const inflight = this._fetchInflight.get(scope);
|
|
if (inflight) return inflight;
|
|
const cached = this._boardCache.get(scope);
|
|
if (cached) return Promise.resolve(cached.data);
|
|
}
|
|
const p = this.fetchBoard(scope)
|
|
.then((data) => {
|
|
if (this._fetchInflight.get(scope) === p) {
|
|
this._boardCache.set(scope, { data, at: Date.now() });
|
|
}
|
|
return data;
|
|
})
|
|
.finally(() => {
|
|
if (this._fetchInflight.get(scope) === p) {
|
|
this._fetchInflight.delete(scope);
|
|
}
|
|
});
|
|
this._fetchInflight.set(scope, p);
|
|
return p;
|
|
}
|
|
|
|
static async fetchBoard(scope: RankBoardScope): Promise<RankBoardResult> {
|
|
if (this._authPromise) {
|
|
await this._authPromise;
|
|
}
|
|
await RankDataManager.ensureTable();
|
|
await gg.data.table.ensureTableLoaded(TableNames.Headicon);
|
|
if (scope === RankBoardScope.DailyWins) {
|
|
await this.syncSelfDailyWins();
|
|
} else {
|
|
await this.syncSelfScore();
|
|
}
|
|
|
|
const user = await this._resolveUserInfo();
|
|
const selfUserId = await this._resolveSelfUserId();
|
|
// 只读存档省份;拉榜副作用不得申请定位
|
|
const province = this.getSavedProvince();
|
|
|
|
let parsed: RankBoardResult;
|
|
if (this._shouldUseMockBoard()) {
|
|
parsed = scope === RankBoardScope.DailyWins
|
|
? this._buildMockDailyWinsBoard(user, selfUserId)
|
|
: this._buildMockBoard(user, selfUserId);
|
|
parsed = this._applyScope(parsed, scope, province, selfUserId, user);
|
|
return parsed;
|
|
}
|
|
|
|
const category = this._categoryOfScope(scope, province);
|
|
if (scope === RankBoardScope.Province && !category) {
|
|
return {
|
|
list: [],
|
|
self: this._buildSelfFallback(scope, user, selfUserId),
|
|
};
|
|
}
|
|
|
|
const listType = scope === RankBoardScope.DailyWins
|
|
? RANK_LIST_TYPE_DAILY
|
|
: RANK_LIST_TYPE_PERMANENT;
|
|
|
|
let raw: unknown = null;
|
|
try {
|
|
raw = await this._requestRankingList(category, listType);
|
|
} catch (e) {
|
|
console.log('[RankBoardManager] fetchBoard failed', String(e));
|
|
}
|
|
parsed = this._parseResponse(raw, selfUserId);
|
|
if (scope !== RankBoardScope.DailyWins) {
|
|
parsed = this._clampStarBoardScores(parsed);
|
|
}
|
|
if (!parsed.list.length && this._shouldUseMockBoard()) {
|
|
parsed = scope === RankBoardScope.DailyWins
|
|
? this._buildMockDailyWinsBoard(user, selfUserId)
|
|
: this._buildMockBoard(user, selfUserId);
|
|
}
|
|
if (!parsed.self) {
|
|
parsed.self = this._buildSelfFallback(scope, user, selfUserId);
|
|
}
|
|
parsed = this._applyScope(parsed, scope, province, selfUserId, user);
|
|
return parsed;
|
|
}
|
|
|
|
private static _categoryOfScope(scope: RankBoardScope, province: string): string {
|
|
if (scope === RankBoardScope.Province) return province;
|
|
if (scope === RankBoardScope.DailyWins) return RANK_DAILY_WINS_CATEGORY;
|
|
return RANK_TOTAL_CATEGORY;
|
|
}
|
|
|
|
private static _needPlatformAuth(): boolean {
|
|
if (EDITOR) return false;
|
|
if (MiniGamePf.id()) return true;
|
|
return sys.platform === sys.Platform.WECHAT_GAME
|
|
|| sys.platform === sys.Platform.BYTEDANCE_MINI_GAME;
|
|
}
|
|
|
|
/**
|
|
* 定位授权拿到省份后写入存档,已有省份则不再变更。
|
|
* 失败会打开系统设置页,返回后以 authSetting 为准。
|
|
* @returns 短省名;拒绝授权或定位失败返回空串
|
|
*/
|
|
static async ensureProvinceWithAuth(): Promise<string> {
|
|
const inflight = this._locateInflight;
|
|
if (inflight) {
|
|
const got = await inflight;
|
|
if (got) return got;
|
|
// 打开排行榜时的低权限请求可能仍在收尾;失败后再允许打开设置
|
|
if (this._locateInflight) return this._locateInflight;
|
|
}
|
|
return this._ensureProvinceSaved({ allowOpenSetting: true });
|
|
}
|
|
|
|
/**
|
|
* 打开排行榜后申请定位省份(进首页不申请)。
|
|
* 只弹授权框,不打开设置页;设置页留给点省榜时的 ensureProvinceWithAuth。
|
|
* @returns 短省名;拒绝授权或定位失败返回空串
|
|
*/
|
|
static ensureProvinceOnRankOpen(): Promise<string> {
|
|
return this._ensureProvinceSaved({ allowOpenSetting: false });
|
|
}
|
|
|
|
/**
|
|
* 定位授权拿到省份后写入存档,已有省份则不再变更。
|
|
*/
|
|
private static _ensureProvinceSaved(opts?: { allowOpenSetting?: boolean }): Promise<string> {
|
|
if (this._locateInflight) return this._locateInflight;
|
|
this._locateInflight = this._doEnsureProvinceSaved(opts).finally(() => {
|
|
this._locateInflight = null;
|
|
});
|
|
return this._locateInflight;
|
|
}
|
|
|
|
private static async _doEnsureProvinceSaved(opts?: { allowOpenSetting?: boolean }): Promise<string> {
|
|
const saved = this.getSavedProvince();
|
|
if (saved) {
|
|
console.warn(`[RankDebug] 定位跳过:已有存档省份="${saved}"`);
|
|
return saved;
|
|
}
|
|
|
|
const located = await this._requestProvince(opts);
|
|
console.warn(`[RankDebug] 拉取定位省份结果="${located || '(空)'}"`);
|
|
if (located) {
|
|
gg.data.doc.userProvince = located;
|
|
gg.data.saveLocal();
|
|
// 省份就绪后补传省榜,并作废省榜缓存
|
|
this.invalidateBoardCache(RankBoardScope.Province);
|
|
void this.syncSelfScore();
|
|
void this.syncSelfDailyWins();
|
|
}
|
|
return located;
|
|
}
|
|
|
|
private static async _requestProvince(opts?: { allowOpenSetting?: boolean }): Promise<string> {
|
|
if (!this._needPlatformAuth()) {
|
|
return this._shortProvince(await ProvinceLocator.getProvince({
|
|
allowEmptyCacheRetry: true,
|
|
force: true,
|
|
}));
|
|
}
|
|
const granted = await this._authorizeLocation(!!opts?.allowOpenSetting);
|
|
if (!granted) {
|
|
// 拒绝定位则不进省榜,勿再调 getLocation(会直接 fail)
|
|
return '';
|
|
}
|
|
// 授权成功后必须 force,否则历史失败空缓存会挡住 getLocation
|
|
const located = await ProvinceLocator.getProvince({
|
|
allowEmptyCacheRetry: true,
|
|
force: true,
|
|
});
|
|
return this._shortProvince(located);
|
|
}
|
|
|
|
private static _shortProvince(name: string): string {
|
|
const n = String(name ?? '').trim();
|
|
if (!n) return '';
|
|
// 长后缀优先,避免「广西壮族自治区」被「自治区」截成「广西壮族」
|
|
return n.replace(
|
|
/(省|市|特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区)$/,
|
|
'',
|
|
) || n;
|
|
}
|
|
|
|
private static _getSetting(): Promise<{ authSetting?: Record<string, unknown> } | null> {
|
|
return new Promise((resolve) => {
|
|
gg.sdk.getSetting((res) => resolve(res ?? null));
|
|
});
|
|
}
|
|
|
|
private static _openSetting(): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
gg.sdk.openSetting((ok) => resolve(!!ok));
|
|
});
|
|
}
|
|
|
|
/** 设置页中该 scope 是否已授权 */
|
|
private static async _isScopeGranted(scope: string): Promise<boolean> {
|
|
const setting = await this._getSetting();
|
|
return !!setting?.authSetting?.[scope];
|
|
}
|
|
|
|
/**
|
|
* 申请定位授权;可选失败后打开设置,返回后以 authSetting 为准。
|
|
* @param allowOpenSetting 授权失败是否打开系统设置页
|
|
*/
|
|
private static async _authorizeLocation(allowOpenSetting: boolean): Promise<boolean> {
|
|
if (await this._isScopeGranted('scope.userLocation')) return true;
|
|
const granted = await new Promise<boolean>((resolve) => {
|
|
gg.sdk.authorizeUserLocation((ok) => resolve(!!ok));
|
|
});
|
|
if (granted) return true;
|
|
if (!allowOpenSetting) return false;
|
|
await this._openSetting();
|
|
return this._isScopeGranted('scope.userLocation');
|
|
}
|
|
|
|
private static _applyScope(
|
|
parsed: RankBoardResult,
|
|
scope: RankBoardScope,
|
|
province: string,
|
|
selfUserId: string,
|
|
user: IUserInfo | null,
|
|
): RankBoardResult {
|
|
let result: RankBoardResult;
|
|
if (scope === RankBoardScope.Province && this._shouldUseMockBoard() && province) {
|
|
const filtered = this._filterProvince(parsed, province, selfUserId);
|
|
if (filtered.list.length > 0) {
|
|
result = filtered;
|
|
} else {
|
|
const mock = this._buildMockBoard(user, selfUserId);
|
|
const filteredMock = this._filterProvince(mock, province, selfUserId);
|
|
result = filteredMock.list.length > 0 ? filteredMock : mock;
|
|
}
|
|
} else {
|
|
const list = parsed.list.map((row, i) => ({ ...row, rank: i + 1 }));
|
|
let self = parsed.self;
|
|
if (self?.userId) {
|
|
const idx = list.findIndex((r) => r.userId === self!.userId);
|
|
self = idx >= 0
|
|
? { ...list[idx], isSelf: true }
|
|
: { ...self, rank: 0, isSelf: true };
|
|
}
|
|
result = { list, self };
|
|
}
|
|
if (scope !== RankBoardScope.DailyWins) {
|
|
result = this._clampStarBoardScores(result);
|
|
}
|
|
return this._patchSelfProfileOnBoard(result, province, user, selfUserId);
|
|
}
|
|
|
|
/** 省榜/总榜条目星数不低于 1 */
|
|
private static _clampStarBoardScores(result: RankBoardResult): RankBoardResult {
|
|
const clamp = (entry: RankBoardEntry): RankBoardEntry => ({
|
|
...entry,
|
|
score: RankDataManager.clampRankStars(entry.score),
|
|
});
|
|
return {
|
|
list: result.list.map(clamp),
|
|
self: result.self ? clamp(result.self) : null,
|
|
};
|
|
}
|
|
|
|
/** 自己排名(含列表内「我」)用本地存档资料覆盖昵称 / 头像 / 地区 */
|
|
private static _patchSelfProfileOnBoard(
|
|
result: RankBoardResult,
|
|
province: string,
|
|
user: IUserInfo | null,
|
|
selfUserId: string,
|
|
): RankBoardResult {
|
|
const patch = (entry: RankBoardEntry): RankBoardEntry => {
|
|
const isSelfRow = entry.isSelf || (!!selfUserId && entry.userId === selfUserId);
|
|
if (!isSelfRow) return entry;
|
|
const display = this._getSelfDisplayProfile(user);
|
|
return {
|
|
...entry,
|
|
nickname: display.nickname,
|
|
avatarUrl: display.avatarUrl,
|
|
region: display.region || province || entry.region,
|
|
};
|
|
};
|
|
return {
|
|
list: result.list.map(patch),
|
|
self: result.self ? patch(result.self) : null,
|
|
};
|
|
}
|
|
|
|
/** 上传榜单用的昵称 / 头像 / 地区 */
|
|
private static _getUploadProfile(): { nickname: string; avatar: string; region: string } {
|
|
if (this.hasRankProfile()) {
|
|
return {
|
|
nickname: this.getSavedNickname(),
|
|
avatar: this.getSavedAvatar(),
|
|
region: this.getSavedProvince(),
|
|
};
|
|
}
|
|
const headId = HeadIconManager.getPlayerHeadIconId();
|
|
if (headId <= 0) {
|
|
console.log('[RankBoardManager] 未授权且无有效 headIconId');
|
|
}
|
|
return {
|
|
nickname: RANK_NICK_ANONYMOUS,
|
|
avatar: headId > 0 ? String(headId) : '',
|
|
region: RANK_REGION_UNKNOWN,
|
|
};
|
|
}
|
|
|
|
/** 自己条目展示用的昵称 / 头像 / 地区 */
|
|
private static _getSelfDisplayProfile(user: IUserInfo | null): {
|
|
nickname: string;
|
|
avatarUrl: string;
|
|
region: string;
|
|
} {
|
|
if (this.hasRankProfile()) {
|
|
return {
|
|
nickname: this.getSavedNickname() || user?.nickName || '我',
|
|
avatarUrl: this.getSavedAvatar() || user?.avatarUrl || '',
|
|
region: this.getSavedProvince() || this._regionOf(user),
|
|
};
|
|
}
|
|
const headId = HeadIconManager.getPlayerHeadIconId();
|
|
return {
|
|
nickname: RANK_NICK_ANONYMOUS,
|
|
avatarUrl: headId > 0 ? String(headId) : '',
|
|
region: RANK_REGION_UNKNOWN,
|
|
};
|
|
}
|
|
|
|
/** 浏览器 / 编辑器 / 非小游戏环境:直接用本地 mock,避免 SDK 登录等待 */
|
|
private static _shouldUseMockBoard(): boolean {
|
|
if (EDITOR || sys.isBrowser) return true;
|
|
if (MiniGamePf.id()) return false;
|
|
return !(sys.platform === sys.Platform.WECHAT_GAME || sys.platform === sys.Platform.BYTEDANCE_MINI_GAME);
|
|
}
|
|
|
|
private static _requestRankingList(category: string, rankingtype: number): Promise<unknown> {
|
|
return new Promise((resolve, reject) => {
|
|
ZYSDK.ZYSDK.getRankingList(
|
|
category,
|
|
RANK_TOP_COUNT,
|
|
RANK_PRESET,
|
|
rankingtype,
|
|
(rep) => {
|
|
console.log('[RankBoardManager] getRankingList ok', category);
|
|
resolve(rep);
|
|
},
|
|
(err) => {
|
|
console.log('[RankBoardManager] getRankingList fail', category, String(err));
|
|
reject(err);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
private static _parseResponse(raw: unknown, selfUserId: string): RankBoardResult {
|
|
const root = this._unwrap(raw);
|
|
const listRaw = this._pickArray(root, ['toplist', 'list', 'ranklist', 'rankinglist', 'ranking_list', 'data_list', 'users']);
|
|
const selfRaw = this._pickObject(root, ['self', 'my', 'myinfo', 'myrank', 'my_rank', 'userdata']);
|
|
|
|
const list: RankBoardEntry[] = [];
|
|
for (let i = 0; i < listRaw.length; i++) {
|
|
const row = this._normalizeEntry(listRaw[i], i + 1, selfUserId);
|
|
if (row) list.push(row);
|
|
}
|
|
|
|
let self = selfRaw ? this._normalizeEntry(selfRaw, 0, selfUserId) : null;
|
|
if (!self && selfUserId) {
|
|
const hit = list.find((r) => r.userId === selfUserId);
|
|
if (hit) self = { ...hit, isSelf: true };
|
|
}
|
|
if (self) {
|
|
self.isSelf = true;
|
|
if (!self.rank && selfUserId) {
|
|
const hit = list.find((r) => r.userId === selfUserId);
|
|
if (hit) self.rank = hit.rank;
|
|
}
|
|
}
|
|
|
|
return { list, self };
|
|
}
|
|
|
|
private static _filterProvince(result: RankBoardResult, province: string, selfUserId: string): RankBoardResult {
|
|
const key = province.trim().toLowerCase();
|
|
const filtered = result.list.filter((row) => {
|
|
const region = (row.region ?? '').trim().toLowerCase();
|
|
return region && (region === key || region.includes(key) || key.includes(region));
|
|
});
|
|
const list = filtered.map((row, i) => ({ ...row, rank: i + 1 }));
|
|
|
|
let self: RankBoardEntry | null = null;
|
|
if (selfUserId) {
|
|
const hit = list.find((r) => r.userId === selfUserId);
|
|
if (hit) {
|
|
self = { ...hit, isSelf: true };
|
|
} else if (result.self) {
|
|
self = { ...result.self, rank: 0, isSelf: true };
|
|
}
|
|
}
|
|
return { list, self };
|
|
}
|
|
|
|
private static _normalizeEntry(raw: unknown, fallbackRank: number, selfUserId: string): RankBoardEntry | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const o = raw as Record<string, unknown>;
|
|
const userId = String(o.userid ?? o.userId ?? o.uid ?? o.openid ?? o.openId ?? '');
|
|
const score = Math.max(0, Math.floor(Number(o.score ?? o.value ?? o.soul ?? o.point ?? 0) || 0));
|
|
const rankNum = Math.floor(Number(o.rank ?? o.ranking ?? o.idx ?? o.index ?? fallbackRank) || 0);
|
|
return {
|
|
rank: rankNum > 0 ? rankNum : fallbackRank,
|
|
score,
|
|
nickname: this._decodeNick(String(o.nickname ?? o.nickName ?? o.name ?? '玩家').trim()) || '玩家',
|
|
avatarUrl: String(o.imgurl ?? o.avatarUrl ?? o.avatar ?? o.head ?? o.icon ?? ''),
|
|
region: String(o.region ?? o.province ?? o.area ?? ''),
|
|
userId,
|
|
isSelf: !!(selfUserId && userId && userId === selfUserId),
|
|
};
|
|
}
|
|
|
|
private static _unwrap(raw: unknown): Record<string, unknown> {
|
|
if (!raw) return {};
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
if (typeof raw !== 'object') return {};
|
|
const o = raw as Record<string, unknown>;
|
|
if (o.data && typeof o.data === 'object') {
|
|
return o.data as Record<string, unknown>;
|
|
}
|
|
return o;
|
|
}
|
|
|
|
private static _pickArray(root: Record<string, unknown>, keys: string[]): unknown[] {
|
|
for (const k of keys) {
|
|
const v = root[k];
|
|
if (Array.isArray(v)) return v;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private static _pickObject(root: Record<string, unknown>, keys: string[]): Record<string, unknown> | null {
|
|
for (const k of keys) {
|
|
const v = root[k];
|
|
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
return v as Record<string, unknown>;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static async _resolveSelfUserId(): Promise<string> {
|
|
if (this._selfUserId) return this._selfUserId;
|
|
try {
|
|
const uid = await ZYSDK.ZYSDK.getUserId();
|
|
this._selfUserId = uid != null ? String(uid) : '';
|
|
} catch {
|
|
this._selfUserId = '';
|
|
}
|
|
return this._selfUserId ?? '';
|
|
}
|
|
|
|
private static async _resolveUserInfo(): Promise<IUserInfo | null> {
|
|
const nick = this.getSavedNickname();
|
|
const avatar = this.getSavedAvatar();
|
|
const province = this.getSavedProvince();
|
|
if (nick || avatar) {
|
|
return {
|
|
nickName: nick || '我',
|
|
avatarUrl: avatar,
|
|
gender: '',
|
|
country: '',
|
|
province,
|
|
city: '',
|
|
language: '',
|
|
};
|
|
}
|
|
if (gg.game?.UserInfo) return gg.game.UserInfo;
|
|
return null;
|
|
}
|
|
|
|
private static _regionOf(user: IUserInfo | null): string {
|
|
return this.getSavedProvince() || String(user?.province ?? user?.city ?? '').trim();
|
|
}
|
|
|
|
/** 编辑器 / 浏览器 / 接口无数据时的本地占位 */
|
|
private static _buildMockBoard(user: IUserInfo | null, selfUserId: string): RankBoardResult {
|
|
const selfScore = RankDataManager.getRankStars();
|
|
const display = this._getSelfDisplayProfile(user);
|
|
const self: RankBoardEntry = {
|
|
rank: 8,
|
|
score: selfScore,
|
|
nickname: display.nickname,
|
|
avatarUrl: display.avatarUrl,
|
|
region: display.region,
|
|
userId: selfUserId || 'self',
|
|
isSelf: true,
|
|
};
|
|
const list: RankBoardEntry[] = [
|
|
{ rank: 1, score: selfScore + 120, nickname: '棋圣张三', avatarUrl: '', region: '广东', userId: 'm1' },
|
|
{ rank: 2, score: selfScore + 90, nickname: '百战李四', avatarUrl: '', region: '北京', userId: 'm2' },
|
|
{ rank: 3, score: selfScore + 60, nickname: '落子王五', avatarUrl: '', region: '上海', userId: 'm3' },
|
|
{ rank: 4, score: selfScore + 40, nickname: '快棋手赵六', avatarUrl: '', region: '浙江', userId: 'm4' },
|
|
{ rank: 5, score: selfScore + 25, nickname: '守擂者钱七', avatarUrl: '', region: '江苏', userId: 'm5' },
|
|
{ rank: 6, score: selfScore + 15, nickname: '挑战者孙八', avatarUrl: '', region: '四川', userId: 'm6' },
|
|
{ rank: 7, score: selfScore + 5, nickname: '修行者周九', avatarUrl: '', region: '湖北', userId: 'm7' },
|
|
{ ...self },
|
|
{ rank: 9, score: RankDataManager.clampRankStars(selfScore - 5), nickname: '新手吴十', avatarUrl: '', region: '广东', userId: 'm9' },
|
|
{ rank: 10, score: RankDataManager.clampRankStars(selfScore - 12), nickname: '学徒郑一', avatarUrl: '', region: '广东', userId: 'm10' },
|
|
];
|
|
return { list, self: { ...self } };
|
|
}
|
|
|
|
/** 每日胜场榜 mock */
|
|
private static _buildMockDailyWinsBoard(user: IUserInfo | null, selfUserId: string): RankBoardResult {
|
|
const selfWins = DailyWinStats.getTodayWins();
|
|
const display = this._getSelfDisplayProfile(user);
|
|
const self: RankBoardEntry = {
|
|
rank: 6,
|
|
score: selfWins,
|
|
nickname: display.nickname,
|
|
avatarUrl: display.avatarUrl,
|
|
region: display.region,
|
|
userId: selfUserId || 'self',
|
|
isSelf: true,
|
|
};
|
|
const list: RankBoardEntry[] = [
|
|
{ rank: 1, score: selfWins + 8, nickname: '常胜张三', avatarUrl: '', region: '广东', userId: 'd1' },
|
|
{ rank: 2, score: selfWins + 6, nickname: '连战李四', avatarUrl: '', region: '北京', userId: 'd2' },
|
|
{ rank: 3, score: selfWins + 4, nickname: '捷报王五', avatarUrl: '', region: '上海', userId: 'd3' },
|
|
{ rank: 4, score: selfWins + 3, nickname: '快赢赵六', avatarUrl: '', region: '浙江', userId: 'd4' },
|
|
{ rank: 5, score: selfWins + 1, nickname: '守擂钱七', avatarUrl: '', region: '江苏', userId: 'd5' },
|
|
{ ...self },
|
|
{ rank: 7, score: Math.max(0, selfWins - 1), nickname: '新手吴十', avatarUrl: '', region: '四川', userId: 'd7' },
|
|
{ rank: 8, score: Math.max(0, selfWins - 2), nickname: '学徒郑一', avatarUrl: '', region: '湖北', userId: 'd8' },
|
|
];
|
|
return { list, self: { ...self } };
|
|
}
|
|
|
|
private static _buildSelfFallback(scope: RankBoardScope, user: IUserInfo | null, selfUserId: string): RankBoardEntry {
|
|
const score = scope === RankBoardScope.DailyWins
|
|
? DailyWinStats.getTodayWins()
|
|
: RankDataManager.getRankStars();
|
|
const display = this._getSelfDisplayProfile(user);
|
|
return {
|
|
rank: 0,
|
|
score,
|
|
nickname: display.nickname,
|
|
avatarUrl: display.avatarUrl,
|
|
region: display.region,
|
|
userId: selfUserId || 'self',
|
|
isSelf: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 授权昵称转 UTF-8 Base64,避免特殊字符破坏存档。
|
|
* 抖音等小游戏环境无 btoa,用自实现兜底。
|
|
* @returns 编码结果;失败返回空串
|
|
*/
|
|
private static _encodeNick(nick: string): string {
|
|
if (!nick) return '';
|
|
try {
|
|
const binary = this._utf8ToBinary(nick);
|
|
if (typeof btoa === 'function') return btoa(binary);
|
|
return this._base64Encode(binary);
|
|
} catch (e) {
|
|
console.warn('[RankBoardManager] 昵称 Base64 编码异常', e);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 存档/榜单昵称解码。旧档明文(非 Base64)原样返回。
|
|
* 纯英文词(如 liweimin)恰好符合 Base64 形态会被误判,解码失败/乱码时按明文原样返回。
|
|
*/
|
|
private static _decodeNick(raw: string): string {
|
|
if (!raw) return '';
|
|
if (!this._isBase64Text(raw)) return raw;
|
|
try {
|
|
const binary = typeof atob === 'function' ? atob(raw) : this._base64Decode(raw);
|
|
if (!binary) return raw;
|
|
const text = this._binaryToUtf8(binary);
|
|
// 解码出控制字符/替换符说明不是有效编码昵称(误判的明文),按明文返回
|
|
if (/[\u0000-\u001F\u007F\uFFFD]/.test(text)) return raw;
|
|
return text;
|
|
} catch {
|
|
// 误判明文的解码失败属预期,静默按明文返回
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
/** latin1 字符串(每字符码 0-255)转 Base64;自实现兜底,不依赖 btoa */
|
|
private static _base64Encode(binary: string): string {
|
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
let out = '';
|
|
let i = 0;
|
|
const len = binary.length;
|
|
for (; i + 2 < len; i += 3) {
|
|
const n = ((binary.charCodeAt(i) & 255) << 16)
|
|
| ((binary.charCodeAt(i + 1) & 255) << 8)
|
|
| (binary.charCodeAt(i + 2) & 255);
|
|
out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63)
|
|
+ CHARS.charAt((n >> 6) & 63) + CHARS.charAt(n & 63);
|
|
}
|
|
const remain = len - i;
|
|
if (remain === 1) {
|
|
const n = (binary.charCodeAt(i) & 255) << 16;
|
|
out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63) + '==';
|
|
} else if (remain === 2) {
|
|
const n = ((binary.charCodeAt(i) & 255) << 16) | ((binary.charCodeAt(i + 1) & 255) << 8);
|
|
out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63) + CHARS.charAt((n >> 6) & 63) + '=';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Base64 转 latin1 字符串;自实现兜底,不依赖 atob。非法输入返回空串 */
|
|
private static _base64Decode(b64: string): string {
|
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
const lookup: Record<string, number> = {};
|
|
for (let i = 0; i < CHARS.length; i++) lookup[CHARS.charAt(i)] = i;
|
|
const clean = b64.replace(/=+$/, '');
|
|
let out = '';
|
|
let buffer = 0;
|
|
let bits = 0;
|
|
for (let i = 0; i < clean.length; i++) {
|
|
const v = lookup[clean.charAt(i)];
|
|
if (v === undefined) return '';
|
|
buffer = (buffer << 6) | v;
|
|
bits += 6;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out += String.fromCharCode((buffer >> bits) & 255);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 是否为标准 Base64 文本(含 padding) */
|
|
private static _isBase64Text(raw: string): boolean {
|
|
if (raw.length < 4 || raw.length % 4 !== 0) return false;
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const c = raw.charCodeAt(i);
|
|
const isAZ = c >= 65 && c <= 90;
|
|
const isaz = c >= 97 && c <= 122;
|
|
const is09 = c >= 48 && c <= 57;
|
|
const isPlus = c === 43;
|
|
const isSlash = c === 47;
|
|
const isPad = c === 61;
|
|
if (isPad) {
|
|
if (i < raw.length - 2) return false;
|
|
continue;
|
|
}
|
|
if (!(isAZ || isaz || is09 || isPlus || isSlash)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** UTF-8 字符串转 latin1 二进制(供 btoa) */
|
|
private static _utf8ToBinary(text: string): string {
|
|
const encoded = encodeURIComponent(text);
|
|
let binary = '';
|
|
for (let i = 0; i < encoded.length; i++) {
|
|
const ch = encoded.charAt(i);
|
|
if (ch === '%' && i + 2 < encoded.length) {
|
|
binary += String.fromCharCode(parseInt(encoded.slice(i + 1, i + 3), 16));
|
|
i += 2;
|
|
} else {
|
|
binary += ch;
|
|
}
|
|
}
|
|
return binary;
|
|
}
|
|
|
|
/** latin1 二进制转 UTF-8 字符串(供 atob 结果) */
|
|
private static _binaryToUtf8(binary: string): string {
|
|
let pct = '';
|
|
for (let i = 0; i < binary.length; i++) {
|
|
const hex = binary.charCodeAt(i).toString(16);
|
|
pct += '%' + (hex.length < 2 ? '0' + hex : hex);
|
|
}
|
|
return decodeURIComponent(pct);
|
|
}
|
|
}
|
|
|