feat: complete tenant learning and admin experience

This commit is contained in:
2026-08-04 17:58:34 +08:00
parent 002bf47231
commit 38f1bc271d
79 changed files with 36211 additions and 822 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ dist/
.env.local
.env.*.local
coverage/
.generated/
*.tsbuildinfo
*.log
.DS_Store

View File

@@ -0,0 +1,63 @@
# 旧版页面与 .NET 接口覆盖矩阵
更新日期2026-08-04。接口事实来源为 `tiku-backend.net` 当前 `/openapi/v1.json`;旧版页面只用于确认业务内容和信息层级,不复用 PocketBase、`MockBackend` 或浏览器伪造结果。
状态说明:
- `ready`:第一批已接入可用的 .NET 契约。
- `partial`:存在可复用基础契约,但缺少完整业务流程或公开发起配置。
- `missing`:当前 OpenAPI 没有满足该学生功能的契约;仅保留前端类型位置,生产入口隐藏。
- `deferred`:后端已有契约,但不在第一批核心学习闭环范围内。
## 第一批闭环
| 旧版能力 | SaaS 路由/位置 | 状态 | 当前 .NET 契约 | 前端模块 |
| --- | --- | --- | --- | --- |
| 密码/短信登录 | `/login` | ready | `/api/tenant/auth/browser/login/password``sms/send``login/sms` | `studentAuthApi` |
| 找回密码 | `/reset-password` | ready | `/api/tenant/auth/browser/password/reset/*` | `studentAuthApi` |
| 地区选择 | `/select-region` | ready | `/api/public/catalog/regions``PATCH /api/student/profile/me` | `catalogApi``profileApi` |
| 学习首页 | `/` | ready | Profile、learning stats/trend、exam countdowns、banners、announcements | `DashboardPage` |
| 科目/章节 | `/subjects``/subject/:subjectId` | ready | subjects、categories、practice-blueprints | `catalogApi` |
| 刷题与恢复 | `/quiz` | ready | practice-sessions create/detail、answers、submit | `learningApi` |
| 报告 | `/quiz/report/:sessionId` | ready | practice-sessions/report`PendingReview` | `PracticeReportPage` |
| 错题/收藏 | `/collections` | ready | wrong-questions、favorites/questions | `CollectionsPage` |
| 公开品牌首页 | `/`(未登录) | ready | `/api/public/runtime/bootstrap`、学生资产 preview | `PublicLandingPage``RuntimeProvider` |
| 主题建站/发布 | `/manage/site` | ready | frontend-config draft/publish、`configVersion` | `site-builder/*` |
| OSS 主题资产 | 建站资产库 | ready | uploads/sign → OSS PUT → uploads/confirm → student asset preview | `assetApi``useThemeAssets` |
| 词汇单元与词表 | `/vocabulary/*` | ready | public vocabulary units/words | `VocabularyPage``VocabularyUnitPage` |
| 知识手册阅读 | `/handbook/*` | ready | public handbook subjects/chapters/entries | `student/pages/Handbook*` |
| 院校分数线 | `/scoreline` | ready | public scoreline records/years | `ScorelinePage` |
| 个人中心基础能力 | `/profile` | ready | student profile | `ProfilePage` |
## 缺失与部分支持
| 旧版能力 | 目标路由 | 状态 | 缺少的后端契约 | 前端预留 |
| --- | --- | --- | --- | --- |
| 择校报告/志愿规划 | `/school-planning` | missing | 报告输入校验、生成、版本、查询和导出契约;公共 schools 目录不足以生成报告 | `SchoolPlanningClient`、feature `school-planning` |
| QQ OAuth | `/login` | missing | 浏览器授权发起、state/回调和租户公开 AppId 配置 | `QqOAuthClient`;不渲染按钮 |
| 学生自助激活码核销 | `/activation-code` | missing | 当前 activation/commerce 契约不能证明等价于旧版学生自助权益核销流程 | `StudentActivationCodeClient`、feature `activation-code` |
| 旧版 C/C++ 代码运行器 | `/code-runner` 或题目内 | missing | 沙箱执行、资源限制、语言版本、结果和审计契约 | `CodeRunnerClient`、feature `code-runner` |
| 微信网页登录 | `/login` | partial | 已有回调处理 `/api/tenant/auth/browser/oauth/wechat`,但 Runtime 缺少公开授权发起参数/URL | 仅记录 `loginMethods`,暂不显示不可完成入口 |
| 独立模拟考试/作业 | 待定 | partial | practice session 可承载答题和判分,但缺考试日程、限时、作业下发、班级分配、交卷策略和成绩册流程 | 后续在 learning 领域扩展,不伪装成普通练习 |
| 主题资产专用权限 | 建站资产库 | partial | 当前资产控制器复用 `tenant:question-bank:manage`,没有更细的 `tenant:theme-assets:manage` | 权限不足时只读保留资产,不回退 Data URL |
## 已有接口但延期接入
| 能力 | 旧版路由/入口 | 状态 | 已确认 .NET 契约族 | 计划前端领域 |
| --- | --- | --- | --- | --- |
| 词汇学习进度/收藏/复习 | `/vocabulary/*` | deferred | student vocabulary progress/review/favorites | `student/vocabulary` |
| 视频精讲 | 题目解析/视频中心 | deferred | student videos search/play/progress、question videos | `student/videos` |
| 分数线动态图表与字段筛选 | `/scoreline` | deferred | public scoreline fields/trend | `student/scoreline` |
| 个人中心完整能力 | `/profile` | deferred | score-events、membership、设置等 | `student/profile` |
| 排行榜 | 首页/个人中心 | deferred | `/api/student/learning/leaderboard` | `student/leaderboard` |
| 积分/兑换 | 个人中心/商城 | deferred | `/api/student/points/*` | `student/points` |
| 商城、订单与支付 | `/store` | deferred | student commerce orders/coupons支付能力需按实际支付渠道复核 | `student/commerce` |
| 优惠券 | 商城 | deferred | student commerce coupons claim/list/check | `student/commerce` |
| 推荐增长 | 邀请入口 | deferred | `/api/student/referral/*` | `student/referral` |
## 前端约束
- 正式路由和导航必须同时满足:前端注册项为 `ready`、Runtime `enabledFeatures` 已启用、租户发布配置允许显示。
- `missing``deferred` 能力可进入固定的建设中页面,但不得调用猜测接口或返回 Mock 成功;租户仍可在发布配置中隐藏入口。
- 不允许配置 JSON 注入任意 React 组件、路由或远程脚本。
- 缺失功能不调用猜测路径、不持久化 Mock 成功结果。后端补齐后,应先更新 OpenAPI 快照,再实现对应 Client。

View File

@@ -0,0 +1,40 @@
# 租户后台旧版能力与 .NET 覆盖矩阵
更新日期2026-08-04。旧版 `tiki-web` 和线上参考后台只用于确认功能名称、信息层级和交互密度;接口状态以当前 `tiku-backend.net` OpenAPI、`ui-bootstrap` 菜单、权限和 Runtime Feature 为准。
状态:`live` 已有真实页面;`frontend_pending` 后端能力存在但前端待接入;`backend_missing` 缺少等价后端契约;`tenant_disabled` 由运行时根据租户 Feature 动态判定。
| 一级大类 | 二级功能 | 前端路由 | 状态 | .NET 菜单/权限/Feature 或缺口 |
| --- | --- | --- | --- | --- |
| 总览 | 数据看板 | `/manage` | live | `tenant.dashboard` / `tenant:dashboard:view`;当前真实展示 Runtime 与 onboarding统一经营聚合接口缺失 |
| 总览 | 收益账本 | `/manage/revenue` | backend_missing | 缺租户级收入趋势、渠道汇总和账本聚合契约 |
| 教学内容 | 题库管理 | `/manage/question-bank` | frontend_pending | `tenant.question-bank` / `tenant:question-bank:manage` / `question_bank.private` |
| 教学内容 | 词汇管理 | `/manage/vocabulary` | frontend_pending | `tenant.vocabulary` / `tenant:vocabulary:manage` / `content.vocabulary` |
| 教学内容 | 知识手册 | `/manage/handbook` | frontend_pending | `tenant.handbook` / `tenant:handbook:manage` / `content.handbook` |
| 教学内容 | 视频精讲 | `/manage/video` | frontend_pending | `tenant.video` / `tenant:video:manage` / `content.video` |
| 教学内容 | 分数线配置 | `/manage/scoreline` | frontend_pending | `tenant.scoreline` / `tenant:scoreline:manage` / `content.scoreline` |
| 教学内容 | 模拟考试 | `/manage/teaching/mock-exams` | backend_missing | 缺独立考试日程、试卷发布、监考策略和成绩册契约 |
| 教学内容 | 智能组卷、PDF 拆章、每日一练、题目导出 | `/manage/teaching/*` | backend_missing | 缺租户端任务、排期、生成物和导出契约 |
| 学员运营 | 学员与班级 | `/manage/students` | frontend_pending | `tenant.students` / `tenant:student:manage` / `student.management` |
| 学员运营 | 学习记录 | `/manage/students/learning-history` | backend_missing | 学生侧记录已有,缺租户管理端跨学员聚合查询 |
| 学员运营 | 反馈管理 | `/manage/students/feedback` | frontend_pending | Tenant Admin Direct feedback 契约 / `tenant:student:manage` |
| 学员运营 | CRM 跟进 | `/manage/students/crm` | frontend_pending | `tenant:crm:manage` / `crm.followup`;当前 `ui-bootstrap` 尚无独立租户 CRM 菜单 |
| 营销交易 | 商品套餐、订单、优惠券 | `/manage/commerce/*` | frontend_pending | `tenant.commerce` / `tenant:commerce:operate` / `commerce.student_store` |
| 营销交易 | 激活码 | `/manage/commerce/activation-codes` | backend_missing | 缺学生自助核销与租户端完整激活码管理契约 |
| 营销交易 | 代理推广 | `/manage/commerce/referrals` | frontend_pending | `tenant:commission:manage` / `growth.referral_commission`;当前 `ui-bootstrap` 尚无独立佣金菜单 |
| 营销交易 | 销售追踪 | `/manage/commerce/sales` | backend_missing | 缺渠道归因、销售漏斗和人员业绩聚合契约 |
| 站点装修 | 站点与主题 | `/manage/site/:tab` | live | frontend-config draft/publish、资产 sign/confirm/preview上传暂复用 `tenant:question-bank:manage` |
| 站点装修 | 运营内容 | `/manage/site-content` | frontend_pending | `tenant.site-content` / `tenant:site-content:manage` / `marketing.site_content` |
| 系统设置 | 地区管理 | `/manage/settings/regions` | backend_missing | 公共地区目录可读,缺租户自主管理地区范围契约 |
| 系统设置 | 员工与权限 | `/manage/staff` | frontend_pending | `tenant.staff` / `tenant:staff:manage` |
| 系统设置 | 基础设置 | `/manage/settings/base` | frontend_pending | `tenant:settings:manage` 与 Tenant Admin Direct 品牌/域名等契约 |
| 系统设置 | 登录方式、外部服务、短信 | `/manage/settings/*``/manage/providers` | frontend_pending | `tenant.providers` / `tenant:provider:manage` |
| 系统设置 | SaaS 账务 | `/manage/billing` | frontend_pending | `tenant.billing` / `tenant:billing:manage` |
| 系统设置 | 审计日志 | `/manage/settings/audit` | frontend_pending | Tenant Admin Direct audit 契约;前端按 `tenant:settings:manage` 暂时归组 |
## 前端行为约束
- `ui-bootstrap` 返回的已知菜单只映射到固定注册路由;未知菜单代码忽略并在开发环境告警。
- 具备权限但套餐 Feature 未启用时显示 `tenant_disabled`,不发送业务请求。
- `frontend_pending``backend_missing` 路由只显示统一占位页,不返回 Mock 成功结果。
- Mock 模式模拟 Owner 完整权限、标准菜单和天津演示数据API 模式不会因为接口为空或失败回退 Mock。

2144
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"generate:api": "node scripts/generate-api.mjs",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
@@ -17,22 +18,32 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@tanstack/react-query": "^5.101.4",
"antd": "6.5.3",
"katex": "^0.18.1",
"lucide-react": "0.468.0",
"openapi-fetch": "^0.17.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router": "8.3.0"
"react-markdown": "^10.1.0",
"react-router": "8.3.0",
"rehype-katex": "^7.0.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/katex": "^0.16.8",
"@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",
"openapi-typescript": "^7.13.0",
"tailwindcss": "4.3.3",
"typescript": "5.9.3",
"vite": "8.2.0",

32
scripts/generate-api.mjs Normal file
View File

@@ -0,0 +1,32 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const openApiUrl = process.env.OPENAPI_URL || "http://localhost:5090/openapi/v1.json";
const response = await fetch(openApiUrl);
if (!response.ok) {
throw new Error(`OpenAPI 下载失败:${response.status} ${response.statusText}`);
}
const document = await response.json();
const generatedDir = path.join(projectRoot, ".generated");
const apiDir = path.join(projectRoot, "src", "api");
mkdirSync(generatedDir, { recursive: true });
mkdirSync(apiDir, { recursive: true });
const snapshotPath = path.join(generatedDir, "openapi.json");
writeFileSync(snapshotPath, `${JSON.stringify(document)}\n`);
const cli = path.join(
projectRoot,
"node_modules",
".bin",
process.platform === "win32" ? "openapi-typescript.cmd" : "openapi-typescript",
);
execFileSync(cli, [snapshotPath, "-o", path.join(apiDir, "schema.generated.d.ts")], {
stdio: "inherit",
});
console.log(`已生成前端 OpenAPI 类型,来源:${openApiUrl}`);

59
src/api/assetApi.ts Normal file
View File

@@ -0,0 +1,59 @@
import { apiClient, unwrap } from "./client";
const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/svg+xml"]);
const maxImageBytes = 8 * 1024 * 1024;
export interface ThemeAssetUpload {
assetId: string;
fileName: string;
}
export const assetApi = {
preview: (assetId: string) => unwrap(apiClient.GET("/api/student/assets/{assetId}/preview", {
params: { path: { assetId }, query: { ExpiresInSeconds: 1800 } },
})),
uploadThemeImage: async (
file: File,
title: string,
onProgress?: (percent: number) => void,
): Promise<ThemeAssetUpload> => {
if (!allowedTypes.has(file.type)) throw new Error("仅支持 PNG、JPEG、WebP 或 SVG 图片。");
if (file.size > maxImageBytes) throw new Error("图片不能超过 8 MB。");
const signed = await unwrap(apiClient.POST("/api/tenant/content/assets/uploads/sign", {
body: {
fileName: file.name,
mimeType: file.type,
fileSizeBytes: file.size,
title,
category: "theme",
assetType: "Image",
visibility: "Public",
isPublic: true,
expiresInSeconds: 900,
metadata: { purpose: "tenant-theme" },
},
}));
await new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open(signed.upload.method, signed.upload.url);
Object.entries(signed.upload.headers).forEach(([key, value]) => request.setRequestHeader(key, value));
request.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100));
};
request.onerror = () => reject(new Error("上传连接中断,请重试。"));
request.onload = () => {
if (request.status >= 200 && request.status < 300) resolve();
else reject(new Error(`对象存储上传失败(${request.status})。`));
};
request.send(file);
});
await unwrap(apiClient.POST("/api/tenant/content/assets/uploads/confirm", {
body: { assetId: signed.item.id, mimeType: file.type, fileSizeBytes: file.size },
}));
onProgress?.(100);
return { assetId: signed.item.id, fileName: file.name };
},
};

View File

@@ -12,11 +12,27 @@ interface BackofficeUiBootstrapResponse {
enabledFeatures: string[];
}
const supportedMenuRoutes: Record<string, { path: string; requiresSiteSettings?: boolean }> = {
'tenant.dashboard': { path: '/manage' },
'tenant.site-content': { path: '/manage/site', requiresSiteSettings: true },
const supportedMenuRoutes: Record<string, string> = {
'tenant.dashboard': '/manage',
'tenant.staff': '/manage/staff',
'tenant.students': '/manage/students',
'tenant.question-bank': '/manage/question-bank',
'tenant.vocabulary': '/manage/vocabulary',
'tenant.handbook': '/manage/handbook',
'tenant.video': '/manage/video',
'tenant.scoreline': '/manage/scoreline',
'tenant.site-content': '/manage/site/brand',
'tenant.providers': '/manage/providers',
'tenant.commerce': '/manage/commerce',
'tenant.billing': '/manage/billing',
};
export function normalizeTenantBackofficePath(code: string, _path: string | null): string | null {
const registered = supportedMenuRoutes[code];
if (registered) return registered;
return null;
}
export const backofficeApi = {
bootstrap: async (): Promise<BackofficeBootstrap> => {
const response = await apiRequest<BackofficeUiBootstrapResponse>('/api/tenant/access/ui-bootstrap');
@@ -25,14 +41,16 @@ export const backofficeApi = {
enabledFeatures: response.enabledFeatures,
menus: response.menus
.flatMap((item) => {
const route = supportedMenuRoutes[item.code];
if (!route) return [];
if (route.requiresSiteSettings && !response.permissionCodes.includes('tenant:settings:manage')) return [];
const path = normalizeTenantBackofficePath(item.code, item.path);
if (!path || !Object.hasOwn(supportedMenuRoutes, item.code)) {
if (import.meta.env.DEV) console.warn(`[backoffice] ignored unknown menu code: ${item.code}`);
return [];
}
return [{
code: item.code,
name: item.title,
path: route.path,
requiredPermission: route.requiresSiteSettings ? 'tenant:settings:manage' : item.permissionCode,
path,
requiredPermission: item.permissionCode,
}];
}),
};

49
src/api/client.ts Normal file
View File

@@ -0,0 +1,49 @@
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema.generated";
import { ApiError } from "./http";
function readCookie(name: string): string | null {
const prefix = `${encodeURIComponent(name)}=`;
const entry = document.cookie
.split(";")
.map((value) => value.trim())
.find((value) => value.startsWith(prefix));
return entry ? decodeURIComponent(entry.slice(prefix.length)) : null;
}
const middleware: Middleware = {
async onRequest({ request }) {
const headers = new Headers(request.headers);
if (import.meta.env.DEV && import.meta.env.VITE_DATA_MODE !== "api") {
headers.set(
"X-Demo-Host",
window.localStorage.getItem("tiku-saas-demo-host") || window.location.hostname,
);
}
if (!["GET", "HEAD", "OPTIONS"].includes(request.method.toUpperCase())) {
const csrf = readCookie("__Host-tiku-csrf");
if (csrf) headers.set("X-CSRF-Token", csrf);
}
return new Request(request, { credentials: "include", headers });
},
};
export const apiClient = createClient<paths>({ baseUrl: window.location.origin });
apiClient.use(middleware);
type TypedResponse<T> = { data?: T; error?: unknown; response: Response };
export async function unwrap<T>(request: Promise<TypedResponse<T>>): Promise<T> {
const result = await request;
if (result.data !== undefined) return result.data;
const problem = (result.error ?? {}) as {
code?: string;
title?: string;
detail?: string;
};
throw new ApiError(
result.response.status,
problem.code ?? `http_${result.response.status}`,
problem.detail ?? problem.title ?? "请求失败,请稍后重试。",
);
}

View File

@@ -0,0 +1,30 @@
/**
* Intentionally contract-only placeholders for legacy capabilities that do not
* have a usable .NET student API. No implementation is exported, so production
* code cannot accidentally report a mock success.
*/
export interface SchoolPlanningClient {
createReport(input: unknown): Promise<unknown>;
getReport(reportId: string): Promise<unknown>;
}
export interface QqOAuthClient {
begin(returnUrl: string): Promise<{ authorizationUrl: string }>;
complete(code: string, state: string): Promise<void>;
}
export interface StudentActivationCodeClient {
inspect(code: string): Promise<unknown>;
redeem(code: string): Promise<unknown>;
}
export interface CodeRunnerClient {
execute(input: { language: string; source: string; stdin?: string }): Promise<unknown>;
}
export interface FutureFeatureClients {
schoolPlanning: SchoolPlanningClient;
qqOAuth: QqOAuthClient;
studentActivationCode: StudentActivationCodeClient;
codeRunner: CodeRunnerClient;
}

View File

@@ -4,3 +4,7 @@ export * from './http';
export * from './onboardingApi';
export * from './runtimeApi';
export * from './siteConfigApi';
export * from './studentApi';
export * from './assetApi';
export type * from './futureFeatureClients';
export * from './queryClient';

34
src/api/queryClient.ts Normal file
View File

@@ -0,0 +1,34 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./http";
export const queryKeys = {
profile: ["student", "profile"] as const,
regions: ["catalog", "regions"] as const,
subjects: (regionId?: string | null) => ["catalog", "subjects", regionId ?? "all"] as const,
categories: (subjectId: string) => ["catalog", "categories", subjectId] as const,
blueprints: (regionId?: string | null) => ["catalog", "practice-blueprints", regionId ?? "all"] as const,
dashboard: ["student", "dashboard"] as const,
practice: (sessionId: string) => ["student", "practice", sessionId] as const,
practiceReport: (sessionId: string) => ["student", "practice-report", sessionId] as const,
favorites: ["student", "favorites", "questions"] as const,
wrongQuestions: ["student", "wrong-questions"] as const,
vocabularyUnits: (regionId?: string | null) => ["catalog", "vocabulary-units", regionId ?? "all"] as const,
vocabularyWords: (unitId: string) => ["catalog", "vocabulary-words", unitId] as const,
handbookSubjects: (regionId?: string | null) => ["catalog", "handbook-subjects", regionId ?? "all"] as const,
handbookChapters: (subjectId: string) => ["catalog", "handbook-chapters", subjectId] as const,
handbookEntries: (chapterId: string) => ["catalog", "handbook-entries", chapterId] as const,
scoreline: (regionId?: string | null, keyword = "", year?: number) => ["catalog", "scoreline", regionId ?? "all", keyword, year ?? "all"] as const,
asset: (assetId: string) => ["assets", "preview", assetId] as const,
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) =>
!(error instanceof ApiError && error.status < 500) && failureCount < 2,
refetchOnWindowFocus: false,
},
mutations: { retry: false },
},
});

30780
src/api/schema.generated.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

105
src/api/studentApi.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { components } from "./schema.generated";
import { apiClient, unwrap } from "./client";
type Schema<Name extends keyof components["schemas"]> = components["schemas"][Name];
const catalogQuery = (filters: Record<string, string | number | boolean | undefined> = {}) => ({
params: { query: { Limit: 200, ...filters } },
});
export const studentAuthApi = {
passwordLogin: (tenantCode: string, identifier: string, password: string) =>
unwrap(apiClient.POST("/api/tenant/auth/browser/login/password", {
body: { realm: 0, tenantCode, identifier, password },
})),
sendSms: (tenantCode: string, phone: string) =>
unwrap(apiClient.POST("/api/tenant/auth/browser/sms/send", {
body: { realm: 0, tenantCode, phone },
})),
smsLogin: (tenantCode: string, phone: string, code: string) =>
unwrap(apiClient.POST("/api/tenant/auth/browser/login/sms", {
body: { realm: 0, tenantCode, phone, code },
})),
sendPasswordResetSms: (tenantCode: string, phone: string) =>
unwrap(apiClient.POST("/api/tenant/auth/browser/password/reset/sms/send", {
body: { tenantCode, phone },
})),
resetPassword: (tenantCode: string, phone: string, code: string, newPassword: string) =>
unwrap(apiClient.POST("/api/tenant/auth/browser/password/reset", {
body: { tenantCode, phone, code, newPassword },
})),
refresh: () => unwrap(apiClient.POST("/api/tenant/auth/browser/refresh")),
};
export const profileApi = {
me: () => unwrap(apiClient.GET("/api/student/profile/me", {
params: { query: { RecentLimit: 8 } },
})),
updateRegion: (regionId: string) =>
unwrap(apiClient.PATCH("/api/student/profile/me", { body: { regionId } })),
countdowns: () => unwrap(apiClient.GET("/api/student/profile/exam-countdowns", {
params: { query: { Limit: 8 } },
})),
};
export const catalogApi = {
regions: () => unwrap(apiClient.GET("/api/public/catalog/regions", catalogQuery())),
subjects: (regionId?: string | null) => unwrap(apiClient.GET(
"/api/public/catalog/subjects",
catalogQuery({ RegionId: regionId ?? undefined }),
)),
categories: (subjectId: string) => unwrap(apiClient.GET(
"/api/public/catalog/categories",
catalogQuery({ SubjectId: subjectId }),
)),
blueprints: (regionId?: string | null, targetId?: string | null) => unwrap(apiClient.GET(
"/api/public/catalog/practice-blueprints",
catalogQuery({ RegionId: regionId ?? undefined, NodeId: targetId ?? undefined }),
)),
banners: (regionId?: string | null) => unwrap(apiClient.GET(
"/api/public/catalog/banners",
catalogQuery({ RegionId: regionId ?? undefined, Limit: 8 }),
)),
announcements: (regionId?: string | null) => unwrap(apiClient.GET(
"/api/public/catalog/announcements",
catalogQuery({ RegionId: regionId ?? undefined, Limit: 8 }),
)),
vocabularyUnits: (regionId?: string | null) => unwrap(apiClient.GET("/api/public/catalog/vocabulary-units", catalogQuery({ RegionId: regionId ?? undefined }))),
vocabularyWords: (unitId: string) => unwrap(apiClient.GET("/api/public/catalog/vocabulary-words", catalogQuery({ UnitId: unitId }))),
handbookSubjects: (regionId?: string | null) => unwrap(apiClient.GET("/api/public/catalog/handbook-subjects", catalogQuery({ RegionId: regionId ?? undefined }))),
handbookChapters: (subjectId: string) => unwrap(apiClient.GET("/api/public/catalog/handbook-chapters", catalogQuery({ SubjectId: subjectId }))),
handbookEntries: (chapterId: string, includeContent = false) => unwrap(apiClient.GET("/api/public/catalog/handbook-entries", catalogQuery({ ChapterId: chapterId, IncludeContent: includeContent }))),
scorelineRecords: (regionId?: string | null, keyword?: string, year?: number) => unwrap(apiClient.GET("/api/public/scoreline/records", { params: { query: { RegionId: regionId ?? undefined, Keyword: keyword || undefined, Year: year, Page: 1, PageSize: 50 } } })),
scorelineYears: (regionId?: string | null) => unwrap(apiClient.GET("/api/public/scoreline/years", { params: { query: { RegionId: regionId ?? undefined, Limit: 50 } } })),
};
export const learningApi = {
stats: () => unwrap(apiClient.GET("/api/student/learning/stats")),
trend: () => unwrap(apiClient.GET("/api/student/learning/trend", {
params: { query: { Limit: 28 } },
})),
createSession: (body: Schema<"CreatePracticeSessionDto">) =>
unwrap(apiClient.POST("/api/student/learning/practice-sessions", { body })),
sessionDetail: (practiceSessionId: string) =>
unwrap(apiClient.GET("/api/student/learning/practice-sessions/detail", {
params: { query: { PracticeSessionId: practiceSessionId } },
})),
submitAnswer: (body: Schema<"SubmitAnswerDto">) =>
unwrap(apiClient.POST("/api/student/learning/answers", { body })),
submitSession: (body: Schema<"SubmitPracticeSessionDto">) =>
unwrap(apiClient.POST("/api/student/learning/practice-sessions/submit", { body })),
report: (practiceSessionId: string) =>
unwrap(apiClient.GET("/api/student/learning/practice-sessions/report", {
params: { query: { PracticeSessionId: practiceSessionId } },
})),
favorites: () => unwrap(apiClient.GET("/api/student/learning/favorites/questions", {
params: { query: { Limit: 200 } },
})),
toggleFavorite: (questionId: string, source: number, favorite: boolean) =>
unwrap(apiClient.POST("/api/student/learning/favorites/questions", {
body: { questionId, source, favorite },
})),
wrongQuestions: () => unwrap(apiClient.GET("/api/student/learning/wrong-questions", {
params: { query: { Limit: 200 } },
})),
};

23
src/api/types.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { components } from "./schema.generated";
export type ApiSchema<Name extends keyof components["schemas"]> = components["schemas"][Name];
export type StudentProfile = ApiSchema<"StudentProfileItem">;
export type LearningStats = ApiSchema<"LearningStatsItem">;
export type LearningTrend = ApiSchema<"LearningTrendItem">;
export type Region = ApiSchema<"RegionCatalogItem">;
export type Subject = ApiSchema<"SubjectCatalogItem">;
export type Category = ApiSchema<"CategoryCatalogItem">;
export type PracticeBlueprint = ApiSchema<"PracticeBlueprintCatalogItem">;
export type PracticeSession = ApiSchema<"PracticeSessionItem">;
export type PracticeDetail = ApiSchema<"PracticeSessionDetailItem">;
export type PracticeQuestion = ApiSchema<"PracticeSessionQuestionItem">;
export type AnswerRecord = ApiSchema<"AnswerRecordItem">;
export type PracticeReport = ApiSchema<"PracticeSessionReportItem">;
export type FavoriteQuestion = ApiSchema<"FavoriteQuestionItem">;
export type WrongQuestion = ApiSchema<"WrongQuestionItem">;
export type Banner = ApiSchema<"BannerCatalogItem">;
export type Announcement = ApiSchema<"AnnouncementCatalogItem">;
export type ExamCountdown = ApiSchema<"ExamCountdownItem">;
export type AssetUploadSignResult = ApiSchema<"AssetUploadSignResult">;
export type AssetUploadConfirmResult = ApiSchema<"AssetUploadConfirmResult">;
export type AssetAccessResponse = ApiSchema<"AssetAccessResponseDto">;

View File

@@ -1,11 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { TenantRuntimeBootstrap } from '../contracts';
import type { ResolvedThemeAssets, TenantRuntimeBootstrap } from '../contracts';
import { runtimeApi } from '../api';
import { useThemeAssets } from '../theme/useThemeAssets';
interface RuntimeContextValue {
runtime: TenantRuntimeBootstrap | null;
loading: boolean;
error: Error | null;
assets: ResolvedThemeAssets;
refresh: () => Promise<void>;
}
@@ -15,6 +17,7 @@ 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 assets = useThemeAssets(runtime?.branding);
const refresh = useCallback(async () => {
setLoading(true);
@@ -23,7 +26,18 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
setRuntime(next);
setError(null);
document.title = next.branding.brandName;
document.documentElement.style.setProperty('--tenant-primary', next.theme.primaryColor);
const root = document.documentElement.style;
root.setProperty('--tenant-primary', next.theme.primaryColor);
root.setProperty('--tenant-secondary', next.theme.secondaryColor);
root.setProperty('--tenant-background', next.theme.backgroundColor);
root.setProperty('--tenant-foreground', next.theme.textColor);
root.setProperty('--tenant-surface', next.theme.surfaceColor ?? '#ffffff');
root.setProperty('--tenant-muted', next.theme.mutedColor ?? '#eef2f8');
root.setProperty('--tenant-border', next.theme.borderColor ?? '#dfe5ef');
root.setProperty('--tenant-success', next.theme.successColor ?? '#15803d');
root.setProperty('--tenant-warning', next.theme.warningColor ?? '#b45309');
root.setProperty('--tenant-danger', next.theme.dangerColor ?? '#dc2626');
root.setProperty('--tenant-radius', `${next.theme.radius}px`);
} catch (reason) {
setError(reason instanceof Error ? reason : new Error('站点加载失败'));
} finally {
@@ -33,7 +47,18 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
useEffect(() => { void refresh(); }, [refresh]);
const value = useMemo(() => ({ runtime, loading, error, refresh }), [runtime, loading, error, refresh]);
useEffect(() => {
if (!assets.favicon) return;
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.append(link);
}
link.href = assets.favicon;
}, [assets.favicon]);
const value = useMemo(() => ({ runtime, loading, error, assets, refresh }), [runtime, loading, error, assets, refresh]);
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
}

View File

@@ -7,6 +7,36 @@ export interface BrandingConfig {
logoUrl: string;
faviconUrl: string;
serviceWechat: string;
logoAssetId?: string;
faviconAssetId?: string;
loginBackgroundAssetId?: string;
heroAssetId?: string;
serviceQrAssetId?: string;
featureCardAssetIds?: Record<string, string>;
imageAlt?: string;
assetAlt?: Partial<Record<ThemeAssetRole, string>>;
}
export type ThemeAssetRole =
| 'logo'
| 'favicon'
| 'loginBackground'
| 'hero'
| 'featureCard'
| 'serviceQr';
export interface ThemeAssetReference {
assetId: string;
role: ThemeAssetRole;
alt?: string;
}
export interface ResolvedThemeAssets {
logo?: string;
favicon?: string;
loginBackground?: string;
hero?: string;
serviceQr?: string;
}
export interface ThemeConfig {
@@ -16,6 +46,12 @@ export interface ThemeConfig {
textColor: string;
fontFamily: string;
radius: number;
surfaceColor?: string;
mutedColor?: string;
borderColor?: string;
successColor?: string;
warningColor?: string;
dangerColor?: string;
}
export interface FeatureConfig {

View File

@@ -6,15 +6,292 @@
background: #f5f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--tenant-primary: #3157d5;
--tenant-secondary: #20b486;
--tenant-background: #f4f6fa;
--tenant-foreground: #14213d;
--tenant-surface: #ffffff;
--tenant-muted: #eef2f8;
--tenant-border: #dfe5ef;
--tenant-success: #15803d;
--tenant-warning: #b45309;
--tenant-danger: #dc2626;
--tenant-radius: 18px;
}
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; margin: 0; }
body { min-width: 320px; }
button, input, textarea, select { font: inherit; }
a { color: inherit; text-decoration: none; }
button, a { -webkit-tap-highlight-color: transparent; }
button:not(:disabled), a { cursor: pointer; }
button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--tenant-primary) 35%, transparent); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 1000; padding: 12px 18px; border-radius: 12px; background: #111827; color: #fff; transition: top .2s ease; }
.skip-link:focus { top: 16px; }
.eyebrow { margin: 0 0 10px; color: var(--tenant-primary); font-size: 11px; font-weight: 800; letter-spacing: .16em; }
.primary-button, .quiet-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 13px; padding: 0 18px; font-weight: 750; transition: transform .18s ease, box-shadow .18s ease, background .18s ease; }
.primary-button { background: var(--tenant-primary); color: #fff; box-shadow: 0 10px 25px color-mix(in srgb, var(--tenant-primary) 22%, transparent); }
.primary-button:hover { transform: translateY(-1px); box-shadow: 0 14px 30px color-mix(in srgb, var(--tenant-primary) 28%, transparent); }
.primary-button:disabled { cursor: not-allowed; opacity: .52; transform: none; box-shadow: none; }
.primary-button svg, .quiet-button svg { width: 18px; height: 18px; }
.primary-button--wide { width: 100%; }
.quiet-button { border: 1px solid var(--tenant-border); background: var(--tenant-surface); color: var(--tenant-foreground); }
.icon-button { min-width: 44px; min-height: 44px; display: inline-grid; place-items: center; border: 0; border-radius: 13px; background: transparent; color: #64748b; }
.icon-button:hover { background: var(--tenant-muted); color: var(--tenant-foreground); }
.icon-button svg { width: 19px; height: 19px; }
.back-link { min-height: 44px; display: inline-flex; align-items: center; gap: 8px; color: #64748b; font-size: 14px; font-weight: 650; }
.back-link svg { width: 17px; }
.form-message { margin: 0; padding: 11px 13px; border-radius: 11px; background: color-mix(in srgb, var(--tenant-danger) 8%, white); color: var(--tenant-danger); font-size: 13px; line-height: 1.5; }
.development-notice { max-width: 680px; margin: -12px 0 24px; padding: 12px 15px; border: 1px solid #f4d38b; border-radius: 12px; background: #fff9e9; color: #805600; font-size: 13px; line-height: 1.6; }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.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); }
/* Public site and authentication */
.public-landing { min-height: 100dvh; color: var(--tenant-foreground); background: radial-gradient(circle at 80% 12%, color-mix(in srgb, var(--tenant-primary) 12%, transparent), transparent 34%), var(--tenant-background); }
.public-header { height: 80px; max-width: 1240px; margin: auto; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; }
.public-logo { display: flex; align-items: center; gap: 12px; font-size: 18px; }
.public-logo span, .public-logo img { width: 40px; height: 40px; display: grid; place-items: center; object-fit: cover; border-radius: 13px; background: var(--tenant-primary); color: #fff; font-weight: 900; }
.public-landing main { max-width: 1240px; margin: auto; padding: 50px 28px 90px; }
.public-hero { min-height: 540px; display: grid; grid-template-columns: 1.08fr .92fr; gap: 72px; align-items: center; }
.public-hero h1 { max-width: 720px; margin: 0; font-size: clamp(46px, 6vw, 78px); line-height: 1.04; letter-spacing: -.055em; }
.public-hero__lead { max-width: 640px; margin: 28px 0; color: #667085; font-size: 18px; line-height: 1.8; }
.public-hero__actions { display: flex; align-items: center; gap: 22px; }
.public-hero__actions > span { display: inline-flex; align-items: center; gap: 7px; color: #667085; font-size: 13px; }
.public-hero__actions svg { width: 18px; }
.public-hero__visual { position: relative; aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; border-radius: 40px; background: linear-gradient(145deg, var(--tenant-primary), color-mix(in srgb, var(--tenant-primary) 60%, #6d5dfc)); box-shadow: 0 40px 80px color-mix(in srgb, var(--tenant-primary) 25%, transparent); }
.public-hero__visual > svg { width: 30%; height: 30%; color: white; stroke-width: 1.1; }
.public-hero__visual > img { width: 100%; height: 100%; object-fit: cover; }
.public-hero__visual i { position: absolute; border: 1px solid rgba(255,255,255,.22); border-radius: 30px; transform: rotate(20deg); }
.public-hero__visual i:nth-child(2) { width: 75%; height: 28%; top: 12%; right: -12%; }.public-hero__visual i:nth-child(3) { width: 50%; height: 18%; bottom: 17%; left: -8%; }.public-hero__visual i:nth-child(4) { width: 32%; height: 32%; top: 30%; left: 9%; }
.public-feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.public-feature-grid article { padding: 28px; border: 1px solid var(--tenant-border); border-radius: var(--tenant-radius); background: color-mix(in srgb, var(--tenant-surface) 92%, transparent); }
.public-feature-grid svg { width: 28px; color: var(--tenant-primary); }.public-feature-grid h2 { margin: 22px 0 8px; font-size: 18px; }.public-feature-grid p { margin: 0; color: #667085; line-height: 1.7; }
.auth-page { min-height: 100dvh; display: grid; grid-template-columns: 1fr 1fr; background: var(--tenant-background); }
.auth-story { padding: 56px clamp(36px, 7vw, 110px); display: flex; flex-direction: column; justify-content: space-between; color: #fff; background: radial-gradient(circle at 80% 15%, rgba(255,255,255,.17), transparent 28%), linear-gradient(145deg, color-mix(in srgb, var(--tenant-primary) 76%, #152a56), var(--tenant-primary)); }
.auth-story .eyebrow { color: rgba(255,255,255,.67); }.auth-story h1 { max-width: 600px; margin: 0; font-size: clamp(48px, 5vw, 76px); line-height: 1.04; letter-spacing: -.05em; }.auth-story p { max-width: 540px; color: rgba(255,255,255,.72); font-size: 17px; line-height: 1.8; }.auth-story > span, .auth-back { display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.68); font-size: 13px; }.auth-story svg { width: 18px; }
.auth-panel, .simple-auth-page { display: grid; place-items: center; padding: 32px; }
.simple-auth-page { min-height: 100dvh; background: var(--tenant-background); }
.auth-card { width: min(100%, 440px); display: flex; flex-direction: column; gap: 20px; padding: 38px; border: 1px solid var(--tenant-border); border-radius: 24px; background: var(--tenant-surface); box-shadow: 0 24px 70px rgba(15,23,42,.08); }
.auth-card h1, .auth-card h2 { margin: 0 0 8px; font-size: 30px; letter-spacing: -.03em; }.auth-card > div > p:last-child, .auth-card > p { margin: 0; color: #667085; line-height: 1.6; }
.auth-card label, .subjective-answer { display: flex; flex-direction: column; gap: 8px; color: #344054; font-size: 13px; font-weight: 700; }
.auth-card input, .auth-card textarea, .auth-card select, .subjective-answer textarea { min-height: 48px; width: 100%; border: 1px solid var(--tenant-border); border-radius: 12px; padding: 0 14px; background: #fff; color: var(--tenant-foreground); }
.auth-card input:focus, .subjective-answer textarea:focus { border-color: var(--tenant-primary); }
.input-with-action { display: grid; grid-template-columns: 1fr auto; gap: 8px; }.input-with-action button { min-width: 110px; border: 1px solid var(--tenant-border); border-radius: 12px; background: var(--tenant-muted); color: var(--tenant-primary); font-weight: 700; }.input-with-action button:disabled { opacity: .5; cursor: not-allowed; }
.segmented-control { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 13px; background: var(--tenant-muted); }.segmented-control button { min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 10px; background: transparent; color: #667085; font-size: 13px; font-weight: 700; }.segmented-control button[aria-selected="true"] { background: #fff; color: var(--tenant-primary); box-shadow: 0 5px 15px rgba(15,23,42,.08); }.segmented-control svg { width: 17px; }
.auth-help { text-align: center; color: var(--tenant-primary); font-size: 13px; font-weight: 650; }.success-icon { width: 54px; height: 54px; color: var(--tenant-success); }
/* Student app shell */
.student-app { min-height: 100dvh; display: grid; grid-template-columns: 252px minmax(0, 1fr); color: var(--tenant-foreground); background: var(--tenant-background); }
.student-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 50; width: 252px; display: flex; flex-direction: column; border-right: 1px solid var(--tenant-border); background: color-mix(in srgb, var(--tenant-surface) 95%, transparent); backdrop-filter: blur(18px); transition: width .2s ease; }
.student-app--collapsed { grid-template-columns: 82px minmax(0,1fr); }.student-app--collapsed .student-sidebar { width: 82px; }.student-app--collapsed .student-nav-link { justify-content: center; padding-inline: 0; }.student-app--collapsed .student-nav-link span { display: none; }
.student-brand { min-height: 82px; display: flex; align-items: center; gap: 11px; padding: 16px; }.student-brand img, .student-brand__mark { flex: 0 0 43px; width: 43px; height: 43px; object-fit: cover; display: grid; place-items: center; border-radius: 14px; background: var(--tenant-primary); color: #fff; font-weight: 900; }.student-brand div { min-width: 0; flex: 1; }.student-brand strong, .student-brand small { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.student-brand small { margin-top: 2px; color: #98a2b3; font-size: 10px; }.student-sidebar__toggle { margin-left: auto; flex-shrink: 0; }
.student-sidebar nav { flex: 1; display: flex; flex-direction: column; gap: 6px; padding: 20px 14px; }.student-nav-link { min-height: 48px; display: flex; align-items: center; gap: 12px; border-radius: 13px; padding: 0 15px; color: #667085; font-size: 14px; font-weight: 650; transition: background .18s, color .18s; }.student-nav-link:hover { background: var(--tenant-muted); color: var(--tenant-foreground); }.student-nav-link.is-active { background: color-mix(in srgb, var(--tenant-primary) 10%, white); color: var(--tenant-primary); }.student-nav-link svg { width: 20px; flex: 0 0 auto; }
.student-sidebar__account { min-height: 84px; display: flex; align-items: center; gap: 9px; margin: 12px; padding: 12px; border-radius: 15px; background: var(--tenant-muted); }.student-sidebar__account div { min-width: 0; flex: 1; }.student-sidebar__account strong, .student-sidebar__account small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.student-sidebar__account strong { font-size: 13px; }.student-sidebar__account small { color: #98a2b3; font-size: 10px; }
.student-workspace { grid-column: 2; min-width: 0; }.student-topbar { height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(20px, 4vw, 52px); border-bottom: 1px solid var(--tenant-border); background: color-mix(in srgb, var(--tenant-background) 88%, transparent); backdrop-filter: blur(16px); }.student-topbar__location, .student-topbar__actions { display: flex; align-items: center; gap: 10px; }.student-topbar__location { color: #667085; font-size: 13px; font-weight: 650; }.student-topbar__location svg { width: 17px; color: var(--tenant-primary); }.student-main { max-width: 1450px; margin: 0 auto; padding: 36px clamp(20px, 4vw, 52px) 90px; }.student-mobile-dock { display: none; }
.page-heading, .dashboard-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 30px; }.page-heading h1, .dashboard-heading h1 { margin: 0; font-size: clamp(30px, 3vw, 44px); letter-spacing: -.045em; }.page-heading p:last-child, .dashboard-heading p:last-child { margin: 10px 0 0; color: #667085; line-height: 1.6; }
/* Dashboard */
.dashboard-page { display: flex; flex-direction: column; gap: 22px; }.dashboard-hero { min-height: 290px; display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 30px; overflow: hidden; padding: clamp(30px, 5vw, 58px); border-radius: calc(var(--tenant-radius) + 8px); color: #fff; background: radial-gradient(circle at 80% 20%, rgba(255,255,255,.19), transparent 25%), linear-gradient(135deg, color-mix(in srgb, var(--tenant-primary) 80%, #172554), var(--tenant-primary)); box-shadow: 0 24px 60px color-mix(in srgb, var(--tenant-primary) 20%, transparent); }.dashboard-hero__badge { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border-radius: 999px; background: rgba(255,255,255,.14); font-size: 12px; font-weight: 750; }.dashboard-hero__badge svg { width: 16px; }.dashboard-hero h2 { max-width: 780px; margin: 24px 0 12px; font-size: clamp(28px, 3.4vw, 48px); line-height: 1.14; letter-spacing: -.045em; }.dashboard-hero p { max-width: 680px; color: rgba(255,255,255,.72); line-height: 1.7; }.dashboard-hero a { margin-top: 16px; display: inline-flex; align-items: center; gap: 8px; font-weight: 800; }.dashboard-hero a svg { width: 18px; }.dashboard-hero__target { aspect-ratio: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.15); border-radius: 38%; background: rgba(255,255,255,.09); }.dashboard-hero__target svg { width: 30px; }.dashboard-hero__target strong { margin: 9px 0 2px; font-size: 62px; line-height: 1; }.dashboard-hero__target span { color: rgba(255,255,255,.65); font-size: 12px; text-align: center; }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }.metric-card { display: flex; align-items: center; gap: 15px; min-height: 102px; padding: 20px; border: 1px solid var(--tenant-border); border-radius: var(--tenant-radius); background: var(--tenant-surface); }.metric-card__icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 15px; }.metric-card__icon svg { width: 22px; }.metric-card__icon.blue { background: #eaf0ff; color: #3157d5; }.metric-card__icon.green { background: #e7f8ef; color: #15803d; }.metric-card__icon.orange { background: #fff1e7; color: #c2410c; }.metric-card__icon.purple { background: #f2eaff; color: #7c3aed; }.metric-card small, .metric-card strong { display: block; }.metric-card small { color: #98a2b3; font-size: 11px; }.metric-card strong { margin-top: 4px; font-size: 24px; letter-spacing: -.03em; }
.dashboard-columns { display: grid; grid-template-columns: 1.1fr .9fr; gap: 18px; }.content-card { border: 1px solid var(--tenant-border); border-radius: var(--tenant-radius); padding: 24px; background: var(--tenant-surface); }.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 20px; }.section-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.02em; }.section-heading > svg { color: #98a2b3; }.mini-chart { height: 180px; display: flex; align-items: end; gap: 5px; padding-top: 25px; border-bottom: 1px solid var(--tenant-border); }.mini-chart span { min-width: 4px; flex: 1; border-radius: 5px 5px 0 0; background: color-mix(in srgb, var(--tenant-primary) 72%, white); transition: height .25s ease; }.recent-list { display: flex; flex-direction: column; gap: 12px; }.recent-list a { display: grid; grid-template-columns: minmax(130px,1fr) minmax(80px, 1fr) auto; align-items: center; gap: 12px; padding: 12px; border-radius: 13px; background: var(--tenant-muted); }.recent-list strong, .recent-list small { display: block; }.recent-list strong { font-size: 13px; }.recent-list small { margin-top: 3px; color: #98a2b3; font-size: 10px; }.recent-list i { height: 5px; overflow: hidden; border-radius: 999px; background: #dbe2ed; }.recent-list b { display: block; height: 100%; border-radius: inherit; background: var(--tenant-primary); }.recent-list em { color: #667085; font-size: 11px; font-style: normal; }.announcements > div:last-child { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }.announcements article { display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 12px; background: var(--tenant-muted); }.announcements article i { width: 7px; height: 7px; border-radius: 50%; background: var(--tenant-primary); }.announcements article p { margin: 0; font-size: 13px; }.empty-inline { min-height: 100px; display: grid; place-items: center; color: #98a2b3; font-size: 13px; }
/* Catalog and collections */
.search-field { min-width: 280px; min-height: 46px; display: flex; align-items: center; gap: 9px; padding: 0 14px; border: 1px solid var(--tenant-border); border-radius: 13px; background: var(--tenant-surface); }.search-field svg { width: 18px; color: #98a2b3; }.search-field input { width: 100%; border: 0; outline: 0; background: transparent; }.subject-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; color: #667085; font-size: 13px; }.subject-toolbar span, .subject-toolbar a { display: flex; align-items: center; gap: 7px; }.subject-toolbar svg { width: 17px; }.subject-toolbar a { color: var(--tenant-primary); font-weight: 700; }
.subject-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 17px; }.subject-card { position: relative; min-height: 260px; display: flex; flex-direction: column; padding: 25px; overflow: hidden; border: 1px solid var(--tenant-border); border-radius: calc(var(--tenant-radius) + 2px); background: var(--tenant-surface); transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease; }.subject-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--tenant-primary) 35%, var(--tenant-border)); box-shadow: 0 18px 40px rgba(15,23,42,.08); }.subject-card > span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 15px; color: var(--tenant-primary); background: color-mix(in srgb, var(--tenant-primary) 10%, white); }.subject-card > span svg { width: 23px; }.subject-card small { margin-top: 26px; color: var(--tenant-primary); font-weight: 750; }.subject-card h2 { margin: 7px 0; font-size: 22px; }.subject-card p { margin: 0; color: #667085; line-height: 1.65; font-size: 13px; }.subject-card em { margin-top: auto; display: flex; align-items: center; gap: 7px; color: var(--tenant-primary); font-size: 13px; font-style: normal; font-weight: 750; }.subject-card em svg { width: 16px; }
.subject-detail-hero { min-height: 260px; display: flex; align-items: center; justify-content: space-between; gap: 32px; margin-top: 12px; padding: 36px; border-radius: calc(var(--tenant-radius) + 4px); background: linear-gradient(135deg, color-mix(in srgb, var(--tenant-primary) 9%, white), white); border: 1px solid color-mix(in srgb, var(--tenant-primary) 15%, var(--tenant-border)); }.subject-detail-hero h1 { margin: 0; font-size: clamp(34px,4vw,54px); letter-spacing: -.05em; }.subject-detail-hero p:last-child { max-width: 650px; color: #667085; line-height: 1.7; }
.practice-mode-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin: 20px 0 34px; }.practice-mode-grid > a { min-height: 120px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; padding: 20px; border: 1px solid var(--tenant-border); border-radius: var(--tenant-radius); background: var(--tenant-surface); }.practice-mode-grid > a > svg:first-child { width: 24px; color: var(--tenant-primary); }.practice-mode-grid > a > svg:last-child { width: 17px; color: #98a2b3; }.practice-mode-grid strong { display: block; }.practice-mode-grid p { margin: 6px 0 0; color: #98a2b3; font-size: 12px; line-height: 1.55; }
.category-section { margin-top: 20px; }.chapter-list { display: flex; flex-direction: column; gap: 10px; }.chapter-list article { min-height: 88px; display: grid; grid-template-columns: 45px 1fr auto; align-items: center; gap: 16px; padding: 17px 20px; border: 1px solid var(--tenant-border); border-radius: 15px; background: var(--tenant-surface); }.chapter-list article > span { color: var(--tenant-primary); font-size: 12px; font-weight: 800; }.chapter-list h3, .chapter-list p { margin: 0; }.chapter-list h3 { font-size: 15px; }.chapter-list p { margin-top: 5px; color: #98a2b3; font-size: 12px; }.chapter-list a { min-height: 40px; display: flex; align-items: center; gap: 7px; color: var(--tenant-primary); font-size: 13px; font-weight: 750; }.chapter-list a svg { width: 16px; }
.selection-page { min-height: 100dvh; max-width: 980px; margin: auto; padding: 70px 24px; }.selection-page > header { max-width: 650px; }.selection-page h1 { margin: 0; font-size: clamp(36px,5vw,56px); letter-spacing: -.05em; }.selection-page header p:last-child { color: #667085; line-height: 1.7; }.selection-page > .search-field { max-width: 480px; margin: 30px 0; }.selection-page section { margin-top: 30px; }.selection-page section h2 { display: flex; align-items: center; gap: 8px; font-size: 17px; }.selection-page section h2 svg { width: 19px; color: var(--tenant-primary); }.region-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 11px; }.region-grid button { min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 17px; border: 1px solid var(--tenant-border); border-radius: 14px; background: var(--tenant-surface); color: var(--tenant-foreground); }.region-grid button:hover { border-color: var(--tenant-primary); color: var(--tenant-primary); }.region-grid svg { width: 17px; }
.collection-tabs { max-width: 390px; margin-bottom: 24px; }.collection-list { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; }.collection-list article { min-height: 90px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 13px; padding: 17px; border: 1px solid var(--tenant-border); border-radius: 15px; background: var(--tenant-surface); }.collection-list article > span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; color: var(--tenant-primary); background: color-mix(in srgb, var(--tenant-primary) 9%, white); }.collection-list article > svg { width: 17px; color: #98a2b3; }.collection-list p { margin: 4px 0 0; color: #98a2b3; font-size: 12px; }
/* Quiz and report */
.quiz-start-page { min-height: 100dvh; padding: 30px clamp(20px,5vw,70px); background: var(--tenant-background); }.quiz-start-page section { width: min(100%, 620px); margin: 9vh auto; padding: 45px; border: 1px solid var(--tenant-border); border-radius: 24px; background: var(--tenant-surface); box-shadow: 0 24px 60px rgba(15,23,42,.08); }.quiz-start-page h1 { margin: 0; font-size: 38px; letter-spacing: -.04em; }.quiz-start-page section > p:not(.eyebrow) { color: #667085; line-height: 1.7; }.quiz-start-page label { display: flex; flex-direction: column; gap: 8px; margin: 24px 0; color: #344054; font-size: 13px; font-weight: 700; }.quiz-start-page select { min-height: 46px; border: 1px solid var(--tenant-border); border-radius: 12px; padding: 0 13px; background: #fff; }.quiz-loading { min-height: 60vh; display: grid; place-items: center; align-content: center; gap: 14px; color: #667085; }.quiz-loading svg { width: 32px; color: var(--tenant-primary); }
.quiz-page { min-height: 100dvh; display: flex; flex-direction: column; background: var(--tenant-background); }.quiz-header { min-height: 70px; display: grid; grid-template-columns: 48px minmax(0, 600px) auto; align-items: center; justify-content: center; gap: 24px; padding: 10px 28px; border-bottom: 1px solid var(--tenant-border); background: var(--tenant-surface); }.quiz-header > a { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 13px; }.quiz-header > div { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; }.quiz-header strong { font-size: 13px; }.quiz-header div > span { height: 5px; overflow: hidden; border-radius: 999px; background: var(--tenant-muted); }.quiz-header div > span i { display: block; height: 100%; background: var(--tenant-primary); }.quiz-header small { color: #98a2b3; }.quiz-header > span { display: flex; align-items: center; gap: 7px; color: #667085; font-size: 12px; }.quiz-header svg { width: 18px; }
.quiz-workspace { width: min(100%, 1280px); margin: 0 auto; padding: 30px; display: grid; grid-template-columns: minmax(0, 1fr) 270px; align-items: start; gap: 20px; }.question-card, .question-navigator { border: 1px solid var(--tenant-border); border-radius: 20px; background: var(--tenant-surface); }.question-card { min-height: 620px; padding: clamp(24px,4vw,45px); }.question-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; }.question-meta span, .question-meta em { padding: 6px 10px; border-radius: 999px; background: var(--tenant-muted); color: #667085; font-size: 11px; font-style: normal; font-weight: 750; }.question-meta em { color: var(--tenant-primary); background: color-mix(in srgb, var(--tenant-primary) 9%, white); }.question-meta button { margin-left: auto; width: 44px; height: 44px; display: grid; place-items: center; border: 0; border-radius: 12px; background: transparent; color: #98a2b3; }.question-meta svg { width: 18px; }.safe-markdown { line-height: 1.75; }.safe-markdown > :first-child { margin-top: 0; }.safe-markdown > :last-child { margin-bottom: 0; }.safe-markdown table { width: 100%; border-collapse: collapse; }.safe-markdown th, .safe-markdown td { border: 1px solid var(--tenant-border); padding: 8px 10px; text-align: left; }.safe-markdown a { color: var(--tenant-primary); text-decoration: underline; }.question-content { margin-bottom: 30px; font-size: 18px; }.answer-options { display: flex; flex-direction: column; gap: 11px; }.answer-options > button { min-height: 58px; display: grid; grid-template-columns: 34px 1fr 20px; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--tenant-border); border-radius: 14px; background: #fff; color: var(--tenant-foreground); text-align: left; transition: border .18s, background .18s; }.answer-options > button:hover:not(:disabled), .answer-options > button.is-selected { border-color: var(--tenant-primary); background: color-mix(in srgb, var(--tenant-primary) 5%, white); }.answer-options > button > span { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 10px; background: var(--tenant-muted); font-size: 12px; font-weight: 800; }.answer-options > button.is-selected > span { background: var(--tenant-primary); color: #fff; }.answer-options > button > svg { width: 18px; color: var(--tenant-primary); }.subjective-answer textarea { padding: 13px; resize: vertical; line-height: 1.6; }.question-answer > .primary-button { margin-top: 22px; }.answer-status { display: flex; align-items: center; gap: 8px; margin-top: 20px; color: var(--tenant-success); font-size: 13px; font-weight: 700; }.answer-status.is-pending { color: var(--tenant-warning); }.answer-status svg { width: 18px; }
.question-navigator { position: sticky; top: 20px; padding: 20px; }.question-navigator h2 { margin: 0 0 16px; font-size: 16px; }.question-navigator > div { display: grid; grid-template-columns: repeat(5,1fr); gap: 7px; }.question-navigator > div button { aspect-ratio: 1; border: 1px solid var(--tenant-border); border-radius: 9px; background: #fff; color: #667085; font-size: 11px; }.question-navigator > div button.is-answered { border-color: color-mix(in srgb, var(--tenant-success) 30%, white); background: color-mix(in srgb, var(--tenant-success) 8%, white); color: var(--tenant-success); }.question-navigator > div button.is-current { border-color: var(--tenant-primary); background: var(--tenant-primary); color: #fff; }.submit-paper-button { width: 100%; min-height: 46px; margin-top: 18px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 12px; background: #111827; color: #fff; font-weight: 750; }.submit-paper-button svg { width: 17px; }.quiz-footer { margin-top: auto; min-height: 72px; display: flex; justify-content: center; gap: 12px; padding: 12px; border-top: 1px solid var(--tenant-border); background: var(--tenant-surface); }.quiz-footer button { min-width: 140px; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--tenant-border); border-radius: 12px; background: #fff; color: var(--tenant-foreground); font-weight: 700; }.quiz-footer button:disabled { opacity: .4; cursor: not-allowed; }.quiz-footer svg { width: 17px; }
.report-page { max-width: 900px; margin: auto; }.report-page > header { text-align: center; }.report-page > header h1 { margin: 0; font-size: clamp(34px,5vw,52px); letter-spacing: -.05em; }.report-page > header p:last-child { color: #667085; }.report-score { margin: 35px auto 24px; text-align: center; }.report-score > div strong { font-size: 78px; letter-spacing: -.06em; }.report-score > div span { color: #98a2b3; }.report-score > i { width: min(100%, 440px); height: 10px; display: block; margin: 15px auto 0; border-radius: 999px; background: linear-gradient(to right, var(--tenant-primary) var(--score), var(--tenant-muted) var(--score)); }.report-score p { color: var(--tenant-primary); font-weight: 750; }.report-metrics { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; }.report-metrics article { display: flex; align-items: center; gap: 12px; min-height: 95px; padding: 17px; border: 1px solid var(--tenant-border); border-radius: 15px; background: var(--tenant-surface); }.report-metrics article > svg { color: var(--tenant-primary); }.report-metrics small, .report-metrics strong { display: block; }.report-metrics small { color: #98a2b3; }.report-metrics strong { margin-top: 4px; }.pending-review-note { display: flex; gap: 14px; margin-top: 16px; padding: 20px; border: 1px solid #fed7aa; border-radius: 15px; background: #fff7ed; color: #9a3412; }.pending-review-note p { margin: 5px 0 0; color: #c2410c; font-size: 13px; }.report-actions { display: flex; justify-content: center; gap: 12px; margin-top: 28px; }
.empty-panel, .error-panel { min-height: 220px; display: grid; place-items: center; align-content: center; gap: 12px; padding: 30px; border: 1px dashed var(--tenant-border); border-radius: var(--tenant-radius); color: #667085; text-align: center; background: color-mix(in srgb, var(--tenant-surface) 75%, transparent); }.error-panel button { min-height: 42px; border: 1px solid var(--tenant-border); border-radius: 11px; padding: 0 16px; background: #fff; color: var(--tenant-primary); font-weight: 700; }.skeleton-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; }.skeleton-grid i, .chart-skeleton { min-height: 120px; display: block; border-radius: var(--tenant-radius); background: linear-gradient(100deg, var(--tenant-muted) 20%, #fff 38%, var(--tenant-muted) 56%); background-size: 200% 100%; animation: shimmer 1.3s infinite; }.subject-grid.skeleton-grid i { min-height: 260px; }.chart-skeleton { min-height: 180px; }.site-state-page { min-height: 100dvh; display: grid; place-items: center; padding: 24px; background: #0f172a; color: #fff; }.site-state-page > div { width: min(100%, 560px); padding: 45px; border: 1px solid rgba(255,255,255,.1); border-radius: 28px; background: rgba(255,255,255,.06); text-align: center; }.site-state-page > div > span { width: 64px; height: 64px; display: grid; place-items: center; margin: auto; border-radius: 20px; }.site-state-page h1 { font-size: 36px; }.site-state-page p, .site-state-page article, .site-state-page small { color: rgba(255,255,255,.62); }.site-state-page small { display: flex; justify-content: center; align-items: center; gap: 7px; margin-top: 30px; }
@keyframes shimmer { to { background-position-x: -200%; } }
@media (max-width: 1050px) {
.public-hero { grid-template-columns: 1fr; gap: 30px; }.public-hero__visual { width: min(100%, 540px); justify-self: center; }.public-feature-grid, .subject-grid { grid-template-columns: repeat(2,1fr); }.metric-grid, .report-metrics { grid-template-columns: repeat(2,1fr); }.dashboard-columns { grid-template-columns: 1fr; }.practice-mode-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
body { font-size: 16px; }.public-header { height: 68px; padding: 0 18px; }.public-header .primary-button { min-height: 42px; padding: 0 13px; font-size: 12px; }.public-landing main { padding: 35px 18px 65px; }.public-hero { min-height: auto; }.public-hero h1 { font-size: 45px; }.public-hero__lead { font-size: 16px; }.public-hero__actions { align-items: flex-start; flex-direction: column; }.public-feature-grid { grid-template-columns: 1fr; margin-top: 45px; }.auth-page { grid-template-columns: 1fr; }.auth-story { min-height: 340px; padding: 28px 24px; }.auth-story h1 { font-size: 42px; }.auth-story > span { display: none; }.auth-panel { padding: 20px 16px 40px; }.auth-card { padding: 25px 20px; }
.student-app, .student-app--collapsed { display: block; }.student-sidebar { display: none; }.student-workspace { min-height: 100dvh; }.student-topbar { height: 62px; padding: 0 16px; }.student-topbar .quiet-button { min-height: 40px; padding: 0 12px; font-size: 11px; }.student-main { padding: 24px 16px 105px; }.student-mobile-dock { position: fixed; z-index: 80; left: 10px; right: 10px; bottom: max(10px, env(safe-area-inset-bottom)); min-height: 68px; display: flex; align-items: center; justify-content: space-around; padding: 7px; border: 1px solid rgba(255,255,255,.8); border-radius: 20px; background: rgba(255,255,255,.94); box-shadow: 0 15px 45px rgba(15,23,42,.18); backdrop-filter: blur(18px); }.student-mobile-dock a { min-width: 62px; min-height: 50px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; border-radius: 14px; color: #98a2b3; font-size: 9px; }.student-mobile-dock a.is-active { color: var(--tenant-primary); background: color-mix(in srgb, var(--tenant-primary) 8%, white); }.student-mobile-dock svg { width: 21px; height: 21px; }.page-heading, .dashboard-heading { align-items: flex-start; flex-direction: column; }.page-heading .search-field { width: 100%; min-width: 0; }.dashboard-heading .primary-button { width: 100%; }.dashboard-hero { grid-template-columns: 1fr; padding: 28px 22px; }.dashboard-hero__target { display: none; }.dashboard-hero h2 { font-size: 32px; }.metric-grid { grid-template-columns: repeat(2,1fr); gap: 9px; }.metric-card { min-height: 90px; padding: 13px; }.metric-card__icon { width: 39px; height: 39px; }.metric-card strong { font-size: 19px; }.content-card { padding: 18px; }.announcements > div:last-child { grid-template-columns: 1fr; }.subject-grid, .collection-list { grid-template-columns: 1fr; }.subject-card { min-height: 230px; }.subject-detail-hero { align-items: flex-start; flex-direction: column; padding: 26px 20px; }.subject-detail-hero .primary-button { width: 100%; }.chapter-list article { grid-template-columns: 35px 1fr; }.chapter-list article a { grid-column: 2; }.region-grid { grid-template-columns: repeat(2,1fr); }.selection-page { padding-top: 40px; }.selection-page h1 { font-size: 40px; }.quiz-start-page section { margin-top: 30px; padding: 28px 20px; }.quiz-header { grid-template-columns: 44px 1fr; gap: 8px; padding: 8px 12px; }.quiz-header > span { display: none; }.quiz-header > div { grid-template-columns: 1fr auto; }.quiz-header div > span { grid-column: 1 / -1; grid-row: 2; }.quiz-workspace { display: block; padding: 14px; }.question-card { min-height: 0; padding: 22px 17px; }.question-navigator { position: static; margin-top: 14px; }.quiz-footer { position: sticky; bottom: 0; z-index: 20; }.quiz-footer button { min-width: 0; flex: 1; }.report-metrics { grid-template-columns: repeat(2,1fr); }.report-actions { flex-direction: column; }.report-actions > * { width: 100%; }
}
.site-student-preview { --preview-primary: #3157d5; min-height: 620px; display: grid; grid-template-columns: 190px minmax(0,1fr); overflow: hidden; border: 1px solid #e4e7ec; border-radius: 18px; background: #f4f6f9; text-align: left; }
.site-student-preview > aside { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid #e5e7eb; background: rgba(255,255,255,.96); }
.site-student-preview > aside > header { min-height: 64px; display: flex; align-items: center; gap: 9px; padding: 12px; border-bottom: 1px solid #f0f2f5; }.site-student-preview aside header img, .site-student-preview aside header > i { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; object-fit: cover; background: var(--preview-primary); color: #fff; }.site-student-preview aside header svg { width: 18px; }.site-student-preview aside header strong, .site-student-preview aside header small { display: block; }.site-student-preview aside header strong { font-size: 12px; }.site-student-preview aside header small { margin-top: 2px; color: #98a2b3; font-size: 8px; }
.site-student-preview aside nav { flex: 1; padding: 10px 8px; }.site-student-preview aside nav section { margin-bottom: 9px; }.site-student-preview aside nav p { margin: 0 8px 3px; color: #a0a7b4; font-size: 7px; font-weight: 800; letter-spacing: .1em; }.site-student-preview aside nav span { min-height: 32px; display: flex; align-items: center; gap: 8px; padding: 0 9px; border-radius: 8px; color: #667085; font-size: 9px; font-weight: 650; }.site-student-preview aside nav span.is-active { background: #111827; color: #fff; }.site-student-preview aside nav svg { width: 13px; }.site-student-preview aside nav em { margin-left: auto; color: #98a2b3; font-size: 6px; font-style: normal; }.site-student-preview aside footer { display: flex; align-items: center; gap: 8px; padding: 10px; border-top: 1px solid #eef0f3; }.site-student-preview aside footer > i { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: var(--preview-primary); color: #fff; font-size: 10px; font-style: normal; font-weight: 800; }.site-student-preview aside footer strong, .site-student-preview aside footer small { display: block; font-size: 8px; }.site-student-preview aside footer small { color: #98a2b3; }
.site-student-preview > main { min-width: 0; background-image: linear-gradient(to right,rgba(15,23,42,.03) 1px,transparent 1px),linear-gradient(to bottom,rgba(15,23,42,.03) 1px,transparent 1px); background-size: 28px 28px; }.site-student-preview > main > header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid #e5e7eb; background: rgba(244,246,249,.9); color: #667085; font-size: 9px; }.site-student-preview > main > header button { min-height: 28px; padding: 0 10px; border: 1px solid #dfe3e9; border-radius: 8px; background: #fff; font-size: 8px; }.site-preview-content { padding: 18px; }.site-preview-welcome { display: flex; justify-content: space-between; align-items: center; }.site-preview-welcome small { color: var(--preview-primary); font-size: 7px; font-weight: 800; }.site-preview-welcome h2 { margin: 3px 0; font-size: 19px; }.site-preview-welcome p { margin: 0; color: #98a2b3; font-size: 8px; }.site-preview-welcome button { min-height: 30px; padding: 0 12px; border: 0; border-radius: 8px; background: var(--preview-primary); color: #fff; font-size: 8px; font-weight: 700; }.site-preview-hero { min-height: 145px; display: grid; grid-template-columns: 1fr 110px; align-items: center; margin-top: 12px; padding: 18px 22px; overflow: hidden; border-radius: 16px; color: #fff; }.site-preview-hero small { font-size: 7px; }.site-preview-hero h1 { margin: 8px 0 5px; font-size: 21px; }.site-preview-hero p { margin: 0; color: rgba(255,255,255,.72); font-size: 8px; }.site-preview-hero > svg { justify-self: center; width: 58px; height: 58px; opacity: .32; }.site-preview-hero img { width: 100px; height: 100px; justify-self: end; border-radius: 20px; object-fit: cover; }.site-preview-stats { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 9px; }.site-preview-stats article { min-height: 54px; display: flex; align-items: center; gap: 8px; padding: 9px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; }.site-preview-stats svg { width: 16px; color: var(--preview-primary); }.site-preview-stats small, .site-preview-stats strong { display: block; }.site-preview-stats small { color: #98a2b3; font-size: 7px; }.site-preview-stats strong { margin-top: 2px; font-size: 12px; }.site-preview-modules { display: grid; grid-template-columns: repeat(2,1fr); gap: 8px; margin-top: 9px; }.site-preview-module { min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; }.site-preview-module strong { font-size: 9px; }.site-preview-module span { display: flex; align-items: center; gap: 2px; color: var(--preview-primary); font-size: 7px; }.site-preview-module svg { width: 9px; }
.builder-preview-device.is-mobile .site-student-preview { min-height: 650px; display: block; }.builder-preview-device.is-mobile .site-student-preview > aside { display: none; }.builder-preview-device.is-mobile .site-preview-content { padding: 12px; }.builder-preview-device.is-mobile .site-preview-hero { grid-template-columns: 1fr; min-height: 170px; }.builder-preview-device.is-mobile .site-preview-hero img, .builder-preview-device.is-mobile .site-preview-hero > svg { display: none; }.builder-preview-device.is-mobile .site-preview-stats { grid-template-columns: 1fr; }.builder-preview-device.is-mobile .site-preview-modules { grid-template-columns: 1fr; }
.builder-navigation-row { grid-template-columns: minmax(120px,1fr) minmax(100px,auto) auto auto !important; }
.catalog-learning-page, .vocabulary-unit-page, .handbook-reader-list, .handbook-entry-reader, .scoreline-page { max-width: 1120px; margin: auto; }
.learning-catalog-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }.learning-catalog-grid > a { min-height: 210px; display: grid; grid-template-rows: 44px 1fr auto; padding: 20px; border: 1px solid #e2e6ed; border-radius: 18px; background: #fff; box-shadow: 0 5px 16px rgba(15,23,42,.035); }.learning-catalog-grid > a:hover { border-color: var(--tenant-primary); transform: translateY(-1px); }.learning-catalog-grid > a > span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; background: color-mix(in srgb,var(--tenant-primary) 10%,white); color: var(--tenant-primary); font-size: 12px; font-weight: 850; }.learning-catalog-grid small { color: #98a2b3; font-size: 9px; letter-spacing: .1em; }.learning-catalog-grid h2 { margin: 8px 0; font-size: 20px; }.learning-catalog-grid p { color: #667085; font-size: 12px; line-height: 1.6; }.learning-catalog-grid strong { color: var(--tenant-primary); font-size: 11px; }.learning-catalog-grid > a > svg { justify-self: end; width: 17px; color: #98a2b3; }
.word-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 11px; }.word-list article { position: relative; min-height: 190px; padding: 20px; border: 1px solid #e2e6ed; border-radius: 17px; background: #fff; }.word-list article > div { display: flex; align-items: baseline; gap: 9px; }.word-list article > div strong { font-size: 24px; }.word-list article > div span { color: #98a2b3; }.word-list article > button { position: absolute; top: 15px; right: 15px; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 11px; background: #f2f4f7; color: #98a2b3; }.word-list article > button svg { width: 17px; }.word-list article > p { color: #475467; }.word-list blockquote { margin: 16px 0 0; padding: 12px 14px; border-left: 3px solid var(--tenant-primary); background: #f8fafc; color: #344054; font-size: 12px; line-height: 1.6; }.word-list blockquote small { display: block; margin-top: 5px; color: #98a2b3; }
.handbook-reader-list > section { display: grid; gap: 10px; }.handbook-reader-list > section > a { min-height: 82px; display: grid; grid-template-columns: 48px 1fr 20px; align-items: center; gap: 14px; padding: 14px 18px; border: 1px solid #e3e7ed; border-radius: 15px; background: #fff; }.handbook-reader-list > section > a:hover { border-color: var(--tenant-primary); }.handbook-reader-list > section > a > span { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 11px; background: #f2f4f7; color: var(--tenant-primary); font-size: 11px; font-weight: 800; }.handbook-reader-list strong { font-size: 15px; }.handbook-reader-list p { margin: 5px 0 0; color: #98a2b3; font-size: 11px; }.handbook-reader-list a > svg { width: 17px; color: #b0b7c3; }.handbook-entry-reader { max-width: 820px; padding: 34px 42px; border: 1px solid #e2e6ed; border-radius: 20px; background: #fff; }.handbook-entry-reader header { margin: 22px 0 32px; padding-bottom: 24px; border-bottom: 1px solid #eef0f3; }.handbook-entry-reader h1 { margin: 6px 0 10px; font-size: 34px; }.handbook-entry-reader header > p:last-child { color: #667085; }.handbook-entry-reader .safe-markdown { font-size: 16px; line-height: 1.9; }
.scoreline-toolbar { display: flex; gap: 10px; margin-bottom: 16px; }.scoreline-toolbar .search-field { flex: 1; }.scoreline-toolbar select { min-width: 150px; min-height: 46px; padding: 0 13px; border: 1px solid var(--tenant-border); border-radius: 12px; background: #fff; }.scoreline-list { display: grid; gap: 10px; }.scoreline-list article { min-height: 100px; display: grid; grid-template-columns: 46px 1fr minmax(120px,auto); align-items: center; gap: 14px; padding: 17px; border: 1px solid #e3e7ed; border-radius: 15px; background: #fff; }.scoreline-list article > span { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 12px; background: color-mix(in srgb,var(--tenant-primary) 9%,white); color: var(--tenant-primary); }.scoreline-list article > span svg { width: 20px; }.scoreline-list small { color: var(--tenant-primary); font-weight: 700; }.scoreline-list h2 { margin: 4px 0; font-size: 17px; }.scoreline-list p { margin: 0; color: #667085; font-size: 11px; }.scoreline-list code { max-width: 280px; overflow: hidden; color: #98a2b3; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 760px) { .learning-catalog-grid, .word-list { grid-template-columns: 1fr; }.learning-catalog-grid > a { min-height: 180px; }.handbook-entry-reader { padding: 24px 18px; }.scoreline-toolbar { flex-direction: column; }.scoreline-list article { grid-template-columns: 42px 1fr; }.scoreline-list code { grid-column: 2; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }
.tenant-admin-shell { --admin-ink: #172033; min-height: 100dvh; color: #172033; background: #f3f5f8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; }
.tenant-admin-header { position: sticky; top: 0; z-index: 100; min-height: 72px; display: grid; grid-template-columns: 240px minmax(520px, 1fr) auto; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid #e6e9ef; background: rgba(255,255,255,.96); box-shadow: 0 8px 30px rgba(15,23,42,.045); backdrop-filter: blur(16px); }
.tenant-admin-brand { min-width: 0; display: flex; align-items: center; gap: 11px; }.tenant-admin-brand > img, .tenant-admin-brand > span { flex: 0 0 40px; width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; object-fit: cover; background: var(--tenant-primary); color: #fff; font-weight: 850; box-shadow: 0 8px 18px color-mix(in srgb,var(--tenant-primary) 18%,transparent); }.tenant-admin-brand > div { min-width: 0; }.tenant-admin-brand strong, .tenant-admin-brand small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tenant-admin-brand strong { font-size: 15px; }.tenant-admin-brand small { margin-top: 2px; color: #98a2b3; font-size: 10px; }
.tenant-admin-menu-trigger { flex: 0 0 auto; min-width: 40px; min-height: 40px; }
.tenant-admin-category-nav { min-width: 0; height: 72px; display: flex; align-items: center; justify-content: center; gap: 4px; overflow-x: auto; scrollbar-width: none; }.tenant-admin-category-nav::-webkit-scrollbar { display: none; }.tenant-admin-category-nav button { position: relative; flex: 0 0 auto; min-height: 44px; display: inline-flex; align-items: center; gap: 8px; padding: 0 15px; border: 0; border-radius: 11px; background: transparent; color: #667085; font-size: 13px; font-weight: 700; transition: color .18s ease, background .18s ease; }.tenant-admin-category-nav button:hover { color: #111827; background: #f3f4f6; }.tenant-admin-category-nav button.is-active { color: #111827; background: #eef1f5; }.tenant-admin-category-nav button.is-active::after { content: ""; position: absolute; left: 15px; right: 15px; bottom: -14px; height: 3px; border-radius: 3px 3px 0 0; background: var(--tenant-primary); }.tenant-admin-category-nav svg { font-size: 16px; }
.tenant-admin-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }.tenant-admin-region-select { width: 142px; }.tenant-admin-site-state { margin: 0; white-space: nowrap; }
.tenant-admin-body { min-height: calc(100dvh - 72px); display: grid; grid-template-columns: 240px minmax(0,1fr); gap: 22px; padding: 18px 22px 24px; }
.tenant-admin-sidebar { position: sticky; top: 90px; align-self: start; height: calc(100dvh - 108px); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #e8ebf0; border-radius: 24px; background: #fff; box-shadow: 0 10px 30px rgba(15,23,42,.04); }.tenant-admin-sidebar-heading { min-height: 82px; display: flex; align-items: center; gap: 12px; padding: 18px; border-bottom: 1px solid #eef0f3; }.tenant-admin-sidebar-heading > span { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; background: #eef1f5; color: #344054; font-size: 18px; }.tenant-admin-sidebar-heading small, .tenant-admin-sidebar-heading strong { display: block; }.tenant-admin-sidebar-heading small { color: #98a2b3; font-size: 10px; }.tenant-admin-sidebar-heading strong { margin-top: 2px; font-size: 15px; }.tenant-admin-sidebar nav { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; padding: 12px; }
.tenant-admin-nav-item { min-height: 44px; display: flex; align-items: center; gap: 11px; padding: 0 12px; border-radius: 11px; color: #667085; font-size: 13px; font-weight: 650; transition: color .18s ease, background .18s ease, box-shadow .18s ease; }.tenant-admin-nav-item:hover { color: #111827; background: #f4f5f7; }.tenant-admin-nav-item.is-active { color: #fff; background: var(--admin-ink, #172033); box-shadow: 0 8px 18px rgba(17,24,39,.15); }.tenant-admin-nav-icon { width: 20px; display: grid; place-items: center; font-size: 16px; }.tenant-admin-nav-item > span:nth-child(2) { min-width: 0; flex: 1; }.tenant-admin-nav-item .ant-tag { flex: 0 0 auto; margin: 0; padding-inline: 5px; color: #98a2b3; background: #f2f4f7; font-size: 9px; line-height: 18px; }.tenant-admin-nav-item.is-active .ant-tag { color: rgba(255,255,255,.68); background: rgba(255,255,255,.1); }.tenant-admin-sidebar-footnote { padding: 13px 16px; border-top: 1px solid #eef0f3; color: #a0a7b4; font-size: 9px; line-height: 1.5; }
.tenant-admin-content { min-width: 0; min-height: calc(100dvh - 114px); padding: 26px 28px 34px; border: 1px solid #e8ebf0; border-radius: 24px; outline: 0; background: #fff; box-shadow: 0 10px 30px rgba(15,23,42,.035); }
.tenant-page-header { min-height: 62px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 22px; }.tenant-page-header h2 { margin: 0 !important; font-size: 28px !important; letter-spacing: -.035em; }.tenant-page-title-line { display: flex; align-items: center; gap: 10px; }.tenant-page-title-line .ant-tag { margin: 0; color: #667085; background: #f2f4f7; }.tenant-dashboard-toolbar { display: flex; align-items: center; gap: 10px; }
/* Tenant dashboard */
.tenant-dashboard-page { max-width: 1500px; margin: 0 auto; }.tenant-dashboard-page > .ant-alert { margin-bottom: 18px; }.tenant-metric-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }.tenant-metric-card .ant-card-body { min-height: 130px; display: flex; align-items: flex-start; gap: 15px; padding: 20px; }.tenant-metric-icon { flex: 0 0 42px; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; font-size: 19px; }.tenant-metric-icon.is-blue { color: #3157d5; background: #edf2ff; }.tenant-metric-icon.is-green { color: #15803d; background: #e9f8ef; }.tenant-metric-icon.is-cyan { color: #0f766e; background: #e8f8f6; }.tenant-metric-icon.is-orange { color: #c46b16; background: #fff4e8; }.tenant-metric-icon.is-purple { color: #7c3aed; background: #f3edff; }.tenant-metric-card small, .tenant-metric-card strong, .tenant-metric-card em { display: block; }.tenant-metric-card small { color: #667085; font-size: 11px; }.tenant-metric-card strong { margin-top: 8px; color: #111827; font-size: 25px; line-height: 1.1; letter-spacing: -.035em; }.tenant-metric-card em { margin-top: 8px; color: #98a2b3; font-size: 10px; font-style: normal; }
.tenant-dashboard-grid { display: grid; grid-template-columns: 1.08fr .92fr; gap: 16px; margin-bottom: 16px; }.tenant-dashboard-grid.is-wide-left { grid-template-columns: 1.35fr .65fr; }.tenant-dashboard-panel { min-width: 0; border-color: #e8ebf0; }.tenant-dashboard-panel .ant-card-head { min-height: 56px; padding-inline: 20px; }.tenant-dashboard-panel .ant-card-head-title { font-size: 14px; }.tenant-dashboard-panel .ant-card-body { padding: 20px; }.tenant-dashboard-panel .ant-empty { min-height: 180px; display: grid; place-items: center; align-content: center; }
.tenant-user-composition { min-height: 220px; display: grid; grid-template-columns: 210px 1fr; align-items: center; gap: 24px; }.tenant-donut { width: 176px; height: 176px; display: grid; place-items: center; margin: auto; border-radius: 50%; background: conic-gradient(#4c7df0 0 17%,#f6c453 17% 19%,#e5e7eb 19% 100%); }.tenant-donut::before { content: ""; grid-area: 1/1; width: 116px; height: 116px; border-radius: 50%; background: #fff; }.tenant-donut span { z-index: 1; grid-area: 1/1; text-align: center; }.tenant-donut strong, .tenant-donut small { display: block; }.tenant-donut strong { font-size: 25px; }.tenant-donut small { color: #98a2b3; font-size: 10px; }.tenant-user-legend p { display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 9px; margin: 0; padding: 11px 0; border-bottom: 1px solid #f0f2f5; color: #667085; font-size: 12px; }.tenant-user-legend p:last-child { border-bottom: 0; }.tenant-user-legend i { width: 8px; height: 8px; border-radius: 50%; background: #d9dde4; }.tenant-user-legend i.is-primary { background: #4c7df0; }.tenant-user-legend i.is-warning { background: #f6c453; }.tenant-user-legend strong { color: #111827; }
.tenant-onboarding-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin: 18px 0; }.tenant-onboarding-list > div { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-radius: 10px; background: #f7f8fa; }.tenant-onboarding-list span { overflow: hidden; color: #667085; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.tenant-onboarding-list .ant-tag { margin: 0; font-size: 9px; }
.tenant-bar-chart { height: 210px; display: flex; align-items: end; gap: 10px; padding: 20px 8px 0; border-bottom: 1px solid #e9ecf1; background-image: linear-gradient(to bottom,#eef0f4 1px,transparent 1px); background-size: 100% 25%; }.tenant-bar-chart i { flex: 1; max-height: 92%; min-height: 12px; border-radius: 5px 5px 0 0; background: linear-gradient(180deg,#6f94ef,#3157d5); opacity: .86; }
.tenant-resource-list { display: grid; gap: 10px; }.tenant-resource-list > div { min-height: 58px; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 11px; background: #f7f8fa; }.tenant-resource-list svg { color: #3157d5; }.tenant-resource-list span { color: #667085; font-size: 11px; }.tenant-resource-list strong { font-size: 17px; }.tenant-activity-list { display: grid; }.tenant-activity-list > div { min-height: 53px; display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 10px; border-bottom: 1px solid #eef0f3; }.tenant-activity-list > div:last-child { border-bottom: 0; }.tenant-activity-list i { width: 7px; height: 7px; border-radius: 50%; background: #3157d5; }.tenant-activity-list p { margin: 0; }.tenant-activity-list strong, .tenant-activity-list span { display: block; }.tenant-activity-list strong { font-size: 11px; }.tenant-activity-list span, .tenant-activity-list time { margin-top: 3px; color: #98a2b3; font-size: 9px; }.tenant-feature-placeholder { max-width: 880px; margin: 40px auto; }.tenant-feature-placeholder .ant-result { padding-bottom: 16px; }.tenant-feature-placeholder-detail { max-width: 620px; margin: 0 auto; padding: 20px 0 12px; border-top: 1px solid #eef0f3; text-align: center; }
/* Tenant site builder workspace */
.site-builder-workspace { max-width: 1480px; margin: 0 auto; }
.site-builder-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
.site-builder-header h2 { margin: 0 0 4px !important; }
.site-builder-tabs { margin-bottom: 18px; padding: 0 18px; border: 1px solid #e7ebf2; border-radius: 14px; background: #f8f9fb; }
.site-builder-tabs .ant-tabs-nav { margin: 0 !important; }.site-builder-tabs .ant-tabs-tab { min-height: 54px; padding: 0 5px !important; font-weight: 650; }
.site-builder-canvas { min-width: 0; }
.site-builder-footer { display: flex; justify-content: space-between; margin-top: 20px; }
.builder-panel-stack { display: grid; gap: 16px; }
.builder-asset-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.builder-asset-card { min-width: 0; }
.builder-upload-button { position: relative; display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: 8px; padding: 8px 16px; border: 1px dashed #8fa2c2; border-radius: 10px; color: #3157d5; cursor: pointer; transition: background-color 180ms ease, border-color 180ms ease; }
.builder-upload-button:hover { border-color: #3157d5; background: #f3f6ff; }
.builder-upload-button.is-disabled { opacity: .5; cursor: not-allowed; }
.builder-upload-button input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.builder-template { display: grid; width: 100%; min-height: 150px; grid-template-columns: 1fr auto; gap: 12px; align-items: center; padding: 12px; border: 1px solid #e1e7f0; border-radius: 14px; background: #fff; text-align: left; cursor: pointer; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; }
.builder-template:hover { border-color: #9badd0; transform: translateY(-1px); }
.builder-template.is-active { border-color: #3157d5; box-shadow: 0 0 0 2px rgb(49 87 213 / 12%); }
/* Student workspace — high-fidelity migration of the original student product */
.student-app { --student-sidebar-width: 256px; min-height: 100dvh; display: grid; grid-template-columns: var(--student-sidebar-width) minmax(0, 1fr); background: #f4f6f9; }
.student-app--collapsed { --student-sidebar-width: 80px; }
.student-grid-background { position: fixed; inset: 0; pointer-events: none; opacity: .55; background-image: linear-gradient(to right, rgba(15,23,42,.035) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.035) 1px, transparent 1px); background-size: 40px 40px; }
.student-sidebar { width: var(--student-sidebar-width); border-right: 1px solid #e5e7eb; background: rgba(255,255,255,.94); box-shadow: none; backdrop-filter: blur(16px); }
.student-brand { min-height: 76px; padding: 14px 16px; border-bottom: 1px solid #f0f2f5; }
.student-brand img, .student-brand__mark { flex-basis: 42px; width: 42px; height: 42px; border-radius: 12px; box-shadow: 0 8px 18px color-mix(in srgb, var(--tenant-primary) 18%, transparent); }
.student-brand strong { color: #111827; font-size: 15px; font-weight: 800; }
.student-brand small { color: var(--tenant-primary); font-size: 10px; font-weight: 650; }
.student-sidebar nav { overflow-y: auto; padding: 14px 12px 18px; gap: 0; }
.student-nav-group { display: grid; gap: 4px; margin-bottom: 14px; }
.student-nav-group > p { margin: 0 10px 4px; color: #a0a7b4; font-size: 10px; font-weight: 800; letter-spacing: .12em; }
.student-nav-link { position: relative; min-height: 44px; gap: 12px; padding: 0 13px; border-radius: 11px; color: #667085; font-size: 13px; font-weight: 650; }
.student-nav-link:hover { background: #f3f4f6; color: #111827; }
.student-nav-link.is-active { background: #111827; color: #fff; box-shadow: 0 8px 18px rgba(17,24,39,.14); }
.student-nav-link em { margin-left: auto; padding: 3px 6px; border-radius: 999px; background: #f2f4f7; color: #98a2b3; font-size: 9px; font-style: normal; font-weight: 700; }
.student-nav-link.is-active em { background: rgba(255,255,255,.14); color: rgba(255,255,255,.72); }
.student-app--collapsed .student-nav-group { margin-bottom: 8px; }
.student-app--collapsed .student-nav-link { justify-content: center; padding: 0; }
.student-app--collapsed .student-nav-link span, .student-app--collapsed .student-nav-link em { display: none; }
.student-sidebar__account { min-height: auto; gap: 10px; margin: 0; padding: 14px 12px; border-radius: 0; border-top: 1px solid #eef0f3; background: #fff; }
.student-account-avatar { flex: 0 0 38px; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 50%; background: linear-gradient(135deg, var(--tenant-primary), color-mix(in srgb, var(--tenant-primary) 55%, #60a5fa)); color: white; font-size: 14px; font-weight: 850; }
.student-sidebar__account strong { color: #344054; }
.student-sidebar__account small { display: inline-block; margin-top: 3px; padding: 2px 7px; border-radius: 999px; background: #f2f4f7; color: #667085; }
.student-logout { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; margin-left: auto; padding: 0 10px; border: 0; border-radius: 10px; background: #fff1f2; color: #e11d48; font-size: 11px; font-weight: 750; }
.student-logout:hover { background: #ffe4e6; }
.student-logout svg { width: 16px; }
.student-workspace { grid-column: 2; position: relative; z-index: 1; min-width: 0; }
.student-topbar { position: sticky; top: 0; z-index: 30; height: 58px; padding: 0 clamp(18px,3vw,36px); background: rgba(244,246,249,.88); border-bottom-color: rgba(223,229,239,.8); }
.student-topbar .quiet-button { min-height: 36px; padding: 0 13px; border-radius: 10px; font-size: 11px; }
.student-main { width: min(100%, 1380px); max-width: none; padding: 24px clamp(18px,3vw,36px) 80px; }
.dashboard-page { max-width: 1240px; margin: auto; }
.dashboard-heading, .page-heading { margin-bottom: 18px; }
.dashboard-heading h1, .page-heading h1 { font-size: clamp(25px,3vw,34px); letter-spacing: -.035em; }
.dashboard-heading > div > p:last-child, .page-heading > div > p:last-child { margin-top: 7px; color: #667085; font-size: 13px; }
.dashboard-hero { min-height: 210px; grid-template-columns: minmax(0,1fr) 190px; padding: 28px 34px; border-radius: 22px; box-shadow: 0 16px 40px color-mix(in srgb, var(--tenant-primary) 22%, transparent); }
.dashboard-hero h2 { max-width: 640px; margin: 15px 0 8px; font-size: clamp(26px,3vw,38px); }
.dashboard-hero p { margin: 0 0 20px; font-size: 13px; }
.dashboard-service-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 11px; margin: 14px 0; }
.dashboard-service-card { min-height: 92px; display: grid; grid-template-columns: 44px minmax(0,1fr) 18px; align-items: center; gap: 11px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 16px; background: rgba(255,255,255,.94); box-shadow: 0 5px 14px rgba(15,23,42,.035); }
.dashboard-service-card:hover { border-color: color-mix(in srgb, var(--tenant-primary) 30%, #e5e7eb); box-shadow: 0 9px 24px rgba(15,23,42,.07); }
.dashboard-service-card > span { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 13px; }
.dashboard-service-card > span svg { width: 21px; }
.dashboard-service-card.blue > span { background: #e8f0ff; color: #3157d5; }.dashboard-service-card.rose > span { background: #fff0f3; color: #e11d48; }.dashboard-service-card.amber > span { background: #fff7e5; color: #d97706; }.dashboard-service-card.green > span { background: #eafaf3; color: #15803d; }
.dashboard-service-card strong, .dashboard-service-card small { display: block; }.dashboard-service-card strong { color: #1d2939; font-size: 14px; }.dashboard-service-card small { margin-top: 4px; color: #98a2b3; font-size: 10px; }.dashboard-service-card > svg { width: 16px; color: #b4bac4; }
.metric-grid { gap: 11px; margin: 0 0 14px; }
.metric-card { min-height: 88px; padding: 14px; border-radius: 15px; box-shadow: 0 4px 14px rgba(15,23,42,.035); }
.metric-card__icon { width: 42px; height: 42px; border-radius: 12px; }
.metric-card strong { font-size: 21px; }
.content-card { padding: 20px; border-radius: 17px; box-shadow: 0 5px 16px rgba(15,23,42,.035); }
.dashboard-columns { gap: 14px; }
.section-heading h2 { font-size: 17px; }
.feature-placeholder { min-height: calc(100dvh - 150px); display: grid; place-items: center; }
.feature-placeholder > section { width: min(100%,650px); padding: 42px; border: 1px solid #e3e7ee; border-radius: 24px; background: rgba(255,255,255,.95); box-shadow: 0 18px 50px rgba(15,23,42,.07); text-align: center; }
.feature-placeholder > section > span { width: 64px; height: 64px; display: grid; place-items: center; margin: auto; border-radius: 18px; background: color-mix(in srgb, var(--tenant-primary) 10%, white); color: var(--tenant-primary); }
.feature-placeholder > section > span svg { width: 28px; }
.feature-placeholder h1 { margin: 8px 0 20px; font-size: 36px; }.feature-placeholder h2 { margin: 0; font-size: 18px; }.feature-placeholder section > p:not(.eyebrow) { max-width: 510px; margin: 12px auto 24px; color: #667085; line-height: 1.8; }.feature-placeholder section > div { display: flex; justify-content: center; align-items: center; gap: 18px; }
.services-page, .profile-page { max-width: 1080px; margin: auto; }.service-section { margin-top: 26px; }.service-section > h2 { margin: 0 0 12px; color: #475467; font-size: 14px; }.service-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 11px; }.service-grid > a { min-height: 112px; display: grid; grid-template-columns: 48px 1fr 18px; align-items: center; gap: 13px; padding: 18px; border: 1px solid #e2e6ed; border-radius: 16px; background: #fff; }.service-grid > a:hover { border-color: var(--tenant-primary); }.service-grid > a > span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 14px; background: color-mix(in srgb, var(--tenant-primary) 9%, white); color: var(--tenant-primary); }.service-grid strong, .service-grid p, .service-grid small { display: block; }.service-grid p { margin: 5px 0; color: #667085; font-size: 12px; }.service-grid small { display: flex; align-items: center; gap: 4px; color: #98a2b3; font-size: 10px; }.service-grid small svg { width: 12px; }.service-grid > a > svg { width: 17px; color: #b0b7c3; }
.profile-hero { min-height: 180px; display: flex; align-items: center; gap: 22px; padding: 30px; border-radius: 22px; background: linear-gradient(135deg,#111827,#344054); color: #fff; }.profile-hero > span { width: 76px; height: 76px; display: grid; place-items: center; border-radius: 50%; background: var(--tenant-primary); font-size: 28px; font-weight: 850; }.profile-hero p { margin: 0; color: rgba(255,255,255,.55); font-size: 11px; }.profile-hero h1 { margin: 7px 0; font-size: 30px; }.profile-hero small { padding: 4px 9px; border-radius: 999px; background: rgba(255,255,255,.12); }.profile-stat-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; margin: 14px 0; }.profile-stat-grid article { min-height: 105px; display: grid; grid-template-columns: 38px 1fr; align-items: center; padding: 18px; border: 1px solid #e5e7eb; border-radius: 16px; background: #fff; }.profile-stat-grid svg { grid-row: 1 / 3; color: var(--tenant-primary); }.profile-stat-grid small { color: #98a2b3; }.profile-stat-grid strong { font-size: 22px; }.profile-menu { border: 1px solid #e5e7eb; border-radius: 17px; overflow: hidden; background: #fff; }.profile-menu a { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid #eef0f3; }.profile-menu a:last-child { border-bottom: 0; }.profile-menu a > svg:first-child { color: var(--tenant-primary); }.profile-menu strong, .profile-menu small { display: block; }.profile-menu small { margin-top: 4px; color: #98a2b3; }
@media (max-width: 1050px) {
.dashboard-service-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
}
@media (max-width: 760px) {
.student-app, .student-app--collapsed { display: block; }.student-sidebar { display: none; }.student-workspace { min-height: 100dvh; }.student-main { padding: 18px 14px 105px; }.student-grid-background { background-size: 32px 32px; }
.student-mobile-dock { left: 12px; right: 12px; min-height: 66px; padding: 7px 5px; border-radius: 18px; background: rgba(255,255,255,.97); box-shadow: 0 8px 30px rgba(15,23,42,.15); }
.student-mobile-dock a { flex: 1; min-width: 0; min-height: 50px; font-size: 10px; }.student-mobile-dock svg { width: 22px; }
.dashboard-heading { flex-direction: row; align-items: center; }.dashboard-heading > div > p:last-child { display: none; }.dashboard-heading .primary-button { width: auto; min-height: 40px; padding: 0 13px; font-size: 11px; }.dashboard-heading h1 { font-size: 24px; }
.dashboard-hero { min-height: 190px; padding: 23px 20px; border-radius: 20px; }.dashboard-hero h2 { font-size: 26px; }.dashboard-service-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }.dashboard-service-card { min-height: 84px; grid-template-columns: 38px 1fr; padding: 11px; }.dashboard-service-card > span { width: 38px; height: 38px; }.dashboard-service-card > svg { display: none; }.dashboard-service-card small { font-size: 9px; }
.service-grid { grid-template-columns: 1fr; }.feature-placeholder > section { padding: 30px 20px; }.feature-placeholder section > div { flex-direction: column; }.profile-stat-grid { grid-template-columns: 1fr; }.profile-hero { padding: 24px 20px; }.profile-hero > span { width: 62px; height: 62px; }
}
.builder-template-swatch { grid-column: 1 / -1; display: block; height: 80px; border-radius: 10px; }
.builder-color-field { display: grid; gap: 8px; color: #475569; }
.builder-color-field input[type='color'] { width: 48px; min-height: 40px; padding: 3px; border: 1px solid #d9d9d9; background: #fff; cursor: pointer; }
.builder-navigation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 12px; border-radius: 12px; background: #f7f9fc; }
.builder-navigation-row code { max-width: 120px; overflow: hidden; color: #64748b; text-overflow: ellipsis; }
.builder-sortable { display: grid; grid-template-columns: 44px minmax(0, 1fr) 130px auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid #e5eaf2; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(15 23 42 / 4%); }
.builder-drag-handle { width: 44px; height: 44px; border: 0; border-radius: 8px; background: transparent; color: #64748b; cursor: grab; }
.builder-drag-handle:hover { background: #f1f5f9; }
.builder-preview-device { margin: 0 auto; overflow: auto; border: 10px solid #172033; background: #fff; box-shadow: 0 24px 60px rgb(15 23 42 / 18%); transition: max-width 200ms ease, border-radius 200ms ease; }
.builder-preview-device.is-desktop { min-height: 680px; max-width: 1180px; border-radius: 24px; }
.builder-preview-device.is-mobile { height: 720px; max-width: 390px; border-radius: 38px; }
@media (max-width: 1280px) {
.tenant-admin-header { grid-template-columns: auto minmax(0,1fr) auto; }
.tenant-admin-site-state { display: none; }
.tenant-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
}
@media (max-width: 991px) {
.tenant-admin-header { min-height: 118px; grid-template-columns: minmax(0,1fr) auto; grid-template-rows: 64px 54px; gap: 0 12px; padding: 0 16px; }
.tenant-admin-category-nav { grid-column: 1 / -1; grid-row: 2; height: 54px; justify-content: flex-start; margin: 0 -16px; padding: 0 16px; border-top: 1px solid #f0f2f5; }
.tenant-admin-category-nav button { min-height: 40px; padding-inline: 12px; }
.tenant-admin-category-nav button.is-active::after { bottom: -7px; }
.tenant-admin-body { min-height: calc(100dvh - 118px); display: block; padding: 14px 16px 22px; }
.tenant-admin-content { min-height: calc(100dvh - 154px); padding: 22px; }
.tenant-admin-mobile-drawer .ant-drawer-body { padding: 0; background: #f5f7fa; }
.tenant-admin-mobile-drawer .tenant-admin-sidebar { position: static; height: calc(100dvh - 56px); border: 0; border-radius: 0; box-shadow: none; }
.tenant-dashboard-grid, .tenant-dashboard-grid.is-wide-left { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.site-builder-header { align-items: stretch; flex-direction: column; }
.builder-asset-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.tenant-admin-category-nav { margin-inline: -12px; padding-inline: 12px; }
.tenant-admin-header { padding-inline: 12px; }.tenant-admin-brand > img, .tenant-admin-brand > span { flex-basis: 36px; width: 36px; height: 36px; }.tenant-admin-brand strong { max-width: 120px; font-size: 13px; }.tenant-admin-header-actions { gap: 0; }.tenant-admin-body { padding: 0; }.tenant-admin-content { min-height: calc(100dvh - 118px); padding: 18px 14px 28px; border: 0; border-radius: 0; box-shadow: none; }.tenant-page-header { align-items: stretch; flex-direction: column; gap: 14px; }.tenant-page-header h2 { font-size: 24px !important; }.tenant-dashboard-toolbar { justify-content: space-between; overflow-x: auto; }.tenant-dashboard-toolbar .ant-segmented { flex: 1; }.tenant-dashboard-toolbar .ant-segmented-item-label { padding-inline: 8px; }.tenant-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; }.tenant-metric-card .ant-card-body { min-height: 118px; display: block; padding: 14px; }.tenant-metric-icon { width: 36px; height: 36px; margin-bottom: 10px; }.tenant-metric-card strong { font-size: 20px; }.tenant-user-composition { grid-template-columns: 1fr; }.tenant-onboarding-list { grid-template-columns: 1fr; }.tenant-dashboard-panel .ant-card-body { padding: 15px; }.tenant-feature-placeholder { margin: 0; }.site-builder-tabs { margin-inline: -4px; padding-inline: 10px; overflow: hidden; }
.builder-sortable { grid-template-columns: 44px minmax(0, 1fr) auto; }
.builder-sortable .ant-typography { grid-column: 2; }
.builder-navigation-row { grid-template-columns: minmax(0, 1fr) auto; }
.builder-navigation-row code { grid-column: 1 / -1; grid-row: 2; }
}
.preview-frame {
container-type: inline-size;

View File

@@ -1,8 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import 'antd/dist/reset.css';
import './index.css';
import { App } from './app/App';
import { queryClient } from './api';
async function enableMocking(): Promise<void> {
const dataMode = import.meta.env.VITE_DATA_MODE ?? (import.meta.env.DEV ? 'mock' : 'api');
@@ -15,6 +17,8 @@ await enableMocking();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -8,9 +8,20 @@ import {
onboardingOf,
runtimeOf,
updateTenant,
MOCK_OWNER_PERMISSIONS,
} from './store';
import { cloneConfig } from '../shared/defaults';
const demoRegionId = '10000000-0000-0000-0000-000000000001';
const demoSubjectId = '20000000-0000-0000-0000-000000000001';
const demoCategoryId = '30000000-0000-0000-0000-000000000001';
const demoQuestionId = '40000000-0000-0000-0000-000000000001';
const demoSessionId = '50000000-0000-0000-0000-000000000001';
const demoSessionQuestionId = '60000000-0000-0000-0000-000000000001';
const mockAssets = new Map<string, { fileName: string; mimeType: string }>();
let mockSessionVersion = 1;
let mockLastAnswer: Record<string, unknown> | null = null;
function hostOf(request: Request): string {
return request.headers.get('X-Demo-Host') || new URL(request.url).hostname;
}
@@ -38,13 +49,15 @@ export const handlers = [
const host = hostOf(request);
const body = await request.json() as LoginRequest;
const state = ensureTenant(host);
if (body.identifier !== state.ownerIdentifier || body.password !== state.password) {
const ownerLogin = body.identifier === state.ownerIdentifier && body.password === state.password;
const studentLogin = body.identifier === 'student@example.com' && body.password === 'Demo1234';
if (!ownerLogin && !studentLogin) {
return HttpResponse.json({ code: 'login_failed', title: '账号或密码错误。' }, { status: 401 });
}
const next = updateTenant(host, current => {
current.session = {
user: { userId: current.tenantId, name: '租户负责人', email: 'owner@academy.example' },
permissions: ['tenant:dashboard:view', 'tenant:settings:manage', 'tenant:site-content:manage'],
permissions: ownerLogin ? [...MOCK_OWNER_PERMISSIONS] : [],
};
});
return HttpResponse.json({ status: 'Authenticated', user: next.session!.user });
@@ -128,4 +141,98 @@ export const handlers = [
return problem(error);
}
}),
http.post('/api/tenant/auth/browser/sms/send', () => HttpResponse.json({ verificationId: crypto.randomUUID(), expiresAt: new Date(Date.now() + 300_000).toISOString() })),
http.post('/api/tenant/auth/browser/password/reset/sms/send', () => HttpResponse.json({ verificationId: crypto.randomUUID(), expiresAt: new Date(Date.now() + 300_000).toISOString() })),
http.post('/api/tenant/auth/browser/login/sms', ({ request }) => {
const next = updateTenant(hostOf(request), current => {
current.session = { user: { userId: current.tenantId, name: '演示学生', phone: '13800000000' }, permissions: [] };
});
return HttpResponse.json({ status: 'Authenticated', user: next.session!.user });
}),
http.post('/api/tenant/auth/browser/password/reset', () => HttpResponse.json({ status: 'PasswordReset' })),
http.post('/api/tenant/auth/browser/refresh', () => HttpResponse.json({ status: 'Authenticated' })),
http.get('/api/student/profile/me', ({ request }) => {
const state = ensureTenant(hostOf(request));
if (!state.session) return HttpResponse.json({ code: 'authentication_required' }, { status: 401 });
return HttpResponse.json({
profileId: state.tenantId,
userId: state.session.user.userId,
username: 'demo-student',
name: state.session.user.name ?? '演示学生',
phone: state.session.user.phone ?? null,
email: state.session.user.email ?? null,
primaryRole: 'Student',
membership: { isSvip: false, expiresAt: null },
score: 120,
questionsAnsweredToday: 8,
masteredWordsCount: 36,
regionId: state.studentRegionId ?? null,
regionName: state.studentRegionId ? '天津' : null,
recentPractices: [],
});
}),
http.patch('/api/student/profile/me', async ({ request }) => {
const body = await request.json() as { regionId: string };
const state = updateTenant(hostOf(request), current => { current.studentRegionId = body.regionId; });
return HttpResponse.json({ profileId: state.tenantId, userId: state.session?.user.userId ?? state.tenantId, username: 'demo-student', name: '演示学生', phone: null, email: null, primaryRole: 'Student', membership: { isSvip: false, expiresAt: null }, score: 120, questionsAnsweredToday: 8, masteredWordsCount: 36, regionId: body.regionId, regionName: '天津', recentPractices: [] });
}),
http.get('/api/student/profile/exam-countdowns', () => HttpResponse.json({ items: [{ id: crypto.randomUUID(), examName: '目标考试', examDate: '2026-12-20', daysLeft: 138 }] })),
http.get('/api/public/catalog/regions', () => HttpResponse.json({ items: [{ id: demoRegionId, name: '天津', code: 'tianjin', isActive: true }], total: 1 })),
http.get('/api/public/catalog/subjects', () => HttpResponse.json({ items: [{ id: demoSubjectId, name: '公共基础', description: '面向成人考试的综合基础科目', type: null, isActive: true }], total: 1 })),
http.get('/api/public/catalog/categories', () => HttpResponse.json({ items: [{ id: demoCategoryId, subjectId: demoSubjectId, name: '第一章 基础训练', isActive: true }], total: 1 })),
http.get('/api/public/catalog/practice-blueprints', () => HttpResponse.json({ items: [], total: 0 })),
http.get('/api/public/catalog/banners', () => HttpResponse.json({ items: [{ id: crypto.randomUUID(), title: '从今天的一组真题开始', subtitle: '稳步练习,及时复盘。', buttonText: '开始练习', buttonLink: '/subjects', isActive: true }], total: 1 })),
http.get('/api/public/catalog/announcements', () => HttpResponse.json({ items: [{ id: crypto.randomUUID(), content: '本周题库内容已更新。', isActive: true }], total: 1 })),
http.get('/api/public/catalog/vocabulary-units', () => HttpResponse.json({ items: [{ id: '71000000-0000-0000-0000-000000000001', name: '考研核心词汇', description: '高频基础词与真题常见表达', wordCount: 120, order: 1, regionId: demoRegionId, legacyId: null, entryId: null, contentNodeId: null, metadata: {} }], total: 1 })),
http.get('/api/public/catalog/vocabulary-words', () => HttpResponse.json({ items: [{ id: '72000000-0000-0000-0000-000000000001', unitId: '71000000-0000-0000-0000-000000000001', word: 'essential', phonetic: '/ɪˈsenʃl/', meaning: 'adj. 必不可少的;本质的', example: 'Practice is essential for steady improvement.', exampleTranslation: '练习对于稳定进步至关重要。', difficulty: 2, order: 1, legacyId: null, entryId: null, contentNodeId: null, tags: [], metadata: {} }], total: 1 })),
http.get('/api/public/catalog/handbook-subjects', () => HttpResponse.json({ items: [{ id: '73000000-0000-0000-0000-000000000001', name: '高等数学', description: '核心公式、题型与解题方法', regionId: demoRegionId, schoolId: null, majorId: null, entryId: null, contentNodeId: null, legacyId: null, type: null, icon: null, color: null, order: 1, metadata: {} }], total: 1 })),
http.get('/api/public/catalog/handbook-chapters', () => HttpResponse.json({ items: [{ id: '74000000-0000-0000-0000-000000000001', subjectId: '73000000-0000-0000-0000-000000000001', name: '第一章 函数与极限', description: '极限定义、性质和常用计算方法', entryId: null, contentNodeId: null, legacyId: null, order: 1, metadata: {} }], total: 1 })),
http.get('/api/public/catalog/handbook-entries', () => HttpResponse.json({ items: [{ id: '75000000-0000-0000-0000-000000000001', chapterId: '74000000-0000-0000-0000-000000000001', title: '两个重要极限', summary: '掌握常见等价无穷小与两个重要极限。', content: '## 重点公式\n\n当 $x \\to 0$ 时:\n\n$$\\frac{\\sin x}{x} \\to 1$$', entryId: null, contentNodeId: null, legacyId: null, tags: ['极限'], order: 1, metadata: {} }], total: 1 })),
http.get('/api/public/scoreline/years', () => HttpResponse.json({ items: [2026, 2025, 2024], total: 3 })),
http.get('/api/public/scoreline/records', () => HttpResponse.json({ page: 1, pageSize: 50, total: 1, items: [{ id: '76000000-0000-0000-0000-000000000001', legacyId: null, regionId: demoRegionId, schoolId: null, majorId: null, year: 2025, schoolName: '天津示例大学', majorName: '计算机科学与技术', fieldValues: { lowestScore: 248, averageScore: 276 } }] })),
http.get('/api/student/learning/stats', () => HttpResponse.json({ answerCount: 24, correctCount: 18, wrongCount: 6, favoriteQuestionCount: 2 })),
http.get('/api/student/learning/trend', () => HttpResponse.json({ items: [{ date: '2026-08-02', answerCount: 8 }, { date: '2026-08-03', answerCount: 16 }] })),
http.get('/api/student/learning/wrong-questions', () => HttpResponse.json({ items: [], total: 0 })),
http.get('/api/student/learning/favorites/questions', () => HttpResponse.json({ items: [], total: 0 })),
http.post('/api/student/learning/favorites/questions', () => HttpResponse.json({ favorite: true })),
http.post('/api/student/learning/practice-sessions', () => {
mockSessionVersion = 1;
mockLastAnswer = null;
return HttpResponse.json({ id: demoSessionId, version: mockSessionVersion, status: 'Active' });
}),
http.get('/api/student/learning/practice-sessions/detail', () => HttpResponse.json({
session: { id: demoSessionId, version: mockSessionVersion, lastClientSequence: mockLastAnswer ? 1 : 0, durationMinutes: null, status: 'Active' },
questions: [{ sessionQuestionId: demoSessionQuestionId, questionReferenceId: demoQuestionId, type: 'single_choice', typeLabel: '单选题', content: '下列 HTML 中,语义化主内容标签是?', options: [{ key: 'A', content: '`<main>`' }, { key: 'B', content: '`<div>`' }] }],
answersBySessionQuestion: mockLastAnswer ? { [demoSessionQuestionId]: mockLastAnswer } : {},
})),
http.post('/api/student/learning/answers', async ({ request }) => {
const body = await request.json() as { clientSequence?: number };
mockSessionVersion += 1;
mockLastAnswer = { status: 'Graded', isCorrect: true, sessionVersion: mockSessionVersion, clientSequence: body.clientSequence ?? 1 };
return HttpResponse.json(mockLastAnswer);
}),
http.post('/api/student/learning/practice-sessions/submit', () => {
mockSessionVersion += 1;
return HttpResponse.json({ id: demoSessionId, version: mockSessionVersion, status: 'Submitted' });
}),
http.get('/api/student/learning/practice-sessions/report', () => HttpResponse.json({ practiceSessionId: demoSessionId, status: 'Final', score: 1, totalScore: 1, accuracy: 1, correctCount: 1, wrongCount: 0, pendingReviewCount: 0, durationSeconds: 42 })),
http.post('/api/tenant/content/assets/uploads/sign', async ({ request }) => {
const body = await request.json() as { fileName: string; mimeType: string };
const id = crypto.randomUUID();
mockAssets.set(id, { fileName: body.fileName, mimeType: body.mimeType });
return HttpResponse.json({ item: { id }, upload: { method: 'PUT', url: `/mock-oss/${id}`, headers: { 'x-mock-upload': 'theme' }, expiresAt: new Date(Date.now() + 900_000).toISOString() } });
}),
http.put('/mock-oss/:assetId', () => new HttpResponse(null, { status: 200 })),
http.post('/api/tenant/content/assets/uploads/confirm', () => HttpResponse.json({ status: 'Completed' })),
http.get('/api/student/assets/:assetId/preview', ({ params }) => {
const id = String(params.assetId);
const item = mockAssets.get(id);
if (!item) return HttpResponse.json({ code: 'asset_not_found' }, { status: 404 });
return HttpResponse.json({ item: { id, fileName: item.fileName, previewUrl: null, uploadStatus: 2, securityScanStatus: 2, previewStatus: 2, visibility: 0 }, access: { userId: null, isMember: false, hasSvip: false }, url: { provider: 'mock', bucket: null, objectKey: id, method: 'GET', url: `/mock-oss/${id}`, headers: {}, expiresAt: new Date(Date.now() + 1_800_000).toISOString(), expiresInSeconds: 1800, signatureMode: 'mock' }, watermark: null });
}),
];

View File

@@ -12,9 +12,40 @@ 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',
'tenant:role:manage',
'tenant:student:manage',
'tenant:question-bank:manage',
'tenant:vocabulary:manage',
'tenant:handbook:manage',
'tenant:video:manage',
'tenant:scoreline:manage',
'tenant:site-content:manage',
'tenant:settings:manage',
'tenant:provider:manage',
'tenant:commerce:operate',
'tenant:crm:manage',
'tenant:commission:manage',
'tenant:job:manage',
'tenant:billing:manage',
];
export const MOCK_OWNER_PERMISSIONS = [...OWNER_PERMISSIONS];
const MOCK_BACKOFFICE_FEATURES = [
'core.backoffice',
'question_bank.private',
'learning.practice',
'learning.exam',
'content.vocabulary',
'content.handbook',
'content.video',
'content.scoreline',
'marketing.site_content',
'student.management',
'commerce.student_store',
'crm.followup',
'growth.referral_commission',
];
export interface DemoActivation {
@@ -39,6 +70,7 @@ export interface DemoTenantState {
password: string | null;
activation: DemoActivation;
session: DemoSession | null;
studentRegionId?: string | null;
config: TenantSiteConfig;
}
@@ -56,7 +88,7 @@ export function createDemoTenant(host = 'academy.localhost'): DemoTenantState {
return {
host,
tenantId: randomId(),
tenantCode: host.split('.')[0].replace(/[^a-z0-9-]/gi, '-').toLowerCase() || 'academy',
tenantCode: (host.split('.')[0] ?? 'academy').replace(/[^a-z0-9-]/gi, '-').toLowerCase() || 'academy',
tenantName: config.branding.brandName,
siteState: 'setup_required',
ownerIdentifier: 'owner@academy.example',
@@ -68,6 +100,7 @@ export function createDemoTenant(host = 'academy.localhost'): DemoTenantState {
consumedAt: null,
},
session: null,
studentRegionId: null,
config: {
schemaVersion: 1,
configVersion: 1,
@@ -141,7 +174,7 @@ export function runtimeOf(state: DemoTenantState): TenantRuntimeBootstrap {
tenantName: state.tenantName,
siteState: state.siteState,
...cloneConfig(state.config.published),
enabledFeatures: ['core.backoffice', 'student.practice', 'site.content'],
enabledFeatures: ['core.backoffice', 'learning.practice', 'content.vocabulary', 'content.handbook', 'content.scoreline', 'commerce.student_store', 'marketing.site_content'],
loginMethods: ['password', 'sms'],
};
}
@@ -149,13 +182,24 @@ export function runtimeOf(state: DemoTenantState): TenantRuntimeBootstrap {
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 permissions = OWNER_PERMISSIONS;
const menus = [
{ code: 'tenant.dashboard', name: '工作台', path: '/tenant/dashboard', requiredPermission: 'tenant:dashboard:view' },
{ code: 'tenant.site-content', name: '站点设计', path: '/tenant/site-content', requiredPermission: 'tenant:settings:manage' },
].filter(menu => state.session!.permissions.includes(menu.requiredPermission));
{ code: 'tenant.dashboard', name: '租户总览', path: '/tenant/dashboard', requiredPermission: 'tenant:dashboard:view' },
{ code: 'tenant.staff', name: '员工与权限', path: '/tenant/staff', requiredPermission: 'tenant:staff:manage' },
{ code: 'tenant.students', name: '班级与学生', path: '/tenant/students', requiredPermission: 'tenant:student:manage' },
{ code: 'tenant.question-bank', name: '私有题库', path: '/tenant/question-bank', requiredPermission: 'tenant:question-bank:manage' },
{ code: 'tenant.vocabulary', name: '词汇', path: '/tenant/vocabulary', requiredPermission: 'tenant:vocabulary:manage' },
{ code: 'tenant.handbook', name: '知识手册', path: '/tenant/handbook', requiredPermission: 'tenant:handbook:manage' },
{ code: 'tenant.video', name: '视频', path: '/tenant/video', requiredPermission: 'tenant:video:manage' },
{ code: 'tenant.scoreline', name: '分数线', path: '/tenant/scoreline', requiredPermission: 'tenant:scoreline:manage' },
{ code: 'tenant.site-content', name: '运营内容', path: '/tenant/site-content', requiredPermission: 'tenant:site-content:manage' },
{ code: 'tenant.providers', name: '外部服务', path: '/tenant/providers', requiredPermission: 'tenant:provider:manage' },
{ code: 'tenant.commerce', name: '交易运营', path: '/tenant/commerce', requiredPermission: 'tenant:commerce:operate' },
{ code: 'tenant.billing', name: 'SaaS 账务', path: '/tenant/billing', requiredPermission: 'tenant:billing:manage' },
].filter(menu => permissions.includes(menu.requiredPermission));
return {
permissions: [...state.session.permissions],
enabledFeatures: ['core.backoffice', 'site.content'],
permissions: [...permissions],
enabledFeatures: [...MOCK_BACKOFFICE_FEATURES],
menus,
};
}

View File

@@ -0,0 +1,42 @@
import Markdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeSanitize from "rehype-sanitize";
import "katex/dist/katex.min.css";
interface MarkdownNode {
type: string;
value?: string;
children?: MarkdownNode[];
}
/** Keep raw tags visible for HTML questions while ensuring React only receives text. */
function preserveHtmlQuestionSource() {
return (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.type === "html") node.type = "text";
node.children?.forEach(visit);
};
visit(tree);
};
}
export function SafeMarkdown({ children, className = "" }: { children: string; className?: string }) {
return (
<div className={`safe-markdown ${className}`}>
<Markdown
urlTransform={defaultUrlTransform}
remarkPlugins={[remarkGfm, remarkMath, preserveHtmlQuestionSource]}
rehypePlugins={[rehypeSanitize, rehypeKatex]}
components={{
a: ({ href, children: label }) => (
<a href={href} target="_blank" rel="noopener noreferrer">{label}</a>
),
}}
>
{children}
</Markdown>
</div>
);
}

View File

@@ -1,45 +1,18 @@
import { BookOpen, ChartNoAxesCombined, GraduationCap, Megaphone, MessageCircle, ShoppingBag, Sparkles } from 'lucide-react';
import { ArrowRight, BarChart3, BookOpenCheck, Clock3, GraduationCap, Target } from 'lucide-react';
import type { HomeModule, TenantFrontendConfigDraft } from '../contracts';
import { normalizeStudentNavigation, studentFeatures, studentNavigationGroups } from '../student/features';
import { useThemeAssets } from '../theme/useThemeAssets';
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>;
function PreviewModule({ module }: { module: HomeModule }) {
return <article className="site-preview-module"><strong>{module.title}</strong><span> <ArrowRight /></span></article>;
}
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>
);
const assets = useThemeAssets(branding);
const navigation = normalizeStudentNavigation(config.navigation).filter((item) => item.visible);
return <div className={`site-student-preview ${compact ? 'is-compact' : ''}`} style={{ '--preview-primary': theme.primaryColor, '--preview-radius': `${theme.radius}px`, background: theme.backgroundColor, color: theme.textColor, fontFamily: theme.fontFamily } as React.CSSProperties}>
<aside><header>{assets.logo ? <img src={assets.logo} alt={branding.assetAlt?.logo || `${branding.brandName} Logo`} /> : <i><GraduationCap /></i>}<div><strong>{branding.shortName || branding.brandName}</strong><small>{branding.brandName}</small></div></header><nav>{studentNavigationGroups.map((group) => { const items = navigation.filter((item) => studentFeatures.find((feature) => feature.path === item.href)?.group === group.code); if (!items.length) return null; return <section key={group.code}><p>{group.label}</p>{items.map((item, index) => { const feature = studentFeatures.find((value) => value.path === item.href); const Icon = feature?.icon || GraduationCap; return <span className={index === 0 && group.code === 'learning' ? 'is-active' : ''} key={item.id}><Icon />{item.label}{feature?.implementation !== 'ready' && <em></em>}</span>; })}</section>; })}</nav><footer><i>{branding.shortName?.slice(0,1) || '学'}</i><span><strong></strong><small></small></span></footer></aside>
<main><header><span> · </span><button></button></header><div className="site-preview-content"><div className="site-preview-welcome"><div><small>TODAY'S FOCUS</small><h2>你好,演示学员</h2><p>保持节奏,完成今天的学习任务。</p></div><button>开始练习</button></div><section className="site-preview-hero" style={{ background: `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})` }}><div><small>今日学习计划</small><h1>{branding.slogan || ''}</h1><p>课程、题库和成长规划汇聚在同一个学习工作台。</p></div>{assets.hero ? <img src={assets.hero} alt={branding.assetAlt?.hero || ''} /> : <Target />}</section><section className="site-preview-stats"><article><BookOpenCheck /><span><small>累计答题</small><strong>1,280</strong></span></article><article><BarChart3 /><span><small>正确率</small><strong>82%</strong></span></article><article><Clock3 /><span><small>学习天数</small><strong>36</strong></span></article></section><section className="site-preview-modules">{config.homeModules.filter((item) => item.visible && item.type !== 'hero').slice(0, 4).map((module) => <PreviewModule key={module.id} module={module} />)}</section></div></main>
</div>;
}

View File

@@ -1,6 +1,7 @@
import type { BackofficeBootstrap } from '../contracts';
export const SITE_SETTINGS_PERMISSION = 'tenant:settings:manage';
export const THEME_ASSET_PERMISSION = 'tenant:question-bank:manage';
export function hasBackofficeAccess(bootstrap: BackofficeBootstrap | null): boolean {
return Boolean(bootstrap?.permissions.length);
@@ -9,3 +10,7 @@ export function hasBackofficeAccess(bootstrap: BackofficeBootstrap | null): bool
export function canManageSite(bootstrap: BackofficeBootstrap | null): boolean {
return Boolean(bootstrap?.permissions.includes(SITE_SETTINGS_PERMISSION));
}
export function canManageThemeAssets(bootstrap: BackofficeBootstrap | null): boolean {
return Boolean(bootstrap?.permissions.includes(THEME_ASSET_PERMISSION));
}

View File

@@ -1,4 +1,5 @@
import type { TenantFrontendConfigDraft } from '../contracts';
import { knownStudentNavigation } from '../student/features';
export const templateNames = {
clarity: '清朗学习',
@@ -26,14 +27,15 @@ export function createDefaultConfig(): TenantFrontendConfigDraft {
...templateThemes.clarity,
fontFamily: 'system-ui, sans-serif',
radius: 18,
surfaceColor: '#ffffff',
mutedColor: '#eef2f8',
borderColor: '#dfe5ef',
successColor: '#15803d',
warningColor: '#b45309',
dangerColor: '#dc2626',
},
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 },
],
navigation: knownStudentNavigation(),
homeModules: [
{ id: 'hero', type: 'hero', title: '首页主视觉', visible: true },
{ id: 'notice', type: 'announcement', title: '最新公告', visible: true },

8
src/shared/number.ts Normal file
View File

@@ -0,0 +1,8 @@
export function numeric(value: number | string | null | undefined): number {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
export function percent(value: number | string | null | undefined): string {
return `${Math.round(numeric(value) * (numeric(value) <= 1 ? 100 : 1))}%`;
}

View File

@@ -0,0 +1,60 @@
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Input, Progress, Space, Typography } from 'antd';
import type { BrandingConfig } from '../contracts';
import type { AssetSlotDefinition, AssetUploadState } from './types';
const slots: AssetSlotDefinition[] = [
{ role: 'logo', title: '品牌 Logo', description: '用于学生端导航、登录页和后台标识。', ratio: '建议 1:1PNG / WebP' },
{ role: 'favicon', title: '浏览器图标', description: '发布后显示在浏览器标签页。', ratio: '建议 1:1PNG / SVG' },
{ role: 'loginBackground', title: '登录背景', description: '登录页宽屏品牌背景,移动端会自适应裁切。', ratio: '建议 16:10WebP' },
{ role: 'hero', title: '首页 Hero', description: '公开品牌首页的主视觉资源。', ratio: '建议 4:3WebP' },
{ role: 'serviceQr', title: '客服二维码', description: '用于联系顾问模块,不应包含敏感信息。', ratio: '建议 1:1PNG' },
];
const fieldByRole = {
logo: 'logoAssetId', favicon: 'faviconAssetId', loginBackground: 'loginBackgroundAssetId',
hero: 'heroAssetId', serviceQr: 'serviceQrAssetId',
} as const;
export function AssetLibraryPanel({
branding, canUpload, uploads, onUpload, onRetry, onRemove, onAltChange,
}: {
branding: BrandingConfig;
canUpload: boolean;
uploads: Partial<Record<AssetSlotDefinition['role'], AssetUploadState>>;
onUpload: (slot: AssetSlotDefinition, file: File) => void;
onRetry: (slot: AssetSlotDefinition) => void;
onRemove: (slot: AssetSlotDefinition) => void;
onAltChange: (slot: AssetSlotDefinition, alt: string) => void;
}) {
return (
<div className="builder-panel-stack">
{!canUpload && <Alert type="warning" showIcon title="当前账号没有主题资产上传权限" description="当前 .NET 资产上传接口需要 tenant:question-bank:manage。你仍可编辑文字、主题与模块已有资产会原样保留不会回退为 Data URL。" />}
<div className="builder-asset-grid">
{slots.map((slot) => {
const state = uploads[slot.role];
const assetId = branding[fieldByRole[slot.role]];
return (
<Card key={slot.role} className="builder-asset-card" title={slot.title} extra={assetId ? <Typography.Text type="success"></Typography.Text> : null}>
<Typography.Paragraph type="secondary">{slot.description}<br /><small>{slot.ratio} 8 MB</small></Typography.Paragraph>
<label className={`builder-upload-button ${!canUpload ? 'is-disabled' : ''}`}>
<UploadOutlined /> {assetId ? '替换图片' : '选择图片'}
<input disabled={!canUpload || state?.status === 'uploading'} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" onChange={(event) => {
const file = event.target.files?.[0];
if (file) onUpload(slot, file);
event.currentTarget.value = '';
}} />
</label>
{state?.status === 'uploading' && <Progress percent={state.progress} size="small" status="active" />}
{state?.status === 'error' && <Alert className="mt-3" type="error" showIcon title={state.message} action={<Button size="small" icon={<ReloadOutlined />} onClick={() => onRetry(slot)}></Button>} />}
{assetId && <>
<Input className="mt-3" aria-label={`${slot.title}替代文本`} placeholder="图片替代文本" value={branding.assetAlt?.[slot.role] ?? ''} onChange={(event) => onAltChange(slot, event.target.value)} />
<Space className="mt-3"><Typography.Text code copyable>{assetId}</Typography.Text><Button danger type="text" icon={<DeleteOutlined />} disabled={!canUpload} onClick={() => onRemove(slot)}></Button></Space>
</>}
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Card, Col, Form, Input, Row, Typography } from 'antd';
import type { BrandingConfig } from '../contracts';
export function BrandPanel({ branding, onChange }: { branding: BrandingConfig; onChange: (patch: Partial<BrandingConfig>) => void }) {
return (
<Card title="品牌识别" extra={<Typography.Text type="secondary"></Typography.Text>}>
<Form layout="vertical">
<Row gutter={18}>
<Col xs={24} md={12}><Form.Item label="品牌全称" required><Input value={branding.brandName} onChange={(event) => onChange({ brandName: event.target.value })} /></Form.Item></Col>
<Col xs={24} md={12}><Form.Item label="品牌简称" required><Input value={branding.shortName} onChange={(event) => onChange({ shortName: event.target.value })} /></Form.Item></Col>
</Row>
<Form.Item label="品牌口号"><Input value={branding.slogan} onChange={(event) => onChange({ slogan: event.target.value })} /></Form.Item>
<Form.Item label="客服微信" extra="二维码图片请在资产库中上传。"><Input value={branding.serviceWechat} onChange={(event) => onChange({ serviceWechat: event.target.value })} /></Form.Item>
<Form.Item label="默认 Logo 替代文本" extra="用于已有 URL Logo 的兼容回退。"><Input value={branding.imageAlt ?? ''} onChange={(event) => onChange({ imageAlt: event.target.value })} /></Form.Item>
</Form>
</Card>
);
}

View File

@@ -0,0 +1,34 @@
import { DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { HolderOutlined } from '@ant-design/icons';
import { Card, Col, Input, Row, Switch, Tag, Typography } from 'antd';
import type { HomeModule, NavigationItem } from '../contracts';
import { studentFeatures } from '../student/features';
function SortableModule({ module, onChange }: { module: HomeModule; onChange: (patch: Partial<HomeModule>) => void }) {
const sortable = useSortable({ id: module.id });
return <div ref={sortable.setNodeRef} style={{ transform: CSS.Transform.toString(sortable.transform), transition: sortable.transition }} className="builder-sortable">
<button aria-label={`拖动${module.title}`} type="button" className="builder-drag-handle" {...sortable.attributes} {...sortable.listeners}><HolderOutlined /></button>
<Input value={module.title} aria-label="模块名称" onChange={(event) => onChange({ title: event.target.value })} />
<Typography.Text type="secondary">{module.type}</Typography.Text>
<Switch aria-label={`显示${module.title}`} checked={module.visible} onChange={(visible) => onChange({ visible })} />
</div>;
}
export function ModulesPanel({ navigation, modules, onNavigation, onModule, onReorder }: {
navigation: NavigationItem[];
modules: HomeModule[];
onNavigation: (id: string, patch: Partial<NavigationItem>) => void;
onModule: (id: string, patch: Partial<HomeModule>) => void;
onReorder: (activeId: string, overId: string) => void;
}) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
const dragEnd = ({ active, over }: DragEndEvent) => { if (over && active.id !== over.id) onReorder(String(active.id), String(over.id)); };
return <Row gutter={[20, 20]}>
<Col xs={24} lg={9}><Card title="导航菜单" extra="入口仍受功能注册表约束"><div className="builder-panel-stack">
{navigation.map((item) => { const feature = studentFeatures.find((value) => value.path === item.href); return <div className="builder-navigation-row" key={item.id}><Input value={item.label} onChange={(event) => onNavigation(item.id, { label: event.target.value })} /><code>{item.href}</code><Tag color={feature?.implementation === 'ready' ? 'success' : feature?.implementation === 'backend_missing' ? 'error' : 'processing'}>{feature?.implementation === 'ready' ? '已开放' : feature?.implementation === 'backend_missing' ? '缺后端契约' : '迁移中'}</Tag><Switch aria-label={`显示${item.label}`} checked={item.visible} onChange={(visible) => onNavigation(item.id, { visible })} /></div>; })}
</div></Card></Col>
<Col xs={24} lg={15}><Card title="首页模块" extra="拖动调整顺序"><DndContext sensors={sensors} onDragEnd={dragEnd}><SortableContext items={modules.map((item) => item.id)} strategy={verticalListSortingStrategy}><div className="builder-panel-stack">{modules.map((module) => <SortableModule key={module.id} module={module} onChange={(patch) => onModule(module.id, patch)} />)}</div></SortableContext></DndContext></Card></Col>
</Row>;
}

View File

@@ -0,0 +1,12 @@
import { DesktopOutlined, MobileOutlined } from '@ant-design/icons';
import { Segmented } from 'antd';
import type { TenantFrontendConfigDraft } from '../contracts';
import { SitePreview } from '../shared/SitePreview';
import type { PreviewMode } from './types';
export function PreviewPanel({ draft, mode, onModeChange }: { draft: TenantFrontendConfigDraft; mode: PreviewMode; onModeChange: (mode: PreviewMode) => void }) {
return <div>
<div className="mb-4 flex justify-center"><Segmented value={mode} onChange={(value) => onModeChange(value as PreviewMode)} options={[{ label: '桌面端', value: 'desktop', icon: <DesktopOutlined /> }, { label: '移动端', value: 'mobile', icon: <MobileOutlined /> }]} /></div>
<div className={`builder-preview-device ${mode === 'mobile' ? 'is-mobile' : 'is-desktop'}`}><SitePreview config={draft} compact /></div>
</div>;
}

View File

@@ -0,0 +1,41 @@
import { CheckOutlined } from '@ant-design/icons';
import { Card, Col, Form, Input, InputNumber, Radio, Row, Space, Tag, Typography } from 'antd';
import type { ThemeConfig } from '../contracts';
import { templateNames, templateThemes } from '../shared/defaults';
const colors: Array<[keyof Pick<ThemeConfig, 'primaryColor' | 'secondaryColor' | 'backgroundColor' | 'textColor' | 'surfaceColor' | 'mutedColor' | 'borderColor' | 'successColor' | 'warningColor' | 'dangerColor'>, string]> = [
['primaryColor', '品牌主色'], ['secondaryColor', '品牌辅助色'], ['backgroundColor', '页面背景'], ['surfaceColor', '卡片表面'],
['textColor', '正文文字'], ['mutedColor', '弱化表面'], ['borderColor', '边框'], ['successColor', '成功状态'], ['warningColor', '警告状态'], ['dangerColor', '错误状态'],
];
export function ThemePanel({ theme, template, onTemplate, onChange }: {
theme: ThemeConfig;
template: keyof typeof templateNames;
onTemplate: (value: keyof typeof templateNames) => void;
onChange: (patch: Partial<ThemeConfig>) => void;
}) {
return <div className="builder-panel-stack">
<Card title="视觉基线" extra={<Typography.Text type="secondary"></Typography.Text>}>
<Row gutter={[16, 16]}>
{(Object.keys(templateNames) as Array<keyof typeof templateNames>).map((key) => <Col xs={24} md={8} key={key}>
<button type="button" className={`builder-template ${template === key ? 'is-active' : ''}`} onClick={() => onTemplate(key)}>
<span className="builder-template-swatch" style={{ background: `linear-gradient(135deg, ${templateThemes[key].primaryColor}, ${templateThemes[key].secondaryColor})` }} />
<strong>{templateNames[key]}</strong>{template === key && <Tag icon={<CheckOutlined />} color="blue"></Tag>}
</button>
</Col>)}
</Row>
</Card>
<Card title="语义化主题 Token">
<Row gutter={[18, 16]}>{colors.map(([field, label]) => <Col xs={24} sm={12} lg={8} key={field}>
<label className="builder-color-field"><span>{label}</span><Space.Compact block><input type="color" value={theme[field] ?? '#ffffff'} onChange={(event) => onChange({ [field]: event.target.value })} /><Input value={theme[field] ?? ''} onChange={(event) => onChange({ [field]: event.target.value })} /></Space.Compact></label>
</Col>)}</Row>
<Form className="mt-6" layout="vertical">
<Form.Item label="界面字体"><Radio.Group value={theme.fontFamily} onChange={(event) => onChange({ fontFamily: event.target.value })}>
<Radio.Button value="Inter, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif">线</Radio.Button>
<Radio.Button value="'Source Han Serif SC', 'Songti SC', Georgia, serif">线</Radio.Button>
</Radio.Group></Form.Item>
<Form.Item label="圆角大小"><InputNumber min={0} max={32} addonAfter="px" value={theme.radius} onChange={(value) => onChange({ radius: value ?? 0 })} /></Form.Item>
</Form>
</Card>
</div>;
}

19
src/site-builder/types.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { ThemeAssetRole } from '../contracts';
export type BuilderTab = 'brand' | 'theme' | 'modules' | 'assets' | 'preview';
export type PreviewMode = 'desktop' | 'mobile';
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
export interface AssetUploadState {
file?: File;
progress: number;
status: 'idle' | 'uploading' | 'success' | 'error';
message?: string;
}
export interface AssetSlotDefinition {
role: Exclude<ThemeAssetRole, 'featureCard'>;
title: string;
description: string;
ratio: string;
}

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TenantFrontendConfigDraft, TenantSiteConfig, ThemeAssetRole } from '../contracts';
import { ApiError, assetApi, siteConfigApi } from '../api';
import { cloneConfig, templateNames, templateThemes } from '../shared/defaults';
import type { AssetUploadState, PreviewMode, SaveState } from './types';
import { normalizeStudentNavigation } from '../student/features';
const assetFieldByRole = {
logo: 'logoAssetId',
favicon: 'faviconAssetId',
loginBackground: 'loginBackgroundAssetId',
hero: 'heroAssetId',
serviceQr: 'serviceQrAssetId',
} as const;
function normalizeDraft(value: TenantFrontendConfigDraft): TenantFrontendConfigDraft {
const next = cloneConfig(value);
next.navigation = normalizeStudentNavigation(next.navigation);
return next;
}
export function useSiteBuilder(onPublished: () => Promise<void>) {
const [siteConfig, setSiteConfig] = useState<TenantSiteConfig | null>(null);
const [draft, setDraft] = useState<TenantFrontendConfigDraft | null>(null);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [previewMode, setPreviewMode] = useState<PreviewMode>('desktop');
const [error, setError] = useState('');
const [published, setPublished] = useState(false);
const [dirty, setDirty] = useState(false);
const [uploads, setUploads] = useState<Partial<Record<ThemeAssetRole, AssetUploadState>>>({});
const revisionRef = useRef(0);
const load = useCallback(async () => {
setError('');
try {
const value = await siteConfigApi.get();
setSiteConfig(value);
setDraft(normalizeDraft(value.draft));
setDirty(false);
setSaveState('saved');
} catch {
setError('无法读取站点配置,请重新登录后再试。');
}
}, []);
useEffect(() => { void load(); }, [load]);
const update = useCallback((mutator: (next: TenantFrontendConfigDraft) => void) => {
setDraft((current) => {
if (!current) return current;
const next = cloneConfig(current);
mutator(next);
revisionRef.current += 1;
setDirty(true);
setPublished(false);
return next;
});
}, []);
useEffect(() => {
if (!draft || !dirty) return;
const revision = revisionRef.current;
setSaveState('saving');
const timer = window.setTimeout(async () => {
try {
const value = await siteConfigApi.saveDraft(draft);
setSiteConfig(value);
setSaveState('saved');
if (revisionRef.current === revision) setDirty(false);
} catch {
setSaveState('error');
}
}, 700);
return () => window.clearTimeout(timer);
}, [draft, dirty]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (!dirty && saveState !== 'saving') return;
event.preventDefault();
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty, saveState]);
const chooseTemplate = (template: keyof typeof templateNames) => update((next) => {
next.features.template = template;
Object.assign(next.theme, templateThemes[template]);
});
const uploadAsset = useCallback(async (
role: Exclude<ThemeAssetRole, 'featureCard'>,
file: File,
title: string,
) => {
setUploads((current) => ({ ...current, [role]: { file, progress: 0, status: 'uploading' } }));
try {
const uploaded = await assetApi.uploadThemeImage(file, title, (progress) => {
setUploads((current) => ({ ...current, [role]: { file, progress, status: 'uploading' } }));
});
update((next) => {
next.branding[assetFieldByRole[role]] = uploaded.assetId;
next.branding.assetAlt ??= {};
});
setUploads((current) => ({ ...current, [role]: { file, progress: 100, status: 'success' } }));
} catch (reason) {
setUploads((current) => ({
...current,
[role]: {
file,
progress: current[role]?.progress ?? 0,
status: 'error',
message: reason instanceof Error ? reason.message : '上传失败,请重试。',
},
}));
}
}, [update]);
const removeAsset = useCallback((role: Exclude<ThemeAssetRole, 'featureCard'>) => {
update((next) => { delete next.branding[assetFieldByRole[role]]; });
setUploads((current) => ({ ...current, [role]: { progress: 0, status: 'idle' } }));
}, [update]);
const retryAsset = useCallback((role: Exclude<ThemeAssetRole, 'featureCard'>, title: string) => {
const file = uploads[role]?.file;
if (file) void uploadAsset(role, file, title);
}, [uploadAsset, uploads]);
const publish = useCallback(async () => {
if (!draft) return;
setError('');
try {
setSaveState('saving');
const saved = await siteConfigApi.saveDraft(draft);
const next = await siteConfigApi.publish(saved.configVersion);
setSiteConfig(next);
setDraft(normalizeDraft(next.draft));
setDirty(false);
setSaveState('saved');
setPublished(true);
await onPublished();
} catch (reason) {
setSaveState('error');
if (reason instanceof ApiError && reason.code === 'frontend_config_version_conflict') {
setError('配置已经被其他管理员修改。请重新载入最新草稿,确认后再发布。');
} else {
setError('发布失败,草稿仍保留在当前页面,请稍后重试。');
}
}
}, [draft, onPublished]);
return {
siteConfig, draft, saveState, previewMode, error, published, dirty, uploads,
setPreviewMode, setPublished, update, chooseTemplate, uploadAsset, removeAsset,
retryAsset, publish, reload: load,
};
}

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { profileApi, queryKeys } from "../api";
import { ApiError } from "../api/http";
import type { StudentProfile } from "../api/types";
import { applyRememberedDevelopmentRegion } from "./developmentCatalog";
interface StudentSessionValue {
profile: StudentProfile | null;
loading: boolean;
authenticated: boolean;
error: Error | null;
refresh: () => Promise<unknown>;
}
const StudentSessionContext = createContext<StudentSessionValue | null>(null);
export function StudentSessionProvider({ children }: { children: ReactNode }) {
const query = useQuery({
queryKey: queryKeys.profile,
queryFn: profileApi.me,
retry: false,
staleTime: 60_000,
});
const isGuest = query.error instanceof ApiError && query.error.status === 401;
const value: StudentSessionValue = {
profile: query.data ? applyRememberedDevelopmentRegion(query.data) : null,
loading: query.isLoading,
authenticated: Boolean(query.data),
error: query.error && !isGuest ? query.error : null,
refresh: query.refetch,
};
return <StudentSessionContext.Provider value={value}>{children}</StudentSessionContext.Provider>;
}
export function useStudentSession(): StudentSessionValue {
const value = useContext(StudentSessionContext);
if (!value) throw new Error("useStudentSession must be used inside StudentSessionProvider");
return value;
}

View File

@@ -1,44 +1,107 @@
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';
import { Clock3, CloudOff, LockKeyhole, ShieldCheck } from "lucide-react";
import { lazy, Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useRuntime } from "../app/RuntimeProvider";
import { FullScreenLoading, NotFoundPage } from "../shared/StatusPages";
import { StudentLayout } from "./components/StudentLayout";
import { StudentSessionProvider, useStudentSession } from "./StudentSession";
import { getStudentFeature, isStudentFeatureAvailable } from "./features";
const PublicLandingPage = lazy(() => import("./pages/PublicLandingPage"));
const StudentLoginPage = lazy(() => import("./pages/StudentLoginPage"));
const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage"));
const RegionSelectorPage = lazy(() => import("./pages/RegionSelectorPage"));
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
const SubjectsPage = lazy(() => import("./pages/SubjectsPage"));
const SubjectDetailPage = lazy(() => import("./pages/SubjectDetailPage"));
const QuizPage = lazy(() => import("./pages/QuizPage"));
const PracticeReportPage = lazy(() => import("./pages/PracticeReportPage"));
const CollectionsPage = lazy(() => import("./pages/CollectionsPage"));
const ServicesPage = lazy(() => import("./pages/ServicesPage"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const FeaturePlaceholderPage = lazy(() => import("./pages/FeaturePlaceholderPage"));
const VocabularyPage = lazy(() => import("./pages/VocabularyPage"));
const VocabularyUnitPage = lazy(() => import("./pages/VocabularyUnitPage"));
const HandbookPage = lazy(() => import("./pages/HandbookPage"));
const HandbookChaptersPage = lazy(() => import("./pages/HandbookChaptersPage"));
const HandbookEntriesPage = lazy(() => import("./pages/HandbookEntriesPage"));
const HandbookEntryReaderPage = lazy(() => import("./pages/HandbookEntryReaderPage"));
const ScorelinePage = lazy(() => import("./pages/ScorelinePage"));
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>
);
return <div className="site-state-page"><div><span style={{ background: runtime!.theme.primaryColor }}><Icon /></span><p>{runtime!.branding.brandName}</p><h1>{title}</h1><article>{detail}</article><small><ShieldCheck /> </small></div></div>;
}
function Protected({ children, requireRegion = true, layout = true, feature }: { children: ReactNode; requireRegion?: boolean; layout?: boolean; feature?: string }) {
const location = useLocation();
const { runtime } = useRuntime();
const { authenticated, loading, profile, error } = useStudentSession();
if (loading) return <FullScreenLoading label="正在恢复学习会话…" />;
if (error) return <div className="error-panel"><p>{error.message}</p></div>;
if (!authenticated) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
if (requireRegion && !profile?.regionId) return <Navigate to="/select-region" replace />;
if (feature && runtime && !isStudentFeatureAvailable(runtime, feature)) {
const registered = getStudentFeature(runtime, feature);
if (!registered) return <NotFoundPage />;
const placeholder = <FeaturePlaceholderPage feature={registered} />;
return layout ? <StudentLayout>{placeholder}</StudentLayout> : placeholder;
}
return layout ? <StudentLayout>{children}</StudentLayout> : children;
}
function HomeRoute() {
const { runtime } = useRuntime();
const { authenticated, loading, profile } = useStudentSession();
if (loading) return <FullScreenLoading label="正在打开学习空间…" />;
if (!authenticated) return <PublicLandingPage />;
if (!profile?.regionId) return <Navigate to="/select-region" replace />;
if (!runtime || !isStudentFeatureAvailable(runtime, "dashboard")) return <NotFoundPage />;
return <StudentLayout><DashboardPage /></StudentLayout>;
}
function RegisteredFeatureRoute({ code }: { code: string }) {
const { runtime } = useRuntime();
if (!runtime) return <NotFoundPage />;
const feature = getStudentFeature(runtime, code);
if (!feature) return <NotFoundPage />;
return <Protected><FeaturePlaceholderPage feature={feature} /></Protected>;
}
function ActiveSiteRoutes() {
return <StudentSessionProvider><Suspense fallback={<FullScreenLoading />}><Routes>
<Route path="/" element={<HomeRoute />} />
<Route path="/login" element={<StudentLoginPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/select-region" element={<Protected requireRegion={false} layout={false}><RegionSelectorPage /></Protected>} />
<Route path="/subjects" element={<Protected feature="practice"><SubjectsPage /></Protected>} />
<Route path="/subject/:subjectId" element={<Protected feature="practice"><SubjectDetailPage /></Protected>} />
<Route path="/quiz" element={<Protected feature="practice" layout={false}><QuizPage /></Protected>} />
<Route path="/practice/report/:sessionId" element={<Protected feature="practice"><PracticeReportPage /></Protected>} />
<Route path="/collections" element={<Protected feature="collections"><CollectionsPage /></Protected>} />
<Route path="/services" element={<Protected><ServicesPage /></Protected>} />
<Route path="/profile" element={<Protected><ProfilePage /></Protected>} />
<Route path="/vocabulary" element={<Protected feature="vocabulary"><VocabularyPage /></Protected>} />
<Route path="/vocabulary/:unitId" element={<Protected feature="vocabulary"><VocabularyUnitPage /></Protected>} />
<Route path="/handbook" element={<Protected feature="handbook"><HandbookPage /></Protected>} />
<Route path="/handbook/subject/:subjectId" element={<Protected feature="handbook"><HandbookChaptersPage /></Protected>} />
<Route path="/handbook/chapter/:chapterId" element={<Protected feature="handbook"><HandbookEntriesPage /></Protected>} />
<Route path="/handbook/entry/:entryId" element={<Protected feature="handbook"><HandbookEntryReaderPage /></Protected>} />
<Route path="/store/*" element={<RegisteredFeatureRoute code="store" />} />
<Route path="/scoreline" element={<Protected feature="scoreline"><ScorelinePage /></Protected>} />
<Route path="/school-planning/*" element={<RegisteredFeatureRoute code="school-planning" />} />
<Route path="/activation-code/*" element={<RegisteredFeatureRoute code="activation-code" />} />
<Route path="/code-runner/*" element={<RegisteredFeatureRoute code="code-runner" />} />
<Route path="*" element={<NotFoundPage />} />
</Routes></Suspense></StudentSessionProvider>;
}
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>
);
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 <ActiveSiteRoutes />;
}

View File

@@ -0,0 +1,39 @@
import { MapPin } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { authApi } from "../../api";
import { useRuntime } from "../../app/RuntimeProvider";
import { useStudentSession } from "../StudentSession";
import { StudentMobileDock, StudentSidebar } from "./StudentNavigation";
const COLLAPSED_KEY = "tiku-saas-student-sidebar-collapsed";
export function StudentLayout({ children }: { children: ReactNode }) {
const { runtime, assets } = useRuntime();
const { profile } = useStudentSession();
const location = useLocation();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "1");
const focusedRoute = location.pathname === "/quiz";
useEffect(() => { window.localStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0"); }, [collapsed]);
if (!runtime) return null;
const logout = async () => {
await authApi.logout();
navigate("/", { replace: true });
window.location.reload();
};
return <div className={`student-app ${collapsed ? "student-app--collapsed" : ""} ${focusedRoute ? "student-app--focus" : ""}`}>
<a className="skip-link" href="#student-main"></a>
{!focusedRoute && <StudentSidebar runtime={runtime} assets={assets} profile={profile} collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} onLogout={() => void logout()} />}
<div className="student-grid-background" aria-hidden="true" />
<div className="student-workspace">
{!focusedRoute && <header className="student-topbar"><div className="student-topbar__location"><MapPin /><span>{profile?.regionName || "未选择地区"}</span></div><Link to="/select-region" className="quiet-button"></Link></header>}
<main id="student-main" className="student-main" tabIndex={-1}>{children}</main>
</div>
{!focusedRoute && <StudentMobileDock runtime={runtime} />}
<span className="sr-only" aria-live="polite">{location.pathname}</span>
</div>;
}

View File

@@ -0,0 +1,57 @@
import { LogOut, PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink } from "react-router";
import type { ResolvedThemeAssets, TenantRuntimeBootstrap } from "../../contracts";
import type { StudentProfile } from "../../api/types";
import { mobileStudentFeatures, publishedStudentFeatures, studentNavigationGroups } from "../features";
interface NavigationProps {
runtime: TenantRuntimeBootstrap;
assets: ResolvedThemeAssets;
profile: StudentProfile | null;
collapsed: boolean;
onToggle: () => void;
onLogout: () => void;
}
function FeatureLink({ feature, mobile = false }: { feature: ReturnType<typeof publishedStudentFeatures>[number]; mobile?: boolean }) {
const Icon = feature.icon;
return <NavLink to={feature.path} end={feature.path === "/"} title={feature.label} className={({ isActive }) => mobile ? (isActive ? "is-active" : "") : `student-nav-link ${isActive ? "is-active" : ""}`}>
<Icon /><span>{mobile ? feature.shortLabel || feature.label : feature.label}</span>
{!mobile && feature.status === "placeholder" && <em></em>}
</NavLink>;
}
export function StudentSidebar({ runtime, assets, profile, collapsed, onToggle, onLogout }: NavigationProps) {
const features = publishedStudentFeatures(runtime);
const role = profile?.primaryRole?.toLowerCase() ?? "";
const canEnterBackoffice = role.includes("owner") || role.includes("admin");
const displayName = profile?.name || profile?.username || "同学";
return <aside className="student-sidebar" aria-label="学习导航">
<div className="student-brand">
{assets.logo ? <img src={assets.logo} alt={runtime.branding.assetAlt?.logo || `${runtime.branding.brandName} Logo`} /> : <span className="student-brand__mark">{runtime.branding.shortName?.slice(0, 1) || "学"}</span>}
{!collapsed && <div><strong>{profile?.regionName ? `${profile.regionName} · ${runtime.branding.shortName}` : runtime.branding.shortName}</strong><small>{runtime.branding.brandName}</small></div>}
<button type="button" className="icon-button student-sidebar__toggle" onClick={onToggle} aria-label={collapsed ? "展开侧栏" : "收起侧栏"}>{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}</button>
</div>
<nav>
{studentNavigationGroups.map((group) => {
const groupFeatures = features.filter((feature) => feature.group === group.code);
if (!groupFeatures.length) return null;
return <section className="student-nav-group" key={group.code} aria-label={group.label}>
{!collapsed && <p>{group.label}</p>}
{groupFeatures.map((feature) => <FeatureLink key={feature.code} feature={feature} />)}
</section>;
})}
</nav>
<div className="student-sidebar__account">
{!collapsed && <><span className="student-account-avatar">{displayName.slice(0, 1).toUpperCase()}</span><div><strong>{displayName}</strong><small>{profile?.membership?.isSvip ? "SVIP 学员" : "普通会员"}</small></div></>}
{canEnterBackoffice && <NavLink to="/manage" className="icon-button" aria-label="租户后台" title="租户后台"><Settings /></NavLink>}
<button type="button" className={collapsed ? "icon-button student-logout" : "student-logout"} onClick={onLogout} aria-label="退出登录"><LogOut />{!collapsed && <span>退</span>}</button>
</div>
</aside>;
}
export function StudentMobileDock({ runtime }: { runtime: TenantRuntimeBootstrap }) {
return <nav className="student-mobile-dock" aria-label="移动端学习导航">
{mobileStudentFeatures(runtime).map((feature) => <FeatureLink key={feature.code} feature={feature} mobile />)}
</nav>;
}

View File

@@ -0,0 +1,35 @@
import type { Region, StudentProfile } from "../api/types";
const DEVELOPMENT_REGION_STORAGE_KEY = "tiku-saas-development-region";
export const developmentRegion: Region = {
id: "10000000-0000-0000-0000-000000000002",
legacyId: null,
name: "天津",
shortName: "津",
fullName: "天津市",
icon: null,
pinyin: "tianjin",
code: "120000",
isHot: true,
order: 0,
};
export function withDevelopmentRegionFallback(items: Region[], enabled = import.meta.env.DEV): Region[] {
return enabled && items.length === 0 ? [developmentRegion] : items;
}
export function isDevelopmentRegion(regionId: string): boolean {
return regionId === developmentRegion.id;
}
export function rememberDevelopmentRegion(): void {
if (import.meta.env.DEV) window.sessionStorage.setItem(DEVELOPMENT_REGION_STORAGE_KEY, developmentRegion.id);
}
export function applyRememberedDevelopmentRegion(profile: StudentProfile): StudentProfile {
if (!import.meta.env.DEV || profile.regionId || window.sessionStorage.getItem(DEVELOPMENT_REGION_STORAGE_KEY) !== developmentRegion.id) {
return profile;
}
return { ...profile, regionId: developmentRegion.id, regionName: `${developmentRegion.name}(演示)` };
}

100
src/student/features.ts Normal file
View File

@@ -0,0 +1,100 @@
import {
BadgeHelp,
BookMarked,
BookOpen,
ChartNoAxesCombined,
CodeXml,
Compass,
FileText,
GraduationCap,
KeyRound,
LayoutGrid,
ShoppingBag,
UserRound,
type LucideIcon,
} from "lucide-react";
import type { NavigationItem, TenantRuntimeBootstrap } from "../contracts";
export type StudentFeatureStatus = "live" | "placeholder" | "hidden";
export type StudentPlaceholderReason = "frontend_pending" | "backend_missing" | "tenant_disabled";
export type StudentNavigationGroup = "learning" | "specialized" | "planning" | "account";
export interface StudentFeature {
code: string;
label: string;
shortLabel?: string;
path: string;
group: StudentNavigationGroup;
requiredFeature?: string;
implementation: "ready" | "frontend_pending" | "backend_missing";
icon: LucideIcon;
mobileOrder?: number;
sidebar?: boolean;
description: string;
}
export interface ResolvedStudentFeature extends StudentFeature {
status: StudentFeatureStatus;
placeholderReason?: StudentPlaceholderReason;
}
export const studentNavigationGroups: Array<{ code: StudentNavigationGroup; label: string }> = [
{ code: "learning", label: "学习" },
{ code: "specialized", label: "专项能力" },
{ code: "planning", label: "升学服务" },
{ code: "account", label: "账户" },
];
export const studentFeatures: StudentFeature[] = [
{ code: "dashboard", label: "学习工作台", shortLabel: "首页", path: "/", group: "learning", implementation: "ready", icon: LayoutGrid, mobileOrder: 1, sidebar: true, description: "今日任务、学习进度和继续学习" },
{ code: "practice", label: "题库练习", shortLabel: "题库", path: "/subjects", group: "learning", requiredFeature: "learning.practice", implementation: "ready", icon: BookOpen, mobileOrder: 2, sidebar: true, description: "按科目和章节开始练习" },
{ code: "collections", label: "错题收藏", shortLabel: "复习", path: "/collections", group: "learning", requiredFeature: "learning.practice", implementation: "ready", icon: BookMarked, mobileOrder: 3, sidebar: true, description: "集中复习错题和收藏题" },
{ code: "vocabulary", label: "背单词", path: "/vocabulary", group: "specialized", requiredFeature: "content.vocabulary", implementation: "ready", icon: GraduationCap, sidebar: true, description: "词汇单元、记忆进度和收藏复习" },
{ code: "handbook", label: "知识手册", path: "/handbook", group: "specialized", requiredFeature: "content.handbook", implementation: "ready", icon: FileText, sidebar: true, description: "按科目与章节阅读知识要点" },
{ code: "store", label: "学习商城", path: "/store", group: "specialized", requiredFeature: "commerce.student_store", implementation: "frontend_pending", icon: ShoppingBag, sidebar: true, description: "课程套餐、订单和优惠权益" },
{ code: "scoreline", label: "院校分数线", path: "/scoreline", group: "planning", requiredFeature: "content.scoreline", implementation: "ready", icon: ChartNoAxesCombined, sidebar: true, description: "历年院校与专业分数线" },
{ code: "school-planning", label: "择校规划", path: "/school-planning", group: "planning", requiredFeature: "planning.school", implementation: "backend_missing", icon: Compass, sidebar: true, description: "根据目标和成绩生成择校建议" },
{ code: "profile", label: "个人中心", shortLabel: "我的", path: "/profile", group: "account", implementation: "ready", icon: UserRound, mobileOrder: 5, sidebar: true, description: "账户资料、学习数据和会员权益" },
{ code: "services", label: "学习服务", shortLabel: "服务", path: "/services", group: "specialized", implementation: "ready", icon: BadgeHelp, mobileOrder: 4, sidebar: false, description: "查看全部学习与升学服务" },
{ code: "activation-code", label: "激活码", path: "/activation-code", group: "account", requiredFeature: "commerce.activation.student", implementation: "backend_missing", icon: KeyRound, sidebar: false, description: "学生自助核销学习权益" },
{ code: "code-runner", label: "代码运行器", path: "/code-runner", group: "specialized", requiredFeature: "learning.code_runner", implementation: "backend_missing", icon: CodeXml, sidebar: false, description: "安全运行编程题代码" },
];
export const knownStudentNavigation = (): NavigationItem[] => studentFeatures
.filter((feature) => feature.sidebar)
.map((feature) => ({ id: feature.code, label: feature.label, href: feature.path, visible: true }));
export function normalizeStudentNavigation(items: NavigationItem[]): NavigationItem[] {
const configured = new Map(items.map((item) => [item.href, item]));
return knownStudentNavigation().map((fallback) => configured.get(fallback.href) ?? fallback);
}
export function resolveStudentFeatures(runtime: TenantRuntimeBootstrap): ResolvedStudentFeature[] {
const navigation = normalizeStudentNavigation(runtime.navigation);
return studentFeatures.reduce<ResolvedStudentFeature[]>((resolved, feature) => {
const configured = feature.sidebar ? navigation.find((item) => item.href === feature.path) : undefined;
if (configured && !configured.visible) return resolved;
const label = configured?.label || feature.label;
if (feature.implementation === "backend_missing") resolved.push({ ...feature, label, status: "placeholder", placeholderReason: "backend_missing" });
else if (feature.implementation === "frontend_pending") resolved.push({ ...feature, label, status: "placeholder", placeholderReason: "frontend_pending" });
else if (feature.requiredFeature && !runtime.enabledFeatures.includes(feature.requiredFeature)) resolved.push({ ...feature, label, status: "placeholder", placeholderReason: "tenant_disabled" });
else resolved.push({ ...feature, label, status: "live" });
return resolved;
}, []);
}
export function publishedStudentFeatures(runtime: TenantRuntimeBootstrap) {
return resolveStudentFeatures(runtime).filter((feature) => feature.sidebar);
}
export function mobileStudentFeatures(runtime: TenantRuntimeBootstrap) {
return resolveStudentFeatures(runtime).filter((feature) => feature.mobileOrder).sort((a, b) => a.mobileOrder! - b.mobileOrder!).slice(0, 5);
}
export function getStudentFeature(runtime: TenantRuntimeBootstrap, code: string) {
return resolveStudentFeatures(runtime).find((feature) => feature.code === code);
}
export function isStudentFeatureAvailable(runtime: TenantRuntimeBootstrap, code: string): boolean {
return getStudentFeature(runtime, code)?.status === "live";
}

View File

@@ -0,0 +1,17 @@
import { ArrowRight, Bookmark, Heart, LoaderCircle, RotateCcw } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router";
import { learningApi, queryKeys } from "../../api";
import { numeric } from "../../shared/number";
export default function CollectionsPage() {
const navigate = useNavigate();
const [tab, setTab] = useState<"wrong" | "favorite">("wrong");
const wrong = useQuery({ queryKey: queryKeys.wrongQuestions, queryFn: learningApi.wrongQuestions });
const favorites = useQuery({ queryKey: queryKeys.favorites, queryFn: learningApi.favorites });
const start = useMutation({ mutationFn: (mode: string) => learningApi.createSession({ mode, questionLimit: 50 }), onSuccess: (session) => navigate(`/quiz?sessionId=${session.id}`) });
const items = tab === "wrong" ? wrong.data?.items ?? [] : favorites.data?.items ?? [];
const loading = tab === "wrong" ? wrong.isLoading : favorites.isLoading;
return <div className="collections-page"><header className="page-heading"><div><p className="eyebrow">REVIEW CENTER</p><h1></h1><p></p></div><button className="primary-button" disabled={start.isPending || !items.length} onClick={() => start.mutate(tab === "wrong" ? "wrong_review" : "favorite_review")}>{start.isPending ? <LoaderCircle className="spin" /> : <RotateCcw />} </button></header><div className="segmented-control collection-tabs"><button aria-selected={tab === "wrong"} onClick={() => setTab("wrong")}><Bookmark /> {wrong.data?.items.length ?? 0}</button><button aria-selected={tab === "favorite"} onClick={() => setTab("favorite")}><Heart /> {favorites.data?.items.length ?? 0}</button></div>{loading ? <div className="skeleton-grid"><i /><i /><i /></div> : items.length ? <div className="collection-list">{items.map((item) => <article key={item.questionReferenceId}><span>{tab === "wrong" ? <Bookmark /> : <Heart />}</span><div><strong> {item.locator.questionId.slice(0, 8)}</strong><p>{tab === "wrong" ? `累计答错 ${numeric("wrongCount" in item ? item.wrongCount : 0)}` : `收藏于 ${new Date("createdAt" in item ? item.createdAt : Date.now()).toLocaleDateString("zh-CN")}`}</p></div><ArrowRight /></article>)}</div> : <div className="empty-panel"></div>}{start.error && <p className="form-message">{start.error.message}</p>}</div>;
}

View File

@@ -0,0 +1,62 @@
import { ArrowRight, BookMarked, BookOpen, BookOpenCheck, CalendarDays, CheckCircle2, Clock3, FileText, Flame, GraduationCap, Heart, Megaphone, Target } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router";
import { catalogApi, learningApi, profileApi, queryKeys } from "../../api";
import { numeric, percent } from "../../shared/number";
import { useStudentSession } from "../StudentSession";
import { useRuntime } from "../../app/RuntimeProvider";
import { getStudentFeature } from "../features";
function Metric({ label, value, icon: Icon, tone }: { label: string; value: string; icon: typeof Target; tone: string }) {
return <article className="metric-card"><span className={`metric-card__icon ${tone}`}><Icon /></span><div><small>{label}</small><strong>{value}</strong></div></article>;
}
export default function DashboardPage() {
const { profile } = useStudentSession();
const { runtime } = useRuntime();
const regionId = profile?.regionId;
const stats = useQuery({ queryKey: [...queryKeys.dashboard, "stats"], queryFn: learningApi.stats });
const trend = useQuery({ queryKey: [...queryKeys.dashboard, "trend"], queryFn: learningApi.trend });
const countdowns = useQuery({ queryKey: [...queryKeys.dashboard, "countdowns"], queryFn: profileApi.countdowns });
const banners = useQuery({ queryKey: [...queryKeys.dashboard, "banners", regionId], queryFn: () => catalogApi.banners(regionId) });
const announcements = useQuery({ queryKey: [...queryKeys.dashboard, "announcements", regionId], queryFn: () => catalogApi.announcements(regionId) });
const answerCount = numeric(stats.data?.answerCount);
const accuracy = answerCount ? numeric(stats.data?.correctCount) / answerCount : 0;
const trendItems = trend.data?.items ?? [];
const maxTrend = Math.max(1, ...trendItems.map((item) => numeric(item.answerCount)));
const banner = banners.data?.items.find((item) => item.isActive);
const countdown = countdowns.data?.items[0];
const quickServices = [
{ code: "practice", title: "章节练习", detail: "按科目进入题库", path: "/subjects", icon: BookOpen, tone: "blue" },
{ code: "collections", title: "错题复习", detail: "集中解决薄弱项", path: "/collections", icon: BookMarked, tone: "rose" },
{ code: "vocabulary", title: "核心词汇", detail: "按单元巩固记忆", path: "/vocabulary", icon: GraduationCap, tone: "amber" },
{ code: "handbook", title: "知识手册", detail: "考前重点快速查", path: "/handbook", icon: FileText, tone: "green" },
].filter((item) => runtime && getStudentFeature(runtime, item.code));
return <div className="dashboard-page">
<header className="dashboard-heading"><div><p className="eyebrow">TODAY'S FOCUS</p><h1>{profile?.name || profile?.username || "同学"}</h1><p></p></div><Link className="primary-button" to="/subjects"> <ArrowRight /></Link></header>
<section className="dashboard-hero">
<div><span className="dashboard-hero__badge"><Flame /> </span><h2>{banner?.title || "专注完成一组练习,再认真复盘一次"}</h2><p>{banner?.subtitle || "系统会保存你的进度,客观题由服务端判分,错题自动进入复习清单。"}</p><Link to={banner?.buttonLink || "/subjects"}>{banner?.buttonText || "继续学习"} <ArrowRight /></Link></div>
<div className="dashboard-hero__target"><Target /><strong>{countdown?.daysLeft === null || countdown?.daysLeft === undefined ? "--" : numeric(countdown.daysLeft)}</strong><span>{countdown?.examName || "目标考试"}</span></div>
</section>
<section className="dashboard-service-grid" aria-label="常用学习服务">{quickServices.map((service) => { const Icon = service.icon; return <Link className={`dashboard-service-card ${service.tone}`} to={service.path} key={service.code}><span><Icon /></span><div><strong>{service.title}</strong><small>{service.detail}</small></div><ArrowRight /></Link>; })}</section>
<section className="metric-grid" aria-label="学习数据">
<Metric label="累计答题" value={answerCount.toLocaleString("zh-CN")} icon={BookOpenCheck} tone="blue" />
<Metric label="正确率" value={percent(accuracy)} icon={CheckCircle2} tone="green" />
<Metric label="错题待复盘" value={numeric(stats.data?.wrongCount).toString()} icon={Target} tone="orange" />
<Metric label="收藏题目" value={numeric(stats.data?.favoriteQuestionCount).toString()} icon={Heart} tone="purple" />
</section>
<div className="dashboard-columns">
<section className="content-card learning-trend"><div className="section-heading"><div><p className="eyebrow">LEARNING RHYTHM</p><h2></h2></div><CalendarDays /></div>
{trend.isLoading ? <div className="chart-skeleton" /> : trendItems.length ? <div className="mini-chart" aria-label="近期每日答题量">{trendItems.map((item) => <span key={item.date} style={{ height: `${Math.max(10, numeric(item.answerCount) / maxTrend * 100)}%` }} title={`${item.date}${numeric(item.answerCount)}`} />)}</div> : <div className="empty-inline"></div>}
</section>
<section className="content-card recent-practice"><div className="section-heading"><div><p className="eyebrow">CONTINUE</p><h2></h2></div><Clock3 /></div>
{profile?.recentPractices.length ? <div className="recent-list">{profile.recentPractices.slice(0, 4).map((item) => <Link key={item.id} to={`/quiz?sessionId=${item.id}`}><span><strong>{item.targetName || "未完成练习"}</strong><small>{item.practiceType || "练习"}</small></span><i><b style={{ width: `${Math.min(100, numeric(item.progress))}%` }} /></i><em>{numeric(item.progress)}%</em></Link>)}</div> : <div className="empty-inline"></div>}
</section>
</div>
<section className="content-card announcements"><div className="section-heading"><div><p className="eyebrow">UPDATES</p><h2></h2></div><Megaphone /></div>
<div>{(announcements.data?.items ?? []).filter((item) => item.isActive).slice(0, 4).map((item) => <article key={item.id}><i /><p>{item.content || "学习内容已更新"}</p></article>)}</div>
{!announcements.isLoading && !(announcements.data?.items.length) && <div className="empty-inline"></div>}
</section>
</div>;
}

View File

@@ -0,0 +1,16 @@
import { ArrowLeft, Construction, DatabaseZap, ShieldAlert } from "lucide-react";
import { Link } from "react-router";
import type { ResolvedStudentFeature, StudentPlaceholderReason } from "../features";
const reasonContent: Record<StudentPlaceholderReason, { title: string; detail: string; icon: typeof Construction }> = {
frontend_pending: { title: "页面正在迁移", detail: "后端能力已经具备,学生端正在按旧版体验完成高保真迁移。", icon: Construction },
backend_missing: { title: "等待服务能力接入", detail: "当前后端还没有完整业务契约,页面不会调用猜测接口或生成虚假结果。", icon: DatabaseZap },
tenant_disabled: { title: "当前机构暂未开通", detail: "该服务尚未包含在当前机构的产品版本中,如有需要请联系机构老师。", icon: ShieldAlert },
};
export default function FeaturePlaceholderPage({ feature }: { feature: ResolvedStudentFeature }) {
const reason = feature.placeholderReason ?? "frontend_pending";
const content = reasonContent[reason];
const Icon = content.icon;
return <div className="feature-placeholder"><section><span><Icon /></span><p className="eyebrow">{feature.group.toUpperCase()} SERVICE</p><h1>{feature.label}</h1><h2>{content.title}</h2><p>{feature.description}{content.detail}</p><div><Link className="primary-button" to="/services"></Link><Link className="back-link" to="/"><ArrowLeft /> </Link></div></section></div>;
}

View File

@@ -0,0 +1,10 @@
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router";
import { catalogApi, queryKeys } from "../../api";
export default function HandbookChaptersPage() {
const subjectId = useParams().subjectId ?? "";
const query = useQuery({ queryKey: queryKeys.handbookChapters(subjectId), queryFn: () => catalogApi.handbookChapters(subjectId), enabled: Boolean(subjectId) });
return <div className="handbook-reader-list"><Link className="back-link" to="/handbook"><ArrowLeft /> </Link><header className="page-heading"><div><p className="eyebrow">HANDBOOK CHAPTERS</p><h1></h1><p></p></div></header>{query.isLoading ? <div className="skeleton-grid"><i /><i /></div> : query.data?.items.length ? <section>{query.data.items.map((item, index) => <Link to={`/handbook/chapter/${item.id}`} key={item.id}><span>{String(index + 1).padStart(2, "0")}</span><div><strong>{item.name}</strong><p>{item.description || "查看本章知识条目"}</p></div><ArrowRight /></Link>)}</section> : <div className="empty-panel"></div>}</div>;
}

View File

@@ -0,0 +1,10 @@
import { ArrowLeft, ArrowRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router";
import { catalogApi, queryKeys } from "../../api";
export default function HandbookEntriesPage() {
const chapterId = useParams().chapterId ?? "";
const query = useQuery({ queryKey: queryKeys.handbookEntries(chapterId), queryFn: () => catalogApi.handbookEntries(chapterId, true), enabled: Boolean(chapterId) });
return <div className="handbook-reader-list"><Link className="back-link" to="/handbook"><ArrowLeft /> </Link><header className="page-heading"><div><p className="eyebrow">KNOWLEDGE ENTRIES</p><h1></h1></div></header>{query.isLoading ? <div className="skeleton-grid"><i /><i /></div> : query.data?.items.length ? <section>{query.data.items.map((item, index) => <Link to={`/handbook/entry/${item.id}`} state={{ entry: item }} key={item.id}><span>{String(index + 1).padStart(2, "0")}</span><div><strong>{item.title}</strong><p>{item.summary || "阅读完整内容"}</p></div><ArrowRight /></Link>)}</section> : <div className="empty-panel"></div>}</div>;
}

View File

@@ -0,0 +1,11 @@
import { ArrowLeft } from "lucide-react";
import { Link, useLocation, useParams } from "react-router";
import type { components } from "../../api/schema.generated";
import { SafeMarkdown } from "../../shared/SafeMarkdown";
type Entry = components["schemas"]["HandbookEntryCatalogItem"];
export default function HandbookEntryReaderPage() {
const entryId = useParams().entryId;
const entry = (useLocation().state as { entry?: Entry } | null)?.entry;
return <article className="handbook-entry-reader"><Link className="back-link" to="/handbook"><ArrowLeft /> </Link>{entry && entry.id === entryId ? <><header><p className="eyebrow">KNOWLEDGE ENTRY</p><h1>{entry.title}</h1><p>{entry.summary}</p></header><SafeMarkdown>{entry.content || "该条目暂未发布正文。"}</SafeMarkdown></> : <div className="empty-panel"></div>}</article>;
}

View File

@@ -0,0 +1,11 @@
import { ArrowRight, FileText } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router";
import { catalogApi, queryKeys } from "../../api";
import { useStudentSession } from "../StudentSession";
export default function HandbookPage() {
const { profile } = useStudentSession();
const query = useQuery({ queryKey: queryKeys.handbookSubjects(profile?.regionId), queryFn: () => catalogApi.handbookSubjects(profile?.regionId) });
return <div className="catalog-learning-page"><header className="page-heading"><div><p className="eyebrow">KNOWLEDGE HANDBOOK</p><h1></h1><p></p></div></header>{query.isLoading ? <div className="skeleton-grid"><i /><i /><i /></div> : query.isError ? <div className="error-panel"><p></p><button onClick={() => void query.refetch()}></button></div> : query.data?.items.length ? <section className="learning-catalog-grid">{query.data.items.map((item) => <Link to={`/handbook/subject/${item.id}`} key={item.id}><span><FileText /></span><div><small>HANDBOOK SUBJECT</small><h2>{item.name}</h2><p>{item.description || "查看章节和知识条目。"}</p></div><ArrowRight /></Link>)}</section> : <div className="empty-panel"></div>}</div>;
}

View File

@@ -0,0 +1,14 @@
import { ArrowRight, CheckCircle2, Clock3, RotateCcw, Target, TriangleAlert } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router";
import { learningApi, queryKeys } from "../../api";
import { numeric, percent } from "../../shared/number";
export default function PracticeReportPage() {
const { sessionId = "" } = useParams();
const report = useQuery({ queryKey: queryKeys.practiceReport(sessionId), queryFn: () => learningApi.report(sessionId), enabled: Boolean(sessionId) });
if (report.isLoading) return <div className="quiz-loading"></div>;
if (report.isError || !report.data) return <div className="error-panel"><p>{report.error?.message || "报告加载失败。"}</p><button onClick={() => void report.refetch()}></button></div>;
const item = report.data;
return <div className="report-page"><header><p className="eyebrow">PRACTICE REPORT</p><h1>{numeric(item.pendingReviewCount) ? "练习已提交,部分题目待审核" : "本次练习已完成"}</h1><p></p></header><section className="report-score"><div><strong>{Math.round(numeric(item.score))}</strong><span>/ {numeric(item.totalScore)} </span></div><i style={{ "--score": `${Math.min(100, numeric(item.accuracy) * (numeric(item.accuracy) <= 1 ? 100 : 1))}%` } as React.CSSProperties} /><p> {percent(item.accuracy)}</p></section><section className="report-metrics"><article><CheckCircle2 /><span><small></small><strong>{numeric(item.correctCount)}</strong></span></article><article><TriangleAlert /><span><small></small><strong>{numeric(item.wrongCount)}</strong></span></article><article><Target /><span><small></small><strong>{numeric(item.pendingReviewCount)}</strong></span></article><article><Clock3 /><span><small></small><strong>{Math.ceil(numeric(item.durationSeconds) / 60)} </strong></span></article></section>{numeric(item.pendingReviewCount) > 0 && <div className="pending-review-note"><Target /><div><strong></strong><p></p></div></div>}<div className="report-actions"><Link to="/collections" className="quiet-button"><RotateCcw /> </Link><Link to="/subjects" className="primary-button"> <ArrowRight /></Link></div></div>;
}

View File

@@ -0,0 +1,14 @@
import { Award, BookOpenCheck, CalendarDays, ChevronRight, MapPin, UserRound } from "lucide-react";
import { Link } from "react-router";
import { numeric } from "../../shared/number";
import { useStudentSession } from "../StudentSession";
export default function ProfilePage() {
const { profile } = useStudentSession();
if (!profile) return null;
const name = profile.name || profile.username || "同学";
return <div className="profile-page"><section className="profile-hero"><span>{name.slice(0, 1)}</span><div><p></p><h1>{name}</h1><small>{profile.membership?.isSvip ? "SVIP 学员" : "普通会员"}</small></div></section>
<section className="profile-stat-grid"><article><Award /><small></small><strong>{numeric(profile.score)}</strong></article><article><BookOpenCheck /><small></small><strong>{numeric(profile.questionsAnsweredToday)}</strong></article><article><CalendarDays /><small></small><strong>{numeric(profile.masteredWordsCount)}</strong></article></section>
<section className="profile-menu"><Link to="/select-region"><MapPin /><span><strong></strong><small>{profile.regionName || "尚未选择"}</small></span><ChevronRight /></Link><Link to="/services"><UserRound /><span><strong></strong><small></small></span><ChevronRight /></Link></section>
</div>;
}

View File

@@ -0,0 +1,35 @@
import { ArrowRight, BookOpenCheck, Layers3, ShieldCheck } from "lucide-react";
import { Link } from "react-router";
import { useRuntime } from "../../app/RuntimeProvider";
export default function PublicLandingPage() {
const { runtime, assets } = useRuntime();
if (!runtime) return null;
return (
<div className="public-landing">
<header className="public-header">
<Link to="/" className="public-logo">
{assets.logo ? <img src={assets.logo} alt={runtime.branding.assetAlt?.logo || runtime.branding.imageAlt || `${runtime.branding.brandName} Logo`} /> : <span>{runtime.branding.shortName.slice(0, 1)}</span>}
<strong>{runtime.branding.brandName}</strong>
</Link>
<Link to="/login" className="primary-button"> <ArrowRight /></Link>
</header>
<main>
<section className="public-hero">
<div>
<p className="eyebrow">TENANT LEARNING SPACE</p>
<h1>{runtime.branding.slogan || "把每一步学习,变成看得见的进步"}</h1>
<p className="public-hero__lead"></p>
<div className="public-hero__actions"><Link to="/login" className="primary-button"> <ArrowRight /></Link><span><ShieldCheck /> </span></div>
</div>
<div className="public-hero__visual" aria-hidden={!assets.hero}>{assets.hero ? <img src={assets.hero} alt={runtime.branding.assetAlt?.hero || '机构学习品牌主视觉'} /> : <><BookOpenCheck /><i /><i /><i /></>}</div>
</section>
<section className="public-feature-grid">
<article><BookOpenCheck /><h2></h2><p></p></article>
<article><Layers3 /><h2></h2><p></p></article>
<article><ShieldCheck /><h2></h2><p></p></article>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { ArrowLeft, ArrowRight, Bookmark, Check, ChevronLeft, ChevronRight, Clock3, Flag, LoaderCircle, Send } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { learningApi, queryKeys } from "../../api";
import type { AnswerRecord, PracticeQuestion } from "../../api/types";
import { SafeMarkdown } from "../../shared/SafeMarkdown";
import { numeric } from "../../shared/number";
interface ParsedOption { key: string; content: string }
function parseOptions(value: unknown): ParsedOption[] {
if (!Array.isArray(value)) return [];
return value.map((item, index) => {
if (typeof item === "string") return { key: String(index), content: item };
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
return { key: String(record.key ?? record.label ?? index), content: String(record.content ?? record.text ?? record.value ?? "") };
}
return { key: String(index), content: String(item ?? "") };
});
}
function QuestionAnswer({ question, existing, version, sequence, onAccepted }: {
question: PracticeQuestion;
existing?: AnswerRecord;
version: number;
sequence: number;
onAccepted: (answer: AnswerRecord) => void;
}) {
const options = useMemo(() => parseOptions(question.options), [question.options]);
const isMultiple = /multiple|多选/i.test(question.type);
const [selected, setSelected] = useState<number[]>([]);
const [text, setText] = useState("");
const answer = useMutation({
mutationFn: () => learningApi.submitAnswer({
sessionQuestionId: question.sessionQuestionId,
expectedSessionVersion: version,
clientSequence: sequence,
idempotencyKey: crypto.randomUUID(),
selectedOptionIndices: options.length ? selected : undefined,
answerText: options.length ? undefined : text,
}),
onSuccess: onAccepted,
});
const toggle = (index: number) => setSelected((current) => isMultiple
? current.includes(index) ? current.filter((value) => value !== index) : [...current, index]
: [index]);
const disabled = answer.isPending || Boolean(existing);
return <div className="question-answer">
{options.length ? <div className="answer-options">{options.map((option, index) => <button key={option.key} type="button" disabled={disabled} className={selected.includes(index) ? "is-selected" : ""} onClick={() => toggle(index)}><span>{String.fromCharCode(65 + index)}</span><SafeMarkdown>{option.content}</SafeMarkdown>{selected.includes(index) && <Check />}</button>)}</div> : <label className="subjective-answer"><textarea disabled={disabled} value={text} onChange={(event) => setText(event.target.value)} rows={7} placeholder="请在这里作答。主观题提交后会进入待审核状态。" /></label>}
{existing ? <p className={`answer-status ${existing.status.toLowerCase().includes("pending") ? "is-pending" : ""}`}><Check /> {existing.status.toLowerCase().includes("pending") ? "等待人工审核" : "服务端已记录判分"}</p> : <button type="button" className="primary-button" disabled={answer.isPending || (options.length ? !selected.length : !text.trim())} onClick={() => answer.mutate()}>{answer.isPending ? <LoaderCircle className="spin" /> : <Send />} </button>}
{answer.error && <p className="form-message" role="alert">{answer.error.message}</p>}
</div>;
}
export default function QuizPage() {
const [search] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const sessionId = search.get("sessionId");
const targetType = search.get("targetType");
const targetId = search.get("targetId");
const mode = search.get("mode") || "sequential";
const [index, setIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, AnswerRecord>>({});
const [version, setVersion] = useState(1);
const [sequence, setSequence] = useState(1);
const [questionLimit, setQuestionLimit] = useState(20);
const detail = useQuery({
queryKey: queryKeys.practice(sessionId ?? "new"),
queryFn: () => learningApi.sessionDetail(sessionId!),
enabled: Boolean(sessionId),
staleTime: 0,
});
useEffect(() => {
if (!detail.data) return;
setVersion(numeric(detail.data.session.version));
setSequence(numeric(detail.data.session.lastClientSequence) + 1);
setAnswers(detail.data.answersBySessionQuestion ?? {});
}, [detail.data]);
const create = useMutation({
mutationFn: () => learningApi.createSession({ mode, targetType, targetId, questionLimit }),
onSuccess: (session) => navigate(`/quiz?sessionId=${session.id}`, { replace: true }),
});
const submit = useMutation({
mutationFn: () => learningApi.submitSession({ practiceSessionId: sessionId!, expectedSessionVersion: version, idempotencyKey: crypto.randomUUID() }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.dashboard });
navigate(`/practice/report/${sessionId}`, { replace: true });
},
});
if (!sessionId) return <div className="quiz-start-page"><Link to={targetId && targetType === "subject" ? `/subject/${targetId}` : "/subjects"} className="back-link"><ArrowLeft /> </Link><section><p className="eyebrow">READY TO PRACTICE</p><h1></h1><p></p><label><select value={questionLimit} onChange={(event) => setQuestionLimit(Number(event.target.value))}><option value={10}>10 </option><option value={20}>20 </option><option value={50}>50 </option></select></label><button className="primary-button" onClick={() => create.mutate()} disabled={create.isPending || (!targetId && !["wrong_review", "favorite_review"].includes(mode))}>{create.isPending ? <LoaderCircle className="spin" /> : <ArrowRight />} </button>{create.error && <p className="form-message">{create.error.message}</p>}</section></div>;
if (detail.isLoading) return <div className="quiz-loading"><LoaderCircle className="spin" /><p></p></div>;
if (detail.isError || !detail.data) return <div className="error-panel"><p>{detail.error?.message || "练习加载失败。"}</p><button onClick={() => void detail.refetch()}></button></div>;
const questions = detail.data.questions;
const question = questions[index];
if (!question) return <div className="empty-panel"></div>;
const accepted = answers[question.sessionQuestionId];
const progress = Math.round(Object.keys(answers).length / Math.max(1, questions.length) * 100);
return <div className="quiz-page">
<header className="quiz-header"><Link to="/subjects" aria-label="退出练习"><ArrowLeft /></Link><div><strong></strong><span><i style={{ width: `${progress}%` }} /></span><small>{Object.keys(answers).length}/{questions.length} </small></div><span><Clock3 /> {detail.data.session.durationMinutes ? `${numeric(detail.data.session.durationMinutes)} 分钟` : "不限时"}</span></header>
<main className="quiz-workspace">
<section className="question-card"><div className="question-meta"><span> {index + 1} / {questions.length}</span><em>{question.typeLabel || question.type}</em><button type="button" aria-label="标记本题"><Flag /></button></div><SafeMarkdown className="question-content">{question.content || "题目内容暂不可用"}</SafeMarkdown><QuestionAnswer question={question} existing={accepted} version={version} sequence={sequence} onAccepted={(answer) => { setAnswers((current) => ({ ...current, [question.sessionQuestionId]: answer })); setVersion(numeric(answer.sessionVersion)); setSequence(numeric(answer.clientSequence) + 1); }} /></section>
<aside className="question-navigator"><h2></h2><div>{questions.map((item, itemIndex) => <button key={item.sessionQuestionId} className={`${itemIndex === index ? "is-current" : ""} ${answers[item.sessionQuestionId] ? "is-answered" : ""}`} onClick={() => setIndex(itemIndex)}>{itemIndex + 1}</button>)}</div><button className="submit-paper-button" onClick={() => submit.mutate()} disabled={submit.isPending}><Bookmark /> {submit.isPending ? "正在交卷" : "提交整组练习"}</button>{submit.error && <p className="form-message">{submit.error.message}</p>}</aside>
</main>
<footer className="quiz-footer"><button disabled={index === 0} onClick={() => setIndex((value) => value - 1)}><ChevronLeft /> </button><button disabled={index === questions.length - 1} onClick={() => setIndex((value) => value + 1)}> <ChevronRight /></button></footer>
</div>;
}

View File

@@ -0,0 +1,45 @@
import { ArrowRight, MapPin, Search } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { catalogApi, profileApi, queryKeys } from "../../api";
import type { StudentProfile } from "../../api/types";
import { developmentRegion, isDevelopmentRegion, rememberDevelopmentRegion, withDevelopmentRegionFallback } from "../developmentCatalog";
export default function RegionSelectorPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState("");
const regions = useQuery({ queryKey: queryKeys.regions, queryFn: catalogApi.regions, staleTime: 5 * 60_000 });
const choose = useMutation({
mutationFn: async (regionId: string) => {
if (!isDevelopmentRegion(regionId)) return profileApi.updateRegion(regionId);
rememberDevelopmentRegion();
queryClient.setQueryData<StudentProfile>(queryKeys.profile, (profile) => profile
? { ...profile, regionId: developmentRegion.id, regionName: `${developmentRegion.name}(演示)` }
: profile);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.profile });
navigate("/", { replace: true });
},
});
const sourceItems = useMemo(() => withDevelopmentRegionFallback(regions.data?.items ?? []), [regions.data]);
const isDevelopmentFallback = sourceItems.length === 1 && sourceItems[0]?.id === developmentRegion.id;
const items = useMemo(() => sourceItems.filter((item) =>
!keyword || `${item.name}${item.shortName ?? ""}${item.pinyin ?? ""}`.toLowerCase().includes(keyword.toLowerCase()),
), [sourceItems, keyword]);
const hot = items.filter((item) => item.isHot);
return <div className="selection-page">
<header><p className="eyebrow">LEARNING REGION</p><h1></h1><p></p></header>
<label className="search-field"><Search /><span className="sr-only"></span><input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索省份或地区" /></label>
{regions.isLoading ? <div className="skeleton-grid" aria-label="正在加载地区"><i /><i /><i /><i /></div> : regions.isError ? <div className="error-panel"><p></p><button onClick={() => void regions.refetch()}></button></div> : <>
{isDevelopmentFallback && <p className="development-notice" role="status"></p>}
{hot.length > 0 && <section><h2><MapPin /> </h2><div className="region-grid">{hot.map((item) => <button key={item.id} onClick={() => choose.mutate(item.id)} disabled={choose.isPending}><strong>{item.name}</strong><ArrowRight /></button>)}</div></section>}
<section><h2></h2><div className="region-grid">{items.map((item) => <button key={item.id} onClick={() => choose.mutate(item.id)} disabled={choose.isPending}><strong>{item.name}</strong><ArrowRight /></button>)}</div></section>
{!items.length && <div className="empty-panel">{keyword}</div>}
</>}
{choose.error && <p className="form-message" role="alert">{choose.error.message}</p>}
</div>;
}

View File

@@ -0,0 +1,29 @@
import { CheckCircle2 } from "lucide-react";
import { FormEvent, useState } from "react";
import { Link } from "react-router";
import { studentAuthApi } from "../../api";
import { useRuntime } from "../../app/RuntimeProvider";
export default function ResetPasswordPage() {
const { runtime } = useRuntime();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [done, setDone] = useState(false);
const send = async () => { if (runtime) { await studentAuthApi.sendPasswordResetSms(runtime.tenantCode, phone); setMessage("验证码已发送。"); } };
const submit = async (event: FormEvent) => {
event.preventDefault(); if (!runtime) return;
try { await studentAuthApi.resetPassword(runtime.tenantCode, phone, code, password); setDone(true); }
catch (error) { setMessage(error instanceof Error ? error.message : "重置失败。"); }
};
return <div className="simple-auth-page"><form className="auth-card" onSubmit={submit}>
{done ? <><CheckCircle2 className="success-icon" /><h1></h1><p>使</p><Link className="primary-button primary-button--wide" to="/login"></Link></> : <>
<p className="eyebrow">ACCOUNT RECOVERY</p><h1></h1>
<label><input inputMode="tel" value={phone} onChange={(e) => setPhone(e.target.value)} required /></label>
<label><span className="input-with-action"><input inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} required /><button type="button" onClick={() => void send()} disabled={!phone}></button></span></label>
<label><input type="password" autoComplete="new-password" minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} required /></label>
{message && <p className="form-message" role="status">{message}</p>}<button className="primary-button primary-button--wide"></button><Link className="auth-help" to="/login"></Link>
</>}
</form></div>;
}

View File

@@ -0,0 +1,15 @@
import { Search, TrendingUp } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { catalogApi, queryKeys } from "../../api";
import { numeric } from "../../shared/number";
import { useStudentSession } from "../StudentSession";
export default function ScorelinePage() {
const { profile } = useStudentSession();
const [keyword, setKeyword] = useState("");
const [year, setYear] = useState<number | undefined>();
const years = useQuery({ queryKey: ["catalog", "scoreline-years", profile?.regionId], queryFn: () => catalogApi.scorelineYears(profile?.regionId) });
const records = useQuery({ queryKey: queryKeys.scoreline(profile?.regionId, keyword, year), queryFn: () => catalogApi.scorelineRecords(profile?.regionId, keyword, year) });
return <div className="scoreline-page"><header className="page-heading"><div><p className="eyebrow">SCORELINE DATABASE</p><h1>线</h1><p></p></div></header><section className="scoreline-toolbar"><label className="search-field"><Search /><span className="sr-only"></span><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索院校或专业" /></label><select aria-label="选择年份" value={year ?? ""} onChange={(event) => setYear(event.target.value ? Number(event.target.value) : undefined)}><option value=""></option>{(years.data?.items ?? []).map((item) => <option value={numeric(item)} key={numeric(item)}>{numeric(item)} </option>)}</select></section>{records.isLoading ? <div className="skeleton-grid"><i /><i /><i /></div> : records.isError ? <div className="error-panel"><p>线</p><button onClick={() => void records.refetch()}></button></div> : records.data?.items.length ? <section className="scoreline-list">{records.data.items.map((item) => <article key={item.id}><span><TrendingUp /></span><div><small>{numeric(item.year)} </small><h2>{item.schoolName || "未命名院校"}</h2><p>{item.majorName || "综合专业"}</p></div><code>{JSON.stringify(item.fieldValues)}</code></article>)}</section> : <div className="empty-panel">线</div>}</div>;
}

View File

@@ -0,0 +1,17 @@
import { ArrowRight, CircleCheck, Clock3 } from "lucide-react";
import { Link } from "react-router";
import { useRuntime } from "../../app/RuntimeProvider";
import { resolveStudentFeatures, studentNavigationGroups } from "../features";
export default function ServicesPage() {
const { runtime } = useRuntime();
if (!runtime) return null;
const features = resolveStudentFeatures(runtime).filter((feature) => !["dashboard", "services"].includes(feature.code));
return <div className="services-page"><header className="page-heading"><div><p className="eyebrow">LEARNING SERVICES</p><h1></h1><p></p></div></header>
{studentNavigationGroups.map((group) => {
const items = features.filter((feature) => feature.group === group.code);
if (!items.length) return null;
return <section className="service-section" key={group.code}><h2>{group.label}</h2><div className="service-grid">{items.map((feature) => { const Icon = feature.icon; return <Link to={feature.path} key={feature.code}><span><Icon /></span><div><strong>{feature.label}</strong><p>{feature.description}</p><small>{feature.status === "live" ? <><CircleCheck /> </> : <><Clock3 /> </>}</small></div><ArrowRight /></Link>; })}</div></section>;
})}
</div>;
}

View File

@@ -0,0 +1,69 @@
import { ArrowLeft, KeyRound, MessageSquareText, ShieldCheck } from "lucide-react";
import { FormEvent, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { studentAuthApi } from "../../api";
import { useRuntime } from "../../app/RuntimeProvider";
export default function StudentLoginPage() {
const { runtime, assets } = useRuntime();
const navigate = useNavigate();
const location = useLocation();
const [method, setMethod] = useState<"password" | "sms">("password");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [message, setMessage] = useState("");
const allowSms = runtime?.loginMethods.includes("sms") ?? false;
const from = (location.state as { from?: string } | null)?.from || "/";
const sendCode = async () => {
if (!runtime || !identifier) return;
setSending(true); setMessage("");
try { await studentAuthApi.sendSms(runtime.tenantCode, identifier); setMessage("验证码已发送,请注意查收。"); }
catch (error) { setMessage(error instanceof Error ? error.message : "验证码发送失败。"); }
finally { setSending(false); }
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!runtime) return;
setLoading(true); setMessage("");
try {
if (method === "sms") await studentAuthApi.smsLogin(runtime.tenantCode, identifier, code);
else await studentAuthApi.passwordLogin(runtime.tenantCode, identifier, password);
navigate(from, { replace: true });
window.location.reload();
} catch (error) { setMessage(error instanceof Error ? error.message : "登录失败,请重试。"); }
finally { setLoading(false); }
};
return (
<div className="auth-page">
<section className="auth-story" style={assets.loginBackground ? { backgroundImage: `linear-gradient(145deg, rgb(15 23 42 / 82%), color-mix(in srgb, var(--tenant-primary) 74%, transparent)), url(${JSON.stringify(assets.loginBackground).slice(1, -1)})` } : undefined}>
<Link to="/" className="auth-back"><ArrowLeft /> </Link>
<div><p className="eyebrow">{runtime?.branding.shortName} LEARNING</p><h1></h1><p></p></div>
<span><ShieldCheck /> · </span>
</section>
<section className="auth-panel">
<form onSubmit={submit} className="auth-card">
<div><p className="eyebrow">WELCOME BACK</p><h2></h2><p>使</p></div>
{allowSms && <div className="segmented-control" role="tablist" aria-label="登录方式">
<button type="button" role="tab" aria-selected={method === "password"} onClick={() => setMethod("password")}><KeyRound /> </button>
<button type="button" role="tab" aria-selected={method === "sms"} onClick={() => setMethod("sms")}><MessageSquareText /> </button>
</div>}
<label><input autoComplete="username" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required /></label>
{method === "password" ? (
<label><input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required /></label>
) : (
<label><span className="input-with-action"><input inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required /><button type="button" onClick={() => void sendCode()} disabled={sending || !identifier}>{sending ? "发送中" : "获取验证码"}</button></span></label>
)}
{message && <p className="form-message" role="status">{message}</p>}
<button className="primary-button primary-button--wide" disabled={loading}>{loading ? "正在登录…" : "登录"}</button>
<Link to="/reset-password" className="auth-help">使</Link>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ArrowLeft, ArrowRight, BookMarked, ListChecks, Shuffle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router";
import { catalogApi, queryKeys } from "../../api";
import { useStudentSession } from "../StudentSession";
export default function SubjectDetailPage() {
const { subjectId = "" } = useParams();
const { profile } = useStudentSession();
const subjects = useQuery({ queryKey: queryKeys.subjects(profile?.regionId), queryFn: () => catalogApi.subjects(profile?.regionId) });
const categories = useQuery({ queryKey: queryKeys.categories(subjectId), queryFn: () => catalogApi.categories(subjectId), enabled: Boolean(subjectId) });
const subject = subjects.data?.items.find((item) => item.id === subjectId);
return <div className="subject-detail-page">
<Link to="/subjects" className="back-link"><ArrowLeft /> </Link>
<header className="subject-detail-hero"><div><p className="eyebrow">SUBJECT OVERVIEW</p><h1>{subject?.name || "科目学习"}</h1><p>{subject?.description || "按章节推进练习,在每一次提交后复盘结果。"}</p></div><Link className="primary-button" to={`/quiz?targetType=subject&targetId=${subjectId}&mode=sequential`}> <ArrowRight /></Link></header>
<section className="practice-mode-grid"><Link to={`/quiz?targetType=subject&targetId=${subjectId}&mode=random`}><Shuffle /><div><strong></strong><p></p></div><ArrowRight /></Link><Link to={`/quiz?targetType=subject&targetId=${subjectId}&mode=sequential`}><ListChecks /><div><strong></strong><p>沿</p></div><ArrowRight /></Link><Link to="/collections"><BookMarked /><div><strong></strong><p></p></div><ArrowRight /></Link></section>
<section className="category-section"><div className="section-heading"><div><p className="eyebrow">CHAPTERS</p><h2></h2></div><span>{categories.data?.items.length ?? 0} </span></div>
{categories.isLoading ? <div className="chapter-list skeleton-grid"><i /><i /><i /></div> : categories.data?.items.length ? <div className="chapter-list">{categories.data.items.map((category, index) => <article key={category.id}><span>{String(index + 1).padStart(2, "0")}</span><div><h3>{category.name}</h3><p></p></div><Link to={`/quiz?targetType=category&targetId=${category.id}&mode=sequential`}> <ArrowRight /></Link></article>)}</div> : <div className="empty-panel">使</div>}
</section>
</div>;
}

View File

@@ -0,0 +1,18 @@
import { ArrowRight, BookOpen, Search, SlidersHorizontal } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { catalogApi, queryKeys } from "../../api";
import { useStudentSession } from "../StudentSession";
export default function SubjectsPage() {
const { profile } = useStudentSession();
const [keyword, setKeyword] = useState("");
const subjects = useQuery({ queryKey: queryKeys.subjects(profile?.regionId), queryFn: () => catalogApi.subjects(profile?.regionId) });
const filtered = useMemo(() => (subjects.data?.items ?? []).filter((item) => !keyword || `${item.name}${item.description ?? ""}`.toLowerCase().includes(keyword.toLowerCase())), [subjects.data, keyword]);
return <div className="subjects-page">
<header className="page-heading"><div><p className="eyebrow">COURSE LIBRARY</p><h1></h1><p>{profile?.regionName}</p></div><label className="search-field"><Search /><span className="sr-only"></span><input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索科目" /></label></header>
<div className="subject-toolbar"><span><SlidersHorizontal /> {filtered.length} </span><Link to="/collections"> <ArrowRight /></Link></div>
{subjects.isLoading ? <div className="subject-grid skeleton-grid"><i /><i /><i /><i /></div> : subjects.isError ? <div className="error-panel"><p></p><button onClick={() => void subjects.refetch()}></button></div> : filtered.length ? <section className="subject-grid">{filtered.map((subject, index) => <Link className="subject-card" to={`/subject/${subject.id}`} key={subject.id} style={{ "--subject-index": index } as React.CSSProperties}><span><BookOpen /></span><small>{subject.type === null ? "综合科目" : "学习科目"}</small><h2>{subject.name}</h2><p>{subject.description || "查看章节与可用练习,开始今天的学习任务。"}</p><em> <ArrowRight /></em></Link>)}</section> : <div className="empty-panel"></div>}
</div>;
}

View File

@@ -0,0 +1,17 @@
import { ArrowRight, BookOpen, Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { catalogApi, queryKeys } from "../../api";
import { numeric } from "../../shared/number";
import { useStudentSession } from "../StudentSession";
export default function VocabularyPage() {
const { profile } = useStudentSession();
const [keyword, setKeyword] = useState("");
const query = useQuery({ queryKey: queryKeys.vocabularyUnits(profile?.regionId), queryFn: () => catalogApi.vocabularyUnits(profile?.regionId) });
const items = useMemo(() => (query.data?.items ?? []).filter((item) => !keyword || `${item.name}${item.description ?? ""}`.toLowerCase().includes(keyword.toLowerCase())), [keyword, query.data]);
return <div className="catalog-learning-page"><header className="page-heading"><div><p className="eyebrow">VOCABULARY</p><h1></h1><p></p></div><label className="search-field"><Search /><span className="sr-only"></span><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索词汇单元" /></label></header>
{query.isLoading ? <div className="skeleton-grid"><i /><i /><i /></div> : query.isError ? <div className="error-panel"><p></p><button onClick={() => void query.refetch()}></button></div> : items.length ? <section className="learning-catalog-grid">{items.map((item, index) => <Link to={`/vocabulary/${item.id}`} key={item.id}><span>{String(index + 1).padStart(2, "0")}</span><div><small>VOCABULARY UNIT</small><h2>{item.name}</h2><p>{item.description || "进入单元开始词汇学习与复习。"}</p><strong>{numeric(item.wordCount)} </strong></div><ArrowRight /></Link>)}</section> : <div className="empty-panel"></div>}
</div>;
}

View File

@@ -0,0 +1,10 @@
import { ArrowLeft, Volume2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router";
import { catalogApi, queryKeys } from "../../api";
export default function VocabularyUnitPage() {
const unitId = useParams().unitId ?? "";
const query = useQuery({ queryKey: queryKeys.vocabularyWords(unitId), queryFn: () => catalogApi.vocabularyWords(unitId), enabled: Boolean(unitId) });
return <div className="vocabulary-unit-page"><Link className="back-link" to="/vocabulary"><ArrowLeft /> </Link><header className="page-heading"><div><p className="eyebrow">WORD LIST</p><h1></h1><p></p></div></header>{query.isLoading ? <div className="skeleton-grid"><i /><i /><i /></div> : query.isError ? <div className="error-panel"><p></p><button onClick={() => void query.refetch()}></button></div> : query.data?.items.length ? <section className="word-list">{query.data.items.map((item) => <article key={item.id}><div><strong>{item.word}</strong><span>{item.phonetic}</span></div><button type="button" aria-label={`朗读 ${item.word}`} disabled title="语音能力待接入"><Volume2 /></button><p>{item.meaning || "暂无释义"}</p>{item.example && <blockquote>{item.example}<small>{item.exampleTranslation}</small></blockquote>}</article>)}</section> : <div className="empty-panel"></div>}</div>;
}

View File

@@ -1,51 +1,124 @@
import { useMemo } from 'react';
import { AppstoreOutlined, EyeOutlined, HomeOutlined, LogoutOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Layout, Menu, Space, Tag, Typography } from 'antd';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Drawer, Grid } from 'antd';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router';
import { authApi } from '../api';
import { authApi, catalogApi, queryKeys } 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';
import { FullScreenLoading, NotFoundPage } from '../shared/StatusPages';
import {
categoryDefaultPath,
findTenantAdminFeature,
resolveTenantAdminCategory,
resolveTenantAdminNavigation,
tenantAdminFeatureMatchesPath,
type ResolvedTenantAdminCategory,
} from './navigation';
import { TenantAdminHeader } from './shell/TenantAdminHeader';
import { TenantAdminSidebar } from './shell/TenantAdminSidebar';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const SiteBuilderPage = lazy(() => import('./pages/SiteBuilderPage'));
const TenantFeaturePlaceholderPage = lazy(() => import('./pages/TenantFeaturePlaceholderPage').then((module) => ({ default: module.TenantFeaturePlaceholderPage })));
const LAST_PATH_PREFIX = 'tenant-admin:last-path:';
const REGION_KEY = 'tenant-admin:region-id';
function PlaceholderRoute({ categories }: { categories: ResolvedTenantAdminCategory[] }) {
const location = useLocation();
const feature = findTenantAdminFeature(categories, location.pathname);
if (!feature || feature.availability === 'live') return <NotFoundPage />;
return <TenantFeaturePlaceholderPage feature={feature} />;
}
export default function TenantShell() {
const bootstrap = useBackoffice();
const { runtime } = useRuntime();
const { runtime, assets } = useRuntime();
const location = useLocation();
const navigate = useNavigate();
const menuItems = useMemo(() => bootstrap.menus.map(item => ({
key: item.path,
icon: item.code === 'tenant.site-content' ? <SettingOutlined /> : <AppstoreOutlined />,
label: item.name,
})), [bootstrap.menus]);
const screens = Grid.useBreakpoint();
const compact = !screens.lg;
const [drawerOpen, setDrawerOpen] = useState(false);
const [regionId, setRegionId] = useState(() => window.sessionStorage.getItem(REGION_KEY) ?? '');
const categories = useMemo(() => resolveTenantAdminNavigation(bootstrap, runtime), [bootstrap, runtime]);
const activeCategoryCode = resolveTenantAdminCategory(categories, location.pathname);
const activeCategory = categories.find((category) => category.code === activeCategoryCode) ?? categories[0];
const regions = useQuery({ queryKey: queryKeys.regions, queryFn: catalogApi.regions, staleTime: 10 * 60_000 });
const regionOptions = (regions.data?.items ?? []).map((region) => ({ label: region.name, value: region.id }));
const logout = async () => { await authApi.logout(); navigate('/', { replace: true }); window.location.reload(); };
useEffect(() => {
const feature = findTenantAdminFeature(categories, location.pathname);
if (feature) window.sessionStorage.setItem(`${LAST_PATH_PREFIX}${feature.category}`, location.pathname);
}, [categories, location.pathname]);
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>
useEffect(() => {
const onlyRegion = regionOptions.length === 1 ? regionOptions[0] : undefined;
if (!regionId && onlyRegion) {
setRegionId(onlyRegion.value);
window.sessionStorage.setItem(REGION_KEY, onlyRegion.value);
}
}, [regionId, regionOptions]);
const selectCategory = (category: ResolvedTenantAdminCategory) => {
const remembered = window.sessionStorage.getItem(`${LAST_PATH_PREFIX}${category.code}`);
const validRemembered = remembered && category.features.some((feature) => tenantAdminFeatureMatchesPath(feature, remembered));
navigate(validRemembered ? remembered : categoryDefaultPath(category));
};
const changeRegion = (value: string) => {
setRegionId(value);
window.sessionStorage.setItem(REGION_KEY, value);
};
const logout = async () => {
await authApi.logout();
navigate('/', { replace: true });
window.location.reload();
};
if (!activeCategory) return <NotFoundPage />;
return <div className="tenant-admin-shell">
<a className="skip-link" href="#tenant-admin-main"></a>
<TenantAdminHeader
categories={categories}
activeCategory={activeCategoryCode}
tenantName={runtime?.tenantName ?? '机构后台'}
shortName={runtime?.branding.shortName ?? runtime?.tenantName ?? '题库'}
logoUrl={assets.logo}
siteState={runtime?.siteState}
regionId={regionId}
regionOptions={regionOptions}
regionsLoading={regions.isLoading}
compact={compact}
onCategorySelect={selectCategory}
onRegionChange={changeRegion}
onOpenMenu={() => setDrawerOpen(true)}
onLogout={() => void logout()}
/>
<div className="tenant-admin-body">
{!compact && <TenantAdminSidebar category={activeCategory} />}
<main id="tenant-admin-main" className="tenant-admin-content" tabIndex={-1}>
<Suspense fallback={<FullScreenLoading label="正在加载后台模块…" />}>
<Routes>
<Route index element={<DashboardPage />} />
<Route path="site" element={<Navigate to="/manage/site/brand" replace />} />
<Route path="site/:builderTab" element={canManageSite(bootstrap) ? <SiteBuilderPage /> : <NotFoundPage />} />
<Route path="onboarding" element={canManageSite(bootstrap) ? <SiteBuilderPage onboarding /> : <NotFoundPage />} />
<Route path="login" element={<Navigate to="/manage" replace />} />
<Route path="*" element={<PlaceholderRoute categories={categories} />} />
</Routes>
</Suspense>
</main>
</div>
);
<Drawer
className="tenant-admin-mobile-drawer"
placement="left"
size={286}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={activeCategory.label}
><TenantAdminSidebar category={activeCategory} onNavigate={() => setDrawerOpen(false)} /></Drawer>
</div>;
}

View File

@@ -0,0 +1,3 @@
export * from './types';
export * from './registry';
export * from './resolver';

View File

@@ -0,0 +1,88 @@
import {
AccountBookOutlined,
ApiOutlined,
AppstoreOutlined,
AuditOutlined,
BgColorsOutlined,
BookOutlined,
CalendarOutlined,
CustomerServiceOutlined,
DashboardOutlined,
DatabaseOutlined,
DownloadOutlined,
ExperimentOutlined,
FileDoneOutlined,
FilePdfOutlined,
GiftOutlined,
GlobalOutlined,
HistoryOutlined,
KeyOutlined,
LineChartOutlined,
LoginOutlined,
MessageOutlined,
NotificationOutlined,
ProfileOutlined,
ReadOutlined,
RiseOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShareAltOutlined,
ShoppingCartOutlined,
TeamOutlined,
VideoCameraOutlined,
WalletOutlined,
} from '@ant-design/icons';
import type { TenantAdminCategory, TenantAdminFeature } from './types';
export const tenantAdminCategories: TenantAdminCategory[] = [
{ code: 'overview', label: '总览', icon: <DashboardOutlined />, order: 10, defaultPath: '/manage' },
{ code: 'teaching', label: '教学内容', icon: <BookOutlined />, order: 20, defaultPath: '/manage/question-bank' },
{ code: 'students', label: '学员运营', icon: <TeamOutlined />, order: 30, defaultPath: '/manage/students' },
{ code: 'commerce', label: '营销交易', icon: <ShoppingCartOutlined />, order: 40, defaultPath: '/manage/commerce' },
{ code: 'site', label: '站点装修', icon: <BgColorsOutlined />, order: 50, defaultPath: '/manage/site/brand' },
{ code: 'settings', label: '系统设置', icon: <SettingOutlined />, order: 60, defaultPath: '/manage/staff' },
];
export const tenantAdminFeatures: TenantAdminFeature[] = [
{ code: 'dashboard', backendMenuCode: 'tenant.dashboard', category: 'overview', label: '数据看板', path: '/manage', icon: <DashboardOutlined />, order: 10, availability: 'live', requiredPermission: 'tenant:dashboard:view', description: '查看租户开通状态、站点运行信息和经营概览。' },
{ code: 'revenue', category: 'overview', label: '收益账本', path: '/manage/revenue', icon: <WalletOutlined />, order: 20, availability: 'backend_missing', description: '汇总商城、激活码和其他渠道的收入明细。', contractNote: '缺少租户级统一收入趋势与渠道汇总契约。' },
{ code: 'question-bank', backendMenuCode: 'tenant.question-bank', category: 'teaching', label: '题库管理', path: '/manage/question-bank', icon: <DatabaseOutlined />, order: 10, availability: 'frontend_pending', requiredPermission: 'tenant:question-bank:manage', requiredFeature: 'question_bank.private', description: '录入、导入、维护和导出机构私有题库。' },
{ code: 'vocabulary', backendMenuCode: 'tenant.vocabulary', category: 'teaching', label: '词汇管理', path: '/manage/vocabulary', icon: <ReadOutlined />, order: 20, availability: 'frontend_pending', requiredPermission: 'tenant:vocabulary:manage', requiredFeature: 'content.vocabulary', description: '管理词汇单元、词条、例句和复习内容。' },
{ code: 'handbook', backendMenuCode: 'tenant.handbook', category: 'teaching', label: '知识手册', path: '/manage/handbook', icon: <BookOutlined />, order: 30, availability: 'frontend_pending', requiredPermission: 'tenant:handbook:manage', requiredFeature: 'content.handbook', description: '维护知识手册科目、章节和 Markdown 条目。' },
{ code: 'video', backendMenuCode: 'tenant.video', category: 'teaching', label: '视频精讲', path: '/manage/video', icon: <VideoCameraOutlined />, order: 40, availability: 'frontend_pending', requiredPermission: 'tenant:video:manage', requiredFeature: 'content.video', description: '维护课程视频、关联内容和播放信息。' },
{ code: 'scoreline', backendMenuCode: 'tenant.scoreline', category: 'teaching', label: '分数线配置', path: '/manage/scoreline', icon: <LineChartOutlined />, order: 50, availability: 'frontend_pending', requiredPermission: 'tenant:scoreline:manage', requiredFeature: 'content.scoreline', description: '按地区、院校、专业和年份维护分数线。' },
{ code: 'mock-exam', category: 'teaching', label: '模拟考试', path: '/manage/teaching/mock-exams', icon: <FileDoneOutlined />, order: 60, availability: 'backend_missing', description: '配置独立模拟考试、试卷和考试结果。', contractNote: '当前只有练习、考试基础能力,缺少与旧版一致的独立模拟考试管理契约。' },
{ code: 'exam-maker', category: 'teaching', label: '智能组卷', path: '/manage/teaching/exam-maker', icon: <ExperimentOutlined />, order: 70, availability: 'backend_missing', description: '按题型、章节和难度生成试卷。', contractNote: '缺少租户端组卷任务和试卷导出契约。' },
{ code: 'pdf-maker', category: 'teaching', label: 'PDF 拆章命题', path: '/manage/teaching/pdf-maker', icon: <FilePdfOutlined />, order: 80, availability: 'backend_missing', description: '从 PDF 教材拆章并辅助命题。', contractNote: '缺少 PDF 拆章与命题任务契约。' },
{ code: 'daily-practice', category: 'teaching', label: '每日一练', path: '/manage/teaching/daily-practice', icon: <CalendarOutlined />, order: 90, availability: 'backend_missing', description: '生成和发布每日练习内容。', contractNote: '缺少每日练习发布和排期契约。' },
{ code: 'question-export', category: 'teaching', label: '题目导出', path: '/manage/teaching/question-export', icon: <DownloadOutlined />, order: 100, availability: 'backend_missing', requiredPermission: 'tenant:question-bank:manage', description: '按筛选条件导出题目和答案。', contractNote: '缺少面向租户的题目导出任务契约。' },
{ code: 'students', backendMenuCode: 'tenant.students', category: 'students', label: '学员与班级', path: '/manage/students', icon: <TeamOutlined />, order: 10, availability: 'frontend_pending', requiredPermission: 'tenant:student:manage', requiredFeature: 'student.management', description: '管理学员档案、班级、成员状态和归属关系。' },
{ code: 'learning-history', category: 'students', label: '学习记录', path: '/manage/students/learning-history', icon: <HistoryOutlined />, order: 20, availability: 'backend_missing', requiredPermission: 'tenant:student:manage', description: '查看学员练习、错题、学习进度和活跃情况。', contractNote: '已有学生侧学习接口,缺少租户管理端聚合查询契约。' },
{ code: 'feedback', category: 'students', label: '反馈管理', path: '/manage/students/feedback', icon: <MessageOutlined />, order: 30, availability: 'frontend_pending', requiredPermission: 'tenant:student:manage', description: '查看、分派和处理学员反馈。' },
{ code: 'crm', category: 'students', label: 'CRM 跟进', path: '/manage/students/crm', icon: <CustomerServiceOutlined />, order: 40, availability: 'frontend_pending', requiredPermission: 'tenant:crm:manage', requiredFeature: 'crm.followup', description: '维护线索、跟进任务和学员转化记录。' },
{ code: 'commerce', backendMenuCode: 'tenant.commerce', category: 'commerce', label: '商品套餐', path: '/manage/commerce', icon: <ShoppingCartOutlined />, order: 10, availability: 'frontend_pending', requiredPermission: 'tenant:commerce:operate', requiredFeature: 'commerce.student_store', description: '维护学生商城商品、课程和会员套餐。' },
{ code: 'orders', category: 'commerce', label: '订单管理', path: '/manage/commerce/orders', icon: <ProfileOutlined />, order: 20, availability: 'frontend_pending', requiredPermission: 'tenant:commerce:operate', requiredFeature: 'commerce.student_store', description: '查询订单、支付状态和售后记录。' },
{ code: 'coupons', category: 'commerce', label: '优惠券', path: '/manage/commerce/coupons', icon: <GiftOutlined />, order: 30, availability: 'frontend_pending', requiredPermission: 'tenant:commerce:operate', requiredFeature: 'commerce.student_store', description: '配置优惠券、领取规则和核销记录。' },
{ code: 'activation-codes', category: 'commerce', label: '激活码', path: '/manage/commerce/activation-codes', icon: <KeyOutlined />, order: 40, availability: 'backend_missing', description: '生成、分发和追踪学生激活码。', contractNote: '缺少学生自助核销和租户端激活码全流程契约。' },
{ code: 'referrals', category: 'commerce', label: '代理推广', path: '/manage/commerce/referrals', icon: <ShareAltOutlined />, order: 50, availability: 'frontend_pending', requiredPermission: 'tenant:commission:manage', requiredFeature: 'growth.referral_commission', description: '管理推广关系、佣金规则和结算。' },
{ code: 'sales', category: 'commerce', label: '销售追踪', path: '/manage/commerce/sales', icon: <RiseOutlined />, order: 60, availability: 'backend_missing', description: '汇总渠道、销售人员和地区转化表现。', contractNote: '缺少租户销售漏斗与渠道归因聚合契约。' },
{ code: 'site-theme', backendMenuCode: 'tenant.site-content', category: 'site', label: '站点与主题', path: '/manage/site/brand', pathPrefix: '/manage/site', icon: <BgColorsOutlined />, order: 10, availability: 'live', requiredPermission: 'tenant:settings:manage', requiredFeature: 'marketing.site_content', description: '配置品牌、主题 Token、页面模块、资产和实时预览。' },
{ code: 'site-content', backendMenuCode: 'tenant.site-content', category: 'site', label: '运营内容', path: '/manage/site-content', icon: <NotificationOutlined />, order: 20, availability: 'frontend_pending', requiredPermission: 'tenant:site-content:manage', requiredFeature: 'marketing.site_content', description: '维护轮播、公告、备考时间线和常见问题。' },
{ code: 'regions', category: 'settings', label: '地区管理', path: '/manage/settings/regions', icon: <GlobalOutlined />, order: 10, availability: 'backend_missing', requiredPermission: 'tenant:settings:manage', description: '配置租户可运营地区及地区级内容范围。', contractNote: '公共地区目录可读,但缺少租户自主管理地区目录契约。' },
{ code: 'staff', backendMenuCode: 'tenant.staff', category: 'settings', label: '员工与权限', path: '/manage/staff', icon: <SafetyCertificateOutlined />, order: 20, availability: 'frontend_pending', requiredPermission: 'tenant:staff:manage', description: '管理员工、角色、菜单授权和数据范围。' },
{ code: 'base-settings', category: 'settings', label: '基础设置', path: '/manage/settings/base', icon: <SettingOutlined />, order: 30, availability: 'frontend_pending', requiredPermission: 'tenant:settings:manage', description: '维护机构基础信息、站点入口和通用设置。' },
{ code: 'login-methods', category: 'settings', label: '登录方式', path: '/manage/settings/login-methods', icon: <LoginOutlined />, order: 40, availability: 'frontend_pending', requiredPermission: 'tenant:provider:manage', description: '配置密码、短信和第三方登录能力。' },
{ code: 'providers', backendMenuCode: 'tenant.providers', category: 'settings', label: '外部服务', path: '/manage/providers', icon: <ApiOutlined />, order: 50, availability: 'frontend_pending', requiredPermission: 'tenant:provider:manage', description: '配置对象存储、支付、CRM 等外部服务。' },
{ code: 'sms', category: 'settings', label: '短信配置', path: '/manage/settings/sms', icon: <NotificationOutlined />, order: 60, availability: 'frontend_pending', requiredPermission: 'tenant:provider:manage', description: '查看短信渠道状态并维护租户级发送配置。' },
{ code: 'billing', backendMenuCode: 'tenant.billing', category: 'settings', label: 'SaaS 账务', path: '/manage/billing', icon: <AccountBookOutlined />, order: 70, availability: 'frontend_pending', requiredPermission: 'tenant:billing:manage', description: '查看租户订阅、用量和 SaaS 应收状态。' },
{ code: 'audit', category: 'settings', label: '审计日志', path: '/manage/settings/audit', icon: <AuditOutlined />, order: 80, availability: 'frontend_pending', requiredPermission: 'tenant:settings:manage', description: '查询后台敏感操作和权限变更记录。' },
];
export const knownTenantBackendMenuCodes = new Set(
tenantAdminFeatures.flatMap((feature) => feature.backendMenuCode ? [feature.backendMenuCode] : []),
);

View File

@@ -0,0 +1,70 @@
import type { BackofficeBootstrap, TenantRuntimeBootstrap } from '../../contracts';
import { tenantAdminCategories, tenantAdminFeatures } from './registry';
import type {
ResolvedTenantAdminCategory,
ResolvedTenantAdminFeature,
TenantAdminCategoryCode,
} from './types';
export function resolveTenantAdminNavigation(
bootstrap: BackofficeBootstrap,
runtime: TenantRuntimeBootstrap | null,
): ResolvedTenantAdminCategory[] {
const permissions = new Set(bootstrap.permissions);
const serverMenus = new Set(bootstrap.menus.map((menu) => menu.code));
const enabledFeatures = new Set([
...bootstrap.enabledFeatures,
...(runtime?.enabledFeatures ?? []),
]);
const features = tenantAdminFeatures.flatMap<ResolvedTenantAdminFeature>((feature) => {
if (feature.requiredPermission && !permissions.has(feature.requiredPermission)) return [];
if (feature.backendMenuCode && !serverMenus.has(feature.backendMenuCode) && !feature.requiredPermission) return [];
const availability = feature.requiredFeature && !enabledFeatures.has(feature.requiredFeature)
? 'tenant_disabled'
: feature.availability;
return [{ ...feature, availability }];
});
return tenantAdminCategories
.map((category) => ({
...category,
features: features
.filter((feature) => feature.category === category.code)
.sort((left, right) => left.order - right.order),
}))
.filter((category) => category.features.length > 0)
.sort((left, right) => left.order - right.order);
}
export function findTenantAdminFeature(
categories: ResolvedTenantAdminCategory[],
pathname: string,
): ResolvedTenantAdminFeature | undefined {
const normalized = pathname.replace(/\/+$/, '') || '/';
return categories
.flatMap((category) => category.features)
.filter((feature) => tenantAdminFeatureMatchesPath(feature, normalized))
.sort((left, right) => right.path.length - left.path.length)[0];
}
export function tenantAdminFeatureMatchesPath(feature: ResolvedTenantAdminFeature, pathname: string): boolean {
const normalized = pathname.replace(/\/+$/, '') || '/';
const featurePath = (feature.pathPrefix ?? feature.path).replace(/\/+$/, '') || '/';
if (featurePath === '/manage') return normalized === '/manage';
return normalized === featurePath || normalized.startsWith(`${featurePath}/`);
}
export function resolveTenantAdminCategory(
categories: ResolvedTenantAdminCategory[],
pathname: string,
): TenantAdminCategoryCode {
if (pathname === '/manage/onboarding' || pathname.startsWith('/manage/onboarding/')) return 'site';
return findTenantAdminFeature(categories, pathname)?.category ?? categories[0]?.code ?? 'overview';
}
export function categoryDefaultPath(category: ResolvedTenantAdminCategory): string {
const registeredDefault = category.features.find((feature) => feature.path === category.defaultPath);
return registeredDefault?.path ?? category.features[0]?.path ?? '/manage';
}

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
export type TenantAdminCategoryCode =
| 'overview'
| 'teaching'
| 'students'
| 'commerce'
| 'site'
| 'settings';
export type TenantAdminAvailability =
| 'live'
| 'frontend_pending'
| 'backend_missing'
| 'tenant_disabled';
export interface TenantAdminCategory {
code: TenantAdminCategoryCode;
label: string;
icon: ReactNode;
order: number;
defaultPath: string;
}
export interface TenantAdminFeature {
code: string;
backendMenuCode?: string;
category: TenantAdminCategoryCode;
label: string;
path: string;
pathPrefix?: string;
icon: ReactNode;
order: number;
availability: Exclude<TenantAdminAvailability, 'tenant_disabled'>;
requiredPermission?: string;
requiredFeature?: string;
description: string;
contractNote?: string;
}
export interface ResolvedTenantAdminFeature extends Omit<TenantAdminFeature, 'availability'> {
availability: TenantAdminAvailability;
}
export interface ResolvedTenantAdminCategory extends TenantAdminCategory {
features: ResolvedTenantAdminFeature[];
}

View File

@@ -1,108 +1,119 @@
import { useEffect, useState } from "react";
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
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";
BookOutlined,
CheckCircleOutlined,
DatabaseOutlined,
KeyOutlined,
MessageOutlined,
ReloadOutlined,
RiseOutlined,
TeamOutlined,
ThunderboltOutlined,
WalletOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Progress, Segmented, Skeleton, Table, Tag, Typography } from 'antd';
import { useNavigate } from 'react-router';
import { onboardingApi } from '../../api';
import { useBackoffice } from '../../app/BackofficeContext';
import { useRuntime } from '../../app/RuntimeProvider';
import { canManageSite } from '../../shared/access';
const isMockDataMode = import.meta.env.DEV && import.meta.env.VITE_DATA_MODE !== 'api';
const mockMetrics = [
{ label: '总用户', value: '1,856', note: '今日新增 3', icon: <TeamOutlined />, tone: 'blue' },
{ label: '在线用户', value: '18', note: '近 2 分钟有心跳', icon: <ThunderboltOutlined />, tone: 'green' },
{ label: '题目总数', value: '53,095', note: '199 个科目', icon: <DatabaseOutlined />, tone: 'cyan' },
{ label: '可用激活码', value: '1,807', note: '本月使用 191', icon: <KeyOutlined />, tone: 'orange' },
{ label: '累计答题次数', value: '74,057', note: '本周 +3.8%', icon: <CheckCircleOutlined />, tone: 'blue' },
{ label: '人均答题量', value: '39.9', note: '较上期 +1.2', icon: <RiseOutlined />, tone: 'purple' },
{ label: '待处理反馈', value: '9', note: '2 条高优先级', icon: <MessageOutlined />, tone: 'orange' },
{ label: '本月收入', value: '¥89.7', note: '3 笔已支付订单', icon: <WalletOutlined />, tone: 'green' },
];
const regionRows = [
{ key: 'tianjin', region: '天津', users: '1,720', questions: '44,196', growth: '+2.0%' },
{ key: 'guangdong', region: '广东', users: '41', questions: '504', growth: '+0.8%' },
{ key: 'henan', region: '河南', users: '18', questions: '0', growth: '+0.0%' },
{ key: 'shandong', region: '山东', users: '4', questions: '0', growth: '+0.0%' },
];
const activity = [
['新用户注册', '微信用户_7285', '今天 16:01'],
['新用户注册', '手机用户_2353', '今天 15:05'],
['会员订单', '月卡 ¥29.9', '昨天 18:20'],
['题库更新', '高等数学新增 32 题', '昨天 11:36'],
];
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
const [range, setRange] = useState('近30天');
const onboarding = useQuery({ queryKey: ['tenant', 'onboarding'], queryFn: onboardingApi.status, staleTime: 60_000 });
const status = onboarding.data;
const percent = status?.requiredSteps
? 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>
)}
const realMetrics = useMemo(() => [
{ label: '配置版本', value: `V${runtime?.configVersion ?? 1}`, note: 'Runtime 已发布版本', icon: <BookOutlined />, tone: 'blue' },
{ label: '站点状态', value: runtime?.siteState === 'active' ? '已上线' : '配置中', note: runtime?.siteState ?? 'unknown', icon: <CheckCircleOutlined />, tone: 'green' },
{ label: '启用能力', value: String(runtime?.enabledFeatures.length ?? 0), note: '当前租户 Runtime Feature', icon: <ThunderboltOutlined />, tone: 'purple' },
{ label: '开通进度', value: `${percent}%`, note: `${status?.completedRequiredSteps ?? 0}/${status?.requiredSteps ?? 0} 个必需步骤`, icon: <RiseOutlined />, tone: 'orange' },
], [percent, runtime, status]);
const metrics = isMockDataMode ? mockMetrics : realMetrics;
return <div className="tenant-dashboard-page">
<header className="tenant-page-header">
<div><div className="tenant-page-title-line"><Typography.Title level={2}></Typography.Title><Tag variant="filled"></Tag></div><Typography.Text type="secondary"></Typography.Text></div>
<div className="tenant-dashboard-toolbar">
<Segmented value={range} options={['近7天', '近30天', '近90天']} onChange={(value) => setRange(String(value))} />
<Button icon={<ReloadOutlined />} loading={onboarding.isFetching} onClick={() => void onboarding.refetch()}></Button>
</div>
</header>
{!isMockDataMode && <Alert showIcon type="info" title="当前展示真实租户配置数据" description="用户、收入、在线人数和运营趋势的租户聚合接口尚未开放,因此不在 API 模式伪造经营数据。相关位置保留在覆盖矩阵中。" />}
{onboarding.isLoading ? <div className="tenant-metric-grid">{Array.from({ length: 4 }, (_, index) => <Card key={index}><Skeleton active paragraph={{ rows: 2 }} /></Card>)}</div> : <section className="tenant-metric-grid" aria-label="租户核心指标">
{metrics.map((metric) => <Card key={metric.label} className="tenant-metric-card">
<span className={`tenant-metric-icon is-${metric.tone}`}>{metric.icon}</span>
<div><small>{metric.label}</small><strong>{metric.value}</strong><em>{metric.note}</em></div>
</Card>)}
</section>}
<section className="tenant-dashboard-grid">
<Card className="tenant-dashboard-panel tenant-dashboard-user-panel" title="用户构成" extra={isMockDataMode ? <Tag color="blue"> +3</Tag> : null}>
{isMockDataMode ? <div className="tenant-user-composition"><div className="tenant-donut"><span><strong>1,856</strong><small></small></span></div><div className="tenant-user-legend"><p><i className="is-primary" />SVIP <strong>322</strong></p><p><i className="is-warning" /> <strong>2</strong></p><p><i /> <strong>1,534</strong></p></div></div> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="等待租户用户统计接口" />}
</Card>
</div>
);
<Card className="tenant-dashboard-panel" title="开通与发布状态" extra={<Tag color={status?.readyForStudentTraffic ? 'success' : 'processing'}>{status?.readyForStudentTraffic ? '可以接待学生' : '继续配置'}</Tag>}>
<Progress percent={percent} strokeColor="#3157d5" />
<div className="tenant-onboarding-list">{status?.steps.map((step) => <div key={step.code}><span>{step.code.replaceAll('_', ' ')}</span><Tag variant="filled" color={step.completed ? 'success' : 'default'}>{step.completed ? '完成' : '待处理'}</Tag></div>)}</div>
{canManageSite(bootstrap) && <Button type="primary" onClick={() => navigate('/manage/site/brand')}></Button>}
</Card>
</section>
<section className="tenant-dashboard-grid is-wide-left">
<Card className="tenant-dashboard-panel" title={`用户增长趋势 · ${range}`} extra={<Typography.Text type="secondary">Mock </Typography.Text>}>
{isMockDataMode ? <div className="tenant-bar-chart" aria-label="用户增长趋势示意图">{[32, 48, 43, 66, 58, 82, 74, 91, 86, 108, 118, 126].map((height, index) => <i key={index} style={{ height }} />)}</div> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="等待用户趋势聚合接口" />}
</Card>
<Card className="tenant-dashboard-panel" title="资源配置">
<div className="tenant-resource-list"><div><BookOutlined /><span></span><strong>{isMockDataMode ? '199' : '—'}</strong></div><div><DatabaseOutlined /><span></span><strong>{isMockDataMode ? '53,095' : '—'}</strong></div><div><TeamOutlined /><span></span><strong>{isMockDataMode ? '36' : '—'}</strong></div></div>
</Card>
</section>
<section className="tenant-dashboard-grid is-wide-left">
<Card className="tenant-dashboard-panel" title="地区运营排行">
{isMockDataMode ? <Table size="small" pagination={false} dataSource={regionRows} columns={[
{ title: '地区', dataIndex: 'region' },
{ title: '用户数', dataIndex: 'users' },
{ title: '题目数', dataIndex: 'questions' },
{ title: '增长', dataIndex: 'growth', render: (value: string) => <Typography.Text type="success">{value}</Typography.Text> },
]} /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="等待地区运营聚合接口" />}
</Card>
<Card className="tenant-dashboard-panel" title="运营动态">
{isMockDataMode ? <div className="tenant-activity-list">{activity.map(([type, detail, time]) => <div key={`${type}-${detail}`}><i /><p><strong>{type}</strong><span>{detail}</span></p><time>{time}</time></div>)}</div> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="等待运营事件接口" />}
</Card>
</section>
</div>;
}

View File

@@ -1,583 +1,102 @@
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";
import { useEffect, useMemo, useState } from 'react';
import { arrayMove } from '@dnd-kit/sortable';
import { EyeOutlined, ReloadOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Result, Space, Spin, Steps, Tabs, Tag, Typography } from 'antd';
import { useNavigate, useParams } from 'react-router';
import { useBackoffice } from '../../app/BackofficeContext';
import { useRuntime } from '../../app/RuntimeProvider';
import { canManageThemeAssets } from '../../shared/access';
import { AssetLibraryPanel } from '../../site-builder/AssetLibraryPanel';
import { BrandPanel } from '../../site-builder/BrandPanel';
import { ModulesPanel } from '../../site-builder/ModulesPanel';
import { PreviewPanel } from '../../site-builder/PreviewPanel';
import { ThemePanel } from '../../site-builder/ThemePanel';
import type { BuilderTab } from '../../site-builder/types';
import { useSiteBuilder } from '../../site-builder/useSiteBuilder';
const stepItems = [
{ title: "品牌信息" },
{ title: "选择模板" },
{ title: "主题样式" },
{ title: "页面模块" },
{ title: "实时预览" },
{ title: "发布上线" },
const tabs: Array<{ key: BuilderTab; label: string }> = [
{ key: 'brand', label: '品牌' },
{ key: 'theme', label: '主题' },
{ key: 'modules', label: '页面模块' },
{ key: 'assets', label: '资产库' },
{ key: 'preview', label: '实时预览' },
];
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;
}) {
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 } }),
);
const bootstrap = useBackoffice();
const builder = useSiteBuilder(refresh);
const navigate = useNavigate();
const params = useParams<{ builderTab?: string }>();
const [onboardingTab, setOnboardingTab] = useState<BuilderTab>('brand');
const routeTab = tabs.some((item) => item.key === params.builderTab) ? params.builderTab as BuilderTab : 'brand';
const tab = onboarding ? onboardingTab : routeTab;
const activeIndex = tabs.findIndex((item) => item.key === tab);
const assetPermission = canManageThemeAssets(bootstrap);
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 (!onboarding && params.builderTab && !tabs.some((item) => item.key === params.builderTab)) {
navigate('/manage/site/brand', { replace: true });
}
}, [navigate, onboarding, params.builderTab]);
const changeTab = (value: string) => {
const next = value as BuilderTab;
if (onboarding) setOnboardingTab(next);
else navigate(`/manage/site/${next}`);
};
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 = useMemo(() => {
if (!builder.draft) return null;
const draft = builder.draft;
if (tab === 'brand') return <BrandPanel branding={draft.branding} onChange={(patch) => builder.update((next) => { Object.assign(next.branding, patch); })} />;
if (tab === 'theme') return <ThemePanel theme={draft.theme} template={draft.features.template} onTemplate={builder.chooseTemplate} onChange={(patch) => builder.update((next) => { Object.assign(next.theme, patch); })} />;
if (tab === 'modules') return <ModulesPanel
navigation={draft.navigation}
modules={draft.homeModules}
onNavigation={(id, patch) => builder.update((next) => { const item = next.navigation.find((value) => value.id === id); if (item) Object.assign(item, patch); })}
onModule={(id, patch) => builder.update((next) => { const item = next.homeModules.find((value) => value.id === id); if (item) Object.assign(item, patch); })}
onReorder={(activeId, overId) => builder.update((next) => {
const from = next.homeModules.findIndex((item) => item.id === activeId);
const to = next.homeModules.findIndex((item) => item.id === overId);
if (from >= 0 && to >= 0) next.homeModules = arrayMove(next.homeModules, from, to);
})}
/>;
if (tab === 'assets') return <AssetLibraryPanel
branding={draft.branding}
canUpload={assetPermission}
uploads={builder.uploads}
onUpload={(slot, file) => void builder.uploadAsset(slot.role, file, `${draft.branding.brandName}-${slot.title}`)}
onRetry={(slot) => builder.retryAsset(slot.role, `${draft.branding.brandName}-${slot.title}`)}
onRemove={(slot) => builder.removeAsset(slot.role)}
onAltChange={(slot, alt) => builder.update((next) => { next.branding.assetAlt ??= {}; next.branding.assetAlt[slot.role] = alt; })}
/>;
return <PreviewPanel draft={draft} mode={builder.previewMode} onModeChange={builder.setPreviewMode} />;
}, [assetPermission, builder, tab]);
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>
);
})();
if (builder.error && !builder.draft) return <Result status="error" title="无法打开站点设计" subTitle={builder.error} extra={<Button icon={<ReloadOutlined />} onClick={() => void builder.reload()}></Button>} />;
if (!builder.draft || !builder.siteConfig) return <div className="grid min-h-[60vh] place-items-center"><Spin size="large" /></div>;
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>
);
const saveLabel = builder.saveState === 'saving' ? '正在保存' : builder.saveState === 'error' ? '保存失败' : builder.dirty ? '等待保存' : '草稿已保存';
const saveColor = builder.saveState === 'error' ? 'error' : builder.saveState === 'saving' || builder.dirty ? 'processing' : 'success';
return <main className="site-builder-workspace">
<header className="site-builder-header">
<div><Typography.Title level={2}>{onboarding ? '创建机构学习站点' : '站点与主题'}</Typography.Title><Typography.Text type="secondary"> {builder.siteConfig.configVersion}</Typography.Text></div>
<Space wrap>
<Tag icon={<SaveOutlined />} color={saveColor}>{saveLabel}</Tag>
<Button icon={<EyeOutlined />} onClick={() => window.open('/', '_blank')}>线</Button>
<Button type="primary" icon={<SendOutlined />} loading={builder.saveState === 'saving'} onClick={() => void builder.publish()}></Button>
</Space>
</header>
{builder.error && <Alert className="mb-4" type="error" showIcon title={builder.error} action={<Button size="small" icon={<ReloadOutlined />} onClick={() => void builder.reload()}></Button>} />}
{builder.published && <Alert className="mb-4" type="success" showIcon title="发布成功" description="Runtime 已刷新,学生端将使用最新品牌、主题与页面模块。" closable onClose={() => builder.setPublished(false)} />}
{onboarding ? <Card className="mb-4"><Steps current={activeIndex} responsive items={tabs.map((item) => ({ title: item.label }))} /></Card> : null}
<nav className="site-builder-tabs" aria-label="站点与主题配置"><Tabs activeKey={tab} onChange={changeTab} items={tabs.map((item) => ({ key: item.key, label: item.label }))} /></nav>
<section className="site-builder-canvas">{content}</section>
{onboarding && <footer className="site-builder-footer"><Button disabled={activeIndex === 0} onClick={() => setOnboardingTab(tabs[activeIndex - 1]?.key ?? 'brand')}></Button>{activeIndex < tabs.length - 1 ? <Button type="primary" onClick={() => setOnboardingTab(tabs[activeIndex + 1]?.key ?? 'preview')}></Button> : <Button type="primary" icon={<SendOutlined />} onClick={() => void builder.publish()}></Button>}</footer>}
</main>;
}

View File

@@ -0,0 +1,38 @@
import {
ApiOutlined,
CheckCircleOutlined,
LockOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { Button, Card, Result, Tag, Typography } from 'antd';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import type { ResolvedTenantAdminFeature, TenantAdminAvailability } from '../navigation';
const statusContent: Record<TenantAdminAvailability, { label: string; title: string; detail: string; icon: ReactNode; color: string }> = {
live: { label: '已接入', title: '功能已经接入', detail: '该功能已具备真实前端页面和后端契约。', icon: <CheckCircleOutlined />, color: 'success' },
frontend_pending: { label: '前端待接入', title: '功能正在接入中', detail: '后端能力或权限已经存在,当前批次尚未完成前端页面。', icon: <ToolOutlined />, color: 'processing' },
backend_missing: { label: '后端契约缺失', title: '功能建设中', detail: '旧版具备该能力,但当前 .NET 后端还没有完整公开契约。', icon: <ApiOutlined />, color: 'warning' },
tenant_disabled: { label: '租户未启用', title: '当前套餐未启用此能力', detail: '功能注册已经完成,但当前租户的 Runtime Feature 未开放。', icon: <LockOutlined />, color: 'default' },
};
export function TenantFeaturePlaceholderPage({ feature }: { feature: ResolvedTenantAdminFeature }) {
const navigate = useNavigate();
const content = statusContent[feature.availability];
return <main className="tenant-feature-placeholder">
<Card>
<Result
icon={content.icon}
title={feature.label}
subTitle={content.title}
extra={<Button onClick={() => navigate(-1)}></Button>}
/>
<div className="tenant-feature-placeholder-detail">
<Tag color={content.color}>{content.label}</Tag>
<Typography.Paragraph>{feature.description}</Typography.Paragraph>
<Typography.Paragraph type="secondary">{feature.contractNote ?? content.detail}</Typography.Paragraph>
<Typography.Text type="secondary"><code>docs/tenant-admin-coverage-matrix.md</code></Typography.Text>
</div>
</Card>
</main>;
}

View File

@@ -0,0 +1,74 @@
import {
EyeOutlined,
LogoutOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { Button, Select, Space, Tag, Tooltip } from 'antd';
import type { ResolvedTenantAdminCategory, TenantAdminCategoryCode } from '../navigation';
interface TenantAdminHeaderProps {
categories: ResolvedTenantAdminCategory[];
activeCategory: TenantAdminCategoryCode;
tenantName: string;
shortName: string;
logoUrl?: string;
siteState?: string;
regionId: string;
regionOptions: Array<{ label: string; value: string }>;
regionsLoading: boolean;
compact: boolean;
onCategorySelect: (category: ResolvedTenantAdminCategory) => void;
onRegionChange: (value: string) => void;
onOpenMenu: () => void;
onLogout: () => void;
}
export function TenantAdminHeader({
categories,
activeCategory,
tenantName,
shortName,
logoUrl,
siteState,
regionId,
regionOptions,
regionsLoading,
compact,
onCategorySelect,
onRegionChange,
onOpenMenu,
onLogout,
}: TenantAdminHeaderProps) {
return <header className="tenant-admin-header">
<div className="tenant-admin-brand">
{compact && <Button className="tenant-admin-menu-trigger" type="text" icon={<MenuOutlined />} aria-label="打开当前分类菜单" onClick={onOpenMenu} />}
{logoUrl ? <img src={logoUrl} alt="" /> : <span>{shortName.slice(0, 1) || '题'}</span>}
<div><strong>{shortName || tenantName}</strong><small></small></div>
</div>
<nav className="tenant-admin-category-nav" aria-label="租户后台一级导航">
{categories.map((category) => <button
key={category.code}
type="button"
className={activeCategory === category.code ? 'is-active' : ''}
aria-current={activeCategory === category.code ? 'page' : undefined}
onClick={() => onCategorySelect(category)}
>{category.icon}<span>{category.label}</span></button>)}
</nav>
<div className="tenant-admin-header-actions">
{!compact && <Select
aria-label="当前管理地区"
className="tenant-admin-region-select"
value={regionId}
loading={regionsLoading}
options={[{ label: '全部地区', value: '' }, ...regionOptions]}
onChange={onRegionChange}
/>}
{!compact && <Tag className="tenant-admin-site-state" color={siteState === 'active' ? 'success' : 'processing'}>{siteState === 'active' ? '站点已上线' : '站点配置中'}</Tag>}
<Space size={4}>
<Tooltip title="查看学生端"><Button type="text" icon={<EyeOutlined />} aria-label="查看学生端" onClick={() => window.open('/', '_blank', 'noopener,noreferrer')} /></Tooltip>
<Tooltip title="退出登录"><Button danger type="text" icon={<LogoutOutlined />} aria-label="退出登录" onClick={onLogout} /></Tooltip>
</Space>
</div>
</header>;
}

View File

@@ -0,0 +1,34 @@
import { Tag } from 'antd';
import { NavLink, useLocation } from 'react-router';
import { findTenantAdminFeature, type ResolvedTenantAdminCategory } from '../navigation';
const availabilityLabels = {
live: null,
frontend_pending: '待接入',
backend_missing: '缺契约',
tenant_disabled: '未启用',
} as const;
export function TenantAdminSidebar({ category, onNavigate }: { category: ResolvedTenantAdminCategory; onNavigate?: () => void }) {
const location = useLocation();
const activeFeature = findTenantAdminFeature([category], location.pathname);
return <aside className="tenant-admin-sidebar">
<div className="tenant-admin-sidebar-heading">
<span>{category.icon}</span>
<div><small></small><strong>{category.label}</strong></div>
</div>
<nav aria-label={`${category.label}二级导航`}>
{category.features.map((feature) => <NavLink
key={feature.code}
to={feature.path}
className={`tenant-admin-nav-item${activeFeature?.code === feature.code ? ' is-active' : ''}`}
onClick={onNavigate}
>
<span className="tenant-admin-nav-icon">{feature.icon}</span>
<span>{feature.label}</span>
{availabilityLabels[feature.availability] && <Tag variant="filled">{availabilityLabels[feature.availability]}</Tag>}
</NavLink>)}
</nav>
<div className="tenant-admin-sidebar-footnote"></div>
</aside>;
}

View File

@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import FeaturePlaceholderPage from '../student/pages/FeaturePlaceholderPage';
import { studentFeatures, type ResolvedStudentFeature } from '../student/features';
describe('FeaturePlaceholderPage', () => {
it('explains a backend gap without making a network request', () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const base = studentFeatures.find((item) => item.code === 'school-planning')!;
const feature: ResolvedStudentFeature = { ...base, status: 'placeholder', placeholderReason: 'backend_missing' };
render(<MemoryRouter><FeaturePlaceholderPage feature={feature} /></MemoryRouter>);
expect(screen.getByText('等待服务能力接入')).toBeInTheDocument();
expect(screen.getByText(/不会调用猜测接口/)).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { SafeMarkdown } from '../shared/SafeMarkdown';
describe('SafeMarkdown', () => {
it('renders GFM tables and KaTeX math', () => {
const { container } = render(<SafeMarkdown>{'| 科目 | 分数 |\n| --- | ---: |\n| 数学 | 120 |\n\n公式$x^2 + y^2$'}</SafeMarkdown>);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(container.querySelector('.katex')).toBeInTheDocument();
});
it('shows HTML question source as text without executing it and blocks javascript URLs', () => {
const { container } = render(<SafeMarkdown>{'<button onclick="alert(1)">提交</button> 的作用是? [危险链接](javascript:alert(1))'}</SafeMarkdown>);
expect(screen.getByText(/<button onclick="alert\(1\)">提交<\/button>/)).toBeInTheDocument();
expect(container.querySelector('button')).not.toBeInTheDocument();
expect(container.querySelector('img')).not.toBeInTheDocument();
expect(screen.getByText('危险链接').closest('a')).not.toHaveAttribute('href');
});
});

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, it } from 'vitest';
import { SitePreview } from '../shared/SitePreview';
import { createDefaultConfig } from '../shared/defaults';
@@ -8,7 +9,8 @@ describe('SitePreview', () => {
const config = createDefaultConfig();
config.branding.brandName = '远航教育';
config.homeModules.find(item => item.type === 'store-entry')!.visible = false;
render(<SitePreview config={config} />);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><SitePreview config={config} /></QueryClientProvider>);
expect(screen.getByText('远航教育', { exact: false })).toBeInTheDocument();
expect(screen.queryByText('精选课程')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,31 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { StudentProfile } from '../api/types';
import type { TenantRuntimeBootstrap } from '../contracts';
import { createDefaultConfig } from '../shared/defaults';
import { StudentMobileDock, StudentSidebar } from '../student/components/StudentNavigation';
const runtime: TenantRuntimeBootstrap = { ...createDefaultConfig(), schemaVersion: 1, configVersion: 1, tenantCode: 'demo', tenantName: '演示', siteState: 'active', enabledFeatures: ['learning.practice'], loginMethods: ['password'] };
const profile = { name: '张同学', primaryRole: 'Student', regionName: '天津', membership: { isSvip: false } } as StudentProfile;
afterEach(cleanup);
describe('student navigation', () => {
it('renders grouped desktop navigation and does not expose backoffice to a student', () => {
const toggle = vi.fn();
render(<MemoryRouter><StudentSidebar runtime={runtime} assets={{}} profile={profile} collapsed={false} onToggle={toggle} onLogout={vi.fn()} /></MemoryRouter>);
expect(screen.getByText('学习工作台')).toBeInTheDocument();
expect(screen.getByText('背单词')).toBeInTheDocument();
expect(screen.getAllByText('建设中').length).toBeGreaterThan(0);
expect(screen.queryByLabelText('租户后台')).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('收起侧栏'));
expect(toggle).toHaveBeenCalledOnce();
});
it('limits the mobile dock to five known destinations', () => {
render(<MemoryRouter><StudentMobileDock runtime={runtime} /></MemoryRouter>);
const dock = screen.getByLabelText('移动端学习导航');
expect(dock.querySelectorAll('a')).toHaveLength(5);
expect(within(dock).getByText('服务')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import type { BackofficeBootstrap, TenantRuntimeBootstrap } from '../contracts';
import { createDefaultConfig } from '../shared/defaults';
import { normalizeTenantBackofficePath } from '../api/backofficeApi';
import {
findTenantAdminFeature,
resolveTenantAdminCategory,
resolveTenantAdminNavigation,
type ResolvedTenantAdminCategory,
} from '../tenant/navigation';
import { TenantFeaturePlaceholderPage } from '../tenant/pages/TenantFeaturePlaceholderPage';
import { TenantAdminHeader } from '../tenant/shell/TenantAdminHeader';
import { TenantAdminSidebar } from '../tenant/shell/TenantAdminSidebar';
const runtime: TenantRuntimeBootstrap = {
...createDefaultConfig(),
schemaVersion: 1,
configVersion: 1,
tenantCode: 'demo',
tenantName: '天津成人考试中心',
siteState: 'active',
enabledFeatures: ['question_bank.private', 'marketing.site_content'],
loginMethods: ['password'],
};
function bootstrap(overrides: Partial<BackofficeBootstrap> = {}): BackofficeBootstrap {
return {
permissions: ['tenant:dashboard:view', 'tenant:question-bank:manage', 'tenant:settings:manage', 'tenant:site-content:manage'],
enabledFeatures: ['core.backoffice', 'question_bank.private', 'marketing.site_content'],
menus: [
{ code: 'tenant.dashboard', name: '租户总览', path: '/manage', requiredPermission: 'tenant:dashboard:view' },
{ code: 'tenant.question-bank', name: '私有题库', path: '/manage/question-bank', requiredPermission: 'tenant:question-bank:manage' },
{ code: 'tenant.site-content', name: '运营内容', path: '/manage/site/brand', requiredPermission: 'tenant:site-content:manage' },
],
...overrides,
};
}
describe('tenant admin navigation resolver', () => {
it('groups authorized features and keeps known missing-contract placeholders', () => {
const categories = resolveTenantAdminNavigation(bootstrap(), runtime);
expect(categories.map((category) => category.label)).toEqual(['总览', '教学内容', '营销交易', '站点装修', '系统设置']);
expect(categories.find((category) => category.code === 'overview')?.features.map((feature) => feature.label)).toEqual(['数据看板', '收益账本']);
expect(findTenantAdminFeature(categories, '/manage/question-bank/import')?.code).toBe('question-bank');
expect(resolveTenantAdminCategory(categories, '/manage/site/theme')).toBe('site');
expect(resolveTenantAdminCategory(categories, '/manage/onboarding')).toBe('site');
});
it('hides unauthorized backend modules and marks authorized disabled features', () => {
const value = bootstrap({
permissions: ['tenant:dashboard:view', 'tenant:vocabulary:manage'],
enabledFeatures: ['core.backoffice'],
menus: [{ code: 'tenant.vocabulary', name: '词汇', path: '/manage/vocabulary', requiredPermission: 'tenant:vocabulary:manage' }],
});
const categories = resolveTenantAdminNavigation(value, { ...runtime, enabledFeatures: [] });
expect(categories.flatMap((category) => category.features).some((feature) => feature.code === 'staff')).toBe(false);
expect(findTenantAdminFeature(categories, '/manage/vocabulary')?.availability).toBe('tenant_disabled');
});
it('normalizes only registered .NET tenant menu codes', () => {
expect(normalizeTenantBackofficePath('tenant.students', '/tenant/students')).toBe('/manage/students');
expect(normalizeTenantBackofficePath('tenant.site-content', '/tenant/site-content')).toBe('/manage/site/brand');
expect(normalizeTenantBackofficePath('tenant.unknown', '/tenant/unknown')).toBeNull();
});
});
describe('tenant admin navigation components', () => {
const categories: ResolvedTenantAdminCategory[] = [
{ code: 'overview', label: '总览', order: 10, defaultPath: '/manage', icon: null, features: [{ code: 'dashboard', category: 'overview', label: '数据看板', path: '/manage', icon: null, order: 10, availability: 'live', description: '总览' }] },
{ code: 'teaching', label: '教学内容', order: 20, defaultPath: '/manage/question-bank', icon: null, features: [{ code: 'question-bank', category: 'teaching', label: '题库管理', path: '/manage/question-bank', icon: null, order: 10, availability: 'frontend_pending', description: '题库' }] },
];
it('renders top categories and reports category selection', () => {
const onCategorySelect = vi.fn();
render(<TenantAdminHeader categories={categories} activeCategory="overview" tenantName="天津成人考试中心" shortName="天津题库" siteState="active" regionId="" regionOptions={[]} regionsLoading={false} compact={false} onCategorySelect={onCategorySelect} onRegionChange={vi.fn()} onOpenMenu={vi.fn()} onLogout={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '教学内容' }));
expect(onCategorySelect).toHaveBeenCalledWith(categories[1]);
expect(screen.getByRole('button', { name: '总览' })).toHaveAttribute('aria-current', 'page');
});
it('renders only the selected category secondary menu and a non-network placeholder', () => {
const request = vi.spyOn(window, 'fetch');
render(<MemoryRouter initialEntries={['/manage/question-bank']}><TenantAdminSidebar category={categories[1]!} /><TenantFeaturePlaceholderPage feature={categories[1]!.features[0]!} /></MemoryRouter>);
expect(screen.getByRole('link', { name: /题库管理/ })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /数据看板/ })).not.toBeInTheDocument();
expect(screen.getByText('前端待接入')).toBeInTheDocument();
expect(request).not.toHaveBeenCalled();
request.mockRestore();
});
});

View File

@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssetLibraryPanel } from '../site-builder/AssetLibraryPanel';
import { createDefaultConfig } from '../shared/defaults';
class SuccessfulUploadRequest {
static last: SuccessfulUploadRequest | null = null;
method = '';
url = '';
status = 0;
headers: Record<string, string> = {};
upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null };
onerror: ((event: ProgressEvent) => void) | null = null;
onload: ((event: ProgressEvent) => void) | null = null;
constructor() { SuccessfulUploadRequest.last = this; }
open(method: string, url: string) { this.method = method; this.url = url; }
setRequestHeader(key: string, value: string) { this.headers[key] = value; }
send() {
this.upload.onprogress?.({ lengthComputable: true, loaded: 10, total: 10 } as ProgressEvent);
this.status = 200;
this.onload?.({} as ProgressEvent);
}
}
afterEach(() => { vi.unstubAllGlobals(); });
describe('theme asset workflow', () => {
it('signs, uploads with returned method and headers, then confirms', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ item: { id: 'asset-1' }, upload: { method: 'PUT', url: 'https://oss.example/upload', headers: { 'x-oss-token': 'signed' } } }), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'Completed' }), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('XMLHttpRequest', SuccessfulUploadRequest);
vi.resetModules();
const { assetApi } = await import('../api/assetApi');
const progress: number[] = [];
const result = await assetApi.uploadThemeImage(new File(['image'], 'hero.webp', { type: 'image/webp' }), '首页主视觉', (value) => progress.push(value));
expect(result).toEqual({ assetId: 'asset-1', fileName: 'hero.webp' });
expect(SuccessfulUploadRequest.last).toMatchObject({ method: 'PUT', url: 'https://oss.example/upload', headers: { 'x-oss-token': 'signed' } });
expect(progress.at(-1)).toBe(100);
const signRequest = fetchMock.mock.calls[0]?.[0] as Request;
expect(await signRequest.clone().json()).toMatchObject({ category: 'theme', visibility: 'Public', isPublic: true });
const confirmRequest = fetchMock.mock.calls[1]?.[0] as Request;
expect(await confirmRequest.clone().json()).toMatchObject({ assetId: 'asset-1' });
});
it('keeps existing assets read-only when upload permission is absent', () => {
const branding = createDefaultConfig().branding;
branding.logoAssetId = 'existing-logo';
const onRemove = vi.fn();
render(<AssetLibraryPanel branding={branding} canUpload={false} uploads={{}} onUpload={vi.fn()} onRetry={vi.fn()} onRemove={onRemove} onAltChange={vi.fn()} />);
expect(screen.getByText('当前账号没有主题资产上传权限')).toBeInTheDocument();
const inputs = document.querySelectorAll<HTMLInputElement>('input[type="file"]');
expect(inputs.length).toBeGreaterThan(0);
inputs.forEach((input) => expect(input).toBeDisabled());
expect(screen.getByRole('button', { name: /移除引用/ })).toBeDisabled();
expect(onRemove).not.toHaveBeenCalled();
expect(screen.getByText('existing-logo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { Region, StudentProfile } from "../api/types";
import {
applyRememberedDevelopmentRegion,
developmentRegion,
rememberDevelopmentRegion,
withDevelopmentRegionFallback,
} from "../student/developmentCatalog";
const profile = { regionId: null, regionName: null } as StudentProfile;
describe("development catalog fallback", () => {
it("adds Tianjin only when an enabled development catalog is empty", () => {
expect(withDevelopmentRegionFallback([], true)).toEqual([developmentRegion]);
expect(withDevelopmentRegionFallback([], false)).toEqual([]);
const real = [{ id: "real", name: "北京" }] as Region[];
expect(withDevelopmentRegionFallback(real, true)).toBe(real);
});
it("remembers the demo region for the current development session", () => {
rememberDevelopmentRegion();
expect(applyRememberedDevelopmentRegion(profile)).toMatchObject({
regionId: developmentRegion.id,
regionName: "天津(演示)",
});
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { mobileStudentFeatures, normalizeStudentNavigation, publishedStudentFeatures } from '../student/features';
import { createDefaultConfig } from '../shared/defaults';
import type { TenantRuntimeBootstrap } from '../contracts';
describe('student feature registry', () => {
it('normalizes legacy navigation, keeps explicit hiding and resolves route states', () => {
const config = createDefaultConfig();
config.navigation.find((item) => item.href === '/collections')!.visible = false;
config.navigation = config.navigation.filter((item) => item.href !== '/vocabulary');
const runtime: TenantRuntimeBootstrap = { ...config, schemaVersion: 1, configVersion: 1, tenantCode: 'demo', tenantName: '演示', siteState: 'active', enabledFeatures: ['learning.practice', 'content.scoreline'], loginMethods: ['password'] };
const available = publishedStudentFeatures(runtime);
expect(available.find((item) => item.code === 'collections')).toBeUndefined();
expect(available.find((item) => item.code === 'dashboard')?.status).toBe('live');
expect(available.find((item) => item.code === 'scoreline')?.status).toBe('live');
expect(available.find((item) => item.code === 'vocabulary')?.placeholderReason).toBe('tenant_disabled');
expect(normalizeStudentNavigation([]).some((item) => item.href === '/profile')).toBe(true);
expect(mobileStudentFeatures(runtime).length).toBeLessThanOrEqual(5);
expect(mobileStudentFeatures(runtime).some((item) => item.code === 'services')).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { assetApi } from '../api';
import { queryKeys } from '../api/queryClient';
import type { BrandingConfig, ResolvedThemeAssets } from '../contracts';
function useAssetPreview(assetId?: string) {
return useQuery({
queryKey: queryKeys.asset(assetId ?? 'none'),
queryFn: () => assetApi.preview(assetId!),
enabled: Boolean(assetId),
staleTime: 20 * 60_000,
refetchInterval: (query) => {
const expiresAt = query.state.data?.url.expiresAt;
if (!expiresAt) return false;
return Math.max(60_000, new Date(expiresAt).getTime() - Date.now() - 60_000);
},
});
}
export function useThemeAssets(branding?: BrandingConfig | null): ResolvedThemeAssets {
const logo = useAssetPreview(branding?.logoAssetId);
const favicon = useAssetPreview(branding?.faviconAssetId);
const loginBackground = useAssetPreview(branding?.loginBackgroundAssetId);
const hero = useAssetPreview(branding?.heroAssetId);
const serviceQr = useAssetPreview(branding?.serviceQrAssetId);
return {
logo: logo.data?.url.url ?? branding?.logoUrl ?? undefined,
favicon: favicon.data?.url.url ?? branding?.faviconUrl ?? undefined,
loginBackground: loginBackground.data?.url.url,
hero: hero.data?.url.url,
serviceQr: serviceQr.data?.url.url,
};
}

View File

@@ -8,6 +8,7 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",