Compare commits
No commits in common. "c071cad50a4f0786f6337629320c89fcca68a894" and "c38d9bdc4f4da72bbd4338f48d1759b243ba4489" have entirely different histories.
c071cad50a
...
c38d9bdc4f
17
README.md
17
README.md
|
|
@ -1,12 +1,18 @@
|
||||||
# TTTK — Tabletop Toolkit
|
# TTRPG Tools
|
||||||
|
|
||||||
A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser.
|
一个基于 `solid.js` 和 `rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 无需安装,直接使用
|
# 安装依赖
|
||||||
npx tttk serve ./content
|
npm install
|
||||||
|
|
||||||
|
# 全局安装 CLI
|
||||||
|
npm link
|
||||||
|
|
||||||
|
# 预览内容
|
||||||
|
ttrpg serve ./content
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
@ -14,7 +20,6 @@ npx tttk serve ./content
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
||||||
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
|
|
||||||
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
||||||
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
||||||
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
|
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
|
||||||
|
|
@ -22,7 +27,7 @@ npx tttk serve ./content
|
||||||
|
|
||||||
## 功能概览
|
## 功能概览
|
||||||
|
|
||||||
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
|
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
|
||||||
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
||||||
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||||
|
|
||||||
|
|
|
||||||
32
docs/cli.md
32
docs/cli.md
|
|
@ -1,18 +1,18 @@
|
||||||
# CLI 使用说明
|
# CLI 使用说明
|
||||||
|
|
||||||
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 无需安装,直接运行
|
# 克隆仓库后安装依赖
|
||||||
npx tttk serve ./content
|
npm install
|
||||||
|
|
||||||
# 或全局安装
|
# 全局链接 CLI 工具
|
||||||
npm install -g tttk
|
npm link
|
||||||
```
|
```
|
||||||
|
|
||||||
安装完成后,可在任意目录使用 `tttk` 命令。
|
安装完成后,可在任意目录使用 `ttrpg` 命令。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ CLI 使用子命令组织:
|
||||||
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
|
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tttk serve [dir] -p 3000
|
ttrpg serve [dir] -p 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
|
|
@ -50,13 +50,13 @@ tttk serve [dir] -p 3000
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 预览当前目录
|
# 预览当前目录
|
||||||
tttk serve
|
ttrpg serve
|
||||||
|
|
||||||
# 预览指定目录
|
# 预览指定目录
|
||||||
tttk serve ./my-ttrpg-content
|
ttrpg serve ./my-ttrpg-content
|
||||||
|
|
||||||
# 指定端口
|
# 指定端口
|
||||||
tttk serve ./docs -p 8080
|
ttrpg serve ./docs -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### compile - 编译模式
|
### compile - 编译模式
|
||||||
|
|
@ -64,7 +64,7 @@ tttk serve ./docs -p 8080
|
||||||
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tttk compile [dir] -o ./dist/output
|
ttrpg compile [dir] -o ./dist/output
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数:**
|
**参数:**
|
||||||
|
|
@ -90,13 +90,13 @@ tttk compile [dir] -o ./dist/output
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 编译当前目录
|
# 编译当前目录
|
||||||
tttk compile
|
ttrpg compile
|
||||||
|
|
||||||
# 编译指定目录并输出到指定位置
|
# 编译指定目录并输出到指定位置
|
||||||
tttk compile ./content -o ./build
|
ttrpg compile ./content -o ./build
|
||||||
|
|
||||||
# 编译并部署
|
# 编译并部署
|
||||||
tttk compile ./docs -o ./public && npm run preview
|
ttrpg compile ./docs -o ./public && npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## 输入文件
|
## 输入文件
|
||||||
|
|
@ -162,7 +162,7 @@ my-ttrpg-content/
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 使用其他端口
|
# 使用其他端口
|
||||||
tttk serve -p 8080
|
ttrpg serve -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 编译输出为空
|
### 编译输出为空
|
||||||
|
|
@ -174,5 +174,5 @@ tttk serve -p 8080
|
||||||
ls -R ./content
|
ls -R ./content
|
||||||
|
|
||||||
# 重新编译
|
# 重新编译
|
||||||
tttk compile ./content -o ./dist/output
|
ttrpg compile ./content -o ./dist/output
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ npm test
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
tttk/
|
ttrpg-tools/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── cli/ # CLI 工具源码
|
│ ├── cli/ # CLI 工具源码
|
||||||
│ │ ├── commands/ # 命令实现 (serve, compile)
|
│ │ ├── commands/ # 命令实现 (serve, compile)
|
||||||
|
|
|
||||||
|
|
@ -535,8 +535,7 @@ src/cli/
|
||||||
│ │ └── write-frontmatter.ts
|
│ │ └── write-frontmatter.ts
|
||||||
│ ├── card/
|
│ ├── card/
|
||||||
│ │ └── card-crud.ts
|
│ │ └── card-crud.ts
|
||||||
│ ├── deck-utils.ts
|
│ ├── ensure-deck-preview.ts
|
||||||
│ ├── preview-deck.ts
|
|
||||||
│ └── generate-card-deck.ts
|
│ └── generate-card-deck.ts
|
||||||
└── index.ts
|
└── index.ts
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "tttk",
|
"name": "ttrpg-tools",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content",
|
"description": "A tabletop RPG toolbox based on solid.js and rsbuild",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tttk": "./dist/cli/index.js"
|
"ttrpg": "./dist/cli/index.js",
|
||||||
|
"ttt": "./dist/cli/index.js"
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
|
|
@ -21,7 +22,6 @@
|
||||||
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
||||||
"cli:build": "tsc -p tsconfig.cli.json",
|
"cli:build": "tsc -p tsconfig.cli.json",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepare": "npx husky"
|
"prepare": "npx husky"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import type { CompileCommandHandler } from "../types.js";
|
import type { CompileCommandHandler } from '../types.js';
|
||||||
|
|
||||||
export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
||||||
console.log("开始编译...");
|
console.log(`开始编译...`);
|
||||||
console.log(`源目录:${dir}`);
|
console.log(`目录:${dir}`);
|
||||||
console.log(`输出目录:${options.output}`);
|
console.log(`输出目录:${options.output}`);
|
||||||
|
|
||||||
// TODO: 实现编译逻辑
|
// TODO: 实现编译逻辑
|
||||||
// 1. 扫描目录下的所有 .md 文件
|
// 1. 扫描目录下的所有 .md 文件
|
||||||
// 2. 解析 markdown 并生成路由
|
// 2. 解析 markdown 并生成路由
|
||||||
// 3. 打包为带 hash 路由的单个 HTML 入口
|
// 3. 打包为带 hash 路由的单个 HTML 入口
|
||||||
|
|
||||||
console.log("编译完成!");
|
console.log('编译完成!');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,14 @@ interface ContentIndex {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录)
|
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹)
|
||||||
*/
|
*/
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIME 类型映射表
|
* MIME 类型映射
|
||||||
*/
|
*/
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
|
|
@ -48,7 +48,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据文件扩展名获取对应的 MIME 类型
|
* 获取文件扩展名对应的 MIME 类型
|
||||||
*/
|
*/
|
||||||
function getMimeType(filePath: string): string {
|
function getMimeType(filePath: string): string {
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
|
@ -56,7 +56,7 @@ function getMimeType(filePath: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost)
|
* Get the best network IP for display (prefer LAN IPv4, fallback to localhost).
|
||||||
*/
|
*/
|
||||||
function getBestIP(): string {
|
function getBestIP(): string {
|
||||||
const interfaces = networkInterfaces();
|
const interfaces = networkInterfaces();
|
||||||
|
|
@ -85,7 +85,7 @@ function getBestIP(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 SVG 字符串中提取 <title> 内容,无则返回 null
|
* Extract the <title> from an SVG string, or null if absent.
|
||||||
*/
|
*/
|
||||||
function extractSvgTitle(svg: string): string | null {
|
function extractSvgTitle(svg: string): string | null {
|
||||||
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
||||||
|
|
@ -93,15 +93,17 @@ function extractSvgTitle(svg: string): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从文件名生成可读标签
|
* Derive a human-readable label from a filename.
|
||||||
* 如 "character-sheet" -> "Character Sheet"
|
* "character-sheet" -> "Character Sheet"
|
||||||
*/
|
*/
|
||||||
function labelFromId(id: string): string {
|
function labelFromId(id: string): string {
|
||||||
return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
return id
|
||||||
|
.replace(/[-_]/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描目录内的 .md 等文件,生成内容索引与块数据
|
* 扫描目录内的 .md 文件,生成索引
|
||||||
*/
|
*/
|
||||||
export function scanDirectory(dir: string): {
|
export function scanDirectory(dir: string): {
|
||||||
index: ContentIndex;
|
index: ContentIndex;
|
||||||
|
|
@ -168,7 +170,7 @@ export function scanDirectory(dir: string): {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送文件响应(流式传输)
|
* 发送文件响应
|
||||||
*/
|
*/
|
||||||
function sendFile(res: ServerResponse, filePath: string) {
|
function sendFile(res: ServerResponse, filePath: string) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|
@ -179,7 +181,7 @@ function sendFile(res: ServerResponse, filePath: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 404 未找到响应
|
* 发送 404 响应
|
||||||
*/
|
*/
|
||||||
function send404(res: ServerResponse) {
|
function send404(res: ServerResponse) {
|
||||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
|
@ -187,7 +189,7 @@ function send404(res: ServerResponse) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 JSON 格式的响应
|
* 发送 JSON 响应
|
||||||
*/
|
*/
|
||||||
function sendJson(res: ServerResponse, data: unknown) {
|
function sendJson(res: ServerResponse, data: unknown) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|
@ -199,7 +201,7 @@ function sendJson(res: ServerResponse, data: unknown) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试提供静态文件
|
* 尝试提供静态文件
|
||||||
* @returns 文件存在并成功发送时返回 true
|
* @returns 如果文件存在并已成功发送则返回 true
|
||||||
*/
|
*/
|
||||||
function tryServeStatic(
|
function tryServeStatic(
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
|
|
@ -222,7 +224,7 @@ function tryServeStatic(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 HTTP 请求处理器
|
* 创建请求处理器
|
||||||
*/
|
*/
|
||||||
function createRequestHandler(
|
function createRequestHandler(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
|
|
@ -272,7 +274,7 @@ function createRequestHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内容服务器实例接口
|
* 内容服务器接口
|
||||||
*/
|
*/
|
||||||
export interface ContentServer {
|
export interface ContentServer {
|
||||||
/**
|
/**
|
||||||
|
|
@ -298,7 +300,7 @@ export interface ContentServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建内容服务器(HTTP + WebSocket + 文件监听)
|
* 创建内容服务器
|
||||||
*/
|
*/
|
||||||
export function createContentServer(
|
export function createContentServer(
|
||||||
contentDir: string,
|
contentDir: string,
|
||||||
|
|
@ -322,7 +324,7 @@ export function createContentServer(
|
||||||
statSheets: [],
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 从当前内容索引和已收集的块重新扫描补全数据 */
|
/** Re-scan completions from current content index and collected blocks */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
|
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -331,7 +333,7 @@ export function createContentServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
// 扫描内容目录生成索引
|
||||||
console.log("正在扫描内容目录...");
|
console.log("扫描内容目录...");
|
||||||
const scanResult = scanDirectory(contentDir);
|
const scanResult = scanDirectory(contentDir);
|
||||||
contentIndex = scanResult.index;
|
contentIndex = scanResult.index;
|
||||||
collectedBlocks = scanResult.blocks;
|
collectedBlocks = scanResult.blocks;
|
||||||
|
|
@ -339,7 +341,7 @@ export function createContentServer(
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
|
|
||||||
// 监听文件变化
|
// 监听文件变化
|
||||||
console.log("正在监听文件变化...");
|
console.log("监听文件变化...");
|
||||||
const watcher = watch(contentDir, {
|
const watcher = watch(contentDir, {
|
||||||
ignored: /(^|[\/\\])\./,
|
ignored: /(^|[\/\\])\./,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
|
|
@ -429,7 +431,7 @@ export function createContentServer(
|
||||||
// ---- Journal / MQTT broker ----
|
// ---- Journal / MQTT broker ----
|
||||||
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
||||||
|
|
||||||
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
|
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
|
||||||
const handleRequest = createRequestHandler(
|
const handleRequest = createRequestHandler(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
|
|
@ -452,7 +454,7 @@ export function createContentServer(
|
||||||
}
|
}
|
||||||
console.log(`内容目录:${contentDir}`);
|
console.log(`内容目录:${contentDir}`);
|
||||||
console.log(`静态资源目录:${distPath}`);
|
console.log(`静态资源目录:${distPath}`);
|
||||||
console.log(`日志连接:ws://${bestIP}:${port}\n`);
|
console.log(`Journal 连接:ws://${bestIP}:${port}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -461,7 +463,7 @@ export function createContentServer(
|
||||||
index: contentIndex,
|
index: contentIndex,
|
||||||
completions: completionsIndex,
|
completions: completionsIndex,
|
||||||
close() {
|
close() {
|
||||||
console.log("正在关闭内容服务器...");
|
console.log("关闭内容服务器...");
|
||||||
server.close();
|
server.close();
|
||||||
watcher.close();
|
watcher.close();
|
||||||
if (journal) journal.close();
|
if (journal) journal.close();
|
||||||
|
|
@ -474,12 +476,6 @@ export function createContentServer(
|
||||||
*/
|
*/
|
||||||
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||||
const contentDir = resolve(dir);
|
const contentDir = resolve(dir);
|
||||||
|
|
||||||
if (!existsSync(contentDir)) {
|
|
||||||
console.error(`错误:目录不存在:${contentDir}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const host = options.host || "0.0.0.0";
|
const host = options.host || "0.0.0.0";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,42 +8,25 @@ import type { ServeOptions, CompileOptions } from "./types.js";
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("tttk")
|
.name("ttrpg")
|
||||||
.description(
|
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
|
||||||
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
|
|
||||||
)
|
|
||||||
.version("0.0.1");
|
.version("0.0.1");
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("serve")
|
.command("serve")
|
||||||
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
|
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
||||||
.argument("[dir]", "要预览的目录", ".")
|
.argument("[dir]", "要预览的目录", ".")
|
||||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||||
.option("--host <host>", "主机地址", "0.0.0.0")
|
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("compile")
|
.command("compile")
|
||||||
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
|
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
|
||||||
.argument("[dir]", "要编译的目录", ".")
|
.argument("[dir]", "要编译的目录", ".")
|
||||||
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
||||||
.action(compileCommand);
|
.action(compileCommand);
|
||||||
|
|
||||||
program.addCommand(mcpCommand);
|
program.addCommand(mcpCommand);
|
||||||
|
|
||||||
// 无子命令时默认执行 serve(即 `npx tttk` 等同于 `npx tttk serve .`)
|
program.parse();
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length > 0) {
|
|
||||||
const first = args[0];
|
|
||||||
const isKnownCommand =
|
|
||||||
!first.startsWith("-") &&
|
|
||||||
program.commands.some(
|
|
||||||
(c) => c.name() === first || c.aliases().includes(first),
|
|
||||||
);
|
|
||||||
if (!isKnownCommand) {
|
|
||||||
args.unshift("serve");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args.unshift("serve");
|
|
||||||
}
|
|
||||||
program.parse([process.argv[0], process.argv[1], ...args]);
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
/**
|
/**
|
||||||
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 会话清单
|
* Journal stream persistence — MQTT broker + JSONL append + session manifest
|
||||||
*
|
*
|
||||||
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
* Runs an aedes MQTT broker over WebSocket, attached to the existing
|
||||||
* 使用 ws v8 内置的 createWebSocketStream。
|
* HTTP server. Uses ws v8's built-in createWebSocketStream.
|
||||||
*
|
*
|
||||||
* 实现参考 aedes 文档:
|
* Pattern from aedes docs:
|
||||||
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
|
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
|
||||||
*
|
*
|
||||||
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
* Persistence uses aedes's internal subscribe/publish API, so the
|
||||||
* 以 MQTT 客户端身份连接自身。
|
* server process doesn't need to connect to itself as an MQTT client.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
|
|
@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
|
||||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 类型定义
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface SessionMeta {
|
interface SessionMeta {
|
||||||
|
|
@ -46,7 +46,7 @@ export interface JournalServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 路径工具函数
|
// Path helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function dataDir(contentDir: string): string {
|
function dataDir(contentDir: string): string {
|
||||||
|
|
@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 服务器工厂
|
// Server factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||||
|
|
@ -98,15 +98,15 @@ export async function createJournalServer(
|
||||||
httpServer: HttpServer,
|
httpServer: HttpServer,
|
||||||
): Promise<JournalServer> {
|
): Promise<JournalServer> {
|
||||||
const root = dataDir(contentDir);
|
const root = dataDir(contentDir);
|
||||||
console.log(`[日志] 数据目录:${root}`);
|
console.log(`[journal] data dir: ${root}`);
|
||||||
|
|
||||||
// ---- MQTT 代理 ----
|
// ---- MQTT Broker ----
|
||||||
const { Aedes: AedesFactory } = await import("aedes");
|
const { Aedes: AedesFactory } = await import("aedes");
|
||||||
// aedes 需要先 listen() 初始化持久化,再处理连接
|
// aedes requires listen() to initialize persistence before handling connections.
|
||||||
const broker = await AedesFactory.createBroker();
|
const broker = await AedesFactory.createBroker();
|
||||||
|
|
||||||
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
|
// ---- WebSocket server (attached to the existing HTTP server) ----
|
||||||
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
|
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
|
||||||
const wss = new WebSocketServer({ server: httpServer });
|
const wss = new WebSocketServer({ server: httpServer });
|
||||||
|
|
||||||
wss.on("connection", (ws, req) => {
|
wss.on("connection", (ws, req) => {
|
||||||
|
|
@ -115,13 +115,13 @@ export async function createJournalServer(
|
||||||
});
|
});
|
||||||
|
|
||||||
broker.on("client", (client: import("aedes").Client) => {
|
broker.on("client", (client: import("aedes").Client) => {
|
||||||
console.log(`[日志] 客户端已连接:${client.id}`);
|
console.log(`[journal] client ready: ${client.id}`);
|
||||||
});
|
});
|
||||||
broker.on("clientDisconnect", (client: import("aedes").Client) => {
|
broker.on("clientDisconnect", (client: import("aedes").Client) => {
|
||||||
console.log(`[日志] 客户端已断开:${client.id}`);
|
console.log(`[journal] client disconnected: ${client.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
|
// ---- Persistence (internal subscribe, no separate MQTT client) ----
|
||||||
broker.subscribe(
|
broker.subscribe(
|
||||||
"ttrpg/+/stream",
|
"ttrpg/+/stream",
|
||||||
(packet, cb) => {
|
(packet, cb) => {
|
||||||
|
|
@ -130,7 +130,7 @@ export async function createJournalServer(
|
||||||
try {
|
try {
|
||||||
appendStream(root, sessionId, packet.payload.toString());
|
appendStream(root, sessionId, packet.payload.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
console.error(`[journal] stream append err for ${sessionId}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cb();
|
cb();
|
||||||
|
|
@ -150,7 +150,7 @@ export async function createJournalServer(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 启动时发布初始清单,让客户端能看到已有会话
|
// Publish the initial manifest on startup so clients see existing sessions
|
||||||
const initialManifest = loadManifest(root);
|
const initialManifest = loadManifest(root);
|
||||||
broker.publish(
|
broker.publish(
|
||||||
{
|
{
|
||||||
|
|
@ -164,14 +164,14 @@ export async function createJournalServer(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[日志] 持久化监听已启动");
|
console.log("[journal] persistence listener active");
|
||||||
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
console.log("[journal] broker available at ws://<host>:<port>");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
broker,
|
broker,
|
||||||
wsServer: wss,
|
wsServer: wss,
|
||||||
async close() {
|
async close() {
|
||||||
console.log("[日志] 正在关闭...");
|
console.log("[journal] shutting down...");
|
||||||
await new Promise<void>((r) => wss.close(() => r()));
|
await new Promise<void>((r) => wss.close(() => r()));
|
||||||
await new Promise<void>((r) => broker.close(() => r()));
|
await new Promise<void>((r) => broker.close(() => r()));
|
||||||
},
|
},
|
||||||
|
|
@ -179,7 +179,7 @@ export async function createJournalServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 工具函数
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function extractSessionId(topic: string): string | null {
|
function extractSessionId(topic: string): string | null {
|
||||||
|
|
@ -188,7 +188,7 @@ function extractSessionId(topic: string): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 会话生命周期管理
|
// Session lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function handleMetaChange(
|
function handleMetaChange(
|
||||||
|
|
@ -212,7 +212,7 @@ function handleMetaChange(
|
||||||
} catch {
|
} catch {
|
||||||
/* */
|
/* */
|
||||||
}
|
}
|
||||||
console.log(`[日志] 会话已删除:${sessionId}`);
|
console.log(`[journal] session deleted: ${sessionId}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
manifest.sessions[sessionId] = {
|
manifest.sessions[sessionId] = {
|
||||||
|
|
@ -220,7 +220,7 @@ function handleMetaChange(
|
||||||
created: meta.created || Date.now(),
|
created: meta.created || Date.now(),
|
||||||
players: meta.players || [],
|
players: meta.players || [],
|
||||||
};
|
};
|
||||||
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveManifest(root, manifest);
|
saveManifest(root, manifest);
|
||||||
|
|
@ -236,6 +236,6 @@ function handleMetaChange(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
console.error(`[journal] meta parse err for ${sessionId}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parse } from 'csv-parse/browser/esm/sync';
|
||||||
import { stringify } from "csv-stringify/browser/esm/sync";
|
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||||
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
|
import yaml from 'js-yaml';
|
||||||
import {
|
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
|
||||||
parseFrontMatter,
|
|
||||||
serializeFrontMatter,
|
|
||||||
} from "../frontmatter/shared.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡牌数据
|
* 卡牌数据
|
||||||
|
|
@ -26,7 +23,7 @@ export interface CardCrudParams {
|
||||||
/**
|
/**
|
||||||
* 操作类型
|
* 操作类型
|
||||||
*/
|
*/
|
||||||
action: "create" | "read" | "update" | "delete";
|
action: 'create' | 'read' | 'update' | 'delete';
|
||||||
/**
|
/**
|
||||||
* 卡牌数据(单张或数组)
|
* 卡牌数据(单张或数组)
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,6 +44,41 @@ export interface CardCrudResult {
|
||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化 frontmatter 为 YAML 字符串
|
||||||
|
*/
|
||||||
|
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||||
|
const yamlStr = yaml.dump(frontmatter, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
quotingType: '"',
|
||||||
|
forceQuotes: false
|
||||||
|
});
|
||||||
|
return `---\n${yamlStr}---\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 CSV 数据(包含 frontmatter)
|
* 加载 CSV 数据(包含 frontmatter)
|
||||||
*/
|
*/
|
||||||
|
|
@ -55,19 +87,19 @@ function loadCSVWithFrontmatter(filePath: string): {
|
||||||
records: CardData[];
|
records: CardData[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
} {
|
} {
|
||||||
const content = readFileSync(filePath, "utf-8");
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
const { frontmatter, csvContent } = parseFrontMatter(content);
|
const { frontmatter, csvContent } = parseFrontMatter(content);
|
||||||
|
|
||||||
const records = parse(csvContent, {
|
const records = parse(csvContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
comment: "#",
|
comment: '#',
|
||||||
trim: true,
|
trim: true,
|
||||||
skipEmptyLines: true,
|
skipEmptyLines: true
|
||||||
}) as CardData[];
|
}) as CardData[];
|
||||||
|
|
||||||
// 获取表头
|
// 获取表头
|
||||||
const firstLine = csvContent.split("\n")[0];
|
const firstLine = csvContent.split('\n')[0];
|
||||||
const headers = firstLine.split(",").map((h) => h.trim());
|
const headers = firstLine.split(',').map(h => h.trim());
|
||||||
|
|
||||||
return { frontmatter, records, headers };
|
return { frontmatter, records, headers };
|
||||||
}
|
}
|
||||||
|
|
@ -79,30 +111,30 @@ function saveCSVWithFrontmatter(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
frontmatter: DeckFrontmatter | undefined,
|
frontmatter: DeckFrontmatter | undefined,
|
||||||
records: CardData[],
|
records: CardData[],
|
||||||
headers?: string[],
|
headers?: string[]
|
||||||
): void {
|
): void {
|
||||||
// 序列化 frontmatter
|
// 序列化 frontmatter
|
||||||
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
|
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
|
||||||
|
|
||||||
// 确定表头
|
// 确定表头
|
||||||
if (!headers || headers.length === 0) {
|
if (!headers || headers.length === 0) {
|
||||||
// 从 records 和 frontmatter.fields 推断表头
|
// 从 records 和 frontmatter.fields 推断表头
|
||||||
headers = ["label"];
|
headers = ['label'];
|
||||||
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
||||||
for (const field of frontmatter.fields) {
|
for (const field of frontmatter.fields) {
|
||||||
if (field.name && typeof field.name === "string") {
|
if (field.name && typeof field.name === 'string') {
|
||||||
headers.push(field.name);
|
headers.push(field.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers.push("body");
|
headers.push('body');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保所有 record 都有 headers 中的列
|
// 确保所有 record 都有 headers 中的列
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
for (const header of headers) {
|
for (const header of headers) {
|
||||||
if (!(header in record)) {
|
if (!(header in record)) {
|
||||||
record[header] = "";
|
record[header] = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,11 +142,11 @@ function saveCSVWithFrontmatter(
|
||||||
// 序列化 CSV
|
// 序列化 CSV
|
||||||
const csvContent = stringify(records, {
|
const csvContent = stringify(records, {
|
||||||
header: true,
|
header: true,
|
||||||
columns: headers,
|
columns: headers
|
||||||
});
|
});
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
|
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,10 +167,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
const { csv_file, action, cards, label } = params;
|
const { csv_file, action, cards, label } = params;
|
||||||
|
|
||||||
// 检查文件是否存在(create 操作可以不存在)
|
// 检查文件是否存在(create 操作可以不存在)
|
||||||
if (action !== "create" && !existsSync(csv_file)) {
|
if (action !== 'create' && !existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `文件不存在:${csv_file}`,
|
message: `文件不存在:${csv_file}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,8 +189,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
|
|
||||||
// 执行操作
|
// 执行操作
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "create": {
|
case 'create': {
|
||||||
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||||
for (const card of newCards) {
|
for (const card of newCards) {
|
||||||
if (!card.label) {
|
if (!card.label) {
|
||||||
card.label = generateNextLabel(records);
|
card.label = generateNextLabel(records);
|
||||||
|
|
@ -170,22 +202,16 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功创建 ${newCards.length} 张卡牌`,
|
message: `成功创建 ${newCards.length} 张卡牌`,
|
||||||
cards: newCards,
|
cards: newCards,
|
||||||
count: newCards.length,
|
count: newCards.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "read": {
|
case 'read': {
|
||||||
const labelsToRead = Array.isArray(label)
|
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
? label
|
|
||||||
: label
|
|
||||||
? [label]
|
|
||||||
: null;
|
|
||||||
let resultCards: CardData[];
|
let resultCards: CardData[];
|
||||||
|
|
||||||
if (labelsToRead && labelsToRead.length > 0) {
|
if (labelsToRead && labelsToRead.length > 0) {
|
||||||
resultCards = records.filter((r) =>
|
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
|
||||||
labelsToRead.includes(r.label || ""),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
resultCards = records;
|
resultCards = records;
|
||||||
}
|
}
|
||||||
|
|
@ -194,26 +220,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功读取 ${resultCards.length} 张卡牌`,
|
message: `成功读取 ${resultCards.length} 张卡牌`,
|
||||||
cards: resultCards,
|
cards: resultCards,
|
||||||
count: resultCards.length,
|
count: resultCards.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "update": {
|
case 'update': {
|
||||||
const labelsToUpdate = Array.isArray(label)
|
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
? label
|
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||||
: label
|
|
||||||
? [label]
|
|
||||||
: null;
|
|
||||||
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
||||||
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
||||||
// 按 label 更新
|
// 按 label 更新
|
||||||
for (const updateCard of updateCards) {
|
for (const updateCard of updateCards) {
|
||||||
const targetLabel =
|
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
|
||||||
updateCard.label ||
|
const index = records.findIndex(r => r.label === targetLabel);
|
||||||
labelsToUpdate[updatedCount % labelsToUpdate.length];
|
|
||||||
const index = records.findIndex((r) => r.label === targetLabel);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
records[index] = { ...records[index], ...updateCard };
|
records[index] = { ...records[index], ...updateCard };
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|
@ -223,9 +243,7 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
// 按 cards 中的 label 更新
|
// 按 cards 中的 label 更新
|
||||||
for (const updateCard of updateCards) {
|
for (const updateCard of updateCards) {
|
||||||
if (updateCard.label) {
|
if (updateCard.label) {
|
||||||
const index = records.findIndex(
|
const index = records.findIndex(r => r.label === updateCard.label);
|
||||||
(r) => r.label === updateCard.label,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
records[index] = { ...records[index], ...updateCard };
|
records[index] = { ...records[index], ...updateCard };
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|
@ -239,29 +257,23 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功更新 ${updatedCount} 张卡牌`,
|
message: `成功更新 ${updatedCount} 张卡牌`,
|
||||||
cards: updateCards,
|
cards: updateCards,
|
||||||
count: updatedCount,
|
count: updatedCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "delete": {
|
case 'delete': {
|
||||||
const labelsToDelete = Array.isArray(label)
|
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
|
||||||
? label
|
|
||||||
: label
|
|
||||||
? [label]
|
|
||||||
: null;
|
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
if (labelsToDelete && labelsToDelete.length > 0) {
|
if (labelsToDelete && labelsToDelete.length > 0) {
|
||||||
const beforeCount = records.length;
|
const beforeCount = records.length;
|
||||||
records = records.filter(
|
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
|
||||||
(r) => !labelsToDelete.includes(r.label || ""),
|
|
||||||
);
|
|
||||||
deletedCount = beforeCount - records.length;
|
deletedCount = beforeCount - records.length;
|
||||||
} else if (cards) {
|
} else if (cards) {
|
||||||
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
||||||
const beforeCount = records.length;
|
const beforeCount = records.length;
|
||||||
records = records.filter(
|
records = records.filter(r =>
|
||||||
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
|
!cardsToDelete.some(c => c.label && r.label === c.label)
|
||||||
);
|
);
|
||||||
deletedCount = beforeCount - records.length;
|
deletedCount = beforeCount - records.length;
|
||||||
}
|
}
|
||||||
|
|
@ -270,20 +282,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功删除 ${deletedCount} 张卡牌`,
|
message: `成功删除 ${deletedCount} 张卡牌`,
|
||||||
count: deletedCount,
|
count: deletedCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `未知操作:${action}`,
|
message: `未知操作:${action}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
|
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
/**
|
|
||||||
* Shared deck utility functions — used by generate-card-deck and preview-deck.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { dirname, join, basename, extname, relative } from "path";
|
|
||||||
import type {
|
|
||||||
DeckConfig,
|
|
||||||
DeckFrontmatter,
|
|
||||||
} from "./frontmatter/read-frontmatter.js";
|
|
||||||
import { parseFrontMatter } from "./frontmatter/shared.js";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
|
|
||||||
*/
|
|
||||||
export function buildDeckComponent(
|
|
||||||
csvPath: string,
|
|
||||||
config?: DeckConfig,
|
|
||||||
): string {
|
|
||||||
const parts = [`:md-deck[${csvPath}]`];
|
|
||||||
const attrs: string[] = [];
|
|
||||||
|
|
||||||
if (config?.size) {
|
|
||||||
attrs.push(`size="${config.size}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.grid) {
|
|
||||||
attrs.push(`grid="${config.grid}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
|
||||||
attrs.push(`bleed="${config.bleed}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.padding !== undefined && config.padding !== 2) {
|
|
||||||
attrs.push(`padding="${config.padding}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.shape && config.shape !== "rectangle") {
|
|
||||||
attrs.push(`shape="${config.shape}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.layers) {
|
|
||||||
attrs.push(`layers="${config.layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.back_layers) {
|
|
||||||
attrs.push(`back-layers="${config.back_layers}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs.length > 0) {
|
|
||||||
parts.push(`{${attrs.join(" ")}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a human-readable title from a CSV filename.
|
|
||||||
* e.g. "my-deck.csv" → "My Deck"
|
|
||||||
*/
|
|
||||||
export function titleFromCsvPath(csvPath: string): string {
|
|
||||||
const name = basename(csvPath, extname(csvPath));
|
|
||||||
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the relative path from a markdown file to its CSV.
|
|
||||||
* Handles the case where they are in the same or different directories.
|
|
||||||
*/
|
|
||||||
export function relativeCsvPath(
|
|
||||||
mdFilePath: string,
|
|
||||||
csvFilePath: string,
|
|
||||||
): string {
|
|
||||||
const mdDir = dirname(mdFilePath);
|
|
||||||
const rel = relative(mdDir, csvFilePath);
|
|
||||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the default markdown file path for a CSV.
|
|
||||||
* e.g. "foo/bar.csv" → "foo/bar.md"
|
|
||||||
*/
|
|
||||||
export function defaultMdPath(csvFilePath: string): string {
|
|
||||||
const dir = dirname(csvFilePath);
|
|
||||||
const name = basename(csvFilePath, extname(csvFilePath));
|
|
||||||
return join(dir, `${name}.md`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read DeckConfig from a CSV file's frontmatter, if present.
|
|
||||||
*/
|
|
||||||
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(csvFilePath, "utf-8");
|
|
||||||
const { frontmatter } = parseFrontMatter(content);
|
|
||||||
return frontmatter?.deck;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the standard markdown content for a deck preview file.
|
|
||||||
*/
|
|
||||||
export function generateDeckPreviewMd(
|
|
||||||
deckTitle: string,
|
|
||||||
deckComponent: string,
|
|
||||||
description?: string,
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(`# ${deckTitle}`);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
lines.push(description);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("## 卡牌预览");
|
|
||||||
lines.push("");
|
|
||||||
lines.push(deckComponent);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
lines.push("## 使用说明");
|
|
||||||
lines.push("");
|
|
||||||
lines.push("- 点击卡牌可以查看详情");
|
|
||||||
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
|
|
||||||
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
|
import { dirname, join, basename, extname } from 'path';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Deck 预览文件的参数
|
||||||
|
*/
|
||||||
|
export interface EnsureDeckPreviewParams {
|
||||||
|
/**
|
||||||
|
* CSV 文件路径
|
||||||
|
*/
|
||||||
|
csv_file: string;
|
||||||
|
/**
|
||||||
|
* Markdown 文件路径(可选,默认与 CSV 同名)
|
||||||
|
*/
|
||||||
|
md_file?: string;
|
||||||
|
/**
|
||||||
|
* 标题(可选,默认从 CSV 文件名推断)
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* 描述(可选)
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Deck 预览文件的结果
|
||||||
|
*/
|
||||||
|
export interface EnsureDeckPreviewResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
md_file?: string;
|
||||||
|
created?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 md-deck 组件代码
|
||||||
|
*/
|
||||||
|
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
||||||
|
const parts = [`:md-deck[${csvPath}]`];
|
||||||
|
const attrs: string[] = [];
|
||||||
|
|
||||||
|
if (config?.size) {
|
||||||
|
attrs.push(`size="${config.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.grid) {
|
||||||
|
attrs.push(`grid="${config.grid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||||
|
attrs.push(`bleed="${config.bleed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.padding !== undefined && config.padding !== 2) {
|
||||||
|
attrs.push(`padding="${config.padding}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.shape && config.shape !== 'rectangle') {
|
||||||
|
attrs.push(`shape="${config.shape}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.layers) {
|
||||||
|
attrs.push(`layers="${config.layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.back_layers) {
|
||||||
|
attrs.push(`back-layers="${config.back_layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.length > 0) {
|
||||||
|
parts.push(`{${attrs.join(' ')}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保 Markdown 预览文件存在
|
||||||
|
*/
|
||||||
|
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
|
||||||
|
const { csv_file, md_file, title, description } = params;
|
||||||
|
|
||||||
|
// 检查 CSV 文件是否存在
|
||||||
|
if (!existsSync(csv_file)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `CSV 文件不存在:${csv_file}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定 Markdown 文件路径
|
||||||
|
let mdFilePath = md_file;
|
||||||
|
if (!mdFilePath) {
|
||||||
|
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
||||||
|
const dir = dirname(csv_file);
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
mdFilePath = join(dir, `${name}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = !existsSync(mdFilePath);
|
||||||
|
|
||||||
|
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||||
|
if (!created) {
|
||||||
|
try {
|
||||||
|
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
||||||
|
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
||||||
|
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||||
|
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `预览文件 ${mdFilePath} 已存在`,
|
||||||
|
md_file: mdFilePath,
|
||||||
|
created: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 读取失败,继续创建
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 CSV 的 frontmatter 获取配置
|
||||||
|
let deckConfig: DeckConfig | undefined;
|
||||||
|
try {
|
||||||
|
const csvContent = readFileSync(csv_file, 'utf-8');
|
||||||
|
const { frontmatter } = parseFrontMatter(csvContent);
|
||||||
|
deckConfig = frontmatter?.deck;
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误,使用默认配置
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定标题
|
||||||
|
let deckTitle = title;
|
||||||
|
if (!deckTitle) {
|
||||||
|
// 从 CSV 文件名推断
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 md-deck 组件代码(使用相对路径)
|
||||||
|
const mdDir = dirname(mdFilePath);
|
||||||
|
const csvDir = dirname(csv_file);
|
||||||
|
let relativeCsvPath: string;
|
||||||
|
|
||||||
|
if (mdDir === csvDir) {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
} else {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
||||||
|
|
||||||
|
// 生成 Markdown 内容
|
||||||
|
const mdLines: string[] = [];
|
||||||
|
|
||||||
|
mdLines.push(`# ${deckTitle}`);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
mdLines.push(description);
|
||||||
|
mdLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
mdLines.push('## 卡牌预览');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push(deckComponent);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
mdLines.push('## 使用说明');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push('- 点击卡牌可以查看详情');
|
||||||
|
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||||
|
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
try {
|
||||||
|
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: created
|
||||||
|
? `创建预览文件 ${mdFilePath}`
|
||||||
|
: `更新预览文件 ${mdFilePath}`,
|
||||||
|
md_file: mdFilePath,
|
||||||
|
created
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { readFileSync, existsSync } from "fs";
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { parseFrontMatter } from "./shared.js";
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取 CSV frontmatter 的参数
|
* 读取 CSV frontmatter 的参数
|
||||||
|
|
@ -52,7 +52,7 @@ export interface CardFieldStyle {
|
||||||
/**
|
/**
|
||||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
*/
|
*/
|
||||||
up?: "n" | "w" | "s" | "e";
|
up?: 'n' | 'w' | 's' | 'e';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,7 +74,7 @@ export interface DeckConfig {
|
||||||
grid?: string;
|
grid?: string;
|
||||||
bleed?: number;
|
bleed?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
shape?: "rectangle" | "circle" | "hex" | "diamond";
|
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
||||||
layers?: string;
|
layers?: string;
|
||||||
back_layers?: string;
|
back_layers?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
@ -89,25 +89,45 @@ export interface ReadFrontmatterResult {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取 CSV 文件的 frontmatter
|
* 读取 CSV 文件的 frontmatter
|
||||||
*/
|
*/
|
||||||
export function readFrontmatter(
|
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
|
||||||
params: ReadFrontmatterParams,
|
|
||||||
): ReadFrontmatterResult {
|
|
||||||
const { csv_file } = params;
|
const { csv_file } = params;
|
||||||
|
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
if (!existsSync(csv_file)) {
|
if (!existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `文件不存在:${csv_file}`,
|
message: `文件不存在:${csv_file}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
const content = readFileSync(csv_file, "utf-8");
|
const content = readFileSync(csv_file, 'utf-8');
|
||||||
|
|
||||||
// 解析 frontmatter
|
// 解析 frontmatter
|
||||||
const { frontmatter } = parseFrontMatter(content);
|
const { frontmatter } = parseFrontMatter(content);
|
||||||
|
|
@ -116,19 +136,19 @@ export function readFrontmatter(
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
frontmatter: {},
|
frontmatter: {},
|
||||||
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
|
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
frontmatter,
|
frontmatter,
|
||||||
message: `成功读取 ${csv_file} 的 frontmatter`,
|
message: `成功读取 ${csv_file} 的 frontmatter`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
|
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
/**
|
|
||||||
* Shared frontmatter utilities — used by read-frontmatter, write-frontmatter,
|
|
||||||
* card-crud, preview-deck, and generate-card-deck.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse YAML frontmatter from a CSV file's raw content.
|
|
||||||
* Expects `---\n...\n---\n` delimited frontmatter at the top of the file.
|
|
||||||
*/
|
|
||||||
export function parseFrontMatter(content: string): {
|
|
||||||
frontmatter?: DeckFrontmatter;
|
|
||||||
csvContent: string;
|
|
||||||
} {
|
|
||||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
|
||||||
|
|
||||||
if (parts.length !== 3 || parts[0] !== "") {
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const frontmatterStr = parts[1].trim();
|
|
||||||
const frontmatter = yaml.load(frontmatterStr) as
|
|
||||||
| DeckFrontmatter
|
|
||||||
| undefined;
|
|
||||||
const csvContent = parts.slice(2).join("---\n").trimStart();
|
|
||||||
return { frontmatter, csvContent };
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to parse front matter:", error);
|
|
||||||
return { csvContent: content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize a DeckFrontmatter object to YAML frontmatter string.
|
|
||||||
*/
|
|
||||||
export function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
|
||||||
const yamlStr = yaml.dump(frontmatter, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: -1,
|
|
||||||
noRefs: true,
|
|
||||||
quotingType: '"',
|
|
||||||
forceQuotes: false,
|
|
||||||
});
|
|
||||||
return `---\n${yamlStr}---\n`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
import yaml from 'js-yaml';
|
||||||
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
|
import type { DeckFrontmatter } from './read-frontmatter.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写入 CSV frontmatter 的参数
|
* 写入 CSV frontmatter 的参数
|
||||||
|
|
@ -29,37 +29,70 @@ export interface WriteFrontmatterResult {
|
||||||
csv_file?: string;
|
csv_file?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CSV 文件的 frontmatter
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatterStr = parts[1].trim();
|
||||||
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
|
return { frontmatter, csvContent };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化 frontmatter 为 YAML 字符串
|
||||||
|
*/
|
||||||
|
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||||
|
const yamlStr = yaml.dump(frontmatter, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1, // 不限制行宽
|
||||||
|
noRefs: true, // 不使用引用
|
||||||
|
quotingType: '"',
|
||||||
|
forceQuotes: false
|
||||||
|
});
|
||||||
|
return `---\n${yamlStr}---\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写入/更新 CSV 文件的 frontmatter
|
* 写入/更新 CSV 文件的 frontmatter
|
||||||
*/
|
*/
|
||||||
export function writeFrontmatter(
|
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
|
||||||
params: WriteFrontmatterParams,
|
|
||||||
): WriteFrontmatterResult {
|
|
||||||
const { csv_file, frontmatter, merge = true } = params;
|
const { csv_file, frontmatter, merge = true } = params;
|
||||||
|
|
||||||
let csvContent = "";
|
let csvContent = '';
|
||||||
let existingFrontmatter: DeckFrontmatter | undefined;
|
let existingFrontmatter: DeckFrontmatter | undefined;
|
||||||
|
|
||||||
// 如果文件存在且需要合并,先读取现有内容
|
// 如果文件存在且需要合并,先读取现有内容
|
||||||
if (merge && existsSync(csv_file)) {
|
if (merge && existsSync(csv_file)) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(csv_file, "utf-8");
|
const content = readFileSync(csv_file, 'utf-8');
|
||||||
const result = parseFrontMatter(content);
|
const result = parseFrontMatter(content);
|
||||||
existingFrontmatter = result.frontmatter;
|
existingFrontmatter = result.frontmatter;
|
||||||
csvContent = result.csvContent;
|
csvContent = result.csvContent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
|
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并或替换 frontmatter
|
// 合并或替换 frontmatter
|
||||||
const finalFrontmatter =
|
const finalFrontmatter = merge && existingFrontmatter
|
||||||
merge && existingFrontmatter
|
? { ...existingFrontmatter, ...frontmatter }
|
||||||
? { ...existingFrontmatter, ...frontmatter }
|
: frontmatter;
|
||||||
: frontmatter;
|
|
||||||
|
|
||||||
// 序列化 frontmatter
|
// 序列化 frontmatter
|
||||||
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
|
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
|
||||||
|
|
@ -67,32 +100,32 @@ export function writeFrontmatter(
|
||||||
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
||||||
if (!csvContent.trim()) {
|
if (!csvContent.trim()) {
|
||||||
// 从 frontmatter 推断 CSV 表头
|
// 从 frontmatter 推断 CSV 表头
|
||||||
const headers = ["label"];
|
const headers = ['label'];
|
||||||
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
||||||
for (const field of finalFrontmatter.fields) {
|
for (const field of finalFrontmatter.fields) {
|
||||||
if (field.name && typeof field.name === "string") {
|
if (field.name && typeof field.name === 'string') {
|
||||||
headers.push(field.name);
|
headers.push(field.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers.push("body");
|
headers.push('body');
|
||||||
csvContent = headers.join(",") + "\n";
|
csvContent = headers.join(',') + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
try {
|
try {
|
||||||
const fullContent = frontmatterStr + csvContent;
|
const fullContent = frontmatterStr + csvContent;
|
||||||
writeFileSync(csv_file, fullContent, "utf-8");
|
writeFileSync(csv_file, fullContent, 'utf-8');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `成功写入 frontmatter 到 ${csv_file}`,
|
message: `成功写入 frontmatter 到 ${csv_file}`,
|
||||||
csv_file,
|
csv_file
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
import { join } from "path";
|
import { join } from 'path';
|
||||||
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
|
|
||||||
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字段样式配置
|
* 字段样式配置
|
||||||
|
|
@ -26,7 +24,7 @@ export interface CardFieldStyle {
|
||||||
/**
|
/**
|
||||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||||
*/
|
*/
|
||||||
up?: "n" | "w" | "s" | "e";
|
up?: 'n' | 'w' | 's' | 'e';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,7 +45,18 @@ export interface CardTemplate {
|
||||||
examples?: Record<string, string>[];
|
examples?: Record<string, string>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { DeckConfig };
|
/**
|
||||||
|
* Deck 配置
|
||||||
|
*/
|
||||||
|
export interface DeckConfig {
|
||||||
|
size?: string;
|
||||||
|
grid?: string;
|
||||||
|
bleed?: number;
|
||||||
|
padding?: number;
|
||||||
|
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
||||||
|
layers?: string;
|
||||||
|
backLayers?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成卡牌组的参数
|
* 生成卡牌组的参数
|
||||||
|
|
@ -64,31 +73,31 @@ export interface GenerateCardDeckParams {
|
||||||
/**
|
/**
|
||||||
* 生成卡牌数据 CSV
|
* 生成卡牌数据 CSV
|
||||||
*/
|
*/
|
||||||
function generateCardCSV(template: CardTemplate, cardCount: number): string {
|
function generateCardCSV(
|
||||||
|
template: CardTemplate,
|
||||||
|
cardCount: number
|
||||||
|
): string {
|
||||||
const fields = template.fields;
|
const fields = template.fields;
|
||||||
|
|
||||||
// 构建 CSV 表头
|
// 构建 CSV 表头
|
||||||
const headers = ["label", ...fields.map((f) => f.name), "body"];
|
const headers = ['label', ...fields.map(f => f.name), 'body'];
|
||||||
|
|
||||||
// 生成示例数据
|
// 生成示例数据
|
||||||
const rows: string[][] = [];
|
const rows: string[][] = [];
|
||||||
const examples = template.examples || [];
|
const examples = template.examples || [];
|
||||||
|
|
||||||
for (let i = 0; i < cardCount; i++) {
|
for (let i = 0; i < cardCount; i++) {
|
||||||
const row: string[] = [(i + 1).toString()];
|
const row: string[] = [(i + 1).toString()];
|
||||||
|
|
||||||
// 为每个字段生成值
|
// 为每个字段生成值
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
let value = "";
|
let value = '';
|
||||||
|
|
||||||
if (examples.length > 0) {
|
if (examples.length > 0) {
|
||||||
// 从示例中循环取值
|
// 从示例中循环取值
|
||||||
const exampleIndex = i % examples.length;
|
const exampleIndex = i % examples.length;
|
||||||
const example = examples[exampleIndex];
|
const example = examples[exampleIndex];
|
||||||
value =
|
value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || '';
|
||||||
example[field.name] ||
|
|
||||||
field.examples?.[i % (field.examples?.length || 1)] ||
|
|
||||||
"";
|
|
||||||
} else if (field.examples && field.examples.length > 0) {
|
} else if (field.examples && field.examples.length > 0) {
|
||||||
// 从字段的示例中取值
|
// 从字段的示例中取值
|
||||||
value = field.examples[i % field.examples.length];
|
value = field.examples[i % field.examples.length];
|
||||||
|
|
@ -96,49 +105,131 @@ function generateCardCSV(template: CardTemplate, cardCount: number): string {
|
||||||
// 默认占位符
|
// 默认占位符
|
||||||
value = `{{${field.name}_${i + 1}}}`;
|
value = `{{${field.name}_${i + 1}}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.push(value);
|
row.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// body 列使用模板语法
|
// body 列使用模板语法
|
||||||
const bodyParts: string[] = [];
|
const bodyParts: string[] = [];
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
|
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
|
||||||
}
|
}
|
||||||
row.push(bodyParts.join("\n\n"));
|
row.push(bodyParts.join('\n\n'));
|
||||||
|
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组合 CSV 内容
|
// 组合 CSV 内容
|
||||||
const csvLines = [headers.join(",")];
|
const csvLines = [headers.join(',')];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
csvLines.push(
|
csvLines.push(row.map(cell => {
|
||||||
row
|
// 处理包含逗号或换行的单元格
|
||||||
.map((cell) => {
|
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
|
||||||
// 处理包含逗号或换行的单元格
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
|
}
|
||||||
return `"${cell.replace(/"/g, '""')}"`;
|
return cell;
|
||||||
}
|
}).join(','));
|
||||||
return cell;
|
|
||||||
})
|
|
||||||
.join(","),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return csvLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
return csvLines.join("\n");
|
/**
|
||||||
|
* 生成卡牌介绍的 Markdown 文件
|
||||||
|
*/
|
||||||
|
function generateDeckMarkdown(
|
||||||
|
deckName: string,
|
||||||
|
csvFileName: string,
|
||||||
|
deckConfig: DeckConfig,
|
||||||
|
description?: string
|
||||||
|
): string {
|
||||||
|
const mdLines: string[] = [];
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
mdLines.push(`# ${deckName}`);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
// 描述
|
||||||
|
if (description) {
|
||||||
|
mdLines.push(description);
|
||||||
|
mdLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡牌预览组件
|
||||||
|
mdLines.push('## 卡牌预览');
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
// 构建 :md-deck 组件代码
|
||||||
|
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
|
||||||
|
mdLines.push(deckComponent);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
// 使用说明
|
||||||
|
mdLines.push('## 使用说明');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push('- 点击卡牌可以查看详情');
|
||||||
|
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||||
|
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
return mdLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 :md-deck 组件代码
|
||||||
|
*/
|
||||||
|
function buildDeckComponent(
|
||||||
|
csvFileName: string,
|
||||||
|
config: DeckConfig
|
||||||
|
): string {
|
||||||
|
const parts = [`:md-deck[${csvFileName}]`];
|
||||||
|
const attrs: string[] = [];
|
||||||
|
|
||||||
|
if (config.size) {
|
||||||
|
attrs.push(`size="${config.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.grid) {
|
||||||
|
attrs.push(`grid="${config.grid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.bleed !== undefined && config.bleed !== 1) {
|
||||||
|
attrs.push(`bleed="${config.bleed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.padding !== undefined && config.padding !== 2) {
|
||||||
|
attrs.push(`padding="${config.padding}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.shape && config.shape !== 'rectangle') {
|
||||||
|
attrs.push(`shape="${config.shape}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.layers) {
|
||||||
|
attrs.push(`layers="${config.layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.backLayers) {
|
||||||
|
attrs.push(`back-layers="${config.backLayers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.length > 0) {
|
||||||
|
parts.push(`{${attrs.join(' ')}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动生成图层配置
|
* 自动生成图层配置
|
||||||
*/
|
*/
|
||||||
function autoGenerateLayers(fields: CardField[]): string {
|
function autoGenerateLayers(fields: CardField[]): string {
|
||||||
if (fields.length === 0) return "";
|
if (fields.length === 0) return '';
|
||||||
|
|
||||||
const layers: string[] = [];
|
const layers: string[] = [];
|
||||||
const totalHeight = 8;
|
const totalHeight = 8;
|
||||||
const heightPerField = Math.floor((totalHeight - 2) / fields.length);
|
const heightPerField = Math.floor((totalHeight - 2) / fields.length);
|
||||||
|
|
||||||
for (let i = 0; i < fields.length; i++) {
|
for (let i = 0; i < fields.length; i++) {
|
||||||
const field = fields[i];
|
const field = fields[i];
|
||||||
const y1 = 2 + i * heightPerField;
|
const y1 = 2 + i * heightPerField;
|
||||||
|
|
@ -146,8 +237,8 @@ function autoGenerateLayers(fields: CardField[]): string {
|
||||||
const fontSize = Math.min(12, Math.floor(80 / fields.length));
|
const fontSize = Math.min(12, Math.floor(80 / fields.length));
|
||||||
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return layers.join(" ");
|
return layers.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,66 +256,61 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
||||||
card_count = 10,
|
card_count = 10,
|
||||||
card_template,
|
card_template,
|
||||||
deck_config = {},
|
deck_config = {},
|
||||||
description,
|
description
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// 确保输出目录存在
|
// 确保输出目录存在
|
||||||
if (!existsSync(output_dir)) {
|
if (!existsSync(output_dir)) {
|
||||||
mkdirSync(output_dir, { recursive: true });
|
mkdirSync(output_dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
|
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
|
||||||
const csvFileName = `${safeName}.csv`;
|
const csvFileName = `${safeName}.csv`;
|
||||||
const mdFileName = `${safeName}.md`;
|
const mdFileName = `${safeName}.md`;
|
||||||
|
|
||||||
// 创建默认模板(如果没有提供)
|
// 创建默认模板(如果没有提供)
|
||||||
const template: CardTemplate = card_template || {
|
const template: CardTemplate = card_template || {
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
|
||||||
name: "name",
|
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
|
||||||
description: "卡牌名称",
|
{ name: 'cost', description: '费用', examples: ['1', '2'] },
|
||||||
examples: ["示例卡牌 1", "示例卡牌 2"],
|
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
|
||||||
},
|
]
|
||||||
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
|
|
||||||
{ name: "cost", description: "费用", examples: ["1", "2"] },
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
description: "效果描述",
|
|
||||||
examples: ["这是一个效果描述", "这是另一个效果"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建默认配置(如果没有提供)
|
// 创建默认配置(如果没有提供)
|
||||||
const config: DeckConfig = {
|
const config: DeckConfig = {
|
||||||
size: deck_config.size || "54x86",
|
size: deck_config.size || '54x86',
|
||||||
grid: deck_config.grid || "5x8",
|
grid: deck_config.grid || '5x8',
|
||||||
bleed: deck_config.bleed ?? 1,
|
bleed: deck_config.bleed ?? 1,
|
||||||
padding: deck_config.padding ?? 2,
|
padding: deck_config.padding ?? 2,
|
||||||
shape: deck_config.shape || "rectangle",
|
shape: deck_config.shape || 'rectangle',
|
||||||
layers: deck_config.layers || autoGenerateLayers(template.fields),
|
layers: deck_config.layers || autoGenerateLayers(template.fields)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成 CSV 内容
|
// 生成 CSV 内容
|
||||||
const csvContent = generateCardCSV(template, card_count);
|
const csvContent = generateCardCSV(template, card_count);
|
||||||
const csvPath = join(output_dir, csvFileName);
|
const csvPath = join(output_dir, csvFileName);
|
||||||
writeFileSync(csvPath, csvContent, "utf-8");
|
writeFileSync(csvPath, csvContent, 'utf-8');
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
// 生成 Markdown 内容
|
||||||
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
const mdContent = generateDeckMarkdown(
|
||||||
const mdContent = generateDeckPreviewMd(
|
|
||||||
deck_name,
|
deck_name,
|
||||||
deckComponent,
|
`./${csvFileName}`,
|
||||||
description,
|
config,
|
||||||
|
description
|
||||||
);
|
);
|
||||||
const mdPath = join(output_dir, mdFileName);
|
const mdPath = join(output_dir, mdFileName);
|
||||||
writeFileSync(mdPath, mdContent, "utf-8");
|
writeFileSync(mdPath, mdContent, 'utf-8');
|
||||||
|
|
||||||
|
// 构建完整的 deck 组件代码
|
||||||
|
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mdFile: mdPath,
|
mdFile: mdPath,
|
||||||
csvFile: csvPath,
|
csvFile: csvPath,
|
||||||
deckComponent,
|
deckComponent,
|
||||||
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
|
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { relative } from "path";
|
import { dirname, join, basename, extname, relative } from 'path';
|
||||||
import { execSync } from "child_process";
|
import { execSync } from 'child_process';
|
||||||
import {
|
import yaml from 'js-yaml';
|
||||||
buildDeckComponent,
|
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
||||||
titleFromCsvPath,
|
|
||||||
defaultMdPath,
|
|
||||||
readDeckConfig,
|
|
||||||
generateDeckPreviewMd,
|
|
||||||
} from "./deck-utils.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览 Deck 的参数
|
* 预览 Deck 的参数
|
||||||
|
|
@ -43,32 +38,85 @@ export interface PreviewDeckResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开浏览器
|
* 解析 CSV 文件的 frontmatter
|
||||||
*/
|
*/
|
||||||
function openBrowser(url: string) {
|
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||||
|
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||||
|
|
||||||
|
if (parts.length !== 3 || parts[0] !== '') {
|
||||||
|
return { csvContent: content };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (process.platform === "win32") {
|
const frontmatterStr = parts[1].trim();
|
||||||
execSync(`start "" "${url}"`, { stdio: "ignore" });
|
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||||
} else if (process.platform === "darwin") {
|
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||||
execSync(`open "${url}"`, { stdio: "ignore" });
|
return { frontmatter, csvContent };
|
||||||
} else {
|
|
||||||
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
|
||||||
}
|
|
||||||
console.error(`已打开浏览器:${url}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("打开浏览器失败:", error);
|
console.warn('Failed to parse front matter:', error);
|
||||||
|
return { csvContent: content };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算预览 URL
|
* 构建 md-deck 组件代码
|
||||||
*/
|
*/
|
||||||
function computePreviewUrl(mdFilePath: string): string {
|
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
||||||
const relativeMdPath = relative(process.cwd(), mdFilePath)
|
const parts = [`:md-deck[${csvPath}]`];
|
||||||
.split("\\")
|
const attrs: string[] = [];
|
||||||
.join("/")
|
|
||||||
.replace(/\.md$/, "");
|
if (config?.size) {
|
||||||
return `http://localhost:3000/${relativeMdPath}`;
|
attrs.push(`size="${config.size}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.grid) {
|
||||||
|
attrs.push(`grid="${config.grid}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||||
|
attrs.push(`bleed="${config.bleed}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.padding !== undefined && config.padding !== 2) {
|
||||||
|
attrs.push(`padding="${config.padding}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.shape && config.shape !== 'rectangle') {
|
||||||
|
attrs.push(`shape="${config.shape}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.layers) {
|
||||||
|
attrs.push(`layers="${config.layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.back_layers) {
|
||||||
|
attrs.push(`back-layers="${config.back_layers}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.length > 0) {
|
||||||
|
parts.push(`{${attrs.join(' ')}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开浏览器
|
||||||
|
*/
|
||||||
|
function openBrowser(url: string) {
|
||||||
|
try {
|
||||||
|
// Windows 使用 start 命令
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
execSync(`open "${url}"`, { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
||||||
|
}
|
||||||
|
console.error(`已打开浏览器:${url}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开浏览器失败:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,61 +129,108 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
||||||
if (!existsSync(csv_file)) {
|
if (!existsSync(csv_file)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `CSV 文件不存在:${csv_file}`,
|
message: `CSV 文件不存在:${csv_file}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定 Markdown 文件路径
|
// 确定 Markdown 文件路径
|
||||||
const mdFilePath = md_file || defaultMdPath(csv_file);
|
let mdFilePath = md_file;
|
||||||
|
if (!mdFilePath) {
|
||||||
|
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
||||||
|
const dir = dirname(csv_file);
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
mdFilePath = join(dir, `${name}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
const created = !existsSync(mdFilePath);
|
const created = !existsSync(mdFilePath);
|
||||||
|
|
||||||
// 如果文件已存在,检查是否已有 md-deck 组件
|
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||||
if (!created) {
|
if (!created) {
|
||||||
try {
|
try {
|
||||||
const existingContent = readFileSync(mdFilePath, "utf-8");
|
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
||||||
if (
|
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
||||||
existingContent.includes(`:md-deck[${csv_file}]`) ||
|
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
|
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
||||||
) {
|
// 仍然打开浏览器
|
||||||
const previewUrl = computePreviewUrl(mdFilePath);
|
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
||||||
|
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
|
||||||
openBrowser(previewUrl);
|
openBrowser(previewUrl);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `预览文件 ${mdFilePath} 已存在`,
|
message: `预览文件 ${mdFilePath} 已存在`,
|
||||||
md_file: mdFilePath,
|
md_file: mdFilePath,
|
||||||
created: false,
|
created: false,
|
||||||
preview_url: previewUrl,
|
preview_url: previewUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// 读取失败,继续创建
|
// 读取失败,继续创建
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取 CSV 的 frontmatter 获取配置
|
// 读取 CSV 的 frontmatter 获取配置
|
||||||
const deckConfig = readDeckConfig(csv_file);
|
let deckConfig: DeckConfig | undefined;
|
||||||
|
try {
|
||||||
|
const csvContent = readFileSync(csv_file, 'utf-8');
|
||||||
|
const { frontmatter } = parseFrontMatter(csvContent);
|
||||||
|
deckConfig = frontmatter?.deck;
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误,使用默认配置
|
||||||
|
}
|
||||||
|
|
||||||
// 确定标题
|
// 确定标题
|
||||||
const deckTitle = title || titleFromCsvPath(csv_file);
|
let deckTitle = title;
|
||||||
|
if (!deckTitle) {
|
||||||
|
// 从 CSV 文件名推断
|
||||||
|
const name = basename(csv_file, extname(csv_file));
|
||||||
|
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
// 构建 md-deck 组件代码
|
// 构建 md-deck 组件代码(使用相对路径)
|
||||||
const deckComponent = buildDeckComponent(
|
const mdDir = dirname(mdFilePath);
|
||||||
`./${csv_file.split("/").pop()}`,
|
const csvDir = dirname(csv_file);
|
||||||
deckConfig,
|
let relativeCsvPath: string;
|
||||||
);
|
|
||||||
|
if (mdDir === csvDir) {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
} else {
|
||||||
|
relativeCsvPath = `./${basename(csv_file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
// 生成 Markdown 内容
|
||||||
const mdContent = generateDeckPreviewMd(
|
const mdLines: string[] = [];
|
||||||
deckTitle,
|
|
||||||
deckComponent,
|
mdLines.push(`# ${deckTitle}`);
|
||||||
description,
|
mdLines.push('');
|
||||||
);
|
|
||||||
|
if (description) {
|
||||||
|
mdLines.push(description);
|
||||||
|
mdLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
mdLines.push('## 卡牌预览');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push(deckComponent);
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
|
mdLines.push('## 使用说明');
|
||||||
|
mdLines.push('');
|
||||||
|
mdLines.push('- 点击卡牌可以查看详情');
|
||||||
|
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||||
|
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||||
|
mdLines.push('');
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
try {
|
try {
|
||||||
writeFileSync(mdFilePath, mdContent, "utf-8");
|
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
||||||
|
|
||||||
const previewUrl = computePreviewUrl(mdFilePath);
|
// 计算预览 URL
|
||||||
|
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
||||||
|
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
|
||||||
|
|
||||||
|
// 打开浏览器
|
||||||
openBrowser(previewUrl);
|
openBrowser(previewUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -145,12 +240,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
||||||
: `更新预览文件 ${mdFilePath}`,
|
: `更新预览文件 ${mdFilePath}`,
|
||||||
md_file: mdFilePath,
|
md_file: mdFilePath,
|
||||||
created,
|
created,
|
||||||
preview_url: previewUrl,
|
preview_url: previewUrl
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
|
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="fixed inset-0 bg-black/50 z-60 overflow-auto">
|
<div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
|
||||||
<div class="min-h-screen py-20 px-4">
|
<div class="min-h-screen py-20 px-4">
|
||||||
<PrintPreviewHeader
|
<PrintPreviewHeader
|
||||||
store={store}
|
store={store}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
import { customElement, noShadowDOM } from "solid-element";
|
import { customElement, noShadowDOM } from 'solid-element';
|
||||||
import {
|
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
|
||||||
createSignal,
|
import { marked } from '../markdown';
|
||||||
For,
|
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
|
||||||
Show,
|
import { resolvePath } from './utils/path';
|
||||||
createEffect,
|
import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
|
||||||
createMemo,
|
|
||||||
createResource,
|
|
||||||
} from "solid-js";
|
|
||||||
import { marked } from "../markdown";
|
|
||||||
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader";
|
|
||||||
import { resolvePath } from "./utils/path";
|
|
||||||
import {
|
|
||||||
areAllLabelsNumeric,
|
|
||||||
weightedRandomIndex,
|
|
||||||
} from "./utils/weighted-random";
|
|
||||||
|
|
||||||
export interface TableProps {
|
export interface TableProps {
|
||||||
roll?: boolean;
|
roll?: boolean;
|
||||||
|
|
@ -26,208 +16,191 @@ interface TableRow {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
customElement(
|
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
|
||||||
"md-table",
|
noShadowDOM();
|
||||||
{ roll: false, remix: false },
|
const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>);
|
||||||
(props, { element }) => {
|
const [activeTab, setActiveTab] = createSignal(0);
|
||||||
noShadowDOM();
|
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
|
||||||
const [rows, setRows] = createSignal<CSV<TableRow>>(
|
const [bodyHtml, setBodyHtml] = createSignal('');
|
||||||
[] as unknown as CSV<TableRow>,
|
let tabsContainer: HTMLDivElement | undefined;
|
||||||
);
|
|
||||||
const [activeTab, setActiveTab] = createSignal(0);
|
|
||||||
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
|
|
||||||
const [bodyHtml, setBodyHtml] = createSignal("");
|
|
||||||
let tabsContainer: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
|
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
|
||||||
const rawContent = element?.textContent?.trim() || "";
|
const rawContent = element?.textContent?.trim() || '';
|
||||||
|
|
||||||
// 隐藏原始文本内容
|
// 隐藏原始文本内容
|
||||||
if (element) {
|
if (element) {
|
||||||
element.textContent = "";
|
element.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
|
||||||
|
const articleEl = element?.closest('article[data-src]');
|
||||||
|
const articlePath = articleEl?.getAttribute('data-src') || '';
|
||||||
|
|
||||||
|
// 如果是 inline CSV,直接使用;否则解析相对路径
|
||||||
|
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
|
||||||
|
|
||||||
|
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
|
||||||
|
const [csvData] = createResource(() => contentOrPath, loadCSV);
|
||||||
|
|
||||||
|
// 当数据加载完成后更新 rows
|
||||||
|
createEffect(() => {
|
||||||
|
const data = csvData();
|
||||||
|
if (data) {
|
||||||
|
// 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性
|
||||||
|
setRows(data as unknown as CSV<TableRow>);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
|
// 检测是否有 group 列
|
||||||
const articleEl = element?.closest("article[data-src]");
|
const hasGroup = createMemo(() => {
|
||||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
const allRows = rows();
|
||||||
|
return allRows.length > 0 && 'group' in allRows[0];
|
||||||
|
});
|
||||||
|
|
||||||
// 如果是 inline CSV,直接使用;否则解析相对路径
|
// 获取所有分组
|
||||||
const contentOrPath = isCSV(rawContent)
|
const groups = createMemo(() => {
|
||||||
? rawContent
|
if (!hasGroup()) return [];
|
||||||
: resolvePath(articlePath, rawContent);
|
const allRows = rows();
|
||||||
|
const groupSet = new Set<string>();
|
||||||
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
|
for (const row of allRows) {
|
||||||
const [csvData] = createResource(() => contentOrPath, loadCSV);
|
if (row.group) {
|
||||||
|
groupSet.add(row.group);
|
||||||
// 当数据加载完成后更新 rows
|
|
||||||
createEffect(() => {
|
|
||||||
const data = csvData();
|
|
||||||
if (data) {
|
|
||||||
// 将加载的数据赋值给 rows,CSV 类型已经包含 sourcePath 等属性
|
|
||||||
setRows(data as unknown as CSV<TableRow>);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
return Array.from(groupSet).sort();
|
||||||
|
});
|
||||||
|
|
||||||
// 检测是否有 group 列
|
// 根据当前选中的分组过滤行
|
||||||
const hasGroup = createMemo(() => {
|
const filteredRows = createMemo(() => {
|
||||||
const allRows = rows();
|
const allRows = rows();
|
||||||
return allRows.length > 0 && "group" in allRows[0];
|
const group = activeGroup();
|
||||||
});
|
if (!group) return allRows;
|
||||||
|
return allRows.filter(row => row.group === group);
|
||||||
|
});
|
||||||
|
|
||||||
// 获取所有分组
|
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||||
const groups = createMemo(() => {
|
const processBody = (body: string, currentRow: TableRow): string => {
|
||||||
if (!hasGroup()) return [];
|
// 使用 marked 解析 markdown
|
||||||
const allRows = rows();
|
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
|
||||||
const groupSet = new Set<string>();
|
};
|
||||||
for (const row of allRows) {
|
|
||||||
if (row.group) {
|
|
||||||
groupSet.add(row.group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(groupSet).sort();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 根据当前选中的分组过滤行
|
// 更新 body 内容
|
||||||
const filteredRows = createMemo(() => {
|
const updateBodyContent = () => {
|
||||||
const allRows = rows();
|
const filtered = filteredRows();
|
||||||
const group = activeGroup();
|
if (!csvData.loading && filtered.length > 0) {
|
||||||
if (!group) return allRows;
|
const index = Math.min(activeTab(), filtered.length - 1);
|
||||||
return allRows.filter((row) => row.group === group);
|
const currentRow = filtered[index];
|
||||||
});
|
setBodyHtml(processBody(currentRow.body, currentRow));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
// 监听 activeTab 和 activeGroup 变化并更新内容
|
||||||
const processBody = (body: string, currentRow: TableRow): string => {
|
createEffect(() => {
|
||||||
// 使用 marked 解析 markdown
|
activeTab();
|
||||||
return marked.parse(
|
activeGroup();
|
||||||
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
|
updateBodyContent();
|
||||||
) as string;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// 更新 body 内容
|
// 切换分组时重置 tab 索引
|
||||||
const updateBodyContent = () => {
|
const handleGroupChange = (group: string | null) => {
|
||||||
const filtered = filteredRows();
|
setActiveGroup(group);
|
||||||
if (!csvData.loading && filtered.length > 0) {
|
setActiveTab(0);
|
||||||
const index = Math.min(activeTab(), filtered.length - 1);
|
};
|
||||||
const currentRow = filtered[index];
|
|
||||||
setBodyHtml(processBody(currentRow.body, currentRow));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听 activeTab 和 activeGroup 变化并更新内容
|
// 随机切换 tab
|
||||||
createEffect(() => {
|
const handleRoll = () => {
|
||||||
activeTab();
|
const filtered = filteredRows();
|
||||||
activeGroup();
|
if (filtered.length === 0) return;
|
||||||
updateBodyContent();
|
|
||||||
});
|
// 检查所有 label 是否都是整数/整数范围格式
|
||||||
|
const labels = filtered.map(row => row.label);
|
||||||
// 切换分组时重置 tab 索引
|
if (areAllLabelsNumeric(labels)) {
|
||||||
const handleGroupChange = (group: string | null) => {
|
// 使用加权随机
|
||||||
setActiveGroup(group);
|
const randomIndex = weightedRandomIndex(labels);
|
||||||
setActiveTab(0);
|
if (randomIndex !== -1) {
|
||||||
};
|
|
||||||
|
|
||||||
// 随机切换 tab
|
|
||||||
const handleRoll = () => {
|
|
||||||
const filtered = filteredRows();
|
|
||||||
if (filtered.length === 0) return;
|
|
||||||
|
|
||||||
// 检查所有 label 是否都是整数/整数范围格式
|
|
||||||
const labels = filtered.map((row) => row.label);
|
|
||||||
if (areAllLabelsNumeric(labels)) {
|
|
||||||
// 使用加权随机
|
|
||||||
const randomIndex = weightedRandomIndex(labels);
|
|
||||||
if (randomIndex !== -1) {
|
|
||||||
setActiveTab(randomIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 使用简单随机
|
|
||||||
const randomIndex = Math.floor(Math.random() * filtered.length);
|
|
||||||
setActiveTab(randomIndex);
|
setActiveTab(randomIndex);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 使用简单随机
|
||||||
|
const randomIndex = Math.floor(Math.random() * filtered.length);
|
||||||
|
setActiveTab(randomIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到可视区域
|
||||||
|
setTimeout(() => {
|
||||||
|
const activeButton = tabsContainer?.querySelector('.border-blue-600');
|
||||||
|
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
// 滚动到可视区域
|
return (
|
||||||
setTimeout(() => {
|
<div class="ttrpg-table">
|
||||||
const activeButton = tabsContainer?.querySelector(".border-blue-600");
|
<div class="flex items-center gap-2 border-b border-gray-200">
|
||||||
activeButton?.scrollIntoView({
|
<div class="flex flex-col overflow-x-auto">
|
||||||
behavior: "smooth",
|
{/* 分组 tabs */}
|
||||||
block: "nearest",
|
<Show when={hasGroup()}>
|
||||||
inline: "nearest",
|
<div class="flex gap-2 border-b border-gray-100 pb-2">
|
||||||
});
|
<button
|
||||||
}, 50);
|
onClick={() => handleGroupChange(null)}
|
||||||
};
|
class={`font-medium transition-colors ${
|
||||||
|
activeGroup() === null
|
||||||
return (
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
<div class="ttrpg-table">
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
<div class="flex items-center gap-2 border-b border-gray-200">
|
}`}
|
||||||
<div class="flex flex-col overflow-x-auto">
|
|
||||||
{/* 分组 tabs */}
|
|
||||||
<Show when={hasGroup()}>
|
|
||||||
<div class="flex gap-2 border-b border-gray-100 pb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleGroupChange(null)}
|
|
||||||
class={`font-medium transition-colors ${
|
|
||||||
activeGroup() === null
|
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-500 hover:text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
<For each={groups()}>
|
|
||||||
{(group) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleGroupChange(group)}
|
|
||||||
class={`font-medium transition-colors ${
|
|
||||||
activeGroup() === group
|
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-gray-500 hover:text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{group}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
{/* 内容 tabs */}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={props.roll}>
|
|
||||||
<button
|
|
||||||
onClick={handleRoll}
|
|
||||||
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
|
|
||||||
title="随机切换"
|
|
||||||
>
|
|
||||||
🎲
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
ref={tabsContainer}
|
|
||||||
class="flex gap-1 overflow-x-auto flex-1 min-w-0"
|
|
||||||
>
|
>
|
||||||
<For each={filteredRows()}>
|
全部
|
||||||
{(row, index) => (
|
</button>
|
||||||
<button
|
<For each={groups()}>
|
||||||
onClick={() => setActiveTab(index())}
|
{(group) => (
|
||||||
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer ${
|
<button
|
||||||
activeTab() === index()
|
onClick={() => handleGroupChange(group)}
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
class={`font-medium transition-colors ${
|
||||||
: "text-gray-500 hover:text-gray-700"
|
activeGroup() === group
|
||||||
}`}
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
>
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
{row.label}
|
}`}
|
||||||
</button>
|
>
|
||||||
)}
|
{group}
|
||||||
</For>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
{/* 内容 tabs */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Show when={props.roll}>
|
||||||
|
<button
|
||||||
|
onClick={handleRoll}
|
||||||
|
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
|
||||||
|
title="随机切换"
|
||||||
|
>
|
||||||
|
🎲
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
|
||||||
|
<For each={filteredRows()}>
|
||||||
|
{(row, index) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab(index())}
|
||||||
|
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
|
||||||
|
activeTab() === index()
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 prose max-w-none">
|
|
||||||
<Show when={!csvData.loading && filteredRows().length > 0}>
|
|
||||||
<div innerHTML={bodyHtml()} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div class="p-4 prose max-w-none">
|
||||||
},
|
<Show when={!csvData.loading && filteredRows().length > 0}>
|
||||||
);
|
<div innerHTML={bodyHtml()} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue