247 lines
9.6 KiB
Python
247 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
任务4: 生成结构化开发规格 docs/spec.json (机器可直接消费)。
|
||
|
|
把审核决策(features.keep/note) + 完整字段字典(field_specs) + 隐藏入口(page_extras)
|
||
|
|
+ 真实导出样本(downloads, 含表头/行列预览) + 审批模板库(approval_templates)
|
||
|
|
汇总为一份"按模块->功能页"组织的结构化规格, 供下一步开发直接读取。
|
||
|
|
|
||
|
|
输出结构:
|
||
|
|
{
|
||
|
|
meta, stats,
|
||
|
|
modules: [ { module, page_count, pages: [ {
|
||
|
|
node_key, name, group, full_path, url, keep,
|
||
|
|
description, understanding, review_note, screenshot,
|
||
|
|
field_count, fields:[{group,name,order}],
|
||
|
|
hidden_entries:[...], # "更多"菜单发现的隐藏功能界面
|
||
|
|
data_samples:[{file,kind,size,sheet,headers,preview_rows,total_rows}]
|
||
|
|
} ] } ],
|
||
|
|
approval_templates: [ {category,name,provider,description} ]
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
import os, json, sqlite3, time, re, collections, warnings
|
||
|
|
|
||
|
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
DB = os.path.join(BASE, "db", "research.db")
|
||
|
|
OUT = os.path.join(BASE, "docs", "spec.json")
|
||
|
|
warnings.filterwarnings("ignore")
|
||
|
|
|
||
|
|
|
||
|
|
def db():
|
||
|
|
c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c
|
||
|
|
|
||
|
|
|
||
|
|
def excel_preview(relpath, max_scan=6, max_head=40):
|
||
|
|
"""读导出文件表头 + 前几行预览。.xls 用 xlrd, .xlsx 用 openpyxl。"""
|
||
|
|
p = os.path.join(BASE, relpath)
|
||
|
|
if not os.path.isfile(p):
|
||
|
|
return None
|
||
|
|
low = p.lower()
|
||
|
|
try:
|
||
|
|
if low.endswith(".xls"):
|
||
|
|
import xlrd
|
||
|
|
wb = xlrd.open_workbook(p); ws = wb.sheet_by_index(0)
|
||
|
|
rows = []
|
||
|
|
for r in range(min(max_scan, ws.nrows)):
|
||
|
|
rows.append([str(ws.cell_value(r, c)).strip() for c in range(min(max_head, ws.ncols))])
|
||
|
|
return {"sheet": ws.name, "total_rows": ws.nrows, "total_cols": ws.ncols, "rows": rows}
|
||
|
|
if low.endswith(".xlsx"):
|
||
|
|
import openpyxl
|
||
|
|
wb = openpyxl.load_workbook(p, read_only=True, data_only=True); ws = wb.active
|
||
|
|
rows = []
|
||
|
|
for i, row in enumerate(ws.iter_rows(values_only=True)):
|
||
|
|
if i >= max_scan:
|
||
|
|
break
|
||
|
|
rows.append([("" if v is None else str(v)).strip() for v in row[:max_head]])
|
||
|
|
r = {"sheet": ws.title, "total_rows": ws.max_row, "total_cols": ws.max_column, "rows": rows}
|
||
|
|
wb.close(); return r
|
||
|
|
except Exception as e:
|
||
|
|
return {"error": str(e)[:120]}
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def best_header(rows):
|
||
|
|
"""从前几行里挑字段数最多的一行作为表头(过滤单格标题行)。"""
|
||
|
|
if not rows:
|
||
|
|
return []
|
||
|
|
best, cnt = [], 0
|
||
|
|
for r in rows:
|
||
|
|
vals = [v for v in r if v]
|
||
|
|
if len(vals) > cnt:
|
||
|
|
best, cnt = vals, len(vals)
|
||
|
|
return [] if cnt <= 1 else best
|
||
|
|
|
||
|
|
|
||
|
|
def load_leaves():
|
||
|
|
c = db()
|
||
|
|
rows = [dict(r) for r in c.execute(
|
||
|
|
"SELECT node_key,parent_key,level,name,full_path,url,description,"
|
||
|
|
"understanding,screenshot_path,keep,note,sort_order FROM features ORDER BY sort_order,id")]
|
||
|
|
c.close()
|
||
|
|
by_parent = {}
|
||
|
|
for r in rows:
|
||
|
|
by_parent.setdefault(r["parent_key"] or "", []).append(r)
|
||
|
|
leaves = []
|
||
|
|
|
||
|
|
def dfs(pk, module, group):
|
||
|
|
for r in by_parent.get(pk, []):
|
||
|
|
m = r["name"] if r["level"] == 0 else module
|
||
|
|
g = r["name"] if r["level"] == 1 else group
|
||
|
|
kids = by_parent.get(r["node_key"])
|
||
|
|
if kids:
|
||
|
|
dfs(r["node_key"], m, g)
|
||
|
|
else:
|
||
|
|
r["_module"] = m
|
||
|
|
r["_group"] = g if g and g != r["name"] else ""
|
||
|
|
leaves.append(r)
|
||
|
|
|
||
|
|
dfs("", "", "")
|
||
|
|
return leaves
|
||
|
|
|
||
|
|
|
||
|
|
def load_fields():
|
||
|
|
c = db(); out = collections.defaultdict(list)
|
||
|
|
try:
|
||
|
|
for r in c.execute("SELECT node_key,field,field_group,source,sort_order "
|
||
|
|
"FROM field_specs ORDER BY node_key,sort_order"):
|
||
|
|
out[r["node_key"]].append({"group": r["field_group"] or "", "name": r["field"],
|
||
|
|
"order": r["sort_order"], "source": r["source"]})
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
c.close(); return out
|
||
|
|
|
||
|
|
|
||
|
|
def load_extras():
|
||
|
|
c = db(); out = collections.defaultdict(list)
|
||
|
|
try:
|
||
|
|
for r in c.execute("SELECT node_key,label FROM page_extras ORDER BY node_key,id"):
|
||
|
|
out[r["node_key"]].append(r["label"])
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
c.close(); return out
|
||
|
|
|
||
|
|
|
||
|
|
def load_downloads():
|
||
|
|
c = db(); out = collections.defaultdict(list)
|
||
|
|
try:
|
||
|
|
for r in c.execute("SELECT node_key,file_path,kind,size FROM downloads WHERE status='ok' OR status IS NULL"):
|
||
|
|
out[r["node_key"]].append(dict(r))
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
c.close(); return out
|
||
|
|
|
||
|
|
|
||
|
|
def load_deep_shots():
|
||
|
|
"""深层界面截图(非 main): 主操作弹窗/表单(add)、筛选(filter)、自定义列(columns)、行详情(detail)。"""
|
||
|
|
kind_cn = {"add": "主操作弹窗/表单", "filter": "筛选面板",
|
||
|
|
"columns": "自定义列/选择表头", "detail": "行详情/编辑"}
|
||
|
|
c = db(); out = collections.defaultdict(list)
|
||
|
|
try:
|
||
|
|
for r in c.execute("SELECT node_key,kind,label,path FROM page_shots "
|
||
|
|
"WHERE kind<>'main' ORDER BY node_key,id"):
|
||
|
|
out[r["node_key"]].append({"kind": r["kind"], "kind_cn": kind_cn.get(r["kind"], r["kind"]),
|
||
|
|
"label": r["label"] or "", "path": r["path"]})
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
c.close(); return out
|
||
|
|
|
||
|
|
|
||
|
|
def load_templates():
|
||
|
|
c = db(); out = []
|
||
|
|
try:
|
||
|
|
for r in c.execute("SELECT category,name,provider,description,sort_order "
|
||
|
|
"FROM approval_templates ORDER BY sort_order,id"):
|
||
|
|
out.append({"category": r["category"], "name": r["name"],
|
||
|
|
"provider": r["provider"] or "", "description": r["description"] or ""})
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
c.close(); return out
|
||
|
|
|
||
|
|
|
||
|
|
def norm(s):
|
||
|
|
return re.sub(r"^\d+_", "", s or "").strip()
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
leaves = load_leaves()
|
||
|
|
fields = load_fields()
|
||
|
|
extras = load_extras()
|
||
|
|
downloads = load_downloads()
|
||
|
|
deep_shots = load_deep_shots()
|
||
|
|
templates = load_templates()
|
||
|
|
|
||
|
|
modules = collections.OrderedDict()
|
||
|
|
n_fields_pages = 0
|
||
|
|
for lf in leaves:
|
||
|
|
nk = lf["node_key"]
|
||
|
|
mod = lf["_module"] or "未分类"
|
||
|
|
fl = fields.get(nk, [])
|
||
|
|
if fl:
|
||
|
|
n_fields_pages += 1
|
||
|
|
samples = []
|
||
|
|
for d in downloads.get(nk, []):
|
||
|
|
pv = excel_preview(d["file_path"])
|
||
|
|
item = {"file": d["file_path"], "kind": d["kind"], "size": d["size"]}
|
||
|
|
if pv and "rows" in pv:
|
||
|
|
item.update({"sheet": pv["sheet"], "total_rows": pv["total_rows"],
|
||
|
|
"total_cols": pv["total_cols"], "headers": best_header(pv["rows"])})
|
||
|
|
samples.append(item)
|
||
|
|
page = {
|
||
|
|
"node_key": nk, "name": lf["name"], "group": lf["_group"],
|
||
|
|
"full_path": lf["full_path"], "url": lf["url"] or "",
|
||
|
|
"keep": 1 if (lf["keep"] is None or lf["keep"] == 1) else 0,
|
||
|
|
"description": lf["description"] or "",
|
||
|
|
"understanding": lf["understanding"] or "",
|
||
|
|
"review_note": lf["note"] or "",
|
||
|
|
"screenshot": lf["screenshot_path"] or "",
|
||
|
|
"field_count": len(fl),
|
||
|
|
"fields": [{"group": f["group"], "name": f["name"], "order": f["order"]} for f in fl],
|
||
|
|
"hidden_entries": extras.get(nk, []),
|
||
|
|
"deep_shots": deep_shots.get(nk, []),
|
||
|
|
"data_samples": samples,
|
||
|
|
}
|
||
|
|
modules.setdefault(mod, []).append(page)
|
||
|
|
|
||
|
|
mod_list = [{"module": m, "page_count": len(ps),
|
||
|
|
"field_pages": sum(1 for p in ps if p["field_count"]),
|
||
|
|
"pages": ps} for m, ps in modules.items()]
|
||
|
|
|
||
|
|
total_fields = sum(len(v) for v in fields.values())
|
||
|
|
total_samples = sum(len(v) for v in downloads.values())
|
||
|
|
spec = {
|
||
|
|
"meta": {
|
||
|
|
"product": "2号人事部",
|
||
|
|
"source": "https://2haohr.com 功能调研",
|
||
|
|
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||
|
|
"schema_version": 1,
|
||
|
|
"notes": "按 模块->功能页 组织; fields 为自定义列/选择表头弹窗采集的完整字段(远超截图);"
|
||
|
|
" hidden_entries 为'更多'菜单发现的隐藏功能界面; data_samples 为真实导出样本(含表头预览);"
|
||
|
|
" screenshot 为去遮挡后重拍的整页主图; deep_shots 为点击按钮抵达的深层界面截图(新增表单/筛选/自定义列/行详情)。",
|
||
|
|
},
|
||
|
|
"stats": {
|
||
|
|
"modules": len(mod_list),
|
||
|
|
"pages": len(leaves),
|
||
|
|
"kept_pages": sum(1 for lf in leaves if (lf["keep"] is None or lf["keep"] == 1)),
|
||
|
|
"pages_with_fields": n_fields_pages,
|
||
|
|
"total_fields": total_fields,
|
||
|
|
"pages_with_hidden_entries": sum(1 for k in extras if extras[k]),
|
||
|
|
"total_hidden_entries": sum(len(v) for v in extras.values()),
|
||
|
|
"data_samples": total_samples,
|
||
|
|
"pages_with_deep_shots": sum(1 for k in deep_shots if deep_shots[k]),
|
||
|
|
"total_deep_shots": sum(len(v) for v in deep_shots.values()),
|
||
|
|
"approval_templates": len(templates),
|
||
|
|
},
|
||
|
|
"modules": mod_list,
|
||
|
|
"approval_templates": templates,
|
||
|
|
}
|
||
|
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||
|
|
with open(OUT, "w", encoding="utf-8") as f:
|
||
|
|
json.dump(spec, f, ensure_ascii=False, indent=2)
|
||
|
|
size = os.path.getsize(OUT)
|
||
|
|
print("生成 docs/spec.json (%.1f KB)" % (size / 1024))
|
||
|
|
print("统计:", json.dumps(spec["stats"], ensure_ascii=False))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|