feat: scaffold tenant site builder demo
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_DATA_MODE=mock
|
||||||
|
VITE_API_BASE_URL=
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
62
README.md
Normal file
62
README.md
Normal 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
14
index.html
Normal 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
4587
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal 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
361
public/mockServiceWorker.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
20
scripts/check-production-bundle.mjs
Normal file
20
scripts/check-production-bundle.mjs
Normal 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
15
src/api/authApi.ts
Normal 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
6
src/api/backofficeApi.ts
Normal 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
41
src/api/http.ts
Normal 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
6
src/api/index.ts
Normal 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
6
src/api/onboardingApi.ts
Normal 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
6
src/api/runtimeApi.ts
Normal 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
16
src/api/siteConfigApi.ts
Normal 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
114
src/app/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/BackofficeContext.tsx
Normal file
14
src/app/BackofficeContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
44
src/app/RuntimeProvider.tsx
Normal file
44
src/app/RuntimeProvider.tsx
Normal 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
45
src/contracts/auth.ts
Normal 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
2
src/contracts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './auth';
|
||||||
|
export * from './site';
|
||||||
72
src/contracts/site.ts
Normal file
72
src/contracts/site.ts
Normal 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
265
src/demo/DemoLauncher.tsx
Normal 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
28
src/index.css
Normal 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
20
src/main.tsx
Normal 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
4
src/mocks/browser.ts
Normal 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
121
src/mocks/handlers.ts
Normal 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
4
src/mocks/server.ts
Normal 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
191
src/mocks/store.ts
Normal 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));
|
||||||
|
}
|
||||||
45
src/shared/SitePreview.tsx
Normal file
45
src/shared/SitePreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/shared/StatusPages.tsx
Normal file
13
src/shared/StatusPages.tsx
Normal 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
11
src/shared/access.ts
Normal 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
51
src/shared/defaults.ts
Normal 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);
|
||||||
|
}
|
||||||
44
src/student/StudentShell.tsx
Normal file
44
src/student/StudentShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/tenant/ActivationPage.tsx
Normal file
149
src/tenant/ActivationPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/tenant/TenantLoginPage.tsx
Normal file
74
src/tenant/TenantLoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/tenant/TenantShell.tsx
Normal file
51
src/tenant/TenantShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/tenant/pages/DashboardPage.tsx
Normal file
108
src/tenant/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
583
src/tenant/pages/SiteBuilderPage.tsx
Normal file
583
src/tenant/pages/SiteBuilderPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/tests/SitePreview.test.tsx
Normal file
15
src/tests/SitePreview.test.tsx
Normal 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
23
src/tests/access.test.ts
Normal 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
20
src/tests/setup.ts
Normal 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
29
src/tests/store.test.ts
Normal 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
10
src/vite-env.d.ts
vendored
Normal 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
21
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal 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
34
vite.config.ts
Normal 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
14
vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user