Files
tiku-saas-web/src/mocks/store.ts

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