npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@_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.jsonpeerDependencies 为准,包括 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 中的配置(如 portenvauthresourceCacheTimeMs 等)。

常用默认配置示例:

// config/config.default.ts
export default {
  port: 9000,
  auth: {
    ATKey: 'Authorization',
    RTKey: 'RT',
  },
  resourceCacheTimeMs: 5 * 60 * 1000,
}

auth.ATKey / auth.RTKey 用于约定项目鉴权中间件读取短 token 和刷新 token 的 header 名;默认分别为 AuthorizationRT

resourceCacheTimeMs 控制生产环境静态资源缓存时间,单位是 ms,默认 5 分钟。

服务端返回给前端的错误消息会按请求头里的语种翻译。当前优先读取 x-tc-language,也兼容 tc_languagetc-languagelanguagelanglocaleaccept-language;如果没取到,默认按英文 en-US 处理。

静态资源挂载顺序为:项目 app/public、框架 app/publicadditionalPublicPaths。Nunjucks 模板查找顺序为:项目 app/public/dist、框架 app/public/distadditionalPublicPaths/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/distnode_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.tsx
import { 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() 之后执行,入参包含 paramsquerypathappctxprops

组件级流式异步数据可以使用 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=TemplateCore

buildSSR() 会构建私有 {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 需要放到 useEffecttypeof 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=demo

projk 对应 models/{modelKey}/project/{projectKey}.ts 里的 projectKey

内置功能

  • Koa 服务启动:serverStart(options)
  • 约定式加载:自动加载 configextendsrouter-schemaservicecontrollermiddlewares、根级 middlewarerouter
  • 双层 app:框架内置 app/ 和项目 app/ 一起加载。
  • 默认项目接口:
    • GET /api/project/model_list
    • GET /api/project/list
    • GET /api/project/:key
  • React Dashboard:内置 /dash 页面入口。
  • 组件预览页面:内置 /ui-components 页面,用于查看和测试框架内置的 UI 组件。
  • 菜单渲染:支持 schemacustomiframesidebar
  • 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 | 构建入口,提供 buildFEbuildBEbuildSSRwatchSSRFiles | | @_tc/template-core/ssr | SSR 类型与运行时辅助,导出 SSRPropsContextSSRPageMetaResolverStreamingRenderuseSuspensePromise | | @_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 默认导出函数的约束类型,用于接收 approuter 并注册路由。 |

示例:

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 RouterFN

model 数据如何配置

model 是后台菜单、项目和 Schema 页面的数据源。框架会读取:

models/{modelKey}/mode.ts
models/{modelKey}/project/{projectKey}.ts

mode.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 model

namedesciconhomePage 都是可选项。Dashboard 页头标题优先使用 desc,没有时回退到 nameicon 可以是图片地址、图片路径、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.tsmodels/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/demo

Dashboard 会根据 projk 请求 /api/project/:key,再按返回的 menu 渲染页面。

Schema 页面的接口约定:

GET    {api}/list   -> 表格列表
GET    {api}        -> 编辑/详情拉取,参数由 fetchKey 决定
POST   {api}        -> 新增
PUT    {api}        -> 编辑
DELETE {api}        -> 删除

$schema::字段名 表示从当前表格行读取字段值,常用于删除按钮参数。

服务端兜底路由守卫

项目可以创建 app/router-guard.tsapp/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/deletecreate(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 注入边界:

  • getDBDatasetDBDatadeleteDBDatalistDBData 这类通用方法内部使用参数绑定,不需要手写 SQL。
  • queryDBrunDBgetDBFirst 是原始 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 getDB

Controller 示例:

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 getProductController

Router 示例:

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 控制挂载顺序,数值越小越早挂载。
  • 默认 level0,框架兜底 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 | | 内置前端组件 | AsyncSelectLanguageSwitchThemeSwitch | | 组件加载辅助 | renderImportComponent | | Dash 扩展类型 | DashRoutesExtenderDashRoutesContextDashComponentsMapDashHeaderUserAreaPropsDashRouteGuard | | SchemaPage 类型 | CallComComponentsMapCallComRendererSchemaTableRenderComponentSchemaTableRenderComponentsMap | | SchemaForm 类型 | FormFieldSchemaSchemaFormComponentsMapSchemaFormNamespaceSelectProps | | 请求方法 | apirequestgetpostputpatchdel | | 请求类型 | BaseResponsePageParamsPageResponseRequestConfigResponseConfigAxiosError | | RAF 风格计时器 | rafSetTimeoutrafSetIntervalrafClearTimeoutrafClearIntervalclearRafTimerRafTimerIdRafTimerCallback | | Token 工具 | getAuthTokensetAuthTokenclearAuthTokenlocalKeyMap | | 多语言工具 | useTextgetTexttregisterFrontendI18nResourcesaddLanguageResourcessetLanguagesetFallbackLanguagesetResourcesgetCurrentLanguagegetFallbackLanguagegetSupportedLanguages | | 请求冻结 | apiFreezerStoreuseApiFreezerFreezeStateApiFreezerRequestMatcher | | 事件工具 | eventsInfomerge | | 共享状态 | modeStoreuseModeStoreschemaStoreuseSchemaStoreschemaEventBus |

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.*.labelrowButtons.*.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-CNen-US,也支持传入 options 覆盖语言列表。组件当前是按钮 + 下拉菜单形态,option.label 支持 ReactNode,buttonClassName 可自定义触发按钮样式。组件内部会调用 i18nStore.getState().setLanguage(),因此会沿用 tc_language 的持久化行为。

AJV 校验文案

Schema 表单校验会把 AJV keyword 映射到内置语言 key,覆盖 requiredtypeformatminimummaximumminLengthmaxLengthpatternenumoneOfanyOf 等常见规则。

非必填字段为空时会跳过 AJV 校验,空值包括 undefinednull、空字符串、空数组和空对象。

如果需要扩展更多 AJV 文案,需要在 TemplateCore 源码中补充后重新发布:

  1. frontend/src/defaultPages/SchemaPage/utils/validator.tsformatError() 映射。
  2. frontend/src/language/zh-CN.tsfrontend/src/language/en-US.ts 文案。
  3. frontend/src/language/index.tsfrontendLangKeys

前端主题切换

项目页面可以使用 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 | 受控模式,值为 lightdark。 | | 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 汇总入口包含 useBreadcrumbuseExecuteOnceuseInituseLanguageusePaginationuseRefStateuseWatch

useRefState 支持可选的 delayTiming 参数,传入大于 0 的值时会延迟 state 提交,并复用包内浏览器优先、Node/SSR 自动降级的计时器实现。

前端请求封装详解

框架内置的请求封装通过 @_tc/template-core/fe@_tc/template-core/fe/common/request 对外提供,底层使用原生 fetch API,实现了类似 Axios 的接口和拦截器机制,提供了签名、鉴权、错误处理等能力。

基础配置

  • BASE_URL:固定为 /api
  • timeout:15000ms(15秒)
  • credentials'include',支持跨域携带 cookie
  • window._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
  • 错误提取:自动从响应中提取 messagemsgerrors 字段
  • 超时处理:请求超时会返回当前语言的超时提示
  • 网络错误:网络异常会返回当前语言的网络错误提示

使用示例

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 状态拦截请求;HibernationThawing 状态都会直接放行。解冻时会按最多 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 是请求函数收到的完整参数

注意事项

  • getdel 方法的参数会自动转为 query string
  • postputpatch 方法的参数会作为 request body
  • 所有请求方法都会自动处理错误,无需手动 try-catch(除非需要自定义错误处理)
  • 底层使用原生 fetch API,但提供了类似 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._basePathwindow._projKeywindow._signKeywindow._renderModewindow._isSSRwindow.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/distcjs/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 扫描并复制内置白名单资源扩展,主要覆盖 appconfigmodel 目录。

常见配置:

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 跳转。
  • DashRouteGuardResultstring | undefinedundefined(包括已登录分支的隐式返回)也会打断 Dashboard 内置重定向。
  • 没有自定义 routeGuard 文件时,框架使用兜底空对象,不影响默认 Dashboard 重定向。

扩展 SchemaForm 字段组件

  1. 声明字段类型。
  2. 注册运行时组件。
  3. modelcreateFormOptioneditFormOptionsearchOption 中使用。

类型声明:

// 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 componentsMap

model 中使用:

status: {
  type: 'string',
  label: '状态',
  searchOption: {
    comType: 'businessSelect',
    requestUrl: '/api/status/options',
    valueField: 'value',
    labelField: 'label',
  },
}

扩展 SchemaPage CallCom

CallCom 是 SchemaPage 中的弹窗、抽屉、面板类组件。内置组件有:

  • createForm
  • editForm
  • detailPanel

声明配置类型:

// 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

组件会收到 valuerecordrowIndexfieldKey,以及 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
  • 组件运行时会自动收到 valuerecordrowIndexfieldKeyrenderComponentProps 只配置自定义参数。

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 生成。