feat: scaffold taro h5 frontends

This commit is contained in:
Codex
2026-06-29 11:40:32 +08:00
parent f74db433cc
commit c090008f52
40 changed files with 18033 additions and 16 deletions

View File

@@ -0,0 +1,132 @@
import Taro from '@tarojs/taro';
import { appEnv } from '@/env';
import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types';
import { getStorage, removeStorage, setStorage } from './storage';
const TENANT_KEY = 'tiku:tenant';
const SESSION_KEY = 'tiku:session';
export class ApiError extends Error {
status: number;
code: string;
details?: unknown;
constructor(payload: ApiErrorPayload) {
super(payload.message);
this.name = 'ApiError';
this.status = payload.status;
this.code = payload.code;
this.details = payload.details;
}
}
export function getTenantContext() {
return getStorage<TenantContext>(TENANT_KEY);
}
export function saveTenantContext(tenant: TenantContext) {
setStorage(TENANT_KEY, tenant);
}
export function clearTenantContext() {
removeStorage(TENANT_KEY);
}
export function getSession() {
return getStorage<ApiSession>(SESSION_KEY);
}
export function saveSession(session: ApiSession) {
setStorage(SESSION_KEY, session);
}
export function clearSession() {
removeStorage(SESSION_KEY);
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, '');
}
function buildUrl(path: string, query?: Record<string, string | number | boolean | null | undefined>) {
const url = `${normalizeBaseUrl(appEnv.apiBaseUrl)}${path.startsWith('/') ? path : `/${path}`}`;
const params = Object.entries(query || {})
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
return params.length ? `${url}?${params.join('&')}` : url;
}
export async function apiRequest<T>(
path: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
query?: Record<string, string | number | boolean | null | undefined>;
body?: unknown;
tenantId?: string | null;
token?: string | null;
headers?: Record<string, string>;
} = {},
): Promise<T> {
const tenant = getTenantContext();
const session = getSession();
const token = options.token ?? session?.token ?? null;
const tenantId = options.tenantId ?? tenant?.tenantId ?? null;
const headers: Record<string, string> = {
'content-type': 'application/json',
...(tenantId ? { 'x-tenant-id': tenantId } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
...options.headers,
};
const response = await Taro.request({
url: buildUrl(path, options.query),
method: options.method || 'GET',
data: options.body === undefined ? undefined : options.body,
header: headers,
});
const payload = (response.data || {}) as Record<string, unknown>;
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode === 401) clearSession();
throw new ApiError({
status: response.statusCode,
code: String(payload.code || 'API_ERROR'),
message: String(payload.message || '请求失败'),
details: payload,
});
}
return payload as T;
}
export async function resolveTenant(input: { host?: string; tenantCode?: string } = {}) {
const payload = await apiRequest<{
item?: TenantContext;
tenant?: {
id?: string;
tenantId?: string;
slug?: string;
};
branding?: TenantContext['branding'];
features?: TenantContext['features'];
adminFeatures?: TenantContext['adminFeatures'];
publicConfig?: TenantContext['publicConfig'];
}>('/api/tenant/resolve', {
query: {
host: input.host,
tenantCode: input.tenantCode || appEnv.tenantCode,
},
tenantId: null,
});
const tenantId = payload.item?.tenantId || payload.tenant?.tenantId || payload.tenant?.id;
if (!tenantId) throw new ApiError({ status: 500, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' });
const context: TenantContext = {
tenantId,
tenantSlug: payload.item?.tenantSlug || payload.tenant?.slug,
host: input.host,
branding: payload.item?.branding || payload.branding || {},
features: payload.item?.features || payload.features || {},
adminFeatures: payload.item?.adminFeatures || payload.adminFeatures || {},
publicConfig: payload.item?.publicConfig || payload.publicConfig || {},
};
saveTenantContext(context);
return context;
}

View File

@@ -0,0 +1,31 @@
import { apiRequest, clearSession, saveSession } from './api';
import type { ApiEnvelope, CurrentUser } from '@/types';
export async function sendSmsCode(phone: string, purpose: 'login' | 'bind_phone' = 'login') {
return apiRequest<ApiEnvelope<never>>('/api/auth/sms/send', {
method: 'POST',
body: { phone, purpose },
tenantId: null,
});
}
export async function verifySmsCode(phone: string, code: string, purpose: 'login' | 'bind_phone' = 'login') {
const payload = await apiRequest<ApiEnvelope<CurrentUser>>('/api/auth/sms/verify', {
method: 'POST',
body: { phone, code, purpose },
});
if (payload.session?.token) saveSession(payload.session);
return payload;
}
export async function loadCurrentUser() {
return apiRequest<ApiEnvelope<CurrentUser>>('/api/auth/me');
}
export async function logout() {
try {
await apiRequest('/api/auth/logout', { method: 'POST' });
} finally {
clearSession();
}
}

View File

@@ -0,0 +1,32 @@
import { apiRequest } from './api';
export interface ContentEntry {
id: string;
name: string;
entryType?: string;
route?: string;
icon?: string;
sortOrder?: number;
}
export interface DashboardSnapshot {
entries: ContentEntry[];
banners: unknown[];
announcements: unknown[];
profile: unknown | null;
}
export async function loadStudentDashboard(regionId?: string): Promise<DashboardSnapshot> {
const [entries, banners, announcements, profile] = await Promise.all([
apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId } }),
apiRequest<{ items?: unknown[] }>('/api/catalog/banners'),
apiRequest<{ items?: unknown[] }>('/api/catalog/announcements'),
apiRequest<{ item?: unknown }>('/api/profile/me').catch(() => ({ item: null })),
]);
return {
entries: entries.items || [],
banners: banners.items || [],
announcements: announcements.items || [],
profile: profile.item || null,
};
}

View File

@@ -0,0 +1,19 @@
import Taro from '@tarojs/taro';
export function getStorage<T>(key: string): T | null {
try {
const value = Taro.getStorageSync<string>(key);
if (!value) return null;
return JSON.parse(value) as T;
} catch {
return null;
}
}
export function setStorage<T>(key: string, value: T) {
Taro.setStorageSync(key, JSON.stringify(value));
}
export function removeStorage(key: string) {
Taro.removeStorageSync(key);
}

View File

@@ -0,0 +1,25 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { appEnv } from '@/env';
let client: SupabaseClient | null = null;
export function getSupabaseClient() {
if (!appEnv.supabaseUrl || !appEnv.supabasePublishableKey) return null;
if (!client) {
client = createClient(appEnv.supabaseUrl, appEnv.supabasePublishableKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});
}
return client;
}
export async function getSupabaseAccessToken() {
const supabase = getSupabaseClient();
if (!supabase) return null;
const { data } = await supabase.auth.getSession();
return data.session?.access_token || null;
}