feat: complete tenant learning and admin experience
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
coverage/
|
||||
.generated/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
63
docs/legacy-feature-gap.md
Normal file
63
docs/legacy-feature-gap.md
Normal 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。
|
||||
40
docs/tenant-admin-coverage-matrix.md
Normal file
40
docs/tenant-admin-coverage-matrix.md
Normal 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
2144
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
32
scripts/generate-api.mjs
Normal 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
59
src/api/assetApi.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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
49
src/api/client.ts
Normal 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 ?? "请求失败,请稍后重试。",
|
||||
);
|
||||
}
|
||||
30
src/api/futureFeatureClients.ts
Normal file
30
src/api/futureFeatureClients.ts
Normal 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;
|
||||
}
|
||||
@@ -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
34
src/api/queryClient.ts
Normal 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
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
105
src/api/studentApi.ts
Normal 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
23
src/api/types.ts
Normal 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">;
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
281
src/index.css
281
src/index.css
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
42
src/shared/SafeMarkdown.tsx
Normal file
42
src/shared/SafeMarkdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
8
src/shared/number.ts
Normal 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))}%`;
|
||||
}
|
||||
60
src/site-builder/AssetLibraryPanel.tsx
Normal file
60
src/site-builder/AssetLibraryPanel.tsx
Normal 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:1,PNG / WebP' },
|
||||
{ role: 'favicon', title: '浏览器图标', description: '发布后显示在浏览器标签页。', ratio: '建议 1:1,PNG / SVG' },
|
||||
{ role: 'loginBackground', title: '登录背景', description: '登录页宽屏品牌背景,移动端会自适应裁切。', ratio: '建议 16:10,WebP' },
|
||||
{ role: 'hero', title: '首页 Hero', description: '公开品牌首页的主视觉资源。', ratio: '建议 4:3,WebP' },
|
||||
{ role: 'serviceQr', title: '客服二维码', description: '用于联系顾问模块,不应包含敏感信息。', ratio: '建议 1:1,PNG' },
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
src/site-builder/BrandPanel.tsx
Normal file
18
src/site-builder/BrandPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/site-builder/ModulesPanel.tsx
Normal file
34
src/site-builder/ModulesPanel.tsx
Normal 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>;
|
||||
}
|
||||
12
src/site-builder/PreviewPanel.tsx
Normal file
12
src/site-builder/PreviewPanel.tsx
Normal 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>;
|
||||
}
|
||||
41
src/site-builder/ThemePanel.tsx
Normal file
41
src/site-builder/ThemePanel.tsx
Normal 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
19
src/site-builder/types.ts
Normal 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;
|
||||
}
|
||||
157
src/site-builder/useSiteBuilder.ts
Normal file
157
src/site-builder/useSiteBuilder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
src/student/StudentSession.tsx
Normal file
40
src/student/StudentSession.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
39
src/student/components/StudentLayout.tsx
Normal file
39
src/student/components/StudentLayout.tsx
Normal 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>;
|
||||
}
|
||||
57
src/student/components/StudentNavigation.tsx
Normal file
57
src/student/components/StudentNavigation.tsx
Normal 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>;
|
||||
}
|
||||
35
src/student/developmentCatalog.ts
Normal file
35
src/student/developmentCatalog.ts
Normal 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
100
src/student/features.ts
Normal 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";
|
||||
}
|
||||
17
src/student/pages/CollectionsPage.tsx
Normal file
17
src/student/pages/CollectionsPage.tsx
Normal 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>;
|
||||
}
|
||||
62
src/student/pages/DashboardPage.tsx
Normal file
62
src/student/pages/DashboardPage.tsx
Normal 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>;
|
||||
}
|
||||
16
src/student/pages/FeaturePlaceholderPage.tsx
Normal file
16
src/student/pages/FeaturePlaceholderPage.tsx
Normal 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>;
|
||||
}
|
||||
10
src/student/pages/HandbookChaptersPage.tsx
Normal file
10
src/student/pages/HandbookChaptersPage.tsx
Normal 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>;
|
||||
}
|
||||
10
src/student/pages/HandbookEntriesPage.tsx
Normal file
10
src/student/pages/HandbookEntriesPage.tsx
Normal 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>;
|
||||
}
|
||||
11
src/student/pages/HandbookEntryReaderPage.tsx
Normal file
11
src/student/pages/HandbookEntryReaderPage.tsx
Normal 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>;
|
||||
}
|
||||
11
src/student/pages/HandbookPage.tsx
Normal file
11
src/student/pages/HandbookPage.tsx
Normal 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>;
|
||||
}
|
||||
14
src/student/pages/PracticeReportPage.tsx
Normal file
14
src/student/pages/PracticeReportPage.tsx
Normal 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>;
|
||||
}
|
||||
14
src/student/pages/ProfilePage.tsx
Normal file
14
src/student/pages/ProfilePage.tsx
Normal 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>;
|
||||
}
|
||||
35
src/student/pages/PublicLandingPage.tsx
Normal file
35
src/student/pages/PublicLandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/student/pages/QuizPage.tsx
Normal file
112
src/student/pages/QuizPage.tsx
Normal 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>;
|
||||
}
|
||||
45
src/student/pages/RegionSelectorPage.tsx
Normal file
45
src/student/pages/RegionSelectorPage.tsx
Normal 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>;
|
||||
}
|
||||
29
src/student/pages/ResetPasswordPage.tsx
Normal file
29
src/student/pages/ResetPasswordPage.tsx
Normal 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>;
|
||||
}
|
||||
15
src/student/pages/ScorelinePage.tsx
Normal file
15
src/student/pages/ScorelinePage.tsx
Normal 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>;
|
||||
}
|
||||
17
src/student/pages/ServicesPage.tsx
Normal file
17
src/student/pages/ServicesPage.tsx
Normal 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>;
|
||||
}
|
||||
69
src/student/pages/StudentLoginPage.tsx
Normal file
69
src/student/pages/StudentLoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/student/pages/SubjectDetailPage.tsx
Normal file
21
src/student/pages/SubjectDetailPage.tsx
Normal 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>;
|
||||
}
|
||||
18
src/student/pages/SubjectsPage.tsx
Normal file
18
src/student/pages/SubjectsPage.tsx
Normal 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>;
|
||||
}
|
||||
17
src/student/pages/VocabularyPage.tsx
Normal file
17
src/student/pages/VocabularyPage.tsx
Normal 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>;
|
||||
}
|
||||
10
src/student/pages/VocabularyUnitPage.tsx
Normal file
10
src/student/pages/VocabularyUnitPage.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
3
src/tenant/navigation/index.ts
Normal file
3
src/tenant/navigation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './registry';
|
||||
export * from './resolver';
|
||||
88
src/tenant/navigation/registry.tsx
Normal file
88
src/tenant/navigation/registry.tsx
Normal 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] : []),
|
||||
);
|
||||
70
src/tenant/navigation/resolver.ts
Normal file
70
src/tenant/navigation/resolver.ts
Normal 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';
|
||||
}
|
||||
47
src/tenant/navigation/types.ts
Normal file
47
src/tenant/navigation/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
38
src/tenant/pages/TenantFeaturePlaceholderPage.tsx
Normal file
38
src/tenant/pages/TenantFeaturePlaceholderPage.tsx
Normal 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>;
|
||||
}
|
||||
74
src/tenant/shell/TenantAdminHeader.tsx
Normal file
74
src/tenant/shell/TenantAdminHeader.tsx
Normal 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>;
|
||||
}
|
||||
34
src/tenant/shell/TenantAdminSidebar.tsx
Normal file
34
src/tenant/shell/TenantAdminSidebar.tsx
Normal 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>;
|
||||
}
|
||||
17
src/tests/FeaturePlaceholder.test.tsx
Normal file
17
src/tests/FeaturePlaceholder.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
19
src/tests/SafeMarkdown.test.tsx
Normal file
19
src/tests/SafeMarkdown.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
31
src/tests/StudentNavigation.test.tsx
Normal file
31
src/tests/StudentNavigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
92
src/tests/TenantAdminNavigation.test.tsx
Normal file
92
src/tests/TenantAdminNavigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
62
src/tests/assetApi.test.tsx
Normal file
62
src/tests/assetApi.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
27
src/tests/developmentCatalog.test.ts
Normal file
27
src/tests/developmentCatalog.test.ts
Normal 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: "天津(演示)",
|
||||
});
|
||||
});
|
||||
});
|
||||
21
src/tests/studentFeatures.test.ts
Normal file
21
src/tests/studentFeatures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
src/theme/useThemeAssets.ts
Normal file
34
src/theme/useThemeAssets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
Reference in New Issue
Block a user