first commit
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user