写在前面
多租户常见两种形态:运行时按域名切租户 (单包多租户),或构建期选定租户 (一租户一产物)。pro-a 属于后者:通过 VITE_TENANT_ID 在构建/开发时选定租户,产物内只打进该租户的 config、静态资源与权限;不是同一套前端在浏览器里按域名动态切换百租户。
本文按入驻 → 构建 → 启动串联核心逻辑,并附关键代码位置,便于对照仓库阅读。
一、租户在仓库里长什么样
每个租户对应 src/tenant/<tenantId>/,常见结构:
| 路径 | 作用 |
|---|---|
config/index.js |
应用信息、站点 SEO、外链、路由覆盖、cssVars 主题 |
assets/image/ |
Logo、支付图等 |
assets/js/permissions.js |
功能开关(hasPermission) |
assets/public/ |
协议 HTML 等,构建时复制到产物根目录 |
标准模板租户为 tenant-standard;createOrg.js 会从这里复制图片、权限、public 等作为新租户基线。
二、入驻第一步:createOrg.js 脚手架
根目录执行 node createOrg.js,交互输入:tenantId、机构名、appId、开发端口、是否独立租户。
脚本主要做四件事:
- 创建
src/tenant/<tenantId>/目录树(assets/*、config)。 - 从
tenant-standard复制图片、permissions.js、assets/public。 - 生成
config/index.js(appInfo、site、outUrl、router.routes、cssVars)。 - 若选独立租户,把
tenantId写入build/tenant-domain-map.js的independentTenants。
92:116:d:/project/pro-a/createOrg.js
// 1. 创建租户目录结构
const tenantRoot = path.join(__dirname, 'src', 'tenant', tenantId);
createDirectory(path.join(tenantRoot, 'assets', 'css'));
createDirectory(path.join(tenantRoot, 'assets', 'image'));
createDirectory(path.join(tenantRoot, 'assets', 'js'));
createDirectory(path.join(tenantRoot, 'assets', 'public'));
createDirectory(path.join(tenantRoot, 'config'));
// 2. 复制标准平台资源(无论是否独立租户,都复制基础资源)
const standardTenantRoot = path.join(__dirname, 'src', 'tenant', 'tenant-standard');
// ... 复制 image、permissions、public
166:186:d:/project/pro-a/createOrg.js
const domainMapPath = path.join(__dirname, 'build', 'tenant-domain-map.js');
let domainMapContent = readFile(domainMapPath);
if (isIndependent) {
if (!domainMapContent.includes(`'${tenantId}'`)) {
domainMapContent = domainMapContent.replace(
/export const independentTenants = \[(.*?)\];/s,
(match, tenants) => {
const updatedTenants = tenants.trim() ? `${tenants.trim()}, '${tenantId}'` : `'${tenantId}'`;
return `export const independentTenants = [${updatedTenants}];`;
}
);
writeFile(domainMapPath, domainMapContent);
}
}
脚手架不会 自动改 package.json。README 要求手动增加类似脚本:
json
"dev:tenant-a": "cross-env VITE_TENANT_ID=tenant-a vite --port 8090",
"build:tenant-a": "cross-env VITE_TENANT_ID=tenant-a vite build"
三、构建期:Vite 如何「锁定」一个租户
vite.config.js 读取 process.env.VITE_TENANT_ID(默认 platform),加载该租户 config/index.js 做 SEO 等;用 independentTenants 判断是否独立租户,并写入构建常量。
115:185:d:/project/live-pc-web/vite.config.js
import { independentTenants } from './build/tenant-domain-map.js'
const isIndependent = independentTenants.includes(tenantId)
// ...
export default defineConfig({
publicDir: path.resolve(__dirname, `src/tenant/${tenantId}/assets/public`),
define: {
__IS_INDEPENDENT_TENANT__: isIndependent,
'import.meta.env.VITE_TENANT_ID': JSON.stringify(tenantId),
'import.meta.env.VITE_IS_INDEPENDENT_TENANT': JSON.stringify(isIndependent),
},
别名 @tenant 指向当前构建租户目录,业务里写 @tenant/assets/image/... 即该租户资源:
219:227:d:/project/live-pc-web/vite.config.js
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@tenant': path.resolve(__dirname, `src/tenant/${tenantId}`),
'@platform': path.resolve(__dirname, 'src/tenant/platform')
},
构建插件会把租户 assets/public 再复制到 dist-* 根目录(如 agreement.html)。批量构建见 build/build-all-tenants.js:遍历 src/tenant 子目录,对每个租户执行 cross-env VITE_TENANT_ID=... vite build。
四、运行时:谁决定「当前是哪个租户」
tenant-loader.js 从构建注入的 import.meta.env 读租户 ID 与是否独立;只加载本租户 config,缺失则抛错,不继承 platform 默认配置。
8:40:d:/project/live-pc-web/src/utils/tenant-loader.js
export const getCurrentTenant = () => {
if (import.meta.env.VITE_TENANT_ID) {
return import.meta.env.VITE_TENANT_ID;
}
return 'platform';
};
export const isIndependent = () => {
if (import.meta.env.VITE_IS_INDEPENDENT_TENANT) {
return import.meta.env.VITE_IS_INDEPENDENT_TENANT === 'true';
}
const tenantId = getCurrentTenant();
return independentTenants.includes(tenantId);
};
export const loadTenantConfig = async () => {
const tenantId = getCurrentTenant();
try {
const tenantModule = await import(`../tenant/${tenantId}/config/index.js`);
return tenantModule.default;
} catch (e) {
throw new Error(`租户${tenantId}配置缺失,无法启动`);
}
};
useTenantStore 把配置放进 Pinia,组件可读 appInfo、site、cssVars 等。
五、应用启动顺序(main.js)
59:99:d:/project/live-pc-web/src/main.js
async function initApp() {
const app = createApp(App)
const pinia = Pinia.createPinia();
app.use(pinia);
// ...
const router = await initRouter();
app.use(router);
const tenantConfig = await initTenantConfig(app);
app.config.globalProperties.tenantConfig = tenantConfig;
setGlobalConfig(tenantConfig);
sessionStorage.setItem('configId', tenantConfig?.appInfo?.appId || '');
await initPermissions();
mountGlobalMethods(app);
app.mount('#app')
}
要点:先路由、再租户配置、再权限 。configId(appId)会进 sessionStorage,与店铺等接口上下文相关。
initTenantConfig 写 Pinia、设标题/描述/关键词,并按 cssVars 同步 Element Plus / Vant 主题变量。
六、路由合并:租户如何覆盖平台页
initRouter 以平台 constantRoutes 为底,用租户 router.routes 按 name 覆盖 或追加无 name 的新路由;覆盖时合并 beforeEnter,避免租户换组件却丢掉平台守卫。
788:830:d:/project/live-pc-web/src/router/index.js
if (tenantConfig && tenantConfig.router && tenantConfig.router.routes) {
const routeMap = new Map();
routes.forEach(route => {
if (route.name) {
routeMap.set(route.name, route);
}
});
tenantConfig.router.routes.forEach(tenantRoute => {
if (tenantRoute.name) {
const baseRoute = routeMap.get(tenantRoute.name);
if (baseRoute?.beforeEnter && !tenantRoute.beforeEnter) {
routeMap.set(tenantRoute.name, { ...tenantRoute, beforeEnter: baseRoute.beforeEnter });
} else if (baseRoute?.beforeEnter && tenantRoute.beforeEnter) {
const merged = [
...normalizeBeforeEnter(baseRoute.beforeEnter),
...normalizeBeforeEnter(tenantRoute.beforeEnter),
];
routeMap.set(tenantRoute.name, { ...tenantRoute, beforeEnter: merged });
} else {
routeMap.set(tenantRoute.name, tenantRoute);
}
} else {
routes.push(tenantRoute);
}
});
routes = Array.from(routeMap.values());
}
tenant-standard 示例:覆盖 download、agreement、agreementM 到标准协议/下载页。
七、权限与协议:白标边界
权限按租户动态 import permissions.js,用点路径判断,例如 hasPermission('order.showBIteMiniProgram')。
27:41:d:/project/live-pc-web/src/utils/permission.js
export const initPermissions = async () => {
const tenantId = getCurrentTenant();
try {
const tenantPermissions = await import(`../tenant/${tenantId}/assets/js/permissions.js`);
permissions = tenantPermissions.default || {};
} catch (error) {
permissions = {};
}
return permissions;
};
协议类静态页在 assets/public/(如 agreement.html),配合租户路由指向 service-standard 或租户专属 Vue 页;README 有 PC/移动协议文件清单。
八、独立租户 vs 非独立(本仓库含义)
| 维度 | 非独立 | 独立 |
|---|---|---|
| 标记 | 不在 independentTenants |
createOrg 写入列表 |
| 构建 | VITE_IS_INDEPENDENT_TENANT 为 false |
为 true |
| 配置 | 仍从 tenant-standard 复制基线,再改 config/资源 |
同上,可更深定制路由与协议 |
| 与资金分账 | 无直接关系;分账/宝付在业务后端,非本前端入驻脚本 |
「独立」在这里主要是构建标识 + 工程隔离,不等于已配置自有微信/支付宝商户号。
九、端到端流程(串联)
十、小结与边界
核心结论 :入驻 = createOrg 生成租户目录与配置 → VITE_TENANT_ID 构建注入 → @tenant 与 publicDir 绑定资源 → 启动时 loadTenantConfig + 路由按 name 合并 + 租户 permissions。
本文未覆盖:后台开户、课程/订单库表隔离、宝付分账、支付商户进件------属业务与后端,需另文展开。
延伸阅读 :同系列可写支付收银台(wechat-jsapi-cashier)、订单 settleMode 与小程序跳转分账展示模式,并与资金侧「监管分账」区分命名。