diff --git a/README.md b/README.md index bf1c6b11..8b345202 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 @@ -33,7 +33,7 @@ - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、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 提交。 更完整的进度看这些文档: @@ -287,7 +287,7 @@ apps/api/src/features/ 分佣设置、汇总、来源明细、结算单、审核/打款状态 scoreline/ 分数线 tenant/ 租户解析 - tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计 + tenant-admin/ 租户后台配置、主题、成员权限、班级学生、活动、勋章和审计 tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出 video/ 题目视频讲解 ``` @@ -302,7 +302,7 @@ API 身份上下文: ## 重要安全约定 -- 租户公开配置不能存放密钥。 +- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。 - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 989d62a0..0034ccab 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -83,6 +83,8 @@ export function tenantPermissionCatalog() { { key: 'tenant:overview:read', label: '租户概览' }, { key: 'dashboard:read', label: '数据看板查看' }, { key: 'tenant:branding:write', label: '品牌配置' }, + { key: 'tenant:theme:read', label: '主题查看' }, + { key: 'tenant:theme:write', label: '主题预览/发布' }, { key: 'tenant:settings:write', label: '公开设置' }, { key: 'tenant:domains:read', label: '域名查看' }, { key: 'tenant:domains:write', label: '域名管理' }, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index f69ee8af..807860f4 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -42,10 +42,14 @@ import { grantBadgeRoute, generateActivationCodesRoute, paymentAccountsRoute, + previewTenantThemeRoute, + publishTenantThemeRoute, tenantDomainsRoute, tenantMembersRoute, tenantOverviewRoute, tenantPermissionsRoute, + tenantThemeRoute, + tenantThemeTemplatesRoute, tenantRoleTemplatesRoute, tenantSecretsRoute, upsertTenantMemberRoute, @@ -89,6 +93,10 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute], ['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute], ['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], ['POST', '/api/tenant-admin/domains', createTenantDomainRoute], ['GET', '/api/tenant-admin/payment-accounts', paymentAccountsRoute], diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index 9c0c93cb..772b6035 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -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_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 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| 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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) { return `app_private.tenant_secrets:${scope}:${secretKey}`; } @@ -718,6 +939,303 @@ export async function updateTenantSettingsRoute(ctx: RequestContext) { 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; + publicAssets: Record; + }>( + ` + 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; + let sourcePublicAssets: Record; + let sourceTemplateCode: string | null; + let templateName: string | null = null; + + if (useDraft) { + const draft = await client.query<{ + draftTemplateCode: string | null; + draftTheme: Record; + draftPublicAssets: Record; + 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; + publicAssets: Record; + }>( + ` + 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) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'tenant:domains:read'); diff --git a/apps/api/src/features/tenant/routes.ts b/apps/api/src/features/tenant/routes.ts index 1d992e29..b9a4582b 100644 --- a/apps/api/src/features/tenant/routes.ts +++ b/apps/api/src/features/tenant/routes.ts @@ -17,6 +17,7 @@ interface TenantResolveRow { service_wechat: string | null; service_account_name: string | null; theme: Record; + public_assets: Record; feature_flags: Record; admin_feature_flags: Record; public_config: Record; @@ -37,13 +38,16 @@ export async function resolveTenantRoute(ctx: RequestContext) { select t.id, t.slug, t.name, t.status, t.mode, null::text as host, 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.admin_feature_flags, '{}'::jsonb) as admin_feature_flags, coalesce(s.public_config, '{}'::jsonb) as public_config from public.tenants t 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_theme_configs tc on tc.tenant_id = t.id and tc.status = 'published' where t.slug = $1 and t.status = 'active' limit 1 `, @@ -54,7 +58,9 @@ export async function resolveTenantRoute(ctx: RequestContext) { select t.id, t.slug, t.name, t.status, t.mode, d.host::text, 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.admin_feature_flags, '{}'::jsonb) as admin_feature_flags, 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 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_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' limit 1 `, @@ -100,6 +107,7 @@ export async function resolveTenantRoute(ctx: RequestContext) { serviceWechat: row.service_wechat || '', serviceAccountName: row.service_account_name || '', theme: row.theme || {}, + publicAssets: row.public_assets || {}, }, features: row.feature_flags || {}, adminFeatures: row.admin_feature_flags || {}, diff --git a/apps/taro/src/pages/tenant-admin/admin.css b/apps/taro/src/pages/tenant-admin/admin.css index e699a55f..e208795b 100644 --- a/apps/taro/src/pages/tenant-admin/admin.css +++ b/apps/taro/src/pages/tenant-admin/admin.css @@ -107,6 +107,15 @@ 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 { display: block; color: #64748b; @@ -230,6 +239,60 @@ 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 { word-break: break-all; overflow-wrap: anywhere; diff --git a/apps/taro/src/pages/tenant-admin/settings/index.tsx b/apps/taro/src/pages/tenant-admin/settings/index.tsx index 767316d7..770853b6 100644 --- a/apps/taro/src/pages/tenant-admin/settings/index.tsx +++ b/apps/taro/src/pages/tenant-admin/settings/index.tsx @@ -11,6 +11,10 @@ import { loadTenantDomains, loadTenantOverview, loadTenantPermissions, + loadTenantTheme, + loadThemeTemplates, + previewTenantTheme, + publishTenantTheme, upsertTenantMember, upsertRoleTemplate, type TenantMemberItem, @@ -18,6 +22,8 @@ import { type TenantPermissionCatalogItem, type TenantPermissionsPayload, type TenantRoleTemplateItem, + type TenantThemeConfigItem, + type TenantThemeTemplateItem, } from '@/services/tenantAdmin'; import '../admin.css'; @@ -52,6 +58,16 @@ interface MemberForm { permissions: Record; } +interface ThemeForm { + templateCode: string; + primaryColor: string; + accentColor: string; + logoUrl: string; + shareImageUrl: string; + iconSet: string; + shareCardStyle: string; +} + const BASE_ROLE_OPTIONS = [ { key: 'tenant_operator', label: '运营' }, { key: 'teacher', label: '教师' }, @@ -100,6 +116,16 @@ const MODULE_CATALOG: TenantPermissionCatalogItem[] = [ { 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) { const source = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; const result: Record = {}; @@ -176,6 +202,27 @@ function memberFormFrom(item?: TenantMemberItem | null): MemberForm { }; } +function stringFromRecord(value: Record | 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) { return item.label ? `${item.label}` : item.key; } @@ -187,6 +234,9 @@ export default function TenantSettingsPage() { const [authProviders, setAuthProviders] = useState[]>([]); const [roles, setRoles] = useState([]); const [members, setMembers] = useState([]); + const [themeTemplates, setThemeTemplates] = useState([]); + const [themeConfig, setThemeConfig] = useState(null); + const [themeForm, setThemeForm] = useState(() => DEFAULT_THEME_FORM); const [permissionsPayload, setPermissionsPayload] = useState({}); const [selectedRoleId, setSelectedRoleId] = useState(''); const [roleForm, setRoleForm] = useState(() => emptyRoleTemplateForm()); @@ -203,25 +253,37 @@ export default function TenantSettingsPage() { const fieldCatalog = permissionsPayload.fieldGroups || permissionsPayload.fieldCatalog || []; const editingSystemRole = Boolean(selectedRole?.isSystem); 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) { setError(''); 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 })), loadTenantDomains().catch(() => ({ items: [] })), loadPaymentAccounts().catch(() => ({ items: [] })), loadAuthProviders().catch(() => ({ items: [] })), loadRoleTemplates().catch(() => ({ items: [] })), loadTenantPermissions().catch(() => ({})), + loadThemeTemplates().catch(() => ({ items: [] })), + loadTenantTheme().catch(() => ({ item: null })), ]); const nextRoles = rolePayload.items || []; 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); setDomains(domainPayload.items || []); setPayments(paymentPayload.items || []); setAuthProviders(authPayload.items || []); setRoles(nextRoles); + setThemeTemplates(nextThemeTemplates); + setThemeConfig(nextThemeConfig); + setThemeForm(themeFormFrom(nextThemeConfig, nextTemplate)); setPermissionsPayload(permissionPayload); setSelectedRoleId(nextSelected?.id || ''); setRoleForm(nextSelected ? roleTemplateFormFrom(nextSelected) : emptyRoleTemplateForm()); @@ -269,6 +331,15 @@ export default function TenantSettingsPage() { 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) { setRoleForm(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) { if (!items.length) return 暂无可配置项。; return ( @@ -466,6 +602,91 @@ export default function TenantSettingsPage() { + + + 主题模板 + + + + {themeTemplates.map(item => ( + selectThemeTemplate(item)} + > + + + + + {item.name} + {item.description || item.code} + + ))} + + {!themeTemplates.length ? 暂无主题模板。 : null} + + + + 主题草稿 + + 当前发布:{themeConfig?.activeTemplateName || themeConfig?.activeTemplateCode || '未发布模板'} + 草稿:{themeConfig?.draftTemplateName || themeConfig?.draftTemplateCode || '无草稿'} · 状态 {themeConfig?.status || 'published'} + 前端通过 /api/tenant/resolve 读取已发布主题;草稿仅租户后台可见。 + + + setThemeForm(prev => ({ ...prev, primaryColor: String(event.detail.value || '') }))} + /> + setThemeForm(prev => ({ ...prev, accentColor: String(event.detail.value || '') }))} + /> + setThemeForm(prev => ({ ...prev, logoUrl: String(event.detail.value || '') }))} + /> + setThemeForm(prev => ({ ...prev, shareImageUrl: String(event.detail.value || '') }))} + /> + setThemeForm(prev => ({ ...prev, iconSet: String(event.detail.value || '') }))} + /> + setThemeForm(prev => ({ ...prev, shareCardStyle: String(event.detail.value || '') }))} + /> + + + + {overview?.shortName || overview?.brandName || '租户题库'} + 模板 {selectedThemeTemplate?.name || themeForm.templateCode} + + + + 主色 {themeForm.primaryColor || '-'} · 强调色 {themeForm.accentColor || '-'} + + + + + + + + 域名 diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index ea68a82b..29232d06 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -443,6 +443,41 @@ export interface TenantOverview { publicConfig?: Record; } +export interface TenantThemeTemplateItem { + code: string; + name: string; + description?: string | null; + previewImageUrl?: string | null; + theme?: Record; + publicAssets?: Record; + sortOrder?: number; + status?: string; + updatedAt?: string; +} + +export interface TenantThemeConfigItem { + tenantId?: string; + activeTemplateCode?: string | null; + activeTemplateName?: string | null; + activeTheme?: Record; + activePublicAssets?: Record; + draftTemplateCode?: string | null; + draftTemplateName?: string | null; + draftTheme?: Record; + draftPublicAssets?: Record; + status?: string; + publishedAt?: string | null; + updatedAt?: string | null; + brandingTheme?: Record; + brandingPublicAssets?: Record; +} + +export interface TenantThemeDraftInput { + templateCode: string; + theme?: Record; + publicAssets?: Record; +} + export interface TenantPermissionCatalogItem { key: string; label?: string; @@ -538,6 +573,28 @@ export async function loadTenantOverview() { 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; publicAssets?: Record } = {}) { + return apiRequest<{ item?: TenantThemeConfigItem & { templateName?: string | null } }>('/api/tenant-admin/theme/publish', { + method: 'POST', + body: { useDraft: input.useDraft ?? true, ...input }, + }); +} + export async function loadTenantPermissions() { return apiRequest('/api/tenant-admin/permissions'); } diff --git a/apps/taro/src/types.ts b/apps/taro/src/types.ts index c0ed56e6..3cfc620f 100644 --- a/apps/taro/src/types.ts +++ b/apps/taro/src/types.ts @@ -4,6 +4,7 @@ export interface TenantBranding { logoUrl?: string; slogan?: string; theme?: Record; + publicAssets?: Record; } export interface TenantContext { diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 57a57ad5..08da1eb1 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -29,7 +29,7 @@ | 品牌名、Logo、客服、主题 JSON | 可联调 | `tenant_branding`、`tenant_settings` | | 功能开关 | 可联调 | `features`、`adminFeatures` | | 自定义域名管理 | 可联调 | `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` | | 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` | | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index d5381a74..c89d7133 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -26,7 +26,7 @@ ## 当前可进入的前端工作 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 -- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 +- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 - 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。 - 可以继续复刻旧题库学生端主要视觉和交互:长题干、公式图片混排、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: @@ -37,7 +37,7 @@ - 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。 - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - 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`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。 @@ -48,7 +48,7 @@ - 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。 - 题目/单词/知识手册/分数线/视频 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/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/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 平台后台页面 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 76352531..f81ca295 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -47,9 +47,9 @@ | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 | | 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 | -| 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 | +| 品牌配置 | 已覆盖 | 品牌基础配置、主题模板、草稿预览、发布和公开解析已有;后续补素材库和更精细视觉编辑器 | | 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 | -| 主题系统 | 部分覆盖 | 当前 theme JSON 可用;缺三套平台主题模板和素材管理 | +| 主题系统 | 部分覆盖 | 三套平台主题模板、租户草稿预览、发布、权限、审计和 Taro 租户设置页第一版已补;仍缺素材库、更多模板和可视化细节编辑 | | 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 | | 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 | | Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 | @@ -124,7 +124,7 @@ ### P2:增强体验 -1. 主题模板、素材库、主题预览/发布。 +1. 素材库、更多主题模板和可视化细节编辑。 2. 班级、教师、学生分组和学习督导。 3. AI 择校推荐和 PDF 报告。 4. 题库导出模板精排、多模板排版和运营操作台。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index e295173e..eb3735c6 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -10,6 +10,7 @@ - Node.js API 分层:`core/features`。 - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 +- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 @@ -145,9 +146,10 @@ - 继续补更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则,以及成员批量运营和完整后台菜单权限联动。 2. 主题系统 - - 平台默认三套主题。 - - 租户自定义主色、Logo、图标、启动图、小程序分享图。 - - 主题预览和发布。 + - 已完成平台默认三套主题。 + - 已完成租户自定义主色、强调色、Logo、图标集和分享图的草稿预览/发布 API。 + - 已完成 Taro 租户设置页主题模板、草稿和发布第一版。 + - 继续补素材库、启动图/小程序分享图上传到 `content_assets` 的后台体验、更多主题模板和更细组件级视觉 token。 3. CRM worker - 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。 @@ -165,8 +167,8 @@ - 已新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。 - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 -- 页面主题、品牌、功能开关都从后端租户配置读取。 -- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补长题干/公式图片混排、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 +- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 +- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补长题干/公式图片混排、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 ### 第一批页面 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 20fb5f43..e824f7a1 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -23,7 +23,7 @@ F:\project\参考\旧题库项目\src 1. 从 `window.location.host` 获取当前域名。 2. 调用 `GET /api/tenant/resolve?host=`。 3. 保存 `tenant.id`、`tenant.slug`、`branding`、`features`、`publicConfig`。 -4. 初始化主题、Logo、页面标题、功能开关。 +4. 使用 `branding.theme` 和 `branding.publicAssets` 初始化主题、Logo、分享图、页面标题、功能开关。 5. 检查本地 session token,调用 `GET /api/auth/me`。 6. 如果未登录,进入登录页;如果已登录,加载个人中心和首页数据。 @@ -119,6 +119,87 @@ TARO_APP_SUPABASE_PUBLISHABLE_KEY | 429 | 倒计时重试,例如短信冷却 | | 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 | 内容 | @@ -176,6 +257,7 @@ tenant::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` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | | 租户数据看板 | `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/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` | @@ -1750,7 +1832,7 @@ npm run build:taro:h5:platform 下一批前端开发重点: - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。 -- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证和更细数据范围 UI。 +- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、CRM 分配策略、真实打款/导出/凭证、更细数据范围 UI 和主题素材库。 - 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 - 小程序:验证 `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` - 角色模板与成员:`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、字段映射、模板下载和导入后复检 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 908f18a9..dc029491 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -4836,6 +4836,129 @@ async function testTenantAdminOps() { }); 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', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/supabase/migrations/202606290020_tenant_theme_templates.sql b/supabase/migrations/202606290020_tenant_theme_templates.sql new file mode 100644 index 00000000..6c21aa3f --- /dev/null +++ b/supabase/migrations/202606290020_tenant_theme_templates.sql @@ -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();