forked from wangziqi/gongxue-base
feat: add tenant theme templates
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
||||||
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
|
|
||||||
更完整的进度看这些文档:
|
更完整的进度看这些文档:
|
||||||
@@ -287,7 +287,7 @@ apps/api/src/features/
|
|||||||
分佣设置、汇总、来源明细、结算单、审核/打款状态
|
分佣设置、汇总、来源明细、结算单、审核/打款状态
|
||||||
scoreline/ 分数线
|
scoreline/ 分数线
|
||||||
tenant/ 租户解析
|
tenant/ 租户解析
|
||||||
tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计
|
tenant-admin/ 租户后台配置、主题、成员权限、班级学生、活动、勋章和审计
|
||||||
tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出
|
tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出
|
||||||
video/ 题目视频讲解
|
video/ 题目视频讲解
|
||||||
```
|
```
|
||||||
@@ -302,7 +302,7 @@ API 身份上下文:
|
|||||||
|
|
||||||
## 重要安全约定
|
## 重要安全约定
|
||||||
|
|
||||||
- 租户公开配置不能存放密钥。
|
- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。
|
||||||
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
||||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export function tenantPermissionCatalog() {
|
|||||||
{ key: 'tenant:overview:read', label: '租户概览' },
|
{ key: 'tenant:overview:read', label: '租户概览' },
|
||||||
{ key: 'dashboard:read', label: '数据看板查看' },
|
{ key: 'dashboard:read', label: '数据看板查看' },
|
||||||
{ key: 'tenant:branding:write', label: '品牌配置' },
|
{ key: 'tenant:branding:write', label: '品牌配置' },
|
||||||
|
{ key: 'tenant:theme:read', label: '主题查看' },
|
||||||
|
{ key: 'tenant:theme:write', label: '主题预览/发布' },
|
||||||
{ key: 'tenant:settings:write', label: '公开设置' },
|
{ key: 'tenant:settings:write', label: '公开设置' },
|
||||||
{ key: 'tenant:domains:read', label: '域名查看' },
|
{ key: 'tenant:domains:read', label: '域名查看' },
|
||||||
{ key: 'tenant:domains:write', label: '域名管理' },
|
{ key: 'tenant:domains:write', label: '域名管理' },
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ import {
|
|||||||
grantBadgeRoute,
|
grantBadgeRoute,
|
||||||
generateActivationCodesRoute,
|
generateActivationCodesRoute,
|
||||||
paymentAccountsRoute,
|
paymentAccountsRoute,
|
||||||
|
previewTenantThemeRoute,
|
||||||
|
publishTenantThemeRoute,
|
||||||
tenantDomainsRoute,
|
tenantDomainsRoute,
|
||||||
tenantMembersRoute,
|
tenantMembersRoute,
|
||||||
tenantOverviewRoute,
|
tenantOverviewRoute,
|
||||||
tenantPermissionsRoute,
|
tenantPermissionsRoute,
|
||||||
|
tenantThemeRoute,
|
||||||
|
tenantThemeTemplatesRoute,
|
||||||
tenantRoleTemplatesRoute,
|
tenantRoleTemplatesRoute,
|
||||||
tenantSecretsRoute,
|
tenantSecretsRoute,
|
||||||
upsertTenantMemberRoute,
|
upsertTenantMemberRoute,
|
||||||
@@ -89,6 +93,10 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute],
|
['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute],
|
||||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||||
|
['GET', '/api/tenant-admin/theme-templates', tenantThemeTemplatesRoute],
|
||||||
|
['GET', '/api/tenant-admin/theme', tenantThemeRoute],
|
||||||
|
['POST', '/api/tenant-admin/theme/preview', previewTenantThemeRoute],
|
||||||
|
['POST', '/api/tenant-admin/theme/publish', publishTenantThemeRoute],
|
||||||
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
|
['GET', '/api/tenant-admin/domains', tenantDomainsRoute],
|
||||||
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
|
['POST', '/api/tenant-admin/domains', createTenantDomainRoute],
|
||||||
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
|
['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute],
|
||||||
|
|||||||
@@ -24,6 +24,48 @@ const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
|
|||||||
const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom'];
|
const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom'];
|
||||||
const BADGE_UNLOCK_TYPES = ['manual', 'auto', 'score', 'check_in', 'practice_count', 'vocabulary_mastered', 'mock_exam_score', 'feedback_resolved', 'custom'];
|
const BADGE_UNLOCK_TYPES = ['manual', 'auto', 'score', 'check_in', 'practice_count', 'vocabulary_mastered', 'mock_exam_score', 'feedback_resolved', 'custom'];
|
||||||
const BADGE_OPERATORS = ['gte', 'lte', 'eq', 'gt', 'lt'];
|
const BADGE_OPERATORS = ['gte', 'lte', 'eq', 'gt', 'lt'];
|
||||||
|
const THEME_MODES = ['light', 'dark', 'auto'];
|
||||||
|
const THEME_DENSITIES = ['compact', 'comfortable', 'dense'];
|
||||||
|
const THEME_PUBLIC_ASSET_KEYS = new Set([
|
||||||
|
'logoUrl',
|
||||||
|
'faviconUrl',
|
||||||
|
'appIconUrl',
|
||||||
|
'shareImageUrl',
|
||||||
|
'loginPosterUrl',
|
||||||
|
'splashImageUrl',
|
||||||
|
'iconSet',
|
||||||
|
'shareCardStyle',
|
||||||
|
]);
|
||||||
|
const THEME_TOKEN_KEYS = new Set([
|
||||||
|
'mode',
|
||||||
|
'primaryColor',
|
||||||
|
'accentColor',
|
||||||
|
'backgroundColor',
|
||||||
|
'surfaceColor',
|
||||||
|
'textColor',
|
||||||
|
'mutedColor',
|
||||||
|
'borderColor',
|
||||||
|
'successColor',
|
||||||
|
'warningColor',
|
||||||
|
'dangerColor',
|
||||||
|
'borderRadius',
|
||||||
|
'buttonRadius',
|
||||||
|
'fontFamily',
|
||||||
|
'layoutDensity',
|
||||||
|
'customCssVars',
|
||||||
|
'icons',
|
||||||
|
]);
|
||||||
|
const THEME_URL_ASSET_KEYS = new Set([
|
||||||
|
'logoUrl',
|
||||||
|
'faviconUrl',
|
||||||
|
'appIconUrl',
|
||||||
|
'shareImageUrl',
|
||||||
|
'loginPosterUrl',
|
||||||
|
'splashImageUrl',
|
||||||
|
]);
|
||||||
|
const SAFE_THEME_TOKEN_PATTERN = /^[a-z0-9][a-z0-9_-]{0,31}$/i;
|
||||||
|
const SAFE_THEME_CSS_VAR_KEY_PATTERN = /^--tiku-[a-z0-9-]{1,48}$/i;
|
||||||
|
const UNSAFE_THEME_STRING_PATTERN = /(app_private\.tenant_secrets|-----BEGIN|<script|javascript:|data:text\/html|expression\s*\(|@import|url\s*\()/i;
|
||||||
|
|
||||||
function jsonBodyValue(value: unknown) {
|
function jsonBodyValue(value: unknown) {
|
||||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||||
@@ -140,6 +182,185 @@ function publicJsonValue(value: unknown) {
|
|||||||
return JSON.stringify(publicConfig);
|
return JSON.stringify(publicConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertThemePublicString(value: unknown, path: string) {
|
||||||
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, `${path} must be a string`, 'INVALID_THEME_STRING');
|
||||||
|
}
|
||||||
|
if (UNSAFE_THEME_STRING_PATTERN.test(value)) {
|
||||||
|
throw new HttpError(400, `${path} contains unsafe public theme content`, 'PUBLIC_CONFIG_SECRET_REJECTED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertHexColor(value: unknown, key: string) {
|
||||||
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value.trim())) {
|
||||||
|
throw new HttpError(400, `${key} must be a #RRGGBB color`, 'INVALID_THEME_COLOR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeNumber(value: unknown, key: string) {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 32) {
|
||||||
|
throw new HttpError(400, `${key} must be a number between 0 and 32`, 'INVALID_THEME_NUMBER');
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemeFontFamily(value: unknown) {
|
||||||
|
const candidate = nullableString(value);
|
||||||
|
if (!candidate) return 'system';
|
||||||
|
assertThemePublicString(candidate, 'theme.fontFamily');
|
||||||
|
if (!/^[\u4e00-\u9fa5a-zA-Z0-9\s,"'-]{1,80}$/.test(candidate)) {
|
||||||
|
throw new HttpError(400, 'fontFamily contains unsupported characters', 'INVALID_THEME_FONT');
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemeCssVars(value: unknown) {
|
||||||
|
const source = objectValue(value);
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
if (!SAFE_THEME_CSS_VAR_KEY_PATTERN.test(key)) {
|
||||||
|
throw new HttpError(400, `Invalid CSS variable key: ${key}`, 'INVALID_THEME_CSS_VAR');
|
||||||
|
}
|
||||||
|
assertThemePublicString(raw, `theme.customCssVars.${key}`);
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (text.length > 96 || /[{};]/.test(text)) {
|
||||||
|
throw new HttpError(400, `Invalid CSS variable value: ${key}`, 'INVALID_THEME_CSS_VAR');
|
||||||
|
}
|
||||||
|
result[key] = text;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemeIcons(value: unknown) {
|
||||||
|
const source = objectValue(value);
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
if (!SAFE_THEME_TOKEN_PATTERN.test(key)) {
|
||||||
|
throw new HttpError(400, `Invalid icon key: ${key}`, 'INVALID_THEME_ICON');
|
||||||
|
}
|
||||||
|
assertThemePublicString(raw, `theme.icons.${key}`);
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (text.startsWith('/') || /^https?:\/\//i.test(text)) {
|
||||||
|
result[key] = sanitizeThemePublicUrl(text, `theme.icons.${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!SAFE_THEME_TOKEN_PATTERN.test(text)) {
|
||||||
|
throw new HttpError(400, `Invalid icon value: ${key}`, 'INVALID_THEME_ICON');
|
||||||
|
}
|
||||||
|
result[key] = text;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemePublicUrl(value: unknown, path: string) {
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
assertThemePublicString(value, path);
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text.startsWith('/')) {
|
||||||
|
if (text.startsWith('//') || text.includes('\\')) {
|
||||||
|
throw new HttpError(400, `${path} must be an HTTPS URL or absolute public path`, 'INVALID_THEME_ASSET_URL');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(text);
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, `${path} must be an HTTPS URL or absolute public path`, 'INVALID_THEME_ASSET_URL');
|
||||||
|
}
|
||||||
|
const isLocalDev = parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||||||
|
if (parsed.protocol !== 'https:' && !isLocalDev) {
|
||||||
|
throw new HttpError(400, `${path} must use HTTPS`, 'INVALID_THEME_ASSET_URL');
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemePublicToken(value: unknown, path: string) {
|
||||||
|
if (value === undefined || value === null || value === '') return '';
|
||||||
|
assertThemePublicString(value, path);
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!SAFE_THEME_TOKEN_PATTERN.test(text)) {
|
||||||
|
throw new HttpError(400, `${path} must be a safe token`, 'INVALID_THEME_ASSET_TOKEN');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemeTokens(value: unknown) {
|
||||||
|
const source = objectValue(value);
|
||||||
|
assertPublicConfigHasNoSecrets(source, 'theme');
|
||||||
|
const theme: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
if (!THEME_TOKEN_KEYS.has(key)) {
|
||||||
|
throw new HttpError(400, `Invalid theme key: ${key}`, 'INVALID_THEME_KEY');
|
||||||
|
}
|
||||||
|
if (key.endsWith('Color')) {
|
||||||
|
assertHexColor(raw, key);
|
||||||
|
theme[key] = typeof raw === 'string' ? raw.trim().toLowerCase() : raw;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'mode') {
|
||||||
|
theme.mode = optionalChoice(raw, THEME_MODES, 'light');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'layoutDensity') {
|
||||||
|
theme.layoutDensity = optionalChoice(raw, THEME_DENSITIES, 'comfortable');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'borderRadius' || key === 'buttonRadius') {
|
||||||
|
const parsed = themeNumber(raw, key);
|
||||||
|
if (parsed !== undefined) theme[key] = parsed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'fontFamily') {
|
||||||
|
theme.fontFamily = sanitizeThemeFontFamily(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'customCssVars') {
|
||||||
|
theme.customCssVars = sanitizeThemeCssVars(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'icons') {
|
||||||
|
theme.icons = sanitizeThemeIcons(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
theme[key] = raw;
|
||||||
|
}
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeThemePublicAssets(value: unknown) {
|
||||||
|
const source = objectValue(value);
|
||||||
|
assertPublicConfigHasNoSecrets(source, 'publicAssets');
|
||||||
|
const assets: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(source)) {
|
||||||
|
if (!THEME_PUBLIC_ASSET_KEYS.has(key)) {
|
||||||
|
throw new HttpError(400, `Invalid theme asset key: ${key}`, 'INVALID_THEME_ASSET_KEY');
|
||||||
|
}
|
||||||
|
assets[key] = THEME_URL_ASSET_KEYS.has(key)
|
||||||
|
? sanitizeThemePublicUrl(raw, `publicAssets.${key}`)
|
||||||
|
: sanitizeThemePublicToken(raw, `publicAssets.${key}`);
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTheme(templateTheme: unknown, overrides: unknown) {
|
||||||
|
return {
|
||||||
|
...objectValue(templateTheme),
|
||||||
|
...sanitizeThemeTokens(overrides),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeThemeAssets(templateAssets: unknown, overrides: unknown) {
|
||||||
|
return {
|
||||||
|
...objectValue(templateAssets),
|
||||||
|
...sanitizeThemePublicAssets(overrides),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function secretRef(scope: SecretScope, secretKey: string) {
|
function secretRef(scope: SecretScope, secretKey: string) {
|
||||||
return `app_private.tenant_secrets:${scope}:${secretKey}`;
|
return `app_private.tenant_secrets:${scope}:${secretKey}`;
|
||||||
}
|
}
|
||||||
@@ -718,6 +939,303 @@ export async function updateTenantSettingsRoute(ctx: RequestContext) {
|
|||||||
return { item };
|
return { item };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function tenantThemeTemplatesRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'tenant:theme:read');
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select code, name, description, preview_image_url as "previewImageUrl",
|
||||||
|
theme, public_assets as "publicAssets", sort_order as "sortOrder",
|
||||||
|
status, updated_at as "updatedAt"
|
||||||
|
from public.tenant_theme_templates
|
||||||
|
where status = 'active'
|
||||||
|
order by sort_order asc, code asc
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tenantThemeRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'tenant:theme:read');
|
||||||
|
|
||||||
|
const item = await queryOne(
|
||||||
|
`
|
||||||
|
select c.tenant_id as "tenantId",
|
||||||
|
c.active_template_code as "activeTemplateCode",
|
||||||
|
c.active_theme as "activeTheme",
|
||||||
|
c.active_public_assets as "activePublicAssets",
|
||||||
|
c.draft_template_code as "draftTemplateCode",
|
||||||
|
c.draft_theme as "draftTheme",
|
||||||
|
c.draft_public_assets as "draftPublicAssets",
|
||||||
|
c.status,
|
||||||
|
c.published_at as "publishedAt",
|
||||||
|
c.published_by as "publishedBy",
|
||||||
|
c.draft_updated_by as "draftUpdatedBy",
|
||||||
|
c.updated_at as "updatedAt",
|
||||||
|
at.name as "activeTemplateName",
|
||||||
|
dt.name as "draftTemplateName",
|
||||||
|
coalesce(b.theme, '{}'::jsonb) as "brandingTheme",
|
||||||
|
coalesce(b.public_assets, '{}'::jsonb) as "brandingPublicAssets"
|
||||||
|
from public.tenant_theme_configs c
|
||||||
|
left join public.tenant_theme_templates at on at.code = c.active_template_code
|
||||||
|
left join public.tenant_theme_templates dt on dt.code = c.draft_template_code
|
||||||
|
left join public.tenant_branding b on b.tenant_id = c.tenant_id
|
||||||
|
where c.tenant_id = $1
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[auth.tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item) return { item };
|
||||||
|
|
||||||
|
const fallback = await queryOne(
|
||||||
|
`
|
||||||
|
select b.tenant_id as "tenantId",
|
||||||
|
null::text as "activeTemplateCode",
|
||||||
|
coalesce(b.theme, '{}'::jsonb) as "activeTheme",
|
||||||
|
coalesce(b.public_assets, '{}'::jsonb) as "activePublicAssets",
|
||||||
|
null::text as "draftTemplateCode",
|
||||||
|
'{}'::jsonb as "draftTheme",
|
||||||
|
'{}'::jsonb as "draftPublicAssets",
|
||||||
|
'published'::text as status,
|
||||||
|
null::timestamptz as "publishedAt",
|
||||||
|
null::uuid as "publishedBy",
|
||||||
|
null::uuid as "draftUpdatedBy",
|
||||||
|
b.updated_at as "updatedAt",
|
||||||
|
null::text as "activeTemplateName",
|
||||||
|
null::text as "draftTemplateName",
|
||||||
|
coalesce(b.theme, '{}'::jsonb) as "brandingTheme",
|
||||||
|
coalesce(b.public_assets, '{}'::jsonb) as "brandingPublicAssets"
|
||||||
|
from public.tenant_branding b
|
||||||
|
where b.tenant_id = $1
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[auth.tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { item: fallback || {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
activeTemplateCode: null,
|
||||||
|
activeTheme: {},
|
||||||
|
activePublicAssets: {},
|
||||||
|
draftTemplateCode: null,
|
||||||
|
draftTheme: {},
|
||||||
|
draftPublicAssets: {},
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewTenantThemeRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'tenant:theme:write');
|
||||||
|
const body = await readJsonBody(ctx);
|
||||||
|
const templateCode = requiredString(body, 'templateCode');
|
||||||
|
const themeOverrides = body.theme || body.themeOverrides || {};
|
||||||
|
const publicAssetOverrides = body.publicAssets || body.publicAssetOverrides || {};
|
||||||
|
|
||||||
|
const item = await transaction(async client => {
|
||||||
|
const template = await client.query<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
theme: Record<string, unknown>;
|
||||||
|
publicAssets: Record<string, unknown>;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select code, name, theme, public_assets as "publicAssets"
|
||||||
|
from public.tenant_theme_templates
|
||||||
|
where code = $1 and status = 'active'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[templateCode],
|
||||||
|
);
|
||||||
|
const selected = template.rows[0];
|
||||||
|
if (!selected) throw new HttpError(404, 'Theme template not found', 'THEME_TEMPLATE_NOT_FOUND');
|
||||||
|
|
||||||
|
const draftTheme = mergeTheme(selected.theme, themeOverrides);
|
||||||
|
const draftPublicAssets = mergeThemeAssets(selected.publicAssets, publicAssetOverrides);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_theme_configs (
|
||||||
|
tenant_id, draft_template_code, draft_theme, draft_public_assets,
|
||||||
|
status, draft_updated_by
|
||||||
|
)
|
||||||
|
values ($1, $2, $3::jsonb, $4::jsonb, 'draft', $5)
|
||||||
|
on conflict (tenant_id)
|
||||||
|
do update set draft_template_code = excluded.draft_template_code,
|
||||||
|
draft_theme = excluded.draft_theme,
|
||||||
|
draft_public_assets = excluded.draft_public_assets,
|
||||||
|
status = 'draft',
|
||||||
|
draft_updated_by = excluded.draft_updated_by,
|
||||||
|
updated_at = now()
|
||||||
|
returning tenant_id as "tenantId",
|
||||||
|
draft_template_code as "draftTemplateCode",
|
||||||
|
draft_theme as "draftTheme",
|
||||||
|
draft_public_assets as "draftPublicAssets",
|
||||||
|
status, updated_at as "updatedAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
selected.code,
|
||||||
|
JSON.stringify(draftTheme),
|
||||||
|
JSON.stringify(draftPublicAssets),
|
||||||
|
auth.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAudit(client, auth, 'tenant.theme.previewed', 'tenant_theme_configs', auth.tenantId, {
|
||||||
|
templateCode: selected.code,
|
||||||
|
themeKeys: Object.keys(draftTheme),
|
||||||
|
publicAssetKeys: Object.keys(draftPublicAssets),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result.rows[0],
|
||||||
|
templateName: selected.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishTenantThemeRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'tenant:theme:write');
|
||||||
|
const body = await readJsonBody(ctx);
|
||||||
|
const useDraft = boolValue(body.useDraft, true);
|
||||||
|
const templateCode = nullableString(body.templateCode);
|
||||||
|
|
||||||
|
const item = await transaction(async client => {
|
||||||
|
let sourceTheme: Record<string, unknown>;
|
||||||
|
let sourcePublicAssets: Record<string, unknown>;
|
||||||
|
let sourceTemplateCode: string | null;
|
||||||
|
let templateName: string | null = null;
|
||||||
|
|
||||||
|
if (useDraft) {
|
||||||
|
const draft = await client.query<{
|
||||||
|
draftTemplateCode: string | null;
|
||||||
|
draftTheme: Record<string, unknown>;
|
||||||
|
draftPublicAssets: Record<string, unknown>;
|
||||||
|
templateName: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select c.draft_template_code as "draftTemplateCode",
|
||||||
|
c.draft_theme as "draftTheme",
|
||||||
|
c.draft_public_assets as "draftPublicAssets",
|
||||||
|
t.name as "templateName"
|
||||||
|
from public.tenant_theme_configs c
|
||||||
|
left join public.tenant_theme_templates t on t.code = c.draft_template_code
|
||||||
|
where c.tenant_id = $1
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[auth.tenantId],
|
||||||
|
);
|
||||||
|
const row = draft.rows[0];
|
||||||
|
if (!row || !row.draftTemplateCode) {
|
||||||
|
throw new HttpError(400, 'No draft theme to publish', 'THEME_DRAFT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sourceTheme = sanitizeThemeTokens(row.draftTheme);
|
||||||
|
sourcePublicAssets = sanitizeThemePublicAssets(row.draftPublicAssets);
|
||||||
|
sourceTemplateCode = row.draftTemplateCode;
|
||||||
|
templateName = row.templateName;
|
||||||
|
} else {
|
||||||
|
const selectedCode = templateCode || requiredString(body, 'templateCode');
|
||||||
|
const template = await client.query<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
theme: Record<string, unknown>;
|
||||||
|
publicAssets: Record<string, unknown>;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select code, name, theme, public_assets as "publicAssets"
|
||||||
|
from public.tenant_theme_templates
|
||||||
|
where code = $1 and status = 'active'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[selectedCode],
|
||||||
|
);
|
||||||
|
const selected = template.rows[0];
|
||||||
|
if (!selected) throw new HttpError(404, 'Theme template not found', 'THEME_TEMPLATE_NOT_FOUND');
|
||||||
|
sourceTheme = mergeTheme(selected.theme, body.theme || body.themeOverrides || {});
|
||||||
|
sourcePublicAssets = mergeThemeAssets(selected.publicAssets, body.publicAssets || body.publicAssetOverrides || {});
|
||||||
|
sourceTemplateCode = selected.code;
|
||||||
|
templateName = selected.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_branding (tenant_id, brand_name, theme, public_assets)
|
||||||
|
values (
|
||||||
|
$1,
|
||||||
|
coalesce((select brand_name from public.tenant_branding where tenant_id = $1), (select name from public.tenants where id = $1), '租户题库'),
|
||||||
|
$2::jsonb,
|
||||||
|
$3::jsonb
|
||||||
|
)
|
||||||
|
on conflict (tenant_id)
|
||||||
|
do update set theme = excluded.theme,
|
||||||
|
public_assets = coalesce(public.tenant_branding.public_assets, '{}'::jsonb) || excluded.public_assets,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[auth.tenantId, JSON.stringify(sourceTheme), JSON.stringify(sourcePublicAssets)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_theme_configs (
|
||||||
|
tenant_id, active_template_code, active_theme, active_public_assets,
|
||||||
|
draft_template_code, draft_theme, draft_public_assets,
|
||||||
|
status, published_at, published_by, draft_updated_by
|
||||||
|
)
|
||||||
|
values ($1, $2, $3::jsonb, $4::jsonb, null, '{}'::jsonb, '{}'::jsonb, 'published', now(), $5, $5)
|
||||||
|
on conflict (tenant_id)
|
||||||
|
do update set active_template_code = excluded.active_template_code,
|
||||||
|
active_theme = excluded.active_theme,
|
||||||
|
active_public_assets = excluded.active_public_assets,
|
||||||
|
draft_template_code = null,
|
||||||
|
draft_theme = '{}'::jsonb,
|
||||||
|
draft_public_assets = '{}'::jsonb,
|
||||||
|
status = 'published',
|
||||||
|
published_at = now(),
|
||||||
|
published_by = excluded.published_by,
|
||||||
|
updated_at = now()
|
||||||
|
returning tenant_id as "tenantId",
|
||||||
|
active_template_code as "activeTemplateCode",
|
||||||
|
active_theme as "activeTheme",
|
||||||
|
active_public_assets as "activePublicAssets",
|
||||||
|
status,
|
||||||
|
published_at as "publishedAt",
|
||||||
|
published_by as "publishedBy",
|
||||||
|
updated_at as "updatedAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
sourceTemplateCode,
|
||||||
|
JSON.stringify(sourceTheme),
|
||||||
|
JSON.stringify(sourcePublicAssets),
|
||||||
|
auth.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAudit(client, auth, 'tenant.theme.published', 'tenant_theme_configs', auth.tenantId, {
|
||||||
|
templateCode: sourceTemplateCode,
|
||||||
|
themeKeys: Object.keys(sourceTheme),
|
||||||
|
publicAssetKeys: Object.keys(sourcePublicAssets),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result.rows[0],
|
||||||
|
templateName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item };
|
||||||
|
}
|
||||||
|
|
||||||
export async function tenantDomainsRoute(ctx: RequestContext) {
|
export async function tenantDomainsRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantAdmin(ctx);
|
const auth = await requireTenantAdmin(ctx);
|
||||||
requireTenantPermission(auth, 'tenant:domains:read');
|
requireTenantPermission(auth, 'tenant:domains:read');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface TenantResolveRow {
|
|||||||
service_wechat: string | null;
|
service_wechat: string | null;
|
||||||
service_account_name: string | null;
|
service_account_name: string | null;
|
||||||
theme: Record<string, unknown>;
|
theme: Record<string, unknown>;
|
||||||
|
public_assets: Record<string, unknown>;
|
||||||
feature_flags: Record<string, unknown>;
|
feature_flags: Record<string, unknown>;
|
||||||
admin_feature_flags: Record<string, unknown>;
|
admin_feature_flags: Record<string, unknown>;
|
||||||
public_config: Record<string, unknown>;
|
public_config: Record<string, unknown>;
|
||||||
@@ -37,13 +38,16 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
|||||||
select t.id, t.slug, t.name, t.status, t.mode,
|
select t.id, t.slug, t.name, t.status, t.mode,
|
||||||
null::text as host,
|
null::text as host,
|
||||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
b.service_wechat, b.service_account_name,
|
||||||
|
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||||
|
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||||
from public.tenants t
|
from public.tenants t
|
||||||
left join public.tenant_branding b on b.tenant_id = t.id
|
left join public.tenant_branding b on b.tenant_id = t.id
|
||||||
left join public.tenant_settings s on s.tenant_id = t.id
|
left join public.tenant_settings s on s.tenant_id = t.id
|
||||||
|
left join public.tenant_theme_configs tc on tc.tenant_id = t.id and tc.status = 'published'
|
||||||
where t.slug = $1 and t.status = 'active'
|
where t.slug = $1 and t.status = 'active'
|
||||||
limit 1
|
limit 1
|
||||||
`,
|
`,
|
||||||
@@ -54,7 +58,9 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
|||||||
select t.id, t.slug, t.name, t.status, t.mode,
|
select t.id, t.slug, t.name, t.status, t.mode,
|
||||||
d.host::text,
|
d.host::text,
|
||||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||||
b.service_wechat, b.service_account_name, coalesce(b.theme, '{}'::jsonb) as theme,
|
b.service_wechat, b.service_account_name,
|
||||||
|
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||||
|
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||||
@@ -62,6 +68,7 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
|||||||
join public.tenants t on t.id = d.tenant_id
|
join public.tenants t on t.id = d.tenant_id
|
||||||
left join public.tenant_branding b on b.tenant_id = t.id
|
left join public.tenant_branding b on b.tenant_id = t.id
|
||||||
left join public.tenant_settings s on s.tenant_id = t.id
|
left join public.tenant_settings s on s.tenant_id = t.id
|
||||||
|
left join public.tenant_theme_configs tc on tc.tenant_id = t.id and tc.status = 'published'
|
||||||
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
||||||
limit 1
|
limit 1
|
||||||
`,
|
`,
|
||||||
@@ -100,6 +107,7 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
|||||||
serviceWechat: row.service_wechat || '',
|
serviceWechat: row.service_wechat || '',
|
||||||
serviceAccountName: row.service_account_name || '',
|
serviceAccountName: row.service_account_name || '',
|
||||||
theme: row.theme || {},
|
theme: row.theme || {},
|
||||||
|
publicAssets: row.public_assets || {},
|
||||||
},
|
},
|
||||||
features: row.feature_flags || {},
|
features: row.feature_flags || {},
|
||||||
adminFeatures: row.admin_feature_flags || {},
|
adminFeatures: row.admin_feature_flags || {},
|
||||||
|
|||||||
@@ -107,6 +107,15 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-metric.theme-template {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric.theme-template.selected {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-metric-label {
|
.admin-metric-label {
|
||||||
display: block;
|
display: block;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -230,6 +239,60 @@
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-swatch-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-panel {
|
||||||
|
margin-top: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe3ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-banner {
|
||||||
|
min-height: 134px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-title {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview-pill {
|
||||||
|
width: 88px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.break-line {
|
.break-line {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
loadTenantDomains,
|
loadTenantDomains,
|
||||||
loadTenantOverview,
|
loadTenantOverview,
|
||||||
loadTenantPermissions,
|
loadTenantPermissions,
|
||||||
|
loadTenantTheme,
|
||||||
|
loadThemeTemplates,
|
||||||
|
previewTenantTheme,
|
||||||
|
publishTenantTheme,
|
||||||
upsertTenantMember,
|
upsertTenantMember,
|
||||||
upsertRoleTemplate,
|
upsertRoleTemplate,
|
||||||
type TenantMemberItem,
|
type TenantMemberItem,
|
||||||
@@ -18,6 +22,8 @@ import {
|
|||||||
type TenantPermissionCatalogItem,
|
type TenantPermissionCatalogItem,
|
||||||
type TenantPermissionsPayload,
|
type TenantPermissionsPayload,
|
||||||
type TenantRoleTemplateItem,
|
type TenantRoleTemplateItem,
|
||||||
|
type TenantThemeConfigItem,
|
||||||
|
type TenantThemeTemplateItem,
|
||||||
} from '@/services/tenantAdmin';
|
} from '@/services/tenantAdmin';
|
||||||
import '../admin.css';
|
import '../admin.css';
|
||||||
|
|
||||||
@@ -52,6 +58,16 @@ interface MemberForm {
|
|||||||
permissions: Record<string, boolean>;
|
permissions: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThemeForm {
|
||||||
|
templateCode: string;
|
||||||
|
primaryColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
logoUrl: string;
|
||||||
|
shareImageUrl: string;
|
||||||
|
iconSet: string;
|
||||||
|
shareCardStyle: string;
|
||||||
|
}
|
||||||
|
|
||||||
const BASE_ROLE_OPTIONS = [
|
const BASE_ROLE_OPTIONS = [
|
||||||
{ key: 'tenant_operator', label: '运营' },
|
{ key: 'tenant_operator', label: '运营' },
|
||||||
{ key: 'teacher', label: '教师' },
|
{ key: 'teacher', label: '教师' },
|
||||||
@@ -100,6 +116,16 @@ const MODULE_CATALOG: TenantPermissionCatalogItem[] = [
|
|||||||
{ key: 'crm_queue', label: 'CRM 队列' },
|
{ key: 'crm_queue', label: 'CRM 队列' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_THEME_FORM: ThemeForm = {
|
||||||
|
templateCode: 'classic',
|
||||||
|
primaryColor: '#2563eb',
|
||||||
|
accentColor: '#0f766e',
|
||||||
|
logoUrl: '',
|
||||||
|
shareImageUrl: '',
|
||||||
|
iconSet: 'classic',
|
||||||
|
shareCardStyle: 'clean',
|
||||||
|
};
|
||||||
|
|
||||||
function boolRecord(value: unknown) {
|
function boolRecord(value: unknown) {
|
||||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
const result: Record<string, boolean> = {};
|
const result: Record<string, boolean> = {};
|
||||||
@@ -176,6 +202,27 @@ function memberFormFrom(item?: TenantMemberItem | null): MemberForm {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringFromRecord(value: Record<string, unknown> | undefined, key: string, fallback = '') {
|
||||||
|
const raw = value?.[key];
|
||||||
|
return typeof raw === 'string' ? raw : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeFormFrom(item?: TenantThemeConfigItem | null, template?: TenantThemeTemplateItem | null): ThemeForm {
|
||||||
|
const theme = item?.draftTemplateCode ? item.draftTheme : item?.activeTheme;
|
||||||
|
const assets = item?.draftTemplateCode ? item.draftPublicAssets : item?.activePublicAssets;
|
||||||
|
const templateTheme = template?.theme || {};
|
||||||
|
const templateAssets = template?.publicAssets || {};
|
||||||
|
return {
|
||||||
|
templateCode: item?.draftTemplateCode || item?.activeTemplateCode || template?.code || DEFAULT_THEME_FORM.templateCode,
|
||||||
|
primaryColor: stringFromRecord(theme, 'primaryColor', stringFromRecord(templateTheme, 'primaryColor', DEFAULT_THEME_FORM.primaryColor)),
|
||||||
|
accentColor: stringFromRecord(theme, 'accentColor', stringFromRecord(templateTheme, 'accentColor', DEFAULT_THEME_FORM.accentColor)),
|
||||||
|
logoUrl: stringFromRecord(assets, 'logoUrl', stringFromRecord(templateAssets, 'logoUrl', DEFAULT_THEME_FORM.logoUrl)),
|
||||||
|
shareImageUrl: stringFromRecord(assets, 'shareImageUrl', stringFromRecord(templateAssets, 'shareImageUrl', DEFAULT_THEME_FORM.shareImageUrl)),
|
||||||
|
iconSet: stringFromRecord(assets, 'iconSet', stringFromRecord(templateAssets, 'iconSet', DEFAULT_THEME_FORM.iconSet)),
|
||||||
|
shareCardStyle: stringFromRecord(assets, 'shareCardStyle', stringFromRecord(templateAssets, 'shareCardStyle', DEFAULT_THEME_FORM.shareCardStyle)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function catalogLabel(item: TenantPermissionCatalogItem) {
|
function catalogLabel(item: TenantPermissionCatalogItem) {
|
||||||
return item.label ? `${item.label}` : item.key;
|
return item.label ? `${item.label}` : item.key;
|
||||||
}
|
}
|
||||||
@@ -187,6 +234,9 @@ export default function TenantSettingsPage() {
|
|||||||
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
|
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
|
||||||
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
|
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
|
||||||
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
const [members, setMembers] = useState<TenantMemberItem[]>([]);
|
||||||
|
const [themeTemplates, setThemeTemplates] = useState<TenantThemeTemplateItem[]>([]);
|
||||||
|
const [themeConfig, setThemeConfig] = useState<TenantThemeConfigItem | null>(null);
|
||||||
|
const [themeForm, setThemeForm] = useState<ThemeForm>(() => DEFAULT_THEME_FORM);
|
||||||
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
|
const [permissionsPayload, setPermissionsPayload] = useState<TenantPermissionsPayload>({});
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState('');
|
const [selectedRoleId, setSelectedRoleId] = useState('');
|
||||||
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => emptyRoleTemplateForm());
|
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => emptyRoleTemplateForm());
|
||||||
@@ -203,25 +253,37 @@ export default function TenantSettingsPage() {
|
|||||||
const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || [];
|
const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || [];
|
||||||
const editingSystemRole = Boolean(selectedRole?.isSystem);
|
const editingSystemRole = Boolean(selectedRole?.isSystem);
|
||||||
const activeRoleTemplates = roles.filter(item => item.status === 'active');
|
const activeRoleTemplates = roles.filter(item => item.status === 'active');
|
||||||
|
const selectedThemeTemplate = useMemo(
|
||||||
|
() => themeTemplates.find(item => item.code === themeForm.templateCode) || themeTemplates[0] || null,
|
||||||
|
[themeTemplates, themeForm.templateCode],
|
||||||
|
);
|
||||||
|
|
||||||
async function reloadSettings(preferredRoleId = selectedRoleId) {
|
async function reloadSettings(preferredRoleId = selectedRoleId) {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [overviewPayload, domainPayload, paymentPayload, authPayload, rolePayload, permissionPayload] = await Promise.all([
|
const [overviewPayload, domainPayload, paymentPayload, authPayload, rolePayload, permissionPayload, templatePayload, themePayload] = await Promise.all([
|
||||||
loadTenantOverview().catch(() => ({ item: null })),
|
loadTenantOverview().catch(() => ({ item: null })),
|
||||||
loadTenantDomains().catch(() => ({ items: [] })),
|
loadTenantDomains().catch(() => ({ items: [] })),
|
||||||
loadPaymentAccounts().catch(() => ({ items: [] })),
|
loadPaymentAccounts().catch(() => ({ items: [] })),
|
||||||
loadAuthProviders().catch(() => ({ items: [] })),
|
loadAuthProviders().catch(() => ({ items: [] })),
|
||||||
loadRoleTemplates().catch(() => ({ items: [] })),
|
loadRoleTemplates().catch(() => ({ items: [] })),
|
||||||
loadTenantPermissions().catch(() => ({})),
|
loadTenantPermissions().catch(() => ({})),
|
||||||
|
loadThemeTemplates().catch(() => ({ items: [] })),
|
||||||
|
loadTenantTheme().catch(() => ({ item: null })),
|
||||||
]);
|
]);
|
||||||
const nextRoles = rolePayload.items || [];
|
const nextRoles = rolePayload.items || [];
|
||||||
const nextSelected = nextRoles.find(item => item.id === preferredRoleId) || nextRoles[0] || null;
|
const nextSelected = nextRoles.find(item => item.id === preferredRoleId) || nextRoles[0] || null;
|
||||||
|
const nextThemeTemplates = templatePayload.items || [];
|
||||||
|
const nextThemeConfig = themePayload.item || null;
|
||||||
|
const nextTemplate = nextThemeTemplates.find(item => item.code === (nextThemeConfig?.draftTemplateCode || nextThemeConfig?.activeTemplateCode)) || nextThemeTemplates[0] || null;
|
||||||
setOverview(overviewPayload.item || null);
|
setOverview(overviewPayload.item || null);
|
||||||
setDomains(domainPayload.items || []);
|
setDomains(domainPayload.items || []);
|
||||||
setPayments(paymentPayload.items || []);
|
setPayments(paymentPayload.items || []);
|
||||||
setAuthProviders(authPayload.items || []);
|
setAuthProviders(authPayload.items || []);
|
||||||
setRoles(nextRoles);
|
setRoles(nextRoles);
|
||||||
|
setThemeTemplates(nextThemeTemplates);
|
||||||
|
setThemeConfig(nextThemeConfig);
|
||||||
|
setThemeForm(themeFormFrom(nextThemeConfig, nextTemplate));
|
||||||
setPermissionsPayload(permissionPayload);
|
setPermissionsPayload(permissionPayload);
|
||||||
setSelectedRoleId(nextSelected?.id || '');
|
setSelectedRoleId(nextSelected?.id || '');
|
||||||
setRoleForm(nextSelected ? roleTemplateFormFrom(nextSelected) : emptyRoleTemplateForm());
|
setRoleForm(nextSelected ? roleTemplateFormFrom(nextSelected) : emptyRoleTemplateForm());
|
||||||
@@ -269,6 +331,15 @@ export default function TenantSettingsPage() {
|
|||||||
setMemberForm(memberFormFrom(item));
|
setMemberForm(memberFormFrom(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectThemeTemplate(item: TenantThemeTemplateItem) {
|
||||||
|
setThemeForm(prev => ({
|
||||||
|
...themeFormFrom(null, item),
|
||||||
|
logoUrl: prev.logoUrl,
|
||||||
|
shareImageUrl: prev.shareImageUrl,
|
||||||
|
templateCode: item.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function updateBooleanMap(field: BooleanMapField, key: string) {
|
function updateBooleanMap(field: BooleanMapField, key: string) {
|
||||||
setRoleForm(prev => ({
|
setRoleForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -414,6 +485,71 @@ export default function TenantSettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function previewTheme() {
|
||||||
|
if (!themeForm.templateCode) {
|
||||||
|
Taro.showToast({ title: '请选择主题模板', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('preview-theme');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await previewTenantTheme({
|
||||||
|
templateCode: themeForm.templateCode,
|
||||||
|
theme: {
|
||||||
|
primaryColor: themeForm.primaryColor.trim(),
|
||||||
|
accentColor: themeForm.accentColor.trim(),
|
||||||
|
},
|
||||||
|
publicAssets: {
|
||||||
|
...(themeForm.logoUrl.trim() ? { logoUrl: themeForm.logoUrl.trim() } : {}),
|
||||||
|
...(themeForm.shareImageUrl.trim() ? { shareImageUrl: themeForm.shareImageUrl.trim() } : {}),
|
||||||
|
...(themeForm.iconSet.trim() ? { iconSet: themeForm.iconSet.trim() } : {}),
|
||||||
|
...(themeForm.shareCardStyle.trim() ? { shareCardStyle: themeForm.shareCardStyle.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '主题草稿已保存', icon: 'success' });
|
||||||
|
await reloadSettings(selectedRoleId);
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '主题预览失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishTheme() {
|
||||||
|
const confirmed = await Taro.showModal({
|
||||||
|
title: '发布主题',
|
||||||
|
content: themeConfig?.draftTemplateCode ? '确认发布当前主题草稿?发布后学生端和后台会读取新主题。' : '当前没有草稿,将按表单配置直接发布主题。',
|
||||||
|
confirmText: '发布',
|
||||||
|
cancelText: '取消',
|
||||||
|
});
|
||||||
|
if (!confirmed.confirm) return;
|
||||||
|
|
||||||
|
setBusy('publish-theme');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await publishTenantTheme(themeConfig?.draftTemplateCode ? { useDraft: true } : {
|
||||||
|
useDraft: false,
|
||||||
|
templateCode: themeForm.templateCode,
|
||||||
|
theme: {
|
||||||
|
primaryColor: themeForm.primaryColor.trim(),
|
||||||
|
accentColor: themeForm.accentColor.trim(),
|
||||||
|
},
|
||||||
|
publicAssets: {
|
||||||
|
...(themeForm.logoUrl.trim() ? { logoUrl: themeForm.logoUrl.trim() } : {}),
|
||||||
|
...(themeForm.shareImageUrl.trim() ? { shareImageUrl: themeForm.shareImageUrl.trim() } : {}),
|
||||||
|
...(themeForm.iconSet.trim() ? { iconSet: themeForm.iconSet.trim() } : {}),
|
||||||
|
...(themeForm.shareCardStyle.trim() ? { shareCardStyle: themeForm.shareCardStyle.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '主题已发布', icon: 'success' });
|
||||||
|
await reloadSettings(selectedRoleId);
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '主题发布失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderToggleGroup(items: TenantPermissionCatalogItem[], field: BooleanMapField) {
|
function renderToggleGroup(items: TenantPermissionCatalogItem[], field: BooleanMapField) {
|
||||||
if (!items.length) return <View className='admin-empty'>暂无可配置项。</View>;
|
if (!items.length) return <View className='admin-empty'>暂无可配置项。</View>;
|
||||||
return (
|
return (
|
||||||
@@ -466,6 +602,91 @@ export default function TenantSettingsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Text className='admin-section-title'>主题模板</Text>
|
||||||
|
<Button className='admin-button' onClick={() => reloadSettings(selectedRoleId)}>刷新主题</Button>
|
||||||
|
</View>
|
||||||
|
<View className='admin-grid three'>
|
||||||
|
{themeTemplates.map(item => (
|
||||||
|
<View
|
||||||
|
className={`admin-metric theme-template ${themeForm.templateCode === item.code ? 'selected' : ''}`}
|
||||||
|
key={item.code}
|
||||||
|
onClick={() => selectThemeTemplate(item)}
|
||||||
|
>
|
||||||
|
<View className='theme-swatch-row'>
|
||||||
|
<View className='theme-swatch' style={{ backgroundColor: String(item.theme?.primaryColor || '#2563eb') }} />
|
||||||
|
<View className='theme-swatch' style={{ backgroundColor: String(item.theme?.accentColor || '#0f766e') }} />
|
||||||
|
</View>
|
||||||
|
<Text className='admin-metric-value'>{item.name}</Text>
|
||||||
|
<Text className='admin-row-meta'>{item.description || item.code}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!themeTemplates.length ? <View className='admin-empty'>暂无主题模板。</View> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>主题草稿</Text>
|
||||||
|
<View className='admin-row'>
|
||||||
|
<Text className='admin-row-main'>当前发布:{themeConfig?.activeTemplateName || themeConfig?.activeTemplateCode || '未发布模板'}</Text>
|
||||||
|
<Text className='admin-row-meta'>草稿:{themeConfig?.draftTemplateName || themeConfig?.draftTemplateCode || '无草稿'} · 状态 {themeConfig?.status || 'published'}</Text>
|
||||||
|
<Text className='admin-row-meta'>前端通过 /api/tenant/resolve 读取已发布主题;草稿仅租户后台可见。</Text>
|
||||||
|
</View>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='主色 #2563eb'
|
||||||
|
value={themeForm.primaryColor}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, primaryColor: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='强调色 #0f766e'
|
||||||
|
value={themeForm.accentColor}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, accentColor: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='Logo URL 或 /assets/...'
|
||||||
|
value={themeForm.logoUrl}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, logoUrl: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='分享图 URL 或 /assets/...'
|
||||||
|
value={themeForm.shareImageUrl}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, shareImageUrl: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='图标集,例如 classic/focus/contrast'
|
||||||
|
value={themeForm.iconSet}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, iconSet: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className='admin-input'
|
||||||
|
placeholder='分享卡片样式,例如 clean/study/bold'
|
||||||
|
value={themeForm.shareCardStyle}
|
||||||
|
onInput={event => setThemeForm(prev => ({ ...prev, shareCardStyle: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='theme-preview-panel'>
|
||||||
|
<View className='theme-preview-banner' style={{ backgroundColor: themeForm.primaryColor || '#2563eb' }}>
|
||||||
|
<Text className='theme-preview-title'>{overview?.shortName || overview?.brandName || '租户题库'}</Text>
|
||||||
|
<Text className='theme-preview-subtitle'>模板 {selectedThemeTemplate?.name || themeForm.templateCode}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='theme-preview-actions'>
|
||||||
|
<View className='theme-preview-pill' style={{ backgroundColor: themeForm.accentColor || '#0f766e' }} />
|
||||||
|
<Text className='admin-row-meta'>主色 {themeForm.primaryColor || '-'} · 强调色 {themeForm.accentColor || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Button className='admin-button primary' loading={busy === 'preview-theme'} onClick={previewTheme}>保存草稿</Button>
|
||||||
|
<Button className='admin-button' loading={busy === 'publish-theme'} onClick={publishTheme}>发布主题</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='admin-section'>
|
<View className='admin-section'>
|
||||||
<Text className='admin-section-title'>域名</Text>
|
<Text className='admin-section-title'>域名</Text>
|
||||||
<View className='admin-list'>
|
<View className='admin-list'>
|
||||||
|
|||||||
@@ -443,6 +443,41 @@ export interface TenantOverview {
|
|||||||
publicConfig?: Record<string, unknown>;
|
publicConfig?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantThemeTemplateItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
previewImageUrl?: string | null;
|
||||||
|
theme?: Record<string, unknown>;
|
||||||
|
publicAssets?: Record<string, unknown>;
|
||||||
|
sortOrder?: number;
|
||||||
|
status?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantThemeConfigItem {
|
||||||
|
tenantId?: string;
|
||||||
|
activeTemplateCode?: string | null;
|
||||||
|
activeTemplateName?: string | null;
|
||||||
|
activeTheme?: Record<string, unknown>;
|
||||||
|
activePublicAssets?: Record<string, unknown>;
|
||||||
|
draftTemplateCode?: string | null;
|
||||||
|
draftTemplateName?: string | null;
|
||||||
|
draftTheme?: Record<string, unknown>;
|
||||||
|
draftPublicAssets?: Record<string, unknown>;
|
||||||
|
status?: string;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
brandingTheme?: Record<string, unknown>;
|
||||||
|
brandingPublicAssets?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantThemeDraftInput {
|
||||||
|
templateCode: string;
|
||||||
|
theme?: Record<string, unknown>;
|
||||||
|
publicAssets?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TenantPermissionCatalogItem {
|
export interface TenantPermissionCatalogItem {
|
||||||
key: string;
|
key: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -538,6 +573,28 @@ export async function loadTenantOverview() {
|
|||||||
return apiRequest<{ item?: TenantOverview }>('/api/tenant-admin/overview');
|
return apiRequest<{ item?: TenantOverview }>('/api/tenant-admin/overview');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadThemeTemplates() {
|
||||||
|
return apiRequest<{ items?: TenantThemeTemplateItem[] }>('/api/tenant-admin/theme-templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTenantTheme() {
|
||||||
|
return apiRequest<{ item?: TenantThemeConfigItem }>('/api/tenant-admin/theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewTenantTheme(input: TenantThemeDraftInput) {
|
||||||
|
return apiRequest<{ item?: TenantThemeConfigItem & { templateName?: string | null } }>('/api/tenant-admin/theme/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishTenantTheme(input: { useDraft?: boolean; templateCode?: string; theme?: Record<string, unknown>; publicAssets?: Record<string, unknown> } = {}) {
|
||||||
|
return apiRequest<{ item?: TenantThemeConfigItem & { templateName?: string | null } }>('/api/tenant-admin/theme/publish', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { useDraft: input.useDraft ?? true, ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTenantPermissions() {
|
export async function loadTenantPermissions() {
|
||||||
return apiRequest<TenantPermissionsPayload>('/api/tenant-admin/permissions');
|
return apiRequest<TenantPermissionsPayload>('/api/tenant-admin/permissions');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface TenantBranding {
|
|||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
slogan?: string;
|
slogan?: string;
|
||||||
theme?: Record<string, unknown>;
|
theme?: Record<string, unknown>;
|
||||||
|
publicAssets?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantContext {
|
export interface TenantContext {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| 品牌名、Logo、客服、主题 JSON | 可联调 | `tenant_branding`、`tenant_settings` |
|
| 品牌名、Logo、客服、主题 JSON | 可联调 | `tenant_branding`、`tenant_settings` |
|
||||||
| 功能开关 | 可联调 | `features`、`adminFeatures` |
|
| 功能开关 | 可联调 | `features`、`adminFeatures` |
|
||||||
| 自定义域名管理 | 可联调 | `GET/POST /api/tenant-admin/domains` |
|
| 自定义域名管理 | 可联调 | `GET/POST /api/tenant-admin/domains` |
|
||||||
| 多套主题模板 | 待补齐 | 当前只有租户 theme JSON,缺平台主题模板、预览、发布流程 |
|
| 多套主题模板 | 可联调 | `tenant_theme_templates`、`tenant_theme_configs` + `/api/tenant-admin/theme-templates`、`theme`、`theme/preview`、`theme/publish`;已内置经典蓝、专注绿、高对比三套模板,支持租户草稿预览、发布、审计和公开解析返回已发布主题 |
|
||||||
|
|
||||||
## 鉴权与权限
|
## 鉴权与权限
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
|
|
||||||
| 能力 | 状态 | 后端接口 |
|
| 能力 | 状态 | 后端接口 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 租户概览、品牌、设置 | 可联调 | `/api/tenant-admin/overview`、`branding`、`settings` |
|
| 租户概览、品牌、主题、设置 | 可联调 | `/api/tenant-admin/overview`、`branding`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`settings` |
|
||||||
| 商户收款配置 | 可联调 | `/api/tenant-admin/payment-accounts` |
|
| 商户收款配置 | 可联调 | `/api/tenant-admin/payment-accounts` |
|
||||||
| 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` |
|
| 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` |
|
||||||
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
|
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
## 当前可进入的前端工作
|
## 当前可进入的前端工作
|
||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:长题干、公式图片混排、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:长题干、公式图片混排、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
- 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。
|
- 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。
|
||||||
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
|
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
|
||||||
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。
|
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。
|
||||||
- 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。
|
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
||||||
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||||
- 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。
|
- 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||||
- 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。
|
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
## 前后端协作建议
|
## 前后端协作建议
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@
|
|||||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
||||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、结算导出和凭证。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,9 @@
|
|||||||
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
||||||
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
|
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
|
||||||
| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 |
|
| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 |
|
||||||
| 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 |
|
| 品牌配置 | 已覆盖 | 品牌基础配置、主题模板、草稿预览、发布和公开解析已有;后续补素材库和更精细视觉编辑器 |
|
||||||
| 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 |
|
| 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 |
|
||||||
| 主题系统 | 部分覆盖 | 当前 theme JSON 可用;缺三套平台主题模板和素材管理 |
|
| 主题系统 | 部分覆盖 | 三套平台主题模板、租户草稿预览、发布、权限、审计和 Taro 租户设置页第一版已补;仍缺素材库、更多模板和可视化细节编辑 |
|
||||||
| 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 |
|
| 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 |
|
||||||
| 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 |
|
| 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 |
|
||||||
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
|
|
||||||
### P2:增强体验
|
### P2:增强体验
|
||||||
|
|
||||||
1. 主题模板、素材库、主题预览/发布。
|
1. 素材库、更多主题模板和可视化细节编辑。
|
||||||
2. 班级、教师、学生分组和学习督导。
|
2. 班级、教师、学生分组和学习督导。
|
||||||
3. AI 择校推荐和 PDF 报告。
|
3. AI 择校推荐和 PDF 报告。
|
||||||
4. 题库导出模板精排、多模板排版和运营操作台。
|
4. 题库导出模板精排、多模板排版和运营操作台。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- Node.js API 分层:`core/features`。
|
- Node.js API 分层:`core/features`。
|
||||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||||
|
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
@@ -145,9 +146,10 @@
|
|||||||
- 继续补更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则,以及成员批量运营和完整后台菜单权限联动。
|
- 继续补更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则,以及成员批量运营和完整后台菜单权限联动。
|
||||||
|
|
||||||
2. 主题系统
|
2. 主题系统
|
||||||
- 平台默认三套主题。
|
- 已完成平台默认三套主题。
|
||||||
- 租户自定义主色、Logo、图标、启动图、小程序分享图。
|
- 已完成租户自定义主色、强调色、Logo、图标集和分享图的草稿预览/发布 API。
|
||||||
- 主题预览和发布。
|
- 已完成 Taro 租户设置页主题模板、草稿和发布第一版。
|
||||||
|
- 继续补素材库、启动图/小程序分享图上传到 `content_assets` 的后台体验、更多主题模板和更细组件级视觉 token。
|
||||||
|
|
||||||
3. CRM worker
|
3. CRM worker
|
||||||
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
|
||||||
@@ -165,8 +167,8 @@
|
|||||||
- 已新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。
|
- 已新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。
|
||||||
- H5 和小程序共用同一套业务 API client。
|
- H5 和小程序共用同一套业务 API client。
|
||||||
- 租户通过域名、小程序配置或启动参数解析。
|
- 租户通过域名、小程序配置或启动参数解析。
|
||||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补长题干/公式图片混排、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补长题干/公式图片混排、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||||
|
|
||||||
### 第一批页面
|
### 第一批页面
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ F:\project\参考\旧题库项目\src
|
|||||||
1. 从 `window.location.host` 获取当前域名。
|
1. 从 `window.location.host` 获取当前域名。
|
||||||
2. 调用 `GET /api/tenant/resolve?host=<host>`。
|
2. 调用 `GET /api/tenant/resolve?host=<host>`。
|
||||||
3. 保存 `tenant.id`、`tenant.slug`、`branding`、`features`、`publicConfig`。
|
3. 保存 `tenant.id`、`tenant.slug`、`branding`、`features`、`publicConfig`。
|
||||||
4. 初始化主题、Logo、页面标题、功能开关。
|
4. 使用 `branding.theme` 和 `branding.publicAssets` 初始化主题、Logo、分享图、页面标题、功能开关。
|
||||||
5. 检查本地 session token,调用 `GET /api/auth/me`。
|
5. 检查本地 session token,调用 `GET /api/auth/me`。
|
||||||
6. 如果未登录,进入登录页;如果已登录,加载个人中心和首页数据。
|
6. 如果未登录,进入登录页;如果已登录,加载个人中心和首页数据。
|
||||||
|
|
||||||
@@ -119,6 +119,87 @@ TARO_APP_SUPABASE_PUBLISHABLE_KEY
|
|||||||
| 429 | 倒计时重试,例如短信冷却 |
|
| 429 | 倒计时重试,例如短信冷却 |
|
||||||
| 500 | 展示系统异常并上报日志 |
|
| 500 | 展示系统异常并上报日志 |
|
||||||
|
|
||||||
|
## 租户主题与品牌契约
|
||||||
|
|
||||||
|
学生端、租户后台、平台后台启动时都通过 `GET /api/tenant/resolve` 获取已发布主题。响应中的 `branding.theme` 是已发布 token,`branding.publicAssets` 是公开素材引用,前端可以安全消费;租户后台草稿不会出现在公开解析响应里。
|
||||||
|
|
||||||
|
主题 token 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primaryColor": "#2563eb",
|
||||||
|
"accentColor": "#0f766e",
|
||||||
|
"backgroundColor": "#f8fafc",
|
||||||
|
"surfaceColor": "#ffffff",
|
||||||
|
"textColor": "#0f172a",
|
||||||
|
"borderRadius": 8,
|
||||||
|
"buttonRadius": 8,
|
||||||
|
"layoutDensity": "comfortable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
公开素材示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logoUrl": "/assets/tenant/logo.png",
|
||||||
|
"shareImageUrl": "https://static.example.com/share.png",
|
||||||
|
"iconSet": "focus",
|
||||||
|
"shareCardStyle": "study"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- 只读取 `/api/tenant/resolve` 返回的已发布主题来渲染学生端和公开页面。
|
||||||
|
- 租户后台主题草稿只调用 `GET /api/tenant-admin/theme` 展示,不能让学生端读取草稿。
|
||||||
|
- 不允许前端把任意 CSS、HTML、JS 或远程脚本当作主题执行;后端已经限制主题为颜色、半径、安全 CSS 变量、图标 token 和 HTTPS/站内公开素材。
|
||||||
|
- Logo、分享图、启动图等长期素材后续应从后台上传进入 `content_assets` 或静态公共资源,再把公开 URL/路径写入主题 `publicAssets`。
|
||||||
|
- 小程序端用 `branding.publicAssets.shareImageUrl` 作为分享图时,仍要遵守微信平台对图片尺寸、域名和 HTTPS 的要求。
|
||||||
|
|
||||||
|
租户后台主题配置接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/tenant-admin/theme-templates
|
||||||
|
GET /api/tenant-admin/theme
|
||||||
|
POST /api/tenant-admin/theme/preview
|
||||||
|
POST /api/tenant-admin/theme/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
权限:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tenant:theme:read
|
||||||
|
tenant:theme:write
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/tenant-admin/theme/preview` 只保存草稿:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateCode": "focus",
|
||||||
|
"theme": {
|
||||||
|
"primaryColor": "#123abc",
|
||||||
|
"accentColor": "#f59e0b"
|
||||||
|
},
|
||||||
|
"publicAssets": {
|
||||||
|
"logoUrl": "/assets/tenant/logo.png",
|
||||||
|
"iconSet": "focus",
|
||||||
|
"shareCardStyle": "study"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/tenant-admin/theme/publish` 发布草稿:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"useDraft": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
发布成功后,下一次 `GET /api/tenant/resolve` 会返回新主题。主题预览和发布都会写入租户审计日志,越权角色会返回 `TENANT_PERMISSION_REQUIRED`。
|
||||||
|
|
||||||
## 全局状态建议
|
## 全局状态建议
|
||||||
|
|
||||||
| Store | 内容 |
|
| Store | 内容 |
|
||||||
@@ -176,6 +257,7 @@ tenant:<tenantId>:theme
|
|||||||
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges` |
|
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges` |
|
||||||
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
||||||
| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` |
|
| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` |
|
||||||
|
| 租户主题模板 | `GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish` |
|
||||||
| 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` |
|
| 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` |
|
||||||
| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` |
|
| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` |
|
||||||
| 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` |
|
| 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` |
|
||||||
@@ -1750,7 +1832,7 @@ npm run build:taro:h5:platform
|
|||||||
下一批前端开发重点:
|
下一批前端开发重点:
|
||||||
|
|
||||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||||
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证和更细数据范围 UI。
|
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证、更细数据范围 UI 和主题素材库。
|
||||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||||
|
|
||||||
@@ -1765,6 +1847,7 @@ npm run build:taro:h5:platform
|
|||||||
- 用户与成员权限
|
- 用户与成员权限
|
||||||
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`
|
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`
|
||||||
- 角色模板与成员:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`、`GET/PUT /api/tenant-admin/members`、`POST /api/tenant-admin/members/disable`;当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员绑定模板和成员状态配置。
|
- 角色模板与成员:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`、`GET/PUT /api/tenant-admin/members`、`POST /api/tenant-admin/members/disable`;当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员绑定模板和成员状态配置。
|
||||||
|
- 主题模板:`GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish`;当前 Taro 租户设置页已提供第一版模板选择、主色/强调色、Logo/分享图、安全草稿预览和发布。
|
||||||
- 内容入口/分类树/题目集合/练习蓝图
|
- 内容入口/分类树/题目集合/练习蓝图
|
||||||
- 题目/单词/知识手册/分数线/视频维护
|
- 题目/单词/知识手册/分数线/视频维护
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
|
||||||
|
|||||||
@@ -4836,6 +4836,129 @@ async function testTenantAdminOps() {
|
|||||||
});
|
});
|
||||||
assert.equal(branding.item?.brandName, '集成测试品牌', 'tenant admin should update branding');
|
assert.equal(branding.item?.brandName, '集成测试品牌', 'tenant admin should update branding');
|
||||||
|
|
||||||
|
const themeTemplatesDenied = await request('/api/tenant-admin/theme-templates', {
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(themeTemplatesDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not list tenant theme templates');
|
||||||
|
|
||||||
|
const themeTemplatesPermissionDenied = await request('/api/tenant-admin/theme-templates', {
|
||||||
|
userId: TENANT_OPERATOR_USER_ID,
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(themeTemplatesPermissionDenied.code, 'TENANT_PERMISSION_REQUIRED', 'operator without theme permission should not list theme templates');
|
||||||
|
|
||||||
|
const themeTemplates = await request('/api/tenant-admin/theme-templates', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
assert.equal(themeTemplates.items?.length >= 3, true, 'tenant admin should list platform theme templates');
|
||||||
|
assert.ok(themeTemplates.items?.some(item => item.code === 'classic'), 'theme templates should include classic default');
|
||||||
|
|
||||||
|
const invalidThemeKey = await request('/api/tenant-admin/theme/preview', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
templateCode: 'classic',
|
||||||
|
theme: {
|
||||||
|
rawCss: 'body{display:none}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectStatus: 400,
|
||||||
|
});
|
||||||
|
assert.equal(invalidThemeKey.code, 'INVALID_THEME_KEY', 'theme preview should reject unknown theme keys');
|
||||||
|
|
||||||
|
const unsafeThemeAsset = await request('/api/tenant-admin/theme/preview', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
templateCode: 'classic',
|
||||||
|
publicAssets: {
|
||||||
|
shareImageUrl: 'javascript:alert(1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectStatus: 400,
|
||||||
|
});
|
||||||
|
assert.equal(unsafeThemeAsset.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'theme public assets should reject unsafe public strings');
|
||||||
|
|
||||||
|
const unsafeCssVar = await request('/api/tenant-admin/theme/preview', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
templateCode: 'classic',
|
||||||
|
theme: {
|
||||||
|
customCssVars: {
|
||||||
|
'--tiku-card-bg': 'url(https://example.test/tracker.png)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectStatus: 400,
|
||||||
|
});
|
||||||
|
assert.equal(unsafeCssVar.code, 'PUBLIC_CONFIG_SECRET_REJECTED', 'theme custom CSS variables should reject unsafe CSS values');
|
||||||
|
|
||||||
|
const previewTheme = await request('/api/tenant-admin/theme/preview', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
templateCode: 'focus',
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#123abc',
|
||||||
|
accentColor: '#f59e0b',
|
||||||
|
borderRadius: 10,
|
||||||
|
buttonRadius: 10,
|
||||||
|
customCssVars: {
|
||||||
|
'--tiku-focus-ring': '#123abc',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
home: 'book-open',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publicAssets: {
|
||||||
|
logoUrl: '/assets/tenant/logo.png',
|
||||||
|
iconSet: 'focus',
|
||||||
|
shareCardStyle: 'study',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(previewTheme.item?.draftTemplateCode, 'focus', 'theme preview should persist draft template code');
|
||||||
|
assert.equal(previewTheme.item?.draftTheme?.primaryColor, '#123abc', 'theme preview should merge color overrides');
|
||||||
|
assert.equal(previewTheme.item?.draftPublicAssets?.logoUrl, '/assets/tenant/logo.png', 'theme preview should persist safe public asset references');
|
||||||
|
|
||||||
|
const tenantTheme = await request('/api/tenant-admin/theme', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
assert.equal(tenantTheme.item?.draftTemplateCode, 'focus', 'tenant theme detail should expose draft theme');
|
||||||
|
assert.equal(tenantTheme.item?.draftTheme?.customCssVars?.['--tiku-focus-ring'], '#123abc', 'tenant theme detail should expose safe CSS variables');
|
||||||
|
|
||||||
|
const publishedTheme = await request('/api/tenant-admin/theme/publish', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
useDraft: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(publishedTheme.item?.activeTemplateCode, 'focus', 'theme publish should activate draft template');
|
||||||
|
assert.equal(publishedTheme.item?.activeTheme?.primaryColor, '#123abc', 'theme publish should activate draft tokens');
|
||||||
|
assert.equal(publishedTheme.item?.status, 'published', 'theme publish should mark config published');
|
||||||
|
|
||||||
|
const tenantThemeAfterPublish = await request('/api/tenant-admin/theme', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
});
|
||||||
|
assert.equal(tenantThemeAfterPublish.item?.draftTemplateCode, null, 'theme publish should clear draft template');
|
||||||
|
assert.equal(tenantThemeAfterPublish.item?.activePublicAssets?.iconSet, 'focus', 'theme publish should expose active public assets');
|
||||||
|
|
||||||
|
const resolvedTenantTheme = await request('/api/tenant/resolve', {
|
||||||
|
userId: false,
|
||||||
|
tenantId: false,
|
||||||
|
query: { tenantCode: 'master' },
|
||||||
|
});
|
||||||
|
assert.equal(resolvedTenantTheme.branding?.theme?.primaryColor, '#123abc', 'tenant resolve should return published theme tokens to frontend');
|
||||||
|
|
||||||
|
const themeAuditLogs = await request('/api/tenant-admin/audit-logs', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { action: 'tenant.theme', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(themeAuditLogs.items?.some(item => item.action === 'tenant.theme.previewed'), 'audit logs should include theme preview');
|
||||||
|
assert.ok(themeAuditLogs.items?.some(item => item.action === 'tenant.theme.published'), 'audit logs should include theme publish');
|
||||||
|
|
||||||
const publicSecretRejected = await request('/api/tenant-admin/auth-providers', {
|
const publicSecretRejected = await request('/api/tenant-admin/auth-providers', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
129
supabase/migrations/202606290020_tenant_theme_templates.sql
Normal file
129
supabase/migrations/202606290020_tenant_theme_templates.sql
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
create table if not exists public.tenant_theme_templates (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
code text not null unique,
|
||||||
|
name text not null,
|
||||||
|
description text,
|
||||||
|
preview_image_url text,
|
||||||
|
theme jsonb not null default '{}'::jsonb,
|
||||||
|
public_assets jsonb not null default '{}'::jsonb,
|
||||||
|
status text not null default 'active' check (status in ('active', 'disabled')),
|
||||||
|
sort_order integer not null default 100,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.tenant_theme_configs (
|
||||||
|
tenant_id uuid primary key references public.tenants(id) on delete cascade,
|
||||||
|
active_template_code text references public.tenant_theme_templates(code) on delete set null,
|
||||||
|
active_theme jsonb not null default '{}'::jsonb,
|
||||||
|
active_public_assets jsonb not null default '{}'::jsonb,
|
||||||
|
draft_template_code text references public.tenant_theme_templates(code) on delete set null,
|
||||||
|
draft_theme jsonb not null default '{}'::jsonb,
|
||||||
|
draft_public_assets jsonb not null default '{}'::jsonb,
|
||||||
|
status text not null default 'published' check (status in ('draft', 'published')),
|
||||||
|
published_at timestamptz,
|
||||||
|
published_by uuid references public.platform_users(id) on delete set null,
|
||||||
|
draft_updated_by uuid references public.platform_users(id) on delete set null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_theme_templates_status_sort
|
||||||
|
on public.tenant_theme_templates(status, sort_order, code);
|
||||||
|
|
||||||
|
alter table public.tenant_theme_templates enable row level security;
|
||||||
|
alter table public.tenant_theme_configs enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists theme_templates_public_read on public.tenant_theme_templates;
|
||||||
|
create policy theme_templates_public_read on public.tenant_theme_templates
|
||||||
|
for select
|
||||||
|
using (status = 'active' or app.is_platform_admin());
|
||||||
|
|
||||||
|
drop policy if exists tenant_isolation on public.tenant_theme_configs;
|
||||||
|
create policy tenant_isolation on public.tenant_theme_configs
|
||||||
|
for all
|
||||||
|
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||||
|
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||||
|
|
||||||
|
drop trigger if exists set_updated_at on public.tenant_theme_templates;
|
||||||
|
create trigger set_updated_at
|
||||||
|
before update on public.tenant_theme_templates
|
||||||
|
for each row
|
||||||
|
execute function app.touch_updated_at();
|
||||||
|
|
||||||
|
drop trigger if exists set_updated_at on public.tenant_theme_configs;
|
||||||
|
create trigger set_updated_at
|
||||||
|
before update on public.tenant_theme_configs
|
||||||
|
for each row
|
||||||
|
execute function app.touch_updated_at();
|
||||||
|
|
||||||
|
insert into public.tenant_theme_templates (code, name, description, theme, public_assets, sort_order)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
'classic',
|
||||||
|
'经典蓝',
|
||||||
|
'稳重、清晰、适合默认题库和机构后台。',
|
||||||
|
'{
|
||||||
|
"mode":"light",
|
||||||
|
"primaryColor":"#2563eb",
|
||||||
|
"accentColor":"#0f766e",
|
||||||
|
"backgroundColor":"#f8fafc",
|
||||||
|
"surfaceColor":"#ffffff",
|
||||||
|
"textColor":"#0f172a",
|
||||||
|
"mutedColor":"#64748b",
|
||||||
|
"borderRadius":8,
|
||||||
|
"buttonRadius":8,
|
||||||
|
"fontFamily":"system",
|
||||||
|
"layoutDensity":"comfortable"
|
||||||
|
}'::jsonb,
|
||||||
|
'{"iconSet":"classic","shareCardStyle":"clean"}'::jsonb,
|
||||||
|
10
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'focus',
|
||||||
|
'专注绿',
|
||||||
|
'低干扰学习主题,适合学生端刷题和背单词场景。',
|
||||||
|
'{
|
||||||
|
"mode":"light",
|
||||||
|
"primaryColor":"#0f766e",
|
||||||
|
"accentColor":"#7c3aed",
|
||||||
|
"backgroundColor":"#f5fbf8",
|
||||||
|
"surfaceColor":"#ffffff",
|
||||||
|
"textColor":"#10201b",
|
||||||
|
"mutedColor":"#55706a",
|
||||||
|
"borderRadius":8,
|
||||||
|
"buttonRadius":8,
|
||||||
|
"fontFamily":"system",
|
||||||
|
"layoutDensity":"compact"
|
||||||
|
}'::jsonb,
|
||||||
|
'{"iconSet":"focus","shareCardStyle":"study"}'::jsonb,
|
||||||
|
20
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'high-contrast',
|
||||||
|
'高对比',
|
||||||
|
'强化标题、按钮和导航对比度,适合强运营入口和大屏后台。',
|
||||||
|
'{
|
||||||
|
"mode":"light",
|
||||||
|
"primaryColor":"#111827",
|
||||||
|
"accentColor":"#f59e0b",
|
||||||
|
"backgroundColor":"#f3f4f6",
|
||||||
|
"surfaceColor":"#ffffff",
|
||||||
|
"textColor":"#030712",
|
||||||
|
"mutedColor":"#4b5563",
|
||||||
|
"borderRadius":6,
|
||||||
|
"buttonRadius":6,
|
||||||
|
"fontFamily":"system",
|
||||||
|
"layoutDensity":"dense"
|
||||||
|
}'::jsonb,
|
||||||
|
'{"iconSet":"contrast","shareCardStyle":"bold"}'::jsonb,
|
||||||
|
30
|
||||||
|
)
|
||||||
|
on conflict (code)
|
||||||
|
do update set name = excluded.name,
|
||||||
|
description = excluded.description,
|
||||||
|
theme = excluded.theme,
|
||||||
|
public_assets = excluded.public_assets,
|
||||||
|
sort_order = excluded.sort_order,
|
||||||
|
status = 'active',
|
||||||
|
updated_at = now();
|
||||||
Reference in New Issue
Block a user