chore: initialize WanderQ MiniAPP 2.0 repository
This commit is contained in:
237
apps/miniprogram/src/services/api.ts
Normal file
237
apps/miniprogram/src/services/api.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user