@niping9542/create-nova-admin 0.0.0-stage → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -2
- package/bin/index.js +116 -0
- package/package.json +37 -4
- package/src/create-project.js +228 -0
- package/src/presets.js +133 -0
package/README.md
CHANGED
|
@@ -1,3 +1,263 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Create Nova Admin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
使用命令行创建 Vue 3 + TypeScript + Element Plus + Pinia 后台管理项目,请求使用 `@npora/request`,多语言使用 Vue I18n,并同步 Element Plus、Day.js 与页面日期格式。
|
|
4
|
+
|
|
5
|
+
npm 包名:`@niping9542/create-nova-admin`;[生成器源码](https://github.com/ni9542/create-nova-admin)。生成器从 [Npora Admin Template](https://github.com/npora/admin-template) 获取模板,默认使用 `main` 分支,按所选预设生成项目文件。
|
|
6
|
+
|
|
7
|
+
## 在线演示
|
|
8
|
+
|
|
9
|
+
[打开 Npora Admin 展示网站](https://admin-demo.npora.workers.dev)
|
|
10
|
+
|
|
11
|
+
| 账号 | 密码 |
|
|
12
|
+
| ------- | -------- |
|
|
13
|
+
| `admin` | `123456` |
|
|
14
|
+
|
|
15
|
+
以上账号仅用于在线演示和默认测试 API。纯净版、自己部署的后端和正式 SQL 不会自动获得这个账号。
|
|
16
|
+
|
|
17
|
+
## 环境要求
|
|
18
|
+
|
|
19
|
+
- 创建项目和开发前端:Node.js 22.12.0 或更高版本。
|
|
20
|
+
- 检查 Worker 后端:Node.js 22.13.0 或更高版本。
|
|
21
|
+
- 生成项目后,使用其 `package.json` 中 `packageManager` 指定的 pnpm 版本。
|
|
22
|
+
- 在线创建需要能够访问 npm 和 GitHub;完整演示的默认测试 API 也需要网络。
|
|
23
|
+
|
|
24
|
+
## 创建项目
|
|
25
|
+
|
|
26
|
+
### 交互选择
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx @niping9542/create-nova-admin@latest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
按提示输入项目名,选择模板。直接回车使用默认纯净版:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
选择模板:
|
|
36
|
+
1. 纯净后台(默认)
|
|
37
|
+
2. 后台 + Worker 后端
|
|
38
|
+
3. 完整演示
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 三个模板的区别
|
|
42
|
+
|
|
43
|
+
| 内容 | `starter` 纯净版 | `worker` 后端版 | `demo` 完整演示 |
|
|
44
|
+
| -------------------------------- | ---------------- | --------------- | --------------- |
|
|
45
|
+
| 登录页、后台布局、导航与标签页 | 保留 | 保留 | 保留 |
|
|
46
|
+
| 首页 | 核心首页 | 核心首页 | 美化演示首页 |
|
|
47
|
+
| 用户、部门、角色、菜单及权限管理 | 保留 | 保留 | 保留 |
|
|
48
|
+
| 个人中心、公共组件、主题和多语言 | 保留 | 保留 | 保留 |
|
|
49
|
+
| 请求封装与前端 API 定义 | 保留 | 保留 | 保留 |
|
|
50
|
+
| 正式 MySQL 权限 SQL | 保留 | 保留 | 保留 |
|
|
51
|
+
| Worker 后端源码与 SQLite 迁移 | 不生成 | 生成 | 生成 |
|
|
52
|
+
| 表格、表单、下载、缓存等示例页面 | 不生成 | 不生成 | 生成 |
|
|
53
|
+
| 展示站部署辅助 | 不生成 | 不生成 | 生成 |
|
|
54
|
+
|
|
55
|
+
**纯净版包含登录页和首页,也保留完整的核心后台功能。** 它不附带 Worker 后端、示例页面和展示站部署工具,相关依赖、插件和命令也不生成。
|
|
56
|
+
|
|
57
|
+
所有预设使用真实 HTTP 请求,不附带本地 Mock。纯净版的登录流程、动态菜单和系统管理数据需要对接使用者自己的后端;Worker 版提供可独立部署的后端源码,但需要先初始化并运行后端。完整演示默认连接现有测试 API。
|
|
58
|
+
|
|
59
|
+
### 创建纯净版
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npx @niping9542/create-nova-admin@latest my-admin --preset starter
|
|
63
|
+
cd my-admin
|
|
64
|
+
pnpm install
|
|
65
|
+
pnpm dev
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
开发地址默认 `http://localhost:3000`,端口被占用时以终端打印的地址为准。登录页面可以正常打开;完成真实登录并进入首页,需要可用的后端认证接口。
|
|
69
|
+
|
|
70
|
+
### 创建 Worker 版
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npx @niping9542/create-nova-admin@latest my-worker --preset worker
|
|
74
|
+
cd my-worker
|
|
75
|
+
pnpm install
|
|
76
|
+
pnpm dev
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
该预设在纯净版基础上增加 `workers/api/`,后端菜单仅引用核心页面。按生成项目中的 `workers/api/README.md` 完成数据库迁移、密钥与账号初始化,并配置可访问的后端地址。
|
|
80
|
+
|
|
81
|
+
`pnpm dev` 只启动前端,不会自动启动或部署 Worker。根目录的 `pnpm run check:backend` 用于检查后端构建与测试。
|
|
82
|
+
|
|
83
|
+
### 创建完整演示
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npx @niping9542/create-nova-admin@latest my-demo --preset demo
|
|
87
|
+
cd my-demo
|
|
88
|
+
pnpm install
|
|
89
|
+
pnpm dev:demo
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
兼容快捷选项:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npx @niping9542/create-nova-admin@latest my-demo --demo
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
完整演示默认使用测试 API,可使用前面的演示账号。`--demo` 等同于 `--preset demo`,不能同时指定其他预设。
|
|
99
|
+
|
|
100
|
+
## 接入自己的后端
|
|
101
|
+
|
|
102
|
+
在创建时指定接口地址:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
npx @niping9542/create-nova-admin@latest my-admin --preset starter --api-url https://api.example.com
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
也可以创建后修改生成项目中的 `.env.development.local`:
|
|
109
|
+
|
|
110
|
+
```dotenv
|
|
111
|
+
VITE_API_URL=https://api.example.com
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
修改后重启开发服务。默认 API 前缀为 `/api`,开发代理会移除该前缀后转发给后端。此地址只配置开发代理;生产环境需要由站点服务器将 `/api` 转发到实际后端。
|
|
115
|
+
|
|
116
|
+
前端 API 定义位于生成项目的 `src/api/`,接口契约见其中的 `docs/api.md`;后端响应格式有差异时,集中调整 `src/api/protocol/`。
|
|
117
|
+
|
|
118
|
+
完整演示指定自定义 API 后,生成器会写入接口地址并启用演示页面,此时使用打印的 `pnpm dev` 命令启动:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npx @niping9542/create-nova-admin@latest my-demo --preset demo --api-url https://api.example.com
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## 生成项目的目录
|
|
125
|
+
|
|
126
|
+
以下是创建后的后台项目目录,不是本生成器仓库的目录。
|
|
127
|
+
|
|
128
|
+
| 目录 | 用途 | 包含的预设 |
|
|
129
|
+
| ----------------- | ---------------------------------------------------------- | ---------------- |
|
|
130
|
+
| `src/` | 登录、首页、布局、权限、系统管理、状态管理、请求与公共组件 | 全部 |
|
|
131
|
+
| `public/` | 网站图标等原样输出的静态资源 | 全部 |
|
|
132
|
+
| `build/` | 公共构建配置和启动加载层 | 全部 |
|
|
133
|
+
| `database/mysql/` | 正式权限表结构与基础授权 SQL | 全部 |
|
|
134
|
+
| `scripts/` | 所选预设需要的开发与检查工具 | 全部 |
|
|
135
|
+
| `tests/` | 前端核心回归与测试辅助 | 全部 |
|
|
136
|
+
| `docs/` | 与所选预设匹配的目录、开发、接口和权限说明 | 全部 |
|
|
137
|
+
| `workers/api/` | 独立后端源码、SQLite 迁移、初始化脚本及测试 | `worker`、`demo` |
|
|
138
|
+
| `examples/` | 演示首页、示例页面、统计 API 与示例测试 | `demo` |
|
|
139
|
+
| `deploy/` | 展示站部署辅助 | `demo` |
|
|
140
|
+
|
|
141
|
+
生成器按预设准备文件,使用者无需先删除 Worker 或演示目录。`node_modules/` 和 `dist/` 等安装、构建产物在本机生成,不随模板复制。
|
|
142
|
+
|
|
143
|
+
### 正式权限 SQL
|
|
144
|
+
|
|
145
|
+
所有预设都保留以下文件,在空 MySQL 数据库中按顺序导入:
|
|
146
|
+
|
|
147
|
+
1. `database/mysql/01-schema.sql`:用户、部门、角色、菜单及关联表,包含索引与约束。
|
|
148
|
+
2. `database/mysql/02-seed.sql`:基础部门、角色、系统管理菜单、按钮权限和授权关系。
|
|
149
|
+
|
|
150
|
+
使用方法见生成项目的 `database/mysql/README.md`。正式 SQL 不包含演示账号,不替使用者选择密码哈希算法;管理员账号由实际后端初始化。Worker 使用独立的 SQLite 迁移,不能把 MySQL 脚本当作 Worker 迁移执行。
|
|
151
|
+
|
|
152
|
+
### 常用命令
|
|
153
|
+
|
|
154
|
+
在生成的后台项目根目录执行:
|
|
155
|
+
|
|
156
|
+
| 命令 | 用途 | 包含的预设 |
|
|
157
|
+
| ------------------------- | -------------------------------------- | ---------------- |
|
|
158
|
+
| `pnpm dev` | 启动前端,按所选预设与本机配置加载页面 | 全部 |
|
|
159
|
+
| `pnpm build` | 类型检查并构建到 `dist/` | 全部 |
|
|
160
|
+
| `pnpm preview` | 预览构建产物,API 转发仍需自行提供 | 全部 |
|
|
161
|
+
| `pnpm run check` | 格式、类型与核心回归检查 | 全部 |
|
|
162
|
+
| `pnpm run check:backend` | 后端构建与回归检查 | `worker`、`demo` |
|
|
163
|
+
| `pnpm dev:demo` | 启动完整演示,默认连接测试 API | `demo` |
|
|
164
|
+
| `pnpm build:demo` | 类型检查并构建完整演示 | `demo` |
|
|
165
|
+
| `pnpm run check:examples` | 示例页面与路由检查 | `demo` |
|
|
166
|
+
| `pnpm run check:all` | 完整演示仓库回归检查 | `demo` |
|
|
167
|
+
|
|
168
|
+
## 命令行选项
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
npx @niping9542/create-nova-admin@latest --help
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
| 选项 | 说明 |
|
|
175
|
+
| ---------------------------- | -------------------------------------------------------------------------- |
|
|
176
|
+
| `[project-name]` | 当前目录下的单级项目名,使用小写字母、数字、连字符、下划线或点,不接受路径 |
|
|
177
|
+
| `-y, --yes` | 无交互创建;未指定名称时使用 `nova-admin-app`,未指定预设时使用 `starter` |
|
|
178
|
+
| `--preset <preset>` | 指定 `starter`、`worker` 或 `demo` |
|
|
179
|
+
| `--demo` | `--preset demo` 的快捷方式 |
|
|
180
|
+
| `--api-url <url>` | 自定义 HTTP/HTTPS 后端地址,不接受账号密码、查询参数或片段 |
|
|
181
|
+
| `--ref <ref>` | 模板分支或标签,默认 `main`;目标版本须支持当前预设结构 |
|
|
182
|
+
| `--template-dir <directory>` | 使用本地模板目录,跳过远程模板下载 |
|
|
183
|
+
| `-V, --version` | 显示生成器版本 |
|
|
184
|
+
| `-h, --help` | 显示帮助 |
|
|
185
|
+
|
|
186
|
+
例如,无交互创建默认纯净版:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
npx @niping9542/create-nova-admin@latest my-admin --yes
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`--yes` 可以与 `--preset worker` 或 `--preset demo` 一起使用。非交互终端不会询问模板,未指定预设时使用纯净版;项目名仍须明确提供,或使用 `--yes` 采用默认名称。
|
|
193
|
+
|
|
194
|
+
已存在的目标目录会导致创建失败,不会被覆盖。生成器不自动安装项目依赖、启动后端、执行 SQL 或部署网站,也不会复制模板中的 Git 历史、本机配置、密钥目录及符号链接。
|
|
195
|
+
|
|
196
|
+
## 维护生成器
|
|
197
|
+
|
|
198
|
+
以下操作在本仓库 `create-nova-admin` 中执行。
|
|
199
|
+
|
|
200
|
+
### 本仓库结构
|
|
201
|
+
|
|
202
|
+
| 路径 | 用途 |
|
|
203
|
+
| ----------------------- | -------------------------------------------------- |
|
|
204
|
+
| `bin/index.js` | CLI 入口,处理项目名称、模板选择、参数和提示 |
|
|
205
|
+
| `src/create-project.js` | 模板获取、输入校验、本机配置写入及创建失败后的清理 |
|
|
206
|
+
| `src/presets.js` | 三种预设的文件组合、依赖快照校验及文档选择 |
|
|
207
|
+
| `tests/` | 参数、预设、目录保护和创建流程的回归测试 |
|
|
208
|
+
| `package.json` | npm 包信息、命令入口、依赖与发布文件清单 |
|
|
209
|
+
| `package-lock.json` | 生成器自身依赖的 npm 锁文件 |
|
|
210
|
+
| `README.md` | GitHub 与 npm 展示的使用说明 |
|
|
211
|
+
|
|
212
|
+
npm 包只包含 CLI 入口、生成逻辑、包信息和本说明,不把后台模板源码打进 npm 包。模板在创建项目时获取。
|
|
213
|
+
|
|
214
|
+
### 本机检查
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
npm ci
|
|
218
|
+
npm run check
|
|
219
|
+
npm pack --dry-run
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
如果后台模板和生成器放在同一父目录,可通过本地模板验证:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
node bin/index.js local-admin --yes --preset starter --template-dir ../admin-template
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
本地模板也必须是包含当前预设差异文件的完整模板仓库。
|
|
229
|
+
|
|
230
|
+
### 更新模板与发布
|
|
231
|
+
|
|
232
|
+
- 只修改后台页面或公共组件时,提交并推送 `admin-template` 的模板分支;之后新创建的项目会获取对应分支内容,已经生成的项目不会自动更新。
|
|
233
|
+
- 修改生成器逻辑或本仓库 README 后,需要发布新的 npm 版本,才能更新 npm 用户拿到的生成器及包说明。
|
|
234
|
+
- 修改模板依赖或核心命令后,在 `admin-template` 执行 `pnpm run update:presets`,同步并提交纯净版依赖和锁文件快照。
|
|
235
|
+
|
|
236
|
+
生成器采用手动发布。在本仓库执行:
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
npm version patch --no-git-tag-version
|
|
240
|
+
npm run check
|
|
241
|
+
npm pack --dry-run
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
将本次改动及同步更新的 `package.json`、`package-lock.json` 提交并推送 Git,确认 npm 登录账号具有发布权限后执行:
|
|
245
|
+
|
|
246
|
+
```sh
|
|
247
|
+
npm whoami --registry=https://registry.npmjs.org/
|
|
248
|
+
npm publish --access public --registry=https://registry.npmjs.org/
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
如果登录失效,先执行 `npm login --auth-type=web --registry=https://registry.npmjs.org/`,按终端提示完成认证。
|
|
252
|
+
|
|
253
|
+
发布后检查公开版本:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
npm view @niping9542/create-nova-admin version dist-tags --json --registry=https://registry.npmjs.org/
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
发布命令提示仍在处理时,稍后再查询公开版本。Git 提交和推送不会自动发布 npm,已经发布的版本不能覆盖;重新发布修改后的内容需要递增版本号。
|
|
260
|
+
|
|
261
|
+
## 许可证
|
|
262
|
+
|
|
263
|
+
MIT
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { createProject } from '../src/create-project.js';
|
|
9
|
+
import { PRESETS, resolvePreset } from '../src/presets.js';
|
|
10
|
+
|
|
11
|
+
const pkg = JSON.parse(
|
|
12
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
|
13
|
+
);
|
|
14
|
+
const program = new Command();
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('create-nova-admin')
|
|
18
|
+
.version(pkg.version)
|
|
19
|
+
.description('创建 Vue 3 + TypeScript + Element Plus + Pinia 后台项目')
|
|
20
|
+
.argument('[project-name]', '项目名称,例如 my-admin')
|
|
21
|
+
.option('-y, --yes', '无交互创建;未指定名称时使用 nova-admin-app')
|
|
22
|
+
.option('--preset <preset>', '模板:starter(默认)、worker、demo')
|
|
23
|
+
.option('--demo', '完整演示模板,等同 --preset demo')
|
|
24
|
+
.option('--api-url <url>', '配置自己的 HTTP 后端地址')
|
|
25
|
+
.option('--ref <ref>', '模板分支或标签', 'main')
|
|
26
|
+
.option('--template-dir <directory>', '从本地模板创建,供离线开发和验证')
|
|
27
|
+
.action(async (projectName, options) => {
|
|
28
|
+
let preset = options.preset;
|
|
29
|
+
if (!projectName && !options.yes && !process.stdin.isTTY)
|
|
30
|
+
throw new Error('请提供项目名称,或使用 --yes 采用默认名称。');
|
|
31
|
+
if (
|
|
32
|
+
process.stdin.isTTY &&
|
|
33
|
+
!options.yes &&
|
|
34
|
+
(!projectName || (!preset && !options.demo))
|
|
35
|
+
) {
|
|
36
|
+
const prompt = createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stdout
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
if (!projectName)
|
|
42
|
+
projectName =
|
|
43
|
+
(await prompt.question('项目名称 (nova-admin-app): ')).trim() ||
|
|
44
|
+
'nova-admin-app';
|
|
45
|
+
if (!preset && !options.demo) {
|
|
46
|
+
console.log(
|
|
47
|
+
'选择模板:\n 1. 纯净后台(默认)\n 2. 后台 + Worker 后端\n 3. 完整演示'
|
|
48
|
+
);
|
|
49
|
+
while (!preset) {
|
|
50
|
+
const answer =
|
|
51
|
+
(await prompt.question('请选择 (1): ')).trim() || '1';
|
|
52
|
+
preset = {
|
|
53
|
+
1: 'starter',
|
|
54
|
+
2: 'worker',
|
|
55
|
+
3: 'demo',
|
|
56
|
+
starter: 'starter',
|
|
57
|
+
worker: 'worker',
|
|
58
|
+
demo: 'demo'
|
|
59
|
+
}[answer];
|
|
60
|
+
if (!preset) console.log('请输入 1、2、3 或模板名称。');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
prompt.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
projectName ||= 'nova-admin-app';
|
|
68
|
+
preset = resolvePreset(preset, options.demo);
|
|
69
|
+
const spinner = ora(
|
|
70
|
+
options.templateDir ? '正在准备本地模板…' : '正在获取 Npora Admin 模板…'
|
|
71
|
+
).start();
|
|
72
|
+
try {
|
|
73
|
+
const project = await createProject({
|
|
74
|
+
projectName,
|
|
75
|
+
...options,
|
|
76
|
+
preset,
|
|
77
|
+
apiURL: options.apiUrl
|
|
78
|
+
});
|
|
79
|
+
spinner.succeed('项目已创建');
|
|
80
|
+
console.log(
|
|
81
|
+
chalk.green(
|
|
82
|
+
`\n${project.projectName} 初始化完成(${project.presetLabel})。`
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
console.log(
|
|
86
|
+
`\n cd ${project.projectName}\n pnpm install\n ${project.startCommand}\n`
|
|
87
|
+
);
|
|
88
|
+
console.log(`包管理器版本:${project.packageManager}`);
|
|
89
|
+
if (project.demo)
|
|
90
|
+
console.log('展示需要网络。演示账号:admin,密码:123456。');
|
|
91
|
+
else
|
|
92
|
+
console.log(
|
|
93
|
+
'请先启动自己的后端,或修改 .env.development.local 中的 VITE_API_URL。'
|
|
94
|
+
);
|
|
95
|
+
console.log('正式权限 SQL:database/mysql/01-schema.sql → 02-seed.sql。');
|
|
96
|
+
if (project.preset === 'worker')
|
|
97
|
+
console.log(
|
|
98
|
+
'后端源码:workers/api/;先迁移数据库并初始化账号,再配置自己的 API 地址。'
|
|
99
|
+
);
|
|
100
|
+
console.log(
|
|
101
|
+
`已生成${PRESETS[project.preset]}所需文件,无需手动删除演示目录。`
|
|
102
|
+
);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
spinner.fail('项目创建失败');
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await program.parseAsync(process.argv);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(
|
|
113
|
+
chalk.red(error.message || '创建失败,请检查模板和网络后重试。')
|
|
114
|
+
);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@niping9542/create-nova-admin",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Npora Admin 后台脚手架生成器:核心模式、独立展示与正式权限 SQL",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-nova-admin": "bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vue",
|
|
11
|
+
"admin",
|
|
12
|
+
"cli",
|
|
13
|
+
"scaffolding",
|
|
14
|
+
"nova"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"author": "niping9542",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chalk": "^5.3.0",
|
|
23
|
+
"commander": "^12.1.0",
|
|
24
|
+
"giget": "^3.3.0",
|
|
25
|
+
"ora": "^8.0.1"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.12.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test tests/cli.test.mjs tests/create-project.test.mjs",
|
|
32
|
+
"check": "node --check bin/index.js && node --check src/create-project.js && npm test"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"bin/",
|
|
36
|
+
"src/",
|
|
37
|
+
"README.md"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cp,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readFile,
|
|
7
|
+
rm,
|
|
8
|
+
writeFile
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { downloadTemplate } from 'giget';
|
|
13
|
+
import { composePreset, PRESETS, resolvePreset } from './presets.js';
|
|
14
|
+
|
|
15
|
+
export const TEMPLATE_REPOSITORY = 'github:npora/admin-template';
|
|
16
|
+
|
|
17
|
+
export function validateProjectName(name) {
|
|
18
|
+
if (
|
|
19
|
+
typeof name !== 'string' ||
|
|
20
|
+
name.length > 214 ||
|
|
21
|
+
!/^[a-z0-9][a-z0-9._-]*$/.test(name) ||
|
|
22
|
+
['node_modules', 'favicon.ico'].includes(name) ||
|
|
23
|
+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'项目名请使用小写字母、数字、连字符、下划线或点,不能是路径或保留名称。'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeApiURL(value) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(value);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error('后端地址必须是完整的 http:// 或 https:// URL。');
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
41
|
+
url.username ||
|
|
42
|
+
url.password ||
|
|
43
|
+
url.search ||
|
|
44
|
+
url.hash
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'后端地址只支持 HTTP/HTTPS,不能包含账号、密码、查询参数或片段。'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return url.toString().replace(/\/$/, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const excluded = new Set([
|
|
54
|
+
'.git',
|
|
55
|
+
'.agents',
|
|
56
|
+
'.codex',
|
|
57
|
+
'.aws',
|
|
58
|
+
'.idea',
|
|
59
|
+
'node_modules',
|
|
60
|
+
'dist',
|
|
61
|
+
'dist-ssr',
|
|
62
|
+
'.cloudflare',
|
|
63
|
+
'.wrangler',
|
|
64
|
+
'coverage',
|
|
65
|
+
'.pnpm-store',
|
|
66
|
+
'.DS_Store',
|
|
67
|
+
'report.html'
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
async function copyTemplate(source, destination) {
|
|
71
|
+
await cp(source, destination, {
|
|
72
|
+
recursive: true,
|
|
73
|
+
force: false,
|
|
74
|
+
errorOnExist: true,
|
|
75
|
+
filter: async (entry) => {
|
|
76
|
+
const name = path.basename(entry);
|
|
77
|
+
return (
|
|
78
|
+
!excluded.has(name) &&
|
|
79
|
+
!name.endsWith('.local') &&
|
|
80
|
+
!name.endsWith('.log') &&
|
|
81
|
+
!(await lstat(entry)).isSymbolicLink()
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function validateTemplate(directory) {
|
|
88
|
+
const required = [
|
|
89
|
+
'src/main.ts',
|
|
90
|
+
'database/mysql/01-schema.sql',
|
|
91
|
+
'database/mysql/02-seed.sql',
|
|
92
|
+
'build/optional-examples.ts',
|
|
93
|
+
'scripts/demo.mjs',
|
|
94
|
+
'scaffold/metadata.json',
|
|
95
|
+
'scaffold/demo/README.md',
|
|
96
|
+
'scaffold/core/package.json',
|
|
97
|
+
'scaffold/core/pnpm-lock.yaml',
|
|
98
|
+
'scaffold/core/vite.config.ts',
|
|
99
|
+
'scaffold/core/src/router/extensions.ts',
|
|
100
|
+
'scaffold/worker/workers/api/src/config/pages.mjs',
|
|
101
|
+
'scaffold/worker/workers/api/migrations/0002_seed.sql'
|
|
102
|
+
];
|
|
103
|
+
for (const file of required) {
|
|
104
|
+
try {
|
|
105
|
+
if (!(await lstat(path.join(directory, file))).isFile())
|
|
106
|
+
throw new Error();
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`模板缺少 ${file},请使用已完成核心/展示分离的新版本模板。`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const pkg = JSON.parse(
|
|
114
|
+
await readFile(path.join(directory, 'package.json'), 'utf8')
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
!pkg.scripts?.dev ||
|
|
118
|
+
!pkg.scripts?.['dev:demo'] ||
|
|
119
|
+
!pkg.packageManager?.startsWith('pnpm@')
|
|
120
|
+
) {
|
|
121
|
+
throw new Error('模板缺少当前 pnpm 开发或展示命令。');
|
|
122
|
+
}
|
|
123
|
+
return pkg;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function createProject({
|
|
127
|
+
projectName,
|
|
128
|
+
cwd = process.cwd(),
|
|
129
|
+
demo = false,
|
|
130
|
+
preset,
|
|
131
|
+
apiURL,
|
|
132
|
+
ref = 'main',
|
|
133
|
+
templateDir,
|
|
134
|
+
download = downloadTemplate
|
|
135
|
+
}) {
|
|
136
|
+
validateProjectName(projectName);
|
|
137
|
+
const selected = resolvePreset(preset, demo);
|
|
138
|
+
demo = selected === 'demo';
|
|
139
|
+
if (
|
|
140
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref) ||
|
|
141
|
+
ref.includes('..') ||
|
|
142
|
+
ref.includes('//') ||
|
|
143
|
+
ref.endsWith('/')
|
|
144
|
+
) {
|
|
145
|
+
throw new Error('模板版本必须是有效的分支或标签名称。');
|
|
146
|
+
}
|
|
147
|
+
const backend = apiURL === undefined ? undefined : normalizeApiURL(apiURL);
|
|
148
|
+
const target = path.resolve(cwd, projectName);
|
|
149
|
+
try {
|
|
150
|
+
await lstat(target);
|
|
151
|
+
throw new Error(
|
|
152
|
+
`目录 ${projectName} 已存在,请换一个项目名;不会覆盖已有内容。`
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.code !== 'ENOENT') throw error;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'create-nova-admin-'));
|
|
159
|
+
const prepared = path.join(temporary, 'prepared');
|
|
160
|
+
let created = false;
|
|
161
|
+
try {
|
|
162
|
+
if (templateDir) {
|
|
163
|
+
const source = path.resolve(cwd, templateDir);
|
|
164
|
+
if (!(await lstat(source)).isDirectory())
|
|
165
|
+
throw new Error('本地模板必须是目录。');
|
|
166
|
+
const relative = path.relative(source, temporary);
|
|
167
|
+
if (
|
|
168
|
+
!relative ||
|
|
169
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
170
|
+
relative !== '..' &&
|
|
171
|
+
!path.isAbsolute(relative))
|
|
172
|
+
) {
|
|
173
|
+
throw new Error('本地模板不能包含生成器的临时工作目录。');
|
|
174
|
+
}
|
|
175
|
+
await copyTemplate(source, prepared);
|
|
176
|
+
} else {
|
|
177
|
+
const downloaded = path.join(temporary, 'downloaded');
|
|
178
|
+
await download(`${TEMPLATE_REPOSITORY}#${ref}`, {
|
|
179
|
+
dir: downloaded,
|
|
180
|
+
force: false,
|
|
181
|
+
registry: false
|
|
182
|
+
});
|
|
183
|
+
await copyTemplate(downloaded, prepared);
|
|
184
|
+
}
|
|
185
|
+
const original = await validateTemplate(prepared);
|
|
186
|
+
const pkg = await composePreset(prepared, selected, original);
|
|
187
|
+
pkg.name = projectName;
|
|
188
|
+
pkg.private = true;
|
|
189
|
+
await writeFile(
|
|
190
|
+
path.join(prepared, 'package.json'),
|
|
191
|
+
`${JSON.stringify(pkg, null, 2)}\n`
|
|
192
|
+
);
|
|
193
|
+
const config = [
|
|
194
|
+
'# 此文件只用于本机开发,不提交版本控制。',
|
|
195
|
+
...(demo ? ['VITE_DEMO_ENABLED=true'] : []),
|
|
196
|
+
...(!demo || backend
|
|
197
|
+
? [`VITE_API_URL=${backend || 'http://localhost:8080'}`]
|
|
198
|
+
: []),
|
|
199
|
+
''
|
|
200
|
+
].join('\n');
|
|
201
|
+
await writeFile(path.join(prepared, '.env.development.local'), config, {
|
|
202
|
+
mode: 0o600
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// 独占创建目标:下载期间出现同名目录时也不覆盖。
|
|
206
|
+
await mkdir(target);
|
|
207
|
+
created = true;
|
|
208
|
+
await copyTemplate(prepared, target);
|
|
209
|
+
// 本机环境只由生成器写入,不从模板源继承。
|
|
210
|
+
await writeFile(path.join(target, '.env.development.local'), config, {
|
|
211
|
+
mode: 0o600
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
directory: target,
|
|
215
|
+
projectName,
|
|
216
|
+
demo,
|
|
217
|
+
preset: selected,
|
|
218
|
+
presetLabel: PRESETS[selected],
|
|
219
|
+
startCommand: demo && !backend ? 'pnpm dev:demo' : 'pnpm dev',
|
|
220
|
+
packageManager: pkg.packageManager
|
|
221
|
+
};
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (created) await rm(target, { recursive: true, force: true });
|
|
224
|
+
throw error;
|
|
225
|
+
} finally {
|
|
226
|
+
await rm(temporary, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { cp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const PRESETS = {
|
|
6
|
+
starter: '纯净后台',
|
|
7
|
+
worker: '后台 + Worker 后端',
|
|
8
|
+
demo: '完整演示'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function resolvePreset(preset, demo = false) {
|
|
12
|
+
const selected = preset ?? (demo ? 'demo' : 'starter');
|
|
13
|
+
if (!Object.hasOwn(PRESETS, selected))
|
|
14
|
+
throw new Error('模板仅支持 starter、worker、demo。');
|
|
15
|
+
if (demo && selected !== 'demo')
|
|
16
|
+
throw new Error('--demo 不能与其他 --preset 同时使用。');
|
|
17
|
+
return selected;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const removeCore = [
|
|
21
|
+
'examples',
|
|
22
|
+
'deploy',
|
|
23
|
+
'cloudflare.config.ts',
|
|
24
|
+
'tsconfig.demo.json',
|
|
25
|
+
'build/optional-examples.ts',
|
|
26
|
+
'src/types/examples.d.ts',
|
|
27
|
+
'scripts/demo.mjs',
|
|
28
|
+
'scripts/check-backend.mjs',
|
|
29
|
+
'scripts/check-standalone.mjs'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Compose data files only; never execute scripts from a downloaded template.
|
|
33
|
+
export async function composePreset(directory, preset, original) {
|
|
34
|
+
const overlays = path.join(directory, 'scaffold');
|
|
35
|
+
if (preset !== 'demo') {
|
|
36
|
+
const metadata = JSON.parse(
|
|
37
|
+
await readFile(path.join(overlays, 'metadata.json'), 'utf8')
|
|
38
|
+
);
|
|
39
|
+
const sourceLockHash = createHash('sha256')
|
|
40
|
+
.update(await readFile(path.join(directory, 'pnpm-lock.yaml')))
|
|
41
|
+
.digest('hex');
|
|
42
|
+
if (metadata.version !== 1 || metadata.sourceLockHash !== sourceLockHash)
|
|
43
|
+
throw new Error(
|
|
44
|
+
'模板的纯净版锁文件快照已过期,请维护者运行 pnpm run update:presets。'
|
|
45
|
+
);
|
|
46
|
+
const core = JSON.parse(
|
|
47
|
+
await readFile(path.join(overlays, 'core/package.json'), 'utf8')
|
|
48
|
+
);
|
|
49
|
+
const expected = { ...original.devDependencies };
|
|
50
|
+
delete expected['@cloudflare/vite-plugin'];
|
|
51
|
+
delete expected.cf;
|
|
52
|
+
const equal = (a, b) =>
|
|
53
|
+
JSON.stringify(Object.entries(a ?? {}).sort()) ===
|
|
54
|
+
JSON.stringify(Object.entries(b ?? {}).sort());
|
|
55
|
+
if (
|
|
56
|
+
!equal(core.dependencies, original.dependencies) ||
|
|
57
|
+
!equal(core.devDependencies, expected) ||
|
|
58
|
+
core.packageManager !== original.packageManager ||
|
|
59
|
+
Object.entries(core.scripts).some(
|
|
60
|
+
([key, value]) => value !== original.scripts[key]
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
throw new Error(
|
|
64
|
+
'模板的纯净版依赖快照已过期,请维护者运行 pnpm run update:presets。'
|
|
65
|
+
);
|
|
66
|
+
for (const entry of removeCore)
|
|
67
|
+
await rm(path.join(directory, entry), { recursive: true, force: true });
|
|
68
|
+
if (preset === 'starter')
|
|
69
|
+
await rm(path.join(directory, 'workers'), {
|
|
70
|
+
recursive: true,
|
|
71
|
+
force: true
|
|
72
|
+
});
|
|
73
|
+
// Keep common contracts, replace repository-specific usage documentation.
|
|
74
|
+
for (const entry of await readdir(path.join(directory, 'docs'))) {
|
|
75
|
+
if (
|
|
76
|
+
!['api.md', 'rbac.md', 'i18n.md', 'deployment.md', 'database'].includes(
|
|
77
|
+
entry
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
await rm(path.join(directory, 'docs', entry), {
|
|
81
|
+
recursive: true,
|
|
82
|
+
force: true
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
await cp(path.join(overlays, 'core'), directory, {
|
|
86
|
+
recursive: true,
|
|
87
|
+
force: true
|
|
88
|
+
});
|
|
89
|
+
if (preset === 'worker') {
|
|
90
|
+
await cp(path.join(overlays, 'worker'), directory, {
|
|
91
|
+
recursive: true,
|
|
92
|
+
force: true
|
|
93
|
+
});
|
|
94
|
+
await rm(path.join(directory, 'workers/api/integration'), {
|
|
95
|
+
recursive: true,
|
|
96
|
+
force: true
|
|
97
|
+
});
|
|
98
|
+
core.scripts['check:backend'] =
|
|
99
|
+
'pnpm --dir workers/api run build && pnpm --dir workers/api test';
|
|
100
|
+
await writeFile(
|
|
101
|
+
path.join(directory, 'package.json'),
|
|
102
|
+
JSON.stringify(core, null, 2) + '\n'
|
|
103
|
+
);
|
|
104
|
+
await writeFile(
|
|
105
|
+
path.join(directory, 'README.md'),
|
|
106
|
+
'\n## 已选择 Worker 后端\n\n项目包含 `workers/api/`,使用前先完成数据库迁移和账号初始化,再配置自己的 API 地址。`pnpm run check:backend` 检查后端。见 [后端说明](workers/api/README.md)。\n',
|
|
107
|
+
{ flag: 'a' }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (preset === 'demo')
|
|
112
|
+
await cp(path.join(overlays, 'demo'), directory, {
|
|
113
|
+
recursive: true,
|
|
114
|
+
force: true
|
|
115
|
+
});
|
|
116
|
+
for (const entry of [
|
|
117
|
+
'scripts/update-presets.mjs',
|
|
118
|
+
'scripts/check-presets.mjs'
|
|
119
|
+
]) {
|
|
120
|
+
await rm(path.join(directory, entry), { force: true });
|
|
121
|
+
}
|
|
122
|
+
await rm(overlays, { recursive: true, force: true });
|
|
123
|
+
const pkg = JSON.parse(
|
|
124
|
+
await readFile(path.join(directory, 'package.json'), 'utf8')
|
|
125
|
+
);
|
|
126
|
+
delete pkg.scripts['update:presets'];
|
|
127
|
+
delete pkg.scripts['check:presets'];
|
|
128
|
+
await writeFile(
|
|
129
|
+
path.join(directory, 'package.json'),
|
|
130
|
+
JSON.stringify(pkg, null, 2) + '\n'
|
|
131
|
+
);
|
|
132
|
+
return pkg;
|
|
133
|
+
}
|