feat: add web application
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_DIR = ROOT / "docs" / "admin_reference_prd"
|
||||
SOURCE = DOC_DIR / "后台管理系统需求文档.docx"
|
||||
TARGET = DOC_DIR / "后台管理系统需求文档.md"
|
||||
ASSET_DIR = DOC_DIR / "assets"
|
||||
|
||||
IMAGE_ALTS = {
|
||||
"prototype_01_dashboard.png": "后台首页看板脱敏原型",
|
||||
"prototype_02_member_list.png": "会员列表脱敏原型",
|
||||
"prototype_03_member_modal.png": "会员编辑弹窗原型",
|
||||
"prototype_04_withdrawal.png": "提现审核脱敏原型",
|
||||
"prototype_05_rbac.png": "RBAC 角色权限原型",
|
||||
"prototype_06_flow.png": "C 端与后台业务闭环原型",
|
||||
}
|
||||
|
||||
|
||||
def iter_blocks(document: Document):
|
||||
for child in document.element.body.iterchildren():
|
||||
if child.tag == qn("w:p"):
|
||||
yield Paragraph(child, document)
|
||||
elif child.tag == qn("w:tbl"):
|
||||
yield Table(child, document)
|
||||
|
||||
|
||||
def media_map(document: Document) -> dict[str, str]:
|
||||
assets_by_hash = {
|
||||
hashlib.sha256(path.read_bytes()).hexdigest(): path.name
|
||||
for path in ASSET_DIR.glob("*.png")
|
||||
}
|
||||
mapping: dict[str, str] = {}
|
||||
for rel_id, rel in document.part.rels.items():
|
||||
if "image" not in rel.reltype:
|
||||
continue
|
||||
digest = hashlib.sha256(rel.target_part.blob).hexdigest()
|
||||
name = assets_by_hash.get(digest)
|
||||
if name:
|
||||
mapping[rel_id] = f"assets/{name}"
|
||||
return mapping
|
||||
|
||||
|
||||
def paragraph_images(paragraph: Paragraph, images: dict[str, str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for blip in paragraph._p.xpath(".//a:blip"):
|
||||
rel_id = blip.get(qn("r:embed"))
|
||||
if rel_id in images:
|
||||
result.append(images[rel_id])
|
||||
return result
|
||||
|
||||
|
||||
def escape_cell(value: str) -> str:
|
||||
return value.strip().replace("|", "\\|").replace("\n", "<br>") or "—"
|
||||
|
||||
|
||||
def table_markdown(table: Table) -> list[str]:
|
||||
rows = [[escape_cell(cell.text) for cell in row.cells] for row in table.rows]
|
||||
if not rows:
|
||||
return []
|
||||
width = max(len(row) for row in rows)
|
||||
rows = [row + ["—"] * (width - len(row)) for row in rows]
|
||||
lines = [
|
||||
"| " + " | ".join(rows[0]) + " |",
|
||||
"| " + " | ".join(["---"] * width) + " |",
|
||||
]
|
||||
lines.extend("| " + " | ".join(row) + " |" for row in rows[1:])
|
||||
return lines
|
||||
|
||||
|
||||
def github_slug(title: str) -> str:
|
||||
slug = title.strip().lower()
|
||||
slug = re.sub(r"[^\w\-\u4e00-\u9fff ]", "", slug)
|
||||
return re.sub(r"\s+", "-", slug)
|
||||
|
||||
|
||||
def convert() -> None:
|
||||
document = Document(SOURCE)
|
||||
images = media_map(document)
|
||||
body: list[str] = []
|
||||
top_level: list[str] = []
|
||||
image_number = 0
|
||||
|
||||
for block in iter_blocks(document):
|
||||
if isinstance(block, Table):
|
||||
body.extend(table_markdown(block))
|
||||
body.append("")
|
||||
continue
|
||||
|
||||
text = block.text.strip()
|
||||
style = block.style.name if block.style else "Normal"
|
||||
embedded = paragraph_images(block, images)
|
||||
|
||||
if style == "Title" and text:
|
||||
body.extend([f"# {text}", ""])
|
||||
elif style == "Heading 1" and text:
|
||||
top_level.append(text)
|
||||
body.extend([f"## {text}", ""])
|
||||
elif style == "Heading 2" and text:
|
||||
body.extend([f"### {text}", ""])
|
||||
elif style == "List Bullet" and text:
|
||||
body.extend([f"- {text}", ""])
|
||||
elif style == "List Number" and text:
|
||||
body.extend([f"1. {text}", ""])
|
||||
elif text:
|
||||
if text.startswith("结论:"):
|
||||
body.extend([f"> **{text[:3]}** {text[3:]}", ""])
|
||||
elif text.startswith("P0:"):
|
||||
body.extend([f"> **P0 风险:** {text[3:]}", ""])
|
||||
else:
|
||||
body.extend([text, ""])
|
||||
|
||||
for path in embedded:
|
||||
image_number += 1
|
||||
alt = IMAGE_ALTS.get(Path(path).name, f"需求原型图 {image_number}")
|
||||
body.extend([f"", ""])
|
||||
|
||||
# Word 封面之后放置 Markdown 可点击目录,去掉 Word 中无意义的空段落。
|
||||
first_section = next(i for i, line in enumerate(body) if line.startswith("## 1 "))
|
||||
toc = ["## 目录", ""]
|
||||
toc.extend(f"- [{title}](#{github_slug(title)})" for title in top_level)
|
||||
toc.extend(["", "---", ""])
|
||||
output = body[:first_section] + toc + body[first_section:]
|
||||
|
||||
compact: list[str] = []
|
||||
for line in output:
|
||||
if line == "" and compact and compact[-1] == "":
|
||||
continue
|
||||
compact.append(line)
|
||||
rendered = "\n".join(compact).rstrip() + "\n"
|
||||
rendered = re.sub(r"(^- .+)\n\n(?=- )", r"\1\n", rendered, flags=re.MULTILINE)
|
||||
rendered = re.sub(r"(^1\. .+)\n\n(?=1\. )", r"\1\n", rendered, flags=re.MULTILINE)
|
||||
TARGET.write_text(rendered, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
convert()
|
||||
Reference in New Issue
Block a user