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

2
apps/taro/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
.swc/

44
apps/taro/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Taro 前端工程
这是 SaaS 题库的新跨端前端地基。当前阶段先提供 H5 多入口壳、租户解析、统一 API client、Supabase Auth client 初始化和安全环境变量边界。
## 三个 H5 入口
```bash
npm run dev:taro:h5
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
```
构建产物:
```text
apps/taro/dist/h5-student
apps/taro/dist/h5-tenant-admin
apps/taro/dist/h5-platform-admin
```
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`
## 前端环境变量
只允许使用:
```text
TARO_APP_PORTAL=student | tenant-admin | platform-admin
TARO_APP_API_BASE_URL=https://api.example.com
TARO_APP_SUPABASE_URL=https://<supabase-host>
TARO_APP_SUPABASE_PUBLISHABLE_KEY=<publishable-key>
TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用>
```
禁止把 service role、数据库连接串、支付私钥、对象存储密钥放进 Taro 构建环境。
## 接入原则
- H5 可用 Supabase client 管理 Auth session。
- 业务数据默认走 `apps/api`
- `x-tenant-id` 只是租户上下文,不是身份凭证。
- 登录后不要传 `x-user-id` 或 body/query `userId` 伪造当前用户。
- 题库练习、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。

View File

@@ -0,0 +1,11 @@
module.exports = {
presets: [
[
'taro',
{
framework: 'react',
ts: true,
},
],
],
};

6
apps/taro/config/dev.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
env: {
NODE_ENV: '"development"',
},
defineConstants: {},
};

62
apps/taro/config/index.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { UserConfigExport } from '@tarojs/cli';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const portal = process.env.TARO_APP_PORTAL || 'student';
const configDir = path.dirname(fileURLToPath(import.meta.url));
const distDirByPortal: Record<string, string> = {
student: 'dist/h5-student',
'tenant-admin': 'dist/h5-tenant-admin',
'platform-admin': 'dist/h5-platform-admin',
};
const outputRoot = distDirByPortal[portal] || distDirByPortal.student;
export default {
projectName: 'tiku-saas-taro',
date: '2026-06-29',
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot,
framework: 'react',
compiler: {
type: 'webpack5',
prebundle: {
enable: false,
},
},
alias: {
'@': path.resolve(configDir, '..', 'src'),
},
defineConstants: {},
copy: {
patterns: [],
options: {},
},
h5: {
publicPath: './',
staticDirectory: 'static',
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].js',
},
router: {
mode: 'browser',
},
},
mini: {
postcss: {
pxtransform: {
enable: true,
config: {},
},
cssModules: {
enable: false,
},
},
},
} satisfies UserConfigExport;

6
apps/taro/config/prod.ts Normal file
View File

@@ -0,0 +1,6 @@
export default {
env: {
NODE_ENV: '"production"',
},
defineConstants: {},
};

40
apps/taro/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "@tiku-saas/taro",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build:h5": "taro build --type h5",
"build:h5:student": "cross-env TARO_APP_PORTAL=student taro build --type h5",
"build:h5:tenant": "cross-env TARO_APP_PORTAL=tenant-admin taro build --type h5",
"build:h5:platform": "cross-env TARO_APP_PORTAL=platform-admin taro build --type h5",
"dev:h5": "taro build --type h5 --watch",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/preset-react": "^7.29.7",
"@supabase/supabase-js": "^2.108.2",
"@tarojs/cli": "4.2.0",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/react": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"@tarojs/webpack5-runner": "4.2.0",
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"babel-preset-taro": "^4.2.0",
"cross-env": "^7.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-refresh": "^0.14.2",
"typescript": "^5.8.3",
"webpack": "5.91.0"
}
}

View File

@@ -0,0 +1,14 @@
{
"miniprogramRoot": "dist/weapp/",
"projectname": "tiku-saas-taro",
"description": "工学教育 SaaS 题库 Taro 多端前端",
"appid": "touristappid",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": false,
"minified": true
},
"compileType": "miniprogram"
}

View File

@@ -0,0 +1,14 @@
export default defineAppConfig({
pages: [
'pages/bootstrap/index',
'pages/student/home/index',
'pages/tenant-admin/workbench/index',
'pages/platform-admin/workbench/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#0f172a',
navigationBarTitleText: '工学题库',
navigationBarTextStyle: 'white',
},
});

17
apps/taro/src/app.css Normal file
View File

@@ -0,0 +1,17 @@
page {
min-height: 100%;
background: #f6f8fb;
color: #172033;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
view,
text,
input,
button {
box-sizing: border-box;
}
button {
margin: 0;
}

6
apps/taro/src/app.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { PropsWithChildren } from 'react';
import './app.css';
export default function App({ children }: PropsWithChildren) {
return children;
}

31
apps/taro/src/env.ts Normal file
View File

@@ -0,0 +1,31 @@
export type Portal = 'student' | 'tenant-admin' | 'platform-admin';
declare const process: {
env: Record<string, string | undefined>;
};
export const appEnv = {
portal: (process.env.TARO_APP_PORTAL || 'student') as Portal,
apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787',
supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '',
supabasePublishableKey: process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '',
tenantCode: process.env.TARO_APP_TENANT_CODE || '',
};
export function assertFrontendSecretsAreAbsent() {
const forbidden = [
'SUPABASE_SERVICE_ROLE_KEY',
'SUPABASE_SECRET_KEY',
'DATABASE_URL',
'ALIYUN_OSS_ACCESS_KEY_SECRET',
'TENCENT_COS_SECRET_KEY',
'WECHAT_PAY_PRIVATE_KEY',
'ALIPAY_APP_PRIVATE_KEY',
'AUTH_SESSION_SECRET',
'PLATFORM_ADMIN_API_KEY',
];
const leaked = forbidden.filter(key => process.env[key]);
if (leaked.length) {
throw new Error(`Forbidden secret env in Taro build: ${leaked.join(', ')}`);
}
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '启动',
});

View File

@@ -0,0 +1,48 @@
.bootstrap-page {
min-height: 100vh;
padding: 56px 28px;
background: #101827;
color: #f8fafc;
}
.bootstrap-shell {
display: flex;
min-height: calc(100vh - 112px);
flex-direction: column;
justify-content: center;
gap: 18px;
}
.eyebrow {
color: #80b7ff;
font-size: 24px;
font-weight: 700;
}
.title {
max-width: 620px;
font-size: 48px;
font-weight: 800;
line-height: 1.18;
}
.status {
color: #cbd5e1;
font-size: 28px;
}
.error {
color: #fecaca;
font-size: 24px;
line-height: 1.5;
}
.primary-button {
width: 240px;
height: 76px;
border-radius: 8px;
background: #2f7cf6;
color: #fff;
font-size: 28px;
line-height: 76px;
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { appEnv, assertFrontendSecretsAreAbsent } from '@/env';
import { resolveTenant } from '@/services/api';
import './index.css';
function hostFromRuntime() {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') return window.location.host;
return '';
}
function landingPath() {
if (appEnv.portal === 'tenant-admin') return '/pages/tenant-admin/workbench/index';
if (appEnv.portal === 'platform-admin') return '/pages/platform-admin/workbench/index';
return '/pages/student/home/index';
}
export default function BootstrapPage() {
const [status, setStatus] = useState('正在解析租户');
const [error, setError] = useState('');
useEffect(() => {
assertFrontendSecretsAreAbsent();
resolveTenant({ host: hostFromRuntime() })
.then(() => {
setStatus('租户解析完成');
Taro.redirectTo({ url: landingPath() });
})
.catch((nextError: Error) => {
setError(nextError.message);
setStatus('租户解析失败');
});
}, []);
return (
<View className='bootstrap-page'>
<View className='bootstrap-shell'>
<Text className='eyebrow'>Tiku SaaS</Text>
<Text className='title'></Text>
<Text className='status'>{status}</Text>
{error ? <Text className='error'>{error}</Text> : null}
{error ? (
<Button className='primary-button' onClick={() => Taro.reLaunch({ url: '/pages/bootstrap/index' })}>
</Button>
) : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '平台后台',
});

View File

@@ -0,0 +1,53 @@
.platform-page {
min-height: 100vh;
padding: 28px;
background: #f7f9fc;
}
.platform-header {
padding-bottom: 28px;
border-bottom: 1px solid #d8e0ec;
}
.platform-title {
display: block;
color: #101827;
font-size: 38px;
font-weight: 800;
}
.platform-subtitle {
display: block;
margin-top: 10px;
color: #64748b;
font-size: 24px;
}
.platform-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 28px;
}
.platform-tile {
min-height: 128px;
padding: 20px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.tile-title {
display: block;
color: #172033;
font-size: 28px;
font-weight: 800;
}
.tile-status {
display: block;
margin-top: 16px;
color: #64748b;
font-size: 22px;
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import { apiRequest } from '@/services/api';
import './index.css';
export default function PlatformWorkbenchPage() {
const [ready, setReady] = useState(false);
useEffect(() => {
apiRequest('/api/platform-admin/overview').then(() => setReady(true)).catch(() => setReady(false));
}, []);
return (
<View className='platform-page'>
<View className='platform-header'>
<Text className='platform-title'>SaaS </Text>
<Text className='platform-subtitle'></Text>
</View>
<View className='platform-grid'>
{['租户管理', 'SaaS 套餐', '服务费账单', '公共题库', '用量记录', '安全审计'].map(name => (
<View className='platform-tile' key={name}>
<Text className='tile-title'>{name}</Text>
<Text className='tile-status'>{ready ? '接口已连接' : '需要平台管理员登录'}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '学习首页',
});

View File

@@ -0,0 +1,98 @@
.student-page {
min-height: 100vh;
padding: 28px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.brand {
display: block;
color: #14213d;
font-size: 36px;
font-weight: 800;
}
.subline {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 24px;
}
.ghost-button {
width: 132px;
height: 64px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #1e3a8a;
font-size: 24px;
line-height: 64px;
}
.hero-band {
margin-top: 36px;
padding: 34px 30px;
border-left: 8px solid #2f7cf6;
background: #fff;
}
.hero-title {
display: block;
color: #0f172a;
font-size: 40px;
font-weight: 800;
}
.hero-copy {
display: block;
margin-top: 14px;
color: #475569;
font-size: 25px;
line-height: 1.55;
}
.section {
margin-top: 36px;
}
.section-title {
display: block;
margin-bottom: 18px;
color: #172033;
font-size: 30px;
font-weight: 800;
}
.entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.entry-tile {
min-height: 126px;
padding: 22px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.entry-name {
display: block;
color: #111827;
font-size: 28px;
font-weight: 700;
}
.entry-type {
display: block;
margin-top: 12px;
color: #64748b;
font-size: 22px;
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Text, View } from '@tarojs/components';
import { getTenantContext } from '@/services/api';
import { loadStudentDashboard, type DashboardSnapshot } from '@/services/catalog';
import { loadCurrentUser } from '@/services/auth';
import './index.css';
export default function StudentHomePage() {
const tenant = getTenantContext();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [userName, setUserName] = useState('');
useEffect(() => {
loadStudentDashboard().then(setSnapshot).catch(() => setSnapshot({ entries: [], banners: [], announcements: [], profile: null }));
loadCurrentUser().then(payload => setUserName(payload.user?.name || payload.item?.name || '')).catch(() => setUserName(''));
}, []);
const entryNames = useMemo(() => (snapshot?.entries || []).slice(0, 6), [snapshot]);
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
return (
<View className='student-page'>
<View className='topbar'>
<View>
<Text className='brand'>{brandName}</Text>
<Text className='subline'>{userName ? `${userName},继续学习` : '题库、手册、资料和会员统一入口'}</Text>
</View>
<Button className='ghost-button'></Button>
</View>
<View className='hero-band'>
<Text className='hero-title'></Text>
<Text className='hero-copy'></Text>
</View>
<View className='section'>
<Text className='section-title'></Text>
<View className='entry-grid'>
{entryNames.length ? entryNames.map(item => (
<View className='entry-tile' key={item.id}>
<Text className='entry-name'>{item.name}</Text>
<Text className='entry-type'>{item.entryType || 'content'}</Text>
</View>
)) : ['刷题', '背单词', '知识手册', '分数线', '资料下载', '会员中心'].map(name => (
<View className='entry-tile' key={name}>
<Text className='entry-name'>{name}</Text>
<Text className='entry-type'></Text>
</View>
))}
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '租户后台',
});

View File

@@ -0,0 +1,72 @@
.admin-page {
min-height: 100vh;
padding: 28px;
background: #f8fafc;
}
.admin-header {
padding-bottom: 26px;
border-bottom: 1px solid #dbe3ef;
}
.admin-title {
display: block;
color: #111827;
font-size: 36px;
font-weight: 800;
}
.admin-subtitle {
display: block;
margin-top: 10px;
color: #64748b;
font-size: 24px;
}
.metric-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 26px;
}
.metric {
min-height: 116px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.metric-label {
display: block;
color: #64748b;
font-size: 22px;
}
.metric-value {
display: block;
margin-top: 16px;
color: #0f172a;
font-size: 30px;
font-weight: 800;
}
.module-list {
margin-top: 28px;
border-top: 1px solid #dbe3ef;
}
.module-row {
display: flex;
justify-content: space-between;
padding: 24px 0;
border-bottom: 1px solid #dbe3ef;
color: #172033;
font-size: 26px;
}
.module-status {
color: #64748b;
font-size: 24px;
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import { apiRequest, getTenantContext } from '@/services/api';
import './index.css';
interface Dashboard {
cards?: Record<string, unknown>;
trends?: unknown[];
recentActivities?: unknown[];
}
export default function TenantWorkbenchPage() {
const tenant = getTenantContext();
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
useEffect(() => {
apiRequest<{ item?: Dashboard }>('/api/tenant-admin/dashboard', { query: { timeRange: '30d' } })
.then(payload => setDashboard(payload.item || null))
.catch(() => setDashboard(null));
}, []);
return (
<View className='admin-page'>
<View className='admin-header'>
<Text className='admin-title'>{tenant?.branding.brandName || '租户运营后台'}</Text>
<Text className='admin-subtitle'></Text>
</View>
<View className='metric-row'>
{['收益', '注册', '答题', '激活码'].map(label => (
<View className='metric' key={label}>
<Text className='metric-label'>{label}</Text>
<Text className='metric-value'>{dashboard ? '已接入' : '待登录'}</Text>
</View>
))}
</View>
<View className='module-list'>
{['内容管理', '导入复检', '学生运营', '销售分佣', 'CRM 队列', '品牌域名'].map(name => (
<View className='module-row' key={name}>
<Text>{name}</Text>
<Text className='module-status'></Text>
</View>
))}
</View>
</View>
);
}

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;
}

48
apps/taro/src/types.ts Normal file
View File

@@ -0,0 +1,48 @@
export interface TenantBranding {
brandName?: string;
shortName?: string;
logoUrl?: string;
slogan?: string;
theme?: Record<string, unknown>;
}
export interface TenantContext {
tenantId: string;
tenantSlug?: string;
host?: string;
branding: TenantBranding;
features: Record<string, unknown>;
adminFeatures: Record<string, unknown>;
publicConfig: Record<string, unknown>;
}
export interface ApiSession {
token: string;
expiresAt?: string;
}
export interface CurrentUser {
id: string;
name?: string;
phone?: string;
roles?: string[];
permissions?: Record<string, boolean>;
}
export interface ApiEnvelope<T> {
item?: T;
items?: T[];
tenant?: unknown;
user?: CurrentUser;
session?: ApiSession;
code?: string;
message?: string;
[key: string]: unknown;
}
export interface ApiErrorPayload {
status: number;
code: string;
message: string;
details?: unknown;
}

19
apps/taro/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["@tarojs/taro", "@tarojs/taro-h5", "node"]
},
"include": ["config/**/*.ts", "src/**/*.ts", "src/**/*.tsx"]
}