forked from wangziqi/gongxue-base
feat: add tenant theme templates
This commit is contained in:
@@ -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: '域名管理' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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|<script|javascript:|data:text\/html|expression\s*\(|@import|url\s*\()/i;
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
@@ -140,6 +182,185 @@ function publicJsonValue(value: unknown) {
|
||||
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) {
|
||||
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<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) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'tenant:domains:read');
|
||||
|
||||
@@ -17,6 +17,7 @@ interface TenantResolveRow {
|
||||
service_wechat: string | null;
|
||||
service_account_name: string | null;
|
||||
theme: Record<string, unknown>;
|
||||
public_assets: Record<string, unknown>;
|
||||
feature_flags: Record<string, unknown>;
|
||||
admin_feature_flags: 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,
|
||||
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 || {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}
|
||||
|
||||
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<string, unknown> : {};
|
||||
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) {
|
||||
return item.label ? `${item.label}` : item.key;
|
||||
}
|
||||
@@ -187,6 +234,9 @@ export default function TenantSettingsPage() {
|
||||
const [authProviders, setAuthProviders] = useState<Record<string, unknown>[]>([]);
|
||||
const [roles, setRoles] = useState<TenantRoleTemplateItem[]>([]);
|
||||
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 [selectedRoleId, setSelectedRoleId] = useState('');
|
||||
const [roleForm, setRoleForm] = useState<RoleTemplateForm>(() => 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 <View className='admin-empty'>暂无可配置项。</View>;
|
||||
return (
|
||||
@@ -466,6 +602,91 @@ export default function TenantSettingsPage() {
|
||||
</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'>
|
||||
<Text className='admin-section-title'>域名</Text>
|
||||
<View className='admin-list'>
|
||||
|
||||
@@ -443,6 +443,41 @@ export interface TenantOverview {
|
||||
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 {
|
||||
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<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() {
|
||||
return apiRequest<TenantPermissionsPayload>('/api/tenant-admin/permissions');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface TenantBranding {
|
||||
logoUrl?: string;
|
||||
slogan?: string;
|
||||
theme?: Record<string, unknown>;
|
||||
publicAssets?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TenantContext {
|
||||
|
||||
Reference in New Issue
Block a user