first commit

This commit is contained in:
Michelle Zan
2026-09-03 15:35:23 +08:00
commit 9b26161bcf
510 changed files with 29402 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
阶段3(改进版) 审核后端服务。
- 打开 http://localhost:8777/ 直接进入"一页一个功能"的审核界面
- 每次点击 保留/删除 立即写回 SQLite(features.keep),供第二阶段开发直接使用
- 提供 API: 功能列表 / 更新保留状态 / 导出 selection.json
依赖: Flask (已确认可用)
用法: python3 scripts/serve.py 然后浏览器打开 http://localhost:8777
"""
import os, json, sqlite3, time, re
from flask import Flask, request, jsonify, send_from_directory, Response
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB = os.path.join(BASE, "db", "research.db")
DOCS = os.path.join(BASE, "docs")
DL_ROOT = os.path.join(BASE, "downloads")
REVIEW_HTML = os.path.join(DOCS, "审核.html")
EXPORT_JSON = os.path.join(DOCS, "selection.json")
EXCEL_EXT = (".xlsx", ".xls", ".docx", ".doc")
app = Flask(__name__)
def db():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
return conn
def load_features():
"""按菜单树先序遍历返回叶子(待审核功能)列表,同一模块的功能连续排列。"""
conn = db()
rows = [dict(r) for r in conn.execute(
"SELECT node_key,parent_key,level,name,full_path,url,description,"
"understanding,screenshot_path,sort_order,keep,note FROM features "
"ORDER BY sort_order,id").fetchall()]
# 深层界面截图(非 main): node_key -> [{kind,kind_cn,label,shot}]
kind_cn = {"add": "主操作弹窗/表单", "filter": "筛选面板",
"columns": "自定义列/选择表头", "detail": "行详情/编辑"}
deep = {}
try:
for r in conn.execute("SELECT node_key,kind,label,path FROM page_shots "
"WHERE kind<>'main' ORDER BY node_key,id").fetchall():
deep.setdefault(r["node_key"], []).append({
"kind": r["kind"], "kind_cn": kind_cn.get(r["kind"], r["kind"]),
"label": r["label"] or "", "shot": "/shot/" + r["path"]})
except sqlite3.OperationalError:
pass
# 导出文件按 node_key 精确挂到页面: node_key -> [{name,rel,size,kind}]
dls = {}
try:
for r in conn.execute("SELECT node_key,file_path,size,kind FROM downloads "
"WHERE node_key IS NOT NULL AND node_key<>'' ORDER BY file_path").fetchall():
fp = r["file_path"] or ""
name = fp.rsplit("/", 1)[-1]
sz = r["size"]
if not sz:
ap = os.path.join(BASE, fp)
sz = os.path.getsize(ap) if os.path.isfile(ap) else 0
dls.setdefault(r["node_key"], []).append(
{"name": name, "rel": fp, "size": sz or 0, "kind": r["kind"] or ""})
except sqlite3.OperationalError:
pass
conn.close()
# 按父分组(已按 sort_order,id 排序,同父下保持菜单顺序)
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
children = by_parent.get(r["node_key"])
if children:
dfs(r["node_key"], m, g)
else: # 叶子
shot = ("/shot/" + r["screenshot_path"]) if r.get("screenshot_path") else ""
leaves.append({
"node_key": r["node_key"],
"name": r["name"],
"module": m,
"group": g if g and g != r["name"] else "",
"full_path": r["full_path"],
"url": r["url"] or "",
"description": r["description"] or "",
"understanding": r["understanding"] or "",
"shot": shot,
"deep_shots": deep.get(r["node_key"], []),
"downloads": dls.get(r["node_key"], []),
"keep": 1 if (r["keep"] is None or r["keep"] == 1) else 0,
"note": r.get("note") or "",
})
dfs("", "", "")
return leaves
@app.route("/")
def index():
with open(REVIEW_HTML, "r", encoding="utf-8") as f:
return Response(f.read(), mimetype="text/html")
@app.route("/api/features")
def api_features():
feats = load_features()
keep = sum(1 for f in feats if f["keep"])
return jsonify({"total": len(feats), "keep": keep, "items": feats})
@app.route("/api/keep", methods=["POST"])
def api_keep():
data = request.get_json(force=True)
node_key = data.get("node_key")
keep = 1 if data.get("keep") else 0
if not node_key:
return jsonify({"ok": False, "err": "missing node_key"}), 400
now = time.strftime("%Y-%m-%d %H:%M:%S")
conn = db()
conn.execute("UPDATE features SET keep=?, updated_at=? WHERE node_key=?",
(keep, now, node_key))
conn.commit()
# 若为叶子节点,同步父节点(分组/模块)保留状态: 只要有一个子叶子保留,父就保留
conn.close()
return jsonify({"ok": True, "node_key": node_key, "keep": keep})
@app.route("/api/keep_batch", methods=["POST"])
def api_keep_batch():
"""批量设置(全选/全不选/按模块)。body: {node_keys:[...], keep:0/1}"""
data = request.get_json(force=True)
keys = data.get("node_keys") or []
keep = 1 if data.get("keep") else 0
now = time.strftime("%Y-%m-%d %H:%M:%S")
conn = db()
conn.executemany("UPDATE features SET keep=?, updated_at=? WHERE node_key=?",
[(keep, now, k) for k in keys])
conn.commit()
conn.close()
return jsonify({"ok": True, "updated": len(keys), "keep": keep})
@app.route("/api/note", methods=["POST"])
def api_note():
"""保存某功能的审核备注(建议/说明)到 SQLite。"""
data = request.get_json(force=True)
node_key = data.get("node_key")
note = data.get("note") or ""
if not node_key:
return jsonify({"ok": False, "err": "missing node_key"}), 400
now = time.strftime("%Y-%m-%d %H:%M:%S")
conn = db()
conn.execute("UPDATE features SET note=?, updated_at=? WHERE node_key=?",
(note, now, node_key))
conn.commit()
conn.close()
return jsonify({"ok": True, "node_key": node_key})
@app.route("/api/export", methods=["POST", "GET"])
def api_export():
"""把当前 SQLite 中的审核决策导出为 docs/selection.json(包含开发所需全部信息)"""
feats = load_features()
kept = sum(1 for f in feats if f["keep"])
# 按模块汇总
mods = {}
for f in feats:
m = mods.setdefault(f["module"], {"module": f["module"], "total": 0, "keep": 0})
m["total"] += 1
m["keep"] += f["keep"]
def item(f):
return {
"module": f["module"], "group": f["group"], "name": f["name"],
"node_key": f["node_key"], "full_path": f["full_path"], "url": f["url"],
"keep": f["keep"],
"description": f["description"], # 功能释义
"understanding": f["understanding"], # 我的理解/开发提示
"note": f["note"], # 审核备注/开发建议
"screenshot": f["shot"].replace("/shot/", "") if f["shot"] else "", # 界面参考图(相对项目根)
}
out = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"source": "2号人事部 https://2haohr.com 功能调研",
"total": len(feats),
"keep": kept,
"dropped": len(feats) - kept,
"module_summary": sorted(mods.values(), key=lambda x: -x["keep"]),
"items": [item(f) for f in feats], # 全量(含删除项, 便于回溯)
"kept_items": [item(f) for f in feats if f["keep"]], # 仅保留项(开发直接用)
}
os.makedirs(DOCS, exist_ok=True)
with open(EXPORT_JSON, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
return jsonify({"ok": True, "path": "docs/selection.json",
"total": out["total"], "keep": out["keep"], "dropped": out["dropped"]})
@app.route("/shot/<path:relpath>")
def shot(relpath):
return send_from_directory(BASE, relpath)
def _strip_num(s):
"""去掉目录名前缀数字, 使 '02_人事' -> '人事', 便于与功能树模块名匹配。"""
return re.sub(r"^\d+_", "", s or "").strip()
def scan_attachments():
"""实时扫描 downloads/ 目录, 按目录分组返回真实导出的 Excel/Word 附件清单。
dl_biz/dl_biz2 新增文件会自动出现(无需重启)。"""
groups = []
if not os.path.isdir(DL_ROOT):
return groups
for root, _, files in os.walk(DL_ROOT):
fs = [f for f in files if f.lower().endswith(EXCEL_EXT)]
if not fs:
continue
rel = os.path.relpath(root, DL_ROOT)
segs = [] if rel == "." else rel.split(os.sep)
seg0 = segs[0] if segs else ""
module = "任务中心" if seg0.startswith("_任务") else _strip_num(seg0)
group = segs[1] if len(segs) > 1 else ""
items = []
for f in sorted(fs):
p = os.path.join(root, f)
items.append({"name": f, "size": os.path.getsize(p),
"rel": os.path.relpath(p, BASE)})
groups.append({"dir": rel.replace(os.sep, " / "), "module": module,
"group": group, "files": items})
groups.sort(key=lambda g: g["dir"])
return groups
@app.route("/api/attachments")
def api_attachments():
"""返回全部真实导出附件(按目录分组), 供审核界面展示与下载。"""
gs = scan_attachments()
total = sum(len(g["files"]) for g in gs)
return jsonify({"total": total, "groups": gs})
@app.route("/dl/<path:relpath>")
def dl(relpath):
"""下载真实导出文件(强制作为附件下载)。"""
d = os.path.dirname(relpath)
fn = os.path.basename(relpath)
return send_from_directory(os.path.join(BASE, d), fn, as_attachment=True)
SHEET_EXT = (".xlsx", ".xls")
MAX_ROWS = 500
MAX_COLS = 60
def _parse_xlsx(path):
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
sheets = []
for ws in wb.worksheets:
rows = []
for i, row in enumerate(ws.iter_rows(values_only=True)):
if i >= MAX_ROWS:
break
cells = [("" if v is None else str(v)) for v in row[:MAX_COLS]]
rows.append(cells)
sheets.append({"name": ws.title, "rows": rows,
"total_rows": ws.max_row, "total_cols": ws.max_column})
wb.close()
return sheets
def _parse_xls(path):
import xlrd, warnings
warnings.filterwarnings("ignore")
wb = xlrd.open_workbook(path)
sheets = []
for ws in wb.sheets():
rows = []
for r in range(min(MAX_ROWS, ws.nrows)):
cells = []
for cc in range(min(MAX_COLS, ws.ncols)):
v = ws.cell_value(r, cc)
if isinstance(v, float) and v == int(v):
v = int(v)
cells.append("" if v == "" else str(v))
rows.append(cells)
sheets.append({"name": ws.name, "rows": rows,
"total_rows": ws.nrows, "total_cols": ws.ncols})
return sheets
@app.route("/api/preview")
def api_preview():
"""后端解析 Excel(.xlsx/openpyxl, .xls/xlrd) 为行列 JSON, 供前端表格预览。
参数 rel=相对项目根的文件路径(限 downloads/ 内, 防穿越)。"""
rel = request.args.get("rel", "")
abspath = os.path.abspath(os.path.join(BASE, rel))
if not abspath.startswith(DL_ROOT + os.sep) or not os.path.isfile(abspath):
return jsonify({"ok": False, "err": "file not found or forbidden"}), 404
low = abspath.lower()
if not low.endswith(SHEET_EXT):
return jsonify({"ok": False, "err": "not a spreadsheet", "kind": "other"}), 200
try:
sheets = _parse_xls(abspath) if low.endswith(".xls") else _parse_xlsx(abspath)
except Exception as e:
return jsonify({"ok": False, "err": str(e)[:200]}), 200
return jsonify({"ok": True, "name": os.path.basename(abspath),
"rel": rel, "sheets": sheets,
"max_rows": MAX_ROWS, "max_cols": MAX_COLS})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8777, debug=False, threaded=True)