@_tc/template-core
v0.3.2
Published
A full-stack TypeScript admin framework package powered by Koa, React, and Vite
Readme
@_tc/template-core
TemplateCore 是一个基于 TypeScript、Koa、React 的后台框架包。它把后端约定式加载、前端 Dashboard、Schema CRUD 页面、models 类型和类型扩展放到同一个 npm 包里,项目只需要按目录补充自己的 app/、models/、frontend/。
预览 & 文档地址:https://t-c-s-p.allomg.qzz.io/preview
安装
pnpm add @_tc/template-core要求:
- Node.js >= 22.13.0
- pnpm >= 8
- 使用内置前端时,需要安装 peer dependencies;完整清单以包内
package.json的peerDependencies为准,包括 Koa、React、Vite、路由、状态、表单和 UI 相关依赖。
快速使用
项目建议结构:
my-admin/
├── app/
│ ├── controller/
│ │ └── product.ts
│ ├── router/
│ │ └── product.ts
│ └── service/
├── config/
│ └── config.default.ts
├── frontend/
│ ├── src/ # 库代码
│ ├── apps/ # 应用入口
│ └── extended/ # 扩展点
├── ssr/
│ └── apps/ # SSR 页面入口,可选
├── models/
│ └── product/
│ ├── mode.ts
│ └── project/
│ └── demo.ts
├── typing/
│ └── template-core.d.ts
└── index.ts启动入口:
import { join } from 'path'
import { serverStart } from '@_tc/template-core'
import { buildFE } from '@_tc/template-core/bundler'
async function main() {
await serverStart({
name: 'my-admin',
baseDir: join(__dirname, '.'),
pageBasePath: '/',
additionalPublicPaths: [join(__dirname, 'dist/public')],
})
await buildFE('dev')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})只用后端能力时,可以不调用 buildFE('dev')。
serverStart 参数说明
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| name | string | 否 | - | 应用名称 |
| baseDir | string | 否 | - | 项目根目录,通常为 join(__dirname, '.') |
| pageBasePath | string | 否 | '/' | 页面访问前缀,所有前端页面路由的基础路径 |
| ssrPageBasePath | string | 否 | '/fessr' | SSR 页面访问前缀,如 /tssr 会让 SSR 页面路由变成 /tssr/:page |
| homePage | string | 否 | - | 应用首页路径 |
| additionalPublicPaths | string | string[] | 否 | - | 追加静态资源目录;如需同时作为 Nunjucks 模板查找目录,建议传绝对路径数组 |
框架会自动读取 config/config.default.ts 中的配置(如 port、env、auth、resourceCacheTimeMs 等)。
常用默认配置示例:
// config/config.default.ts
export default {
port: 9000,
auth: {
ATKey: 'Authorization',
RTKey: 'RT',
},
resourceCacheTimeMs: 5 * 60 * 1000,
}auth.ATKey / auth.RTKey 用于约定项目鉴权中间件读取短 token 和刷新 token 的 header 名;默认分别为 Authorization 和 RT。
resourceCacheTimeMs 控制生产环境静态资源缓存时间,单位是 ms,默认 5 分钟。
服务端返回给前端的错误消息会按请求头里的语种翻译。当前优先读取 x-tc-language,也兼容 tc_language、tc-language、language、lang、locale 和 accept-language;如果没取到,默认按英文 en-US 处理。
静态资源挂载顺序为:项目 app/public、框架 app/public、additionalPublicPaths。Nunjucks 模板查找顺序为:项目 app/public/dist、框架 app/public/dist、additionalPublicPaths/dist。这些目录由 app.publicsPath 统一维护。当 buildFE 输出到自定义目录时,可以用 additionalPublicPaths 把该目录交给 Koa 提供静态资源访问和模板渲染;如果需要模板查找也命中追加目录,请传绝对路径数组。
buildFE 参数说明
buildFE(mode: 'dev' | 'prod', options?: BuildFEOptions)mode:构建模式
'dev':开发模式,构建一次并监听项目frontend/变化后重建'prod':生产模式,构建优化后的静态资源
options.output:构建输出位置
'frame'(默认):输出到当前导入格式对应的框架静态目录,例如node_modules/@_tc/template-core/esm/app/public/dist或node_modules/@_tc/template-core/cjs/app/public/dist'run':输出到项目根目录的app/public/dist,适合 TypeScript 编译后的项目() => string:自定义函数,返回输出目录;建议返回绝对路径
options.minifyHtml:是否压缩 Vite 输出的 HTML 内容后再写出最终 .tpl / .html 文件,默认 false
构建完成后会在输出目录写入 FEBuildKey。服务端渲染 HTML 时会读取 public/dist/FEBuildKey,构建 key 变化后自动清空本地 HTML ETag 缓存。
示例:
// 开发模式,默认输出到框架包
await buildFE('dev')
// 生产模式,输出到项目目录
await buildFE('prod', { output: 'run' })
// 自定义输出路径
await buildFE('prod', {
output: () => join(__dirname, 'dist/public')
})
// 压缩最终模板输出
await buildFE('prod', { minifyHtml: true })SSR 页面(可选)
SSR 入口放在根级 ssr/apps/{page}/{page}.entry.tsx。SSR 和 CSR 并存,SSR 页面默认通过 /fessr/:page 访问,不影响现有 frontend/apps/ 页面。
my-admin/
└── ssr/
└── apps/
└── hello/
└── hello.entry.tsximport { createSSREntry } from '@_tc/template-core/ssr/createSSREntry'
import type { SSRPageMetaResolver, SSRPropsContext } from '@_tc/template-core/ssr'
interface HelloProps {
name: string
}
function HelloPage({ name }: HelloProps) {
return <h1>Hello, {name}</h1>
}
export async function getServerProps(context: SSRPropsContext) {
return { name: (context.query.name as string) || 'SSR' }
}
export const pageTitle: SSRPageMetaResolver<HelloProps> = ({ props }) => `Hello, ${props.name}`
export const pageDescription: SSRPageMetaResolver<HelloProps> = ({ path }) => `SSR hello page at ${path}`
export default HelloPage
createSSREntry(HelloPage)SSR 运行时会通过 React streaming bootstrap 动态 import 客户端入口。页面包含 Suspense 边界时,服务端可以先输出 shell / fallback,浏览器侧先启动 hydration,pending 内容后续继续 stream;如果页面没有 Suspense 边界,hydration 时机通常会接近完整 root HTML 到达之后。完整机制见 Streaming Bootstrap 说明。
pageTitle / pageDescription 可以导出字符串,也可以导出同步或异步函数。函数会在 getServerProps() 之后执行,入参包含 params、query、path、app、ctx 和 props。
组件级流式异步数据可以使用 StreamingRender。它会在服务端 Suspense 中等待 getData(),把结果 JSON 序列化后转成 base64,再随 HTML 片段写入 script;客户端 hydration 时会先解码并复用这份数据。
import { StreamingRender } from '@_tc/template-core/ssr/components'
function OverviewPanel() {
return (
<StreamingRender
keyName="overview"
fallback={<div>Loading...</div>}
getData={loadOverview}
>
{(data) => <OverviewView data={data} />}
</StreamingRender>
)
}keyName 需要在页面内稳定且唯一;getData() 的返回值必须可 JSON 序列化。注入内容会以 base64 形式进入 HTML,避免明文 JSON,但 base64 可逆,不等于加密,不能包含 token、密钥等敏感信息。客户端找不到对应注入数据时也可能执行 getData(),因此数据函数需要兼容浏览器 fallback,或在函数内部显式区分服务端和客户端逻辑。底层 Suspense Promise hook 可从 @_tc/template-core/ssr/hooks 或 @_tc/template-core/ssr/hooks/useSuspensePromise 引入。
启动时构建 SSR 双产物:
import { join } from 'path'
import { serverStart } from '@_tc/template-core'
import { buildSSR, watchSSRFiles } from '@_tc/template-core/bundler'
const baseDir = join(__dirname, '.')
await serverStart({
name: 'my-admin',
baseDir,
ssrPageBasePath: '/fessr',
})
await buildSSR({ baseDir, output: 'run' })
await watchSSRFiles({ baseDir, output: 'run' })访问:
http://localhost:9000/fessr/hello
http://localhost:9000/fessr/hello?name=TemplateCorebuildSSR() 会构建私有 {ssrPrivateRoot}/ssr-server/{page}.mjs、私有 {ssrPrivateRoot}/ssr-manifest/manifest.json,并把 SSR hydration 入口并入 FE browser build。浏览器侧静态资源走普通 /dist/assets/*,可与 CSR 入口共享 chunk;SSR HTML 会根据 manifest 生成 preload,并通过 React bootstrap dynamic import 启动对应客户端入口。服务端会从 {publicPath}/../.tc/ssr 查找 server bundle 和 manifest。/dist/ssr-server/* 和 /dist/.vite/* 会被拦截为 404,避免私有产物或误放文件直连暴露。如果 output 使用自定义目录,需要把对应 public 根目录加入 additionalPublicPaths。
*.entry.tsx 会被 server/client 两次构建处理,顶层代码需要同时兼容 Node 和浏览器。Node-only 逻辑建议放到 *.server.ts,并在 getServerProps() 内动态导入;浏览器 API 需要放到 useEffect 或 typeof window !== 'undefined' 守卫之后。getServerProps() 运行在 Node 侧,不要直接复用依赖 window / localStorage 签名上下文的前端 API 层。
配置文件:
// config/config.default.ts
export default {
port: 9000,
auth: {
ATKey: 'Authorization',
RTKey: 'RT',
},
resourceCacheTimeMs: 5 * 60 * 1000,
}访问默认后台:
http://localhost:9000/dash?projk=demoprojk 对应 models/{modelKey}/project/{projectKey}.ts 里的 projectKey。
内置功能
- Koa 服务启动:
serverStart(options)。 - 约定式加载:自动加载
config、extends、router-schema、service、controller、middlewares、根级middleware、router。 - 双层 app:框架内置
app/和项目app/一起加载。 - 默认项目接口:
GET /api/project/model_listGET /api/project/listGET /api/project/:key
- React Dashboard:内置
/dash页面入口。 - 组件预览页面:内置
/ui-components页面,用于查看和测试框架内置的 UI 组件。 - 菜单渲染:支持
schema、custom、iframe、sidebar。 - Schema CRUD:根据
model中的schemaConfig生成搜索、表格、新增、编辑、详情。 - 内置 UI 组件:提供丰富的 React 组件库,详见下方"内置 UI 组件"章节。
- API 中间件:错误处理、静态资源、body parser、项目上下文、接口签名、参数校验。
- 前端构建:扫描框架和项目的
frontend/**/*.entry.{ts,tsx,js,jsx},输出服务端可渲染的.entry.tpl,并写入FEBuildKey供 HTML ETag 缓存失效使用。 - SSR 页面:扫描框架和项目的
ssr/apps/**/*.entry.{ts,tsx,js,jsx},输出 server/client 双产物并通过/fessr/:page服务端渲染。 - 类型扩展:支持扩展 SchemaForm、SchemaTable 单元格渲染组件、CallCom、KoaApp。
常用公开入口
完整 exports 以发布包内 package.json 为准,除下表外还包含 ./fe/main、./fe/common/request、./ssr、./ssr/createSSREntry、./ssr/components、./ssr/components/StreamingRender、./ssr/hooks、./ssr/hooks/useSuspensePromise、./fe/*、./fe/rc/*、./fe/rc/hooks 等子路径。
| 入口 | 用途 |
| --- | --- |
| @_tc/template-core | 服务启动、基础 Controller/Service、Koa 类型 |
| @_tc/template-core/bundler | 构建入口,提供 buildFE、buildBE、buildSSR、watchSSRFiles |
| @_tc/template-core/ssr | SSR 类型与运行时辅助,导出 SSRPropsContext、SSRPageMetaResolver、StreamingRender、useSuspensePromise |
| @_tc/template-core/ssr/createSSREntry | SSR 页面客户端 hydration 工厂 |
| @_tc/template-core/ssr/components | SSR 组件汇总入口,导出 StreamingRender |
| @_tc/template-core/ssr/components/StreamingRender | StreamingRender 显式子路径 |
| @_tc/template-core/ssr/hooks | SSR hooks 汇总入口,导出 useSuspensePromise |
| @_tc/template-core/ssr/hooks/useSuspensePromise | useSuspensePromise 显式子路径 |
| @_tc/template-core/fe | 前端初始化、动态组件渲染辅助、Dash 路由扩展、请求方法、SchemaPage 事件、token 工具、共享状态和内置前端组件 |
| @_tc/template-core/fe/main | 前端初始化入口 |
| @_tc/template-core/fe/common/request | 请求实例、请求方法和请求类型的显式子路径 |
| @_tc/template-core/fe/rc | UI/SchemaForm 组件和类型 |
| @_tc/template-core/fe/rc/hooks | React hooks 汇总入口 |
| @_tc/template-core/fe/tailwind_ui.css | 前端全局样式入口,源码对应 frontend/src/main.css |
| @_tc/template-core/models | model 配置类型 |
后端类型
@_tc/template-core 会导出常用 Node 侧类型,项目编写 app/* 文件时建议优先复用这些类型。
| 类型 | 使用场景 |
| --- | --- |
| Ctx | Controller、middleware、router handler 的 Koa 请求上下文类型,包含框架补充的 ctx.params 和可选 ctx.reqData。 |
| ControllerFN | app/controller/*.ts 默认导出工厂函数的约束类型,适合配合 satisfies ControllerFN 保留 controller 实例方法类型。 |
| ServiceFN | app/service/*.ts 默认导出工厂函数的约束类型,适合配合 satisfies ServiceFN 约束 service class 工厂。 |
| MiddlewareFN | app/middlewares/*.ts 默认导出工厂函数的约束类型,返回标准 Koa middleware。 |
| RouterFN | app/router/*.ts 默认导出函数的约束类型,用于接收 app 和 router 并注册路由。 |
示例:
import { baseFn, type ControllerFN, type Ctx, type MiddlewareFN, type RouterFN, type ServiceFN } from '@_tc/template-core'
export const getDemoController = ((app) => {
const BaseController = baseFn.baseControllerFn(app)
return class DemoController extends BaseController {
list = async (ctx: Ctx) => {
this.success(ctx, ctx.reqData?.data ?? {})
}
}
}) satisfies ControllerFN
export const getDemoService = ((app) => {
return class DemoService {
readonly app = app
}
}) satisfies ServiceFN
export const demoMiddleware = ((app) => async (ctx, next) => {
app.extends.logger?.info(ctx.path)
await next()
}) satisfies MiddlewareFN
export const demoRouter = ((app, router) => {
router.get(`${app.options.apiPrefix}/demo/list`, app.controller.demo.list)
}) satisfies RouterFNmodel 数据如何配置
model 是后台菜单、项目和 Schema 页面的数据源。框架会读取:
models/{modelKey}/mode.ts
models/{modelKey}/project/{projectKey}.tsmode.ts 定义模型基础配置:
import type { ModelDataType } from '@_tc/template-core/models'
const model: ModelDataType = {
mode: 'MB',
name: '商品后台',
desc: '商品管理',
icon: '',
homePage: '/_sidebar_/product?projk=demo',
menuLayout: 'left',
menu: [
{
key: 'product',
name: '商品列表',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品 ID',
tableOption: { width: 160 },
detailPanelOption: {},
editFormOption: {
comType: 'input',
disabled: true,
},
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {},
searchOption: { comType: 'input' },
createFormOption: { comType: 'input' },
editFormOption: { comType: 'input' },
detailPanelOption: {},
},
price: {
type: 'number',
label: '价格',
tableOption: {},
createFormOption: { comType: 'inputNumber' },
editFormOption: { comType: 'inputNumber' },
detailPanelOption: {},
},
},
required: ['product_name'],
},
componentConfig: {
createForm: {
title: '新增商品',
saveBtnText: '创建',
},
editForm: {
title: '编辑商品',
saveBtnText: '保存',
fetchKey: 'product_id',
},
detailPanel: {
title: '商品详情',
fetchKey: 'product_id',
},
},
tableConfig: {
headerButtons: [
{
label: '新增',
eventKey: 'callComponent',
variant: 'primary',
eventOption: { comName: 'createForm' },
},
],
rowButtons: [
{
label: '编辑',
eventKey: 'callComponent',
eventOption: { comName: 'editForm' },
},
{
label: '详情',
eventKey: 'callComponent',
eventOption: { comName: 'detailPanel' },
},
{
label: '删除',
eventKey: 'remove',
eventOption: {
params: {
product_id: '$schema::product_id',
},
},
},
],
},
},
},
],
}
export default modelname、desc、icon、homePage 都是可选项。Dashboard 页头标题优先使用 desc,没有时回退到 name;icon 可以是图片地址、图片路径、data image 或普通文本。未配置 homePage 时会自动跳到第一个可用菜单;配置时必须命中菜单生成的真实路由,左侧布局通常是 /_sidebar_/{menuKey},顶部布局通常是 /{menuKey}。
project/demo.ts 定义项目覆盖:
export default {
name: 'Demo 企业',
desc: 'Demo 企业商品后台',
homePage: '/_sidebar_/product?projk=demo',
}合并规则:
project会继承同目录上层mode。- 对象递归合并,
project覆盖mode。 - 数组按
key合并:同 key 覆盖,不同 key 新增。 models/index.ts或models/index.js会被 loader 跳过。
model 数组合并示例
假设 mode.ts 定义了两个菜单项:
// models/product/mode.ts
export default {
name: '商品后台',
menu: [
{
key: 'product',
name: '商品列表',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/product',
// ... 省略 schema 配置
},
},
{
key: 'category',
name: '分类管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/category',
// ... 省略 schema 配置
},
},
],
}project/demo.ts 可以:
// models/product/project/demo.ts
export default {
name: 'Demo 企业商品后台',
menu: [
// 1. 覆盖:修改 product 菜单的名称和配置
{
key: 'product',
name: 'Demo 商品', // 覆盖 name
schemaConfig: {
api: '/api/demo/product', // 覆盖 api
},
},
// 2. 新增:添加 brand 菜单
{
key: 'brand',
name: '品牌管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/brand',
},
},
// 3. 继承:category 菜单未在 project 中定义,会完整继承 mode 的配置
],
}合并后的结果:
{
name: 'Demo 企业商品后台', // project 覆盖
menu: [
{
key: 'product',
name: 'Demo 商品', // project 覆盖
menuType: 'module', // mode 继承
moduleType: 'schema', // mode 继承
schemaConfig: {
api: '/api/demo/product', // project 覆盖
// schema 配置会递归合并
},
},
{
key: 'category', // mode 完整继承
name: '分类管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/category',
},
},
{
key: 'brand', // project 新增
name: '品牌管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/brand',
},
},
],
}关键点:
- 数组合并依赖
key字段,确保每个菜单项都有唯一的key - 同
key的项会递归合并,不是简单替换 project中未定义的项会完整继承mode的配置
model 数据如何引用
后端可以直接调用 loader:
import { modelLoader, type KoaApp } from '@_tc/template-core'
export default (app: KoaApp) => {
const modelList = modelLoader(app)
return modelList
}前端默认不直接读文件,而是通过内置接口获取:
GET /api/project/model_list
GET /api/project/list?projk=demo
GET /api/project/demoDashboard 会根据 projk 请求 /api/project/:key,再按返回的 menu 渲染页面。
Schema 页面的接口约定:
GET {api}/list -> 表格列表
GET {api} -> 编辑/详情拉取,参数由 fetchKey 决定
POST {api} -> 新增
PUT {api} -> 编辑
DELETE {api} -> 删除$schema::字段名 表示从当前表格行读取字段值,常用于删除按钮参数。
服务端兜底路由守卫
项目可以创建 app/router-guard.ts 或 app/router-guard.js。它只在所有正常 app/router/* 路由都没有命中时执行。
import type { Ctx, KoaApp } from '@_tc/template-core'
export default (_app: KoaApp) => {
return (ctx: Ctx) => {
if (ctx.path.startsWith('/api/')) {
return {
status: 404,
body: { code: 404, message: `${ctx.path} not found` },
}
}
return '/dash'
}
}返回字符串会 ctx.redirect();返回 { status, body } 会直接设置响应;无返回则使用框架默认兜底:API 路径返回 HTTP 200 + { code: 404, message },非 API 路径返回 HTTP 404 + notFound。
后端约定
项目目录会按下面规则加载:
app/controller/**/*.(js|ts) -> app.controller
app/service/**/*.(js|ts) -> app.service
app/middlewares/**/*.(js|ts) -> app.middlewares
app/middleware.(js|ts) -> 全局中间件编排,按框架 -> 项目顺序执行
app/router/**/*.(js|ts) -> Koa router
app/router-schema/**/*.(js|ts) -> app.routerSchema
app/extends/*.(js|ts) -> app.extends
config/config.default.(js|ts) -> app.config
config/config.{env}.(js|ts) -> app.config
models/**/*.(js|ts) -> 内置 project service 按需读取的项目模型配置后端约定文件的模块格式
TemplateCore 包本身提供 ESM/CJS 入口,业务项目可以用 import 引入包。但上面这些后端约定文件是由 loader 扫描真实文件路径后同步加载的,当前按 require() 语义处理,并只扫描 .js / .ts。
因此后端约定文件需要满足 CommonJS 加载语义:
- 普通 CommonJS 项目可以使用
.js+module.exports。 - TypeScript 项目可以使用
.ts+export default,loader 会把.ts按 CommonJS 方式转译后加载。 - 如果业务项目声明了
"type": "module",不要把.js后端约定文件写成原生 ESM 作为稳定用法,也不要在该作用域下使用.js+module.exports。 "type": "module"项目如需保留.js后端约定文件,请在app/、config/或models/下增加局部package.json:{ "type": "commonjs" }。.mjs、顶层await、依赖原生 ESM 语义的后端约定文件当前不作为支持约定。
推荐分层:启动脚本、构建脚本和前端代码可以使用 ESM;config/、app/、models/ 里的后端约定文件保持 CommonJS 加载语义。
内置扩展:
app.extends.$fetch:Node 侧基于fetch的 axios 风格请求实例,支持get/post/put/patch/delete和create(config)。app.extends.crypto:Node 侧加密辅助方法,支持 base64url 编解码、HMAC 签名、签名 payload 和常量时间比较。app.extends.db:默认 SQLite 框架数据库,提供getDBData/setDBData等通用数据方法。
API controller 可以通过 ctx.reqData 读取统一请求参数:
const { query, body, headers, data } = ctx.reqData ?? {
query: {},
body: undefined,
headers: {},
data: {},
}query来自ctx.query。body来自ctx.request.body。headers来自ctx.headers。data是便捷合并层,当前按query -> body顺序合并;字段冲突时 body 覆盖 query。
ctx.reqData 只在 API 请求中由内置中间件注入,类型上是可选字段。router params 不在 reqData 里,仍然从 ctx.params 获取。
数据库扩展
默认 app.extends.db 使用 SQLite 保存数据,数据库文件位于项目根目录 .template-core/template-core.sqlite。可以在配置中调整路径:
// config/config.default.ts
export default {
db: {
path: 'data/app.sqlite',
},
}Controller 中使用:
import { baseFn, type ControllerFN, type Ctx } from '@_tc/template-core'
const getSettingController = ((app) => {
const BaseController = baseFn.baseControllerFn(app)
return class SettingController extends BaseController {
detail = async (ctx: Ctx) => {
const data = app.extends.db.getDBData('site-setting', {
namespace: 'setting',
})
this.success(ctx, data ?? {})
}
save = async (ctx: Ctx) => {
app.extends.db.setDBData('site-setting', ctx.request.body, {
namespace: 'setting',
})
this.success(ctx, true)
}
}
}) satisfies ControllerFN
export default getSettingController常用方法:
getDBData(key, options):读取 JSON 数据。setDBData(key, value, options):写入 JSON 数据。hasDBData(key, options):判断数据是否存在。deleteDBData(key, options):删除单条数据。listDBData(options):分页列出当前 namespace 下的数据。countDBData(options)/clearDBData(options):统计和清空当前 namespace。queryDB(sql, params)/runDB(sql, params):直接执行 SQLite SQL。transactionDB(handler):事务执行。closeDB():关闭连接。
SQL 注入边界:
getDBData、setDBData、deleteDBData、listDBData这类通用方法内部使用参数绑定,不需要手写 SQL。queryDB、runDB、getDBFirst是原始 SQL 入口,用户输入必须放到params,不要拼接到 SQL 字符串里。execDB没有参数绑定能力,只建议用于固定 SQL,例如建表或迁移。
加密扩展
app.extends.crypto 默认使用 app.config.signKey 作为签名密钥,适合生成登录 token、接口签名和安全比较。
常用方法:
base64urlEncode(value):把字符串或二进制数据编码为 base64url。base64urlDecode(value):把 base64url 解码回 UTF-8 字符串。base64urlDecodeToBuffer(value):把 base64url 解码回 Buffer。hmacSign(payload, options):对文本做 HMAC 签名,默认算法sha256。safeEqual(left, right):常量时间比较字符串。createSignedPayload(payload, options):生成payload.signature形式的签名串。verifySignedPayload(token, options):验证并解析签名串,失败返回null。
const token = app.extends.crypto.createSignedPayload({
account: 'admin',
exp: Date.now() + 1000 * 60 * 60 * 24 * 7,
iat: Date.now(),
})
const payload = app.extends.crypto.verifySignedPayload<{
account: string
exp: number
iat: number
}>(token)
const same = app.extends.crypto.safeEqual('abc', 'abc')// 推荐:参数绑定
const users = app.extends.db.queryDB(
'SELECT * FROM user WHERE name = ?',
[ctx.query.name as string]
)
// 不推荐:拼接用户输入,有 SQL 注入风险
const unsafeUsers = app.extends.db.queryDB(
`SELECT * FROM user WHERE name = '${ctx.query.name}'`
)项目可以用自己的 app/extends/db.ts 覆盖默认实现。建议保留同一组方法名,这样 controller/service 调用方不用调整。
// app/extends/db.ts
import type { DB, DBFactory } from '@_tc/template-core'
interface DataOptions {
namespace?: string
}
const store = new Map<string, unknown>()
const getDB = ((_app) => {
const toKey = (key: string, namespace = 'framework') => `${namespace}:${key}`
const db = {
dbPath: 'memory',
getDBConnection: () => {
throw new Error('memory db 没有底层 SQLite connection')
},
execDB: () => undefined,
runDB: () => ({ changes: 0, lastInsertRowid: 0 }),
queryDB: <T extends object = Record<string, unknown>>() => [] as T[],
getDBFirst: <T extends object = Record<string, unknown>>() => undefined as T | undefined,
transactionDB: <T>(handler: (db: DB) => T) => handler(db),
getDBData: <T = unknown>(key: string, options?: DataOptions) => {
return store.get(toKey(key, options?.namespace)) as T | undefined
},
getDBDataRecord: <T = unknown>(key: string, options?: DataOptions) => {
const value = store.get(toKey(key, options?.namespace)) as T | undefined
if (value === undefined) return undefined
return {
key,
value,
namespace: options?.namespace ?? 'framework',
createdAt: '',
updatedAt: '',
}
},
setDBData: <T = unknown>(key: string, value: T, options?: DataOptions) => {
store.set(toKey(key, options?.namespace), value)
return { changes: 1, lastInsertRowid: 0 }
},
hasDBData: (key: string, options?: DataOptions) => {
return store.has(toKey(key, options?.namespace))
},
deleteDBData: (key: string, options?: DataOptions) => {
return store.delete(toKey(key, options?.namespace))
},
listDBData: <T = unknown>(options?: DataOptions) => {
const namespace = options?.namespace ?? 'framework'
return Array.from(store.entries())
.filter(([key]) => key.startsWith(`${namespace}:`))
.map(([key, value]) => ({
key: key.slice(namespace.length + 1),
value: value as T,
namespace,
createdAt: '',
updatedAt: '',
}))
},
countDBData: () => store.size,
clearDBData: () => {
const size = store.size
store.clear()
return size
},
closeDB: () => undefined,
} satisfies DB
return db
}) satisfies DBFactory
export default getDBController 示例:
import { baseFn, type ControllerFN, type Ctx } from '@_tc/template-core'
const getProductController = ((app) => {
const BaseController = baseFn.baseControllerFn(app)
return class ProductController extends BaseController {
list = async (ctx: Ctx) => {
this.success(ctx, {
data: [],
page: 1,
pageSize: 10,
total: 0,
})
}
}
}) satisfies ControllerFN
export default getProductControllerRouter 示例:
import type { KoaApp, Router } from '@_tc/template-core'
export default (app: KoaApp, router: Router) => {
router.get('/api/product/list', app.controller.product.list)
}路由挂载顺序:
- 每个
app/router/*.ts文件都会获得独立的koa-router实例。 - 可以通过
router.level = number控制挂载顺序,数值越小越早挂载。 - 默认
level是0,框架兜底 router 是99。 - 通配、兜底、重定向类路由建议设置较大的
level,避免抢先匹配项目 API。
import type { KoaApp, Router } from '@_tc/template-core'
export default (app: KoaApp, router: Router) => {
router.level = 10
router.get('/api/fallback/:id', app.controller.fallback.detail)
}前端公共能力
项目侧需要 TemplateCore 前端能力时,优先从 @_tc/template-core/fe 导入,不要直接依赖 frontend/src/common/...、frontend/src/defaultPages/...、frontend/src/stores/... 这类内部路径。
import {
api,
apiFreezerStore,
addLanguageResources,
AsyncSelect,
clearAuthToken,
eventsInfo,
getCurrentLanguage,
getFallbackLanguage,
FreezeState,
get,
getSupportedLanguages,
getAuthToken,
LanguageSwitch,
localKeyMap,
merge,
modeStore,
post,
renderImportComponent,
registerFrontendI18nResources,
request,
schemaEventBus,
schemaStore,
setFallbackLanguage,
setLanguage,
setResources,
setAuthToken,
ThemeSwitch,
t,
useApiFreezer,
useModeStore,
useSchemaStore,
useText,
} from '@_tc/template-core/fe'
import type {
BaseResponse,
CallComComponentsMap,
CallComRenderer,
DashComponentsMap,
DashRouteGuard,
DashRoutesExtender,
FormFieldSchema,
ApiFreezerRequestMatcher,
PageParams,
PageResponse,
RequestConfig,
ResponseConfig,
SchemaTableRenderComponent,
SchemaTableRenderComponentsMap,
SchemaFormComponentsMap,
SchemaFormNamespace,
SelectProps,
} from '@_tc/template-core/fe'当前公共入口包含:
| 类型 | 导出内容 |
| --- | --- |
| 应用启动 | initApp |
| 内置前端组件 | AsyncSelect、LanguageSwitch、ThemeSwitch |
| 组件加载辅助 | renderImportComponent |
| Dash 扩展类型 | DashRoutesExtender、DashRoutesContext、DashComponentsMap、DashHeaderUserAreaProps、DashRouteGuard |
| SchemaPage 类型 | CallComComponentsMap、CallComRenderer、SchemaTableRenderComponent、SchemaTableRenderComponentsMap |
| SchemaForm 类型 | FormFieldSchema、SchemaFormComponentsMap、SchemaFormNamespace、SelectProps |
| 请求方法 | api、request、get、post、put、patch、del |
| 请求类型 | BaseResponse、PageParams、PageResponse、RequestConfig、ResponseConfig、AxiosError |
| RAF 风格计时器 | rafSetTimeout、rafSetInterval、rafClearTimeout、rafClearInterval、clearRafTimer、RafTimerId、RafTimerCallback |
| Token 工具 | getAuthToken、setAuthToken、clearAuthToken、localKeyMap |
| 多语言工具 | useText、getText、t、registerFrontendI18nResources、addLanguageResources、setLanguage、setFallbackLanguage、setResources、getCurrentLanguage、getFallbackLanguage、getSupportedLanguages |
| 请求冻结 | apiFreezerStore、useApiFreezer、FreezeState、ApiFreezerRequestMatcher |
| 事件工具 | eventsInfo、merge |
| 共享状态 | modeStore、useModeStore、schemaStore、useSchemaStore、schemaEventBus |
modeStore 管项目模型和当前项目数据,schemaStore 管当前 Schema 页面的 schema/cache,schemaEventBus 用来在 Search、Table、CallCom 之间通信。
renderImportComponent 封装了 React.lazy + Suspense,默认会用页面骨架屏作为 fallback。getAuthToken()、setAuthToken() 和 clearAuthToken() 用于读写或清理本地短 token,localKeyMap 暴露对应的本地存储 key 常量。
只使用请求层时,也可以从 @_tc/template-core/fe/common/request 导入 api、请求方法和请求类型;这是发布包显式子路径,不要下钻到 frontend/src/common/request。
前端多语言
TemplateCore 前端通过 @_tc/template-core/fe 暴露 i18n 能力。框架内置中文、英文资源,但不会在普通自定义页面 import 前端入口时自动注册,避免非 Dashboard 页面把框架文案整包带入运行时资源。
默认 Dashboard 入口会自动调用 registerFrontendI18nResources(),所以 Dashboard、Schema CRUD、内置页头、语言切换等框架页面可以直接使用框架内置文案。自定义入口如果也要复用这些框架文案,需要在渲染前手动注册:
import { initApp, registerFrontendI18nResources } from '@_tc/template-core/fe'
initApp(App, {
beforeRender: async () => {
await registerFrontendI18nResources()
},
})项目可以继续追加自己的资源或新增语种。
追加自定义语言资源:
import { addLanguageResources } from '@_tc/template-core/fe'
addLanguageResources('zh-CN', {
app: {
product: {
menu: '商品管理',
name: '商品名称',
create: '新增商品',
createTitle: '新增商品',
},
},
})
addLanguageResources('en-US', {
app: {
product: {
menu: 'Products',
name: 'Product name',
create: 'Create product',
createTitle: 'Create product',
},
},
})新增语种并切换:
import { addLanguageResources, setLanguage } from '@_tc/template-core/fe'
addLanguageResources('ja-JP', {
app: {
product: {
menu: '商品管理',
},
},
})
setLanguage('ja-JP')addLanguageResources() 用于追加或新增某个语种的文案,默认会深合并同语种资源;传 { merge: false } 可以替换该语种资源。setResources(resources, { merge: true }) 可批量合并多语种资源。LanguageSwitch 默认会把已注册语种加入选项;需要自定义展示文案时仍可传入 options。
覆盖组件内置文案:
UI 组件库内置文案也在同一份资源里,统一放在 components 命名空间下。项目侧可以覆盖已有文案,也可以给新增语种补齐组件文案:
import { addLanguageResources } from '@_tc/template-core/fe'
addLanguageResources('zh-CN', {
components: {
date: {
placeholder: '选择日期',
placeholderRange: '选择日期区间',
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
monthFormat: 'yyyy年 M月',
},
tableSearch: {
search: '筛选',
reset: '清空',
},
},
})Date 组件除了文案,还依赖 date-fns locale 做月份格式化和周起止计算。新增非内置语种时同步注册:
import { registerDateFnsLocale } from '@_tc/template-core/fe/rc/components/Date'
import { ja } from 'date-fns/locale'
registerDateFnsLocale('ja-JP', ja)React 组件 render 中使用 useText() 读取文案。它会订阅语言和资源变化,调用 setLanguage() 后组件会自动重渲染。
useText() / getText() 遇到 $i18n:: 前缀会先去掉前缀再查语言资源;没有前缀时会直接把传入字符串作为 key 查询,查不到才原样返回:
import { useText } from '@_tc/template-core/fe'
function ProductTitle() {
const text = useText()
return (
<>
<h1>{text('$i18n::app.product.menu')}</h1>
<span>{text('Plain title')}</span>
</>
)
}getText() 是普通函数,适合请求错误、日志、校验工具或事件回调等非 React 场景;它不会主动触发 React 组件重渲染。
资源注册时不写 $i18n:: 前缀,配置中使用时才写:
const title = '$i18n::app.product.createTitle'在 model / Schema 配置中使用:
{
name: '$i18n::app.product.menu',
moduleType: 'schema',
schemaConfig: {
schema: {
type: 'object',
properties: {
name: {
type: 'string',
label: '$i18n::app.product.name',
minLength: 2,
createFormOption: {
comType: 'input',
},
tableOption: {},
searchOption: {},
},
},
required: ['name'],
},
tableConfig: {
headerButtons: [
{
label: '$i18n::app.product.create',
eventKey: 'callComponent',
eventOption: { comName: 'createForm' },
},
],
},
componentConfig: {
createForm: {
title: '$i18n::app.product.createTitle',
saveBtnText: '$i18n::common.submit',
},
},
},
}已支持 $i18n::... 的常用位置:
- 菜单名:
menu.name - 项目标题:
projectInfo.desc或回退的projectInfo.name - Schema 字段名:
schema.properties.*.label - 表格按钮和行按钮:
headerButtons.*.label、rowButtons.*.label - 弹窗、抽屉标题:
componentConfig.*.title - 表单保存按钮:
componentConfig.*.saveBtnText - 接口错误提示:
BaseResponse.message
切换语言:
import { i18nStore } from '@_tc/template-core/fe'
i18nStore.getState().setLanguage('en-US')
i18nStore.getState().setLanguage('zh-CN')默认会写入 localStorage,key 是 tc_language。刷新页面后会继续使用上次语言。
项目页面可以直接使用语言选择组件:
import { LanguageSwitch } from '@_tc/template-core/fe'
export function Toolbar() {
return <LanguageSwitch />
}LanguageSwitch 默认提供 zh-CN 和 en-US,也支持传入 options 覆盖语言列表。组件当前是按钮 + 下拉菜单形态,option.label 支持 ReactNode,buttonClassName 可自定义触发按钮样式。组件内部会调用 i18nStore.getState().setLanguage(),因此会沿用 tc_language 的持久化行为。
AJV 校验文案:
Schema 表单校验会把 AJV keyword 映射到内置语言 key,覆盖 required、type、format、minimum、maximum、minLength、maxLength、pattern、enum、oneOf、anyOf 等常见规则。
非必填字段为空时会跳过 AJV 校验,空值包括 undefined、null、空字符串、空数组和空对象。
如果需要扩展更多 AJV 文案,需要在 TemplateCore 源码中补充后重新发布:
frontend/src/defaultPages/SchemaPage/utils/validator.ts的formatError()映射。frontend/src/language/zh-CN.ts和frontend/src/language/en-US.ts文案。frontend/src/language/index.ts的frontendLangKeys。
前端主题切换
项目页面可以使用 ThemeSwitch 切换浅色和深色模式:
import { ThemeSwitch } from '@_tc/template-core/fe'
export function Toolbar() {
return <ThemeSwitch />
}ThemeSwitch 会切换 document.documentElement 上的 dark class,并同步 color-scheme。默认写入 localStorage,key 是 tc_theme。刷新页面后会优先读取本地主题;没有本地主题时,读取当前根节点是否已有 dark class。默认 Dashboard 会在渲染前调用 initThemeMode(),让已保存主题尽早生效。
常用参数:
| 参数 | 说明 |
| --- | --- |
| value / onChange | 受控模式,值为 light 或 dark。 |
| defaultValue | 非受控初始主题。 |
| showLabel | 是否展示当前主题文本。 |
| persist | 是否写入 localStorage,默认 true。 |
| storageKey | 自定义本地存储 key,默认 tc_theme。 |
如需在自定义入口或组件外手动初始化/切换主题,可以从 @_tc/template-core/fe 导入主题工具:
import { applyThemeMode, getCurrentThemeMode, initThemeMode, themeSwitchStorageKey } from '@_tc/template-core/fe'
initThemeMode()
applyThemeMode('dark', true)RAF 风格计时器通过 @_tc/template-core/fe 对外提供,浏览器优先使用 requestAnimationFrame,缺少 RAF 时自动降级到 setTimeout。
@_tc/template-core/fe/rc 和 @_tc/template-core/fe/rc/hooks 也会随发布包提供对应 UI 与 hooks 入口;hooks 汇总入口包含 useBreadcrumb、useExecuteOnce、useInit、useLanguage、usePagination、useRefState、useWatch。
useRefState 支持可选的 delayTiming 参数,传入大于 0 的值时会延迟 state 提交,并复用包内浏览器优先、Node/SSR 自动降级的计时器实现。
前端请求封装详解
框架内置的请求封装通过 @_tc/template-core/fe 和 @_tc/template-core/fe/common/request 对外提供,底层使用原生 fetch API,实现了类似 Axios 的接口和拦截器机制,提供了签名、鉴权、错误处理等能力。
基础配置:
BASE_URL:固定为/apitimeout:15000ms(15秒)credentials:'include',支持跨域携带 cookiewindow._signKey:由服务端页面模板从app.config.signKey注入- 默认会先设置
Content-Type: application/json;如果是上传文件、FormData或其他内容类型,请在请求配置里显式覆盖
请求拦截器:
每个请求会自动添加以下 headers:
{
's_t': '当前时间戳',
's_sign': 'md5(签名密钥_时间戳)',
'projk': '当前项目 key(从 localStorage 读取)',
'Authorization': 'Bearer token(如果已登录)'
}Authorization 默认对应服务端配置 config.auth.ATKey。前端本地 token 存在 localStorage.auth_token,项目 key 存在 localStorage.p_J_k;刷新 token header 名预留为 config.auth.RTKey,默认是 RT。
签名机制:
- 签名密钥:服务端从
app.config.signKey读取,并在页面渲染时注入为window._signKey - 签名算法:
md5(签名密钥_${时间戳}) - 服务端会在非 local 环境下校验签名和时间戳
响应拦截器:
- 401 处理:自动清空 token 并跳转到
/login - 错误提取:自动从响应中提取
message、msg或errors字段 - 超时处理:请求超时会返回当前语言的超时提示
- 网络错误:网络异常会返回当前语言的网络错误提示
使用示例:
import { api, get, post, put, del } from '@_tc/template-core/fe'
import type { BaseResponse, RequestConfig, ResponseConfig } from '@_tc/template-core/fe'
// GET 请求
const data = await get('/product/list', { page: 1, pageSize: 10 })
// POST 请求
const result = await post('/product', { name: '商品名称', price: 100 })
// PUT 请求
await put('/product', { id: 1, name: '新名称' })
// DELETE 请求
await del('/product', { id: 1 })如果希望把请求能力单独拆出来导入:
import {
api,
get,
post,
request,
} from '@_tc/template-core/fe/common/request'
import type {
BaseResponse,
RequestConfig,
ResponseConfig,
} from '@_tc/template-core/fe/common/request'请求冻结器:
请求冻结器用于在某段流程中暂停普通 API 请求,典型场景是 RT 刷新 token:刷新期间冻结其它请求,刷新接口本身走白名单放行,刷新完成后再解冻并回放队列。
冻结器只在 Freeze 状态拦截请求;Hibernation 和 Thawing 状态都会直接放行。解冻时会按最多 4 个并发回放已冻结的请求;相同序列化参数的请求只会实际发送一次,多个调用方共享同一份响应或错误。
import { apiFreezerStore } from '@_tc/template-core/fe'
const { freezing, thawing, setRequestWhitelist } = apiFreezerStore.getState()
setRequestWhitelist([
'/auth/refresh',
/^\/auth\//,
(api) => api.includes('/refresh-token'),
])
freezing()
try {
await refreshToken()
} finally {
thawing()
}白名单支持三种 matcher:
string:精确匹配 API 路径RegExp:正则匹配 API 路径(api, params) => boolean:自定义判断,params是请求函数收到的完整参数
注意事项:
get和del方法的参数会自动转为 query stringpost、put、patch方法的参数会作为 request body- 所有请求方法都会自动处理错误,无需手动 try-catch(除非需要自定义错误处理)
- 底层使用原生
fetchAPI,但提供了类似 Axios 的拦截器和配置接口
样式与 Tailwind V4
使用内置前端或 UI 组件时,需要引入包内样式。默认 Dashboard 入口已经包含包内全局样式;如果项目侧自定义入口、只使用 fe/rc 组件,或希望样式入口更明确,建议显式引入:
// frontend/xxx/xxx.entry.tsx
import '@_tc/template-core/fe/tailwind_ui.css'如果项目有自己的全局样式入口,也可以在 CSS 中引入:
/* frontend/main.css */
@import "@_tc/template-core/fe/tailwind_ui.css";
/* 扫描项目源码 */
@source "./**/*.{js,ts,jsx,tsx}";
@source "../models/**/*.{js,ts,jsx,tsx}";Tailwind V4 默认会忽略 node_modules 和 .gitignore 中的文件。如果没有使用上面的 tailwind_ui.css,或者项目侧构建链路绕过了 TemplateCore 的样式入口,需要手动把包加入 Tailwind 扫描:
/* frontend/main.css */
@import "tailwindcss";
@source "./**/*.{js,ts,jsx,tsx}";
@source "../models/**/*.{js,ts,jsx,tsx}";
@source "../node_modules/@_tc/template-core";VS Code 建议安装官方 Tailwind CSS IntelliSense 插件,并在工作区 .vscode/settings.json 指向项目 CSS 入口:
{
"tailwindCSS.experimental.configFile": "frontend/main.css",
"files.associations": {
"*.css": "tailwindcss"
},
"css.lint.unknownAtRules": "ignore"
}多入口项目可以写成对象:
{
"tailwindCSS.experimental.configFile": {
"frontend/main.css": "frontend/**/*.{ts,tsx,js,jsx}",
"frontend/admin/admin.css": "frontend/admin/**/*.{ts,tsx,js,jsx}"
}
}内置 UI 组件
框架通过 @_tc/template-core/fe/rc 提供了丰富的 React 组件库,可在项目中直接使用。
基础组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| Button | 按钮组件,支持多种样式和尺寸 | @_tc/template-core/fe/rc |
| Input | 输入框组件,支持清空、右侧附加内容和密码显隐 | @_tc/template-core/fe/rc |
| InputNumber | 数字输入框组件 | @_tc/template-core/fe/rc |
| Textarea | 多行文本输入框组件 | @_tc/template-core/fe/rc |
| Select | 下拉选择组件,支持单选和多选 | @_tc/template-core/fe/rc |
| Search | 搜索输入组件 | @_tc/template-core/fe/rc |
| TreeSelect | 树形选择组件 | @_tc/template-core/fe/rc |
| Dropdown | 下拉菜单组件 | @_tc/template-core/fe/rc |
| DatePicker | 日期选择器 | @_tc/template-core/fe/rc |
| Upload | 文件上传组件 | @_tc/template-core/fe/rc |
| FileUpload | 通用文件上传组件 | @_tc/template-core/fe/rc |
| ImageUpload | 图片上传组件 | @_tc/template-core/fe/rc |
| ImagePreview | 图片预览组件 | @_tc/template-core/fe/rc |
| Checkbox | 复选框组件 | @_tc/template-core/fe/rc |
| Radio | 单选框组件 | @_tc/template-core/fe/rc |
| Switch | 开关组件 | @_tc/template-core/fe/rc |
表单组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| Form | 表单容器组件 | @_tc/template-core/fe/rc |
| SchemaForm | 配置驱动的表单组件,根据 schema 自动生成表单 | @_tc/template-core/fe/rc |
| FormItem | 表单项组件 | @_tc/template-core/fe/rc |
数据展示组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| DataTable | 数据表格组件,支持分页、排序、筛选 | @_tc/template-core/fe/rc |
| Table | 表格组件(含表头、列配置等子组件) | @_tc/template-core/fe/rc |
| TableSearch | 表格搜索组件,配合 DataTable 使用 | @_tc/template-core/fe/rc |
| Pagination | 分页组件 | @_tc/template-core/fe/rc |
| Breadcrumb | 面包屑导航组件 | @_tc/template-core/fe/rc |
| Calendar | 日历组件 | @_tc/template-core/fe/rc |
| TimePicker | 时间选择器组件 | @_tc/template-core/fe/rc |
| Skeleton | 骨架屏加载占位组件 | @_tc/template-core/fe/rc |
反馈组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| Modal | 模态框组件 | @_tc/template-core/fe/rc |
| Drawer | 抽屉组件 | @_tc/template-core/fe/rc |
| message | 消息提示方法 | @_tc/template-core/fe/rc |
| Notification | 通知组件 | @_tc/template-core/fe/rc |
| ConfirmDialog | 确认对话框组件 | @_tc/template-core/fe/rc |
| Popup | 弹出层组件 | @_tc/template-core/fe/rc |
| Tooltip | 文字提示组件 | @_tc/template-core/fe/rc |
| Overlay | 页面遮罩层组件 | @_tc/template-core/fe/rc |
| Loading | 加载状态组件 | @_tc/template-core/fe/rc |
布局组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| Layout | 页面布局组件 | @_tc/template-core/fe/rc |
| Card | 卡片容器组件 | @_tc/template-core/fe/rc |
| Tabs | 标签页组件 | @_tc/template-core/fe/rc |
| Menu | 菜单组件(含 MenuItem 等子组件) | @_tc/template-core/fe/rc |
| Label | 标签组件 | @_tc/template-core/fe/rc |
按钮组件
| 组件 | 用途 | 导入路径 |
| --- | --- | --- |
| Button | 通用按钮组件 | @_tc/template-core/fe/rc |
| AsynchronousButton | 异步按钮组件,支持加载状态 | @_tc/template-core/fe/rc |
| SubmitButton | 表单提交按钮组件 | @_tc/template-core/fe/rc |
| ActionBtn | 操作按钮组件 | @_tc/template-core/fe/rc |
使用示例
import { Button, Input, Select, Modal, DataTable } from '@_tc/template-core/fe/rc'
function MyComponent() {
return (
<div>
<Button variant="primary" onClick={() => console.log('clicked')}>
点击我
</Button>
<Input placeholder="请输入内容" />
<Input type="password" placeholder="请输入密码" />
<Select
options={[
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
]}
/>
</div>
)
}注意:
- 所有组件都支持 TypeScript 类型提示
- 组件样式基于 Tailwind CSS V4
- 详细的组件 API 和示例可以访问
/ui-components页面查看
扩展自定义前端页面
新增应用入口:
frontend/admin/admin.entry.tsx入口文件名必须包含 .entry.,构建器才会扫描。
最小入口示例:
// frontend/admin/admin.entry.tsx
import { initApp } from '@_tc/template-core/fe'
import type { ReactNode } from 'react'
const AdminApp = ({ children }: { children: ReactNode }) => (
<main>{children}</main>
)
initApp(AdminApp, {
initModeData: false,
})如果需要给页面加额外 meta、预连接资源或启动脚本,可以在入口同目录放 .html 插槽文件。这个文件不是完整 HTML,只能包含 tc-slot 块:
<!-- frontend/admin/admin.html -->
<!-- tc-slot:head -->
<meta name="description" content="Admin Console" />
<link rel="preconnect" href="https://example.com" />
<!-- /tc-slot:head -->
<!-- tc-slot:body-before-root -->
<div id="boot-mask"></div>
<!-- /tc-slot:body-before-root -->
<!-- tc-slot:body-after-root -->
<script>
window.__ADMIN_BOOT_TIME__ = Date.now()
</script>
<!-- /tc-slot:body-after-root -->命名规则:
frontend/admin/admin.entry.tsx
frontend/admin/admin.html yes
frontend/report/index.entry.tsx
frontend/report/index.html yes
frontend/report/report.html yes不要在插槽文件里写 <!DOCTYPE html>、<html>、<body> 或 <div id="root"></div>。页面外壳由 TemplateCore 的 app/view/entry.tpl 统一生成,window._basePath、window._projKey、window._signKey、window._renderMode、window._isSSR、window.appOptions 也由它注入。
扩展 Dashboard 路由:
// frontend/extended/dash/customRoutes.tsx
import type { DashRoutesExtender } from '@_tc/template-core/fe'
const extendDashRoutes: DashRoutesExtender = ({ topRoutes, sidebarRoutes }) => {
topRoutes.push({
path: 'custom-page',
component: <div>Custom Page</div>,
})
sidebarRoutes.push({
path: 'custom-sidebar-page',
component: <div>Custom Sidebar Page</div>,
})
}
export default extendDashRoutes路径规则说明:
topRoutes:追加到 Dashboard 顶层路由,路径相对于
/dash- 示例:
path: 'custom-page'→ 访问路径为/dash/custom-page - 不要写成
/custom-page,使用相对路径即可
- 示例:
sidebarRoutes:追加到侧边栏容器的子路由,路径相对于
/dash/_sidebar_- 示例:
path: 'custom-sidebar-page'→ 访问路径为/dash/_sidebar_/custom-sidebar-page - 适用于
menuLayout: 'left'的项目
- 示例:
model 中用 custom 菜单指向自定义路径:
{
key: 'custom',
name: '自定义页面',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/custom-page', // 注意:这里使用完整路径,以 / 开头
},
}前端构建 buildFE
开发构建:
import { buildFE } from '@_tc/template-core/bundler'
await buildFE('dev')生产构建:
await buildFE('prod')默认输出到当前导入格式对应的框架静态目录,例如 esm/app/public/dist 或 cjs/app/public/dist。如果要输出到项目:
await buildFE('prod', {
output: 'run',
})也可以压缩最终模板输出:
await buildFE('prod', {
minifyHtml: true,
})构建完成后会在输出目录写入 FEBuildKey,服务端渲染 HTML 时会用它判断本地 HTML ETag 缓存是否需要清空。
Node/backend 构建 buildBE
消费方 Node/backend 侧构建使用 buildBE()。它复用发布包内的 scripts/vite-build/buildEntries,只是提供消费方默认配置。
最小用法:
import { buildBE } from '@_tc/template-core/bundler'
await buildBE()消费方脚本示例:
// scripts/build-be.mjs
import { buildBE } from '@_tc/template-core/bundler'
await buildBE({
rootDir: process.cwd(),
input: ['index.ts', 'index.js', 'app', 'config', 'models'],
outDir: 'dist',
format: 'cjs',
alias: {
'@app': './app',
'@model': './models',
},
}){
"scripts": {
"build:be": "node scripts/build-be.mjs"
}
}监听文件变化并重新构建:
// scripts/watch-be.mjs
import chokidar from 'chokidar'
import { buildBE } from '@_tc/template-core/bundler'
const input = ['index.ts', 'index.js', 'app', 'config', 'models']
let building = false
let pending = false
async function runBuild() {
if (building) {
pending = true
return
}
building = true
try {
console.log('[buildBE] building...')
await buildBE({
rootDir: process.cwd(),
input,
outDir: 'dist',
format: 'cjs',
alias: {
'@app': './app',
'@model': './models',
},
})
console.log('[buildBE] done')
} catch (error) {
console.error('[buildBE] failed')
console.error(error)
} finally {
building = false
if (pending) {
pending = false
await runBuild()
}
}
}
await runBuild()
chokidar
.watch(input, {
ignored: ['dist/**', 'node_modules/**'],
ignoreInitial: true,
})
.on('all', async (_event, filePath) => {
console.log(`[buildBE] changed: ${filePath}`)
await runBuild()
}){
"scripts": {
"build:be": "node scripts/build-be.mjs",
"build:be:watch": "node scripts/watch-be.mjs"
},
"devDependencies": {
"chokidar": "^4.0.3"
}
}默认会在当前工作目录构建这些入口:
['index.ts', 'index.js', 'app', 'config', 'models']不存在的默认入口会自动跳过;如果没有任何匹配源码,构建仍会失败。输出到 dist,格式为 cjs。默认 outputStructure: "preserve" 按源路径输出,bundleDependencies: false 会 external Node 内置模块和 npm 包,不会把依赖打进产物。buildBE 会按同一组 input 扫描并复制内置白名单资源扩展,主要覆盖 app、config、model 目录。
常见配置:
await buildBE({
input: ['app', 'config', 'models'],
outDir: 'dist',
format: ['es', 'cjs'],
alias: {
'@app': './app',
'@model': './models',
},
})如果需要声明文件:
await buildBE({
dts: {
outDir: 'types',
tsconfig: 'tsconfig.json',
},
})buildEntries() 底层默认生成 d.ts,但 buildBE() 默认关闭声明文件;需要时按上面这样显式传 dts。
如果传入自定义 input,缺失路径默认会报错;需要跳过时显式打开:
await buildBE({
input: ['app', 'config', 'models'],
allowMissingInput: true,
})如何扩展组件
扩展 Dashboard 顶部用户区域
项目可以创建 frontend/extended/dash/components.tsx,通过组件映射填充内置 Dashboard 页头右侧区域。
// frontend/extended/dash/components.tsx
import type { DashComponentsMap } from '@_tc/template-core/fe'
const components = {
'HeaderView.userArea': ({ projectInfo }) => {
return <div>{projectInfo?.name}</div>
},
} satisfies DashComponentsMap
export default components当前支持的 Dashboard 组件扩展槽位:
HeaderView.userArea:Dashboard 顶部右侧区域。
扩展 Dashboard 登录守卫
项目可以创建 frontend/extended/dash/routeGuard.ts,用于在 Dashboard 内置 homePage / 首菜单重定向前做登录判断。
// frontend/extended/dash/routeGuard.ts
import type { DashRouteGuard } from '@_tc/template-core/fe'
const dashRouteGuard: DashRouteGuard = ({ isLoggedIn, isSSR }) => {
if (!isLoggedIn) return isSSR ? '/login?from=ssr' : '/login'
}
export default dashRouteGuard返回值规则:
- 返回
string:框架使用window.location.href跳转,登录页不需要在 Dash 路由下。 - 返回非
string:中断 Dashboard 内置重定向,使用方可自行跳转或处理提示。 - 入参包含
renderMode: 'csr' | 'ssr'和isSSR,可区分 SSR hydration 后的守卫跳转和普通 CSR 跳转。 DashRouteGuardResult是string | undefined;undefined(包括已登录分支的隐式返回)也会打断 Dashboard 内置重定向。- 没有自定义
routeGuard文件时,框架使用兜底空对象,不影响默认 Dashboard 重定向。
扩展 SchemaForm 字段组件
- 声明字段类型。
- 注册运行时组件。
- 在
model的createFormOption、editFormOption、searchOption中使用。
类型声明:
// typing/template-core.d.ts
import '@_tc/template-core/fe/rc'
import type { SelectProps } from '@_tc/template-core/fe/rc'
declare module '@_tc/template-core/fe/rc' {
export namespace SchemaFormNamespace {
interface FieldTypes {
businessSelect: true
}
interface PropsMap {
businessSelect: SelectProps & {
requestUrl?: string
valueField?: string
labelField?: string
}
}
}
}运行时注册:
// frontend/extended/SchemaForm/data.ts
import React from 'react'
import type { SchemaFormComponentsMap } from '@_tc/template-core/fe/rc'
const componentsMap = {
businessSelect: React.lazy(async () => ({
default: (await import('../../components/BusinessSelect')).BusinessSelect,
})),
} satisfies SchemaFormComponentsMap
export default componentsMapmodel 中使用:
status: {
type: 'string',
label: '状态',
searchOption: {
comType: 'businessSelect',
requestUrl: '/api/status/options',
valueField: 'value',
labelField: 'label',
},
}扩展 SchemaPage CallCom
CallCom 是 SchemaPage 中的弹窗、抽屉、面板类组件。内置组件有:
createFormeditFormdetailPanel
声明配置类型:
// typing/template-core.d.ts
import '@_tc/template-core/models'
declare module '@_tc/template-core/models' {
export namespace CallComNamespace {
interface ConfigMap {
auditPanel: {
title: string
fetchKey: string
approveApi?: string
}
}
interface OptionMap {
auditPanel: {
visible?: boolean
}
}
}
}注册运行时组件:
// frontend/extended/SchemaPage/CallCom/data.ts
import { lazy } from 'react'
import type { CallComComponentsMap } from '@_tc/template-core/fe'
const componentsMap = {
auditPanel: lazy(() => import('../../components/AuditPanel')),
} satisfies CallComComponentsMap
export default componentsMap组件示例:
// frontend/src/components/AuditPanel.tsx
import { eventsInfo, merge, schemaEventBus } from '@_tc/template-core/fe'
export default function AuditPanel(props: {
data: Record<string, unknown>
comName: 'auditPanel'
}) {
const closeAndRefresh = () => {
schemaEventBus.getState().emitCom({
type: merge(eventsInfo.closeCom, eventsInfo.initTable),
})
}
return (
<div>
<div>审核对象:{String(props.data.id ?? '')}</div>
<button onClick={closeAndRefresh}>完成</button>
</div>
)
}model 中使用:
componentConfig: {
auditPanel: {
title: '审核',
fetchKey: 'id',
approveApi: '/api/audit/approve',
},
},
tableConfig: {
rowButtons: [
{
label: '审核',
eventKey: 'callComponent',
eventOption: {
comName: 'auditPanel',
},
},
],
},字段级配置使用 ${组件名}Option:
remark: {
type: 'string',
label: '备注',
auditPanelOption: {
visible: true,
},
}扩展 SchemaTable 单元格渲染组件
SchemaPage 表格列支持通过 tableOption.renderComponent 使用内置或自定义注册的渲染组件。内置组件:
PreviewImage:把当前单元格值渲染成图片预览;值是数组时直接作为图片列表,值是单个字符串时自动转成单图数组。
直接使用内置组件:
cover: {
type: 'string',
label: '封面',
tableOption: {
renderComponent: 'PreviewImage',
renderComponentProps: {
width: 48,
height: 48,
},
},
}自定义渲染组件时,先声明 props 类型:
// typing/template-core.d.ts
import '@_tc/template-core/models'
declare module '@_tc/template-core/models' {
export namespace SchemaTableNamespace {
interface RenderComponentPropsMap {
PriceCell: {
value?: unknown
record?: Record<string, unknown>
rowIndex?: number
fieldKey?: string
currency?: string
}
}
}
}注册运行时组件:
// frontend/extended/SchemaPage/SchemaTable/data.ts
import { lazy } from 'react'
import type { SchemaTableRenderComponentsMap } from '@_tc/template-core/fe'
const componentsMap = {
PriceCell: lazy(() => import('../../components/PriceCell')),
} satisfies SchemaTableRenderComponentsMap
export default componentsMap组件会收到 value、record、rowIndex、fieldKey,以及 renderComponentProps 中的自定义参数:
// frontend/components/PriceCell.tsx
export default function PriceCell(props: {
value?: unknown
currency?: string
}) {
return <span>{props.currency ?? '¥'}{Number(props.value ?? 0).toFixed(2)}</span>
}model 中使用:
price: {
type: 'number',
label: '价格',
tableOption: {
renderComponent: 'PriceCell',
renderComponentProps: {
currency: '¥',
},
},
}如何配置类型扩展
tsconfig
如果项目分了后端、前端、model 多个 TS 上下文,确保它们都 include 同一份声明文件:
{
"include": ["app/**/*", "models/**/*", "frontend/**/*", "typing/**/*.d.ts"]
}SchemaForm 类型扩展
扩展目标:
declare module '@_tc/template-core/fe/rc' {
export namespace SchemaFormNamespace {
interface FieldTypes {}
interface PropsMap {}
}
}FieldTypes用来增加字段类型名。PropsMap用来绑定该字段的 props 类型。- 类型声明只负责 TS 校验,运行时还必须在
frontend/extended/SchemaForm/data.ts注册组件。
Model mode 类型扩展
mode 和对应的数据结构一起扩展。内置 MB 对应管理后台菜单结构 MBMenuType。
// typing/template-core.d.ts
import '@_tc/template-core/models'
declare module '@_tc/template-core/models' {
interface ModelModeMap {
portal: {
portalConfig: {
title: string
theme?: 'light' | 'dark'
}
}
}
}使用新 mode:
import type { ModelDataType } from '@_tc/template-core/models'
const model: ModelDataType<'portal'> = {
mode: 'portal',
name: '门户',
desc: '门户配置',
homePage: '/',
portalConfig: {
title: 'Portal',
theme: 'light',
},
}
export default model继续使用内置管理后台时:
import type { ModelDataType } from '@_tc/template-core/models'
const model: ModelDataType<'MB'> = {
mode: 'MB',
name: '商品后台',
desc: '商品管理',
homePage: '/_sidebar_/product?projk=demo',
menu: [],
}CallCom 类型扩展
扩展目标:
declare module '@_tc/template-core/models' {
export namespace CallComNamespace {
interface ConfigMap {}
interface OptionMap {}
}
}ConfigMap对应schemaConfig.componentConfig.xxx。OptionMap对应字段上的xxxOption。- 运行时组件注册在
frontend/extended/SchemaPage/CallCom/data.ts。
SchemaTable 渲染组件类型扩展
扩展目标:
declare module '@_tc/template-core/models' {
export namespace SchemaTableNamespace {
interface RenderComponentPropsMap {}
}
}RenderComponentPropsMap的 key 对应tableOption.renderComponent。- 运行时组件注册在
frontend/extended/SchemaPage/SchemaTable/data.ts。 - 组件运行时会自动收到
value、record、rowIndex、fieldKey;renderComponentProps只配置自定义参数。
KoaApp 类型扩展
使用方推荐增强根包 @_tc/template-core:
// typing/template-core.d.ts
import '@_tc/template-core'
declare module '@_tc/template-core' {
namespace FrameworkAugment {
interface IServiceAugmented {
product: {
list(): Promise<unknown[]>
}
}
interface IControllerAugmented {
product: {
list(ctx: import('@_tc/template-core').Ctx): Promise<void>
}
}
interface IExtendsAugmented {
redis: {
get(key: string): Promise<string | null>
}
}
interface IMiddlewaresAugmented {
auth: import('koa').Middleware
}
interface AppConfigAugmented {
authSecret: string
}
}
}使用:
import type { KoaApp } from '@_tc/template-core'
export default (app: KoaApp) => {
app.config.authSecret
app.service.product.list()
app.extends.$fetch.get('https://example.com/api')
app.extends.redis.get('token')
}如果在 TemplateCore 仓库源码内部扩展,声明目标通常是内部别名 @tc/core;npm 使用方不要依赖 @tc/* 内部路径。
可选:给项目 AI 助手的文档与 Skill
如果你在项目中让 AI 助手协助开发,可以让它优先读取 npm 包内的 AGENT_README.md。安装后的路径通常是 node_modules/@_tc/template-core/AGENT_README.md。这份文档面向“协助使用 TemplateCore 的 AI 助手”,覆盖公开入口、最小启动、目录约定、model 配置、前端使用和构建方式。
包内附带两份 Agent Skill,本质是可复用的 Agent 指令包,采用渐进式披露(SKILL.md + reference/)结构。
| Skill | 用途 |
| --- | --- |
| tc-generator | 按 TemplateCore 约定生成项目、model 配置、Schema CRUD、controller 和 router。 |
| tc-component-usage-skills | 指导 Agent 选用和正确使用内置 React 组件库(Button、Form、DataTable、Modal 等)。 |
如果你的 Agent 支持本地 skills 目录,可以从已安装的 npm 包复制安装。以 Codex 为例:
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
cp -R node_modules/@_tc/template-core/.skills/tc-generator "${CODEX_HOME:-$HOME/.codex}/skills/tc-generator"
cp -R node_modules/@_tc/template-core/.skills/tc-component-usage-skills "${CODEX_HOME:-$HOME/.codex}/skills/tc-component-usage-skills"安装后重启对应 Agent,让新 skill 生效。使用时可以直接说”用 tc-generator 生成一个商品管理模块”或”用 tc-component-usage-skills 帮我写一个带搜索的分页表格”。
其他 Agent 如果不支持自动安装 skill,也可以直接读取 .skills/tc-generator/SKILL.md 或 .skills/tc-component-usage-skills/SKILL.md,再按需读取 reference/ 下的参考文件。
注意事项
- 不要在项目代码里引用
dist/fe/*、dist/models/*这类构建产物路径。 - npm 使用方只使用公开入口:
@_tc/template-core/*。 - SchemaForm 和 CallCom 的类型扩展、运行时注册都要做,少一个就只会“类型通过但页面不显示”或“页面能配但类型报错”。
model数组合并依赖稳定key。- Dashboard 默认需要 URL 带
?projk=项目key。 - Dashboard 登录守卫放在
frontend/extended/dash/routeGuard.ts;登录页通常不在 Dash 路由下,返回登录页 URL 即可。 - 服务端兜底守卫放在项目根目录的
app/router-guard.ts|js。 - 前端入口必须命名为
.entry.tsx、.entry.ts、.entry.jsx或.entry.js。 - 入口同目录的
.html只能作为插槽文件使用,不能写完整 HTML 文档;页面外壳始终由app/view/entry.tpl生成。
