feat: add web application

This commit is contained in:
richjohnrambo
2026-10-10 20:55:59 +08:00
parent 3eb5d78875
commit 50fcfd2d3d
571 changed files with 73992 additions and 135 deletions
+146
View File
@@ -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"![{alt}]({path})", ""])
# 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()