项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

459 行
18 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import {
pickFormattedPhoneNumber,
pickPlaceWebsite,
type GooglePlaceContactFields,
type MapPoiSnapshot,
type ShoppingItem,
} from '../types';
import type { Locale } from '@/os/locale';
import { strings } from '../res/strings';
import { stringsEn } from '../res/strings.en';
import { fromTimestamp } from '../../../os/TimeService';
export type MapStrings = Record<keyof typeof strings, string>;
export function getMapStrings(locale: Locale = 'zh-Hans'): MapStrings {
return locale === 'en'
? ({ ...strings, ...stringsEn } as MapStrings)
: (strings as MapStrings);
}
export const stripHtml = (html: string) =>
String(html || '')
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
export const parseDistanceTextToMeters = (distance: string): number | null => {
const value = String(distance || '').trim();
if (!value) return null;
const matched = value.match(/([\d.]+)\s*(公里|千米|km|米|m|mi|miles?)/i);
if (!matched) return null;
const amount = Number(matched[1]);
if (!Number.isFinite(amount)) return null;
const unit = matched[2].toLowerCase();
if (unit === '公里' || unit === '千米' || unit === 'km') return amount * 1000;
if (unit === 'mi' || unit === 'mile' || unit === 'miles') return amount * 1609.344;
return amount;
};
/** 列表/POI 等展示:≥1000m 为「x.x 公里」;1000m 以下为按 50m 粒度四舍五入;舍入到 1000m 时显示「1.0 公里」而非「1000.0 米」;结果不足 50m 时显示 50.0 米;无效/≤0 返回空串 */
export const MIN_DISPLAY_DISTANCE_METERS = 50;
export const MIN_ROUTE_DISTANCE_METERS = 10;
export function formatDistanceLabelMeters(
meters: number | undefined | null,
locale: Locale = 'zh-Hans',
): string {
const t = getMapStrings(locale);
if (meters === undefined || meters === null || !Number.isFinite(meters) || meters <= 0) {
return '';
}
if (meters >= 1000) {
return `${(meters / 1000).toFixed(1)} ${t.unit_km}`;
}
const step = MIN_DISPLAY_DISTANCE_METERS;
let rounded = Math.round(meters / step) * step;
if (rounded < step) {
rounded = step;
}
if (rounded >= 1000) {
return `${(rounded / 1000).toFixed(1)} ${t.unit_km}`;
}
return `${rounded.toFixed(1)} ${t.unit_m}`;
}
/** 路线详情/导航距离:<1000m 显示按 10m 粒度取整的整数米数;否则显示 1 位小数公里 */
export function formatRouteDistanceMeters(
meters: number | undefined | null,
locale: Locale = 'zh-Hans',
): string {
const t = getMapStrings(locale);
if (meters === undefined || meters === null || !Number.isFinite(meters) || meters <= 0) {
return '';
}
if (meters < 1000) {
const rounded = Math.max(
MIN_ROUTE_DISTANCE_METERS,
Math.round(meters / MIN_ROUTE_DISTANCE_METERS) * MIN_ROUTE_DISTANCE_METERS,
);
if (rounded < 1000) {
return `${rounded} ${t.unit_m}`;
}
return `${(rounded / 1000).toFixed(1)} ${t.unit_km}`;
}
return `${(meters / 1000).toFixed(1)} ${t.unit_km}`;
}
export const toSearchResultSnapshot = (item: ShoppingItem): Record<string, any> => ({
id: item.id,
place_id: item.id,
name: item.name,
rating: item.rating,
user_ratings_total: item.ratingCount,
distance: formatDistanceLabelMeters(item.distance),
distance_meters: item.distance,
address: item.address,
formatted_address: item.address,
category: item.category,
types: item.types,
primary_type: item.primaryType,
status: item.status,
lat: item.lat,
lng: item.lng,
});
export const toPoiSnapshot = (
place: (google.maps.places.PlaceResult & GooglePlaceContactFields) | null,
distanceText: string,
): MapPoiSnapshot | null => {
if (!place) return null;
return {
place_id: place.place_id,
name: place.name || '',
formatted_address: place.formatted_address || place.vicinity || '',
address: place.formatted_address || place.vicinity || '',
formatted_phone_number: pickFormattedPhoneNumber(place),
website: pickPlaceWebsite(place),
rating: place.rating,
user_ratings_total: place.user_ratings_total,
business_status: place.business_status,
types: place.types || [],
distance: distanceText || '',
distance_meters: parseDistanceTextToMeters(distanceText),
};
};
export const toJudgeTravelMode = (mode: 'driving' | 'transit' | 'walking' | 'cycling'): string => {
if (mode === 'walking') return 'WALKING';
if (mode === 'cycling') return 'BICYCLING';
if (mode === 'transit') return 'TRANSIT';
return 'DRIVING';
};
export const PLACE_ABOUT_FIELDS: string[] = [
'displayName',
'formattedAddress',
'location',
'rating',
'userRatingCount',
'businessStatus',
'types',
'primaryType',
'primaryTypeDisplayName',
'internationalPhoneNumber',
'nationalPhoneNumber',
'websiteURI',
'regularOpeningHours',
'currentOpeningHours',
'editorialSummary',
'plusCode',
'accessibilityOptions',
'paymentOptions',
'parkingOptions',
'hasDineIn',
'hasTakeout',
'hasDelivery',
'isReservable',
'servesBreakfast',
'servesLunch',
'servesDinner',
'servesBrunch',
'servesBeer',
'servesWine',
'servesCocktails',
'servesCoffee',
'servesDessert',
'servesVegetarianFood',
'hasRestroom',
'allowsDogs',
'hasOutdoorSeating',
'hasLiveMusic',
'isGoodForWatchingSports',
'isGoodForChildren',
'isGoodForGroups',
'hasMenuForChildren',
];
export type AboutItem = { label: string; value: boolean };
export type AboutSection = { title: string; items: AboutItem[] };
export function extractPlaceAboutData(p: any): Record<string, any> {
const acc = p.accessibilityOptions;
const pay = p.paymentOptions;
const park = p.parkingOptions;
return {
hasDineIn: p.hasDineIn ?? null,
hasTakeout: p.hasTakeout ?? null,
hasDelivery: p.hasDelivery ?? null,
isReservable: p.isReservable ?? null,
servesBreakfast: p.servesBreakfast ?? null,
servesLunch: p.servesLunch ?? null,
servesDinner: p.servesDinner ?? null,
servesBeer: p.servesBeer ?? null,
servesWine: p.servesWine ?? null,
servesBrunch: p.servesBrunch ?? null,
servesCocktails: p.servesCocktails ?? null,
servesCoffee: p.servesCoffee ?? null,
servesDessert: p.servesDessert ?? null,
servesVegetarianFood: p.servesVegetarianFood ?? null,
accessibility: acc
? {
wheelchairEntrance: acc.hasWheelchairAccessibleEntrance ?? acc.wheelchairAccessibleEntrance ?? null,
wheelchairParking: acc.hasWheelchairAccessibleParking ?? acc.wheelchairAccessibleParking ?? null,
wheelchairRestroom: acc.hasWheelchairAccessibleRestroom ?? acc.wheelchairAccessibleRestroom ?? null,
wheelchairSeating: acc.hasWheelchairAccessibleSeating ?? acc.wheelchairAccessibleSeating ?? null,
}
: undefined,
payment: pay
? {
creditCards: pay.acceptsCreditCards ?? null,
debitCards: pay.acceptsDebitCards ?? null,
cashOnly: pay.acceptsCashOnly ?? null,
nfc: pay.acceptsNFC ?? pay.acceptsNfc ?? null,
}
: undefined,
parking: park
? {
freeParking: park.hasFreeParkingLot ?? park.freeParkingLot ?? null,
paidParking: park.hasPaidParkingLot ?? park.paidParkingLot ?? null,
streetParking: park.hasFreeStreetParking ?? park.freeStreetParking ?? park.hasPaidStreetParking ?? park.paidStreetParking ?? null,
valetParking: park.hasValetParking ?? park.valetParking ?? null,
garageParking: park.hasFreeGarageParking ?? park.freeGarageParking ?? park.hasPaidGarageParking ?? park.paidGarageParking ?? null,
}
: undefined,
hasLiveMusic: p.hasLiveMusic ?? null,
isGoodForWatchingSports: p.isGoodForWatchingSports ?? null,
hasOutdoorSeating: p.hasOutdoorSeating ?? null,
hasRestroom: p.hasRestroom ?? null,
allowsDogs: p.allowsDogs ?? null,
isGoodForChildren: p.isGoodForChildren ?? null,
hasMenuForChildren: p.hasMenuForChildren ?? null,
isGoodForGroups: p.isGoodForGroups ?? null,
};
}
export function buildAboutSections(
about: Record<string, any> | undefined,
locale: Locale = 'zh-Hans',
): AboutSection[] {
if (!about) return [];
const t = getMapStrings(locale);
const sections: AboutSection[] = [];
const svc: AboutItem[] = [];
if (about.hasDineIn === true) svc.push({ label: t.about_dine_in, value: true });
if (about.hasTakeout === true) svc.push({ label: t.about_takeout, value: true });
if (about.hasDelivery === true) svc.push({ label: t.about_delivery, value: true });
if (about.isReservable === true) svc.push({ label: t.about_reservable, value: true });
if (about.servesBreakfast === true) svc.push({ label: t.about_serves_breakfast, value: true });
if (about.servesLunch === true) svc.push({ label: t.about_serves_lunch, value: true });
if (about.servesDinner === true) svc.push({ label: t.about_serves_dinner, value: true });
if (about.servesBrunch === true) svc.push({ label: t.about_serves_brunch, value: true });
if (about.servesCoffee === true) svc.push({ label: t.about_serves_coffee, value: true });
if (about.servesDessert === true) svc.push({ label: t.about_serves_dessert, value: true });
if (about.servesBeer === true) svc.push({ label: t.about_serves_beer, value: true });
if (about.servesWine === true) svc.push({ label: t.about_serves_wine, value: true });
if (about.servesCocktails === true) svc.push({ label: t.about_serves_cocktails, value: true });
if (about.servesVegetarianFood === true) svc.push({ label: t.about_serves_vegetarian, value: true });
if (svc.length) sections.push({ title: t.about_service_options, items: svc });
const acc = about.accessibility;
if (acc) {
const items: AboutItem[] = [];
if (acc.wheelchairEntrance === true) items.push({ label: t.about_wheelchair_entrance, value: true });
if (acc.wheelchairParking === true) items.push({ label: t.about_wheelchair_parking, value: true });
if (acc.wheelchairRestroom === true) items.push({ label: t.about_wheelchair_restroom, value: true });
if (acc.wheelchairSeating === true) items.push({ label: t.about_wheelchair_seating, value: true });
if (items.length) sections.push({ title: t.about_accessibility, items });
}
const activities: AboutItem[] = [];
if (about.hasOutdoorSeating === true) activities.push({ label: t.about_outdoor_seating, value: true });
if (about.hasLiveMusic === true) activities.push({ label: t.about_live_music, value: true });
if (about.isGoodForWatchingSports === true) activities.push({ label: t.about_good_for_sports, value: true });
if (activities.length) sections.push({ title: t.about_activities, items: activities });
const facilities: AboutItem[] = [];
if (about.hasRestroom === true) facilities.push({ label: t.about_restroom, value: true });
if (about.isGoodForGroups === true) facilities.push({ label: t.about_good_for_groups, value: true });
if (facilities.length) sections.push({ title: t.about_amenities, items: facilities });
const park = about.parking;
if (park) {
const items: AboutItem[] = [];
if (park.freeParking === true) items.push({ label: t.about_free_parking, value: true });
if (park.paidParking === true) items.push({ label: t.about_paid_parking, value: true });
if (park.streetParking === true) items.push({ label: t.about_street_parking, value: true });
if (park.garageParking === true) items.push({ label: t.about_garage_parking, value: true });
if (park.valetParking === true) items.push({ label: t.about_valet_parking, value: true });
if (items.length) sections.push({ title: t.about_parking, items });
}
const pay = about.payment;
if (pay) {
const items: AboutItem[] = [];
if (pay.debitCards === true) items.push({ label: t.about_debit_cards, value: true });
if (pay.creditCards === true) items.push({ label: t.about_credit_cards, value: true });
if (pay.nfc === true) items.push({ label: t.about_nfc_payments, value: true });
if (pay.cashOnly === true) items.push({ label: t.about_cash_only, value: true });
if (items.length) sections.push({ title: t.about_payments, items });
}
const children: AboutItem[] = [];
if (about.isGoodForChildren === true) children.push({ label: t.about_good_for_children, value: true });
if (about.hasMenuForChildren === true) children.push({ label: t.about_kids_menu, value: true });
if (children.length) sections.push({ title: t.about_children, items: children });
const pets: AboutItem[] = [];
if (about.allowsDogs === true) pets.push({ label: t.about_dogs_allowed, value: true });
if (pets.length) sections.push({ title: t.about_pets, items: pets });
return sections;
}
export const DAY_NAMES_ZH = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export const DAY_NAMES_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function formatCnAmpmTime(hour: number, minute: number): string {
const period = hour >= 12 ? '下午' : '上午';
const h12 = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return `${period}${h12}:${String(minute).padStart(2, '0')}`;
}
/** 24 小时制 HH:mm(与系统「24 小时制」展示一致) */
export function format24hTime(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
export function getNextOpeningDate(now: Date, open: { day: number; hour: number; minute: number }): Date {
const target = fromTimestamp(now.getTime());
const currentDay = now.getDay();
const addDays = (open.day - currentDay + 7) % 7;
target.setDate(now.getDate() + addDays);
target.setHours(open.hour, open.minute, 0, 0);
if (target.getTime() <= now.getTime()) {
target.setDate(target.getDate() + 7);
}
return target;
}
export function findNextOpeningLabel(
periods: any[],
now: Date,
locale: Locale = 'zh-Hans',
): string | null {
if (!periods?.length) return null;
let best: Date | null = null;
for (const pr of periods) {
if (!pr?.open) continue;
const next = getNextOpeningDate(now, pr.open);
if (!best || next.getTime() < best.getTime()) best = next;
}
if (!best) return null;
const dayName = locale === 'en' ? DAY_NAMES_EN[best.getDay()] : DAY_NAMES_ZH[best.getDay()];
return `${dayName}${format24hTime(best.getHours(), best.getMinutes())}`;
}
/** 与 Google Maps 一致:营业中/已结束营业、结束时间、下次开始时间 */
export function computeOpeningStatusFromPeriods(
hrs: { periods: any[] },
now: Date,
locale: Locale = 'zh-Hans',
): { isOpen: boolean; closesAt: string | null; opensNextLabel: string | null } {
const periods = hrs?.periods;
if (!periods?.length) {
return { isOpen: false, closesAt: null, opensNextLabel: null };
}
const day = now.getDay();
const hhmm = now.getHours() * 100 + now.getMinutes();
let closesAt: string | null = null;
let isOpen = false;
for (const pr of periods) {
if (pr.open?.day !== day) continue;
const o = pr.open.hour * 100 + pr.open.minute;
const c = pr.close ? pr.close.hour * 100 + pr.close.minute : 2400;
if (hhmm >= o && hhmm < c) {
isOpen = true;
if (pr.close) {
closesAt = format24hTime(pr.close.hour, pr.close.minute);
}
break;
}
}
const opensNextLabel = isOpen ? null : findNextOpeningLabel(periods, now, locale);
return { isOpen, closesAt, opensNextLabel };
}
/** 系统 locale → Google Maps API languageCode */
export function googleLangCode(locale: 'zh-Hans' | 'en' = 'zh-Hans'): string {
return locale === 'en' ? 'en' : 'zh-CN';
}
/** Place 构造器用 requestedLanguage 参数 */
export function placeRequestedLanguage(locale: 'zh-Hans' | 'en' = 'zh-Hans'): { requestedLanguage: string } {
return { requestedLanguage: googleLangCode(locale) };
}
/** weekdayDescriptions 为 API 本地化字符串;仅按首处冒号分列排版 */
export function splitWeekdayDescriptionLine(desc: string): { left: string; right: string | null } {
const m = String(desc).match(/^([^:]+)[:]\s*(.+)$/);
if (m) return { left: m[1].trim(), right: m[2].trim() };
return { left: desc, right: null };
}
/**
* formattedAddress 有时会把短 Plus Code 嵌进正文(如 …圆明园275X+67P 邮政编码…)。
* compoundCode 单独展示在展开区;主行应去掉嵌入片段,避免重复。
*/
export function stripEmbeddedPlusCodeFromAddress(address: string, compoundCode: string | null | undefined): string {
if (!address) return '';
let out = address.trim();
if (compoundCode) {
const localPart = compoundCode.split(/\s+/)[0]?.trim();
if (localPart && localPart.includes('+')) {
const escaped = localPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
out = out.replace(new RegExp(escaped, 'gi'), '');
}
}
// 兜底:Open Location Code 短码(约 4+2/3 位),与 compoundCode 首段可能不一致时仍剥离
const olcShort = /\s*[23456789CFGHJKLMNPQRSTVWXY]{4,8}\+[23456789CFGHJKLMNPQRSTVWXY]{2,3}\s*/gi;
out = out.replace(olcShort, ' ');
return out.replace(/\s{2,}/g, ' ').trim();
}
/**
* 将完整格式化地址裁成更接近 Google 地图卡片风格的短地址。
* 优先保留道路 / 门牌 / POI 附近信息,去掉国家、省市区等行政前缀。
*/
export function formatShortDisplayAddress(address: string | null | undefined): string {
const original = String(address || '').trim();
if (!original) return '';
let out = stripEmbeddedPlusCodeFromAddress(original, null)
.replace(/^中国/, '')
.replace(/[,,]\s*中国$/, '')
.replace(/\s*邮政编码[:]?\s*\d{6}\s*$/u, '')
.trim();
const administrativePrefixes = [
/^(北京市|上海市|天津市|重庆市|香港特别行政区|澳门特别行政区)/u,
/^[^省]+省/u,
/^[^自治区]+自治区/u,
/^[^特别行政区]+特别行政区/u,
/^[^市]+市/u,
/^[^区县旗]+区/u,
/^[^区县旗]+县/u,
/^[^旗]+旗/u,
];
for (const pattern of administrativePrefixes) {
const shortened = out.replace(pattern, '').trim();
if (shortened && shortened.length >= 4) {
out = shortened;
}
}
return out || original;
}