This commit is contained in:
Mauve
2026-09-16 10:43:59 +08:00
parent a7f2e0ca01
commit 9f2becfa67
36 changed files with 3997 additions and 264 deletions
+31
View File
@@ -0,0 +1,31 @@
/**
* 将 API 返回的 PascalCase 字段名转为前端使用的 camelCase(递归处理嵌套对象/数组)
*/
function toCamelCaseKey(key) {
if (!key || typeof key !== 'string') return key;
if (key[0] === key[0].toLowerCase()) return key;
return key.charAt(0).toLowerCase() + key.slice(1);
}
export function normalizeApiKeys(value) {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map(item => normalizeApiKeys(item));
}
if (typeof value !== 'object') return value;
const result = {};
for (const [key, val] of Object.entries(value)) {
result[toCamelCaseKey(key)] = normalizeApiKeys(val);
}
return result;
}
/** 兼容 API 返回 records / Records */
export function getApiRecords(data) {
if (!data) return [];
const records = data.records ?? data.Records;
return Array.isArray(records) ? records : [];
}
export default normalizeApiKeys;
+83
View File
@@ -0,0 +1,83 @@
/**
* iOS APNs DeviceToken 封装(原生插件:NetworkUtils-NetworkUtilsModule)
* 对应原生方法:getDeviceTokenAsync、refreshDeviceTokenAsync
*/
// #ifdef APP-PLUS
const pushNetworkModule = uni.requireNativePlugin('NetworkUtils-NetworkUtilsModule')
// #endif
/**
* 读取当前缓存的 DeviceToken(最近一次 didRegisterForRemoteNotifications 写入本地)
* @param {Record<string, unknown>} options 透传给原生,可为 {}
*/
export function getDeviceToken(options = {}) {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
if (!pushNetworkModule || typeof pushNetworkModule.getDeviceTokenAsync !== 'function') {
reject(new Error('NetworkUtils 原生插件不可用或未注册 getDeviceTokenAsync'))
return
}
pushNetworkModule.getDeviceTokenAsync(Object.assign({ registerIfEmpty: true }, options || {}), (ret) => {
if (ret && (ret.status === 'success' || ret.status === 'empty')) {
resolve(ret)
} else {
reject(ret || new Error('getDeviceTokenAsync 返回异常'))
}
})
// #endif
// #ifndef APP-PLUS
resolve({
status: 'empty',
deviceToken: '',
hasToken: false,
updatedAt: 0,
message: '非 App-PLUS 环境'
})
// #endif
})
}
/**
* 触发重新向 APNs 注册(token 更新后由原生写入本地,再调用 getDeviceToken 读取)
* @param {{ requestAuthorization?: boolean }} options requestAuthorization 默认 true;已授权后可传 false 避免重复弹权限逻辑
*/
export function refreshDeviceTokenAsync(options = {}) {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
if (!pushNetworkModule || typeof pushNetworkModule.refreshDeviceTokenAsync !== 'function') {
reject(new Error('NetworkUtils 原生插件不可用或未注册 refreshDeviceTokenAsync'))
return
}
const opts = {
requestAuthorization: options.requestAuthorization !== false
}
pushNetworkModule.refreshDeviceTokenAsync(opts, (ret) => {
if (ret && ret.status === 'requested') {
resolve(ret)
} else if (ret && (ret.status === 'error' || ret.status === 'denied')) {
reject(ret)
} else {
resolve(ret || {})
}
})
// #endif
// #ifndef APP-PLUS
resolve({ status: 'skipped', message: '非 App-PLUS 环境' })
// #endif
})
}
/**
* 先刷新注册再轮询读取(适合首次拿 token 或服务端判定失效后)
*/
export async function refreshDeviceTokenAndFetch(options = {}) {
await refreshDeviceTokenAsync(options)
// 授权弹窗关闭 + registerForRemoteNotifications 多为异步,适度拉长首次等待
await new Promise((r) => setTimeout(r, 900))
let last = await getDeviceToken()
for (let i = 0; i < 25 && last.status === 'empty'; i++) {
await new Promise((r) => setTimeout(r, 400))
last = await getDeviceToken()
}
return last
}
+151
View File
@@ -0,0 +1,151 @@
/**
* iOS 远程推送消息封装(原生插件:NetworkUtils-NetworkUtilsModule)
* 对应原生方法:onPushNotificationAsync、consumePendingPushNotificationsAsync
*/
// #ifdef APP-PLUS
const pushNetworkModule = uni.requireNativePlugin('NetworkUtils-NetworkUtilsModule')
// #endif
let lastPushNotification = null
const processedPushKeys = {}
function getPushDedupeKey(title, content) {
return `${title || ''}|${content || ''}`
}
function markPushProcessed(title, content) {
const key = getPushDedupeKey(title, content)
processedPushKeys[key] = true
return key
}
function isPushProcessed(title, content) {
return !!processedPushKeys[getPushDedupeKey(title, content)]
}
function dispatchPushMessage(onMessage, payload, meta = {}) {
if (!meta.userTapped && isPushProcessed(payload.title, payload.content)) {
console.log('[Push] skip duplicate push:', getPushDedupeKey(payload.title, payload.content))
return
}
markPushProcessed(payload.title, payload.content)
lastPushNotification = payload
if (typeof onMessage === 'function') {
onMessage(payload, meta)
}
}
function parseUserTappedFlag(value) {
return value === true || value === 1 || value === '1' || value === 'true'
}
function getPushConfirmNo(res = {}) {
const confirmNo = res.ConfirmNo || res.confirmNo || res.confNo || res.raw?.ConfirmNo || res.raw?.confirmNo || res.raw?.confNo
return confirmNo ? String(confirmNo) : ''
}
/**
* 将原生回调数据规范化为 { title, content, body, confirmNo, userTapped, raw }
* @param {Record<string, unknown>} res
*/
export function normalizePushPayload(res = {}) {
const title = typeof res.title === 'string' ? res.title : (res.title ? String(res.title) : '')
const body = typeof res.body === 'string' ? res.body : (res.body ? String(res.body) : '')
const confirmNo = getPushConfirmNo(res)
return {
title,
content: body,
body,
confirmNo,
userTapped: parseUserTappedFlag(res.userTapped),
raw: res
}
}
/**
* 获取最近一次收到的推送(内存缓存)
*/
export function getLastPushNotification() {
return lastPushNotification
}
/**
* 读取并清空 Native 侧缓存的待处理推送(后台/冷启动场景)
* @returns {Promise<Array<{ title: string, content: string, body: string, raw: object }>>}
*/
export function consumePendingPushNotifications() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
if (!pushNetworkModule) {
console.error('[Push] NetworkUtils 原生插件不可用')
resolve([])
return
}
if (typeof pushNetworkModule.consumePendingPushNotificationsAsync !== 'function') {
console.warn('[Push] 原生暂不支持 consumePendingPushNotificationsAsync,跳过 Native pending 同步')
resolve([])
return
}
console.log('[Push] 调用 consumePendingPushNotificationsAsync ...')
pushNetworkModule.consumePendingPushNotificationsAsync({}, (ret) => {
const items = Array.isArray(ret?.items) ? ret.items : []
console.log('[Push] consumePendingPushNotificationsAsync 返回 count=', ret?.count ?? items.length)
resolve(items.map((item) => normalizePushPayload(item)))
})
// #endif
// #ifndef APP-PLUS
resolve([])
// #endif
})
}
/**
* 同步处理 Native 缓存的推送
* @param {(payload: { title: string, content: string, body: string, raw: object }, meta?: object) => void} onMessage
* @param {{ showToast?: boolean }} [options]
*/
export async function syncPendingPushNotifications(onMessage, options = {}) {
console.log('[Push] syncPendingPushNotifications start')
const pending = await consumePendingPushNotifications()
if (!pending.length) {
console.log('[Push] syncPendingPushNotifications: 队列为空')
return pending
}
console.log('[Push] syncPendingPushNotifications count=', pending.length)
for (const payload of pending) {
dispatchPushMessage(onMessage, payload, {
fromPending: true,
userTapped: payload.userTapped === true,
showToast: options.showToast !== false
})
}
return pending
}
/**
* 注册推送消息监听;每次收到远程推送时 callback 会被调用
* @param {(payload: { title: string, content: string, body: string, raw: object }, meta?: object) => void} onMessage
* @returns {() => void}
*/
export function listenPushNotification(onMessage) {
// #ifdef APP-PLUS
if (!pushNetworkModule || typeof pushNetworkModule.onPushNotificationAsync !== 'function') {
console.warn('[Push] NetworkUtils 原生插件不可用或未注册 onPushNotificationAsync')
return () => {}
}
pushNetworkModule.onPushNotificationAsync({}, (res) => {
const payload = normalizePushPayload(res)
console.log('[Push] 收到推送通知:', JSON.stringify(payload))
dispatchPushMessage(onMessage, payload, {
fromPending: !!res?.fromPending,
userTapped: payload.userTapped === true,
showToast: true
})
})
return () => {}
// #endif
// #ifndef APP-PLUS
console.warn('[Push] 非 App-PLUS 环境,跳过推送监听')
return () => {}
// #endif
}
+9 -4
View File
@@ -121,8 +121,10 @@ module.exports = (vm) => {
break;
case 400:
kickout()
break;
case 401:
if(error.config.url.indexOf('refresh_token')==-1 && error.config.url.indexOf('logout')==-1){
if(error.config.url.indexOf('refresh_token')==-1 && error.config.url.indexOf('logout')==-1 && !error.config.custom?.tokenRetried){
error.config.custom = {...error.config.custom, tokenRetried: true};
return tokenRefresh().then(_ => {
error.config.header.Authorization = uni.getStorageSync("token");
error.config.header.RefreshToken = uni.getStorageSync("refreshToken")
@@ -141,19 +143,22 @@ module.exports = (vm) => {
}
function tokenRefresh(){
let oldToken = uni.getStorageSync("token");
if (oldToken !== null && oldToken !== '') {
const storedRefreshToken = uni.getStorageSync("refreshToken");
if (storedRefreshToken) {
return refreshToken({})
.then((data) => {
if (!data?.success || !data.accessToken) throw new Error('Token refresh failed');
uni.setStorageSync("token",data.accessToken);
uni.setStorageSync("tokenTime",dayjs().unix());
return Promise.resolve(true)
})
.catch(error => {
kickout()
return Promise.reject(error);
})
} else {
kickout()
return Promise.reject(new Error('Missing RefreshToken'));
}
}
function checkToken(){
@@ -198,4 +203,4 @@ function checkIP(header){
}
}*/
}
}
}
+244
View File
@@ -0,0 +1,244 @@
function parseDate(value) {
const parts = value.split('-').map(Number);
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function formatDate(date) {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(date, count) {
const next = new Date(date);
next.setDate(next.getDate() + count);
return next;
}
function addMonths(date, count) {
return new Date(date.getFullYear(), date.getMonth() + count, 1);
}
function startOfWeek(date) {
return addDays(date, -date.getDay());
}
function endOfWeek(date) {
return addDays(date, 6 - date.getDay());
}
function endOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
const monthLabels = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
export function buildCalendarMonths({ today, monthCount, weekdayLabels }) {
const todayDate = parseDate(today);
const months = [];
for (let monthIndex = 0; monthIndex < monthCount; monthIndex++) {
const monthDate = addMonths(todayDate, monthIndex);
const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const start = startOfWeek(monthStart);
const end = endOfWeek(endOfMonth(monthStart));
const weeks = [];
let cursor = start;
let weekIndex = 0;
while (cursor <= end) {
const days = weekdayLabels.map((item, dayIndex) => {
const date = addDays(cursor, dayIndex);
const dateText = formatDate(date);
const isLeadingPastDate = monthIndex === 0 && date < todayDate;
const isOutsideMonth = date.getMonth() !== monthStart.getMonth() && !isLeadingPastDate;
return {
date: dateText,
dayNo: isOutsideMonth ? '' : `${date.getDate()}`.padStart(2, '0'),
isToday: !isOutsideMonth && dateText === today,
isPast: date < todayDate,
isOutsideMonth,
};
});
weeks.push({
weekKey: formatDate(cursor),
days,
});
cursor = addDays(cursor, 7);
weekIndex++;
}
months.push({
monthKey: formatDate(monthStart).slice(0, 7),
monthLabel: monthLabels[monthStart.getMonth()],
weeks,
});
}
return months;
}
export function buildCalendarDateRange({ today, startDate, endDate, weekdayLabels }) {
const todayDate = parseDate(today);
const rangeStart = parseDate(startDate);
const rangeEnd = parseDate(endDate);
const months = [];
let monthDate = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1);
const lastMonthDate = new Date(rangeEnd.getFullYear(), rangeEnd.getMonth(), 1);
while (monthDate <= lastMonthDate) {
const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const visibleStart = rangeStart > monthStart ? rangeStart : monthStart;
const monthEnd = endOfMonth(monthStart);
const visibleEnd = rangeEnd < monthEnd ? rangeEnd : monthEnd;
const start = startOfWeek(visibleStart);
const end = endOfWeek(visibleEnd);
const weeks = [];
let cursor = start;
while (cursor <= end) {
const days = weekdayLabels.map((item, dayIndex) => {
const date = addDays(cursor, dayIndex);
const dateText = formatDate(date);
const isOutsideMonth = date < rangeStart || date > rangeEnd || date.getMonth() !== monthStart.getMonth();
return {
date: dateText,
dayNo: isOutsideMonth ? '' : `${date.getDate()}`.padStart(2, '0'),
isToday: !isOutsideMonth && dateText === today,
isPast: date < todayDate,
isOutsideMonth,
};
});
weeks.push({
weekKey: formatDate(cursor),
days,
});
cursor = addDays(cursor, 7);
}
months.push({
monthKey: formatDate(monthStart).slice(0, 7),
monthLabel: monthLabels[monthStart.getMonth()],
weeks,
});
monthDate = addMonths(monthDate, 1);
}
return months;
}
export function buildContinuousCalendarWeeks(months) {
const weeks = [];
months.forEach(month => {
month.weeks.forEach((week, weekIndex) => {
const days = week.days.map(day => ({ ...day }));
const startsMonth = days.some(day => day.dayNo === '01');
weeks.push({
...week,
weekId: `${month.monthKey}-${week.weekKey}`,
days,
monthLabel: weekIndex === 0 || startsMonth ? month.monthLabel : '',
});
});
});
return weeks;
}
export function getTodayWeekId(months) {
for (const month of months) {
for (const week of month.weeks) {
if (week.days.some(day => day.isToday && !day.isOutsideMonth)) {
return `week-${month.monthKey}-${week.weekKey}`;
}
}
}
return '';
}
export function getAvailabilityStatusClass(item) {
if (item.count > 0) return 'available';
return 'zero';
}
export function getCriteriaStatusClass(item) {
if (!item.statusText) return '';
return item.qualified ? 'qualified' : 'unqualified';
}
function getRecordValue(record, lowerKey, upperKey) {
return record[lowerKey] !== undefined ? record[lowerKey] : record[upperKey];
}
function normalizeRecordDate(value) {
if (!value) return '';
return String(value).slice(0, 10);
}
function formatCriteria(value) {
if (value === undefined || value === null || value === '') return '-';
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return String(value);
return numberValue.toLocaleString('en-US');
}
export function mapRoomCalendarQualificationRecords(records = []) {
return records.reduce((map, record) => {
const date = normalizeRecordDate(getRecordValue(record, 'date', 'Date'));
const roomType = getRecordValue(record, 'roomType', 'RoomType');
if (!date || !roomType) return map;
const flag = Number(getRecordValue(record, 'bookingDateQualificationFlag', 'BookingDateQualificationFlag'));
map[`${date}_${roomType}`] = {
criteria: formatCriteria(getRecordValue(record, 'roomCriteria', 'RoomCriteria')),
count: Number(getRecordValue(record, 'roomCount', 'RoomCount') || 0),
leader: getRecordValue(record, 'propertyLeader', 'PropertyLeader') || getRecordValue(record, 'leader', 'Leader') || '',
qualified: flag === 1,
statusText: flag === 1 ? 'Qualified' : 'Unqualified',
};
return map;
}, {});
}
export function getLeaderForRoomType(roomType) {
const leaderMap = {
GR: 'Samuel H.',
X1: 'Hope C.',
X2: 'Clara N.',
R1: 'Hugo L.',
R2: 'Selina T.',
MO_MOGR: 'Hugo L.',
MO_SUIT: 'Hugo L.',
MO: 'Hugo L.',
ASC: 'Clara N.',
LMG: 'Selina T.',
LAR: 'Kevin L.',
YOBT: 'Momo C.',
HBV: 'Hope C.',
YOHO: 'Samuel H.',
};
return leaderMap[roomType.code] || 'Kevin L.';
}
export function getOtherPropertyRoomTypes(roomTypes) {
const displayNameMap = {
ASC: 'Ascott',
};
return roomTypes.reduce((list, roomType) => {
if (roomType.code === 'MO') {
list.push(
{ ...roomType, code: 'MO_MOGR', label: 'MO', displayName: 'MOGR', parentCode: 'MO' },
{ ...roomType, code: 'MO_SUIT', label: 'MO', displayName: 'MO Suit', parentCode: 'MO' },
);
return list;
}
list.push({
...roomType,
displayName: roomType.displayName || displayNameMap[roomType.code],
parentCode: roomType.parentCode || roomType.code,
});
return list;
}, []);
}