235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
import type {
|
|
BackofficeBootstrap,
|
|
BrowserOwnerActivationRequest,
|
|
CurrentUser,
|
|
SiteState,
|
|
TenantOnboardingStatus,
|
|
TenantRuntimeBootstrap,
|
|
TenantSiteConfig,
|
|
} from '../contracts';
|
|
import { cloneConfig, createDefaultConfig } from '../shared/defaults';
|
|
|
|
const PREFIX = 'tiku-saas-demo:v1:';
|
|
const OWNER_PERMISSIONS = [
|
|
'tenant:dashboard:view',
|
|
'tenant:staff:manage',
|
|
'tenant:role:manage',
|
|
'tenant:student:manage',
|
|
'tenant:question-bank:manage',
|
|
'tenant:vocabulary:manage',
|
|
'tenant:handbook:manage',
|
|
'tenant:video:manage',
|
|
'tenant:scoreline:manage',
|
|
'tenant:site-content:manage',
|
|
'tenant:settings:manage',
|
|
'tenant:provider:manage',
|
|
'tenant:commerce:operate',
|
|
'tenant:crm:manage',
|
|
'tenant:commission:manage',
|
|
'tenant:job:manage',
|
|
'tenant:billing:manage',
|
|
];
|
|
|
|
export const MOCK_OWNER_PERMISSIONS = [...OWNER_PERMISSIONS];
|
|
|
|
const MOCK_BACKOFFICE_FEATURES = [
|
|
'core.backoffice',
|
|
'question_bank.private',
|
|
'learning.practice',
|
|
'learning.exam',
|
|
'content.vocabulary',
|
|
'content.handbook',
|
|
'content.video',
|
|
'content.scoreline',
|
|
'marketing.site_content',
|
|
'student.management',
|
|
'commerce.student_store',
|
|
'crm.followup',
|
|
'growth.referral_commission',
|
|
];
|
|
|
|
export interface DemoActivation {
|
|
activationId: string;
|
|
token: string;
|
|
expiresAt: string;
|
|
consumedAt: string | null;
|
|
}
|
|
|
|
export interface DemoSession {
|
|
user: CurrentUser;
|
|
permissions: string[];
|
|
}
|
|
|
|
export interface DemoTenantState {
|
|
host: string;
|
|
tenantId: string;
|
|
tenantCode: string;
|
|
tenantName: string;
|
|
siteState: SiteState;
|
|
ownerIdentifier: string;
|
|
password: string | null;
|
|
activation: DemoActivation;
|
|
session: DemoSession | null;
|
|
studentRegionId?: string | null;
|
|
config: TenantSiteConfig;
|
|
}
|
|
|
|
function randomId(): string {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
function randomToken(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
export function createDemoTenant(host = 'academy.localhost'): DemoTenantState {
|
|
const config = createDefaultConfig();
|
|
return {
|
|
host,
|
|
tenantId: randomId(),
|
|
tenantCode: (host.split('.')[0] ?? 'academy').replace(/[^a-z0-9-]/gi, '-').toLowerCase() || 'academy',
|
|
tenantName: config.branding.brandName,
|
|
siteState: 'setup_required',
|
|
ownerIdentifier: 'owner@academy.example',
|
|
password: null,
|
|
activation: {
|
|
activationId: randomId(),
|
|
token: randomToken(),
|
|
expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
|
|
consumedAt: null,
|
|
},
|
|
session: null,
|
|
studentRegionId: null,
|
|
config: {
|
|
schemaVersion: 1,
|
|
configVersion: 1,
|
|
published: cloneConfig(config),
|
|
draft: cloneConfig(config),
|
|
publishedAt: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function key(host: string): string {
|
|
return `${PREFIX}${host.toLowerCase()}`;
|
|
}
|
|
|
|
export function saveTenant(state: DemoTenantState): void {
|
|
window.localStorage.setItem(key(state.host), JSON.stringify(state));
|
|
}
|
|
|
|
export function loadTenant(host: string): DemoTenantState | null {
|
|
const value = window.localStorage.getItem(key(host));
|
|
return value ? JSON.parse(value) as DemoTenantState : null;
|
|
}
|
|
|
|
export function ensureTenant(host: string): DemoTenantState {
|
|
const existing = loadTenant(host);
|
|
if (existing) return existing;
|
|
const created = createDemoTenant(host);
|
|
saveTenant(created);
|
|
return created;
|
|
}
|
|
|
|
export function resetTenant(host: string): DemoTenantState {
|
|
window.localStorage.removeItem(key(host));
|
|
return ensureTenant(host);
|
|
}
|
|
|
|
export function updateTenant(host: string, updater: (state: DemoTenantState) => void): DemoTenantState {
|
|
const state = ensureTenant(host);
|
|
updater(state);
|
|
saveTenant(state);
|
|
return state;
|
|
}
|
|
|
|
export function activateOwner(host: string, request: BrowserOwnerActivationRequest): CurrentUser {
|
|
let activated!: CurrentUser;
|
|
updateTenant(host, state => {
|
|
const grant = state.activation;
|
|
if (request.activationId !== grant.activationId || request.token !== grant.token) throw new DemoStoreError(400, 'activation_invalid');
|
|
if (grant.consumedAt) throw new DemoStoreError(409, 'activation_consumed');
|
|
if (new Date(grant.expiresAt).getTime() <= Date.now()) throw new DemoStoreError(400, 'activation_expired');
|
|
if (request.newPassword.length < 8 || !/[a-z]/i.test(request.newPassword) || !/\d/.test(request.newPassword)) {
|
|
throw new DemoStoreError(400, 'activation_password_invalid');
|
|
}
|
|
grant.consumedAt = new Date().toISOString();
|
|
state.password = request.newPassword;
|
|
activated = {
|
|
userId: randomId(),
|
|
name: '租户负责人',
|
|
email: 'owner@academy.example',
|
|
};
|
|
state.session = { user: activated, permissions: [...OWNER_PERMISSIONS] };
|
|
});
|
|
return activated;
|
|
}
|
|
|
|
export function runtimeOf(state: DemoTenantState): TenantRuntimeBootstrap {
|
|
return {
|
|
schemaVersion: state.config.schemaVersion,
|
|
configVersion: state.config.configVersion,
|
|
tenantCode: state.tenantCode,
|
|
tenantName: state.tenantName,
|
|
siteState: state.siteState,
|
|
...cloneConfig(state.config.published),
|
|
enabledFeatures: ['core.backoffice', 'learning.practice', 'content.vocabulary', 'content.handbook', 'content.scoreline', 'commerce.student_store', 'marketing.site_content'],
|
|
loginMethods: ['password', 'sms'],
|
|
};
|
|
}
|
|
|
|
export function backofficeOf(state: DemoTenantState): BackofficeBootstrap {
|
|
if (!state.session) throw new DemoStoreError(401, 'authentication_required');
|
|
if (state.session.permissions.length === 0) throw new DemoStoreError(403, 'backoffice_access_denied');
|
|
const permissions = OWNER_PERMISSIONS;
|
|
const menus = [
|
|
{ code: 'tenant.dashboard', name: '租户总览', path: '/tenant/dashboard', requiredPermission: 'tenant:dashboard:view' },
|
|
{ code: 'tenant.staff', name: '员工与权限', path: '/tenant/staff', requiredPermission: 'tenant:staff:manage' },
|
|
{ code: 'tenant.students', name: '班级与学生', path: '/tenant/students', requiredPermission: 'tenant:student:manage' },
|
|
{ code: 'tenant.question-bank', name: '私有题库', path: '/tenant/question-bank', requiredPermission: 'tenant:question-bank:manage' },
|
|
{ code: 'tenant.vocabulary', name: '词汇', path: '/tenant/vocabulary', requiredPermission: 'tenant:vocabulary:manage' },
|
|
{ code: 'tenant.handbook', name: '知识手册', path: '/tenant/handbook', requiredPermission: 'tenant:handbook:manage' },
|
|
{ code: 'tenant.video', name: '视频', path: '/tenant/video', requiredPermission: 'tenant:video:manage' },
|
|
{ code: 'tenant.scoreline', name: '分数线', path: '/tenant/scoreline', requiredPermission: 'tenant:scoreline:manage' },
|
|
{ code: 'tenant.site-content', name: '运营内容', path: '/tenant/site-content', requiredPermission: 'tenant:site-content:manage' },
|
|
{ code: 'tenant.providers', name: '外部服务', path: '/tenant/providers', requiredPermission: 'tenant:provider:manage' },
|
|
{ code: 'tenant.commerce', name: '交易运营', path: '/tenant/commerce', requiredPermission: 'tenant:commerce:operate' },
|
|
{ code: 'tenant.billing', name: 'SaaS 账务', path: '/tenant/billing', requiredPermission: 'tenant:billing:manage' },
|
|
].filter(menu => permissions.includes(menu.requiredPermission));
|
|
return {
|
|
permissions: [...permissions],
|
|
enabledFeatures: [...MOCK_BACKOFFICE_FEATURES],
|
|
menus,
|
|
};
|
|
}
|
|
|
|
export function onboardingOf(state: DemoTenantState): TenantOnboardingStatus {
|
|
const steps = [
|
|
['owner_activated', Boolean(state.activation.consumedAt)],
|
|
['primary_domain_active', state.siteState !== 'domain_pending'],
|
|
['frontend_config_published', Boolean(state.config.publishedAt)],
|
|
['student_login_configured', true],
|
|
].map(([code, completed]) => ({ code: String(code), required: true, completed: Boolean(completed), detail: null }));
|
|
const completed = steps.filter(step => step.completed).length;
|
|
return {
|
|
tenantId: state.tenantId,
|
|
readyForStudentTraffic: completed === steps.length,
|
|
completedRequiredSteps: completed,
|
|
requiredSteps: steps.length,
|
|
steps,
|
|
};
|
|
}
|
|
|
|
export class DemoStoreError extends Error {
|
|
constructor(public status: number, public code: string) {
|
|
super(code);
|
|
}
|
|
}
|
|
|
|
export function allDemoHosts(): string[] {
|
|
return Object.keys(window.localStorage)
|
|
.filter(item => item.startsWith(PREFIX))
|
|
.map(item => item.slice(PREFIX.length));
|
|
}
|