feat: add tenant theme templates

This commit is contained in:
Codex
2026-06-29 18:05:30 +08:00
parent a68b4ab9e3
commit ee578af1f6
16 changed files with 1239 additions and 24 deletions

View File

@@ -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',