插件能力速查(怎么调)
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页回答的是**"我要做某件事,代码怎么写"**。
不是清单页。 「有哪些能力可以用」是另一册的事,这里一条都不重抄:
这一页只做一件事:每件事给一段能直接抄的真实代码,并标明它出自哪个文件。 所有片段都从仓库里正在跑的插件源码摘出来,不是示意代码。
| 段 | 覆盖什么 |
|---|---|
| 一 | 后端:拿当前用户 / 宿主服务 / SDK 能力 / 跨插件 / 宿主反查插件 / 定时任务 / 开放接口 / 分页 |
| 二 | 前端:发请求 / 拿宿主能力 / 权限 / 字典 / 路由注册 / AutoImport |
| 三 | 想抄某个场景时,打开哪一个文件 |
| 四 | 两条只有一个 H1 之隔、但代价最大的规矩 |
一、后端:最常用的 N 段代码
1. 拿当前登录用户与租户 —— 未登录时不报错
这是每页都要用的一条。LoginHelper 读的是 Sa-Token 上下文,也就是宿主建立的登录态;
插件不要自己解析 token。关键在于判空:同一个接口既可能被后台页面调(有登录态),
也可能被 @SaIgnore 的公开页面调(没有)。
import cn.wujies.common.satoken.utils.LoginHelper;
Long userId = LoginHelper.getUserId();
if (userId == null) {
// 刻意的:判空让调用方决定怎么呈现,比在服务端硬拦更灵活。
// 真要强制登录,用 @SaCheckLogin 把意图写在注解上。
return R.ok(Map.of("loggedIn", false));
}
info.put("username", LoginHelper.getUsername());
info.put("tenantId", LoginHelper.getTenantId()); // 字符串,例如 "000000"出处:wujies/wujies-plugins/wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoHostController.java
(GET /hello/demo/host/whoami,真实可用)。
租户列不用你自己拼。 平台的租户行拦截器会给每条未列入 security.tenant.excludes 的表的 SQL
补上 tenant_id,所以实体 extends TenantEntity 就够了 —— 见
后端工程 的 §1.1 与必须遵守的不变量。
2. 调宿主的系统服务 —— 用构造注入接口,不要注入实现类
宿主能力都做成了 wujies-common-core 里的接口(实现类在宿主的
wujies-system / wujies-common-oss 里)。插件只依赖接口所在的模块,于是"宿主换了实现"与插件无关。
import cn.wujies.common.core.service.ConfigService;
import cn.wujies.common.core.service.OssService;
@RestController
@RequestMapping("/hello/demo/host")
@RequiredArgsConstructor // 构造注入:缺哪个宿主能力会在启动期暴露
public class HelloDemoHostController {
private final ConfigService configService; // 读 sys_config
private final OssService ossService; // sys_oss 行 id → 可访问 URL
@GetMapping("/config")
public R<Map<String, String>> readOwnSiteConfig() {
return R.ok(configService.selectConfigListByCategory("hello").stream()
.collect(Collectors.toMap(
r -> r.getConfigKey().substring("hello.".length()),
ConfigDTO::getConfigValue)));
}
@GetMapping("/oss-url")
public R<String> resolveOssUrl(@RequestParam(required = false) String ossId) {
if (StringUtils.isBlank(ossId)) {
return R.ok(null); // 配置没填时返回 null,别抛异常打断整个页面
}
return R.ok(ossService.selectUrlByIds(ossId));
}
}出处:wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoHostController.java
(GET /hello/demo/host/config、/oss-url)。
两条纪律:插件不要自己拼 OSS 地址(存储介质由宿主配置,库里存的是 id 不是 URL);
插件不要自己读 sys_config 再包一层接口 —— 宿主的泛化端点 GET /open/<code>/config
已经替所有应用做完了(见后端工程 §7 的反面教材说明)。
3. 用 SDK 契约拿宿主能力 —— 必须能降级
SDK 里的 6 个能力接口(PlatformUserApi / PlatformOssApi / PlatformMessageApi /
FollowRelationApi / PlatformDeptApi / PlatformMemberApi)一律用 ObjectProvider 取,
不要强注入 —— 宿主缺少该能力(例如裁剪版)时应当降级,而不是启动失败。
import cn.wujies.common.plugin.sdk.PlatformUserApi;
private final ObjectProvider<PlatformUserApi> platformUserApiProvider;
private PlatformUserApi userApi() {
return platformUserApiProvider.getIfAvailable(); // 拿不到就是 null,别抛
}
void fillUserInfo(List<XxxVo> rows) {
PlatformUserApi api = userApi();
if (api == null) {
log.warn("宿主未提供 PlatformUserApi,用户信息将不显示(其余功能不受影响)");
return;
}
Map<Long, PlatformUserView> users = api.findByIds(rows.stream().map(XxxVo::getUserId).toList());
// ...
}出处:wujies-im/src/main/java/cn/wujies/im/service/impl/ImGroupServiceImpl.java:582、
wujies-member/src/main/java/cn/wujies/member/service/impl/MemberServiceImpl.java:701、
wujies-gallery/src/main/java/cn/wujies/gallery/service/impl/GalleryImageServiceImpl.java:96
(图床那条更硬:requireOssApi() 取不到就抛 ServiceException,因为它没有对象存储就真的没法上传)。
方法与返回值语义见平台 SDK 契约。
4. 容器类能力的两条路:直接拿 Mapper vs 走定位器
这是本项目最容易被做错的一处。先记住方向:
类可见 ≠ bean 可见
类:PluginClassLoader 按包名委托给拥有它的那个加载器 ⇒ 同一 FQCN 只有一个 Class 对象
bean:每个插件有自己的子上下文 ⇒ 你的上下文里没有兄弟的 bean做法 A:直接注入兄弟插件的 Mapper(能成,但有代价)。
Mapper 接口被 PluginMapperRegistrar 注册进了宿主的 bean factory,所以注入得到:
import cn.wujies.member.mapper.MemberMapper; // 需要 pom 里 provided 依赖 wujies-member-plugin
private final MemberMapper memberMapper; // ⚠️ 你从此隐式依赖 member 的数据表
Member row = memberMapper.selectById(id);做法 B:走 SDK 定位器(要装/没装两种分支都写出来)。 定位器是每次调用都反查的代理,兄弟应用在运行期被装 / 卸 / 升级都跟得上,不需要重启:
import cn.wujies.common.plugin.sdk.MemberServiceLocator;
import cn.wujies.member.service.IMemberService;
private final MemberServiceLocator memberServiceLocator;
@GetMapping("/service")
public R<Map<String, Object>> readViaLocator(@RequestParam(required = false) Long userId) {
Map<String, Object> result = new LinkedHashMap<>();
// ① 先问"装了没"。这一步是可选依赖的全部意义所在。
boolean available = memberServiceLocator.available(IMemberService.class);
result.put("memberAppAvailable", available);
if (!available) {
// ② 没装:返回 200 + 一句说明。
// 定位器的 get(...) 会抛「请到应用市场安装 会员管理」——
// 对"可选依赖"来说那是过度反应,所以这里不调 get。
result.put("note", "会员应用未安装 —— 到应用市场装上它就自动生效,不需要改代码");
return R.ok(result);
}
// ③ 装了:真去查。查不到就如实报错,不拿 200 + 空 data 糊过去。
var detail = memberServiceLocator.get(IMemberService.class).queryDetailByUserId(userId);
if (detail == null) {
return R.fail("member 表里没有 user_id=" + userId + " 这一行");
}
result.put("row", detail);
return R.ok(result);
}出处:wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoCrossPluginController.java
(GET /hello/demo/cross/service,/cross/mapper 是不带降级的对照版本)。
定位器的 get / available / proxy 三个方法与回落规则见定位器。
| 场景 | 用哪个 |
|---|---|
| 可选依赖(没装也能跑,只是少个功能) | 定位器 + available(...) 判一下,降级返回 |
| 强依赖(没装这功能就没意义) | 定位器直接 get(...),让「请到应用市场安装 XX」抛出去,比 NPE 清楚 |
| 只想读对方的数据、要联合查询 | 直接注入兄弟 Mapper 最省事,但要意识到你从此隐式依赖它的表结构 |
5. 宿主反查插件 —— PluginServiceProvider
方向反过来:宿主注入不到插件(Spring 父子关系单向)。宿主侧要拿插件服务只有两条路,
其中 PluginServiceProvider 是推荐的那条;它还能顺带回答"这个应用装了没":
import cn.wujies.common.core.plugin.PluginServiceProvider;
private final ObjectProvider<PluginServiceProvider> pluginServiceProvider;
@Override
public Set<Long> followedUserIds(Long userId, Collection<Long> candidateUserIds) {
if (userId == null) {
return Set.of();
}
PluginServiceProvider provider = pluginServiceProvider.getIfAvailable();
if (provider != null && !provider.isPluginLoaded(APP_CODE)) { // APP_CODE = "social"
// 社交应用未安装/未启用 → 没有关注关系可言。
// 不这么判的话,社交被"彻底删除"后这里会去查一张不存在的表,
// 表现为调用方 500 —— 一个合法状态却炸出看不懂的错误。
return Set.of();
}
// ... 真去查
}出处:wujies-social/src/main/java/cn/wujies/social/adapter/FollowRelationApiAdapter.java。
因为这个 SPI 的实现又通过 PluginServiceProvider 回来调本插件的 Mapper。
换个方向看:消费方在别的插件(图床用 ObjectProvider<FollowRelationApi> 取它)时,
实现就必须住在父上下文里 —— 插件看不见兄弟插件,放错了对方只会拿到 null 并静默降级。
6. 定时任务:两种写法各一段
@Scheduled 和 SnailJob @JobExecutor 都是真实存在的做法,不是历史遗留。完整取舍见
后端工程 §8,这里只给能抄的两段。
6a. @Scheduled —— cron 在代码里。不需要写 @EnableScheduling(宿主替插件子上下文注册了
ScheduledAnnotationBeanPostProcessor):
import org.springframework.scheduling.annotation.Scheduled;
@Slf4j
@Component
@RequiredArgsConstructor
public class HelloDemoScheduledTask {
private final HelloService helloService;
@Scheduled(cron = "0 */5 * * * ?") // 6 段式:秒 分 时 日 月 周
public void demoCleanup() {
int count = helloService.listAll().size();
log.info("[hello] 定时任务执行:当前示例数据 {} 条", count);
}
}出处:wujies-hello/src/main/java/cn/wujies/hello/task/HelloDemoScheduledTask.java。
*/ 这个序列会提前结束 javadoc 注释,后面的中文被当成 Java 代码,报一串
"非法字符 / 需要标识符",而且报错位置指在中文上,第一眼完全看不出是注释的问题。
实测在写这个文件时踩到过。 写在 @Scheduled(cron = "...") 注解参数里没问题。
6b. SnailJob @JobExecutor —— cron 在控制台里。方法签名是固定的:
import com.aizuda.snailjob.client.job.core.annotation.JobExecutor;
import com.aizuda.snailjob.client.job.core.dto.JobArgs;
import com.aizuda.snailjob.common.log.SnailJobLog;
import com.aizuda.snailjob.model.dto.ExecuteResult;
@Slf4j
@Component
@RequiredArgsConstructor
@JobExecutor(name = "helloDemoTask") // 名字要与调度中心建任务时填的执行器名完全一致
public class HelloDemoSnailJobTask {
private final HelloService helloService;
public ExecuteResult jobExecute(JobArgs jobArgs) { // 名字/参数/返回类型都不能变
int count = helloService.listAll().size();
// 关键:REMOTE 才能在调度中心的任务日志里看到。普通 log.info 只进本机文件。
SnailJobLog.REMOTE.info("[hello] SnailJob 任务执行:当前示例数据 {} 条", count);
return ExecuteResult.success(count);
}
}出处:wujies-hello/src/main/java/cn/wujies/hello/task/HelloDemoSnailJobTask.java。
光有注解不会跑 —— 还要在调度中心(/snail-job → 任务管理 → 新建)建一条任务。
只把包传上去、不建任务,是不会有任何执行的。这是排查"任务没跑"的第一件事,不是权限也不是插件没装。
任务线程不是请求线程,/open/** 那套"自动设租户"不生效。查多租户表会一条都查不到,
要用 TenantHelper.ignore(...) 包起来;要按某个租户查就用 TenantHelper.dynamic(tenantId, ...)。
现成写法见 wujies-enterprise/.../EnterpriseCompanyServiceImpl.java:133
(先 getTenantId(),再 dynamic(tenantId, importRunner))。
7. 开放 /open 接口 —— 四个动作
import cn.dev33.satoken.annotation.SaIgnore;
@SaIgnore // ① 类上标:告诉 Sa-Token 这条不要登录态
@Validated
@Slf4j
@RestController
@RequestMapping("/open/hello") // ② 路径放 /open/<你的 app>/**
@RequiredArgsConstructor
public class HelloDemoOpenController extends BaseController {
private final HelloService helloService;
@GetMapping("/greet") // ③ 什么都不用注册(宿主自动收进路由清单)
public R<String> openGreet(@RequestParam(required = false) String name) {
return R.ok(helloService.greet(name));
}
}出处:wujies-hello/src/main/java/cn/wujies/hello/open/HelloDemoOpenController.java。
④ 站点配置不用自己写接口:把 hello.* 写进 sys_config(config_category = 'hello',
见 wujies-hello/migrations/03-create-hello-website-config.sql),宿主的泛化端点
GET /open/hello/config 立刻能读。这个端点只认 config_category、不认识任何具体应用。
现成的 /open 端点清单(含路径 / 方法 / 是否匿名,共 50 个)见开放接口清单。
宿主 open-api.tenant.path-patterns 的默认值就含 /open/**,按请求头把租户设进上下文
(不带时用默认值 000000)。所以匿名接口同样会触发租户行拦截器:
「实体没继承 TenantEntity + 表里没有 tenant_id 列」这个组合必炸,报
Unknown column 'tenant_id' in 'where clause'。
这个坑已经在线上炸过一次(hello 1.0.0):/open/hello/config 200、/open/hello/greet 200,
只有真去查那张表的 /open/hello/item/list 500 —— 因为只有它碰了库。
两条路按数据语义二选一,见后端工程 §1.1。
8. 分页与转换 —— 标准 CRUD 的样板
几乎所有列表接口都是这四行。TableDataInfo.build(page) 产出前端的 { rows, total }:
import cn.wujies.common.mybatis.core.page.PageQuery;
import cn.wujies.common.mybatis.core.page.TableDataInfo;
import cn.wujies.common.core.utils.MapstructUtils;
@Override
public TableDataInfo<MallBrandVo> queryPageList(MallBrandBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<MallBrand> lqw = buildQueryWrapper(bo);
Page<MallBrandVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
return TableDataInfo.build(result);
}
@Override
public Boolean insertByBo(MallBrandBo bo) {
MallBrand add = MapstructUtils.convert(bo, MallBrand.class); // BO → 实体
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setId(add.getId()); // 回填主键给前端
}
return flag;
}出处:wujies-mall/src/main/java/cn/wujies/mall/product/service/impl/MallBrandServiceImpl.java
(insertByBo 在 :118、queryPageList 在 :64)。
MapstructUtils.convert 在插件里被用了 200 多处,是事实标准,不要手写 BeanUtils.copyProperties。
selectVoPage / selectVoList / selectVoById 是平台的 BaseMapperPlus 提供的,
实体 → VO 的转换由它做,Service 里不用手写。
二、前端:最常用的 N 段代码
1. 发请求:用 @/utils/request(它被指向 @dromara/request)
远程产物跑在宿主同源上下文里,@/utils/request 在构建期被精确改写成
@dromara/request(工厂里的正则:{ find: /^@\/utils\/request$/, replacement: '@dromara/request' }),
运行期由宿主的 import map 落到宿主正在用的那一个 axios 实例上 ——
所以它已经带了 baseURL / token / 租户头 / 加解密 / 统一错误处理。
// src/api/mall/brand/index.ts
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { BrandVO, BrandForm, BrandQuery } from '@/api/mall/brand/types';
export const listBrand = (query?: BrandQuery): AxiosPromise<BrandVO[]> =>
request({ url: '/mall/brand/list', method: 'get', params: query });
export const addBrand = (data: BrandForm) =>
request({ url: '/mall/brand', method: 'post', data });
export const delBrand = (id: string | number) =>
request({ url: '/mall/brand/' + id, method: 'delete' });出处:wujies/wujies-plugins/frontend/mall-app/src/api/mall/brand/index.ts。
不要自己复刻一份 src/utils/request.ts。那种垫片会
import { getToken } from '@/utils/auth',等于把宿主源码拉进产物 —— 实测 im 就是这么炸的
(ReferenceError: useStorage is not defined,整个应用装载失败)。
字符串 alias 也不行:它是前缀匹配,会把 @/utils/requestCache 一起吃掉,所以工厂用的是正则。
useSharedRequest: true 已经在所有插件工程的 vite.config.ts 里开了。
另一条路是 runtime.request(hello-app 用它,因为它 standalone: true 不依赖宿主源码)。
两者指向同一个宿主实例,选哪条看你的工程要不要读宿主 src/:
要读 → 用 @/utils/request;完全独立 → 用 runtime.request。字段定义见
前端运行时 API。
2. 拿宿主能力:runtime 上有什么
import { getRemoteRuntime } from '../runtime';
const runtime = getRemoteRuntime();
// 宿主 axios 实例
const res: any = await runtime.request({ url: '/captchaConfig', method: 'get' });
// 版本信息,用于兼容降级判断
const vueVersion = runtime.vue.version;
const epVersion = runtime.elementPlus.version;
const platform = runtime.platformVersion;
// 证明拿到的是真实 router 实例
typeof runtime.routerInstance?.addRoute;出处:frontend/hello-app/src/views/DemoHome.vue、views/DemoPluginDev.vue
(页面上真的把这些值显示出来了)。
本地的 src/runtime.ts 只是一层持有者 —— 没被注入过就抛错:
export function getRemoteRuntime(): any {
if (!remoteRuntime) {
throw new Error('[hello] 宿主运行时尚未注入,请确认应用通过 install(runtime) 装载');
}
return remoteRuntime;
}出处:frontend/hello-app/src/runtime.ts。这样"忘了走 install"会立刻暴露,
而不是变成一个 undefined 引发的怪错误。
runtime 的 11 个字段逐个解释见前端运行时 API;
components / modal / app 分别在下面第 5、6、7 段用到。
3. 权限:v-hasPermi(按 perms 字符串)
<KPlainButton v-hasPermi="['mall:brand:add']" :icon="Plus" type="primary" @click="handleAdd">
新增品牌
</KPlainButton>
<el-button v-hasPermi="['mall:brand:edit']" link type="primary" @click="handleUpdate(row)">编辑</el-button>出处:frontend/mall-app/src/views/mall/brand/index.vue:10。
perms 字符串必须与后端 app.json 里那条 type: "F" 按钮菜单的 perms 逐字符一致。
按钮也必须写进清单,否则全新部署装完没有按钮、租户用户看不到(v-hasPermi 过不了),
而且"彻底删除"也删不掉它们 —— 见菜单与权限。
4. 字典:proxy?.useDict('...')
import { computed, getCurrentInstance, reactive, ref, toRefs } from 'vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_normal_disable } = toRefs<any>(proxy?.useDict('sys_normal_disable'));拿到之后直接用在 tag 与下拉里:
<template #status="{ row }">
<KDictTag :options="sys_normal_disable" :value="row.status" />
</template>
<el-radio-group v-model="form.status">
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>出处:frontend/mall-app/src/views/mall/brand/index.vue:115、:47、:85。
多个字典一次取:proxy?.useDict('exam_question_type', 'exam_record_pass_status')
(见 frontend/exam-app/src/views/exam/record/index.vue:76)。
字典数据要随包走(写进 migrations/ 的字典脚本),否则全新部署的应用全是空标签 ——
见种子数据与站点配置。
5. 弹窗与提示:用宿主的 modal,别自己 ElMessage
proxy?.$modal.msgSuccess('保存成功');
await proxy?.$modal.confirm(`是否确认删除品牌编号为“${currentIds}”的数据项?`);出处:frontend/mall-app/src/views/mall/brand/index.vue:347、:358。
$modal 是宿主全局挂的插件,与 runtime.modal 是同一份(字段表见
前端运行时 API)。用它才能与宿主其余页面表现一致。
6. 导出:proxy?.download(url, params, filename)
const handleExport = () => {
proxy?.download(
'mall/brand/export',
{ ...queryParams.value },
`brand_${new Date().getTime()}.xlsx`
);
};出处:frontend/mall-app/src/views/mall/brand/index.vue:364。
需要自己带 header 的场合(导入、上传)用 globalHeaders:
import { globalHeaders } from '@/utils/request';出处:frontend/exam-app/src/views/exam/question/index.vue:142、
frontend/gallery-app/src/components/media/ImageUpload/BatchUpload.vue:321。
7. 要宿主没全局注册的指令时 —— 在 install 里补注册
组件能在构建期解决(工厂的 el-* 解析器把 <el-table> 转成具名 import),
但指令不能:v-loading 被编译成运行期的 resolveDirective("loading"),只在 app 的全局指令表里查。
宿主没有 app.use(ElementPlus),所以要自己补:
const elLoading = runtime?.elementPlus?.ElLoadingDirective;
if (elLoading) {
runtime.app.directive('loading', elLoading); // 只有 app 实例能注册指令
}出处:frontend/gallery-app/src/index.ts(照这个写)。
漏了不报错,只是转圈永远不出现。只注册自己真正用到的,别替宿主做决定。
8. 页面与路由注册:src/index.ts 的 manifest
import DemoAbout from './views/DemoAbout.vue';
import DemoHome from './views/DemoHome.vue';
import { setRemoteRuntime } from './runtime';
const manifest = {
code: 'hello',
name: '示例远程应用',
version: '0.1.0',
routes: [
{ path: '/remote-demo', name: 'RemoteDemoHome', view: 'demo/home',
meta: { title: '示例远程应用', icon: 'ri:apps-2-line' } }
]
};
export default function install(runtime: any) {
setRemoteRuntime(runtime);
return {
manifest,
views: {
// 约定:加载器返回 { default: Component }
'demo/home': () => ({ default: DemoHome }),
'demo/about': () => ({ default: DemoAbout })
}
};
}出处:frontend/hello-app/src/index.ts。
manifest.routes 是远程应用自己往宿主 router 上注册的顶级路由(没有 Layout)。
如果它的 path 与菜单管线为同一页面生成的那条(包在 Layout 里)相同,裸的那条会抢先匹配 ——
表现是页面渲染出来了,但左侧菜单、顶栏、标签页全没了。
hello-app 是唯一保留 routes 的例外(三条 /remote-demo/**,路径恰好不撞)。
照它抄之前先确认你的菜单 path 会不会撞。
9. 视图键与菜单 component 的对应关系
这是最容易做成"悬空菜单"的一处,两边必须逐字符对齐:
前端 views: { 'demo/home': () => ({ default: DemoHome }) }
└──────┬──────┘
后端 app.json: "component": "remote:hello/demo/home"
└─┬─┘ └───┬───┘
appCode 视图键对不上的后果:菜单是清单声明的,所以全新安装也会照建,点开只得到控制台一句
「component 解析不出来,已跳过该路由」。hello 历史上就踩过(清单写
remote:hello/hello/list,而前端从没注册过 hello/list)。
批量体检用 npm run audit:menus(在 plus-ui/ 下,它查库里所有 C 菜单能否落到真实页面)。
10. AutoImport 必须与宿主逐项一致
工厂的 autoImport() 已经按宿主的清单配好了(vue / vue-router / pinia / @vueuse/core
- Element Plus resolver 那四个)。别自己在工程里另起一份 —— 少一项的后果是运行期
ReferenceError、整个应用装载失败,而构建期不报错(Rollup 把未定义的名字当全局变量)。
自检(都在 plus-ui/ 下):
npm run check:globals # 产物里有没有"宿主自动导入宇宙"里未绑定的标识符
npm run check:remote # 组件白名单 + process.env 残留 + manifest.styles 是否存在
npm run check:runtime # 产物真正 import 的名字与运行时垫片导出对账check:globals 也是验尸工具:npm run check:globals -- --dir=<解包后的 frontend 目录>
可以直接复查已发布的历史包,不用重新构建。原理与实测案例见
前端远程应用 §4.5。
三、照着抄哪几个文件
| 我要做… | 打开这个文件 | 输出到哪一页 |
|---|---|---|
| 最小 CRUD(列表 + 抽屉表单 + 权限 + 字典 + 导出) | 后端 wujies-mall/src/main/java/cn/wujies/mall/product/service/impl/MallBrandServiceImpl.java | 本文 §1.8 |
| 同上,前端那一半 | 前端 frontend/mall-app/src/views/mall/brand/index.vue | 本文 §2.1 / 2.3 / 2.4 / 2.6 |
| 接口层写法(含缓存) | frontend/mall-app/src/api/mall/brand/index.ts | 本文 §2.1 |
| 跨插件调用(两种分支都给全) | wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoCrossPluginController.java | 本文 §1.4 |
| 调宿主的系统服务 | wujies-hello/.../controller/HelloDemoHostController.java | 本文 §1.1 / 1.2 |
| 用 SDK 能力接口并降级 | wujies-im/.../service/impl/ImGroupServiceImpl.java(PlatformUserApi) | 本文 §1.3 |
| 宿主反查插件 / 写一个 SDK 契约的实现 | wujies-social/src/main/java/cn/wujies/social/adapter/FollowRelationApiAdapter.java | 本文 §1.5 |
从插件里开放 /open 接口 | wujies-hello/.../open/HelloDemoOpenController.java | 本文 §1.7 |
| 定时任务两种 | wujies-hello/.../task/HelloDemoScheduledTask.java / HelloDemoSnailJobTask.java | 本文 §1.6 |
| 实体 / 建表怎么写 | wujies-hello/.../domain/HelloItem.java + wujies-hello/migrations/01-create-hello-tables.sql | 后端工程 §1.1 · 数据库迁移 |
前端入口契约(install + manifest + views) | frontend/hello-app/src/index.ts | 本文 §2.8 |
| 前端运行时持有者 | frontend/hello-app/src/runtime.ts | 本文 §2.2 |
| 指令补注册 | frontend/gallery-app/src/index.ts | 本文 §2.7 |
| 组件白名单怎么写 | frontend/mall-app/vite.config.ts(12 个组件)vs frontend/hello-app/vite.config.ts(空,因为 standalone) | 前端远程应用 §4.4 |
| 逐文件索引(先扫一眼再抄) | 跟着 hello 读源码 | — |
抄之前记住两件容易漏的:插件仓库根 pom.xml 的 <modules> 要加一行(漏了的表现是
"改了代码没生效");菜单的 type: "F" 按钮也要写进 app.json。
四、两条会在运行期才炸的规矩
这两条都有"构建 / 编译期完全正常、一装载就坏"的特征,所以单独列。
① 平台件必须 provided。 打进插件会出现「同一个接口两个 Class 对象」——instanceof 莫名
返回 false、按类型注入找不到 bean,而且只在运行期暴露。插件仓库父 POM 的
dependencyManagement 已经统一设好了,不要在子模块里改它。
② AutoImport 与组件白名单必须与宿主逐项一致。 症状都是 ReferenceError
或"按钮变成纯文字",构建期不报错。守门脚本见本文 §2.10。