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

@@ -33,7 +33,7 @@
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。 - 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
- Taro 跨端前端还没开始 scaffold - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;完整页面和端到端联调仍需继续补
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:
@@ -59,6 +59,7 @@
```text ```text
apps/api/ Node.js 业务 API apps/api/ Node.js 业务 API
apps/taro/ Taro 4 React 跨端前端H5 三入口,后续扩展小程序
apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、资源复检、导入执行、公共题库同步等 apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、资源复检、导入执行、公共题库同步等
packages/config/ 共享配置 packages/config/ 共享配置
packages/db/ PostgreSQL 连接池和查询封装 packages/db/ PostgreSQL 连接池和查询封装
@@ -92,6 +93,30 @@ npm run db:smoke-seed
npm run dev:api npm run dev:api
``` ```
Taro H5 本地开发:
```bash
npm run dev:taro:h5
```
三套 H5 构建:
```bash
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
```
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。
单次运行 CRM worker 单次运行 CRM worker
```bash ```bash
@@ -157,6 +182,8 @@ npm run check:refactor
npm run check:api npm run check:api
npm run check:worker npm run check:worker
npm run check:importer npm run check:importer
npm run check:taro
npm run audit:runtime
npm run pb:import:dry-run npm run pb:import:dry-run
npm run pb:import:validate npm run pb:import:validate
npm run test:readiness npm run test:readiness
@@ -250,16 +277,22 @@ API 身份上下文:
npx supabase db reset npx supabase db reset
npm run check:refactor npm run check:refactor
npm run test:readiness npm run test:readiness
npm run audit:runtime
npm run test:worker:imports npm run test:worker:imports
npm run test:worker:crm npm run test:worker:crm
npm run test:worker:commerce npm run test:worker:commerce
npm run test:worker:assets npm run test:worker:assets
npm run test:worker:public-banks npm run test:worker:public-banks
npm audit --audit-level=high npm run check:taro
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
git diff --check git diff --check
``` ```
结果:通过。`npm audit --audit-level=high` 无高危漏洞;当前依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 结果:通过。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
## 下一步建议 ## 下一步建议

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"]
}

View File

@@ -177,8 +177,12 @@
```bash ```bash
npx supabase db reset npx supabase db reset
npm audit --audit-level=high npm run audit:runtime
npm run check:refactor npm run check:refactor
npm run check:taro
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
npm run test:readiness npm run test:readiness
npm run test:pb:dry-run npm run test:pb:dry-run
npm run test:worker:imports npm run test:worker:imports

View File

@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
| Taro 前端 | 未开始 | 旧 Web 已有新 API 适配雏形 | `apps/taro`、跨端 API client、H5/小程序页面和端到端测试 | | Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化和基础工作台壳 | 学生端完整页面、租户后台/平台后台业务页面、小程序兼容验证和端到端测试 |
## 前端接入建议 ## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。 - 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 - 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold建立 `apps/taro`,先完成租户解析、首页、题库、背单词、知识手册、个人中心主链路。 - Taro scaffold`apps/taro` 地基已建立;下一步补学生端首页、题库、背单词、知识手册、个人中心主链路和小程序兼容验证
### P1商用收费和运营能力 ### P1商用收费和运营能力

View File

@@ -171,7 +171,7 @@ provider event id 幂等
## 上线前安全验收清单 ## 上线前安全验收清单
- `npm audit` 为 0 高危/严重漏洞 - `npm run audit:runtime` 为 0 high/critical 漏洞Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过
- `npm run check:refactor` 通过。 - `npm run check:refactor` 通过。
- `npm run readiness:production` 没有 blocker。 - `npm run readiness:production` 没有 blocker。
- `npm run readiness:production:db` 没有 blocker。 - `npm run readiness:production:db` 没有 blocker。

View File

@@ -158,10 +158,11 @@
### 架构目标 ### 架构目标
- 建议新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。 - 新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。
- H5 和小程序共用同一套业务 API client。 - H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。 - 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取。 - 页面主题、品牌、功能开关都从后端租户配置读取。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client下一步补完整页面、状态管理和小程序兼容验证。
### 第一批页面 ### 第一批页面

View File

@@ -47,6 +47,17 @@ F:\project
index.ts worker CLI/常驻循环入口 index.ts worker CLI/常驻循环入口
package.json package.json
tsconfig.json tsconfig.json
taro/ Taro 4 React 跨端前端
src/
services/ 统一 API client、Auth、Supabase client、缓存
pages/
bootstrap/ 租户解析和三入口分流
student/ 学生学习端 H5/小程序页面
tenant-admin/ 租户后台工作台和运营页面
platform-admin/ 平台超级管理员后台页面
config/ Taro 构建配置,按 portal 输出三套 H5 产物
package.json
tsconfig.json
packages/ packages/
config/ 共享配置和 env 工具 config/ 共享配置和 env 工具
@@ -127,6 +138,7 @@ git status --short --branch
- `apps/api/**` - `apps/api/**`
- `apps/worker/**` - `apps/worker/**`
- `apps/taro/**`
- `packages/**` - `packages/**`
- `supabase/**` - `supabase/**`
- `scripts/import-pocketbase/**` - `scripts/import-pocketbase/**`

View File

@@ -0,0 +1,129 @@
# Taro H5 三入口部署说明
更新时间2026-06-29
当前 `apps/taro` 按一个 Taro 工程、三套 H5 产物组织:
- 学生学习端:刷题、背单词、知识手册、分数线、资料、会员和个人中心。
- 租户后台:品牌、域名、题库、导入、学生、订单、营销、销售和数据看板。
- 平台后台租户、SaaS 套餐、订阅账单、公共题库授权和平台审计。
## 构建命令
```bash
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
```
推荐部署:
| 域名 | 目录 | 说明 |
| --- | --- | --- |
| `www.example.com` 或租户自有学生端域名 | `h5-student` | 面向学生和 C 端用户 |
| `admin.example.com` | `h5-tenant-admin` | 面向租户公司运营、教师、销售、代理、管理员 |
| `console.example.com` | `h5-platform-admin` | 面向平台超级管理员 |
三个入口可以放在同一台服务器的三个静态目录,也可以放到 CDN/对象存储静态网站。API 推荐独立域名,例如 `api.example.com`
## 环境变量
构建时只允许注入:
```text
TARO_APP_PORTAL=student | tenant-admin | platform-admin
TARO_APP_API_BASE_URL=https://api.example.com
TARO_APP_SUPABASE_URL=https://<supabase-auth-host>
TARO_APP_SUPABASE_PUBLISHABLE_KEY=<publishable-key>
TARO_APP_TENANT_CODE=<可选,小程序或预览环境使用>
```
禁止进入前端构建:
```text
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
```
`apps/taro/src/env.ts` 会在启动时检查这些危险变量,防止误把服务端密钥打包到前端。
## Nginx 建议
H5 使用 history 路由时,静态服务器需要把未知路径回退到 `index.html`
```nginx
server {
server_name www.example.com;
root /www/tiku/h5-student;
location / {
try_files $uri $uri/ /index.html;
}
}
server {
server_name admin.example.com;
root /www/tiku/h5-tenant-admin;
location / {
try_files $uri $uri/ /index.html;
}
}
server {
server_name console.example.com;
root /www/tiku/h5-platform-admin;
location / {
try_files $uri $uri/ /index.html;
}
}
```
API CORS 必须只允许实际域名,不允许生产环境 `CORS=*`。生产前运行:
```bash
npm run readiness:production
npm run readiness:production:db
```
## 前端请求边界
- 所有页面统一通过 `apps/taro/src/services/api.ts` 调用后端。
- H5 可以用 Supabase client 管理 Auth session但业务数据默认走 `apps/api`
- `x-tenant-id` 只是租户上下文,不是身份来源。
- 登录后禁止传 `x-user-id` 或 body/query `userId` 表示当前用户。
- 订单、支付、权益、内容导入、后台配置、CRM、对象存储签名、视频播放签名必须走后端命令层。
## 安全审计边界
H5 线上只发布 `apps/taro/dist/**` 静态文件,不要把 `apps/taro/node_modules` 或源码目录部署到 Web 根目录。后端/API/worker 的生产依赖审计使用:
```bash
npm run audit:runtime
```
Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 告警,主要来自构建期 CLI、webpack、swiper、lodash-es 等传递依赖。不要使用 `npm audit fix --force` 将 Taro 降级到 3.x应等 Taro 官方升级后再处理,或者后续评估 Vite runner 替代方案。上线时以静态产物、前端密钥检查、CORS 域名白名单和 API runtime audit 作为阻断项。
## 下一步页面顺序
1. 学生端:租户启动、登录、首页、题库入口、练习、错题、收藏。
2. 学生端:背单词、知识手册、分数线、资料、会员、个人中心。
3. 租户后台:数据看板、内容导航、题目录入/导入、学生管理、营销中心。
4. 平台后台:租户、套餐、账单、公共题库授权。
5. 小程序:验证 storage/fetch/Auth 兼容性,复用同一套 API client。

16824
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,11 @@
"build:worker": "npm --workspace @tiku-saas/worker run build", "build:worker": "npm --workspace @tiku-saas/worker run build",
"check:api": "npm --workspace @tiku-saas/api run check", "check:api": "npm --workspace @tiku-saas/api run check",
"check:importer": "npm --workspace @tiku-saas/import-pocketbase run check", "check:importer": "npm --workspace @tiku-saas/import-pocketbase run check",
"check:taro": "npm --workspace @tiku-saas/taro run check",
"check:worker": "npm --workspace @tiku-saas/worker run check", "check:worker": "npm --workspace @tiku-saas/worker run check",
"check:refactor": "npm run check:api && npm run check:worker && npm run check:importer && npm run pb:import:validate && npm run test:readiness && npm run test:pb:dry-run && npm run test:api", "check:refactor": "npm run check:api && npm run check:worker && npm run check:importer && npm run pb:import:validate && npm run test:readiness && npm run test:pb:dry-run && npm run test:api",
"audit:runtime": "npm audit --omit=dev --audit-level=high",
"audit:taro:toolchain": "npm audit --workspace @tiku-saas/taro --audit-level=high",
"docker:api:build": "docker compose -f docker-compose.api.yml build", "docker:api:build": "docker compose -f docker-compose.api.yml build",
"docker:api:up": "docker compose -f docker-compose.api.yml up api", "docker:api:up": "docker compose -f docker-compose.api.yml up api",
"docker:api:down": "docker compose -f docker-compose.api.yml down", "docker:api:down": "docker compose -f docker-compose.api.yml down",
@@ -43,6 +46,11 @@
"readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production": "node scripts/production-readiness-check.js --skip-db",
"readiness:production:db": "node scripts/production-readiness-check.js --check-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db",
"test:api:remote": "node scripts/api-integration-test.js", "test:api:remote": "node scripts/api-integration-test.js",
"dev:taro:h5": "npm --workspace @tiku-saas/taro run dev:h5",
"build:taro:h5": "npm --workspace @tiku-saas/taro run build:h5",
"build:taro:h5:student": "npm --workspace @tiku-saas/taro run build:h5:student",
"build:taro:h5:tenant": "npm --workspace @tiku-saas/taro run build:h5:tenant",
"build:taro:h5:platform": "npm --workspace @tiku-saas/taro run build:h5:platform",
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary", "pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk", "pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
"pb:import:dry-run": "npm --workspace @tiku-saas/import-pocketbase run import:dry-run --", "pb:import:dry-run": "npm --workspace @tiku-saas/import-pocketbase run import:dry-run --",