forked from wangziqi/gongxue-base
feat: scaffold taro h5 frontends
This commit is contained in:
2
apps/taro/.gitignore
vendored
Normal file
2
apps/taro/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
.swc/
|
||||
44
apps/taro/README.md
Normal file
44
apps/taro/README.md
Normal 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、资料签名、后台配置必须走后端命令层。
|
||||
11
apps/taro/babel.config.cjs
Normal file
11
apps/taro/babel.config.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'taro',
|
||||
{
|
||||
framework: 'react',
|
||||
ts: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
6
apps/taro/config/dev.ts
Normal file
6
apps/taro/config/dev.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
env: {
|
||||
NODE_ENV: '"development"',
|
||||
},
|
||||
defineConstants: {},
|
||||
};
|
||||
62
apps/taro/config/index.ts
Normal file
62
apps/taro/config/index.ts
Normal 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
6
apps/taro/config/prod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
env: {
|
||||
NODE_ENV: '"production"',
|
||||
},
|
||||
defineConstants: {},
|
||||
};
|
||||
40
apps/taro/package.json
Normal file
40
apps/taro/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
apps/taro/project.config.json
Normal file
14
apps/taro/project.config.json
Normal 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"
|
||||
}
|
||||
14
apps/taro/src/app.config.ts
Normal file
14
apps/taro/src/app.config.ts
Normal 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
17
apps/taro/src/app.css
Normal 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
6
apps/taro/src/app.tsx
Normal 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
31
apps/taro/src/env.ts
Normal 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(', ')}`);
|
||||
}
|
||||
}
|
||||
3
apps/taro/src/pages/bootstrap/index.config.ts
Normal file
3
apps/taro/src/pages/bootstrap/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '启动',
|
||||
});
|
||||
48
apps/taro/src/pages/bootstrap/index.css
Normal file
48
apps/taro/src/pages/bootstrap/index.css
Normal 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;
|
||||
}
|
||||
51
apps/taro/src/pages/bootstrap/index.tsx
Normal file
51
apps/taro/src/pages/bootstrap/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '平台后台',
|
||||
});
|
||||
53
apps/taro/src/pages/platform-admin/workbench/index.css
Normal file
53
apps/taro/src/pages/platform-admin/workbench/index.css
Normal 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;
|
||||
}
|
||||
29
apps/taro/src/pages/platform-admin/workbench/index.tsx
Normal file
29
apps/taro/src/pages/platform-admin/workbench/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/home/index.config.ts
Normal file
3
apps/taro/src/pages/student/home/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '学习首页',
|
||||
});
|
||||
98
apps/taro/src/pages/student/home/index.css
Normal file
98
apps/taro/src/pages/student/home/index.css
Normal 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;
|
||||
}
|
||||
54
apps/taro/src/pages/student/home/index.tsx
Normal file
54
apps/taro/src/pages/student/home/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '租户后台',
|
||||
});
|
||||
72
apps/taro/src/pages/tenant-admin/workbench/index.css
Normal file
72
apps/taro/src/pages/tenant-admin/workbench/index.css
Normal 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;
|
||||
}
|
||||
46
apps/taro/src/pages/tenant-admin/workbench/index.tsx
Normal file
46
apps/taro/src/pages/tenant-admin/workbench/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/taro/src/services/api.ts
Normal file
132
apps/taro/src/services/api.ts
Normal 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;
|
||||
}
|
||||
31
apps/taro/src/services/auth.ts
Normal file
31
apps/taro/src/services/auth.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
32
apps/taro/src/services/catalog.ts
Normal file
32
apps/taro/src/services/catalog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
apps/taro/src/services/storage.ts
Normal file
19
apps/taro/src/services/storage.ts
Normal 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);
|
||||
}
|
||||
25
apps/taro/src/services/supabase.ts
Normal file
25
apps/taro/src/services/supabase.ts
Normal 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
48
apps/taro/src/types.ts
Normal 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
19
apps/taro/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user