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

@@ -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;

View File

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

View File

@@ -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');
}

View File

@@ -4,6 +4,7 @@ export interface TenantBranding {
logoUrl?: string;
slogan?: string;
theme?: Record<string, unknown>;
publicAssets?: Record<string, unknown>;
}
export interface TenantContext {