Commit b22cb9e4 authored by xuchentao's avatar xuchentao

feat: import Word articles with embedded images

parent 17a35237
Pipeline #433 passed with stage
in 20 seconds
...@@ -12,7 +12,7 @@ variables: ...@@ -12,7 +12,7 @@ variables:
SHARED_ENV: "/root/yinzhuang/shared/.env" SHARED_ENV: "/root/yinzhuang/shared/.env"
DEPLOY_LOCK: "/root/yinzhuang/shared/deploy.lock" DEPLOY_LOCK: "/root/yinzhuang/shared/deploy.lock"
APP_NAME: "yinzhuang" APP_NAME: "yinzhuang"
SITE_URL: "http://127.0.0.1:8790/" SITE_URL: "http://127.0.0.1:8791/"
PM2_BIN: "/usr/bin/pm2" PM2_BIN: "/usr/bin/pm2"
PM2_HOME_DIR: "/root/.pm2" PM2_HOME_DIR: "/root/.pm2"
PM2_USE_SUDO: "1" PM2_USE_SUDO: "1"
......
...@@ -28,13 +28,13 @@ npm run build ...@@ -28,13 +28,13 @@ npm run build
npm start npm start
``` ```
- 官网:`http://localhost:8790/` - 官网:`http://localhost:8791/`
- 文章后台:`http://localhost:8790/admin` - 文章后台:`http://localhost:8791/admin`
- 银饰资讯:`http://localhost:8790/articles/` - 银饰资讯:`http://localhost:8791/articles/`
启动命令会自动读取项目根目录的 `.env`,其中包含: 启动命令会自动读取项目根目录的 `.env`,其中包含:
- `CMS_PORT`:官网与后台共用的服务端口,默认 `8790`(斯嘉丽项目端口 `8788` + 1) - `CMS_PORT`:官网与后台共用的服务端口,默认 `8791`
- `CMS_PASSWORD`:后台登录密码 - `CMS_PASSWORD`:后台登录密码
- `CMS_SECRET`:后台会话签名密钥 - `CMS_SECRET`:后台会话签名密钥
- `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token - `CMS_API_KEY`:外部程序调用管理 API 时使用的 Bearer Token
...@@ -64,10 +64,34 @@ npm start ...@@ -64,10 +64,34 @@ npm start
| 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 | | 发布 | `POST /api/cms/articles/:slug/unpublish` | 下架并生成静态页面 |
| 工具 | `POST /api/cms/preview` | Markdown 预览 | | 工具 | `POST /api/cms/preview` | Markdown 预览 |
| 工具 | `POST /api/cms/uploads` | 上传文章图片 | | 工具 | `POST /api/cms/uploads` | 上传文章图片 |
| 导入 | `POST /api/cms/imports/word` | 导入 `.docx` 为 Markdown 草稿并提取内嵌图片 |
| 构建 | `GET /api/cms/build` | 查询静态构建状态 | | 构建 | `GET /api/cms/build` | 查询静态构建状态 |
除会话登录接口外,外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。 除会话登录接口外,外部程序可通过 `Authorization: Bearer <CMS_API_KEY>` 调用这些地址。
后台文章列表的“导入文件”支持 `.md``.docx`:Markdown 的第一个一级标题会作为标题并从正文移除;Word 文件由下述接口转换并直接创建草稿。导入默认使用分类列表中的第一个分类,保存后可在编辑页调整。
### 通过 API 导入 Word
Word 导入接口接收不超过 20MB 的 `.docx` Data URL,不支持旧版 `.doc`。文档第一个一级标题会作为默认文章标题并从正文中移除;也可以显式传入 `title` 覆盖。内嵌的 PNG、JPG、WEBP、GIF 图片会自动保存到上传目录并转成 Markdown 图片链接。导入只创建草稿,不会自动发布;转换或建稿失败时会删除本次已经提取的图片。
```http
POST /api/cms/imports/word
Authorization: Bearer <CMS_API_KEY>
Content-Type: application/json
```
```json
{
"fileName": "银饰文章.docx",
"dataUrl": "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,...",
"category": "佩戴养护",
"title": "可选的自定义标题"
}
```
成功后返回自动生成的 `slug`、最终 `title``imageCount`、转换警告和 `new-draft` 草稿状态。确认内容后再调用 `POST /api/cms/articles/:slug/publish` 发布。
## 内容目录 ## 内容目录
- `src/content/articles/`:后台工作稿,包括草稿、待发布和已发布文章 - `src/content/articles/`:后台工作稿,包括草稿、待发布和已发布文章
...@@ -127,7 +151,7 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock ...@@ -127,7 +151,7 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
- 部署根目录:`/root/yinzhuang` - 部署根目录:`/root/yinzhuang`
- 持久化配置:`/root/yinzhuang/shared/.env` - 持久化配置:`/root/yinzhuang/shared/.env`
- PM2 应用名:`yinzhuang` - PM2 应用名:`yinzhuang`
- 服务端口:`8790` - 服务端口:`8791`
- Nginx 配置:`/etc/nginx/conf.d/yinzhuang.conf` - Nginx 配置:`/etc/nginx/conf.d/yinzhuang.conf`
- 公网入口:`101.126.10.129:80` - 公网入口:`101.126.10.129:80`
...@@ -138,7 +162,7 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock ...@@ -138,7 +162,7 @@ CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
```env ```env
CMS_DATA_DIR=/root/yinzhuang/shared/cms-data CMS_DATA_DIR=/root/yinzhuang/shared/cms-data
CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock CMS_BUILD_LOCK=/root/yinzhuang/shared/site-build.lock
CMS_PORT=8790 CMS_PORT=8791
``` ```
`CMS_DATA_DIR` 中包含工作稿、已发布文章、分类和上传文件。数据库文件、其他业务数据及所有运行期生成的持久化内容也必须放在 `shared/` 中,再通过环境变量或软链接提供给应用。`current` 的全部内容会在每次部署时删除,不能用于持久化数据。 `CMS_DATA_DIR` 中包含工作稿、已发布文章、分类和上传文件。数据库文件、其他业务数据及所有运行期生成的持久化内容也必须放在 `shared/` 中,再通过环境变量或软链接提供给应用。`current` 的全部内容会在每次部署时删除,不能用于持久化数据。
......
...@@ -19,7 +19,7 @@ npm run build ...@@ -19,7 +19,7 @@ npm run build
npm start npm start
``` ```
生产模式默认监听 `http://127.0.0.1:8790/` 生产模式默认监听 `http://127.0.0.1:8791/`
## 常用命令 ## 常用命令
...@@ -40,7 +40,7 @@ npm start # 启动 standalone 服务 ...@@ -40,7 +40,7 @@ npm start # 启动 standalone 服务
- Astro standalone 输出:`dist/server/``dist/client/` - Astro standalone 输出:`dist/server/``dist/client/`
- GitLab CI:`.gitlab-ci.yml` - GitLab CI:`.gitlab-ci.yml`
- Nginx 配置:`deploy/nginx/yinzhuang.conf` - Nginx 配置:`deploy/nginx/yinzhuang.conf`
- 生产端口:`8790` - 生产端口:`8791`
- 站点域名:`www.inzung.cn` - 站点域名:`www.inzung.cn`
- 服务器目录:`/root/yinzhuang/current`(唯一运行版本,实体目录)与 `/root/yinzhuang/shared`(持久化数据) - 服务器目录:`/root/yinzhuang/current`(唯一运行版本,实体目录)与 `/root/yinzhuang/shared`(持久化数据)
......
# 银妆官网 Nginx 配置 # 银妆官网 Nginx 配置
# 通过服务器 IP 访问,反向代理到本机 8790 端口。 # 通过服务器 IP 访问,反向代理到本机 8791 端口。
server { server {
listen 80; listen 80;
server_name inzung.cn www.inzung.cn 101.126.10.129; server_name inzung.cn www.inzung.cn 101.126.10.129;
...@@ -8,7 +8,7 @@ server { ...@@ -8,7 +8,7 @@ server {
client_max_body_size 28m; client_max_body_size 28m;
location / { location / {
proxy_pass http://127.0.0.1:8790; proxy_pass http://127.0.0.1:8791;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
......
This diff is collapsed.
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"build:astro": "astro check && astro build", "build:astro": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check", "check": "astro check",
"test": "node --import tsx/esm --test tests/*.test.ts",
"start": "node --env-file=.env server.mjs", "start": "node --env-file=.env server.mjs",
"admin": "npm start", "admin": "npm start",
"serve": "npm run build && npm start" "serve": "npm run build && npm start"
...@@ -22,10 +23,16 @@ ...@@ -22,10 +23,16 @@
"@astrojs/sitemap": "^3.7.3", "@astrojs/sitemap": "^3.7.3",
"astro": "5.18.2", "astro": "5.18.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"marked": "^14.1.4" "mammoth": "^1.12.0",
"marked": "^14.1.4",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.6", "@astrojs/check": "^0.9.6",
"@types/turndown": "^5.0.6",
"docx": "^9.7.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const API_BASE = "/api/cms"; const API_BASE = "/api/cms";
const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
let categories = []; let categories = [];
let editingSlug = null; let editingSlug = null;
let editingStatus = "new-unsaved"; let editingStatus = "new-unsaved";
...@@ -456,6 +457,58 @@ async function openEditor(slug) { ...@@ -456,6 +457,58 @@ async function openEditor(slug) {
} }
$("#new-article").addEventListener("click", () => openEditor(null)); $("#new-article").addEventListener("click", () => openEditor(null));
$("#import-article").addEventListener("click", () => $("#article-file").click());
$("#article-file").addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (file.size > MAX_UPLOAD_SIZE) {
$("#import-status").textContent = "文件不能超过 20MB";
return;
}
const extension = file.name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (![".md", ".docx"].includes(extension)) {
$("#import-status").textContent = "仅支持 Markdown(.md)或 Word(.docx)文件";
return;
}
$("#import-status").textContent = `正在导入 ${file.name}…`;
try {
let result;
if (extension === ".md") {
let body = await file.text();
const heading = body.match(/^#\s+(.+)$/m);
const title = heading?.[1].trim() || file.name.replace(/\.md$/i, "");
if (heading?.index !== undefined) {
body = `${body.slice(0, heading.index)}${body.slice(heading.index + heading[0].length)}`
.trim()
.replace(/\n{3,}/g, "\n\n");
}
if (!body) throw new Error("Markdown 文件中没有可导入的正文内容");
result = await api("/articles", { method: "POST", body: { title, category: categories[0], body } });
} else {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
result = await api("/imports/word", {
method: "POST",
body: { fileName: file.name, category: categories[0], dataUrl: String(dataUrl).replace(/^data:[^;,]*;/, `data:${DOCX_MIME};`) },
});
}
const successMessage = extension === ".docx"
? `导入成功:已创建草稿并提取 ${result.imageCount} 张图片`
: "导入成功:已创建 Markdown 草稿";
$("#import-status").textContent = successMessage;
await openEditor(result.slug);
$("#message").textContent = successMessage;
} catch (error) {
$("#import-status").textContent = error.message;
}
});
$("#back").addEventListener("click", () => { $("#back").addEventListener("click", () => {
if (dirty && !confirm("还有未保存的内容,确定返回文章列表吗?")) return; if (dirty && !confirm("还有未保存的内容,确定返回文章列表吗?")) return;
setView(false); setView(false);
......
...@@ -45,6 +45,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(209,37,26,.1 ...@@ -45,6 +45,7 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(209,37,26,.1
.view-head p { margin: 0 0 5px; color: var(--primary-dark); font-size: 11px; font-weight: 700; letter-spacing: .16em; } .view-head p { margin: 0 0 5px; color: var(--primary-dark); font-size: 11px; font-weight: 700; letter-spacing: .16em; }
.view-head h1 { margin: 0; font-size: 30px; } .view-head h1 { margin: 0; font-size: 30px; }
.view-actions { display: flex; gap: 9px; } .view-actions { display: flex; gap: 9px; }
.import-status { min-height: 20px; margin: -16px 0 12px; color: var(--muted); font-size: 13px; text-align: right; }
.article-list { display: grid; gap: 10px; } .article-list { display: grid; gap: 10px; }
.article-row { padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; background: white; border: 1px solid var(--border); border-radius: 14px; } .article-row { padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; background: white; border: 1px solid var(--border); border-radius: 14px; }
.article-summary { min-width: 0; flex: 1; } .article-summary { min-width: 0; flex: 1; }
......
import fs from "node:fs/promises";
import path from "node:path";
import { Document, HeadingLevel, ImageRun, Packer, Paragraph, TextRun } from "docx";
const root = process.cwd();
const outputDirectory = path.join(root, "tests", "fixtures");
const imageFile = "/Users/mac/Desktop/test.png";
const marker = "YINZHUANG-PRODUCTION-IMPORT-20260731";
await fs.mkdir(outputDirectory, { recursive: true });
await fs.writeFile(path.join(outputDirectory, "import-test.md"), [
"# 旧银来料加工前,建议先确认这五件事",
"",
"家里闲置的旧银,可能是一只不再合圈口的手镯,也可能是一件承载着家庭记忆的老首饰。把旧银重新设计、熔炼打制,可以让熟悉的材质延续新的故事。为了让沟通和制作更顺利,送到门店前建议先确认下面五件事。",
"",
"![旧银来料加工沟通示意图]({{TEST_IMAGE_URL}})",
"",
"## 一、先明确想做成什么",
"",
"可以先说明佩戴者、使用场景、喜欢的风格和预算。例如,是把旧银改成日常佩戴的实心手镯,还是制作一对带纪念刻字的戒指。参考图能帮助表达方向,但最终还要结合银料重量、尺寸和工艺可行性判断。",
"",
"## 二、现场确认来料状态与重量",
"",
"门店会先检查旧银的状态、材质标识和重量,再说明哪些部分可以继续使用。熔炼、修整和抛光会产生合理损耗,因此加工前应把称重结果、预计损耗和成品目标重量沟通清楚。",
"",
"## 三、把尺寸测准确",
"",
"手镯需要确认圈口和佩戴松紧,戒指需要测量指围,项链则要确认链长。足银质地较软,结构、克重与尺寸会共同影响稳定性和佩戴体验,不能只按外观决定。",
"",
"## 四、分开了解银料与工费",
"",
"光面、古法、磨砂、浮雕、錾刻、镂空和花丝等工艺所需时间与难度不同。建议在制作前确认来料处理、设计、工艺和附加定制分别如何计费,并核对预计交付时间。",
"",
"## 五、保留好确认信息",
"",
"款式、尺寸、刻字内容、重量、损耗、工费和交付时间都确认后,再进入制作环节。成品交付时,可以再次核对结构、表面状态和后续养护方式。",
"",
"银妆深耕银饰行业 14 年,提供来料私人加工、专属雕刻和现场打制等服务。复杂图案或特殊结构建议提前沟通,让匠人根据银料和用途给出更稳妥的方案。",
"",
`测试标记:${marker}-MARKDOWN`,
"",
].join("\n"));
const image = await fs.readFile(imageFile);
const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_2 });
const body = (text) => new Paragraph({ children: [new TextRun(text)] });
const document = new Document({
sections: [{
children: [
new Paragraph({ text: "足银手镯日常佩戴与养护指南", heading: HeadingLevel.HEADING_1 }),
body("足银手镯质地温润,也比许多合金首饰更柔软。日常佩戴时兼顾清洁、收纳与受力保护,能让手镯更长久地保持光泽和形态。"),
new Paragraph({ children: [new ImageRun({ data: image, transformation: { width: 320, height: 210 }, type: "png", altText: { title: "银饰养护测试图片", description: "用于验证 Word 导入图片提取", name: "test.png" } })] }),
heading("佩戴前确认圈口与结构"),
body("手掌最宽处、腕围和个人松紧偏好都会影响圈口选择。第一次购买建议到店试戴或在顾问指导下测量。足银手镯的实心、空心、开口、闭口和推拉结构受力方式不同,日常佩戴前应先了解正确的开合与调整方法。"),
heading("减少化学接触和重压"),
body("洗澡、游泳、泡温泉、运动或做家务时,可以暂时取下手镯。香水、发胶和护肤品使用后,待皮肤表面干燥再佩戴。足银质地较软,应避免用力掰折、碰撞和重压;如出现变形,交给专业门店检查调整更稳妥。"),
heading("不佩戴时单独密封"),
body("收纳前先用柔软布料擦去表面汗液,再放入密封袋或首饰盒。不同硬度、不同表面工艺的首饰尽量分开,避免互相摩擦。潮湿环境和长期裸露收纳都可能加快银饰氧化。"),
heading("根据工艺选择清洁方式"),
body("光面素银可用专业擦银布轻拭;做旧、鎏金、珐琅、珍珠或镶嵌款,不建议自行使用强效清洁液,以免破坏原有效果。银饰氧化发黑属于常见现象,并不等于材质有问题。"),
heading("定期使用专业养护"),
body("银妆采用足银 999/9999 国标银料,产品配套质检证书,并为所售银饰提供终身免费清洗、抛光、去氧化及基础整形服务。日常轻柔佩戴、正确收纳,再配合专业养护,能让银饰持续陪伴重要时刻。"),
body(`测试标记:${marker}-WORD`),
],
}],
});
await fs.writeFile(path.join(outputDirectory, "import-test.docx"), await Packer.toBuffer(document));
console.log(`Created fixtures in ${outputDirectory}`);
process.env.PORT ||= process.env.CMS_PORT || "8790"; process.env.PORT ||= process.env.CMS_PORT || "8791";
process.env.HOST ||= "127.0.0.1"; process.env.HOST ||= "127.0.0.1";
await import("./dist/server/entry.mjs"); await import("./dist/server/entry.mjs");
...@@ -489,3 +489,11 @@ export async function saveUpload(dataUrl: unknown): Promise<string> { ...@@ -489,3 +489,11 @@ export async function saveUpload(dataUrl: unknown): Promise<string> {
await writeAtomic(path.join(UPLOADS_DIR, name), buffer); await writeAtomic(path.join(UPLOADS_DIR, name), buffer);
return `/uploads/${name}`; return `/uploads/${name}`;
} }
export async function removeUpload(url: unknown): Promise<void> {
const match = String(url || "").match(/^\/uploads\/([a-zA-Z0-9._-]+)$/);
if (!match) throw new StoreError("图片地址不合法");
await fs.unlink(path.join(UPLOADS_DIR, match[1])).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
listWorkingArticles, listWorkingArticles,
publishArticle, publishArticle,
readWorkingArticle, readWorkingArticle,
removeUpload,
removeCategory, removeCategory,
renameCategory, renameCategory,
safeSlug, safeSlug,
...@@ -20,6 +21,7 @@ import { ...@@ -20,6 +21,7 @@ import {
unpublishArticle, unpublishArticle,
writeArticle, writeArticle,
} from "./article-store"; } from "./article-store";
import { convertWordToMarkdown, decodeWordDataUrl } from "./word-import";
import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs"; import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../../scripts/site-build.mjs";
const PASSWORD = process.env.CMS_PASSWORD || "admin"; const PASSWORD = process.env.CMS_PASSWORD || "admin";
...@@ -262,6 +264,42 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA ...@@ -262,6 +264,42 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
} }
} }
if (route === "imports/word" && method === "POST") {
const body = await bodyOf(request);
const fileName = String(body.fileName || "").trim();
const buffer = decodeWordDataUrl(body.dataUrl, fileName);
const uploadedUrls: string[] = [];
let createdSlug: string | null = null;
try {
const converted = await convertWordToMarkdown(buffer, fileName, async (dataUrl) => {
const url = await saveUpload(dataUrl);
uploadedUrls.push(url);
return url;
});
const title = String(body.title || "").trim() || converted.suggestedTitle;
const imported = await withSiteBuildLock(async () => {
const slug = await generateSlug(body.category);
createdSlug = slug;
const article = await writeArticle(slug, { title, category: body.category, body: converted.body }, true);
return { slug, publishStatus: await getPublishStatus(article) };
});
return json({
ok: true,
slug: imported.slug,
title,
publishStatus: imported.publishStatus,
imageCount: converted.imageCount,
warnings: converted.warnings,
}, 201);
} catch (error) {
if (createdSlug) {
await withSiteBuildLock(() => discardDraft(createdSlug as string)).catch(() => {});
}
await Promise.allSettled(uploadedUrls.map((url) => removeUpload(url)));
throw error;
}
}
if (parts[0] === "articles" && parts[1]) { if (parts[0] === "articles" && parts[1]) {
const slug = requireSlug(parts[1]); const slug = requireSlug(parts[1]);
if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") { if (parts.length === 3 && parts[2] === "draft" && method === "DELETE") {
......
import mammoth from "mammoth";
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
import { StoreError } from "./article-store";
export const MAX_WORD_FILE_SIZE = 20 * 1024 * 1024;
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const ACCEPTED_DOCX_MIMES = new Set([DOCX_MIME, "application/octet-stream", "application/zip"]);
const IMAGE_MIME_ALIASES: Record<string, string> = {
"image/jpg": "image/jpeg",
"image/x-png": "image/png",
};
const SUPPORTED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
export interface WordImportResult {
body: string;
suggestedTitle: string;
imageCount: number;
warnings: string[];
}
export function decodeWordDataUrl(dataUrl: unknown, fileName: unknown): Buffer {
const name = String(fileName || "").trim();
if (/\.doc$/i.test(name)) throw new StoreError("仅支持 .docx 文件,不支持旧版 .doc 格式");
if (!/\.docx$/i.test(name)) throw new StoreError("请选择 .docx 文件");
const match = String(dataUrl || "").match(/^data:([^;,]+);base64,([a-zA-Z0-9+/=\s]+)$/s);
if (!match || !ACCEPTED_DOCX_MIMES.has(match[1].toLowerCase())) {
throw new StoreError("Word 文件格式不正确,请上传 .docx 文件");
}
const buffer = Buffer.from(match[2].replace(/\s/g, ""), "base64");
if (!buffer.length || buffer.subarray(0, 2).toString("ascii") !== "PK") {
throw new StoreError("Word 文件无效或已经损坏");
}
if (buffer.length > MAX_WORD_FILE_SIZE) throw new StoreError("Word 文件不能超过 20MB", 413);
return buffer;
}
function titleFromFileName(fileName: string): string {
return fileName
.replace(/^.*[\\/]/, "")
.replace(/\.docx$/i, "")
.replace(/[_-]+/g, " ")
.trim() || "Word 导入文章";
}
function plainHeading(value: string): string {
return value
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[`*_~]/g, "")
.trim();
}
export async function convertWordToMarkdown(
buffer: Buffer,
fileName: string,
saveImage: (dataUrl: string) => Promise<string>,
): Promise<WordImportResult> {
let imageCount = 0;
let result: Awaited<ReturnType<typeof mammoth.convertToHtml>>;
try {
result = await mammoth.convertToHtml({ buffer }, {
styleMap: ["p[style-name='Title'] => h1:fresh"],
convertImage: mammoth.images.imgElement(async (image) => {
const mime = IMAGE_MIME_ALIASES[image.contentType] || image.contentType;
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
throw new StoreError(`Word 中包含不支持的图片格式:${image.contentType || "未知格式"}`);
}
const base64 = await image.readAsBase64String();
const src = await saveImage(`data:${mime};base64,${base64}`);
imageCount += 1;
return { src };
}),
});
} catch (error) {
if (error instanceof StoreError) throw error;
throw new StoreError(`Word 文件解析失败:${error instanceof Error ? error.message : "文件无效或已经损坏"}`);
}
const turndown = new TurndownService({
bulletListMarker: "-",
codeBlockStyle: "fenced",
headingStyle: "atx",
});
turndown.use(gfm);
let body = turndown.turndown(result.value).trim();
let suggestedTitle = titleFromFileName(fileName);
const firstHeading = body.match(/^#\s+(.+)$/m);
if (firstHeading && firstHeading.index !== undefined) {
suggestedTitle = plainHeading(firstHeading[1]) || suggestedTitle;
body = `${body.slice(0, firstHeading.index)}${body.slice(firstHeading.index + firstHeading[0].length)}`
.replace(/^\s+|\s+$/g, "")
.replace(/\n{3,}/g, "\n\n");
}
if (!body) throw new StoreError("Word 文件中没有可导入的正文内容");
return {
body,
suggestedTitle,
imageCount,
warnings: result.messages.map((message) => message.message),
};
}
...@@ -35,7 +35,9 @@ ...@@ -35,7 +35,9 @@
</header> </header>
<main id="list-view" class="view"> <main id="list-view" class="view">
<div class="view-head"><div><p>CONTENT</p><h1>银饰资讯</h1></div><div class="view-actions"><button id="manage-categories" class="ghost">管理分类</button><button id="new-article" class="primary">+ 新建文章</button></div></div> <div class="view-head"><div><p>CONTENT</p><h1>银饰资讯</h1></div><div class="view-actions"><button id="manage-categories" class="ghost">管理分类</button><button id="import-article" class="ghost">导入文件</button><button id="new-article" class="primary">+ 新建文章</button></div></div>
<input id="article-file" class="hidden" type="file" accept=".md,.docx,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<p id="import-status" class="import-status" aria-live="polite"></p>
<div id="article-list" class="article-list"></div> <div id="article-list" class="article-list"></div>
</main> </main>
......
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export function gfm(service: TurndownService): void;
}
# 旧银来料加工前,建议先确认这五件事
家里闲置的旧银,可能是一只不再合圈口的手镯,也可能是一件承载着家庭记忆的老首饰。把旧银重新设计、熔炼打制,可以让熟悉的材质延续新的故事。为了让沟通和制作更顺利,送到门店前建议先确认下面五件事。
![旧银来料加工沟通示意图]({{TEST_IMAGE_URL}})
## 一、先明确想做成什么
可以先说明佩戴者、使用场景、喜欢的风格和预算。例如,是把旧银改成日常佩戴的实心手镯,还是制作一对带纪念刻字的戒指。参考图能帮助表达方向,但最终还要结合银料重量、尺寸和工艺可行性判断。
## 二、现场确认来料状态与重量
门店会先检查旧银的状态、材质标识和重量,再说明哪些部分可以继续使用。熔炼、修整和抛光会产生合理损耗,因此加工前应把称重结果、预计损耗和成品目标重量沟通清楚。
## 三、把尺寸测准确
手镯需要确认圈口和佩戴松紧,戒指需要测量指围,项链则要确认链长。足银质地较软,结构、克重与尺寸会共同影响稳定性和佩戴体验,不能只按外观决定。
## 四、分开了解银料与工费
光面、古法、磨砂、浮雕、錾刻、镂空和花丝等工艺所需时间与难度不同。建议在制作前确认来料处理、设计、工艺和附加定制分别如何计费,并核对预计交付时间。
## 五、保留好确认信息
款式、尺寸、刻字内容、重量、损耗、工费和交付时间都确认后,再进入制作环节。成品交付时,可以再次核对结构、表面状态和后续养护方式。
银妆深耕银饰行业 14 年,提供来料私人加工、专属雕刻和现场打制等服务。复杂图案或特殊结构建议提前沟通,让匠人根据银料和用途给出更稳妥的方案。
测试标记:YINZHUANG-PRODUCTION-IMPORT-20260731-MARKDOWN
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { after } from "node:test";
import { Document, HeadingLevel, ImageRun, Packer, Paragraph, TextRun } from "docx";
const TEST_IMAGE = "/Users/mac/Desktop/test.png";
const UNIQUE_MARKER = "YINZHUANG-IMPORT-TEST-20260731";
const dataDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "yinzhuang-import-"));
process.env.CMS_DATA_DIR = dataDirectory;
process.env.CMS_BUILD_LOCK = path.join(dataDirectory, "site-build.lock");
process.env.CMS_API_KEY = "word-import-test-key";
const [{ handleCmsApi }, { GET: getUpload }] = await Promise.all([
import("../src/lib/cms-api"),
import("../src/pages/uploads/[file]"),
]);
after(async () => {
await fs.rm(dataDirectory, { recursive: true, force: true });
});
function cmsRequest(route: string, method = "GET", body?: Record<string, unknown>, key = "word-import-test-key"): Request {
return new Request(`http://localhost/api/cms/${route}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
}
async function wordDocument(): Promise<Buffer> {
const png = await fs.readFile(TEST_IMAGE);
const document = new Document({
sections: [{
children: [
new Paragraph({ text: "Yinzhuang Word Import Test", heading: HeadingLevel.HEADING_1 }),
new Paragraph({ children: [new TextRun(`Imported Word body marker: ${UNIQUE_MARKER}`)] }),
new Paragraph({ children: [new ImageRun({ data: png, transformation: { width: 160, height: 105 }, type: "png" })] }),
],
}],
});
return Packer.toBuffer(document);
}
test("imports a Markdown file payload as a draft", async () => {
const markdown = `# 银妆 Markdown 导入测试\n\n这是 Markdown 上传测试:${UNIQUE_MARKER}`;
const heading = markdown.match(/^#\s+(.+)$/m);
assert.ok(heading?.index !== undefined);
const body = `${markdown.slice(0, heading.index)}${markdown.slice(heading.index + heading[0].length)}`.trim();
const response = await handleCmsApi(cmsRequest("articles", "POST", {
title: heading[1],
category: "佩戴养护",
body,
}), "articles");
assert.equal(response.status, 201);
const imported = await response.json();
assert.equal(imported.publishStatus, "new-draft");
const articleResponse = await handleCmsApi(cmsRequest(`articles/${imported.slug}`), `articles/${imported.slug}`);
const article = await articleResponse.json();
assert.equal(article.title, "银妆 Markdown 导入测试");
assert.match(article.body, new RegExp(UNIQUE_MARKER));
assert.doesNotMatch(article.body, /^#\s+/);
});
test("imports a docx with the requested embedded PNG as a Markdown draft", async () => {
const buffer = await wordDocument();
const response = await handleCmsApi(cmsRequest("imports/word", "POST", {
fileName: "银妆导入测试.docx",
category: "佩戴养护",
dataUrl: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${buffer.toString("base64")}`,
}), "imports/word");
assert.equal(response.status, 201);
const imported = await response.json();
assert.equal(imported.title, "Yinzhuang Word Import Test");
assert.equal(imported.publishStatus, "new-draft");
assert.equal(imported.imageCount, 1);
const articleResponse = await handleCmsApi(cmsRequest(`articles/${imported.slug}`), `articles/${imported.slug}`);
assert.equal(articleResponse.status, 200);
const article = await articleResponse.json();
assert.equal(article.category, "佩戴养护");
assert.match(article.body, new RegExp(UNIQUE_MARKER));
assert.doesNotMatch(article.body, /^#\s+Yinzhuang Word Import Test/m);
const imageUrl = article.body.match(/!\[[^\]]*\]\((\/uploads\/[^)]+)\)/)?.[1];
assert.ok(imageUrl);
const filename = imageUrl.replace(/^\/uploads\//, "");
const imageResponse = await getUpload({ params: { file: filename } } as never);
assert.equal(imageResponse.status, 200);
assert.equal(imageResponse.headers.get("content-type"), "image/png");
assert.deepEqual(Buffer.from(await imageResponse.arrayBuffer()), await fs.readFile(TEST_IMAGE));
});
test("requires valid authentication for Word import", async () => {
const response = await handleCmsApi(cmsRequest("imports/word", "POST", {}, "wrong-key"), "imports/word");
assert.equal(response.status, 401);
});
test("rejects legacy .doc files", async () => {
const response = await handleCmsApi(cmsRequest("imports/word", "POST", {
fileName: "旧文章.doc",
dataUrl: "data:application/octet-stream;base64,AA==",
}), "imports/word");
assert.equal(response.status, 400);
assert.match((await response.json()).error, /不支持旧版 \.doc/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment