chore: initialize WanderQ MiniAPP 2.0 repository

This commit is contained in:
inman
2026-07-23 15:48:34 +08:00
commit b4c0a1c516
93 changed files with 53525 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
import Taro from "@tarojs/taro";
const DEFAULT_API_BASE_URL = "https://biz.wanderqtrip.com/api";
const API_BASE = String(process.env.TARO_APP_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
const TOKEN_KEY = "wanqu_mini_program_token";
const LOCAL_FAVORITES_KEY = "wanqu_mini_program_favorites";
const LOCAL_HISTORY_KEY = "wanqu_mini_program_history";
export function apiUrl(path: string) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const pathWithoutDuplicatePrefix = API_BASE.endsWith("/api") && (normalizedPath === "/api" || normalizedPath.startsWith("/api/"))
? normalizedPath.slice(4) || "/"
: normalizedPath;
return `${API_BASE}${pathWithoutDuplicatePrefix}`;
}
export type Product = {
id: string;
sourceId?: number | null;
title: string;
subtitle?: string | null;
priceAmount?: number | null;
priceUnit: string;
tags: string[];
coverImage?: string | null;
summary?: string | null;
destination?: { id: string; name: string; image?: string | null } | null;
destinationId?: string | null;
volumeKey?: string | null;
durationDays?: number | null;
durationNights?: number | null;
departureDates?: string[];
recommendation?: string | null;
images?: Array<{ id?: string; url: string; alt?: string | null; sortOrder?: number }>;
keyFacts?: Array<{ label: string; value: string }> | null;
detailSections?: Array<{ key: string; label: string; title?: string | null; blocks: Array<{ type: "text" | "image"; text?: string; url?: string; alt?: string | null }> }> | null;
};
export type SiteConfig = {
heroSlides: Array<{ id: string; title: string; kicker?: string | null; image: string; targetType?: string | null; targetValue?: string | null }>;
destinations: Array<{ id: string; name: string; image?: string | null; isHot: boolean }>;
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null }>;
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null }>;
homeModules?: Array<{ id: string; label: string; templateType: string; content: { items: Array<{ id: string; title: string; subtitle?: string; image?: string | null; targetType?: string | null; targetValue?: string | null; productIds?: string[] }> } }>;
};
export type UserProfile = {
id: string;
nickname?: string | null;
avatarUrl?: string | null;
phoneMasked: string | null;
hasPhone: boolean;
status: "active" | "disabled" | "anonymized";
source: string;
firstSeenAt: string;
lastLoginAt: string;
createdAt: string;
leads?: number;
favorites?: number;
history?: number;
};
export type UserLead = {
id: string;
requestType?: "custom" | "booking";
destination?: string | null;
contactName?: string | null;
travelDate?: string | null;
peopleCount?: number | null;
adultCount?: number | null;
childCount?: number | null;
plan?: string | null;
note?: string | null;
status: string;
createdAt: string;
updatedAt: string;
sourceProduct?: { id: string; title: string; coverImage?: string | null } | null;
};
export type Activity = {
favorites: Array<Product & { savedAt: string }>;
history: Array<Product & { viewedAt: string; viewCount: number }>;
};
function readStorage<T>(key: string, fallback: T): T {
try {
const value = Taro.getStorageSync(key);
return value ? (value as T) : fallback;
} catch {
return fallback;
}
}
function writeStorage(key: string, value: unknown) {
try {
Taro.setStorageSync(key, value);
} catch {
// Storage can be unavailable in preview/private contexts; network state remains authoritative.
}
}
export function getToken() {
return readStorage<string | null>(TOKEN_KEY, null);
}
export function setToken(token: string) {
writeStorage(TOKEN_KEY, token);
}
export function clearToken() {
try {
Taro.removeStorageSync(TOKEN_KEY);
} catch {
// Ignore storage cleanup failures.
}
}
export function getLocalActivity() {
return {
favorites: readStorage<string[]>(LOCAL_FAVORITES_KEY, []),
history: readStorage<string[]>(LOCAL_HISTORY_KEY, []),
};
}
function setLocalIds(key: string, ids: string[]) {
writeStorage(key, ids);
}
export function saveLocalFavorite(productId: string, saved: boolean) {
const current = getLocalActivity().favorites.filter((id) => id !== productId);
setLocalIds(LOCAL_FAVORITES_KEY, saved ? [productId, ...current] : current);
}
export function saveLocalHistory(productId: string) {
const current = getLocalActivity().history.filter((id) => id !== productId);
setLocalIds(LOCAL_HISTORY_KEY, [productId, ...current].slice(0, 50));
}
async function request<T>(path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; data?: unknown } = {}) {
const token = getToken();
const headers: Record<string, string> = {
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
if (options.data !== undefined) headers["content-type"] = "application/json";
const response = await Taro.request<T>({
url: apiUrl(path),
method: options.method ?? "GET",
data: options.data,
header: headers,
});
const body = response.data as T & { message?: string };
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(body?.message ?? `请求失败:${response.statusCode}`);
}
return body as T;
}
export async function loginMiniProgram() {
const result = await Taro.login();
const response = await request<{ token: string; user: UserProfile }>("/api/public/auth/wechat-login", {
method: "POST",
data: { code: result.code, source: "mini_program" },
});
setToken(response.token);
return response.user;
}
export async function fetchContent() {
const [site, products] = await Promise.all([
request<SiteConfig>("/api/public/site-config"),
request<{ items: Product[] }>("/api/public/products"),
]);
return { site, products: products.items };
}
export async function fetchMe() {
return request<UserProfile>("/api/public/users/me");
}
export async function updateProfile(data: { nickname?: string | null; avatarUrl?: string | null }) {
return request<UserProfile>("/api/public/users/me", { method: "PATCH", data });
}
export async function fetchActivity() {
return request<Activity>("/api/public/users/me/activity");
}
export async function syncLocalActivity() {
const local = getLocalActivity();
const activity = await request<Activity>("/api/public/users/me/activity/sync", {
method: "POST",
data: { favoriteProductIds: local.favorites, historyProductIds: local.history },
});
setLocalIds(LOCAL_FAVORITES_KEY, []);
setLocalIds(LOCAL_HISTORY_KEY, []);
return activity;
}
export async function saveFavorite(productId: string, saved: boolean) {
await request(saved ? `/api/public/users/me/favorites/${encodeURIComponent(productId)}` : `/api/public/users/me/favorites/${encodeURIComponent(productId)}`, {
method: saved ? "POST" : "DELETE",
});
}
export async function recordHistory(productId: string) {
await request("/api/public/users/me/history", { method: "POST", data: { productId } });
}
export async function clearHistory() {
await request("/api/public/users/me/history", { method: "DELETE" });
}
export async function authorizePhone(code: string) {
return request<{ user: UserProfile; linkedLeadCount: number }>("/api/public/users/me/phone", { method: "POST", data: { code } });
}
export async function createLead(data: Record<string, unknown>) {
return request<{ id: string; status: string }>("/api/public/leads", { method: "POST", data });
}
export async function createBooking(data: Record<string, unknown>) {
return request<{ id: string; status: string; requestType: "booking" }>("/api/public/bookings", { method: "POST", data });
}
export async function fetchMyLeads() {
return request<UserLead[]>("/api/public/users/me/leads");
}
export async function closeAccount() {
return request<{ closed: boolean }>("/api/public/users/me/close", { method: "POST", data: {} });
}
export function resolveAsset(url?: string | null) {
if (!url) return "";
if (/^https?:\/\//i.test(url)) return url;
return apiUrl(url);
}