按 owl 体系创建高质量前端模块
本 Skill 与全局规则 coding-standards.mdc 配合使用。完整模板与验证步骤在 owl-ui/docs/ 和 owl-admin-ui/docs/ 中,动手前先读对应文档。
三条路径(先区分再动手)
| 路径 | 含义 | 前端操作 |
|---|---|---|
| A:新建独立子系统(独立前端仓库) | 新业务线,前端与后端分属不同仓库/根目录 | 新 npm 包(如某业务 -ui),在宿主里 createFlexAdmin({ subsystems: [yourSubsystem] }) 注册 |
| C:一体化业务仓库 | SubApp 与前端 同一 Git 仓库,前端在 frontend/ 下 |
在 frontend/<子应用>/(如 frontend/admin/)内维护独立 npm 包与 src/,与仓库根目录 Go app/ 配套;技术栈与路径 A 相同(defineSubsystem),仅物理根路径在业务仓库内 |
| B:扩展 owl-admin | 在现有后台里加功能 | 在 owl-admin-ui 仓库内加 views/xxx + api,菜单 path/component 用本包 viewModulesPathPrefix(如 /system) |
- 路径 A:前端是独立包(仓库可与后端并列),有自己的
defineSubsystem({ name, viewModulesPathPrefix, viewModules, menuContributions }),后端菜单的 path/component 前缀与该包的viewModulesPathPrefix一致(例如/cms),不要在 owl-admin-ui 里加页面。 - 路径 C:与路径 A 同一套 owl-ui 子系统契约(
defineSubsystem、viewModules、api/分层、标准 CRUD 五文件等),区别是:包根目录为<业务仓库>/frontend/<子应用>/,例如owl-workorder/frontend/admin/(包名如@bit-labs.cn/owl-workorder-ui)。不要假设前端在「与 Go 模块根并列的另一个仓库根」;页面与 API 一律写在frontend/<子应用>/src/下。 - 路径 B:前端只在 owl-admin-ui 里加页面,
viewModulesPathPrefix固定为/system,后端菜单 path/component 为/system/xxx/index等形式。
工作流:先读 docs 再动手
路径 A / C — 新建或扩展独立子系统包(含一体化仓库)
先读 owl-ui/docs/01-architecture-and-bootstrap.md、owl-ui/docs/03-subsystem-contract.md、owl-ui/docs/08-minimal-subsystem-template.md;新包需导出 defineSubsystem 并在宿主 createFlexAdmin({ subsystems }) 中注册。
路径 C 仅在 frontend/<子应用>/ 下执行上述包内结构,文档中的 src/ 均指该目录下的 src/(例如 frontend/admin/src/views/...)。
路径 B — 在 owl-admin 中新增模块
先读 owl-admin-ui/docs/02-feature-folder-pattern.md、owl-admin-ui/docs/03-routing-menu-view-contract.md、owl-admin-ui/docs/07-canonical-examples-and-ai-guardrails.md。
前端分层与接线顺序
路径 A / C — 新前端子系统包
- 包结构:
package.json、src/index.ts(defineSubsystem:name、viewModulesPathPrefix、viewModules、routes、menuContributions)、src/routes/index.ts(可选)、src/api/、src/views/(扁平模块目录,如src/views/issue/、src/views/task/)。路径 C 下完整路径为frontend/<子应用>/package.json、frontend/<子应用>/src/...。 - api:在
src/api/下按 「API 层书写规范」 与 「API 文件与目录」 编写;http.request的 URL、method、params/data 与后端一致。 - 标准 CRUD 强制五文件(见下文通用示例):
types.ts、useXList.ts、columns.tsx、XxxForm.vue、index.vue。 - 宿主:安装该包并在
createFlexAdmin({ subsystems: [yourSubsystem] })中注册;后端菜单 path/component 前缀与包内viewModulesPathPrefix一致。 - 路径约束:路径 A / C 下,独立子系统默认禁止再套额外业务前缀目录(例如
src/views/inspection/issue/);只有当子系统内部确实存在多个一级业务域时,才允许新增一层业务域目录。
API 层书写规范(对齐 owl-admin-ui/src/api/role.ts)
- 引用:
import { http } from "@bit-labs.cn/owl-ui/utils/http"; - 形态:按业务域(或资源)声明 class,方法一律为 箭头函数实例属性;文件末尾 单例导出:
export const roleAPI = new RoleAPI();。路径 A / C 的独立子系统包(如独立仓库的asset-manage-ui,或一体化仓库的frontend/admin)同样采用此范式,勿用零散export const xxx = { list: () => ... }对象字面量。 - 泛型:
http.request<Result>(...)表示常规成功包装;分页列表且响应符合router.PageSuccess(data.list+ 根级total/currentPage/pageSize)时用http.request<ResultTable>(...)。 - 参数:GET 查询用第三个参数
{ params };POST/PUT 写 Body 用{ data };与后端 Gin query/json 绑定一致。 - 静默:只读列表、下拉等不希望自动弹错误提示的接口,在第四个参数加
{ silentMessage: true }(与role.ts中getRoles一致)。 - 命名:class 用
PascalCase + API(如RoleAPI);导出实例用 camelCase + API(如roleAPI),页面中import { roleAPI } from "@bit-labs.cn/owl-admin-ui/api/role"或子包内相对路径。
API 文件与目录(路径 A / C 独立子系统强制)
- 禁止把整条业务线或整个子系统的接口全部写进一个
src/api/xxx.ts(单文件数千行、几十个 class 均不允许)。 - 按业务域建子目录:例如
src/api/asset/warehouse.ts、src/api/inspection/task.ts;一个文件一个 class(或强绑定的一小组接口) + 末尾单例导出。 - 桶文件仅 re-export:
src/api/asset/index.ts、src/api/inspection/index.ts等只做export { warehouseApi } from "./warehouse",不写具体http.request。 - 包导出:在子系统
package.json的exports中为api/<域名>显式指向./src/api/<域名>/index.ts,避免通配解析到已删除的扁平api/asset.ts。 - 页面侧 import 仍可为
from "@bit-labs.cn/your-ui/api/asset",由桶文件聚合;需要按文件拆分时也可from "@bit-labs.cn/your-ui/api/asset/warehouse"(需在exports中按需增加子路径,或仅用桶导出二选一,团队统一即可)。
canonical 示例(摘自 owl-admin-ui,新文件请照此结构扩展方法):
import { http } from "@bit-labs.cn/owl-ui/utils/http";
class RoleAPI {
getRoles = (params?: object) => {
return http.request<ResultTable>("get", "/api/v1/roles", { params }, { silentMessage: true });
};
createRole = (data?: object) => {
return http.request<Result>("post", "/api/v1/roles", { data });
};
}
export const roleAPI = new RoleAPI();
路径 B — owl-admin-ui 内新增 CRUD 页
- api:在
src/api/下新增或扩展模块,遵守 「API 层书写规范」。 - types:表单/行数据类型、与后端 JSON 字段名一致;列表 Query 类型键名对齐后端 tag,模糊查询见「查询条件字段映射约定」。
- useXList:列表状态、分页、onSearch、调用上面 api。
- columns:表格列定义,含状态等 cellRenderer。
- Form.vue:弹窗表单,接收
formInline,暴露getRef()与getFormData(),父页在beforeSure中校验再请求。 - index.vue:页面壳、
defineOptions({ name })与菜单 name 一致、PureTableBar(<pure-table>必须放在其作用域默认插槽v-slot="{ size, dynamicColumns }"内,表格绑定:columns="dynamicColumns"和:size="size")、addDialog + contentRenderer(Form)、beforeSure 内 FormRef.validate 后调 api 再 done()。
分页列表响应(router.PageSuccess):http.request 解析后的对象与 JSON 一致——data 仅为 { list: T[] },total、currentPage、pageSize 在根级,与 data 平级。列表赋值用 res.data.list,分页用 res.total 等;不要写成 data?.total 或假定 data 上同时有 list 与 total。
查询条件字段映射约定
前端强约束:
- 页面
reactive(form)里可以用keyword、name等任意 UI 字段名;组装getList的 payload 时,必须映射为后端json/formtag 中的键。 - 禁止仅凭 Go 字段名生成请求键:例如后端为
NameLike+json:"name"时,错误为nameLike;正确为name。 - 时间范围:若后端用
Between,前端应在onSearch里格式化为一个 tag 键(如eventAt: "1700000000,1700086400"),而不是拆成eventAtGte/eventAtLte两个键。
标准 CRUD 禁止写法
- 在
index.vue内定义整段columns: TableColumnList = [...](应放到columns.tsx的createColumns())。 - 在
index.vue内用临时对象/setup+render充当表单组件。 - 在
addDialog的contentRenderer里用h(resolveComponent("el-form"), ...)手搓整页表单(应使用独立XxxForm.vue)。 - 把
reactive(form) + pagination + onSearch + handleSizeChange整段写在index.vue(应放到useXList.ts)。 beforeSure里用options.props.formInline作为提交体(应xxxFormRef.value.getFormData(),并在getRef().validate通过后提交)。- 用单文件
<script setup lang="tsx">替代标准五文件分层(列定义可留在columns.tsx为 TSX,页面壳用lang="ts")。 - 把
<pure-table>放在<PureTableBar>外面(会导致TypeError: slots.default is not a function)。PureTableBar内部通过slots.default({ size, dynamicColumns })渲染表格,必须将<pure-table>放在其作用域默认插槽内,并使用插槽提供的size和dynamicColumns。正确用法见下方index.vue示例中的<template v-slot="{ size, dynamicColumns }">。
页面分类与最小骨架(非标准 CRUD)
| 类型 | 最小文件 |
|---|---|
| 只读列表(无弹窗表单) | index.vue + useXList.ts + columns.tsx;行类型可放 types.ts |
| 详情页 | index.vue + 按需 useXxxDetail.ts + types.ts;复杂区块拆 components/ |
| 创建/向导 | index.vue + useXxxWizard.ts 或步骤子组件;避免与列表混在单文件 |
| 设计器/画布 | 独立入口 *.vue + components/;列表页仍按标准 CRUD 分层 |
| 仪表盘 | index.vue;数据装配复杂时拆 composables/ |
标准 CRUD 通用代码示例(五文件结构)
src/views/issue/types.ts(路径 C 下为 frontend/<子应用>/src/views/issue/types.ts)
export interface IssueFormData {
id?: string;
title: string;
content: string;
status: number;
}
src/views/issue/useIssueList.ts
import { reactive, ref, onMounted, toRaw } from "vue";
import type { PaginationProps } from "@pureadmin/table";
import { issueAPI } from "../../api/issue";
export function useIssueList() {
const form = reactive({ title: "", status: "" });
const dataList = ref([]);
const loading = ref(true);
const pagination = reactive<PaginationProps>({
total: 0, pageSize: 10, currentPage: 1, background: true
});
async function onSearch() {
loading.value = true;
const payload: any = toRaw(form);
payload.page = pagination.currentPage;
payload.pageSize = pagination.pageSize;
const res = await issueAPI.getList(payload);
dataList.value = res?.data?.list ?? [];
pagination.total = res?.total ?? 0;
pagination.pageSize = res?.pageSize ?? pagination.pageSize;
pagination.currentPage = res?.currentPage ?? pagination.currentPage;
loading.value = false;
}
function resetForm(formEl: any) {
formEl?.resetFields();
onSearch();
}
function handleSizeChange(val: number) {
pagination.pageSize = val;
pagination.currentPage = 1;
onSearch();
}
function handleCurrentChange(val: number) {
pagination.currentPage = val;
onSearch();
}
onMounted(() => onSearch());
return { form, dataList, loading, pagination, onSearch, resetForm, handleSizeChange, handleCurrentChange };
}
src/views/issue/columns.tsx
export function createColumns(): TableColumnList {
return [
{ label: "ID", prop: "id", width: 80 },
{ label: "标题", prop: "title", minWidth: 160 },
{
label: "状态", prop: "status", width: 100,
cellRenderer: ({ row }) => (
<el-tag type={row.status === 1 ? "success" : "danger"}>
{row.status === 1 ? "启用" : "禁用"}
</el-tag>
)
},
{ label: "操作", fixed: "right", width: 160, slot: "operation" }
];
}
src/views/issue/IssueForm.vue
<script setup lang="ts">
import { ref } from "vue";
import type { IssueFormData } from "./types";
const props = defineProps<{ formInline: IssueFormData }>();
const ruleFormRef = ref();
const newFormInline = ref<IssueFormData>({
id: props.formInline?.id,
title: props.formInline?.title ?? "",
content: props.formInline?.content ?? "",
status: props.formInline?.status ?? 1
});
const rules = { title: [{ required: true, message: "请输入标题", trigger: "blur" }] };
function getRef() { return ruleFormRef.value; }
defineExpose({ getRef, getFormData: () => newFormInline.value });
</script>
<template>
<el-form ref="ruleFormRef" :model="newFormInline" :rules="rules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="newFormInline.title" placeholder="请输入标题" clearable />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="newFormInline.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="2">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</template>
src/views/issue/index.vue
<script setup lang="ts">
import { ref, h } from "vue";
import { useIssueList } from "./useIssueList";
import { createColumns } from "./columns";
import IssueForm from "./IssueForm.vue";
import type { IssueFormData } from "./types";
import { issueAPI } from "../../api/issue";
import { addDialog } from "@bit-labs.cn/owl-ui/components/ReDialog";
import { PureTableBar } from "@bit-labs.cn/owl-ui/components/RePureTableBar";
defineOptions({ name: "InspectionIssue" }); // 必须与后端菜单 name 一致
const formRef = ref();
const issueFormRef = ref();
const { form, loading, dataList, pagination, onSearch, resetForm, handleSizeChange, handleCurrentChange } = useIssueList();
const columns = createColumns();
function openDialog(title = "新增", row?: IssueFormData) {
addDialog({
title: `${title}问题`,
props: {
formInline: { id: row?.id, title: row?.title ?? "", content: row?.content ?? "", status: row?.status ?? 1 }
},
width: "500px",
contentRenderer: ({ options }) => h(IssueForm, { ref: issueFormRef, formInline: options.props.formInline }),
beforeSure: done => {
const FormRef = issueFormRef.value.getRef();
const curData = issueFormRef.value.getFormData() as IssueFormData;
FormRef.validate((valid: boolean) => {
if (valid) {
const api = curData.id ? issueAPI.update(curData.id, curData) : issueAPI.create(curData);
api.then(() => { done(); onSearch(); });
}
});
}
});
}
function handleDelete(row: { id: string }) {
issueAPI.remove(row.id).then(() => onSearch());
}
</script>
<template>
<div class="main">
<el-form ref="formRef" :inline="true" :model="form" class="search-form">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="标题" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">搜索</el-button>
<el-button @click="resetForm(formRef)">重置</el-button>
</el-form-item>
</el-form>
<PureTableBar :columns="columns" @refresh="onSearch">
<template #buttons>
<el-button type="primary" @click="openDialog()">新增</el-button>
</template>
<template v-slot="{ size, dynamicColumns }">
<pure-table
:data="dataList" :columns="dynamicColumns" :size="size"
:pagination="pagination" :loading="loading"
@size-change="handleSizeChange" @current-change="handleCurrentChange"
>
<template #operation="{ row }">
<el-button link type="primary" @click="openDialog('编辑', row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</pure-table>
</template>
</PureTableBar>
</div>
</template>
前端快速参考
- 路径 A(独立仓库子系统包):包入口导出
defineSubsystem,宿主createFlexAdmin({ subsystems: [sub] })注册;菜单 path/component 前缀与包内viewModulesPathPrefix一致;src/views/使用扁平模块目录;src/api/<业务域>/下多文件 +index.ts桶导出;标准 CRUD 必须用本 Skill 中五文件通用示例的结构。 - 路径 C(一体化业务仓库):与路径 A 规则相同,包根目录为业务仓库下的
frontend/<子应用>/,Go 后端在同仓app/;禁止把前端写到「与业务仓库并列的另一仓库根」或混淆为 owl-admin-ui(路径 B)。 - 路径 B(owl-admin-ui 内加页):在 owl-admin-ui 的
src/views/、src/api/下按文档增加文件;不要在src/routes/index.ts为业务页加静态路由;路由由后端菜单 + 动态注入。 - 通用:
defineOptions({ name })与后端菜单项 name 必须一致;Form 暴露getRef()与getFormData(),父页在 addDialog 的beforeSure中FormRef.validate通过后再请求、再done()。
前端自检清单
- 页面与菜单:页面
defineOptions({ name })与菜单 name 一致。 - 表单:Form 暴露 getRef、
getFormData,beforeSure 中先 validate 再请求。 - API:URL/method/params/data 与后端一致;class + 单例 +
Result/ResultTable泛型 与owl-admin-ui/src/api/role.ts同范式;接口按域拆到src/api/<域>/*.ts,桶文件仅 re-export;列表搜索参数未误用 Go 字段名(如nameLike)代替 tag(如name)。 - 路径 A / C 专属:前端包已导出
defineSubsystem并在宿主中通过createFlexAdmin({ subsystems })注册;后端菜单 path/component 前缀与该包viewModulesPathPrefix一致;标准 CRUD 为五文件分层,无内联 columns/内联表单。路径 C 还须确认文件落在frontend/<子应用>/src/,与同仓app/后端配套。 - 路径 B 专属:未在 owl-admin-ui 的 routes/index.ts 注册业务路由。
- 验证:登录后菜单可见、列表/增删改可通。