├── api ├── README.md ├── .npmrc ├── test │ ├── healthcheck.mjs │ └── callback.mjs ├── src │ ├── init.ts │ ├── env.d.ts │ ├── useTemp.ts │ ├── access_control.ts │ ├── db │ │ ├── entity │ │ │ ├── record.ts │ │ │ ├── font.ts │ │ │ └── webhook.ts │ │ └── db.ts │ ├── main.ts │ ├── middleware │ │ ├── access │ │ │ └── index.ts │ │ ├── stream │ │ │ ├── StreamTransform.ts │ │ │ └── index.ts │ │ └── webhook.ts │ ├── oss │ │ └── index.ts │ └── routers │ │ ├── webhook.ts │ │ ├── fonts.ts │ │ └── split.ts ├── Dockerfile ├── package.json └── tsconfig.json ├── notification ├── .npmrc ├── src │ ├── index.ts │ ├── RemoteStorage.ts │ ├── utils │ │ └── getSelfIPs.ts │ ├── core.ts │ ├── RemoteFactory.ts │ └── adapter │ │ └── COS.ts ├── Dockerfile ├── test │ ├── cos.mjs │ ├── clear.mjs │ └── index.html ├── package.json ├── README.md └── tsconfig.json ├── scripts ├── init.sh ├── initDev.sh ├── injectFonts.sh └── downloadFonts.sh ├── admin ├── .npmrc ├── server │ └── tsconfig.json ├── public │ └── favicon.ico ├── .gitignore ├── pages │ ├── minio │ │ └── index.vue │ ├── index.vue │ ├── user-font │ │ ├── building.vue │ │ ├── detail │ │ │ └── [id].vue │ │ └── index.vue │ ├── result-font │ │ └── index.vue │ └── webhook │ │ ├── logs │ │ └── [id].vue │ │ └── index.vue ├── tsconfig.json ├── assets │ └── css │ │ └── index.css ├── nuxt.config.ts ├── store │ └── useAuthStore.ts ├── README.md ├── package.json └── layouts │ └── default.vue ├── assets ├── arch.png └── File_Path.png ├── .gitignore ├── LICENSE ├── docker-compose.yml ├── README.md └── api.yml /api/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com -------------------------------------------------------------------------------- /notification/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com -------------------------------------------------------------------------------- /api/test/healthcheck.mjs: -------------------------------------------------------------------------------- 1 | fetch("http://localhost:3000"); 2 | -------------------------------------------------------------------------------- /scripts/init.sh: -------------------------------------------------------------------------------- 1 | sh ./scripts/downloadFonts.sh # 下载测试所需字体 2 | -------------------------------------------------------------------------------- /admin/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | registry=https://registry.npm.taobao.org -------------------------------------------------------------------------------- /admin/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /assets/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KonghaYao/font-server/HEAD/assets/arch.png -------------------------------------------------------------------------------- /assets/File_Path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KonghaYao/font-server/HEAD/assets/File_Path.png -------------------------------------------------------------------------------- /admin/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KonghaYao/font-server/HEAD/admin/public/favicon.ico -------------------------------------------------------------------------------- /api/src/init.ts: -------------------------------------------------------------------------------- 1 | import { initMinio } from "./oss/index.js"; 2 | 3 | await initMinio(); 4 | await import("./db/db.js"); 5 | -------------------------------------------------------------------------------- /notification/src/index.ts: -------------------------------------------------------------------------------- 1 | export { COSAdapter } from "./adapter/COS.js"; 2 | export { PusherCore } from "./core.js"; 3 | -------------------------------------------------------------------------------- /admin/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /admin/pages/minio/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /scripts/initDev.sh: -------------------------------------------------------------------------------- 1 | cd ./api && npm install 2 | cd ../notification && npm install 3 | docker-compose up -d 4 | cd ../ 5 | sh scripts/init.sh -------------------------------------------------------------------------------- /api/src/env.d.ts: -------------------------------------------------------------------------------- 1 | // 声明全局变量 2 | declare global { 3 | namespace NodeJS { 4 | interface Global { 5 | _fetch: typeof globalThis.fetch; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/useTemp.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import path from "path"; 3 | export const createTempPath = (...args: string[]) => { 4 | return path.join(os.tmpdir(), ...args); 5 | }; 6 | -------------------------------------------------------------------------------- /notification/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-slim 2 | RUN apt-get update && apt-get install curl -y 3 | WORKDIR /app 4 | COPY . . 5 | RUN npm install 6 | RUN npm run build 7 | # CMD 不进行使用,在 docker-compose 中定义开始文件 -------------------------------------------------------------------------------- /api/src/access_control.ts: -------------------------------------------------------------------------------- 1 | import { AppAccess } from "./middleware/access/index.js"; 2 | 3 | export const AccessControl = new AppAccess( 4 | process.env.ADMIN_TOKEN!, 5 | process.env.READABLE_TOKEN! 6 | ); 7 | -------------------------------------------------------------------------------- /admin/assets/css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | height: 100%; 4 | } 5 | html { 6 | height: 100%; 7 | } 8 | #__nuxt { 9 | height: 100%; 10 | } 11 | 12 | .el-row { 13 | margin-bottom: 20px; 14 | } 15 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-slim 2 | RUN apt-get update && apt-get install curl -y 3 | RUN npm install -g pm2 --registry=https://registry.npmmirror.com 4 | WORKDIR /app 5 | COPY . . 6 | RUN npm install 7 | RUN npm run build 8 | CMD npm run deploy 9 | # CMD node dist/main.js -------------------------------------------------------------------------------- /scripts/injectFonts.sh: -------------------------------------------------------------------------------- 1 | echo "注入测试字体" 2 | # HOST="http://localhost:3000" 3 | 4 | find ./data/fonts -type f -name '*.ttf' -exec curl -X POST -H "Content-Type: multipart/form-data" -F "font=@{}" "${HOST}/fonts" \; 5 | 6 | echo "开始进行第一个文件切割,大概需要几十秒" 7 | curl -N -F "id=1" -F "md5=daf00ca6691141c1508ef912397947f4" ${HOST}/split -------------------------------------------------------------------------------- /admin/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | ssr: false, 4 | modules: [ 5 | "@element-plus/nuxt", 6 | "@pinia/nuxt", 7 | "@pinia-plugin-persistedstate/nuxt", 8 | "@vueuse/nuxt", 9 | ], 10 | css: ["@/assets/css/index.css"], 11 | }); 12 | -------------------------------------------------------------------------------- /notification/src/RemoteStorage.ts: -------------------------------------------------------------------------------- 1 | export type UploadSingleStream = (path: string) => Promise; 2 | 3 | export interface RemoteStorage { 4 | /** 1. 初始化远程服务器 */ 5 | init(): Promise; 6 | /** 2. 订阅 IP 地址 */ 7 | subscribeWebHook(): Promise; 8 | /** 3. 同步内部对象存储到远程 */ 9 | syncAllFiles(): Promise; 10 | /** 4. 接收到订阅 */ 11 | getSyncMessage(payload: { files: string[] }): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /scripts/downloadFonts.sh: -------------------------------------------------------------------------------- 1 | echo "下载测试字体中" 2 | mkdir -p ./data/fonts 3 | 4 | curl -LJO https://github.com/Pal3love/Source-Han-TrueType/releases/download/2.004-2.001-1.002-R/SourceHanSansCN.zip 5 | unzip SourceHanSansCN.zip -d ./data/fonts/ 6 | rm -f SourceHanSansCN.zip 7 | 8 | curl -LJO https://github.com/atelier-anchor/smiley-sans/releases/download/v1.1.1/smiley-sans-v1.1.1.zip 9 | unzip smiley-sans-v1.1.1.zip -d ./data/fonts/ 10 | rm -f smiley-sans-v1.1.1.zip 11 | 12 | echo "测试字体装载完成" -------------------------------------------------------------------------------- /admin/store/useAuthStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | export const useAuthStore = defineStore("auth", { 4 | state() { 5 | return { 6 | Root: "https://api-konghayao.cloud.okteto.net", 7 | access_token: "api_admin_66618273", 8 | }; 9 | }, 10 | getters: { 11 | authHeader: (state) => ({ 12 | Authorization: "Bearer " + state.access_token, 13 | }), 14 | }, 15 | 16 | persist: true, 17 | }); 18 | -------------------------------------------------------------------------------- /notification/src/utils/getSelfIPs.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | 3 | export const getSelfIPs = () => { 4 | const networkInterfaces = os.networkInterfaces(); 5 | 6 | return ( 7 | /** @ts-ignore */ 8 | Object.values(networkInterfaces) 9 | /** @ts-ignore */ 10 | .reduce((acc, val) => acc.concat(val), []) 11 | .filter( 12 | (iface) => iface.family === "IPv4" && iface.internal === false 13 | ) 14 | .map((iface) => iface.address) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /api/test/callback.mjs: -------------------------------------------------------------------------------- 1 | import Koa from "koa"; 2 | import { koaBody } from "koa-body"; 3 | const app = new Koa(); 4 | 5 | // 一些中间件 6 | app.use( 7 | koaBody({ 8 | json: true, 9 | multipart: true, 10 | formidable: { 11 | maxFieldsSize: 20 * 1024 * 1024, 12 | keepExtensions: true, 13 | hashAlgorithm: "md5", 14 | }, 15 | }) 16 | ).use(async (ctx) => { 17 | console.log(ctx.request.body); 18 | }); 19 | 20 | app.listen(8888, () => { 21 | console.log("测试服务启动了"); 22 | }); 23 | -------------------------------------------------------------------------------- /api/src/db/entity/record.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | CreateDateColumn, 4 | PrimaryGeneratedColumn, 5 | UpdateDateColumn, 6 | } from "typeorm"; 7 | 8 | export class Record extends BaseEntity { 9 | @PrimaryGeneratedColumn() 10 | id!: number; 11 | 12 | @CreateDateColumn({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) 13 | created_at!: Date; 14 | 15 | @UpdateDateColumn({ 16 | type: "timestamp", 17 | default: () => "CURRENT_TIMESTAMP", 18 | onUpdate: "CURRENT_TIMESTAMP", 19 | }) 20 | updated_at!: Date; 21 | } 22 | -------------------------------------------------------------------------------- /notification/test/cos.mjs: -------------------------------------------------------------------------------- 1 | import { COSAdapter } from "../dist/adapter/COS.js"; 2 | import { PusherCore } from "../dist/core.js"; 3 | 4 | const adapter = new COSAdapter( 5 | { 6 | SecretId: process.env.SecretId, 7 | SecretKey: process.env.SecretKey, 8 | }, 9 | { 10 | Bucket: process.env.Bucket, 11 | Region: process.env.Region, 12 | WEBHOOK_HOST: process.env.WEBHOOK_HOST, 13 | MINIO_HOST: process.env.MINIO_HOST, 14 | } 15 | ); 16 | const core = new PusherCore(adapter, { 17 | port: process.env.PORT ? parseInt(process.env.PORT) : 3001, 18 | }); 19 | 20 | try { 21 | await core.run(); 22 | } catch (e) { 23 | console.error(e); 24 | } 25 | -------------------------------------------------------------------------------- /notification/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@konghayao/font-server-pusher", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "dotenv node ./test/cos.mjs", 8 | "build": "tsc" 9 | }, 10 | "type": "module", 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "axios": "^1.4.0", 15 | "cos-nodejs-sdk-v5": "^2.12.1", 16 | "koa": "^2.14.2", 17 | "koa-body": "^6.0.1", 18 | "p-limit": "^4.0.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20.1.7", 22 | "@types/p-limit": "^2.2.0", 23 | "typescript": "^5.0.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # yarn 11 | yarn install 12 | 13 | # npm 14 | npm install 15 | 16 | # pnpm 17 | pnpm install 18 | ``` 19 | 20 | ## Development Server 21 | 22 | Start the development server on `http://localhost:3000` 23 | 24 | ```bash 25 | npm run dev 26 | ``` 27 | 28 | ## Production 29 | 30 | Build the application for production: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | Locally preview production build: 37 | 38 | ```bash 39 | npm run preview 40 | ``` 41 | 42 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 43 | -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@element-plus/nuxt": "^1.0.5", 13 | "@pinia-plugin-persistedstate/nuxt": "^1.1.1", 14 | "@types/node": "^18", 15 | "@vueuse/core": "^10.1.2", 16 | "@vueuse/nuxt": "^10.1.2", 17 | "element-plus": "^2.3.5", 18 | "nuxt": "^3.5.0" 19 | }, 20 | "dependencies": { 21 | "@pinia/nuxt": "^0.4.11", 22 | "filesize": "^10.0.7", 23 | "pinia": "^2.1.3", 24 | "pinia-plugin-persistedstate": "^3.1.0", 25 | "vue": "^3.3.2", 26 | "vue-web-terminal": "^3.1.7" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/src/db/db.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { FontSource, FontSplit } from "./entity/font.js"; 3 | import { WebHook, WebHookLog } from "./entity/webhook.js"; 4 | 5 | export const AppDataSource = await new DataSource({ 6 | type: "postgres", 7 | host: process.env.DB_HOST, 8 | port: 5432, 9 | username: process.env.DB_USERNAME, 10 | password: process.env.DB_PASSWORD, 11 | database: "postgres", 12 | synchronize: true, 13 | logging: false, 14 | entities: [FontSource, FontSplit, WebHook, WebHookLog], 15 | subscribers: [], 16 | migrations: [], 17 | }) 18 | .initialize() 19 | .then((app) => { 20 | console.log("构建数据库完成"); 21 | return app; 22 | }); 23 | export const FontSourceRepo = AppDataSource.getRepository(FontSource); 24 | export const FontSplitRepo = AppDataSource.getRepository(FontSplit); 25 | export const WebHookRepo = AppDataSource.getRepository(WebHook); 26 | -------------------------------------------------------------------------------- /admin/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Wordpress - ignore core, configuration, examples, uploads and logs. 2 | # https://github.com/github/gitignore/blob/main/WordPress.gitignore 3 | 4 | # Core 5 | # 6 | # Note: if you want to stage/commit WP core files 7 | # you can delete this whole section/until Configuration. 8 | /wp-admin/ 9 | /wp-content/index.php 10 | /wp-content/languages 11 | /wp-content/plugins/index.php 12 | /wp-content/themes/index.php 13 | /wp-includes/ 14 | /index.php 15 | /license.txt 16 | /readme.html 17 | /wp-*.php 18 | /xmlrpc.php 19 | node_modules 20 | dist 21 | data 22 | temp 23 | .env 24 | # Configuration 25 | wp-config.php 26 | 27 | # Example themes 28 | /wp-content/themes/twenty*/ 29 | 30 | # Example plugin 31 | /wp-content/plugins/hello.php 32 | 33 | # Uploads 34 | /wp-content/uploads/ 35 | 36 | # Log files 37 | *.log 38 | 39 | # htaccess 40 | /.htaccess 41 | 42 | # All plugins 43 | # 44 | # Note: If you wish to whitelist plugins, 45 | # uncomment the next line 46 | #/wp-content/plugins 47 | 48 | # All themes 49 | # 50 | # Note: If you wish to whitelist themes, 51 | # uncomment the next line 52 | #/wp-content/themes -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 江夏尧 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /notification/README.md: -------------------------------------------------------------------------------- 1 | # Font Server Pusher 2 | 3 | 这是 Font Server 的数据同步推送服务器,通过检查机制和推送机制将 Font Server 系统中的文件同步到远程的云存储中。 4 | 5 | 由于云存储部署方式不同,我们提供了不同的 adapter 方式帮助你接入到各个厂商的云存储系统中,并自动运行同步操作。已经接入: 6 | 7 | 1. ✅ 腾讯云 COS 8 | 9 | ## 使用指南 10 | 11 | ### 定义环境变量 12 | 13 | 环境变量分为 font-server 后台的一些服务和 Adapter 层需要填写的信息 14 | 15 | ```sh 16 | 17 | WEBHOOK_HOST=http://localhost:3000 18 | MINIO_HOST=http://localhost:9000 19 | PORT=3001 20 | 21 | SecretId= 22 | SecretKey= 23 | Bucket= 24 | Region=ap-nanjing 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | ### 编写入口代码 32 | 33 | ```js 34 | import { PusherCore, COSAdapter } from "@konghayao/font-server-pusher"; 35 | 36 | const core = new PusherCore( 37 | new COSAdapter( 38 | { 39 | SecretId: process.env.SecretId, 40 | SecretKey: process.env.SecretKey, 41 | }, 42 | { 43 | Bucket: process.env.Bucket, 44 | Region: process.env.Region, 45 | WEBHOOK_HOST: process.env.WEBHOOK_HOST, 46 | MINIO_HOST: process.env.MINIO_HOST, 47 | } 48 | ), 49 | { port: process.env.PORT ? parseInt(process.env.PORT) : 3001 } 50 | ); 51 | core.run(); 52 | ``` 53 | -------------------------------------------------------------------------------- /admin/pages/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 39 | -------------------------------------------------------------------------------- /api/src/db/entity/font.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, ManyToOne, OneToMany, Unique } from "typeorm"; 2 | import { Record } from "./record.js"; 3 | 4 | /** 字体原始文件存储 */ 5 | @Entity() 6 | export class FontSource extends Record { 7 | /** 对应内部 OSS 中的 URL */ 8 | @Column() 9 | path!: string; 10 | 11 | @Column() 12 | name!: string; 13 | @Column() 14 | size!: number; 15 | 16 | @Column() 17 | @Unique("user_fonts_unique", ["md5"]) 18 | md5!: string; 19 | 20 | // 一个字体包含多个切割版本 21 | @OneToMany(() => FontSplit, (sp) => sp.source) 22 | versions!: FontSplit[]; 23 | } 24 | 25 | export enum SplitEnum { 26 | idle = 0, 27 | cutting = 1, 28 | success = 2, 29 | } 30 | 31 | /** 切割字体的存储 */ 32 | @Entity() 33 | export class FontSplit extends Record { 34 | @ManyToOne(() => FontSource, (source) => source.versions) 35 | source!: FontSource; 36 | /** 对应内部 OSS 中的切割文件成果文件夹的 URL */ 37 | @Column("text", { nullable: true }) 38 | folder!: string; 39 | 40 | @Column("text", { array: true, nullable: true }) 41 | files!: string[]; 42 | 43 | @Column({ 44 | type: "enum", 45 | enum: SplitEnum, 46 | default: SplitEnum.idle, 47 | }) 48 | state!: SplitEnum; 49 | } 50 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/main.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "ADMIN_TOKEN=api_admin_66618273 READABLE_TOKEN=api_readable_4173 DB_HOST=localhost DB_USERNAME=postgres DB_PASSWORD=postgres MINIO_POINT=localhost MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin node dist/main.js ", 9 | "deploy": "node ./dist/init.js && pm2 start dist/main.js -i max -n font-server --no-daemon", 10 | "build": "tsc" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@koa/cors": "^4.0.0", 16 | "@koa/router": "^12.0.0", 17 | "@konghayao/cn-font-split": "^3.4.0", 18 | "koa": "^2.14.2", 19 | "koa-body": "^6.0.1", 20 | "koa-logger": "^3.2.1", 21 | "minio": "^7.1.0", 22 | "pg": "^8.10.0", 23 | "typeorm": "^0.3.16" 24 | }, 25 | "devDependencies": { 26 | "@types/koa": "^2.13.6", 27 | "@types/koa__cors": "^4.0.0", 28 | "@types/koa__router": "^12.0.0", 29 | "@types/koa-logger": "^3.1.2", 30 | "@types/minio": "^7.0.18", 31 | "fonteditor-core": "^2.1.11", 32 | "typescript": "^5.0.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/src/db/entity/webhook.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | ManyToOne, 5 | OneToMany, 6 | Unique, 7 | JoinColumn, 8 | } from "typeorm"; 9 | import { Record } from "./record.js"; 10 | 11 | /** web hook */ 12 | @Entity() 13 | export class WebHook extends Record { 14 | /** 监听的 URL */ 15 | @Column() 16 | @Unique("url_unique", ["url"]) 17 | url!: string; 18 | 19 | @OneToMany((type) => WebHookLog, (webHookLog) => webHookLog.id, { 20 | onDelete: "CASCADE", 21 | }) 22 | logs!: WebHookLog[]; 23 | } 24 | 25 | export enum WebHookCBState { 26 | pending = 0, 27 | success = 1, 28 | error = 2, 29 | } 30 | export enum WebHookEvent { 31 | NULL = 0, 32 | /** 切割字体完成 */ 33 | SPLIT_SUCCESS = 1, 34 | /** 用户上传字体成功 */ 35 | UPLOAD_SUCCESS = 2, 36 | } 37 | 38 | /** WebHook 触发事件的存储 */ 39 | @Entity() 40 | export class WebHookLog extends Record { 41 | @ManyToOne(() => WebHook, (source) => source.id) 42 | source!: WebHook; 43 | @Column({ 44 | type: "enum", 45 | enum: WebHookCBState, 46 | default: WebHookCBState.pending, 47 | }) 48 | state!: WebHookCBState; 49 | @Column({ 50 | type: "enum", 51 | enum: WebHookEvent, 52 | default: WebHookEvent.NULL, 53 | }) 54 | event!: WebHookEvent; 55 | @Column() 56 | message!: string; 57 | } 58 | -------------------------------------------------------------------------------- /notification/test/clear.mjs: -------------------------------------------------------------------------------- 1 | import COS from "cos-nodejs-sdk-v5"; 2 | const cos = new COS({ 3 | SecretId: process.env.SecretId, 4 | SecretKey: process.env.SecretKey, 5 | }); 6 | const config = { Bucket: process.env.Bucket, Region: process.env.Region }; 7 | var deleteFiles = function (marker) { 8 | cos.getBucket( 9 | { 10 | ...config, 11 | Prefix: "result-fonts/", 12 | Marker: marker, 13 | MaxKeys: 1000, 14 | }, 15 | function (listError, listResult) { 16 | if (listError) return console.log("list error:", listError); 17 | var nextMarker = listResult.NextMarker; 18 | var objects = listResult.Contents.map(function (item) { 19 | return { Key: item.Key }; 20 | }); 21 | cos.deleteMultipleObject( 22 | { 23 | ...config, 24 | Objects: objects, 25 | }, 26 | function (delError, deleteResult) { 27 | if (delError) { 28 | console.log("delete error", delError); 29 | console.log("delete stop"); 30 | } else { 31 | console.log("delete result", deleteResult); 32 | if (listResult.IsTruncated === "true") 33 | deleteFiles(nextMarker); 34 | else console.log("delete complete"); 35 | } 36 | } 37 | ); 38 | } 39 | ); 40 | }; 41 | deleteFiles(); 42 | -------------------------------------------------------------------------------- /api/src/main.ts: -------------------------------------------------------------------------------- 1 | import Koa from "koa"; 2 | import Router from "@koa/router"; 3 | import { koaBody } from "koa-body"; 4 | import logger from "koa-logger"; 5 | import { FontsRouter } from "./routers/fonts.js"; 6 | import { SplitRouter } from "./routers/split.js"; 7 | 8 | import { WebHookRouter } from "./routers/webhook.js"; 9 | import { AccessControl } from "./access_control.js"; 10 | import cors from "@koa/cors"; 11 | const app = new Koa(); 12 | const router = new Router(); 13 | 14 | router 15 | 16 | .use(FontsRouter.routes()) 17 | .use(SplitRouter.routes()) 18 | .use(WebHookRouter.routes()); 19 | 20 | // 一些中间件 21 | app.use( 22 | // 错误拦截 23 | async (ctx, next) => { 24 | try { 25 | await next(); 26 | } catch (err: any) { 27 | ctx.status = err.status || 500; 28 | ctx.body = { status: err.status, error: err.message }; 29 | ctx.app.emit("error", err, ctx); 30 | } 31 | } 32 | ) 33 | .use(logger()) 34 | 35 | .use( 36 | cors({ 37 | origin: process.env.CORS_ORIGIN ?? "*", 38 | }) 39 | ) 40 | .use( 41 | new Router() 42 | .get("/", (ctx) => { 43 | ctx.body = "欢迎使用 Font Server 提供的服务"; 44 | }) 45 | .routes() 46 | ) 47 | .use(AccessControl.protect()) 48 | .use( 49 | koaBody({ 50 | json: true, 51 | multipart: true, 52 | formidable: { 53 | maxFieldsSize: 20 * 1024 * 1024, 54 | keepExtensions: true, 55 | hashAlgorithm: "md5", 56 | }, 57 | }) 58 | ) 59 | .use(router.routes()) 60 | .use(router.allowedMethods()); 61 | 62 | app.listen(3000, () => { 63 | console.log("服务启动了"); 64 | }); 65 | -------------------------------------------------------------------------------- /api/src/middleware/access/index.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "koa"; 2 | 3 | type Roles = "null" | "reader" | "admin"; 4 | 5 | declare module "koa" { 6 | interface Request { 7 | acl: Roles; 8 | } 9 | } 10 | export class AppAccess { 11 | constructor(public adminToken: string, public readableToken: string) { 12 | this.roles.set(adminToken, "admin"); 13 | this.roles.set(readableToken, "reader"); 14 | } 15 | roles = new Map(); 16 | 17 | /** 总路径中解析头部获取验证信息 */ 18 | protect(): Middleware { 19 | return async (ctx, next) => { 20 | const auth = ctx.request.headers.authorization; 21 | if (auth) { 22 | const [method, token] = auth.split(" "); 23 | if (method === "Bearer" && this.roles.has(token)) { 24 | const acl = this.roles.get(token)!; 25 | ctx.request.acl = acl; 26 | await next(); 27 | } else { 28 | ctx.status = 415; 29 | ctx.body = JSON.stringify({ 30 | error: "用户验证方式错误或者 token 不存在", 31 | }); 32 | } 33 | } else { 34 | ctx.status = 401; 35 | ctx.body = JSON.stringify({ 36 | error: "用户未验证,请使用 access token 请求 API", 37 | }); 38 | } 39 | }; 40 | } 41 | check(...args: Roles[]): Middleware { 42 | return async (ctx, next) => { 43 | if (args.includes(ctx.request.acl)) { 44 | await next(); 45 | } else { 46 | ctx.status = 403; 47 | ctx.body = JSON.stringify({ 48 | error: ctx.request.acl + "未满足权限", 49 | }); 50 | } 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /admin/pages/user-font/building.vue: -------------------------------------------------------------------------------- 1 | 19 | 59 | -------------------------------------------------------------------------------- /api/src/middleware/stream/StreamTransform.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from "stream"; 2 | import Koa from "koa"; 3 | 4 | export class StreamTransform extends Transform { 5 | opts: { opts: any; closeEvent: "close" }; 6 | ended: boolean; 7 | constructor(public ctx: Koa.Context, opts: any = {}) { 8 | super({ 9 | writableObjectMode: true, 10 | }); 11 | this.opts = { 12 | closeEvent: "close", 13 | ...opts, 14 | }; 15 | this.ended = false; 16 | ctx.req.socket.setTimeout(0); 17 | ctx.req.socket.setNoDelay(true); 18 | ctx.req.socket.setKeepAlive(true); 19 | ctx.set({ 20 | "Content-Type": "text/plain", 21 | "Cache-Control": "no-cache, no-transform", 22 | Connection: "keep-alive", 23 | // 'Keep-Alive': 'timeout=120', 24 | "X-Accel-Buffering": "no", 25 | }); 26 | } 27 | 28 | send( 29 | _data: string | Object, 30 | encoding?: undefined, 31 | callback?: (error: Error | null | undefined) => void 32 | ) { 33 | const data = typeof _data === "string" ? _data : JSON.stringify(_data); 34 | if (arguments.length === 0 || this.ended) return false; 35 | this.write(data, encoding, callback); 36 | } 37 | 38 | sendEnd( 39 | _data?: string | Object, 40 | encoding?: undefined, 41 | callback?: () => void 42 | ) { 43 | const data = typeof _data === "string" ? _data : JSON.stringify(_data); 44 | if (this.ended) { 45 | return false; 46 | } 47 | 48 | this.ended = true; 49 | if (!this.ended) { 50 | this.ended = true; 51 | } 52 | this.end(data, encoding, callback); 53 | } 54 | 55 | _transform(data: string, encoding?: any, callback?: () => void) { 56 | // Concentrated to send 57 | this.push(data + "\n"); 58 | callback && callback(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /api/src/middleware/webhook.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "koa"; 2 | import { WebHookRepo } from "../db/db.js"; 3 | import { 4 | WebHookCBState, 5 | WebHookEvent, 6 | WebHookLog, 7 | } from "../db/entity/webhook.js"; 8 | 9 | declare module "koa" { 10 | interface Response { 11 | webhook?: { 12 | event: WebHookEvent; 13 | payload: any; 14 | }; 15 | } 16 | } 17 | 18 | /** 自动发布 WebHook 事件 */ 19 | export const webhook = (): Middleware => { 20 | return async (ctx, next) => { 21 | await next(); 22 | if (!ctx.response.webhook) return; 23 | const hookMessage = ctx.response.webhook; 24 | // 副作用,不用考虑等待问题 25 | WebHookRepo.find().then((hooks) => { 26 | hooks.forEach(async (i) => { 27 | const log = WebHookLog.create({ 28 | source: i, 29 | state: WebHookCBState.pending, 30 | message: "", 31 | event: hookMessage!.event, 32 | }); 33 | await log.save(); 34 | 35 | const fetch: (typeof globalThis)["fetch"] = (globalThis as any) 36 | ._fetch; 37 | return fetch(i.url, { 38 | method: "post", 39 | headers: { 40 | "content-type": "application/json", 41 | "x-font-server": "", 42 | }, 43 | body: JSON.stringify(hookMessage), 44 | }) 45 | .then((res) => res.json()) 46 | .then((res: any) => { 47 | log.message = res.message; 48 | log.state = WebHookCBState.success; 49 | 50 | return log.save(); 51 | }) 52 | .catch((e: Error) => { 53 | log.message = e.toString(); 54 | log.state = WebHookCBState.error; 55 | return log.save(); 56 | }); 57 | }); 58 | }); 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /api/src/oss/index.ts: -------------------------------------------------------------------------------- 1 | import Minio from "minio"; 2 | 3 | export const minioClient = new Minio.Client({ 4 | endPoint: process.env.MINIO_POINT!, 5 | port: 9000, 6 | useSSL: false, 7 | accessKey: process.env.MINIO_ROOT_USER!, 8 | secretKey: process.env.MINIO_ROOT_PASSWORD!, 9 | }); 10 | 11 | /** 初始化 MINIO */ 12 | export const initMinio = async () => { 13 | await ensureBucket("user-fonts"); 14 | await ensureBucket("result-fonts"); 15 | }; 16 | 17 | export const ensureBucket = async (name: string) => { 18 | const isExist = await minioClient.bucketExists(name); 19 | if (!isExist) { 20 | console.log("重新构建 MINIO ", name); 21 | await minioClient.makeBucket(name, ""); 22 | 23 | await minioClient.setBucketPolicy( 24 | name, 25 | JSON.stringify({ 26 | Version: "2012-10-17", 27 | Statement: [ 28 | { 29 | Effect: "Allow", 30 | Principal: { 31 | AWS: ["*"], 32 | }, 33 | Action: ["s3:GetBucketLocation"], 34 | Resource: [`arn:aws:s3:::${name}`], 35 | }, 36 | { 37 | Effect: "Allow", 38 | Principal: { 39 | AWS: ["*"], 40 | }, 41 | Action: ["s3:ListBucket"], 42 | Resource: [`arn:aws:s3:::${name}`], 43 | Condition: { 44 | StringEquals: { 45 | "s3:prefix": ["*"], 46 | }, 47 | }, 48 | }, 49 | { 50 | Effect: "Allow", 51 | Principal: { 52 | AWS: ["*"], 53 | }, 54 | Action: ["s3:GetObject"], 55 | Resource: [`arn:aws:s3:::${name}/**`], 56 | }, 57 | ], 58 | }) 59 | ); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /api/src/middleware/stream/index.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "koa"; 2 | import { StreamTransform } from "./StreamTransform.js"; 3 | export interface StreamConfig { 4 | maxClients: number; 5 | pingInterval: number; 6 | matchQuery: string; 7 | closeEvent: string; 8 | } 9 | 10 | declare module "koa" { 11 | interface Response { 12 | stream?: StreamTransform; 13 | } 14 | } 15 | const DEFAULT_OPTS: StreamConfig = { 16 | maxClients: 10000, 17 | pingInterval: 60000, 18 | closeEvent: "close", 19 | matchQuery: "", 20 | }; 21 | /** 22 | * koa stream text callback 23 | * 控制台数据流式返回 24 | */ 25 | export function stream(opts: Partial = {}): Middleware { 26 | const config = Object.assign({}, DEFAULT_OPTS, opts); 27 | const streamPool = new Set(); 28 | 29 | return async (ctx, next) => { 30 | if (ctx.res.headersSent) { 31 | if (!(ctx.sse instanceof StreamTransform)) { 32 | console.error( 33 | "SSE response header has been send, Unable to create the sse response" 34 | ); 35 | } 36 | return await next(); 37 | } 38 | if (streamPool.size >= config.maxClients ?? 0) { 39 | console.error( 40 | "SSE sse client number more than the maximum, Unable to create the sse response" 41 | ); 42 | return await next(); 43 | } 44 | if ( 45 | config.matchQuery && 46 | typeof ctx.query[config.matchQuery] === "undefined" 47 | ) { 48 | return await next(); 49 | } 50 | let stream = new StreamTransform(ctx); 51 | streamPool.add(stream); 52 | stream.on("close", function () { 53 | streamPool.delete(stream); 54 | }); 55 | ctx.response.stream = stream; 56 | 57 | // ! 不能进行异步等待,需要直接返回数据 58 | next().catch((e) => { 59 | ctx.response.stream?.sendEnd({ 60 | error: e.message, 61 | }); 62 | }); 63 | 64 | if (!ctx.body) { 65 | ctx.body = ctx.response.stream; 66 | } else { 67 | if (!ctx.response.stream.ended) { 68 | ctx.response.stream.send(ctx.body); 69 | } 70 | ctx.body = stream; 71 | } 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /notification/src/core.ts: -------------------------------------------------------------------------------- 1 | import Koa from "koa"; 2 | import { koaBody } from "koa-body"; 3 | import { RemoteStorage } from "./RemoteStorage.js"; 4 | 5 | export interface PusherCoreConfig { 6 | port: number; 7 | } 8 | 9 | export class PusherCore { 10 | constructor( 11 | public adapter: RemoteStorage, 12 | public config: Partial = {} 13 | ) {} 14 | async run() { 15 | const cos = this.adapter; 16 | console.log("初始化远程"); 17 | await cos.init(); 18 | await cos.subscribeWebHook(); 19 | await cos.syncAllFiles(); 20 | 21 | // 记得挂一个缓存控制 22 | // await cos.changeCORS(); 23 | const app = new Koa(); 24 | 25 | // 一些中间件 26 | app.use( 27 | // 错误拦截 28 | async (ctx, next) => { 29 | try { 30 | await next(); 31 | } catch (err: any) { 32 | ctx.status = err.status || 500; 33 | ctx.body = { status: err.status, error: err.message }; 34 | ctx.app.emit("error", err, ctx); 35 | } 36 | } 37 | ) 38 | 39 | .use( 40 | koaBody({ 41 | json: true, 42 | multipart: true, 43 | formidable: { 44 | maxFieldsSize: 20 * 1024 * 1024, 45 | keepExtensions: true, 46 | hashAlgorithm: "md5", 47 | }, 48 | }) 49 | ) 50 | .use(async (ctx) => { 51 | const data = ctx.request.body; 52 | console.log("收到订阅", ctx.request.body); 53 | if (data.event === 1) { 54 | console.log("同步文件夹 ", data.payload.folder); 55 | // console.log(data.payload); 56 | await cos 57 | .getSyncMessage({ 58 | files: data.payload.files.map( 59 | (i: string) => "result-fonts/" + i 60 | ), 61 | }) 62 | .then((res) => { 63 | console.log("同步文件夹完成 ", data.payload.folder); 64 | }); 65 | } 66 | 67 | ctx.body = JSON.stringify({ 68 | success: "成功收到", 69 | }); 70 | }); 71 | app.listen(this.config.port ?? 3001, () => { 72 | console.log("服务器运行中", this.config.port ?? 3001); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | # 这个是自动推动服务,可以删掉不影响主程序使用 4 | pusher: 5 | depends_on: 6 | - api 7 | restart: always 8 | build: ./notification 9 | expose: 10 | - 3001 11 | environment: 12 | - SecretId=${SecretId} 13 | - SecretKey=${SecretKey} 14 | - Bucket=${Bucket} 15 | - Region=${Region} 16 | 17 | - SELF_HOST=pusher 18 | - WEBHOOK_HOST=http://api:3000 19 | - HOOK_TOKEN=api_admin_66618273 20 | - MINIO_HOST=http://minio:9000 21 | - PORT=3001 22 | # 自定义适配层启动器 23 | command: sh -c "node ./test/cos.mjs " 24 | 25 | api: 26 | depends_on: 27 | - minio 28 | - db 29 | build: ./api 30 | restart: always 31 | environment: 32 | # 以下的两个token涉及到内部的访问权限 admin 拥有管理权限,而 readable 只有读取的部分权限 33 | - ADMIN_TOKEN=api_admin_66618273 34 | - READABLE_TOKEN=api_readable_4173 35 | - CORS_ORIGIN=* 36 | 37 | - DB_HOST=db 38 | - PREVIEW_TEXT="中文网字计划\nAaBbCc" 39 | - DB_USERNAME=postgres 40 | - DB_PASSWORD=postgres 41 | - MINIO_POINT=minio 42 | - MINIO_ROOT_USER=minioadmin 43 | - MINIO_ROOT_PASSWORD=minioadmin 44 | expose: 45 | - 3000 46 | ports: 47 | - "3000:3000" 48 | healthcheck: 49 | test: curl -f http://localhost:3000 || exit 1 50 | interval: 20s 51 | timeout: 10s 52 | retries: 3 53 | 54 | db: 55 | image: postgres:14.1-alpine 56 | restart: always 57 | environment: 58 | - POSTGRES_USER=postgres 59 | - POSTGRES_PASSWORD=postgres 60 | expose: 61 | - 5432 62 | volumes: 63 | - ./data/db:/var/lib/postgresql/data 64 | # postgres 数据库需要等待端口启动,才不会导致其它程序 bug 65 | healthcheck: 66 | test: ["CMD-SHELL", "pg_isready -U postgres"] 67 | interval: 10s 68 | timeout: 10s 69 | retries: 5 70 | minio: 71 | image: minio/minio:RELEASE.2023-05-04T21-44-30Z 72 | command: server --console-address ":9001" /data 73 | restart: always 74 | expose: 75 | - 9000 76 | ports: 77 | - "9000:9000" 78 | - "9001:9001" 79 | environment: 80 | - MINIO_ROOT_USER=minioadmin 81 | - MINIO_ROOT_PASSWORD=minioadmin 82 | volumes: 83 | - ./data/minio/data:/data 84 | - ./data/minio/config:/root/.minio/ 85 | -------------------------------------------------------------------------------- /admin/pages/user-font/detail/[id].vue: -------------------------------------------------------------------------------- 1 | 51 | 76 | -------------------------------------------------------------------------------- /api/src/routers/webhook.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import { WebHookRepo } from "../db/db.js"; 3 | import { webhook } from "../middleware/webhook.js"; 4 | import { WebHook, WebHookEvent, WebHookLog } from "../db/entity/webhook.js"; 5 | import { Like } from "typeorm"; 6 | import { AccessControl } from "../access_control.js"; 7 | const WebHookRouter = new Router(); 8 | 9 | /** 获取订阅事件列表 */ 10 | WebHookRouter.get("/webhook", AccessControl.check("admin"), async (ctx) => { 11 | const { limit, offset, q } = ctx.query; 12 | const res = await WebHook.find({ 13 | skip: parseInt(offset as string), 14 | take: parseInt(limit as string), 15 | where: q 16 | ? { 17 | url: Like(`%${q}%`), 18 | } 19 | : undefined, 20 | order: { 21 | id: "DESC", 22 | }, 23 | }); 24 | ctx.body = JSON.stringify(res); 25 | }); 26 | 27 | /** 查询单个订阅 */ 28 | WebHookRouter.get("/webhook/:id", AccessControl.check("admin"), async (ctx) => { 29 | const { id } = ctx.params; 30 | const { logs, limit, offset, self } = ctx.query; 31 | const res = await WebHook.findOne({ 32 | where: { 33 | id: parseInt(id), 34 | }, 35 | }); 36 | 37 | ctx.body = JSON.stringify({ 38 | data: self === "false" ? null : res, 39 | logs: logs 40 | ? await WebHookLog.createQueryBuilder() 41 | .where('"sourceId" = :id', { id: res!.id }) 42 | .skip(parseInt(offset as string)) 43 | .take(parseInt(limit as string)) 44 | .orderBy("id", "DESC") 45 | .getMany() 46 | : null, 47 | }); 48 | }); 49 | 50 | /** 添加一个事件订阅 */ 51 | WebHookRouter.post("/webhook", AccessControl.check("admin"), async (ctx) => { 52 | const url = ctx.request.body.url; 53 | if (url) { 54 | ctx.body = JSON.stringify( 55 | await WebHookRepo.create({ 56 | url, 57 | }).save() 58 | ); 59 | } else { 60 | ctx.body = JSON.stringify({ error: "没有输入 url" }); 61 | } 62 | }); 63 | /** 删除一个事件订阅 */ 64 | WebHookRouter.delete( 65 | "/webhook/:id", 66 | AccessControl.check("admin"), 67 | async (ctx) => { 68 | const { id } = ctx.params; 69 | const item = await WebHookRepo.findOneBy({ id: parseInt(id) }); 70 | if (item) { 71 | const logs = await WebHookLog.createQueryBuilder() 72 | .where('"sourceId" = :id', { id: item.id }) 73 | .getMany(); 74 | await WebHookLog.remove(logs); 75 | ctx.body = JSON.stringify(await WebHookRepo.remove([item])); 76 | } else { 77 | ctx.body = JSON.stringify({ error: `没有发现 id 为${id}` }); 78 | } 79 | } 80 | ); 81 | /** 测试订阅事件 */ 82 | WebHookRouter.patch( 83 | "/webhook", 84 | AccessControl.check("admin"), 85 | webhook(), 86 | async (ctx) => { 87 | const hookMessage = { 88 | event: WebHookEvent.NULL, 89 | payload: { test: 120392039 }, 90 | }; 91 | ctx.response.webhook = hookMessage; 92 | ctx.body = JSON.stringify(hookMessage); 93 | } 94 | ); 95 | 96 | export { WebHookRouter }; 97 | -------------------------------------------------------------------------------- /admin/pages/result-font/index.vue: -------------------------------------------------------------------------------- 1 | 63 | 89 | -------------------------------------------------------------------------------- /api/src/routers/fonts.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import path from "path"; 3 | import { minioClient } from "../oss/index.js"; 4 | import { FontSource, FontSplit } from "../db/entity/font.js"; 5 | import { FontSourceRepo } from "../db/db.js"; 6 | import { Like } from "typeorm"; 7 | import { webhook } from "../middleware/webhook.js"; 8 | import { WebHookEvent } from "../db/entity/webhook.js"; 9 | import { AccessControl } from "../access_control.js"; 10 | 11 | const FontsRouter = new Router(); 12 | 13 | /** 获取用户字体列表 */ 14 | FontsRouter.get( 15 | "/fonts", 16 | AccessControl.check("reader", "admin"), 17 | async (ctx) => { 18 | const { limit, offset, q, versions } = ctx.query; 19 | const res = await FontSourceRepo.find({ 20 | skip: parseInt(offset as string), 21 | take: parseInt(limit as string), 22 | where: q 23 | ? { 24 | name: Like(`%${q}%`), 25 | } 26 | : undefined, 27 | order: { 28 | id: "DESC", 29 | }, 30 | relations: [versions && "versions"].filter((i) => i) as string[], 31 | }); 32 | 33 | ctx.body = JSON.stringify(res); 34 | } 35 | ); 36 | 37 | FontsRouter.get( 38 | "/fonts/:id", 39 | AccessControl.check("reader", "admin"), 40 | async (ctx) => { 41 | const { versions } = ctx.request.query; 42 | const { id } = ctx.params; 43 | let query = await FontSourceRepo.findOne({ 44 | where: { 45 | id: parseInt(id as string), 46 | }, 47 | relations: versions === "true" ? ["versions"] : undefined, 48 | }); 49 | 50 | ctx.body = JSON.stringify(query); 51 | } 52 | ); 53 | 54 | // TODO 删除用户上传字体 55 | 56 | /** 用户上传字体 */ 57 | FontsRouter.post( 58 | "/fonts", 59 | AccessControl.check("admin"), 60 | webhook(), 61 | async (ctx) => { 62 | // 检查输入 63 | const file = ctx.request.files!.font; 64 | if (file instanceof Array) { 65 | ctx.body = JSON.stringify({ error: "get multi key: font" }); 66 | return; 67 | } 68 | const name = file.hash! + path.extname(file.originalFilename!); 69 | const source_path = "user-fonts/" + name; 70 | 71 | // 上传 MINIO 72 | const cb = await minioClient.fPutObject( 73 | "user-fonts", 74 | name, 75 | file.filepath 76 | ); 77 | console.log(name, cb, ctx.request.body); 78 | 79 | // 上传数据库 80 | const source = FontSource.create({ 81 | path: source_path, 82 | md5: file.hash!, 83 | size: file.size, 84 | versions: [] as FontSplit[], 85 | name: 86 | ctx.request?.body?.name ?? 87 | path.basename(file.originalFilename!), 88 | }); 89 | 90 | const res = await FontSourceRepo.save(source); 91 | const data = { data: res, ...cb, path: source_path }; 92 | ctx.response.webhook = { 93 | event: WebHookEvent.UPLOAD_SUCCESS, 94 | payload: data, 95 | }; 96 | 97 | ctx.body = JSON.stringify({ 98 | data, 99 | }); 100 | } 101 | ); 102 | 103 | export { FontsRouter }; 104 | -------------------------------------------------------------------------------- /admin/pages/webhook/logs/[id].vue: -------------------------------------------------------------------------------- 1 | 53 | 90 | -------------------------------------------------------------------------------- /notification/src/RemoteFactory.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { getSelfIPs } from "./utils/getSelfIPs.js"; 3 | import pLimit from "p-limit"; 4 | import { RemoteStorage, UploadSingleStream } from "./RemoteStorage.js"; 5 | 6 | const AuthHeader = { Authorization: "Bearer " + process.env.HOOK_TOKEN }; 7 | 8 | export class RemoteFactory { 9 | static subscribeWebHook: RemoteStorage["subscribeWebHook"] = async () => { 10 | const ipAddresses = process.env.SELF_HOST 11 | ? [process.env.SELF_HOST] 12 | : getSelfIPs(); 13 | 14 | console.log( 15 | `本机 IP 地址:${ipAddresses.join(", ")}`, 16 | "远程地址:", 17 | process.env.WEBHOOK_HOST 18 | ); 19 | return fetch(process.env.WEBHOOK_HOST + "/webhook", { 20 | method: "POST", 21 | body: JSON.stringify({ 22 | url: 23 | "http://" + 24 | ipAddresses[0] + 25 | ":" + 26 | (process.env.PORT ?? "80"), 27 | }), 28 | headers: { ...AuthHeader, "content-type": "application/json" }, 29 | }) 30 | .then((res) => res.json()) 31 | .then((res) => console.log("加入订阅完成", res)); 32 | }; 33 | static async getAllFiles(WEBHOOK_HOST: string) { 34 | return await axios 35 | .get(WEBHOOK_HOST + "/split", { 36 | headers: AuthHeader, 37 | params: { 38 | limit: 999999, 39 | offset: 0, 40 | state: 2, 41 | }, 42 | }) 43 | .then((res) => { 44 | return res.data.map( 45 | (data: { folder: string; files: string[]; id: number }) => { 46 | return { 47 | folder: "result-fonts/" + data.folder, 48 | files: data.files.map( 49 | (i: string) => "result-fonts/" + i 50 | ), 51 | id: data.id, 52 | }; 53 | } 54 | ); 55 | }); 56 | } 57 | static async createFileStream(MINIO_HOST: string, path: string) { 58 | return await axios({ 59 | responseType: "stream", 60 | url: MINIO_HOST + "/" + path, 61 | }).then((res) => { 62 | /** @ts-ignore */ 63 | const length = res.headers.get("content-length") as string; 64 | if (!length) throw new Error(path + " 长度有误"); 65 | return { 66 | stream: res.data, 67 | length: parseInt(length as string), 68 | }; 69 | }); 70 | } 71 | static createSyncAllFile = ( 72 | WEBHOOK_HOST: () => string, 73 | uploadSingleStream: UploadSingleStream, 74 | isExistedFolder: Function 75 | ): RemoteStorage["syncAllFiles"] => { 76 | return async () => { 77 | const records = await RemoteFactory.getAllFiles(WEBHOOK_HOST()); 78 | 79 | for (const item of records) { 80 | const isExisted = await isExistedFolder(item.folder); 81 | if (!isExisted) { 82 | await RemoteFactory.syncDir(item, uploadSingleStream); 83 | console.log("同步文件夹完成 ", item.id, item.folder); 84 | } 85 | } 86 | }; 87 | }; 88 | /** 并发同步文件夹 */ 89 | static async syncDir( 90 | item: { files: string[] }, 91 | uploadSingleStream: UploadSingleStream 92 | ) { 93 | const limit = pLimit(3); 94 | const list: Promise[] = []; 95 | for (const path of item.files) { 96 | list.push(limit(() => uploadSingleStream(path))); 97 | } 98 | return Promise.all(list); 99 | } 100 | static createSyncMessageCallback( 101 | uploadSingleStream: UploadSingleStream 102 | ): RemoteStorage["getSyncMessage"] { 103 | return async (payload) => { 104 | await RemoteFactory.syncDir(payload, uploadSingleStream); 105 | }; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /notification/src/adapter/COS.ts: -------------------------------------------------------------------------------- 1 | import COS from "cos-nodejs-sdk-v5"; 2 | import { RemoteStorage } from "../RemoteStorage.js"; 3 | import { RemoteFactory } from "../RemoteFactory.js"; 4 | interface COSConfig { 5 | Bucket: string; 6 | Region: string; 7 | WEBHOOK_HOST: string; 8 | MINIO_HOST: string; 9 | } 10 | 11 | export class COSAdapter extends COS implements RemoteStorage { 12 | constructor(opt: COS.COSOptions, public config: COSConfig) { 13 | super({ ...opt, ...config }); 14 | console.log(config); 15 | } 16 | 17 | async init() { 18 | console.log("检测桶存在"); 19 | const isExisted = await this.checkBucket(); 20 | if (!isExisted) { 21 | await this.createBucket({ 22 | ...this.config, 23 | ACL: "public-read", 24 | }); 25 | } 26 | } 27 | 28 | async checkBucket() { 29 | return new Promise((res, rej) => { 30 | this.headBucket(this.config, (err, data) => { 31 | if (err) { 32 | if (err.statusCode == 404) { 33 | console.log(this.config.Bucket + " 存储桶不存在"); 34 | res(false); 35 | } else if (err.statusCode == 403) { 36 | rej(this.config.Bucket + " 没有该存储桶读权限"); 37 | } else { 38 | rej(err); 39 | } 40 | } else { 41 | console.log("存储桶存在"); 42 | res(true); 43 | } 44 | }); 45 | }); 46 | } 47 | async createBucket(config: COS.PutBucketParams) { 48 | return new Promise((res, rej) => { 49 | console.log("创建桶 " + this.config.Bucket); 50 | this.putBucket(config, (err, data) => (err ? rej(err) : res(data))); 51 | }); 52 | } 53 | 54 | subscribeWebHook = RemoteFactory.subscribeWebHook; 55 | syncAllFiles = RemoteFactory.createSyncAllFile( 56 | () => this.config.WEBHOOK_HOST, 57 | this.uploadSingleStream.bind(this), 58 | this.isExistedFolder.bind(this) 59 | ); 60 | getSyncMessage = RemoteFactory.createSyncMessageCallback( 61 | this.uploadSingleStream.bind(this) 62 | ); 63 | /** 判断远程是否存在 */ 64 | async isExistedFolder(folder: string) { 65 | return new Promise((res, rej) => { 66 | this.headObject( 67 | { 68 | ...this.config, 69 | Key: folder + "/result.css", 70 | }, 71 | (err, data) => { 72 | if (err) { 73 | if (err.statusCode == 404) { 74 | res(false); 75 | } else if (err.statusCode == 403) { 76 | rej("没有该对象读权限"); 77 | } 78 | } 79 | if (data) res(true); 80 | } 81 | ); 82 | }); 83 | } 84 | /** 构建流式同步传输 */ 85 | async uploadSingleStream(path: string) { 86 | const response = await RemoteFactory.createFileStream( 87 | this.config.MINIO_HOST, 88 | path 89 | ); 90 | return new Promise((res, rej) => { 91 | this.putObject( 92 | { 93 | ...this.config, 94 | 95 | Key: path, 96 | StorageClass: "STANDARD", 97 | /* 当 Body 为 stream 类型时,ContentLength 必传,否则 onProgress 不能返回正确的进度信息 */ 98 | Body: response.stream, 99 | ContentLength: response.length, 100 | }, 101 | (err, data) => (err ? rej(err) : res(data)) 102 | ); 103 | }); 104 | } 105 | 106 | changeCORS() { 107 | return this.putBucketCors({ 108 | ...this.config, 109 | CORSRules: [ 110 | { 111 | AllowedOrigin: ["*"], 112 | AllowedMethod: ["GET", "POST", "PUT", "DELETE", "HEAD"], 113 | AllowedHeader: ["*"], 114 | ExposeHeader: [ 115 | "ETag", 116 | "x-cos-acl", 117 | "x-cos-version-id", 118 | "x-cos-delete-marker", 119 | "x-cos-server-side-encryption", 120 | ], 121 | MaxAgeSeconds: 5, 122 | }, 123 | ], 124 | }); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /api/src/routers/split.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import path from "path"; 3 | import { minioClient } from "../oss/index.js"; 4 | import { FontSourceRepo, FontSplitRepo } from "../db/db.js"; 5 | import { FontSplit, SplitEnum } from "../db/entity/font.js"; 6 | import { fontSplit } from "@konghayao/cn-font-split"; 7 | import { createTempPath } from "../useTemp.js"; 8 | import fs from "fs/promises"; 9 | import { webhook } from "../middleware/webhook.js"; 10 | import { WebHookEvent } from "../db/entity/webhook.js"; 11 | import { stream } from "../middleware/stream/index.js"; 12 | import { AccessControl } from "../access_control.js"; 13 | 14 | const SplitRouter = new Router(); 15 | 16 | /* ! node 某一个版本新加的 api 导致库的环境判断失误,会BUG */ 17 | (globalThis as any)._fetch = globalThis.fetch; 18 | (globalThis as any).fetch = null; 19 | 20 | /** 切割字体 */ 21 | SplitRouter.post( 22 | "/split", 23 | AccessControl.check("admin"), 24 | stream(), 25 | webhook(), 26 | async (ctx) => { 27 | const stream = ctx.response.stream!; 28 | const { id, md5 } = ctx.request.body; 29 | const item = await FontSourceRepo.findOneBy({ 30 | id: parseInt(id as string), 31 | }); 32 | if (item && md5 === item.md5) { 33 | const newFontSplit = FontSplit.create({ 34 | source: item, 35 | state: SplitEnum.idle, 36 | folder: "", 37 | }); 38 | await newFontSplit.save(); 39 | 40 | const tempFilePath = createTempPath(item.path); 41 | const destFilePath = createTempPath( 42 | path.dirname(item.path), 43 | path.basename(item.path, path.extname(item.path)) 44 | ); 45 | console.log(tempFilePath, destFilePath); 46 | 47 | // fixed: 阻止 otf 打包不了的问题 48 | if (path.extname(tempFilePath).endsWith("otf")) { 49 | throw new Error("暂不支持 otf 文件"); 50 | } 51 | await minioClient.fGetObject( 52 | "user-fonts", 53 | path.basename(item.path), 54 | tempFilePath 55 | ); 56 | 57 | newFontSplit.state = SplitEnum.cutting; 58 | await newFontSplit.save(); 59 | 60 | stream.send(["开始打包"]); 61 | await fontSplit({ 62 | FontPath: tempFilePath, 63 | destFold: destFilePath, 64 | targetType: "woff2", 65 | chunkSize: 70 * 1024, 66 | testHTML: false, 67 | previewImage: { 68 | text: process.env.PREVIEW_TEXT, 69 | }, 70 | log(...args: any[]) { 71 | stream.send(args); 72 | }, 73 | }); 74 | 75 | // 上传全部文件到 minio 76 | const folder = path.join(item.md5, newFontSplit.id.toString()); 77 | const data = await fs.readdir(destFilePath); 78 | const paths = await Promise.all( 79 | data.map(async (i) => { 80 | const file = path.join(destFilePath, i); 81 | const newPath = path.join(folder, i); 82 | await minioClient.fPutObject("result-fonts", newPath, file); 83 | return newPath; 84 | }) 85 | ); 86 | stream.send(["存储 MINIO 完成"]); 87 | 88 | newFontSplit.folder = folder; 89 | newFontSplit.files = paths; 90 | newFontSplit.state = SplitEnum.success; 91 | await newFontSplit.save(); 92 | 93 | stream.sendEnd(newFontSplit); 94 | 95 | // 发布 webhook 96 | ctx.response.webhook = { 97 | event: WebHookEvent.SPLIT_SUCCESS, 98 | payload: newFontSplit, 99 | }; 100 | } else { 101 | ctx.response.stream?.sendEnd({ 102 | error: `${item?.id} or ${item?.md5} not found`, 103 | }); 104 | } 105 | } 106 | ); 107 | 108 | SplitRouter.get( 109 | "/split", 110 | AccessControl.check("reader", "admin"), 111 | async (ctx) => { 112 | const { limit, offset, state, source } = ctx.query; 113 | 114 | const res = await FontSplit.find({ 115 | skip: parseInt(offset as string), 116 | take: parseInt(limit as string), 117 | where: { 118 | state: state ? parseInt(state as string) : undefined, 119 | source: { 120 | id: source ? parseInt(source as string) : undefined, 121 | }, 122 | }, 123 | order: { 124 | id: "DESC", 125 | }, 126 | relations: ["source"], 127 | }); 128 | ctx.body = JSON.stringify(res); 129 | } 130 | ); 131 | 132 | export { SplitRouter }; 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # font-server 中文字体切割服务器 2 | 3 | | author: 江夏尧 | developing | v0.8 | 2023/5/22 4 | 5 | ## 软件定位 6 | 7 | font-server 是一个用于内网的字体存储和管理服务,支持通过 WebHook 对外通知信息,并允许外部程序通过端口进行内部数据访问。 8 | 9 | 主要功能包括: 10 | 11 | 1. ✅ 用户可以上传原始字体文件,系统会保存这些字体文件。 12 | 2. ✅ 触发打包字体功能后,切割服务器会自动获取内部存储的字体文件,并对其进行切割,然后将切割后的字体片段存入内部文件系统。 13 | 3. ✅ 支持 WebHook 订阅功能,触发 hook 事件后,系统会广播订阅 url,通知外部程序相关事件信息。 14 | 4. ✅ 在切割完成后,外部监听程序可以获取内部的切割分片,并将其部署到外部公开的 OSS 系统上。特别地,内外 OSS 系统应该使用相同的路径。 15 | 5. ✅ 用户可以通过 OSS 系统提供的 CDN 加速访问字体文件,同时嵌入字体加载 HTML 片段,浏览器会自动加载相应的 CSS 文件和字体文件。 16 | 6. ✅ 提供简单的 Admin 界面,方便用户进行可视化操作。[Admin 地址](https://font-server.netlify.app) 17 | 18 | ## 软件设计 19 | 20 | > 项目语言:Typescript 一把梭,配合 Docker 、shell 自动化 21 | 22 | ![软件架构图](./assets/arch.png) 23 | 24 | ### 项目所提供的内容 25 | 26 | 1. Koa API Server 27 | 28 | 1. 采用 Nodejs Typescript Koa 框架构建 Restful API 管理内部服务。 29 | 2. 字体切割服务需要占用大量 CPU 资源,需要单独容器进行管理。但暂时采用同一个服务器进行服务。 30 | 3. 构建 WebHook 事件通讯机制,提供事件发布机制的通讯推送 31 | 4. 通过 access_token 简单划分权限,使得可以对外公开部分接口 32 | 33 | 2. PostgreSQL 数据库: 34 | 35 | 1. 使用 Docker 容器中的 Postgres,不直接进行操作。 36 | 2. 在 API Server 中通过 Nodejs TypeORM 框架编写 Schema 和进行数据库容器的操作。 37 | 3. 如果需要,可以变更为其它数据库,但是 ORM 需要更改。 38 | 39 | 3. MINIO 对象存储: 40 | 41 | 1. 主要用于存储静态文件, 备份用户字体、存储切割字体分片。 42 | 2. 对内网管理服务器提供静态文件接口服务。 43 | 3. 内网使用对象存储存数据,防止数据丢失。 44 | 45 | 4. Webhook 订阅通讯 46 | 47 | 1. 主要通过发布订阅模式广播项目系统内部的事件变化。 48 | 2. 通过请求添加外部服务器的 url 到事件监听表中。 49 | 3. 当系统内部触发事件时,向监听的 url 发送事件数据。 50 | 51 | ### Pusher 适配层 52 | 53 | > 出于项目系统独立性考虑,Pusher 适配层需要兼顾具体云存储环境,与项目系统进行沟通。 54 | 55 | 1. 管理服务器 56 | 57 | 1. 订阅 Font Server 内部事件 58 | 2. 第一次全量同步数据 59 | 3. 增量同步文件到云存储中 60 | 61 | 2. 云存储与 CDN 62 | 63 | 1. 云存储主要用在公网备份字体数据。由于各家云存储的 API 不一致,故在系统内部需要有同一的备份文件,同步文件到各个云存储中。 64 | 2. CDN 服务用于在生产环境中加速字体文件到达用户端。 65 | 3. 生产环境中必须使用并发数大,速度快,距离近的 CDN 服务来提供稳定数据。 66 | 67 | ### 订阅制沟通结构 68 | 69 | 1. Font Server 的字体管理核心部分是完全独立的,包括了 api、minio 和 db 三个部分。 70 | 2. 外部系统通过 WebHook 的订阅获取事件推送,如 Pusher 就是通过 WebHook 订阅内部的数据变化并进行推送的。 71 | 3. WebHook 由 Font Server 记录订阅者的 URL,在系统内部发生事件时,发送 POST 请求将包含信息的 JSON 发送给订阅者 72 | 4. 订阅者监听到变化之后进行数据同步等操作。 73 | 74 | ### 打包文件存储架构 75 | 76 | 1. 文件存储采用主从架构,Font Server 内部的文件系统为主,通过 Pusher 同步腾讯云、阿里云等外部从属云存储。 77 | 2. 主文件系统负责保存原始文件、打包文件副本、为 Font Server 内部提供数据。 78 | 3. 从文件系统通常可以开启 CDN 加速静态文件部署、支持防盗链等功能,适合于面向公众提供服务。 79 | 4. 文件系统中的文件都使用同样的路径存储与开放,各个文件系统只是域名不同 80 | 81 | ![](./assets/File_Path.png) 82 | 83 | # 快速部署 84 | 85 | 1. **clone 本仓库** OR **fork 它并打开 Github Workspace** 86 | 87 | 2. 添加一些环境变量修改一下 docker-compose-yml 88 | 89 | > 这些环境变量需要看看 docker-compose.yml 缺少什么,一般都是 Pusher 插件同步文件需要。 90 | > 91 | > 我会把环境变量写在 根目录的 .env 文件中,然后通过 docker-compose 使用 92 | > 93 | > docker-compose.yml 中有些用户名密码之类的可以进行修改,保证私密性 94 | 95 | 3. 在根目录运行 96 | 97 | ```sh 98 | docker-compose --env-file=.env up -d 99 | ``` 100 | 101 | ## 半自动测试 102 | 103 | 1. 自动下载测试字体文件 104 | 105 | ```bash 106 | sudo sh scripts/init.sh # 需要 linux 环境 curl unzip 107 | ``` 108 | 109 | 2. 自动注入基本测试数据 110 | 111 | ```sh 112 | sudo HOST=http://localhost:3000 sh scripts/injectFonts.sh 113 | ``` 114 | 115 | # 接口文档 116 | 117 | [Postman 文档](https://www.postman.com/konghayao/workspace/font-server/collection/18433126-4b25b13a-6c0e-40a4-9dec-ccf655d1660c?action=share&creator=18433126) 118 | 119 | 注意设置好你的接口环境哦 120 | 121 | ## 简单的几个接口 122 | 123 | 1. 获取切割完成的可用字体 124 | 125 | ```js 126 | const root = ""; // 这里写 api 容器暴露的端口 127 | const token = "api_admin_66618273"; // 这里写你的 admin token,默认是这个 token 128 | const cdnRoot = '' // CDN 所在的 Root 129 | 130 | // 请求这个 URL 可以获取到一个文件列表 131 | fetch(root+"/split?limit=5&offset=0&state=2", { 132 | headers: { 133 | "Authorization", "Bearer "+token 134 | } 135 | }) 136 | .then(response => response.json()) 137 | 138 | .then(res=>{ 139 | // 我们来获取一下每个字体可以使用的 css 文件 140 | return res.map(i=>{ 141 | return `${cdnRoot}/result-fonts/${i.folder}/result.css` 142 | }) 143 | }) 144 | ``` 145 | 146 | 返回数据大致如下 147 | 148 | ```jsonc 149 | [ 150 | { 151 | "id": 15, 152 | "created_at": "2023-05-22T02:14:41.706Z", 153 | "updated_at": "2023-05-22T02:15:02.543Z", 154 | 155 | // 原始字体资源的信息 156 | "source": { 157 | "id": 10, 158 | "created_at": "2023-05-22T01:46:07.586Z", 159 | "updated_at": "2023-05-22T01:46:07.586Z", 160 | "path": "user-fonts/e28bdfa317ad66266bceb7f27fb6dde7.woff2", 161 | "name": "得意黑-woff2", 162 | "size": 950116, 163 | "md5": "e28bdfa317ad66266bceb7f27fb6dde7" 164 | }, 165 | // 成品字体所在的文件夹 166 | "folder": "e28bdfa317ad66266bceb7f27fb6dde7/15", 167 | 168 | // 成品的文件列表 169 | "files": [ 170 | "e28bdfa317ad66266bceb7f27fb6dde7/15/1c8e99fa9ef7c6c698d48417c2fe8765.woff2", 171 | "e28bdfa317ad66266bceb7f27fb6dde7/15/preview.png", 172 | "e28bdfa317ad66266bceb7f27fb6dde7/15/reporter.json", 173 | "e28bdfa317ad66266bceb7f27fb6dde7/15/result.css" // 最为重要的文件 174 | ], 175 | "state": 2 176 | } 177 | ] 178 | ``` 179 | -------------------------------------------------------------------------------- /admin/pages/webhook/index.vue: -------------------------------------------------------------------------------- 1 | 64 | 137 | -------------------------------------------------------------------------------- /admin/pages/user-font/index.vue: -------------------------------------------------------------------------------- 1 | 84 | 148 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "experimentalDecorators": true, 5 | /* Visit https://aka.ms/tsconfig to read more about this file */ 6 | 7 | /* Projects */ 8 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 9 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 10 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 11 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 12 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 13 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 14 | 15 | /* Language and Environment */ 16 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 17 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "ESNext" /* Specify what module code is generated. */, 31 | // "rootDir": "./", /* Specify the root folder within your source files. */ 32 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 44 | // "resolveJsonModule": true, /* Enable importing .json files. */ 45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 52 | 53 | /* Emit */ 54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 60 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 61 | // "removeComments": true, /* Disable emitting comments. */ 62 | // "noEmit": true, /* Disable emitting files from a compilation. */ 63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 64 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 77 | 78 | /* Interop Constraints */ 79 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 80 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 81 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 82 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 83 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 84 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 85 | 86 | /* Type Checking */ 87 | "strict": true /* Enable all strict type-checking options. */, 88 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 89 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 90 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 91 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 92 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | 107 | /* Completeness */ 108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /notification/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "experimentalDecorators": true, 5 | /* Visit https://aka.ms/tsconfig to read more about this file */ 6 | 7 | /* Projects */ 8 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 9 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 10 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 11 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 12 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 13 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 14 | 15 | /* Language and Environment */ 16 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 17 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "ESNext" /* Specify what module code is generated. */, 31 | // "rootDir": "./", /* Specify the root folder within your source files. */ 32 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 33 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 44 | // "resolveJsonModule": true, /* Enable importing .json files. */ 45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 52 | 53 | /* Emit */ 54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 60 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 61 | // "removeComments": true, /* Disable emitting comments. */ 62 | // "noEmit": true, /* Disable emitting files from a compilation. */ 63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 64 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 77 | 78 | /* Interop Constraints */ 79 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 80 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 81 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 82 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 83 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 84 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 85 | 86 | /* Type Checking */ 87 | "strict": true /* Enable all strict type-checking options. */, 88 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 89 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 90 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 91 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 92 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | 107 | /* Completeness */ 108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /api.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.2" 2 | info: 3 | title: Font Server API 4 | version: "1.0" 5 | description: 接口为 ChatGPT 生成为主,返回数据格式大部分是错误的,所以自己post 一下好, 这个文档实在太难维护了,后期将会使用代码自动生成 6 | servers: 7 | - url: http://localhost:3000 8 | paths: 9 | /fonts: 10 | get: 11 | summary: 获取用户字体列表 12 | parameters: 13 | - in: query 14 | name: limit 15 | schema: 16 | type: integer 17 | description: 返回结果最大数量 18 | - in: query 19 | name: offset 20 | schema: 21 | type: integer 22 | description: 返回结果起始位置 23 | - in: query 24 | name: q 25 | schema: 26 | type: string 27 | description: 按照名称模糊搜索字体 28 | - in: query 29 | name: versions 30 | schema: 31 | type: boolean 32 | description: 是否返回字体各个版本信息 33 | 34 | responses: 35 | "200": 36 | description: 成功获取用户字体列表 37 | content: 38 | application/json: 39 | schema: 40 | type: array 41 | items: 42 | $ref: "#/components/schemas/FontSource" 43 | post: 44 | summary: 用户上传字体 45 | requestBody: 46 | content: 47 | multipart/form-data: 48 | schema: 49 | type: object 50 | properties: 51 | font: 52 | type: string 53 | format: binary 54 | description: 要上传的字体文件 55 | name: 56 | type: string 57 | description: 要上传的字体名称,可选 58 | required: 59 | - font 60 | responses: 61 | "200": 62 | description: 成功上传用户字体 63 | content: 64 | application/json: 65 | schema: 66 | $ref: "#/components/schemas/FontSource" 67 | /fonts/{id}: 68 | get: 69 | summary: 获取指定 id 的字体信息 70 | parameters: 71 | - in: path 72 | name: id 73 | required: true 74 | schema: 75 | type: integer 76 | description: 字体 id 77 | responses: 78 | "200": 79 | description: 成功获取指定 id 的字体信息 80 | content: 81 | application/json: 82 | schema: 83 | $ref: "#/components/schemas/FontSource" 84 | delete: 85 | summary: 删除指定 id 的字体信息(接口未实现) 86 | parameters: 87 | - in: path 88 | name: id 89 | required: true 90 | schema: 91 | type: integer 92 | description: 字体 id 93 | responses: 94 | "200": 95 | description: 成功删除指定 id 的字体信息 96 | /split: 97 | post: 98 | summary: 切割字体 99 | tags: 100 | - SplitRouter 101 | requestBody: 102 | required: true 103 | content: 104 | application/json: 105 | schema: 106 | type: object 107 | properties: 108 | id: 109 | type: string 110 | description: 字体 ID 111 | md5: 112 | type: string 113 | description: 字体 MD5 值 114 | required: 115 | - id 116 | - md5 117 | responses: 118 | "200": 119 | description: 成功 120 | content: 121 | application/json: 122 | schema: 123 | $ref: "#/components/schemas/FontSplitCallback" 124 | get: 125 | summary: 获取字体切割任务列表 126 | tags: 127 | - SplitRouter 128 | parameters: 129 | - name: limit 130 | in: query 131 | description: 每页返回的数量 132 | required: false 133 | schema: 134 | type: integer 135 | - name: offset 136 | in: query 137 | description: 跳过的数量 138 | required: false 139 | schema: 140 | type: integer 141 | - name: state 142 | in: query 143 | description: 切割状态码 144 | required: false 145 | schema: 146 | type: integer 147 | - name: source 148 | in: query 149 | description: 源字体 ID 150 | required: false 151 | schema: 152 | type: integer 153 | responses: 154 | "200": 155 | description: 成功 156 | content: 157 | application/json: 158 | schema: 159 | $ref: "#/components/schemas/FontSplitInfo" 160 | /webhook: 161 | post: 162 | summary: 添加一个事件订阅 163 | tags: 164 | - WebHook 165 | requestBody: 166 | content: 167 | application/json: 168 | schema: 169 | type: object 170 | properties: 171 | url: 172 | type: string 173 | required: 174 | - url 175 | responses: 176 | "200": 177 | description: 成功创建事件订阅 178 | content: 179 | application/json: 180 | schema: 181 | type: object 182 | properties: 183 | id: 184 | type: integer 185 | example: 1 186 | url: 187 | type: string 188 | example: http://example.com 189 | "400": 190 | description: 请求格式错误 191 | content: 192 | application/json: 193 | schema: 194 | type: object 195 | properties: 196 | error: 197 | type: string 198 | example: 没有输入 url 199 | delete: 200 | summary: 删除一个事件订阅 201 | tags: 202 | - WebHook 203 | parameters: 204 | - in: query 205 | name: id 206 | schema: 207 | type: integer 208 | required: true 209 | description: 需要删除的订阅 ID 210 | responses: 211 | "200": 212 | description: 成功删除事件订阅 213 | content: 214 | application/json: 215 | schema: 216 | type: object 217 | properties: 218 | success: 219 | type: boolean 220 | example: true 221 | 222 | patch: 223 | summary: 测试订阅事件 224 | tags: 225 | - WebHook 226 | responses: 227 | "200": 228 | description: 模拟 webhook 接收到了事件 229 | content: 230 | application/json: 231 | schema: 232 | type: object 233 | properties: 234 | event: 235 | type: string 236 | example: null 237 | payload: 238 | type: object 239 | example: { "test": 120392039 } 240 | components: 241 | schemas: 242 | FontSplit: 243 | type: object 244 | properties: 245 | name: 246 | type: string 247 | description: 字体名称 248 | path: 249 | type: string 250 | description: 字体路径 251 | md5: 252 | type: string 253 | description: 字体 md5 值 254 | FontSource: 255 | type: object 256 | properties: 257 | id: 258 | type: integer 259 | description: 字体 id 260 | path: 261 | type: string 262 | description: 字体路径 263 | md5: 264 | type: string 265 | description: 字体 md5 值 266 | name: 267 | type: string 268 | description: 字体名称 269 | versions: 270 | type: array 271 | items: 272 | $ref: "#/components/schemas/FontSplit" 273 | description: 字体版本信息 274 | FontSplitCallback: 275 | type: object 276 | properties: 277 | id: 278 | type: integer 279 | description: 字体切割 ID 280 | source: 281 | type: object 282 | description: 切割的源字体 283 | properties: 284 | id: 285 | type: integer 286 | description: 源字体 ID 287 | name: 288 | type: string 289 | description: 源字体名称 290 | path: 291 | type: string 292 | description: 源字体文件路径 293 | previewPath: 294 | type: string 295 | description: 源字体预览图路径 296 | md5: 297 | type: string 298 | description: 源字体文件的 MD5 值 299 | createdAt: 300 | type: string 301 | description: 源字体创建时间 302 | updatedAt: 303 | type: string 304 | description: 源字体更新时间 305 | state: 306 | type: integer 307 | description: 切割状态码 308 | folder: 309 | type: string 310 | description: 切割后存储的文件夹路径(相对于字体的 MD5 值) 311 | FontSplitInfo: 312 | type: array 313 | items: 314 | type: object 315 | properties: 316 | id: 317 | type: integer 318 | description: 字体切割 ID 319 | source: 320 | type: object 321 | description: 切割的源字体 322 | properties: 323 | id: 324 | type: integer 325 | description: 源字体 ID 326 | name: 327 | type: string 328 | description: 源字体名称 329 | path: 330 | type: string 331 | description: 源字体文件路径 332 | previewPath: 333 | type: string 334 | description: 源字体预览图路径 335 | md5: 336 | type: string 337 | description: 源字体文件的 MD5 值 338 | createdAt: 339 | type: string 340 | description: 源字体创建时间 341 | updatedAt: 342 | type: string 343 | state: 344 | type: integer 345 | description: 切割状态码 346 | folder: 347 | type: string 348 | description: 切割后存储的文件夹路径(相对于字体的 MD5 值) 349 | -------------------------------------------------------------------------------- /notification/test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 赫蹏 - 一个简约又简单的网页中文排版增强 6 | 10 | 15 | 16 | 17 | 18 |
19 |
20 |

赫蹏

21 |
22 | 古代称用以书写的小幅绢帛。后亦以借指纸。《汉书·外戚传下·孝成赵皇后》:「武(籍武)发篋中,有裹药二枚,赫蹏书。」颜师古注:「邓展曰:『赫音兄弟鬩墙之鬩。』应劭曰:『赫蹏,薄小纸也。』」赵彦卫 25 | 《云麓漫钞》卷七:「《赵后传》所谓『赫蹏』者,注云『薄小纸』,然其寔亦縑帛。」 26 |
27 | 28 | 63 | 64 |

介绍#

65 |

66 | ()()是专为中文网页内容设计的排版样式增强。它基于通行的中文排版规范,可为网站的读者带来更好的内容阅读体验。它的主要特性有: 71 |

72 |
    73 |
  • 贴合网格的排版;
  • 74 |
  • 全标签样式美化;
  • 75 |
  • 预置古文、诗词样式;
  • 76 |
  • 预置多种排版样式(行间注、多栏、竖排等);
  • 77 |
  • 多种预设字体族(仅限桌面端);
  • 78 |
  • 简/繁体中文支持;
  • 79 |
  • 自适应黑暗模式;
  • 80 |
  • 81 | 中西文混排美化,不再手敲空格(基于JavaScript脚本); 82 |
  • 83 |
  • 标点挤压(基于JavaScript脚本);
  • 84 |
  • 85 | 兼容normalize.cssCSS Reset[1]等常见样式重置; 90 |
  • 91 |
  • 移动端支持;
  • 92 |
  • ……
  • 93 |
94 |

总之,用上就会变好看。

95 | 96 |
97 | 98 |

99 | 使用方法# 100 |

101 |

102 | 项目地址:https://github.com/sivan/heti,使用方法如下: 105 |

106 |
    107 |
  1. 108 | 在页面的</head>标签前中引入heti.css样式文件: 109 |
    <link rel="stylesheet" href="//unpkg.com/heti/umd/heti.min.css">
    110 |
  2. 111 |
  3. 112 | 在要作用的容器元素上增加class="heti"的类名即可: 113 | 114 |
    <article class="entry heti">
     115 |           <h1>我的世界观</h1>
     116 |           <p>有钱人的生活就是这么朴实无华,且枯燥。</p>
     117 |           ……
     118 |         </article>
    119 |
  4. 120 |
121 | 注:赫蹏是正文区域的样式增强,不是normalize.cssCSS Reset的替代。因此不建议将它作用在根标签(如<body><div class="container">)上。 128 | 129 |
130 | 131 |

132 | 效果示例# 133 |

134 |

135 | 本页面全页应用了赫蹏样式,所见即所得。下面是内置的多种排版效果演示。 136 |

137 | 138 |

139 | 古文# 140 |

141 |
142 | 如何使用? 143 |

144 | 为容器元素<div class="heti">添加名为heti--ancient的class即可实现古文版式: 146 |

147 |
<div class="heti heti--ancient">...</div>
148 |
149 |
150 | 示例 151 |
152 |
153 |

出师表

154 |

155 | 作者:諸葛亮(181年~234年10月8日) 157 |

158 |

159 | 先帝创业未半而中道崩殂,今天下三分,益州疲弊,此诚危急存亡之秋也。然侍卫之臣不懈于内,忠志之士忘身于外者,盖追先帝之殊遇,欲报之于陛下也。诚宜开张圣听,以光先帝遗德,恢弘志士之气,不宜妄自菲薄,引喻失义,以塞忠谏之路也。 160 |

161 |

162 | 宫中府中,俱为一体;陟罚臧否,不宜异同。若有作奸犯科及为忠善者,宜付有司论其刑赏,以昭陛下平明之理;不宜偏私,使内外异法也。 163 |

164 |

165 | 侍中、侍郎郭攸之、费祎、董允等,此皆良实,志虑忠纯,是以先帝简拔以遗陛下:愚以为宫中之事,事无大小,悉以咨之,然后施行,必能裨补阙漏,有所广益。 166 |

167 |

168 | 将军向宠,性行淑均,晓畅军事,试用于昔日,先帝称之曰能,是以众议举宠为督。愚以为营中之事,悉以咨之,必能使行阵和睦,优劣得所。 169 |

170 |

171 | 亲贤臣,远小人,此先汉所以兴隆也;亲小人,远贤臣,此后汉所以倾颓也。先帝在时,每与臣论此事,未尝不叹息痛恨于桓、灵也。侍中、尚书、长史、参军,此悉贞良死节之臣,愿陛下亲之信之,则汉室之隆,可计日而待也。 172 |

173 |

174 | 臣本布衣,躬耕于南阳,苟全性命于乱世,不求闻达于诸侯。先帝不以臣卑鄙,猥自枉屈,三顾臣于草庐之中,咨臣以当世之事,由是感激,遂许先帝以驱驰。后值倾覆,受任于败军之际,奉命于危难之间,尔来二十有一年矣。 175 |

176 |

177 | 先帝知臣谨慎,故临崩寄臣以大事也。受命以来,夙夜忧叹,恐托付不效,以伤先帝之明;故五月渡泸,深入不毛。今南方已定,兵甲已足,当奖率三军,北定中原,庶竭驽钝,攘除奸凶,兴复汉室,还于旧都。此臣所以报先帝而忠陛下之职分也。至于斟酌损益,进尽忠言,则攸之、祎、允之任也。 178 |

179 |

180 | 愿陛下托臣以讨贼兴复之效,不效,则治臣之罪,以告先帝之灵。若无兴德之言,则责攸之、祎、允等之慢,以彰其咎;陛下亦宜自谋,以咨诹善道,察纳雅言,深追先帝遗诏。臣不胜受恩感激。 181 |

182 |

今当远离,临表涕零,不知所言。

183 |
184 |
185 |
186 | 187 |

188 | 诗词# 189 |

190 |
191 | 如何使用? 192 |
    193 |
  • 194 | 诗词:为容器元素<div class="heti">添加名为heti--poetry的class实现诗词版式: 197 |
    <div class="heti heti--poetry">
     198 |           <h2>九月九日忆山东兄弟<span class="heti-meta heti-small">[唐]<abbr title="号摩诘居士">王维</abbr></span></h2>
     199 |           <p class="heti-x-large">
     200 |             独在异乡为异客<span class="heti-hang">,</span><br>
     201 |             每逢佳节倍思亲<span class="heti-hang">。</span><br>
     202 |             遥知兄弟登高处<span class="heti-hang">,</span><br>
     203 |             遍插茱萸少一人<span class="heti-hang">。</span>
     204 |           </p>
     205 |         </div>
    206 |
  • 207 |
  • 208 | 诗节:在古文版式<div class="heti 210 | heti--ancient">中,为诗句添加名为heti-verse的class可以将其居中显示: 212 |
    <div class="heti heti--ancient">
     213 |           <h2>一剪梅·红藕香残玉簟秋<span class="heti-meta heti-small">[宋]<abbr title="号易安居士">李清照</abbr></span></h2>
     214 |           <p class="heti-verse heti-x-large">
     215 |             红藕香残玉簟秋。轻解罗裳,独上兰舟<span class="heti-hang">。</span><br>
     216 |             云中谁寄锦书来,雁字回时,月满西楼<span class="heti-hang">。</span><br>
     217 |             花自飘零水自流。一种相思,两处闲愁<span class="heti-hang">。</span><br>
     218 |             此情无计可消除,才下眉头,却上心头<span class="heti-hang">。</span>
     219 |           </p>
     220 |         </div>
    221 |
  • 222 |
  • 223 | 搭配使用标点悬挂<span class="heti-hang">、元信息<span class="heti-meta 227 | heti-small">来丰富展示效果。 229 |
  • 230 |
231 |
232 |
233 | 示例 234 |
235 |
236 |

237 | 一剪梅·红藕香残玉簟秋[宋]李清照 243 |

244 |

245 | 红藕香残玉簟秋。轻解罗裳,独上兰舟
249 | 云中谁寄锦书来,雁字回时,月满西楼
253 | 花自飘零水自流。一种相思,两处闲愁
257 | 此情无计可消除,才下眉头,却上心头 261 |

262 |
263 |
264 |
265 |

266 | 赠汪伦[唐]李白 271 |

272 |

273 | 李白乘舟将欲行
275 | 忽闻岸上踏歌声
277 | 桃花潭水深千尺
279 | 不及汪伦送我情 280 |

281 |
282 |
283 |
284 | 285 |

286 | 行间注# 287 |

288 |
289 | 如何使用? 290 |

291 | 为容器元素<div class="heti">添加名为heti--annotation的class后,搭配<ruby>元素即可实现整齐的行间注效果: 293 |

294 |
<div class="heti heti--annotation">...</div>
295 |
296 |
297 | 示例 298 |
299 |
300 |

庖丁解牛

301 |

302 | 作者:庄周(公元前369~公元前286年) 304 |

305 |

306 | 吾生也有涯,而知也无涯。以有涯随无涯,殆已!已而为知者,殆而已矣!为善无近名,为恶无近刑。缘督以为经,可以保身,可以全生,可以养亲,可以尽年。 307 |

308 |

309 | (páo)为文惠君解牛,手之所触,肩之所倚,足之所履,膝之所()(huā)(xiǎng)然,奏刀(huō),莫不中音。合于《桑林》之舞,乃中《经首》之会。 342 |

343 |

344 | 文惠君曰:「嘻,善哉!技()至此乎?」 352 |

353 |

354 | 庖丁释刀对曰:「臣之所好者,道也,进乎技矣。始臣之解牛之时,所见无非牛者。三年之后,未尝见全牛也。方今之时,臣以神遇而不以目视,官知止而神欲行。依乎天理批大()导大(kuǎn)固然,技经肯(qìng)之未尝,而况大()乎!良庖岁更刀,割也;族庖月更刀,折也。今臣之刀十九年矣,所解数千牛矣,而刀刃若新发于(xíng)。彼节者有间,而刀刃者无厚;以无厚入有间,恢恢乎其于游刃必有余地矣,是以十九年而刀刃若新发于硎。虽然,每至于族,吾见其难为,(chù)然为戒,视为止,行为迟。动刀甚微,(huò)然已解,如土委地。提刀而立,为之四顾,为之(chóu)(chú)满志,善刀而藏之。」 397 |

398 |

文惠君曰:「善哉!吾闻庖丁之言,得养生焉。」

399 |
400 |
401 |
402 | 403 |

404 | 多栏排版# 405 |

406 |

赫蹏预置了多种多栏布局类,可以按栏数或每栏行宽进行设置。

407 |
408 | 如何使用? 409 |

410 | 为容器元素<div class="heti">添加名为heti--columns-2的class即可实现双栏排版: 412 |

413 |
<div class="heti heti--columns-2">...</div>
414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 |
方式对应类名可选数值
按栏目数量heti--columns-32, 3, 4
按每栏行宽heti--columns-16em16em, 20em, 24em, … +4em, … , 48em
435 |
436 |
437 | 示例 438 |
439 |
440 |

441 | 先帝创业未半而中道崩殂,今天下三分,益州疲弊,此诚危急存亡之秋也。然侍卫之臣不懈于内,忠志之士忘身于外者,盖追先帝之殊遇,欲报之于陛下也。诚宜开张圣听,以光先帝遗德,恢弘志士之气,不宜妄自菲薄,引喻失义,以塞忠谏之路也。 442 |

443 |

444 | 宫中府中,俱为一体;陟罚臧否,不宜异同。若有作奸犯科及为忠善者,宜付有司论其刑赏,以昭陛下平明之理;不宜偏私,使内外异法也。 445 |

446 |

447 | 侍中、侍郎郭攸之、费祎、董允等,此皆良实,志虑忠纯,是以先帝简拔以遗陛下:愚以为宫中之事,事无大小,悉以咨之,然后施行,必能裨补阙漏,有所广益。 448 |

449 |

450 | 将军向宠,性行淑均,晓畅军事,试用于昔日,先帝称之曰能,是以众议举宠为督。愚以为营中之事,悉以咨之,必能使行阵和睦,优劣得所。 451 |

452 |

453 | 亲贤臣,远小人,此先汉所以兴隆也;亲小人,远贤臣,此后汉所以倾颓也。先帝在时,每与臣论此事,未尝不叹息痛恨于桓、灵也。侍中、尚书、长史、参军,此悉贞良死节之臣,愿陛下亲之信之,则汉室之隆,可计日而待也。 454 |

455 |

456 | 臣本布衣,躬耕于南阳,苟全性命于乱世,不求闻达于诸侯。先帝不以臣卑鄙,猥自枉屈,三顾臣于草庐之中,咨臣以当世之事,由是感激,遂许先帝以驱驰。后值倾覆,受任于败军之际,奉命于危难之间,尔来二十有一年矣。 457 |

458 |

459 | 先帝知臣谨慎,故临崩寄臣以大事也。受命以来,夙夜忧叹,恐托付不效,以伤先帝之明;故五月渡泸,深入不毛。今南方已定,兵甲已足,当奖率三军,北定中原,庶竭驽钝,攘除奸凶,兴复汉室,还于旧都。此臣所以报先帝而忠陛下之职分也。至于斟酌损益,进尽忠言,则攸之、祎、允之任也。 460 |

461 |

462 | 愿陛下托臣以讨贼兴复之效,不效,则治臣之罪,以告先帝之灵。若无兴德之言,则责攸之、祎、允等之慢,以彰其咎;陛下亦宜自谋,以咨诹善道,察纳雅言,深追先帝遗诏。臣不胜受恩感激。 463 |

464 |

今当远离,临表涕零,不知所言。

465 |
466 |
多栏排版演示
467 |
468 |
469 | 470 |

471 | 竖排排版# 472 |

473 |

赫蹏预置了传统的竖排(直排)方向排版,同样贴合栅格。

474 |
475 | 如何使用? 476 |

477 | 为容器元素<div class="heti">添加名为heti--vertical的class即可实现竖排布局: 479 |

480 |
<div class="heti heti--vertical">...</div>
481 |
482 |
483 | 示例 484 |
485 |
486 |
487 |

出師表

488 |

489 | 作者:諸葛亮(181年-234年10月8日) 491 |

492 |

493 | 先帝創業未半,而中道崩殂;今天下三分,益州疲弊,此誠危急存亡之秋也﹗然侍衞之臣,不懈於內;忠志之士,忘身於外者,蓋追先帝之殊遇,欲報之於陛下也。 494 |

495 |

496 | 誠宜開張聖聽,以光先帝遺德,恢弘志士之氣﹔不宜妄自菲薄,引喻失義,以塞忠諫之路也。 497 |

498 |

499 | 宮中、府中,俱為一體;陟罰臧否,不宜異同。若有作姦、犯科,及為忠善者,宜付有司,論其刑賞,以昭陛下平明之治;不宜偏私,使內外異法也。 500 |

501 |

502 | 侍中、侍郎郭攸之、費禕、董允等,此皆良實,志慮忠純,是以先帝簡拔以遺陛下。愚以為宮中之事,事無大小,悉以咨之,然後施行,必能裨補闕漏,有所廣益。將軍向寵,性行淑均,曉暢軍事,試用於昔日,先帝稱之曰「能」,是以眾議舉寵為督。愚以為營中之事,悉以咨之,必能使行陣和睦,優劣得所。 503 |

504 |

505 | 親賢臣,遠小人,此先漢所以興隆也﹔親小人,遠賢臣,此後漢所以傾頹也。先帝在時,每與臣論此事,未嘗不歎息痛恨於桓、靈也!侍中、尚書、長史、參軍,此悉貞良死節之臣,願陛下親之、信之,則漢室之隆,可計日而待也。 506 |

507 |

508 | 臣本布衣,躬耕於南陽,苟全性命於亂世,不求聞達於諸侯。先帝不以臣卑鄙,猥自枉屈,三顧臣於草廬之中,諮臣以當世之事;由是感激,遂許先帝以驅馳。後值傾覆,受任於敗軍之際,奉命於危難之間,爾來二十有一年矣。先帝知臣謹慎,故臨崩寄臣以大事也。受命以來,夙夜憂歎,恐託付不效,以傷先帝之明。故五月渡瀘,深入不毛。今南方已定,兵甲已足,當獎率三軍,北定中原,庶竭駑鈍,攘除姦凶,興復漢室,還於舊都。此臣所以報先帝而忠陛下之職分也。至於斟酌損益,進盡忠言,則攸之、禕、允之任也。 509 |

510 |

511 | 願陛下託臣以討賊興復之效;不效,則治臣之罪,以告先帝之靈。若無興德之言,則責攸之、禕、允等之慢,以彰其咎。陛下亦宜自謀,以諮諏善道,察納雅言,深追先帝遺詔。臣不勝受恩感激。今當遠離,臨表涕零,不知所言! 512 |

513 |
514 |
515 |
竖排排版演示
516 |
517 |
518 | 519 |

520 | 英文排版# 521 |

522 |
523 | 效果演示 524 |
525 |
526 |

Lorem Ipsum

527 |

528 | There is no one who loves pain itself, who 530 | seeks after it and wants to have it, simply 531 | because it is pain... 533 |

534 |

535 | Lorem Ipsum is simply dummy text of 536 | the printing and typesetting industry. Lorem 537 | Ipsum has been the industry's standard dummy 538 | text ever since the 1500s, when an unknown 539 | printer took a galley of type and scrambled it 540 | to make a type specimen book. It has survived 541 | not only five centuries, but also the leap into 542 | electronic typesetting, remaining essentially 543 | unchanged. It was popularised in the 1960s with 544 | the release of Letraset sheets containing Lorem 545 | Ipsum passages, and more recently with desktop 546 | publishing software like 547 | Aldus PageMaker including versions of 548 | Lorem Ipsum. 549 |

550 |

551 | The standard chunk of Lorem Ipsum used since the 552 | 1500s is reproduced below for those interested. 553 | Sections 1.10.32 and 1.10.33 from 554 | "de Finibus Bonorum et Malorum" by 555 | Cicero are also reproduced in their exact 556 | original form, accompanied by English versions 557 | from the 1914 translation by H. Rackham. 558 |

559 |
560 |
561 |
562 | 563 |
564 | 565 |

566 | 设计原则# 567 |

568 |

569 | 赫蹏项目的初衷很简单:它不作为一个CSS 570 | Reset出现,而是根据通行的中文排版规范,对网页正文区域进行排版样式增强。在部分CSS特性尚未有浏览器支持前,可通过JavaScript实现功能补充。 571 |

572 |

文字

573 |

574 | 参考《中文排版需求[2]》中描述的常用书籍排版字体,赫蹏提供了黑体、宋体和传统三种字体风格,前两者分别对应无衬线、衬线字体族。文字默认采用16px作为标准字号。在标题等文字较大的情况下,会适当地增加字间距以便获得更好地可读性。 580 |

581 |
582 | 查看字体风格详细对照表 583 |
584 | 585 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 608 | 609 | 610 | 611 | 612 | 617 | 618 | 619 | 620 | 621 | 628 | 629 | 630 | 631 | 632 | 639 | 640 | 641 | 642 | 643 | 652 | 653 | 654 | 655 | 656 | 668 | 669 | 670 | 671 | 672 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 |
586 | 各字体族下不同标签对应的字体 587 |
黑体宋体传统备注
标题黑体宋体楷体 604 |
605 |

记忆中的站台

606 |
607 |
正文宋体 613 |
614 |

那是一个风雨交加的夜晚。

615 |
616 |
引用楷体 622 |
623 |
624 | 锣鼓喧天,鞭炮齐鸣,红旗招展,人山人海。 625 |
626 |
627 |
强调宋体 633 |
634 |

635 | 父亲特意嘱咐了我两句。 636 |

637 |
638 |
对话楷体 644 |
645 |

646 | 他说:我买几个橘子去。你就在此地,不要走动。 649 |

650 |
651 |
图例黑体 657 |
658 |
659 | 664 |
橘子
665 |
666 |
667 |
表头黑体 673 |
674 | 675 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 |
676 | 当时的情形 677 |
角色物品
父亲橘子
车票
691 |
692 |
角标黑体黑体黑体鲁迅[1]曾经没有说过
703 |
704 |
705 |

标点

706 |

707 | 参考《中文排版需求》制定符号样式。唯一的差异在于简体中文一律采用直角引号(「」)替代弯引号(“”),这样可以保持字符等宽。 708 |

709 |
710 | 查看如何将引号设置为弯引号(“”) 711 |

712 | 通过源码引用的方式覆盖`_variables.scss`文件中`$chinese-quote-set`变量的值为`cn`即可将引号设为GB/T 713 | 15834-2011的国家标准。 714 |

715 |
716 |

间距

717 |

718 | 为保持页面元素总是贴合垂直栅格,块级元素(段落、列表、表格等)采用一行行高作为底边距,半行行高作为顶边距。标题根据亲密性原则采用相反的边距设计。 719 |

720 | 721 |
722 | 723 |

724 | 附录# 725 |

726 |

727 | 兼容性# 728 |

729 |

730 | 赫蹏在间距、边框、位置属性上采用了Logical 731 | properties,在所有现代浏览器上表现良好。 732 |

733 |
734 | 查看兼容性列表 735 |
736 | 737 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 |
738 | 兼容性列表(未经充分测试) 739 |
ChromeSafariFirefoxIEEdge
兼容性6912.13暂未支持79
757 |
758 |
759 | 760 |

761 | 标签示例表# 762 |

763 |
764 | 查看标签示例表 765 |
766 | 767 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | 784 | 791 | 792 | 793 | 794 | 800 | 805 | 806 | 807 | 808 | 814 | 815 | 816 | 817 | 818 | 824 | 830 | 831 | 832 | 833 | 840 | 845 | 846 | 847 | 848 | 853 | 856 | 857 | 858 | 859 | 864 | 869 | 870 | 871 | 872 | 877 | 881 | 882 | 883 | 884 | 889 | 890 | 891 | 892 | 893 | 898 | 899 | 900 | 901 | 902 | 908 | 912 | 913 |
768 | 常用标签样式示例表 769 |
类型标签效果
链接 778 | <a 780 | href="https://github.com/sivan/heti" 781 | title="赫蹏">heti.css</a> 783 | 785 | heti.css 790 |
缩写 795 | <abbr title="Cascading Style 797 | Sheets">CSS</abbr> 799 | 801 | CSS 804 |
代码 809 | <code>.heti { star: 5; 811 | }</code> 813 | .heti { star: 5; }
专名号 819 | 此时来自<u 821 | title="位于山东省聊城市阳谷县城东">景阳冈</u>的<u>武松</u>大喝一声:<q>纳命来!</q> 823 | 825 | 此时来自景阳冈武松大喝一声:纳命来! 829 |
文本变动 834 | 这次考试,我考了<del 836 | datetime="17:00:00">58</del><ins 837 | datetime="18:15:00">98</ins>分呢! 839 | 841 | 这次考试,我考了5898分呢! 844 |
文本更新 849 | 因为谁也不认识,所以最后我们决定念<s>dí</s>tí。 852 | 854 | 因为谁也不认识,所以最后我们决定念tí。 855 |
引号 860 | 窃·格瓦拉曾经说过:<q>打工是不可能打工的。</q> 863 | 865 | 窃·格瓦拉曾经说过:打工是不可能打工的。 868 |
术语 873 | <dfn>窃·格瓦拉</dfn>,中国大陆网络红人、罪犯。被奉为百度「戒赌吧」400万会员的「精神领袖」。 876 | 878 | 窃·格瓦拉,中国大陆网络红人、罪犯。被奉为百度「戒赌吧」400万会员的「精神领袖」。 880 |
标记 885 | 这道题<mark>必考</mark>,你们爱记不记。 888 | 这道题必考,你们爱记不记。
强调 894 | 稳住,<em>我们能赢</em>! 897 | 稳住,我们能赢
着重号 903 | 我们<span 905 | class="heti-em">必将</span>战胜这场疫情。 907 | 909 | 我们必将战胜这场疫情。 911 |
914 |
915 |
916 | 917 |

918 | 增强脚本beta# 920 |

921 |

922 | 由于部分CSS特性尚未有浏览器支持等原因,可选择使用增强脚本进行排版效果优化。在页面的</body>标签前引入JavaScript脚本后初始化即可: 923 |

924 |
<script src="//unpkg.com/heti/umd/heti-addon.min.js"></script>
 925 |         <script>
 926 |           const heti = new Heti('.heti');
 927 |           heti.autoSpacing();
 928 |         </script>
929 |

目前支持的功能有:

930 |
    931 |
  • 932 | 中英文混排优化:无论你的输入习惯是否在中西文之间留有「空格」[3],都会统一成标准间距(¼字宽的空白); 935 |
  • 936 |
  • 937 | 标点挤压:自动对中文标点进行½字宽的挤压(弯引号和间隔符挤压¼字宽)。 938 |
  • 939 |
940 |

效果演示:

941 | 942 | 945 | 946 | 947 | 948 | 949 | 950 | 956 | 957 | 958 | 959 | 965 | 966 | 967 | 968 | 969 | 970 | 971 | 986 | 987 | 988 | 989 | 1003 | 1004 | 1005 | 1006 | 1007 | 1008 | 1009 | 1010 | 1011 | 1012 | 1013 | 1014 | 1015 | 1016 |
943 | 增强脚本示例表 944 |
中西文混排美化
默认文本 951 |
952 | Hello, world!是大家第一次学习Programming时最常写的demo,它看似简单,但对有些人来说寥寥数语有时也会产生bug。 954 |
955 |
脚本效果 960 |
961 | Hello, world!是大家第一次学习Programming时最常写的demo,它看似简单,但对有些人来说寥寥数语有时也会产生bug。 963 |
964 |
标点挤压
默认文本 972 |
979 | 古代称用以书写的小幅绢帛。后亦以借指纸。《汉书·外戚传下·孝成赵皇后》:「武(籍武 980 | )发篋中,有裹药二枚,赫蹏书。」颜师古注:「邓展曰:『赫音兄弟鬩墙之鬩。』应劭曰:『赫蹏,薄小纸也。』」赵彦卫《云麓漫钞》卷七:「《赵后传》所谓『赫蹏』者,注云『薄小纸』,然其寔亦縑帛。」 984 |
985 |
脚本效果 990 |
996 | 古代称用以书写的小幅绢帛。后亦以借指纸。《汉书·外戚传下·孝成赵皇后》:「武(籍武 997 | )发篋中,有裹药二枚,赫蹏书。」颜师古注:「邓展曰:『赫音兄弟鬩墙之鬩。』应劭曰:『赫蹏,薄小纸也。』」赵彦卫《云麓漫钞》卷七:「《赵后传》所谓『赫蹏』者,注云『薄小纸』,然其寔亦縑帛。」 1001 |
1002 |
1017 | 1018 |

1019 | 开源协议# 1020 |

1021 |

「赫蹏」遵循MIT协议开源。

1022 | 1023 |
1024 |
    1025 |
  1. 1026 | ^ 1027 | CSS Reset:指代类似Eric Meyer's Reset 1028 | CSS的样式重置方案 1029 |
  2. 1030 |
  3. 1031 | ^ 1032 | 《中文排版需求》:https://w3c.github.io/clreq/ 1033 |
  4. 1034 |
  5. 1035 | ^ 1036 | 在当下前端技术尚不能完美解决中西文混排间距的情况下,常见的输入习惯是手动在中西文间加入空格(https://github.com/vinta/pangu.js)。这样做的弊端一是间距不可控(有时显得过大),二是通过空格符来排版只能算无奈之举。好消息是在最新的macOS、iOS中,使用原生语言开发的文本区域会自动处理中西文混排的间距(无论是否加空格),期待不用手敲空格的日子早日到来。 1037 |
  6. 1038 |
1039 |
1040 |
1041 |
1042 | 1043 | 1135 | 1136 | 1137 | --------------------------------------------------------------------------------