Wujies 无界平台
平台功能(自研)插件开发

插件能力速查(怎么调)

这一页回答的是**"我要做某件事,代码怎么写"**。

不是清单页。 「有哪些能力可以用」是另一册的事,这里一条都不重抄:

想知道"平台给我提供了什么",先看 API 手册

这一页只做一件事:每件事给一段能直接抄的真实代码,并标明它出自哪个文件。 所有片段都从仓库里正在跑的插件源码摘出来,不是示意代码。

段覆盖什么
一后端:拿当前用户 / 宿主服务 / 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。

别把这个 cron 字符串原样写进 javadoc

*/ 这个序列会提前结束 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/ 会自动设置租户 —— 查表一定带 tenant_id

宿主 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。

视图层由 Mapper 帮你转

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。

别在插件里写"薄垫片"绕 SDK

不要自己复刻一份 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。

挂菜单的应用,routes 必须写 []

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。

相关阅读

目录