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
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
读取 db/menu_map.json, 构建功能树写入 SQLite(features 表)。
层级: level0=顶层模块, level1=分组, level2=菜单页(叶子)
并为每个叶子分配期望截图路径 screenshots/<序号_模块>/<分组>/<序号_名称>.jpg
menu_map.json 结构(由实时爬取生成):
{
"<模块名>": {
"loc": "落地页URL",
"desc": "模块释义(可选)",
"understanding": "开发理解(可选)",
"groups": [ {"title": "分组名", "items": [ {"name":"页名","href":"/路径","desc":"","understanding":""}, ...] } ],
"single": {"name":"页名","href":"/路径"} # 无侧边栏的单页模块(可选)
},
...
}
"""
import json, os, re, sqlite3
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MAP = os.path.join(BASE, "db", "menu_map.json")
DB = os.path.join(BASE, "db", "research.db")
ORIGIN = "https://s-demob.xinrenxinshi.show"
def sanitize(s):
s = re.sub(r'[\\/:*?"<>|]', "_", s or "").strip()
return s[:40] or "page"
def full_url(href):
if not href:
return ""
if href.startswith("http"):
return href
return ORIGIN + (href if href.startswith("/") else "/" + href)
def main():
data = json.load(open(MAP, encoding="utf-8"))
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("DELETE FROM features")
def up(obj):
keys = list(obj.keys()); cols = ",".join(keys); ph = ",".join(["?"] * len(keys))
cur.execute(f"INSERT OR REPLACE INTO features ({cols}) VALUES ({ph})", [obj[k] for k in keys])
for mi, module in enumerate(data.keys(), 1):
v = data[module]
mod_key = f"mod::{module}"
seq_mod = f"{mi:02d}_{sanitize(module)}"
up(dict(node_key=mod_key, parent_key="", level=0, name=module,
full_path=module, url=v.get("loc", ""),
description=v.get("desc", f"{module}模块。"),
understanding=v.get("understanding", ""), sort_order=mi, keep=1))
groups = v.get("groups", [])
single = v.get("single")
flat_items = v.get("items")
# 扁平模式: 模块直接挂功能页(level2), 无中间分组层
if not groups and flat_items:
for ii, it in enumerate(flat_items, 1):
nm = it["name"]; href = it.get("href", "")
leaf_key = f"{module}::{href or nm}"
rel = f"screenshots/{seq_mod}/{ii:02d}_{sanitize(nm)}.jpg"
up(dict(node_key=leaf_key, parent_key=mod_key, level=2, name=nm,
full_path=f"{module} / {nm}", url=full_url(href),
description=it.get("desc", f"{module}模块下「{nm}」相关功能页。"),
understanding=it.get("understanding", ""),
screenshot_path=rel, sort_order=ii, keep=1))
continue
if not groups and single:
nm = single["name"]; href = single.get("href", "")
leaf_key = f"{module}::{href or nm}"
rel = f"screenshots/{seq_mod}/{sanitize(nm)}.jpg"
up(dict(node_key=leaf_key, parent_key=mod_key, level=2, name=nm,
full_path=f"{module} / {nm}", url=full_url(href),
description=single.get("desc", f"{module}模块下「{nm}」相关功能页。"),
understanding=single.get("understanding", ""),
screenshot_path=rel, sort_order=1, keep=1))
continue
gi = 0
for g in groups:
gtitle = g.get("title") or "常规"
gi += 1
grp_key = f"{mod_key}::grp::{gi}_{gtitle}"
up(dict(node_key=grp_key, parent_key=mod_key, level=1, name=gtitle,
full_path=f"{module} / {gtitle}", url="",
description=f"{module}模块下「{gtitle}」功能分组。",
understanding="", sort_order=gi, keep=1))
for ii, it in enumerate(g.get("items", []), 1):
nm = it["name"]; href = it.get("href", "")
leaf_key = f"{module}::{href or (gtitle + '/' + nm)}"
rel = f"screenshots/{seq_mod}/{sanitize(gtitle)}/{ii:02d}_{sanitize(nm)}.jpg"
up(dict(node_key=leaf_key, parent_key=grp_key, level=2, name=nm,
full_path=f"{module} / {gtitle} / {nm}", url=full_url(href),
description=it.get("desc", f"{module}模块下「{nm}」相关功能页。"),
understanding=it.get("understanding", ""),
screenshot_path=rel, sort_order=ii, keep=1))
conn.commit()
for lv in (0, 1, 2):
n = cur.execute("SELECT COUNT(*) FROM features WHERE level=?", (lv,)).fetchone()[0]
print(f"level{lv}: {n}")
print("total:", cur.execute("SELECT COUNT(*) FROM features").fetchone()[0])
conn.close()
if __name__ == "__main__":
main()
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
薪人薪事(xinrenxinshi) 功能调研 —— SQLite 数据存取工具(通用,无站点耦合)
用法:
python dbtool.py init 初始化数据库表结构
python dbtool.py add <json> 新增/更新一个功能节点(传入JSON对象)
python dbtool.py addmany <json_array> 批量新增/更新(传入JSON数组)
python dbtool.py setshot <node_key> <path> 设置某节点的截图路径
python dbtool.py get <node_key> 查询单个节点
python dbtool.py tree 以树形结构打印(JSON)
python dbtool.py stats 统计各层级节点数
节点字段:
node_key 唯一键(建议用 URL 或 path 组合, 用于去重更新)
parent_key 父节点的 node_key (顶层为空)
level 0=顶层模块 1=分组 2=菜单页 3=子页/Tab
name 功能名称
full_path 全路径名称(面包屑, 如 "人事 / 员工管理 / 员工花名册")
url 页面URL
description 释义(功能是做什么的)
understanding 我理解的信息(交互/字段/价值等)
screenshot_path 截图相对路径
sort_order 排序
keep 是否保留(供管理员勾选, 默认1=保留)
note 审核备注
"""
import sqlite3, sys, json, os
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "db", "research.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS features (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_key TEXT UNIQUE NOT NULL,
parent_key TEXT,
level INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
full_path TEXT,
url TEXT,
description TEXT,
understanding TEXT,
screenshot_path TEXT,
sort_order INTEGER DEFAULT 0,
keep INTEGER DEFAULT 1,
note TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_parent ON features(parent_key);
CREATE INDEX IF NOT EXISTS idx_level ON features(level);
-- 深层界面截图(弹窗/筛选/自定义列/详情)
CREATE TABLE IF NOT EXISTS page_shots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_key TEXT NOT NULL,
kind TEXT NOT NULL, -- main/add/filter/columns/detail
label TEXT,
path TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_ps_node ON page_shots(node_key);
-- 真实导出附件
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_key TEXT,
file_path TEXT NOT NULL,
size INTEGER,
kind TEXT,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_dl_node ON downloads(node_key);
"""
FIELDS = ["node_key","parent_key","level","name","full_path","url",
"description","understanding","screenshot_path","sort_order","keep","note"]
def conn():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
return c
def init():
c = conn()
c.executescript(SCHEMA)
c.commit(); c.close()
print("DB initialized at", DB_PATH)
def upsert(c, obj):
keys = [k for k in FIELDS if k in obj]
placeholders = ",".join(["?"]*len(keys))
cols = ",".join(keys)
updates = ",".join([f"{k}=excluded.{k}" for k in keys if k != "node_key"])
sql = f"INSERT INTO features ({cols}) VALUES ({placeholders}) " \
f"ON CONFLICT(node_key) DO UPDATE SET {updates}, updated_at=datetime('now','localtime')"
c.execute(sql, [obj[k] for k in keys])
def add(obj):
c = conn(); upsert(c, obj); c.commit(); c.close()
print("ok:", obj.get("node_key"))
def addmany(arr):
c = conn()
for obj in arr:
upsert(c, obj)
c.commit(); c.close()
print("ok addmany:", len(arr))
def setshot(node_key, path):
c = conn()
c.execute("UPDATE features SET screenshot_path=?, updated_at=datetime('now','localtime') WHERE node_key=?", (path, node_key))
c.commit(); n = c.total_changes; c.close()
print("updated" if n else "not found", node_key)
def get(node_key):
c = conn()
r = c.execute("SELECT * FROM features WHERE node_key=?", (node_key,)).fetchone()
c.close()
print(json.dumps(dict(r), ensure_ascii=False) if r else "null")
def stats():
c = conn()
rows = c.execute("SELECT level, COUNT(*) n FROM features GROUP BY level ORDER BY level").fetchall()
total = c.execute("SELECT COUNT(*) n FROM features").fetchone()["n"]
shots = c.execute("SELECT COUNT(*) n FROM features WHERE screenshot_path IS NOT NULL AND screenshot_path<>''").fetchone()["n"]
c.close()
for r in rows:
print(f"level {r['level']}: {r['n']}")
print(f"total: {total}, with_screenshot: {shots}")
def tree():
c = conn()
rows = [dict(r) for r in c.execute("SELECT * FROM features ORDER BY level, sort_order, id").fetchall()]
c.close()
by_parent = {}
for r in rows:
by_parent.setdefault(r["parent_key"] or "", []).append(r)
def build(pk):
out = []
for r in by_parent.get(pk, []):
node = {k: r[k] for k in ("node_key","name","level","url","description","understanding","screenshot_path","keep")}
node["children"] = build(r["node_key"])
out.append(node)
return out
print(json.dumps(build(""), ensure_ascii=False))
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "init": init()
elif cmd == "add": add(json.loads(sys.argv[2]))
elif cmd == "addmany": addmany(json.loads(sys.argv[2]))
elif cmd == "setshot": setshot(sys.argv[2], sys.argv[3])
elif cmd == "get": get(sys.argv[2])
elif cmd == "stats": stats()
elif cmd == "tree": tree()
else:
print(__doc__)
sys.exit(1)
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
审核后端服务(薪人薪事 功能调研)。
- 打开 http://localhost:8788/ 进入"一页一个功能"的审核界面
- 每次点击 保留/删除 立即写回 SQLite(features.keep)
- 提供 API: 功能列表 / 更新保留状态 / 备注 / 附件清单 / Excel 预览 / 导出 selection.json
依赖: Flask
用法: python serve.py 然后浏览器打开 http://localhost:8788
"""
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", ".pdf")
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()]
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
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()
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():
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():
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():
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": "薪人薪事 https://s-demob.xinrenxinshi.show 功能调研",
"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):
return re.sub(r"^\d+_", "", s or "").strip()
def scan_attachments():
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():
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=8788, debug=False, threaded=True)
+12
View File
@@ -0,0 +1,12 @@
import os, sqlite3, datetime
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB = os.path.join(BASE, "db", "research.db")
NOTE = "该员工自助培训页为嵌入式LMS,持续加载无法稳定截图,已跳过。"
conn = sqlite3.connect(DB)
now = datetime.datetime.now().isoformat(timespec="seconds")
cur = conn.execute(
"UPDATE features SET note=?, updated_at=? WHERE screenshot_path LIKE ?",
(NOTE, now, "%09_我的培训%"))
conn.commit()
print("rows updated:", cur.rowcount)
conn.close()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""列出待截图 / 已截图的功能页,供截图流程驱动。
用法:
python scripts/shot_queue.py # 列出所有 level2 页面(JSON)
python scripts/shot_queue.py --pending # 只列出截图文件尚不存在的页面
"""
import os, json, sqlite3, sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB = os.path.join(BASE, "db", "research.db")
def main():
pending = "--pending" in sys.argv
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
rows = [dict(r) for r in conn.execute(
"SELECT name, full_path, url, screenshot_path FROM features "
"WHERE level=2 ORDER BY sort_order, id").fetchall()]
conn.close()
out = []
for r in rows:
rel = r["screenshot_path"]
ap = os.path.join(BASE, rel) if rel else ""
exists = bool(ap) and os.path.isfile(ap)
if pending and exists:
continue
out.append({
"name": r["name"], "full_path": r["full_path"], "url": r["url"],
"rel": rel, "abs": ap.replace("\\", "/"), "exists": exists,
})
print(json.dumps({"count": len(out), "pages": out}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()