feat: 论坛 emoji 表情支持

- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
This commit is contained in:
2026-09-28 01:28:41 +08:00
parent 2f96a36275
commit 34aee91a13
3522 changed files with 1560 additions and 251 deletions

View File

@@ -0,0 +1,129 @@
/**
* 一次性资产脚本:从 github.com/rento666/douyin-emoji 下载表情图片存档,
* 拍平拷贝到 frontend/public/emoji/dy/ 并生成 manifest.json。
*
* 用法:node scripts/fetch-dy-emoji.mjs
* 产物(图片 + manifest.json)需提交进 git;重复执行会先清空目标目录。
*/
import { execFileSync } from "node:child_process";
import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
import { readdir, writeFile, cp } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const REPO_ZIPS = [
// 国内网络下 GitHub 直连常被限速,优先走镜像加速(实测 gh-proxy.com 可达 MB/s 级)
"https://gh-proxy.com/https://github.com/rento666/douyin-emoji/archive/refs/heads/main.zip",
"https://codeload.github.com/rento666/douyin-emoji/zip/refs/heads/main",
"https://github.com/rento666/douyin-emoji/archive/refs/heads/main.zip",
];
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FRONTEND = path.resolve(HERE, "..");
const DEST = path.join(FRONTEND, "public", "emoji", "dy");
async function downloadZip(url, file) {
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
if (!res.ok || !res.body) throw new Error(`下载失败 HTTP ${res.status}: ${url}`);
await pipeline(Readable.fromWeb(res.body), createWriteStream(file));
}
async function downloadZipWithFallback(file) {
let lastErr;
for (const url of REPO_ZIPS) {
try {
console.log(`尝试 ${url}`);
await downloadZip(url, file);
return;
} catch (e) {
lastErr = e;
console.warn(`下载失败:${e.message}`);
}
}
throw lastErr;
}
function extractZip(zip, dir) {
// Win10+/Linux 自带 bsdtar,可直接解 zip,无需额外依赖
execFileSync("tar", ["-xf", zip, "-C", dir], { stdio: "inherit" });
}
async function collectPng(dir) {
const out = [];
for (const name of await readdir(dir)) {
const p = path.join(dir, name);
if (statSync(p).isDirectory()) out.push(...(await collectPng(p)));
else if (name.toLowerCase().endsWith(".png")) out.push(p);
}
return out;
}
async function main() {
if (!existsSync(DEST)) mkdirSync(DEST, { recursive: true });
const tmp = await readdir(os.tmpdir()).then(() => os.tmpdir());
const work = path.join(tmp, `dy-emoji-${Date.now()}`);
mkdirSync(work, { recursive: true });
const zip = path.join(work, "repo.zip");
console.log("下载仓库压缩包…");
await downloadZipWithFallback(zip);
console.log("解压…");
extractZip(zip, work);
const repo = path.join(work, "douyin-emoji-main", "dy_emojis");
const tosDir = path.join(repo, "tos_emoji");
const fusDir = path.join(repo, "fus_emoji");
if (!existsSync(tosDir) || !existsSync(fusDir)) {
throw new Error(`仓库结构不符合预期:${repo}`);
}
const tosFiles = await collectPng(tosDir);
const fusFiles = await collectPng(fusDir);
if (tosFiles.length === 0 || fusFiles.length === 0) {
throw new Error(`表情文件为空:tos=${tosFiles.length} fus=${fusFiles.length}`);
}
// 拍平拷贝:目标目录清空后写入,文件名唯一性校验
rmSync(DEST, { recursive: true, force: true });
mkdirSync(DEST, { recursive: true });
const seen = new Map();
const tos = [];
const fus = [];
let bytes = 0;
for (const [group, files] of [
["tos", tosFiles],
["fus", fusFiles],
]) {
for (const f of files) {
const key = path.basename(f, ".png");
if (!/^[A-Za-z0-9_-]+$/.test(key)) {
throw new Error(`文件名含非法字符(会破坏短代码):${key}`);
}
if (seen.has(key)) throw new Error(`文件名冲突:${key}(${f} 与 ${seen.get(key)})`);
seen.set(key, f);
await cp(f, path.join(DEST, `${key}.png`));
bytes += statSync(f).size;
(group === "tos" ? tos : fus).push(key);
}
}
tos.sort();
fus.sort();
const manifest = { tos, fus };
await writeFile(path.join(DEST, "manifest.json"), JSON.stringify(manifest), "utf8");
console.log(
`完成:tos=${tos.length} 张,fus=${fus.length} 张,共 ${(bytes / 1024 / 1024).toFixed(1)}MB → ${DEST}`
);
console.log(`manifest.json:${path.join(DEST, "manifest.json")}`);
// 清理临时目录
rmSync(work, { recursive: true, force: true });
}
main().catch((e) => {
console.error(e);
process.exit(1);
});