feat: scaffold tenant site builder demo

This commit is contained in:
2026-08-01 15:09:11 +08:00
commit 015d6649a1
48 changed files with 7434 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_DATA_MODE=mock
VITE_API_BASE_URL=

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.env.local
.env.*.local
coverage/
*.tsbuildinfo
*.log
.DS_Store

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Tiku SaaS Web
租户建站与学生端前端。项目是单应用双 Shell学生端使用 Tailwind CSS租户后台使用 Ant Design。当前版本优先完成一次性 Owner 激活、建站引导、主题和首页模块配置、预览与发布闭环。
## 开发
要求 Node.js 24+ 与 npm 11+。
```bash
npm install
npm run dev
```
默认访问 `http://localhost:5180/__demo`创建或重置租户并复制一次性激活链接。Mock 数据按开发模拟 Host 保存在浏览器 `localStorage`,刷新页面不会丢失。
```bash
npm test
npm run check:production
```
## 数据模式
复制 `.env.example``.env.local`
```dotenv
VITE_DATA_MODE=mock
VITE_API_BASE_URL=
```
- `mock`:仅 Development 动态加载 MSW生产构建不会包含 Worker、启动器、示例 Token 或 Mock 状态。
- `api`:使用同源 `/api`,也可在开发期通过 `VITE_API_BASE_URL` 代理到 .NET API。
页面只能调用 `src/api` 下的领域 Client不能直接调用 `fetch`。前端请求不携带任意 `tenantId`;真实租户由 Host 与受保护 Session 决定。
## 路由和权限
| 路由 | 行为 |
| --- | --- |
| `/` | 学生端;根据 Runtime 的站点状态显示内容、筹备、暂停或域名接入页面 |
| `/activate/:activationId#token=...` | 一次性 Owner 激活Token 读入后立即从地址栏移除 |
| `/manage/login` | 租户后台登录 |
| `/manage/*` | 必须通过租户 Backoffice Bootstrap无 Permission 的账号统一显示 404 |
| `/__demo` | 仅开发环境存在的生命周期启动器 |
后台菜单来自 `GET /api/backoffice/tenant/bootstrap`。进入站点设计还必须包含 `tenant:settings:manage`,前端隐藏菜单不能替代后端 401/403。
## 前后端契约
当前前端 Facade 对齐以下接口:
- `GET /api/runtime/bootstrap`
- `POST /api/browser-auth/activation/complete`
- `POST /api/browser-auth/login/password`
- `GET /api/backoffice/tenant/bootstrap`
- `GET /api/tenant-onboarding/status`
- `GET|PUT|POST /api/tenant-admin/frontend-config/**`
后端目前已有 `/api/auth/activation/complete`,但只返回 204。目标 Browser Auth 激活接口需要在完成密码设置后签发 HttpOnly Session Cookie并返回当前用户摘要前端才能安全地自动进入建站向导。
## 与旧系统的边界
`tiki-web` 仅作为学生业务页面、响应式布局和交互体验的功能参考。本仓库不得引入 PocketBase、`MockBackend``tenant.config.ts`,也不得在浏览器保存支付、短信或对象存储 Secret。Logo 的本地 Data URL 仅用于当前前端原型;真实接入时改为后端签发的上传凭证或预签名 URL。

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#3157d5" />
<meta name="description" content="教育服务平台" />
<title>教育服务平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4587
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "tiku-saas-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"check:production": "npm run build && node scripts/check-production-bundle.mjs",
"check": "npm run test && npm run check:production"
},
"dependencies": {
"@ant-design/icons": "6.1.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"antd": "6.5.3",
"lucide-react": "0.468.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router": "8.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/node": "24.10.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@vitejs/plugin-react": "6.0.5",
"jsdom": "28.1.0",
"msw": "2.15.0",
"tailwindcss": "4.3.3",
"typescript": "5.9.3",
"vite": "8.2.0",
"vitest": "4.1.10"
},
"msw": {
"workerDirectory": [
"public"
]
}
}

361
public/mockServiceWorker.js Normal file
View File

@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -0,0 +1,20 @@
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
async function files(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(join(directory, entry.name)) : [join(directory, entry.name)]))).flat();
}
const forbidden = ['/__demo', 'tiku-saas-demo:v1:', 'mockServiceWorker', 'PocketBase', 's3AccessKeySecret', 'X-Demo-Host'];
const violations = [];
for (const file of await files(fileURLToPath(new URL('../dist', import.meta.url)))) {
const content = await readFile(file, 'utf8').catch(() => '');
for (const marker of forbidden) if (content.includes(marker)) violations.push(`${file}: ${marker}`);
}
if (violations.length) {
console.error(`Production bundle contains development-only markers:\n${violations.join('\n')}`);
process.exit(1);
}
console.log('Production bundle contains no development-only or secret markers.');

15
src/api/authApi.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { BrowserOwnerActivationRequest, CurrentUser, LoginRequest } from '../contracts';
import { apiRequest } from './http';
export const authApi = {
completeOwnerActivation: (request: BrowserOwnerActivationRequest) =>
apiRequest<CurrentUser>('/api/browser-auth/activation/complete', {
method: 'POST',
body: JSON.stringify(request),
}),
login: (request: LoginRequest) => apiRequest<CurrentUser>('/api/browser-auth/login/password', {
method: 'POST',
body: JSON.stringify(request),
}),
logout: () => apiRequest<void>('/api/browser-auth/logout', { method: 'POST' }),
};

6
src/api/backofficeApi.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { BackofficeBootstrap } from '../contracts';
import { apiRequest } from './http';
export const backofficeApi = {
bootstrap: () => apiRequest<BackofficeBootstrap>('/api/backoffice/tenant/bootstrap'),
};

41
src/api/http.ts Normal file
View File

@@ -0,0 +1,41 @@
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) {
super(message);
}
}
function apiBase(): string {
const configured = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ?? '';
return configured;
}
export async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
if (import.meta.env.DEV && import.meta.env.VITE_DATA_MODE !== 'api') {
const host = window.localStorage.getItem('tiku-saas-demo-host') || window.location.hostname;
headers.set('X-Demo-Host', host);
}
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers,
credentials: 'include',
});
if (!response.ok) {
const problem = await response.json().catch(() => ({})) as { code?: string; title?: string; detail?: string };
throw new ApiError(
response.status,
problem.code ?? `http_${response.status}`,
problem.detail ?? problem.title ?? '请求失败,请稍后重试。',
);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}

6
src/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './authApi';
export * from './backofficeApi';
export * from './http';
export * from './onboardingApi';
export * from './runtimeApi';
export * from './siteConfigApi';

6
src/api/onboardingApi.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { TenantOnboardingStatus } from '../contracts';
import { apiRequest } from './http';
export const onboardingApi = {
status: () => apiRequest<TenantOnboardingStatus>('/api/tenant-onboarding/status'),
};

6
src/api/runtimeApi.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { TenantRuntimeBootstrap } from '../contracts';
import { apiRequest } from './http';
export const runtimeApi = {
bootstrap: () => apiRequest<TenantRuntimeBootstrap>('/api/runtime/bootstrap'),
};

16
src/api/siteConfigApi.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { TenantFrontendConfigDraft, TenantSiteConfig } from '../contracts';
import { apiRequest } from './http';
export const siteConfigApi = {
get: () => apiRequest<TenantSiteConfig>('/api/tenant-admin/frontend-config'),
saveDraft: (draft: TenantFrontendConfigDraft) =>
apiRequest<TenantSiteConfig>('/api/tenant-admin/frontend-config/draft', {
method: 'PUT',
body: JSON.stringify(draft),
}),
publish: (expectedVersion: number) =>
apiRequest<TenantSiteConfig>('/api/tenant-admin/frontend-config/publish', {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
};

114
src/app/App.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { lazy, Suspense, useEffect, useState } from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation,
} from "react-router";
import { ConfigProvider } from "antd";
import type { BackofficeBootstrap } from "../contracts";
import { ApiError, backofficeApi } from "../api";
import { BackofficeProvider } from "./BackofficeContext";
import { RuntimeProvider, useRuntime } from "./RuntimeProvider";
import {
FullScreenLoading,
NotFoundPage,
RuntimeErrorPage,
} from "../shared/StatusPages";
const StudentShell = lazy(() => import("../student/StudentShell"));
const ActivationPage = lazy(() => import("../tenant/ActivationPage"));
const TenantLoginPage = lazy(() => import("../tenant/TenantLoginPage"));
const TenantShell = lazy(() => import("../tenant/TenantShell"));
const DemoLauncher = import.meta.env.DEV
? lazy(() => import("../demo/DemoLauncher"))
: null;
function ManageGate() {
const location = useLocation();
const [bootstrap, setBootstrap] = useState<BackofficeBootstrap | null>(null);
const [state, setState] = useState<"loading" | "login" | "denied" | "ready">(
"loading",
);
useEffect(() => {
let active = true;
backofficeApi
.bootstrap()
.then((value) => {
if (!active) return;
if (!value.permissions.length) setState("denied");
else {
setBootstrap(value);
setState("ready");
}
})
.catch((error) => {
if (!active) return;
if (error instanceof ApiError && error.status === 401)
setState("login");
else setState("denied");
});
return () => {
active = false;
};
}, [location.key]);
if (state === "loading")
return <FullScreenLoading label="正在验证后台权限…" />;
if (state === "login")
return (
<Navigate
to="/manage/login"
replace
state={{ from: location.pathname }}
/>
);
if (state === "denied" || !bootstrap) return <NotFoundPage />;
return (
<BackofficeProvider value={bootstrap}>
<TenantShell />
</BackofficeProvider>
);
}
function RuntimeRoutes() {
const { runtime, loading, error, refresh } = useRuntime();
if (loading && !runtime) return <FullScreenLoading />;
if (error || !runtime)
return <RuntimeErrorPage retry={() => void refresh()} />;
return (
<ConfigProvider
theme={{
token: {
colorPrimary: runtime.theme.primaryColor,
borderRadius: 12,
fontFamily: runtime.theme.fontFamily,
},
}}
>
<Suspense fallback={<FullScreenLoading />}>
<Routes>
<Route path="/activate/:activationId" element={<ActivationPage />} />
<Route path="/manage/login" element={<TenantLoginPage />} />
<Route path="/manage/*" element={<ManageGate />} />
{DemoLauncher && <Route path="/__demo" element={<DemoLauncher />} />}
<Route path="/*" element={<StudentShell />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</ConfigProvider>
);
}
export function App() {
return (
<BrowserRouter>
<RuntimeProvider>
<RuntimeRoutes />
</RuntimeProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,14 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { BackofficeBootstrap } from '../contracts';
const BackofficeContext = createContext<BackofficeBootstrap | null>(null);
export function BackofficeProvider({ value, children }: { value: BackofficeBootstrap; children: ReactNode }) {
return <BackofficeContext.Provider value={value}>{children}</BackofficeContext.Provider>;
}
export function useBackoffice(): BackofficeBootstrap {
const value = useContext(BackofficeContext);
if (!value) throw new Error('Backoffice context is unavailable');
return value;
}

View File

@@ -0,0 +1,44 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { TenantRuntimeBootstrap } from '../contracts';
import { runtimeApi } from '../api';
interface RuntimeContextValue {
runtime: TenantRuntimeBootstrap | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}
const RuntimeContext = createContext<RuntimeContextValue | null>(null);
export function RuntimeProvider({ children }: { children: ReactNode }) {
const [runtime, setRuntime] = useState<TenantRuntimeBootstrap | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
const next = await runtimeApi.bootstrap();
setRuntime(next);
setError(null);
document.title = next.branding.brandName;
document.documentElement.style.setProperty('--tenant-primary', next.theme.primaryColor);
} catch (reason) {
setError(reason instanceof Error ? reason : new Error('站点加载失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const value = useMemo(() => ({ runtime, loading, error, refresh }), [runtime, loading, error, refresh]);
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
}
export function useRuntime(): RuntimeContextValue {
const value = useContext(RuntimeContext);
if (!value) throw new Error('useRuntime must be used inside RuntimeProvider');
return value;
}

45
src/contracts/auth.ts Normal file
View File

@@ -0,0 +1,45 @@
export interface BrowserOwnerActivationRequest {
activationId: string;
token: string;
newPassword: string;
}
export interface LoginRequest {
identifier: string;
password: string;
}
export interface CurrentUser {
userId: string;
displayName: string;
identifierMasked: string;
}
export interface BackofficeMenuItem {
code: string;
name: string;
path: string;
requiredPermission: string;
}
export interface BackofficeBootstrap {
user: CurrentUser;
permissions: string[];
menus: BackofficeMenuItem[];
enabledFeatures: string[];
}
export interface TenantOnboardingStep {
code: string;
required: boolean;
completed: boolean;
detail: string | null;
}
export interface TenantOnboardingStatus {
tenantId: string;
readyForStudentTraffic: boolean;
completedRequiredSteps: number;
requiredSteps: number;
steps: TenantOnboardingStep[];
}

2
src/contracts/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './auth';
export * from './site';

72
src/contracts/site.ts Normal file
View File

@@ -0,0 +1,72 @@
export type SiteState = 'domain_pending' | 'setup_required' | 'ready_to_launch' | 'active' | 'suspended';
export interface BrandingConfig {
brandName: string;
shortName: string;
slogan: string;
logoUrl: string;
faviconUrl: string;
serviceWechat: string;
}
export interface ThemeConfig {
primaryColor: string;
secondaryColor: string;
backgroundColor: string;
textColor: string;
fontFamily: string;
radius: number;
}
export interface FeatureConfig {
template: 'clarity' | 'energy' | 'academy';
}
export interface NavigationItem {
id: string;
label: string;
href: string;
visible: boolean;
}
export type HomeModuleType =
| 'hero'
| 'announcement'
| 'feature-grid'
| 'subject-entry'
| 'scoreline-entry'
| 'store-entry'
| 'contact-cta';
export interface HomeModule {
id: string;
type: HomeModuleType;
title: string;
visible: boolean;
}
export interface TenantFrontendConfigDraft {
branding: BrandingConfig;
theme: ThemeConfig;
features: FeatureConfig;
navigation: NavigationItem[];
homeModules: HomeModule[];
}
export interface TenantSiteConfig {
schemaVersion: number;
configVersion: number;
published: TenantFrontendConfigDraft;
draft: TenantFrontendConfigDraft;
publishedAt: string | null;
}
export interface TenantRuntimeBootstrap extends TenantFrontendConfigDraft {
schemaVersion: number;
configVersion: number;
tenantCode: string;
tenantName: string;
siteState: SiteState;
enabledFeatures: string[];
loginMethods: string[];
}

265
src/demo/DemoLauncher.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Form,
Input,
Select,
Space,
Table,
Tag,
Typography,
message,
} from "antd";
import type { SiteState } from "../contracts";
import {
allDemoHosts,
ensureTenant,
resetTenant,
saveTenant,
updateTenant,
} from "../mocks/store";
const statusOptions: Array<{ label: string; value: SiteState }> = [
{ label: "等待域名接入", value: "domain_pending" },
{ label: "等待管理员建站", value: "setup_required" },
{ label: "等待发布", value: "ready_to_launch" },
{ label: "已上线", value: "active" },
{ label: "已暂停", value: "suspended" },
];
export default function DemoLauncher() {
const initialHost =
window.localStorage.getItem("tiku-saas-demo-host") || "academy.localhost";
const [host, setHost] = useState(initialHost);
const [tenant, setTenant] = useState(() => ensureTenant(initialHost));
const [revision, setRevision] = useState(0);
const hosts = useMemo(
() => Array.from(new Set([...allDemoHosts(), host])),
[host, revision],
);
useEffect(() => {
window.localStorage.setItem("tiku-saas-demo-host", host);
}, [host]);
const [toast, contextHolder] = message.useMessage();
const activateHost = (nextHost: string) => {
window.localStorage.setItem("tiku-saas-demo-host", nextHost);
setHost(nextHost);
setTenant(ensureTenant(nextHost));
setRevision((value) => value + 1);
};
const mutate = (updater: Parameters<typeof updateTenant>[1]) =>
setTenant(updateTenant(host, updater));
const activationLink = `${window.location.origin}/activate/${tenant.activation.activationId}#token=${tenant.activation.token}`;
const copyLink = async () => {
await navigator.clipboard.writeText(activationLink);
void toast.success("一次性激活链接已复制");
};
return (
<div className="min-h-screen bg-slate-100 p-5 md:p-10">
{contextHolder}
<div className="mx-auto max-w-6xl">
<Alert
type="warning"
showIcon
title="开发环境生命周期启动器"
description="此页面和全部模拟数据不会进入生产构建。"
/>
<div className="my-6 flex flex-wrap items-end justify-between gap-4">
<div>
<Typography.Title level={2} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Button href="/"></Button>
</div>
<Card title="当前模拟 Host">
<Space.Compact block>
<Select
className="min-w-64"
value={host}
options={hosts.map((value) => ({ label: value, value }))}
onChange={activateHost}
/>
<Input
placeholder="new-school.localhost"
onPressEnter={(event) => {
const value = event.currentTarget.value.trim().toLowerCase();
if (value) activateHost(value);
}}
/>
<Button
type="primary"
onClick={() => {
const value = `school-${Date.now().toString().slice(-5)}.localhost`;
activateHost(value);
}}
>
</Button>
</Space.Compact>
</Card>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<Card title="生命周期">
<Form layout="vertical">
<Form.Item label="站点状态">
<Select
value={tenant.siteState}
options={statusOptions}
onChange={(value: SiteState) =>
mutate((state) => {
state.siteState = value;
})
}
/>
</Form.Item>
<Form.Item label="模拟账号权限">
<Select
value={
tenant.session
? tenant.session.permissions.includes(
"tenant:settings:manage",
)
? "owner"
: tenant.session.permissions.length
? "staff"
: "student"
: "none"
}
options={[
{ label: "未登录", value: "none" },
{ label: "Owner完整建站权限", value: "owner" },
{ label: "员工:只有工作台", value: "staff" },
{ label: "学生:无后台权限", value: "student" },
]}
onChange={(value) =>
mutate((state) => {
if (value === "none") state.session = null;
else
state.session = {
user: {
userId: state.tenantId,
displayName:
value === "student" ? "学生账号" : "运营账号",
identifierMasked: "us***@example.com",
},
permissions:
value === "owner"
? [
"tenant:dashboard:view",
"tenant:settings:manage",
]
: value === "staff"
? ["tenant:dashboard:view"]
: [],
};
})
}
/>
</Form.Item>
<Space wrap>
<Button
onClick={() => {
const next = resetTenant(host);
setTenant(next);
}}
>
</Button>
<Button
danger
onClick={() =>
mutate((state) => {
state.activation.expiresAt = new Date(
Date.now() - 1000,
).toISOString();
})
}
>
</Button>
<Button
danger
onClick={() =>
mutate((state) => {
state.activation.consumedAt = new Date().toISOString();
})
}
>
使
</Button>
</Space>
</Form>
</Card>
<Card
title="一次性建站密钥"
extra={
tenant.activation.consumedAt ? (
<Tag color="red">使</Tag>
) : new Date(tenant.activation.expiresAt).getTime() <
Date.now() ? (
<Tag color="orange"></Tag>
) : (
<Tag color="green"></Tag>
)
}
>
<Typography.Paragraph
copyable={{ text: tenant.activation.activationId }}
>
<strong></strong>
{tenant.activation.activationId}
</Typography.Paragraph>
<Typography.Paragraph>
<strong></strong>
{new Date(tenant.activation.expiresAt).toLocaleString()}
</Typography.Paragraph>
<Input.TextArea
value={activationLink}
readOnly
autoSize={{ minRows: 3 }}
/>
<Space className="mt-4">
<Button type="primary" onClick={() => void copyLink()}>
</Button>
<Button onClick={() => window.location.assign(activationLink)}>
</Button>
</Space>
</Card>
</div>
<Card className="mt-5" title="当前租户快照">
<Table
pagination={false}
size="small"
columns={[
{ title: "租户", dataIndex: "tenant" },
{ title: "状态", dataIndex: "state" },
{ title: "配置版本", dataIndex: "version" },
{ title: "管理员", dataIndex: "owner" },
]}
dataSource={[
{
key: tenant.tenantId,
tenant: tenant.tenantName,
state: tenant.siteState,
version: tenant.config.configVersion,
owner: tenant.activation.consumedAt ? "已激活" : "待激活",
},
]}
/>
</Card>
</div>
</div>
);
}

28
src/index.css Normal file
View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #172033;
background: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; margin: 0; }
body { min-width: 320px; }
button, input, textarea, select { font: inherit; }
.tenant-admin-shell .ant-layout { min-height: 100vh; }
.tenant-admin-shell .ant-layout-sider { box-shadow: 12px 0 40px rgba(17, 28, 62, 0.06); }
.preview-frame {
container-type: inline-size;
overflow: hidden;
}
@container (max-width: 520px) {
.preview-navigation { display: none !important; }
.preview-hero { grid-template-columns: 1fr !important; padding: 28px 20px !important; }
.preview-modules { grid-template-columns: 1fr !important; }
}

20
src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import 'antd/dist/reset.css';
import './index.css';
import { App } from './app/App';
async function enableMocking(): Promise<void> {
const dataMode = import.meta.env.VITE_DATA_MODE ?? (import.meta.env.DEV ? 'mock' : 'api');
if (!import.meta.env.DEV || dataMode !== 'mock') return;
const { worker } = await import('./mocks/browser');
await worker.start({ onUnhandledRequest: 'bypass' });
}
await enableMocking();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

4
src/mocks/browser.ts Normal file
View File

@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);

121
src/mocks/handlers.ts Normal file
View File

@@ -0,0 +1,121 @@
import { http, HttpResponse } from 'msw';
import type { BrowserOwnerActivationRequest, LoginRequest, TenantFrontendConfigDraft } from '../contracts';
import {
activateOwner,
backofficeOf,
DemoStoreError,
ensureTenant,
onboardingOf,
runtimeOf,
updateTenant,
} from './store';
import { cloneConfig } from '../shared/defaults';
function hostOf(request: Request): string {
return request.headers.get('X-Demo-Host') || new URL(request.url).hostname;
}
function problem(error: unknown) {
if (error instanceof DemoStoreError) {
return HttpResponse.json({ code: error.code, title: '请求无法完成。' }, { status: error.status });
}
return HttpResponse.json({ code: 'mock_failure', title: '模拟服务发生错误。' }, { status: 500 });
}
export const handlers = [
http.get('/api/runtime/bootstrap', ({ request }) => HttpResponse.json(runtimeOf(ensureTenant(hostOf(request))))),
http.post('/api/browser-auth/activation/complete', async ({ request }) => {
try {
const body = await request.json() as BrowserOwnerActivationRequest;
return HttpResponse.json(activateOwner(hostOf(request), body));
} catch (error) {
return problem(error);
}
}),
http.post('/api/browser-auth/login/password', async ({ request }) => {
const host = hostOf(request);
const body = await request.json() as LoginRequest;
const state = ensureTenant(host);
if (body.identifier !== state.ownerIdentifier || body.password !== state.password) {
return HttpResponse.json({ code: 'login_failed', title: '账号或密码错误。' }, { status: 401 });
}
const next = updateTenant(host, current => {
current.session = {
user: { userId: current.tenantId, displayName: '租户负责人', identifierMasked: 'ow***@academy.example' },
permissions: ['tenant:dashboard:view', 'tenant:settings:manage', 'tenant:site-content:manage'],
};
});
return HttpResponse.json(next.session!.user);
}),
http.post('/api/browser-auth/logout', ({ request }) => {
updateTenant(hostOf(request), state => { state.session = null; });
return new HttpResponse(null, { status: 204 });
}),
http.get('/api/backoffice/tenant/bootstrap', ({ request }) => {
try {
return HttpResponse.json(backofficeOf(ensureTenant(hostOf(request))));
} catch (error) {
return problem(error);
}
}),
http.get('/api/tenant-onboarding/status', ({ request }) => {
try {
const state = ensureTenant(hostOf(request));
backofficeOf(state);
return HttpResponse.json(onboardingOf(state));
} catch (error) {
return problem(error);
}
}),
http.get('/api/tenant-admin/frontend-config', ({ request }) => {
try {
const state = ensureTenant(hostOf(request));
const bootstrap = backofficeOf(state);
if (!bootstrap.permissions.includes('tenant:settings:manage')) throw new DemoStoreError(403, 'site_settings_denied');
return HttpResponse.json(state.config);
} catch (error) {
return problem(error);
}
}),
http.put('/api/tenant-admin/frontend-config/draft', async ({ request }) => {
try {
const host = hostOf(request);
const state = ensureTenant(host);
const bootstrap = backofficeOf(state);
if (!bootstrap.permissions.includes('tenant:settings:manage')) throw new DemoStoreError(403, 'site_settings_denied');
const draft = await request.json() as TenantFrontendConfigDraft;
const next = updateTenant(host, current => { current.config.draft = cloneConfig(draft); });
return HttpResponse.json(next.config);
} catch (error) {
return problem(error);
}
}),
http.post('/api/tenant-admin/frontend-config/publish', async ({ request }) => {
try {
const host = hostOf(request);
const state = ensureTenant(host);
const bootstrap = backofficeOf(state);
if (!bootstrap.permissions.includes('tenant:settings:manage')) throw new DemoStoreError(403, 'site_settings_denied');
const body = await request.json() as { expectedVersion: number };
if (body.expectedVersion !== state.config.configVersion) throw new DemoStoreError(409, 'frontend_config_version_conflict');
const next = updateTenant(host, current => {
current.config.published = cloneConfig(current.config.draft);
current.config.configVersion += 1;
current.config.publishedAt = new Date().toISOString();
current.siteState = 'active';
current.tenantName = current.config.published.branding.brandName;
});
return HttpResponse.json(next.config);
} catch (error) {
return problem(error);
}
}),
];

4
src/mocks/server.ts Normal file
View File

@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

191
src/mocks/store.ts Normal file
View File

@@ -0,0 +1,191 @@
import type {
BackofficeBootstrap,
BrowserOwnerActivationRequest,
CurrentUser,
SiteState,
TenantOnboardingStatus,
TenantRuntimeBootstrap,
TenantSiteConfig,
} from '../contracts';
import { cloneConfig, createDefaultConfig } from '../shared/defaults';
const PREFIX = 'tiku-saas-demo:v1:';
const OWNER_PERMISSIONS = [
'tenant:dashboard:view',
'tenant:settings:manage',
'tenant:site-content:manage',
'tenant:staff:manage',
];
export interface DemoActivation {
activationId: string;
token: string;
expiresAt: string;
consumedAt: string | null;
}
export interface DemoSession {
user: CurrentUser;
permissions: string[];
}
export interface DemoTenantState {
host: string;
tenantId: string;
tenantCode: string;
tenantName: string;
siteState: SiteState;
ownerIdentifier: string;
password: string | null;
activation: DemoActivation;
session: DemoSession | null;
config: TenantSiteConfig;
}
function randomId(): string {
return crypto.randomUUID();
}
function randomToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
}
export function createDemoTenant(host = 'academy.localhost'): DemoTenantState {
const config = createDefaultConfig();
return {
host,
tenantId: randomId(),
tenantCode: host.split('.')[0].replace(/[^a-z0-9-]/gi, '-').toLowerCase() || 'academy',
tenantName: config.branding.brandName,
siteState: 'setup_required',
ownerIdentifier: 'owner@academy.example',
password: null,
activation: {
activationId: randomId(),
token: randomToken(),
expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
consumedAt: null,
},
session: null,
config: {
schemaVersion: 1,
configVersion: 1,
published: cloneConfig(config),
draft: cloneConfig(config),
publishedAt: null,
},
};
}
function key(host: string): string {
return `${PREFIX}${host.toLowerCase()}`;
}
export function saveTenant(state: DemoTenantState): void {
window.localStorage.setItem(key(state.host), JSON.stringify(state));
}
export function loadTenant(host: string): DemoTenantState | null {
const value = window.localStorage.getItem(key(host));
return value ? JSON.parse(value) as DemoTenantState : null;
}
export function ensureTenant(host: string): DemoTenantState {
const existing = loadTenant(host);
if (existing) return existing;
const created = createDemoTenant(host);
saveTenant(created);
return created;
}
export function resetTenant(host: string): DemoTenantState {
window.localStorage.removeItem(key(host));
return ensureTenant(host);
}
export function updateTenant(host: string, updater: (state: DemoTenantState) => void): DemoTenantState {
const state = ensureTenant(host);
updater(state);
saveTenant(state);
return state;
}
export function activateOwner(host: string, request: BrowserOwnerActivationRequest): CurrentUser {
let activated!: CurrentUser;
updateTenant(host, state => {
const grant = state.activation;
if (request.activationId !== grant.activationId || request.token !== grant.token) throw new DemoStoreError(400, 'activation_invalid');
if (grant.consumedAt) throw new DemoStoreError(409, 'activation_consumed');
if (new Date(grant.expiresAt).getTime() <= Date.now()) throw new DemoStoreError(400, 'activation_expired');
if (request.newPassword.length < 8 || !/[a-z]/i.test(request.newPassword) || !/\d/.test(request.newPassword)) {
throw new DemoStoreError(400, 'activation_password_invalid');
}
grant.consumedAt = new Date().toISOString();
state.password = request.newPassword;
activated = {
userId: randomId(),
displayName: '租户负责人',
identifierMasked: 'ow***@academy.example',
};
state.session = { user: activated, permissions: [...OWNER_PERMISSIONS] };
});
return activated;
}
export function runtimeOf(state: DemoTenantState): TenantRuntimeBootstrap {
return {
schemaVersion: state.config.schemaVersion,
configVersion: state.config.configVersion,
tenantCode: state.tenantCode,
tenantName: state.tenantName,
siteState: state.siteState,
...cloneConfig(state.config.published),
enabledFeatures: ['core.backoffice', 'student.practice', 'site.content'],
loginMethods: ['password', 'sms'],
};
}
export function backofficeOf(state: DemoTenantState): BackofficeBootstrap {
if (!state.session) throw new DemoStoreError(401, 'authentication_required');
if (state.session.permissions.length === 0) throw new DemoStoreError(403, 'backoffice_access_denied');
const menus = [
{ code: 'dashboard', name: '工作台', path: '/manage', requiredPermission: 'tenant:dashboard:view' },
{ code: 'site', name: '站点设计', path: '/manage/site', requiredPermission: 'tenant:settings:manage' },
].filter(menu => state.session!.permissions.includes(menu.requiredPermission));
return {
user: state.session.user,
permissions: [...state.session.permissions],
enabledFeatures: ['core.backoffice', 'site.content'],
menus,
};
}
export function onboardingOf(state: DemoTenantState): TenantOnboardingStatus {
const steps = [
['owner_activated', Boolean(state.activation.consumedAt)],
['primary_domain_active', state.siteState !== 'domain_pending'],
['frontend_config_published', Boolean(state.config.publishedAt)],
['student_login_configured', true],
].map(([code, completed]) => ({ code: String(code), required: true, completed: Boolean(completed), detail: null }));
const completed = steps.filter(step => step.completed).length;
return {
tenantId: state.tenantId,
readyForStudentTraffic: completed === steps.length,
completedRequiredSteps: completed,
requiredSteps: steps.length,
steps,
};
}
export class DemoStoreError extends Error {
constructor(public status: number, public code: string) {
super(code);
}
}
export function allDemoHosts(): string[] {
return Object.keys(window.localStorage)
.filter(item => item.startsWith(PREFIX))
.map(item => item.slice(PREFIX.length));
}

View File

@@ -0,0 +1,45 @@
import { BookOpen, ChartNoAxesCombined, GraduationCap, Megaphone, MessageCircle, ShoppingBag, Sparkles } from 'lucide-react';
import type { HomeModule, TenantFrontendConfigDraft } from '../contracts';
const moduleIcons = {
announcement: Megaphone,
'feature-grid': Sparkles,
'subject-entry': BookOpen,
'scoreline-entry': ChartNoAxesCombined,
'store-entry': ShoppingBag,
'contact-cta': MessageCircle,
hero: GraduationCap,
} as const;
function ModuleCard({ module, primary }: { module: HomeModule; primary: string }) {
const Icon = moduleIcons[module.type];
if (module.type === 'announcement') {
return <div className="col-span-full flex items-center gap-3 rounded-2xl border border-black/5 bg-white px-5 py-4 shadow-sm"><Icon size={18} style={{ color: primary }} /><span className="text-sm font-semibold">{module.title}</span><span className="text-xs opacity-55"></span></div>;
}
return <div className="rounded-3xl border border-black/5 bg-white p-6 shadow-sm transition hover:-translate-y-1"><div className="mb-4 grid h-11 w-11 place-items-center rounded-2xl text-white" style={{ background: primary }}><Icon size={22} /></div><h3 className="font-bold">{module.title}</h3><p className="mt-2 text-sm opacity-60"></p></div>;
}
export function SitePreview({ config, compact = false }: { config: TenantFrontendConfigDraft; compact?: boolean }) {
const { branding, theme } = config;
return (
<div className="preview-frame min-h-full" style={{ background: theme.backgroundColor, color: theme.textColor, fontFamily: theme.fontFamily, borderRadius: compact ? 18 : 0 }}>
<header className="flex items-center justify-between border-b border-black/5 bg-white/85 px-6 py-4 backdrop-blur">
<div className="flex items-center gap-3">
{branding.logoUrl ? <img src={branding.logoUrl} alt="" className="h-10 w-10 rounded-xl object-cover" /> : <div className="grid h-10 w-10 place-items-center rounded-xl text-white" style={{ background: theme.primaryColor }}><GraduationCap size={22} /></div>}
<div><strong>{branding.shortName || branding.brandName}</strong><p className="text-[10px] opacity-50"></p></div>
</div>
<nav className="preview-navigation flex gap-6 text-sm font-medium">{config.navigation.filter(item => item.visible).map(item => <span key={item.id}>{item.label}</span>)}</nav>
<button className="rounded-full px-4 py-2 text-xs font-semibold text-white" style={{ background: theme.primaryColor }}></button>
</header>
<main className={compact ? 'p-4' : 'mx-auto max-w-6xl px-5 pb-16'}>
{config.homeModules.filter(item => item.visible && item.type === 'hero').map(module => (
<section key={module.id} className="preview-hero my-6 grid grid-cols-[1.25fr_.75fr] items-center gap-8 overflow-hidden p-10 text-white" style={{ borderRadius: theme.radius + 12, background: `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})` }}>
<div><span className="rounded-full bg-white/15 px-3 py-1 text-xs"></span><h1 className="mt-5 text-4xl font-black leading-tight">{branding.brandName}<br />{branding.slogan}</h1><p className="mt-4 max-w-xl text-sm text-white/75"></p><button className="mt-6 rounded-full bg-white px-5 py-3 text-sm font-bold" style={{ color: theme.primaryColor }}></button></div>
<div className="grid aspect-square place-items-center rounded-[32%] bg-white/12"><GraduationCap size={compact ? 64 : 110} strokeWidth={1.2} /></div>
</section>
))}
<section className="preview-modules grid grid-cols-3 gap-4">{config.homeModules.filter(item => item.visible && item.type !== 'hero').map(module => <ModuleCard key={module.id} module={module} primary={theme.primaryColor} />)}</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Button, Result, Spin } from 'antd';
export function FullScreenLoading({ label = '正在加载租户站点…' }: { label?: string }) {
return <div className="min-h-screen grid place-items-center bg-slate-50"><div className="text-center"><Spin size="large" /><p className="mt-4 text-slate-500">{label}</p></div></div>;
}
export function NotFoundPage() {
return <div className="min-h-screen grid place-items-center bg-slate-50"><Result status="404" title="页面不存在" subTitle="你访问的页面不存在或暂时不可用。" extra={<Button href="/"></Button>} /></div>;
}
export function RuntimeErrorPage({ retry }: { retry: () => void }) {
return <div className="min-h-screen grid place-items-center bg-slate-50"><Result status="error" title="站点暂时无法加载" subTitle="请检查网络后重试。" extra={<Button type="primary" onClick={retry}></Button>} /></div>;
}

11
src/shared/access.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { BackofficeBootstrap } from '../contracts';
export const SITE_SETTINGS_PERMISSION = 'tenant:settings:manage';
export function hasBackofficeAccess(bootstrap: BackofficeBootstrap | null): boolean {
return Boolean(bootstrap?.permissions.length);
}
export function canManageSite(bootstrap: BackofficeBootstrap | null): boolean {
return Boolean(bootstrap?.permissions.includes(SITE_SETTINGS_PERMISSION));
}

51
src/shared/defaults.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { TenantFrontendConfigDraft } from '../contracts';
export const templateNames = {
clarity: '清朗学习',
energy: '活力成长',
academy: '学院经典',
} as const;
export const templateThemes = {
clarity: { primaryColor: '#3157d5', secondaryColor: '#20b486', backgroundColor: '#f5f7fb', textColor: '#172033' },
energy: { primaryColor: '#f05a28', secondaryColor: '#ffb020', backgroundColor: '#fff8f2', textColor: '#29211d' },
academy: { primaryColor: '#176b55', secondaryColor: '#bf8b30', backgroundColor: '#f6f8f4', textColor: '#17251f' },
} as const;
export function createDefaultConfig(): TenantFrontendConfigDraft {
return {
branding: {
brandName: '启知教育',
shortName: '启知',
slogan: '让每一次学习都有方向',
logoUrl: '',
faviconUrl: '',
serviceWechat: 'qizhi-service',
},
theme: {
...templateThemes.clarity,
fontFamily: 'system-ui, sans-serif',
radius: 18,
},
features: { template: 'clarity' },
navigation: [
{ id: 'home', label: '首页', href: '/', visible: true },
{ id: 'subjects', label: '学习中心', href: '/subjects', visible: true },
{ id: 'scoreline', label: '院校分数线', href: '/scoreline', visible: true },
{ id: 'profile', label: '个人中心', href: '/profile', visible: true },
],
homeModules: [
{ id: 'hero', type: 'hero', title: '首页主视觉', visible: true },
{ id: 'notice', type: 'announcement', title: '最新公告', visible: true },
{ id: 'features', type: 'feature-grid', title: '学习服务', visible: true },
{ id: 'subjects', type: 'subject-entry', title: '热门学科', visible: true },
{ id: 'scoreline', type: 'scoreline-entry', title: '院校分数线', visible: true },
{ id: 'store', type: 'store-entry', title: '精选课程', visible: true },
{ id: 'contact', type: 'contact-cta', title: '学习顾问', visible: true },
],
};
}
export function cloneConfig(config: TenantFrontendConfigDraft): TenantFrontendConfigDraft {
return structuredClone(config);
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Clock3, CloudOff, LockKeyhole, Settings, ShieldCheck } from 'lucide-react';
import { backofficeApi } from '../api';
import { useRuntime } from '../app/RuntimeProvider';
import { SitePreview } from '../shared/SitePreview';
import { NotFoundPage } from '../shared/StatusPages';
function StatePage({ icon: Icon, title, detail }: { icon: typeof Clock3; title: string; detail: string }) {
const { runtime } = useRuntime();
return (
<div className="min-h-screen grid place-items-center bg-slate-950 p-5 text-white">
<div className="w-full max-w-xl rounded-[32px] border border-white/10 bg-white/5 p-9 text-center shadow-2xl backdrop-blur">
<div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-3xl" style={{ background: runtime!.theme.primaryColor }}><Icon size={30} /></div>
<p className="mb-3 text-sm font-semibold text-white/50">{runtime!.branding.brandName}</p>
<h1 className="text-3xl font-black">{title}</h1>
<p className="mx-auto mt-4 max-w-md leading-7 text-white/60">{detail}</p>
<div className="mt-8 flex items-center justify-center gap-2 text-xs text-white/35"><ShieldCheck size={15} /></div>
</div>
</div>
);
}
export default function StudentShell() {
const { runtime } = useRuntime();
const [canManage, setCanManage] = useState(false);
useEffect(() => {
backofficeApi.bootstrap().then(value => setCanManage(value.permissions.length > 0)).catch(() => setCanManage(false));
}, []);
if (!runtime) return <NotFoundPage />;
if (runtime.siteState === 'domain_pending') return <StatePage icon={CloudOff} title="域名正在接入" detail="域名验证与安全证书正在配置,请稍后再来。" />;
if (runtime.siteState === 'setup_required') return <StatePage icon={LockKeyhole} title="站点正在初始化" detail="管理员正在完成站点配置。请通过平台发送的一次性激活链接进入建站流程。" />;
if (runtime.siteState === 'ready_to_launch') return <StatePage icon={Clock3} title="站点即将开放" detail="页面配置已经完成,管理员确认发布后即可开始使用。" />;
if (runtime.siteState === 'suspended') return <StatePage icon={CloudOff} title="站点暂时停用" detail="如需帮助,请联系所属机构或平台服务人员。" />;
return (
<div className="min-h-screen">
<SitePreview config={runtime} />
{canManage && <Link to="/manage" className="fixed bottom-5 right-5 flex items-center gap-2 rounded-full bg-slate-950 px-5 py-3 text-sm font-semibold text-white shadow-xl"><Settings size={17} /></Link>}
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { Alert, Button, Card, Form, Input, Typography } from "antd";
import { KeyRound } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { ApiError, authApi } from "../api";
import { useRuntime } from "../app/RuntimeProvider";
interface FormValues {
password: string;
confirm: string;
}
function readAndClearToken(activationId: string): string {
const storageKey = `tiku-activation:${activationId}`;
const fragment = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const fromUrl = fragment.get("token");
if (fromUrl) sessionStorage.setItem(storageKey, fromUrl);
if (window.location.hash)
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
return fromUrl || sessionStorage.getItem(storageKey) || "";
}
const errorMessages: Record<string, string> = {
activation_invalid: "建站密钥无效,请联系平台重新获取。",
activation_expired: "建站密钥已经过期,请联系平台重新签发。",
activation_consumed: "建站密钥已经使用,不能再次激活。",
activation_password_invalid: "密码至少 8 位,并同时包含字母和数字。",
};
export default function ActivationPage() {
const { activationId = "" } = useParams();
const { runtime } = useRuntime();
const navigate = useNavigate();
const [token, setToken] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
setToken(readAndClearToken(activationId));
}, [activationId]);
const submit = async (values: FormValues) => {
if (!token) {
setError("链接中没有可用的建站密钥,请联系平台重新获取。");
return;
}
setSubmitting(true);
setError("");
try {
await authApi.completeOwnerActivation({
activationId,
token,
newPassword: values.password,
});
sessionStorage.removeItem(`tiku-activation:${activationId}`);
navigate("/manage/onboarding", { replace: true });
} catch (reason) {
const code = reason instanceof ApiError ? reason.code : "";
setError(errorMessages[code] ?? "激活失败,请联系平台服务人员。");
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen grid place-items-center bg-slate-950 p-5">
<Card
className="w-full max-w-md shadow-2xl"
styles={{ body: { padding: 32 } }}
>
<div className="mb-7 flex items-center gap-4">
<div
className="grid h-12 w-12 place-items-center rounded-2xl text-white"
style={{ background: runtime?.theme.primaryColor }}
>
<KeyRound size={24} />
</div>
<div>
<Typography.Text type="secondary">
{runtime?.tenantName}
</Typography.Text>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
</div>
</div>
<Typography.Paragraph type="secondary">
使
</Typography.Paragraph>
{!token && (
<Alert
className="mb-5"
type="warning"
showIcon
title="缺少建站密钥"
/>
)}
{error && (
<Alert className="mb-5" type="error" showIcon title={error} />
)}
<Form layout="vertical" onFinish={submit} requiredMark={false}>
<Form.Item
name="password"
label="管理员密码"
rules={[
{ required: true },
{ min: 8 },
{
pattern: /^(?=.*[A-Za-z])(?=.*\d).+$/,
message: "必须同时包含字母和数字",
},
]}
>
<Input.Password autoComplete="new-password" size="large" />
</Form.Item>
<Form.Item
name="confirm"
label="确认密码"
dependencies={["password"]}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator: (_, value) =>
value === getFieldValue("password")
? Promise.resolve()
: Promise.reject(new Error("两次密码不一致")),
}),
]}
>
<Input.Password autoComplete="new-password" size="large" />
</Form.Item>
<Button
block
size="large"
type="primary"
htmlType="submit"
loading={submitting}
>
</Button>
</Form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import { Alert, Button, Card, Form, Input, Typography } from "antd";
import { Building2 } from "lucide-react";
import { useLocation, useNavigate } from "react-router";
import { authApi } from "../api";
import { useRuntime } from "../app/RuntimeProvider";
export default function TenantLoginPage() {
const { runtime } = useRuntime();
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (values: { identifier: string; password: string }) => {
setLoading(true);
setError("");
try {
await authApi.login(values);
const from = (location.state as { from?: string } | null)?.from;
navigate(from?.startsWith("/manage") ? from : "/manage", {
replace: true,
});
} catch {
setError("账号、密码错误,或该账号没有后台访问权限。");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen grid place-items-center bg-slate-100 p-5">
<Card className="w-full max-w-md" styles={{ body: { padding: 34 } }}>
<div className="mb-7 flex items-center gap-4">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-slate-950 text-white">
<Building2 />
</div>
<div>
<Typography.Text type="secondary">
{runtime?.branding.brandName}
</Typography.Text>
<Typography.Title level={3} style={{ margin: 0 }}>
</Typography.Title>
</div>
</div>
{error && (
<Alert className="mb-5" type="error" showIcon title={error} />
)}
<Form layout="vertical" requiredMark={false} onFinish={submit}>
<Form.Item
name="identifier"
label="管理员账号"
rules={[{ required: true }]}
>
<Input size="large" autoComplete="username" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password size="large" autoComplete="current-password" />
</Form.Item>
<Button
block
type="primary"
size="large"
htmlType="submit"
loading={loading}
>
</Button>
</Form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { AppstoreOutlined, EyeOutlined, HomeOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Layout, Menu, Space, Tag, Typography } from 'antd';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router';
import { authApi } from '../api';
import { useBackoffice } from '../app/BackofficeContext';
import { useRuntime } from '../app/RuntimeProvider';
import { canManageSite } from '../shared/access';
import { NotFoundPage } from '../shared/StatusPages';
import DashboardPage from './pages/DashboardPage';
import SiteBuilderPage from './pages/SiteBuilderPage';
export default function TenantShell() {
const bootstrap = useBackoffice();
const { runtime } = useRuntime();
const location = useLocation();
const navigate = useNavigate();
const menuItems = useMemo(() => bootstrap.menus.map(item => ({
key: item.path,
icon: item.code === 'site' ? <SettingOutlined /> : <AppstoreOutlined />,
label: item.name,
})), [bootstrap.menus]);
const logout = async () => { await authApi.logout(); navigate('/', { replace: true }); window.location.reload(); };
return (
<div className="tenant-admin-shell">
<Layout>
<Layout.Sider width={238} theme="light" breakpoint="lg" collapsedWidth="0">
<div className="flex h-20 items-center gap-3 px-5"><div className="grid h-10 w-10 place-items-center rounded-xl text-white" style={{ background: runtime?.theme.primaryColor }}><HomeOutlined /></div><div><strong>{runtime?.branding.shortName}</strong><p className="m-0 text-xs text-slate-400"></p></div></div>
<Menu mode="inline" selectedKeys={[location.pathname]} items={menuItems} onClick={({ key }) => navigate(key)} />
</Layout.Sider>
<Layout>
<Layout.Header className="flex items-center justify-between border-b border-slate-100 bg-white px-6">
<Space><Typography.Text strong>{runtime?.tenantName}</Typography.Text><Tag color="blue">{runtime?.siteState}</Tag></Space>
<Space><Button icon={<EyeOutlined />} onClick={() => window.open('/', '_blank')}></Button><Button icon={<LogoutOutlined />} onClick={() => void logout()}>退</Button></Space>
</Layout.Header>
<Layout.Content className="p-4 md:p-7">
<Routes>
<Route index element={<DashboardPage />} />
<Route path="onboarding" element={canManageSite(bootstrap) ? <SiteBuilderPage onboarding /> : <NotFoundPage />} />
<Route path="site" element={canManageSite(bootstrap) ? <SiteBuilderPage /> : <NotFoundPage />} />
<Route path="login" element={<Navigate to="/manage" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import {
Button,
Card,
Col,
Progress,
Row,
Space,
Statistic,
Tag,
Typography,
} from "antd";
import { useNavigate } from "react-router";
import type { TenantOnboardingStatus } from "../../contracts";
import { onboardingApi } from "../../api";
import { useBackoffice } from "../../app/BackofficeContext";
import { useRuntime } from "../../app/RuntimeProvider";
import { canManageSite } from "../../shared/access";
export default function DashboardPage() {
const bootstrap = useBackoffice();
const { runtime } = useRuntime();
const navigate = useNavigate();
const [status, setStatus] = useState<TenantOnboardingStatus | null>(null);
useEffect(() => {
onboardingApi
.status()
.then(setStatus)
.catch(() => setStatus(null));
}, []);
const percent = status
? Math.round((status.completedRequiredSteps / status.requiredSteps) * 100)
: 0;
return (
<div className="mx-auto max-w-6xl">
<Space orientation="vertical" size={4}>
<Typography.Title level={2} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Row gutter={[18, 18]} className="mt-7">
<Col xs={24} md={8}>
<Card>
<Statistic
title="配置版本"
value={runtime?.configVersion ?? 1}
prefix="V"
/>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic
title="站点状态"
value={runtime?.siteState === "active" ? "已上线" : "配置中"}
/>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic
title="已启用能力"
value={runtime?.enabledFeatures.length ?? 0}
/>
</Card>
</Col>
</Row>
<Card
className="mt-5"
title="建站进度"
extra={
<Tag
color={status?.readyForStudentTraffic ? "success" : "processing"}
>
{status?.readyForStudentTraffic ? "可以接待学生" : "继续配置"}
</Tag>
}
>
<Progress percent={percent} />
<div className="mt-5 grid gap-3 md:grid-cols-2">
{status?.steps.map((step) => (
<div
key={step.code}
className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3"
>
<span>{step.code.replaceAll("_", " ")}</span>
<Tag color={step.completed ? "success" : "default"}>
{step.completed ? "完成" : "待处理"}
</Tag>
</div>
))}
</div>
{canManageSite(bootstrap) && (
<Button
className="mt-6"
type="primary"
onClick={() => navigate("/manage/site")}
>
</Button>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,583 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Alert,
Button,
Card,
Col,
Divider,
Form,
Input,
InputNumber,
Radio,
Result,
Row,
Segmented,
Space,
Spin,
Steps,
Switch,
Tag,
Typography,
} from "antd";
import {
HolderOutlined,
MobileOutlined,
SaveOutlined,
SendOutlined,
} from "@ant-design/icons";
import type {
HomeModule,
TenantFrontendConfigDraft,
TenantSiteConfig,
} from "../../contracts";
import { ApiError, siteConfigApi } from "../../api";
import { useRuntime } from "../../app/RuntimeProvider";
import {
cloneConfig,
templateNames,
templateThemes,
} from "../../shared/defaults";
import { SitePreview } from "../../shared/SitePreview";
const stepItems = [
{ title: "品牌信息" },
{ title: "选择模板" },
{ title: "主题样式" },
{ title: "页面模块" },
{ title: "实时预览" },
{ title: "发布上线" },
];
function SortableModule({
module,
onToggle,
}: {
module: HomeModule;
onToggle: (id: string, visible: boolean) => void;
}) {
const sortable = useSortable({ id: module.id });
return (
<div
ref={sortable.setNodeRef}
style={{
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
}}
className="mb-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
>
<button
type="button"
className="cursor-grab border-0 bg-transparent text-slate-400"
{...sortable.attributes}
{...sortable.listeners}
>
<HolderOutlined />
</button>
<div className="flex-1">
<strong>{module.title}</strong>
<p className="m-0 text-xs text-slate-400">{module.type}</p>
</div>
<Switch
checked={module.visible}
onChange={(checked) => onToggle(module.id, checked)}
/>
</div>
);
}
export default function SiteBuilderPage({
onboarding = false,
}: {
onboarding?: boolean;
}) {
const { refresh } = useRuntime();
const [siteConfig, setSiteConfig] = useState<TenantSiteConfig | null>(null);
const [draft, setDraft] = useState<TenantFrontendConfigDraft | null>(null);
const [step, setStep] = useState(0);
const [saveState, setSaveState] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const [previewMode, setPreviewMode] = useState<"desktop" | "mobile">(
"desktop",
);
const [published, setPublished] = useState(false);
const [error, setError] = useState("");
const dirtyRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
useEffect(() => {
siteConfigApi
.get()
.then((value) => {
setSiteConfig(value);
setDraft(cloneConfig(value.draft));
})
.catch(() => setError("无法读取站点配置,请重新登录后再试。"));
}, []);
useEffect(() => {
if (!draft || !dirtyRef.current) return;
setSaveState("saving");
const timer = window.setTimeout(() => {
siteConfigApi
.saveDraft(draft)
.then((value) => {
setSiteConfig(value);
setSaveState("saved");
dirtyRef.current = false;
})
.catch(() => setSaveState("error"));
}, 700);
return () => window.clearTimeout(timer);
}, [draft]);
const update = useCallback(
(mutator: (next: TenantFrontendConfigDraft) => void) => {
setDraft((current) => {
if (!current) return current;
const next = cloneConfig(current);
mutator(next);
dirtyRef.current = true;
return next;
});
},
[],
);
const chooseTemplate = (template: keyof typeof templateNames) =>
update((next) => {
next.features.template = template;
Object.assign(next.theme, templateThemes[template]);
});
const handleLogo = (file?: File) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
update((next) => {
next.branding.logoUrl = String(reader.result ?? "");
});
reader.readAsDataURL(file);
};
const dragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
update((next) => {
const from = next.homeModules.findIndex((item) => item.id === active.id);
const to = next.homeModules.findIndex((item) => item.id === over.id);
next.homeModules = arrayMove(next.homeModules, from, to);
});
};
const publish = async () => {
if (!draft || !siteConfig) return;
setError("");
try {
const saved = await siteConfigApi.saveDraft(draft);
const next = await siteConfigApi.publish(saved.configVersion);
setSiteConfig(next);
setPublished(true);
await refresh();
} catch (reason) {
if (
reason instanceof ApiError &&
reason.code === "frontend_config_version_conflict"
)
setError("配置已经被其他管理员修改,请刷新页面后重新发布。");
else setError("发布失败,草稿已经保留,请稍后重试。");
}
};
if (error && !draft)
return <Result status="error" title="无法打开站点设计" subTitle={error} />;
if (!draft || !siteConfig)
return (
<div className="grid min-h-[60vh] place-items-center">
<Spin size="large" />
</div>
);
const content = (() => {
if (step === 0)
return (
<Card title="设置机构品牌" className="mx-auto max-w-3xl">
<Form layout="vertical">
<Row gutter={18}>
<Col span={12}>
<Form.Item label="品牌全称">
<Input
value={draft.branding.brandName}
onChange={(event) =>
update((next) => {
next.branding.brandName = event.target.value;
})
}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="品牌简称">
<Input
value={draft.branding.shortName}
onChange={(event) =>
update((next) => {
next.branding.shortName = event.target.value;
})
}
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="品牌口号">
<Input
value={draft.branding.slogan}
onChange={(event) =>
update((next) => {
next.branding.slogan = event.target.value;
})
}
/>
</Form.Item>
<Form.Item label="客服微信">
<Input
value={draft.branding.serviceWechat}
onChange={(event) =>
update((next) => {
next.branding.serviceWechat = event.target.value;
})
}
/>
</Form.Item>
<Form.Item label="Logo">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleLogo(event.target.files?.[0])}
/>
{draft.branding.logoUrl && (
<img
className="mt-3 h-16 w-16 rounded-xl object-cover"
src={draft.branding.logoUrl}
alt="Logo 预览"
/>
)}
</Form.Item>
</Form>
</Card>
);
if (step === 1)
return (
<div className="mx-auto max-w-5xl">
<Typography.Title level={3}></Typography.Title>
<Row gutter={[18, 18]}>
{(
Object.keys(templateNames) as Array<keyof typeof templateNames>
).map((template) => (
<Col xs={24} md={8} key={template}>
<Card
hoverable
onClick={() => chooseTemplate(template)}
className={
draft.features.template === template
? "border-2 border-blue-500"
: ""
}
>
<div
className="mb-5 h-32 rounded-2xl"
style={{
background: `linear-gradient(135deg, ${templateThemes[template].primaryColor}, ${templateThemes[template].secondaryColor})`,
}}
/>
<Space>
<Typography.Title level={4} style={{ margin: 0 }}>
{templateNames[template]}
</Typography.Title>
{draft.features.template === template && (
<Tag color="blue"></Tag>
)}
</Space>
<Typography.Paragraph type="secondary" className="mt-3">
</Typography.Paragraph>
</Card>
</Col>
))}
</Row>
</div>
);
if (step === 2)
return (
<Card title="调整主题样式" className="mx-auto max-w-3xl">
<Row gutter={[24, 20]}>
{(
[
["primaryColor", "主色"],
["secondaryColor", "辅助色"],
["backgroundColor", "背景色"],
["textColor", "文字色"],
] as const
).map(([field, label]) => (
<Col span={12} key={field}>
<label className="mb-2 block text-sm text-slate-500">
{label}
</label>
<Space>
<input
className="h-10 w-14 cursor-pointer rounded border-0"
type="color"
value={draft.theme[field]}
onChange={(event) =>
update((next) => {
next.theme[field] = event.target.value;
})
}
/>
<Input
value={draft.theme[field]}
onChange={(event) =>
update((next) => {
next.theme[field] = event.target.value;
})
}
/>
</Space>
</Col>
))}
</Row>
<Divider />
<Form layout="vertical">
<Form.Item label="界面字体">
<Radio.Group
value={draft.theme.fontFamily}
onChange={(event) =>
update((next) => {
next.theme.fontFamily = event.target.value;
})
}
>
<Radio.Button value="system-ui, sans-serif">
线
</Radio.Button>
<Radio.Button value="Georgia, serif">线</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="圆角大小">
<Space.Compact>
<InputNumber
min={0}
max={32}
value={draft.theme.radius}
onChange={(value) =>
update((next) => {
next.theme.radius = value ?? 0;
})
}
/>
<span className="inline-flex items-center border border-l-0 border-slate-300 bg-slate-50 px-3 text-slate-500">
px
</span>
</Space.Compact>
</Form.Item>
</Form>
</Card>
);
if (step === 3)
return (
<Row gutter={[20, 20]}>
<Col xs={24} lg={10}>
<Card title="导航菜单">
{draft.navigation.map((item) => (
<div
key={item.id}
className="mb-3 flex items-center gap-3 rounded-xl bg-slate-50 p-3"
>
<Input
value={item.label}
onChange={(event) =>
update((next) => {
next.navigation.find(
(value) => value.id === item.id,
)!.label = event.target.value;
})
}
/>
<Switch
checked={item.visible}
onChange={(checked) =>
update((next) => {
next.navigation.find(
(value) => value.id === item.id,
)!.visible = checked;
})
}
/>
</div>
))}
</Card>
</Col>
<Col xs={24} lg={14}>
<Card title="首页模块" extra="拖动调整顺序">
<DndContext sensors={sensors} onDragEnd={dragEnd}>
<SortableContext
items={draft.homeModules.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
{draft.homeModules.map((module) => (
<SortableModule
key={module.id}
module={module}
onToggle={(id, visible) =>
update((next) => {
next.homeModules.find(
(item) => item.id === id,
)!.visible = visible;
})
}
/>
))}
</SortableContext>
</DndContext>
</Card>
</Col>
</Row>
);
if (step === 4)
return (
<div>
<div className="mb-4 flex justify-center">
<Segmented
value={previewMode}
onChange={(value) =>
setPreviewMode(value as "desktop" | "mobile")
}
options={[
{ label: "桌面端", value: "desktop" },
{ label: "移动端", value: "mobile", icon: <MobileOutlined /> },
]}
/>
</div>
<div
className={`mx-auto overflow-hidden border-[10px] border-slate-900 bg-white shadow-2xl transition-all ${previewMode === "mobile" ? "h-[720px] max-w-[390px] rounded-[38px]" : "min-h-[680px] max-w-6xl rounded-[24px]"}`}
>
<SitePreview config={draft} compact />
</div>
</div>
);
return published ? (
<Result
status="success"
title="站点已经发布上线"
subTitle="学生端已切换到最新配置,你可以随时返回继续调整。"
extra={[
<Button
key="site"
type="primary"
onClick={() => window.open("/", "_blank")}
>
</Button>,
<Button
key="edit"
onClick={() => {
setPublished(false);
setStep(0);
}}
>
</Button>,
]}
/>
) : (
<Card className="mx-auto max-w-2xl text-center">
<SendOutlined className="text-5xl text-blue-500" />
<Typography.Title level={2} className="mt-5">
</Typography.Title>
<Typography.Paragraph type="secondary">
使稿
</Typography.Paragraph>
{error && (
<Alert
className="my-5 text-left"
type="error"
showIcon
title={error}
/>
)}
<Button
size="large"
type="primary"
icon={<SendOutlined />}
onClick={() => void publish()}
>
线
</Button>
</Card>
);
})();
return (
<div className="mx-auto max-w-7xl">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<Typography.Title level={2} style={{ margin: 0 }}>
{onboarding ? "欢迎,开始创建你的站点" : "站点设计"}
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Tag
icon={<SaveOutlined />}
color={
saveState === "error"
? "error"
: saveState === "saving"
? "processing"
: "success"
}
>
{saveState === "saving"
? "正在保存"
: saveState === "error"
? "保存失败"
: "草稿已保存"}
</Tag>
</div>
<Card className="mb-6">
<Steps current={step} items={stepItems} responsive />
</Card>
{content}
<div className="mt-6 flex justify-between">
<Button
disabled={step === 0 || published}
onClick={() => setStep((value) => value - 1)}
>
</Button>
{step < 5 && (
<Button type="primary" onClick={() => setStep((value) => value + 1)}>
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { SitePreview } from '../shared/SitePreview';
import { createDefaultConfig } from '../shared/defaults';
describe('SitePreview', () => {
it('renders published brand and visible modules only', () => {
const config = createDefaultConfig();
config.branding.brandName = '远航教育';
config.homeModules.find(item => item.type === 'store-entry')!.visible = false;
render(<SitePreview config={config} />);
expect(screen.getByText('远航教育', { exact: false })).toBeInTheDocument();
expect(screen.queryByText('精选课程')).not.toBeInTheDocument();
});
});

23
src/tests/access.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { BackofficeBootstrap } from '../contracts';
import { canManageSite, hasBackofficeAccess } from '../shared/access';
function bootstrap(permissions: string[]): BackofficeBootstrap {
return { user: { userId: '1', displayName: '用户', identifierMasked: '***' }, permissions, menus: [], enabledFeatures: [] };
}
describe('backoffice access', () => {
it('denies students without backoffice permissions', () => {
expect(hasBackofficeAccess(bootstrap([]))).toBe(false);
expect(canManageSite(bootstrap([]))).toBe(false);
});
it('allows staff into backoffice without granting site settings', () => {
expect(hasBackofficeAccess(bootstrap(['tenant:dashboard:view']))).toBe(true);
expect(canManageSite(bootstrap(['tenant:dashboard:view']))).toBe(false);
});
it('requires tenant settings permission for the builder', () => {
expect(canManageSite(bootstrap(['tenant:settings:manage']))).toBe(true);
});
});

20
src/tests/setup.ts Normal file
View File

@@ -0,0 +1,20 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length() { return this.values.size; }
clear() { this.values.clear(); }
getItem(key: string) { return this.values.get(key) ?? null; }
key(index: number) { return [...this.values.keys()][index] ?? null; }
removeItem(key: string) { this.values.delete(key); }
setItem(key: string, value: string) { this.values.set(key, value); }
}
Object.defineProperty(window, 'localStorage', { configurable: true, value: new MemoryStorage() });
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: new MemoryStorage() });
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});

29
src/tests/store.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { activateOwner, backofficeOf, createDemoTenant, DemoStoreError, ensureTenant, runtimeOf, saveTenant } from '../mocks/store';
describe('demo tenant lifecycle', () => {
it('isolates tenant state by host', () => {
const first = createDemoTenant('first.localhost');
const second = createDemoTenant('second.localhost');
first.config.draft.branding.brandName = '第一学校';
second.config.draft.branding.brandName = '第二学校';
saveTenant(first);
saveTenant(second);
expect(ensureTenant('first.localhost').config.draft.branding.brandName).toBe('第一学校');
expect(ensureTenant('second.localhost').config.draft.branding.brandName).toBe('第二学校');
});
it('consumes an activation token once and establishes an owner session', () => {
const tenant = createDemoTenant('activate.localhost');
saveTenant(tenant);
activateOwner(tenant.host, { activationId: tenant.activation.activationId, token: tenant.activation.token, newPassword: 'Strongpass1' });
expect(backofficeOf(ensureTenant(tenant.host)).permissions).toContain('tenant:settings:manage');
expect(() => activateOwner(tenant.host, { activationId: tenant.activation.activationId, token: tenant.activation.token, newPassword: 'Strongpass1' })).toThrowError(DemoStoreError);
});
it('keeps drafts out of the public runtime', () => {
const tenant = createDemoTenant('draft.localhost');
tenant.config.draft.branding.brandName = '草稿名称';
expect(runtimeOf(tenant).branding.brandName).not.toBe('草稿名称');
});
});

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DATA_MODE?: 'mock' | 'api';
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

34
vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:5090';
return {
plugins: [react(), tailwindcss()],
publicDir: mode === 'development' ? 'public' : false,
server: {
host: true,
port: 5180,
proxy: env.VITE_DATA_MODE === 'api'
? { '/api': { target: apiTarget, changeOrigin: true } }
: undefined,
},
build: {
target: 'es2022',
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/antd') || id.includes('node_modules/@ant-design')) return 'tenant-admin';
if (id.includes('node_modules/react') || id.includes('node_modules/react-router')) return 'react';
if (id.includes('node_modules/@dnd-kit')) return 'site-builder';
return undefined;
},
},
},
},
};
});

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
environmentOptions: {
jsdom: { url: 'http://localhost/' },
},
setupFiles: ['./src/tests/setup.ts'],
restoreMocks: true,
},
});