当低代码平台的天花板被吐槽了 5 年,是时候看看真正能"深度扩展"的方案长什么样了。
低代码这个赛道,这两年有个很微妙的变化——大家不再争论"低代码会不会取代程序员"了,而是开始吐槽"低代码做出来的东西,改又改不动,扔又舍不得"。
核心痛点就一句话:低代码平台能做的事情天花板太低,一旦需求超出平台能力,就得全部推倒重来。
Forge Admin 最近一轮更新,直接对着这个痛点开刀。7 月以来连续 40+ 次提交,把三个方向拉了一个大版本:
这篇文章拆解这三个方向的技术实现,不讲营销话术,直接看代码和设计思路。
大部分低代码平台的表格组件,能力边界是固定的——分页、排序、搜索,到此为止。你想要一个"金额超过 10 万的行自动标红"的需求,平台不支持,你就只能:
Forge 的答案是 方案 C:在低代码页面上直接写扩展代码,沙箱隔离运行。
新增的 lowcode-extension 组件目录下有三个核心文件:
lowcode-extension/
├── ExtensionCodeWorkbench.vue # 工作台主组件
├── js/ExtensionSandboxHost.vue # JS 沙箱宿主
└── css/ScopedCssPreview.vue # CSS 隔离预览
JS 扩展的隔离方案——用 Web Worker:
// ExtensionSandboxHost.vue 核心逻辑
function execute(script, context = {}, allowedFields = []) {
validateClientScript(script) // 1. 静态校验脚本
const safeContext = sanitizeExtensionContext( // 2. 白名单过滤上下文
context, allowedFields
)
const nonce = createNonce() // 3. 生成唯一 nonce
worker = new Worker(
new URL('./extension-sandbox.worker.js', import.meta.url),
{ type: 'module' }
)
// 4. 超时控制 + 输出大小限制
// - 执行超时默认 800ms
// - 输出上限 64KB
// - Worker 初始化超时 5000ms
}
为什么用 Worker 而不是 eval 或 new Function?三个原因:
| 维度 | eval/new Function | Web Worker 沙箱 |
|---|---|---|
| DOM 访问 | 能访问主线程 DOM,有 XSS 风险 | 不能访问主线程 DOM |
| 崩溃隔离 | 脚本崩溃直接卡死主线程 | Worker 崩溃不影响主应用 |
| 超时控制 | 无法强制中断 | 可以 terminate Worker |
也就是说,用户在低代码页面上写的扩展 JS,就算写了个死循环,也不会拖垮整个后台管理系统。这在多租户场景下尤其重要——一个租户的扩展脚本出问题,不能影响其他租户。
CSS 扩展的隔离方案——用 iframe + 作用域选择器:
// ScopedCssPreview.vue
const previewDocument = computed(() => {
return `<!doctype html>
<html><head><style>
body { margin: 0; padding: 16px; ... }
${css} // 用户写的 CSS
</style></head><body>
<div data-forge-app="${applicationCode}"
data-forge-page="${pageCode}"> <!-- 作用域锚点 -->
<div class="preview-card">...</div>
</div>
</body></html>`
})
<iframe sandbox=""> 加上 data-forge-app / data-forge-page 属性锚定,CSS 样式只在自己的作用域内生效,不会污染其他低代码页面。保存时框架会自动给用户的 CSS 规则加上作用域前缀,再注入到真实页面。
ExtensionCodeWorkbench.vue 这个组件本身的设计也有几个值得说的点:
<header class="workbench-context">
<div class="context-main">
<span class="language-badge">{{ modeTitle }}</span> <!-- JS / CSS 标识 -->
<div>
<strong>{{ hookGuide.label }}</strong> <!-- 钩子说明 -->
<p>{{ hookGuide.description }}</p> <!-- 使用场景 -->
</div>
</div>
<div class="scope-summary">
<span>增强范围</span>
<strong>{{ scopeDescription }}</strong> <!-- 影响哪些组件 -->
</div>
</header>
这不是一个简单的"塞个 Monaco Editor 进去",而是把"扩展代码的生命周期管理"做成了产品级的体验。
低代码 CRUD 页面的数据导入一直是痛点——你要么手写后端导入接口,要么用第三方工具。Forge 这次直接把导入做成了组件:
AiCrudImportModal.vue 的流程是:
选择 Excel 文件
↓
浏览器本地解析预览(不上传,保护数据)
↓
展示前 N 行数据预览
↓
确认无误后点击"开始导入"
↓
后端校验 + 写入 + 返回错误行
关键设计:预览阶段在浏览器本地完成,用的是 SheetJS 解析 .xlsx / .xls,不把文件传到后端。只有用户确认后才上传,减少了无效的网络传输和后端解析压力。
错误行会在预览表格里高亮,用户可以直接看到哪一行哪个字段出了问题。
表格组件 AiTable.vue 这次的改动可以用四个词概括:拖、排、筛、应。
<n-data-table
remote <!-- 1. 远程模式 -->
flex-height <!-- 2. 自适应高度 -->
:columns="tableColumns"
:max-height="maxHeight"
@update:sorter="handleUpdateSorter" <!-- 3. 远程排序 -->
@update:filters="handleUpdateFilters" <!-- 4. 远程筛选 -->
/>
// 列定义新增
{
resizable: col.resizable ?? props.resizable, // 列宽拖拽,默认开启
}
| 能力 | 之前 | 现在 |
|---|---|---|
| 列宽调整 | 不支持 | 默认开启拖拽 |
| 排序 | 前端排序 | 远程排序,支持百万级数据 |
| 筛选 | 前端筛选 | 远程筛选 |
| 高度 | 固定高度 | flex-height 自适应容器 |
| 密度 | 固定 | 紧凑/默认/宽松三档切换 |
| 全屏 | 不支持 | 一键全屏查看 |
| 渲染模式 | 单一 | 表格/卡片模式切换 |
还有一个 ER 图设计器 BusinessRelationDesigner.vue,低代码建模时可以可视化拖线建立对象关系,字段级联一目了然,不用再对着 JSON 想象数据结构。
之前的编码规则就是一行模板字符串:
WL${yyyyMMdd}${seq:3}
简单场景够用,但遇到 MES、WMS 这种企业级场景就撑不住了:
重构后的编码规则变成了主表 + 分段明细子表的结构。一条规则由多个段组成,每段可选五种类型:
| 段类型 | 说明 | 示例 |
|---|---|---|
| DATE | 日期格式 | yyyyMMdd → 20260718 |
| FIXED | 固定字元 | WL → WL |
| SEQ | 流水号 | 3 位十进制 → 001 |
| VARIABLE | 变量值(业务字段) | 仓库编码 → WH01 |
| SYS_VAR | 系统变量 | 租户/用户/组织 → T001 |
流水号(SEQ)支持五种进制:
| 进制 | 字符集 | 3 位容量 | 首个值 |
|---|---|---|---|
| DECIMAL | 0-9 | 1,000 | 001 |
| HEX | 0-9A-F | 4,096 | 001 |
| ALPHA_UPPER | A-Z | 17,576 | AAA |
| ALPHA_LOWER | a-z | 17,576 | aaa |
| ALPHANUMERIC | 0-9A-Z | 46,656 | 001 |
还有一个细节:去除易混淆字符。字母 I 容易和数字 1 混,O 容易和 0 混,Z 和 2 混。开了 excludeAmbiguous 之后,这些字符自动跳过。这个功能在制造业场景里非常重要——产线扫码枪识别错误一个字符,整批料就错了。
这是企业级场景最需要的能力。看一个实际例子:
规则:仓库物料编码
分段配置:
段1: VARIABLE(仓库编码) → 标记为"前置码"(分组依据)
段2: FIXED("-") → 固定连接符
段3: DATE(yyyy) → 年份
段4: SEQ(3位十进制) → 流水号,按前置码分组
生成结果:
WH01-2026-001 ← WH01 仓库的第 1 个
WH01-2026-002 ← WH01 仓库的第 2 个
WH02-2026-001 ← WH02 仓库独立从 001 开始
WH02-2026-002
WH01-2026-003 ← WH01 继续
不同仓库各自的流水号独立计数,互不干扰。 这个在旧模板字符串方案里根本做不到。
分组 key 的构建也很有讲究——不是简单拼字符串,而是按段顺序解析后做 SHA-256 摘要,避免分隔碰撞和数据库 key 超长。
流水号可以按时间周期自动重置:
resetEnabled: true
resetPolicy: MONTH # YEAR / MONTH / DAY / HOUR
YEAR:每年 1 月 1 日归零MONTH:每月 1 日归零DAY:每天 0 点归零HOUR:每小时整点归零周期重置使用应用配置时区,以注入 Clock 的服务端时区为基线,保证多实例部署时行为一致。
这个功能是管理员最喜欢的——配完分段,立刻看到生成的编码长什么样,不需要保存、不需要生成、不消耗真实流水号。
你配置:
段1: FIXED("WL")
段2: DATE(yyyyMMdd)
段3: SEQ(3位,ALPHA_UPPER)
预览结果:
WL20260718AAA ✓ 长度: 13
WL20260718AAB ✓ 下一个
预览接口和真实生成接口是分开的,预览不访问真实计数器,用 startValue 作为示例序号。而且预览请求会取消上一条(防抖),不会因为频繁调整配置而打爆后端。
编码规则的安全边界设计得很克制:
| 变量来源 | 谁能提供 | 风险 |
|---|---|---|
| SYS_VAR | 只从可信 Session 读取 | 业务代码不能覆盖 |
| VARIABLE | 从业务 fields 取值 |
调用方显式传值 |
| SEQ | 服务端号段分配 | 防伪防重 |
旧实现里 resolveTenantId 在上下文缺失时回退租户 1,新规则改成缺失即失败,不允许业务代码伪造租户、用户或组织系统变量。这在多租户场景下是必须的——否则一个租户的业务代码可以伪造系统变量,给另一个租户生成编号。
这次重构最让人头大的问题是——已有的低代码业务对象的自动编号字段,怎么保证不断号?
Forge 的迁移方案分三层:
ai_code_rule 全量迁移:已知模板由 SQL 回填,未知模板由 legacy parser 兼容物化legacy_compat_enabled=1,新建规则关闭,逐步收敛迁移后,低代码字段自动编号继续读取 generation.ruleCode,老项目无感知。
tab/index.vue 用 VueDraggable 实现了标签拖拽排序:
<VueDraggable
class="top-tab-list"
:model-value="tabStore.tabs"
:animation="180" <!-- 拖拽动画 180ms -->
:move="canMoveTab" <!-- 拖拽可行性判断 -->
ghost-class="top-tab-drag-ghost"
chosen-class="top-tab-drag-chosen"
drag-class="top-tab-dragging-item"
@update:model-value="handleTabReorder"
>
标签分两个组:固定标签 和 普通标签。拖拽时 canMoveTab 会判断目标位置是否允许——固定标签始终在左侧,普通标签不能拖到固定标签前面。视觉上两组之间有分隔线,一眼能分清。
ContextMenu.vue 的菜单结构是:
├── 重新加载
├── 固定 / 取消固定
├── 恢复刚关闭的标签
├── ──────────────
├── 页面操作
│ ├── 复制页面地址
│ ├── 复制页面名称和地址
│ └── 在新窗口打开
└── 标签整理
├── 移动到最左侧
├── 移动到最右侧
├── 固定全部
├── 取消全部固定
├── 刷新其他标签
├── 关闭其他
├── 关闭左侧
└── 关闭右侧
每个菜单项都有可用性判断:
const canCloseCurrentTab = computed(() => {
if (!currentTab.value || isCurrentTabPinned.value || currentTab.value.closable === false)
return false
return Boolean(currentTab.value.forceClosable || tabStore.tabs.length > 1)
})
const hasClosableLeftTabs = computed(() =>
tabStore.tabs.some((item, index) => index < currentTabIndex.value && !item.pinned)
)
比如"关闭左侧"在左侧没有可关闭标签时自动禁用,"固定全部"在所有标签都已固定时禁用。不是简单的一堆按钮,而是根据上下文智能启用/禁用。
标签有 dirty 状态(未保存)时显示红点:
<div
class="top-tab-item"
:class="{
'is-active': item.path === tabStore.activeTab,
'is-pinned': item.pinned,
'is-dirty': item.dirty, <!-- 未保存红点 -->
}"
>
关闭有 dirty 状态的标签时,会弹确认框:
// tab-interactions.js
export function confirmDirtyTabs(tabs, actionLabel = '继续操作') {
const dirtyTabs = tabs.filter(tab => tab.dirty)
if (dirtyTabs.length === 0) return true
// ... 弹窗确认
}
恢复刚关闭的标签也是个高频功能——手滑关错了,右键一键恢复,不用重新从菜单找。
标签多了之后,支持滚轮横向滚动,而且边界有渐变阴影提示:
<div
:class="{
'has-tab-overflow-start': canScrollLeft, <!-- 左侧有内容 -->
'has-tab-overflow-end': canScrollRight, <!-- 右侧有内容 -->
}"
@wheel.capture="handleTabWheel"
@scroll="syncTabOverflowState"
>
这些细节看起来小,但对每天在后台开十几个标签页的人来说,体验提升是实打实的——不需要精确点到那个小小的关闭按钮,不需要因为标签太多而找不到页面,不需要因为手滑关闭而重新填一遍表单。
| 层 | 技术 | 版本 |
|---|---|---|
| 前端框架 | Vue 3 | 3.5 |
| UI 组件库 | Naive UI | 2.42 |
| 构建工具 | Vite | 7 |
| 状态管理 | Pinia | 3 |
| 原子化 CSS | UnoCSS | 66 |
| 后端框架 | Spring Boot | 3.2 |
| ORM | MyBatis-Plus | 3.5 |
| 认证授权 | Sa-Token | 1.38 |
| 流程引擎 | Flowable | 7.0 |
| 数据库 | MySQL | 8.0+ |
| 缓存 | Redis | 6.0+ |
# 在线演示(直接打开即用)
http://www.dlforgelab.com:8084/forge/login
# 账号:admin 密码:123456
# 本地启动
git clone https://gitee.com/ForgeLab/forge-admin.git
cd forge-admin-ui && pnpm install && pnpm dev
进去后重点体验:
回到开头的问题——低代码的天花板到底在哪里?
Forge 这轮更新给出的答案是:天花板不在平台能做什么,而在于平台做不到的时候,你有没有出路。
低代码的终局不是消灭代码,而是消灭重复代码。把选择权还给开发者——简单场景配置搞定,复杂场景代码扩展,两者无缝共存。
如果你也在做企业级中后台开发,被重复的 CRUD 和编码规则配置折磨过,不妨试试 Forge Admin。
项目地址:https://gitee.com/ForgeLab/forge-admin
觉得有帮助的话,点个赞收藏起来,也欢迎去项目仓库点个 Star ⭐️ 支持一下~
你对低代码的扩展能力有什么看法?是"平台内置能力够用就行"还是"必须支持自定义代码扩展"?评论区聊聊。