Commit a39ca9ca authored by wenwen.tang's avatar wenwen.tang 😕

feat(收款+权限): 收款日期同步台账最近收款月份;新增只读访客账户

1) 收款日期 → 台账账期同步
- set_receipt 登记/修改收款日期时,把该单据覆盖的台账行「最近收款月份」(AB)
  同步为【收款日期所在月】(口径经确认);清空收款日期则退回同步前的值
- 批次记 receipt_sync 记录,删除批次时一并回滚「最近收款月份」
- 批次明细快照的 last_paid 同步跟进,页面不会出现两个口径
- 修:不能拿 service._d()(失败回落"今天")算月份,改用 rent_parser._d()

2) 只读访客账户
- auth.py 支持多账号 + 角色(admin 可读写 / viewer 只读),首次启动播种
  admin(1989@rent05) 与 guest(guest@2026);未知角色一律降级 viewer;
  主账号不可删;访客被删后不再自动复活
- server.py 增加写闸门:除 /api/logout 外,viewer 的 POST/PUT/DELETE 一律 403
- 新增账号管理接口 GET/POST /api/accounts、DELETE /api/accounts/{u}、
  POST /api/accounts/{u}/password(仅管理员)
- 前端按角色隐藏写入口 + api() 兜底拦截;设置页新增「账号管理」卡片

测试
- tools/test_accounts.py(新增 34 项,含真 HTTP 写闸门实测)
- tools/test_round5.py 补 11 项收款同步用例;全量 191 项通过
- tools/ui_verify7.py(新增,自带沙箱数据目录 + 真浏览器)59/59 通过
parent deac4306
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
登录校验与会话管理 登录校验、多账号与角色(会话管理)
================== ==================================
账密校验:账号 admin / 密码 1989@rent05(首次启动自动写入加盐哈希,不落明文) 账号:data/config.json 的 auth.accounts 列表
会话:登录成功下发 token(secrets.token_urlsafe),存 data/sessions.json,默认 12 小时过期。 [{"username","salt","password_hash","role","note","created_at"}]
role = "admin"(可读写) | "viewer"(只读访客,服务端一律拒绝写操作)
默认账号(首次启动自动播种加盐哈希,不落明文)
- admin / 1989@rent05 管理员,可读写
- guest / guest@2026 只读访客,只能浏览
兼容:旧版 auth 里只有 username / salt / password_hash 三个字段时,
首次 ensure_default_account() 会把它补成 accounts 里的管理员条目;
主账号(auth.username)始终是管理员,且不允许被删除。
会话:登录成功下发 token(secrets.token_urlsafe),存 data/sessions.json,
默认 12 小时过期;会话里同时记下 username 与 role。
""" """
import datetime as dt import datetime as dt
import hashlib import hashlib
...@@ -15,42 +27,206 @@ from . import store ...@@ -15,42 +27,206 @@ from . import store
SESSION_TTL_HOURS = 12 SESSION_TTL_HOURS = 12
ROLE_ADMIN = "admin"
ROLE_VIEWER = "viewer"
ROLES = (ROLE_ADMIN, ROLE_VIEWER)
DEFAULT_ADMIN = ("admin", "1989@rent05", "系统管理员")
DEFAULT_GUEST = ("guest", "guest@2026", "只读访客")
# ---------------------------------------------------------------- 密码 # ---------------------------------------------------------------- 密码
def make_hash(password, salt): def make_hash(password, salt):
return hashlib.sha256((salt + password).encode("utf-8")).hexdigest() return hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
def new_salt():
return secrets.token_hex(8)
def _account(username, password, role, note=""):
salt = new_salt()
return {
"username": str(username or "").strip(),
"salt": salt,
"password_hash": make_hash(str(password or ""), salt),
"role": role,
"note": note,
"created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
# ---------------------------------------------------------------- 账号
def ensure_default_account(): def ensure_default_account():
"""首次启动:若未设置密码,写入默认账号 admin / 1989@rent05。""" """首次启动:把主账号与访客账号补齐(幂等,只在有变化时落盘)。"""
cfg = store.load_config() cfg = store.load_config()
auth = cfg.setdefault("auth", {}) auth = cfg.setdefault("auth", {})
changed = False
# 旧结构:只有 username / salt / password_hash
if not auth.get("password_hash"): if not auth.get("password_hash"):
auth["username"] = auth.get("username") or "admin" auth["username"] = auth.get("username") or DEFAULT_ADMIN[0]
auth["salt"] = auth.get("salt") or secrets.token_hex(8) auth["salt"] = auth.get("salt") or new_salt()
auth["password_hash"] = make_hash("1989@rent05", auth["salt"]) auth["password_hash"] = make_hash(DEFAULT_ADMIN[1], auth["salt"])
changed = True
accounts = auth.get("accounts")
if not isinstance(accounts, list):
accounts = []
changed = True
def _has(name):
return any(str(a.get("username") or "") == name for a in accounts)
admin_name = str(auth.get("username") or DEFAULT_ADMIN[0])
# 主账号 → accounts(role 固定为管理员)
row = next((a for a in accounts if str(a.get("username") or "") == admin_name), None)
if row is None:
accounts.insert(0, {
"username": admin_name,
"salt": auth.get("salt") or new_salt(),
"password_hash": auth.get("password_hash") or make_hash(DEFAULT_ADMIN[1], new_salt()),
"role": ROLE_ADMIN,
"note": DEFAULT_ADMIN[2],
"created_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
changed = True
elif normalize_role(row.get("role")) != ROLE_ADMIN:
row["role"] = ROLE_ADMIN
changed = True
# 访客账号:只在首次初始化时播种一次;
# 之后即便被管理员删掉也不会自动复活(靠 guest_seeded 标记)。
if not auth.get("guest_seeded"):
auth["guest_seeded"] = True
changed = True
if not _has(DEFAULT_GUEST[0]):
accounts.append(_account(DEFAULT_GUEST[0], DEFAULT_GUEST[1],
ROLE_VIEWER, DEFAULT_GUEST[2]))
auth["accounts"] = accounts
if changed:
store.save_config(cfg) store.save_config(cfg)
return auth return auth
def normalize_role(role):
"""未知 / 缺省角色一律按只读处理,避免误放行。"""
r = str(role or "").strip().lower()
return r if r in ROLES else ROLE_VIEWER
def accounts():
ensure_default_account()
cfg = store.load_config()
out = (cfg.get("auth") or {}).get("accounts")
return [dict(a) for a in out] if isinstance(out, list) else []
def admin_username():
cfg = store.load_config()
return str((cfg.get("auth") or {}).get("username") or DEFAULT_ADMIN[0])
def find(username):
u = str(username or "").strip()
for a in accounts():
if str(a.get("username") or "") == u:
return a
return None
def authenticate(username, password):
"""账密校验 → 账号 dict(含 role);失败返回 None。"""
u = str(username or "").strip()
if not u:
return None
for a in accounts():
if not hmac.compare_digest(u, str(a.get("username") or "")):
continue
h = make_hash(str(password or ""), a.get("salt") or "")
if hmac.compare_digest(h, str(a.get("password_hash") or "")):
a["role"] = normalize_role(a.get("role"))
return a
return None
def verify(username, password): def verify(username, password):
auth = ensure_default_account() """兼容旧调用:只判断是否通过。"""
if not hmac.compare_digest(str(username or ""), str(auth.get("username") or "")): return authenticate(username, password) is not None
return False
h = make_hash(str(password or ""), auth.get("salt") or "")
return hmac.compare_digest(h, auth.get("password_hash") or "") def public_accounts():
"""下发给页面的账号清单(不含任何哈希 / 盐)。"""
main = admin_username()
return [{
"username": a.get("username"),
"role": normalize_role(a.get("role")),
"note": a.get("note") or "",
"created_at": a.get("created_at") or "",
"primary": str(a.get("username")) == main,
} for a in accounts()]
def set_password(new_password, username=None):
"""改密码:username 为空时改主账号。同步旧字段与 accounts。"""
ensure_default_account()
cfg = store.load_config()
auth = cfg.setdefault("auth", {})
target = str(username or auth.get("username") or DEFAULT_ADMIN[0])
salt = new_salt()
h = make_hash(new_password, salt)
hit = False
for a in auth.get("accounts") or []:
if str(a.get("username") or "") == target:
a["salt"], a["password_hash"] = salt, h
hit = True
if not hit:
raise ValueError("账号不存在:%s" % target)
if target == str(auth.get("username") or ""):
auth["salt"], auth["password_hash"] = salt, h
store.save_config(cfg)
return target
def add_account(username, password, role=ROLE_VIEWER, note=""):
"""新增账号(已存在则报错)。"""
ensure_default_account()
u = str(username or "").strip()
if not u:
raise ValueError("账号不能为空")
if len(str(password or "")) < 6:
raise ValueError("密码至少 6 位")
if find(u):
raise ValueError("账号已存在:%s" % u)
cfg = store.load_config()
auth = cfg.setdefault("auth", {})
auth.setdefault("accounts", []).append(
_account(u, password, normalize_role(role), note))
store.save_config(cfg)
return find(u)
def set_password(new_password): def remove_account(username):
"""删除账号。主账号不可删。"""
ensure_default_account()
u = str(username or "").strip()
if u == admin_username():
raise ValueError("主账号 %s 不可删除" % u)
cfg = store.load_config() cfg = store.load_config()
auth = cfg.setdefault("auth", {}) auth = cfg.setdefault("auth", {})
auth["salt"] = secrets.token_hex(8) rows = auth.get("accounts") or []
auth["password_hash"] = make_hash(new_password, auth["salt"]) kept = [a for a in rows if str(a.get("username") or "") != u]
if len(kept) == len(rows):
raise ValueError("账号不存在:%s" % u)
auth["accounts"] = kept
store.save_config(cfg) store.save_config(cfg)
return True
# ---------------------------------------------------------------- 会话 # ---------------------------------------------------------------- 会话
def create_session(username, ttl_hours=SESSION_TTL_HOURS): def create_session(username, role=ROLE_ADMIN, ttl_hours=SESSION_TTL_HOURS):
sessions = store.load_sessions() sessions = store.load_sessions()
now = dt.datetime.now() now = dt.datetime.now()
# 顺手清理过期会话 # 顺手清理过期会话
...@@ -59,6 +235,7 @@ def create_session(username, ttl_hours=SESSION_TTL_HOURS): ...@@ -59,6 +235,7 @@ def create_session(username, ttl_hours=SESSION_TTL_HOURS):
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
sessions[token] = { sessions[token] = {
"username": username, "username": username,
"role": normalize_role(role),
"created_at": now.strftime("%Y-%m-%d %H:%M:%S"), "created_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"expire_at": (now + dt.timedelta(hours=ttl_hours)).strftime("%Y-%m-%d %H:%M:%S"), "expire_at": (now + dt.timedelta(hours=ttl_hours)).strftime("%Y-%m-%d %H:%M:%S"),
} }
...@@ -78,6 +255,8 @@ def check_session(token): ...@@ -78,6 +255,8 @@ def check_session(token):
sessions.pop(token, None) sessions.pop(token, None)
store.save_sessions(sessions) store.save_sessions(sessions)
return None return None
# 老会话没有 role 字段 → 当年只有管理员能登录,按管理员处理
s["role"] = normalize_role(s["role"]) if s.get("role") else ROLE_ADMIN
return s return s
......
...@@ -47,6 +47,12 @@ DEFAULT_CONFIG = { ...@@ -47,6 +47,12 @@ DEFAULT_CONFIG = {
# sha256(salt + password),默认密码 1989@rent05 # sha256(salt + password),默认密码 1989@rent05
"salt": "rentos-2026", "salt": "rentos-2026",
"password_hash": "", "password_hash": "",
# 多账号 + 角色。role: "admin" 可读写 | "viewer" 只读访客
# [{"username","salt","password_hash","role","note","created_at"}]
# 首次启动会播种 admin(1989@rent05)与 guest(guest@2026,只读)
"accounts": [],
# 访客账号只播种一次;管理员删掉后不再自动复活
"guest_seeded": False,
}, },
"notify": { "notify": {
# 定时发送总开关;关闭后两张账单都不自动发送,只能手动发 # 定时发送总开关;关闭后两张账单都不自动发送,只能手动发
......
...@@ -7,9 +7,13 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -7,9 +7,13 @@ Python 标准库 http.server,零外部依赖。启动:
python -m rentos.server --port 8899 --no-scheduler python -m rentos.server --port 8899 --no-scheduler
路由总览(全部 JSON,除静态与文件下载) 路由总览(全部 JSON,除静态与文件下载)
POST /api/login 账密登录 → token POST /api/login 账密登录 → token(含 role:admin 可读写 / viewer 只读)
POST /api/logout 退出 POST /api/logout 退出
GET /api/session 当前会话 GET /api/session 当前会话(含 role / can_write)
GET /api/accounts 账号清单(仅管理员)
POST /api/accounts 新增账号(仅管理员)
DELETE /api/accounts/{username} 删除账号(仅管理员;主账号不可删)
POST /api/accounts/{username}/password 重置指定账号密码(仅管理员)
GET /api/overview 看板 GET /api/overview 看板
GET /api/config 读配置 GET /api/config 读配置
PUT /api/config 改配置(通知时间 / 电费参数 / 企微设置) PUT /api/config 改配置(通知时间 / 电费参数 / 企微设置)
...@@ -25,6 +29,7 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -25,6 +29,7 @@ Python 标准库 http.server,零外部依赖。启动:
DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进) DELETE /api/batches/{id} 删除批次(body.rollback=true 时回滚账期推进)
POST /api/batches/{id}/rerender 按最新配置重出单据 POST /api/batches/{id}/rerender 按最新配置重出单据
POST /api/batches/{id}/receipt 登记收款日期 / 是否开票(勾上即记开票日期) POST /api/batches/{id}/receipt 登记收款日期 / 是否开票(勾上即记开票日期)
房租批次会同步台账「最近收款月份」
POST /api/batches/{id}/send 手动发送(可指定单据序号) POST /api/batches/{id}/send 手动发送(可指定单据序号)
GET /api/batches/{id}/export 导出批次数据(xlsx:单据 + 明细 + 汇总) GET /api/batches/{id}/export 导出批次数据(xlsx:单据 + 明细 + 汇总)
GET /api/batches/{id}/file/{i} 下载第 i 份(PDF) GET /api/batches/{id}/file/{i} 下载第 i 份(PDF)
...@@ -133,6 +138,13 @@ class Handler(BaseHTTPRequestHandler): ...@@ -133,6 +138,13 @@ class Handler(BaseHTTPRequestHandler):
def _user(self): def _user(self):
return auth.check_session(self._token()) return auth.check_session(self._token())
@staticmethod
def _role(user):
"""会话角色。缺省按管理员(老会话),未知角色按只读处理。"""
if not user:
return ""
return auth.normalize_role(user.get("role") or auth.ROLE_ADMIN)
def _guard(self): def _guard(self):
u = self._user() u = self._user()
if not u: if not u:
...@@ -140,6 +152,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -140,6 +152,14 @@ class Handler(BaseHTTPRequestHandler):
return None return None
return u return u
def _guard_write(self, user):
"""写操作闸门:只读访客一律拒绝(服务端硬拦截,不依赖前端隐藏按钮)。"""
if self._role(user) == auth.ROLE_ADMIN:
return True
self._send(403, {"ok": False, "readonly": True,
"error": "只读访客账号:仅可浏览,无修改权限"})
return False
_CTYPES = { _CTYPES = {
".pdf": "application/pdf", ".pdf": "application/pdf",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
...@@ -213,7 +233,9 @@ class Handler(BaseHTTPRequestHandler): ...@@ -213,7 +233,9 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/session": if path == "/api/session":
u = self._user() u = self._user()
return self._send(200, {"ok": True, "logged_in": bool(u), return self._send(200, {"ok": True, "logged_in": bool(u),
"username": (u or {}).get("username", "")}) "username": (u or {}).get("username", ""),
"role": self._role(u),
"can_write": self._role(u) == auth.ROLE_ADMIN})
# ---- 需登录 # ---- 需登录
user = self._guard() user = self._guard()
...@@ -228,6 +250,13 @@ class Handler(BaseHTTPRequestHandler): ...@@ -228,6 +250,13 @@ class Handler(BaseHTTPRequestHandler):
cfg.pop("auth", None) # 不下发账号信息 cfg.pop("auth", None) # 不下发账号信息
return self._send(200, {"ok": True, "config": cfg}) return self._send(200, {"ok": True, "config": cfg})
if path == "/api/accounts":
if self._role(user) != auth.ROLE_ADMIN:
return self._send(403, {"ok": False, "error": "仅管理员可查看账号"})
return self._send(200, {"ok": True, "accounts": auth.public_accounts(),
"me": user.get("username"),
"my_role": self._role(user)})
if path == "/api/owners": if path == "/api/owners":
return self._send(200, {"ok": True, return self._send(200, {"ok": True,
"owners": service.owners_config(), "owners": service.owners_config(),
...@@ -400,10 +429,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -400,10 +429,14 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/login" and method == "POST": if path == "/api/login" and method == "POST":
u = str(body.get("username") or "").strip() u = str(body.get("username") or "").strip()
p = str(body.get("password") or "") p = str(body.get("password") or "")
if not auth.verify(u, p): acc = auth.authenticate(u, p)
if not acc:
return self._send(401, {"ok": False, "error": "账号或密码不正确"}) return self._send(401, {"ok": False, "error": "账号或密码不正确"})
token = auth.create_session(u) role = acc.get("role") or auth.ROLE_ADMIN
return self._send(200, {"ok": True, "token": token, "username": u}) token = auth.create_session(acc["username"], role)
return self._send(200, {"ok": True, "token": token,
"username": acc["username"], "role": role,
"can_write": role == auth.ROLE_ADMIN})
user = self._guard() user = self._guard()
if not user: if not user:
...@@ -413,6 +446,10 @@ class Handler(BaseHTTPRequestHandler): ...@@ -413,6 +446,10 @@ class Handler(BaseHTTPRequestHandler):
auth.drop_session(self._token()) auth.drop_session(self._token())
return self._send(200, {"ok": True}) return self._send(200, {"ok": True})
# ---- 只读访客:除退出登录外,所有写操作一律拒绝
if not self._guard_write(user):
return
if path == "/api/password" and method == "POST": if path == "/api/password" and method == "POST":
newp = str(body.get("new_password") or "") newp = str(body.get("new_password") or "")
if len(newp) < 6: if len(newp) < 6:
...@@ -420,6 +457,32 @@ class Handler(BaseHTTPRequestHandler): ...@@ -420,6 +457,32 @@ class Handler(BaseHTTPRequestHandler):
auth.set_password(newp) auth.set_password(newp)
return self._send(200, {"ok": True}) return self._send(200, {"ok": True})
# ================================================ 账号管理(仅管理员)
if path == "/api/accounts" and method == "POST":
row = auth.add_account(str(body.get("username") or ""),
str(body.get("password") or ""),
role=str(body.get("role") or auth.ROLE_VIEWER),
note=body.get("note") or "")
return self._send(200, {"ok": True,
"account": {"username": row["username"],
"role": row["role"],
"note": row.get("note") or ""},
"accounts": auth.public_accounts()})
m = re.match(r"^/api/accounts/([^/]+)/password$", path)
if m and method == "POST":
newp = str(body.get("new_password") or "")
if len(newp) < 6:
return self._send(400, {"ok": False, "error": "新密码至少 6 位"})
target = auth.set_password(newp, username=m.group(1))
return self._send(200, {"ok": True, "username": target})
m = re.match(r"^/api/accounts/([^/]+)$", path)
if m and method == "DELETE":
auth.remove_account(m.group(1))
return self._send(200, {"ok": True, "removed": m.group(1),
"accounts": auth.public_accounts()})
if path == "/api/config" and method == "PUT": if path == "/api/config" and method == "PUT":
allow = ("notify", "wecom", "elec", "remit") allow = ("notify", "wecom", "elec", "remit")
partial = {k: v for k, v in body.items() if k in allow} partial = {k: v for k, v in body.items() if k in allow}
...@@ -540,6 +603,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -540,6 +603,7 @@ class Handler(BaseHTTPRequestHandler):
rollback = None rollback = None
if b and (b.get("kind") or "elec") == service.RENT_KEY and body.get("rollback"): if b and (b.get("kind") or "elec") == service.RENT_KEY and body.get("rollback"):
rollback = service.rollback_rent_advance(b) rollback = service.rollback_rent_advance(b)
service.rollback_receipt_sync(b) # 收款登记同步过的「最近收款月份」一并退回
ok = store.delete_batch(bid) ok = store.delete_batch(bid)
return self._send(200, {"ok": ok, "rollback": rollback}) return self._send(200, {"ok": ok, "rollback": rollback})
...@@ -563,12 +627,13 @@ class Handler(BaseHTTPRequestHandler): ...@@ -563,12 +627,13 @@ class Handler(BaseHTTPRequestHandler):
m = re.match(r"^/api/batches/([^/]+)/receipt$", path) m = re.match(r"^/api/batches/([^/]+)/receipt$", path)
if m and method == "POST": if m and method == "POST":
b = service.set_receipt(m.group(1), body.get("index"), b, sync = service.set_receipt(m.group(1), body.get("index"),
paid_at=body.get("paid_at"), paid_at=body.get("paid_at"),
invoiced=body.get("invoiced"), invoiced=body.get("invoiced"),
invoiced_at=body.get("invoiced_at")) invoiced_at=body.get("invoiced_at"))
return self._send(200, {"ok": True, "batch": b, return self._send(200, {"ok": True, "batch": b,
"receipt": service.receipt_summary(b)}) "receipt": service.receipt_summary(b),
"sync": sync})
m = re.match(r"^/api/batches/([^/]+)/send$", path) m = re.match(r"^/api/batches/([^/]+)/send$", path)
if m and method == "POST": if m and method == "POST":
......
...@@ -522,12 +522,106 @@ def prune_output(batch_id, keep_files, kind="elec"): ...@@ -522,12 +522,106 @@ def prune_output(batch_id, keep_files, kind="elec"):
# ---------------------------------------------------------------- 收款 / 开票登记 # ---------------------------------------------------------------- 收款 / 开票登记
def _month_of(s):
"""日期 → 「YYYY-MM」;空值 / 解析不了返回空串。
注意用 rent_parser._d:它解析失败返回 None;
本模块的 _d 为出单容错会回落「今天」,不能用于此处。
"""
d = RP._d(s)
return d.strftime("%Y-%m") if d else ""
def _sync_records(batch):
rec = batch.get("receipt_sync")
return rec if isinstance(rec, list) else []
def sync_receipt_month(batch, index, doc):
"""登记收款后,把该单据覆盖的台账行「最近收款月份」(AB) 同步为收款月份。
口径(2026-10-13 与巴特菲确认):取**收款日期所在月**("YYYY-MM")。
清空收款日期时回退 —— 仅当台账当前值仍等于上次同步写入的值才退回,
期间被人工改过的行不动(与「删除批次回滚账期」同一保守策略)。
批次里留一份 receipt_sync 记录({doc,row_id,from,to}),
供清空收款日期、删除批次时逐步回退。
返回 {"updated","reverted","month","rows":[{"row_id","from","to"}]}。
"""
col = tables.BILL_COLS["last_paid"]
rid_list = [str(r) for r in (doc.get("row_ids") or []) if str(r).strip()]
target = _month_of(doc.get("paid_at"))
keep = [r for r in _sync_records(batch) if r.get("doc") != index]
old = {r.get("row_id"): r for r in _sync_records(batch) if r.get("doc") == index}
table = tables.load_table(RENT_KEY)
cur = {r.get("_id"): r.get(col) for r in (table or {}).get("rows") or []}
ups, rows_out, recs = [], [], list(keep)
detail_up = {}
reverted = 0
for rid in rid_list:
if rid not in cur:
continue
before = cur.get(rid)
prev = old.get(rid)
if target:
if str(before or "")[:7] == target:
# 已是目标月份:保留原有 from,方便后续回退
if prev:
recs.append(prev)
continue
frm = prev.get("from") if prev else before
ups.append((rid, col, target))
recs.append({"doc": index, "row_id": rid, "from": frm, "to": target})
rows_out.append({"row_id": rid, "from": frm, "to": target})
detail_up[rid] = target
elif prev and str(before or "")[:7] == str(prev.get("to") or ""):
ups.append((rid, col, prev.get("from")))
rows_out.append({"row_id": rid, "from": prev.get("to"), "to": prev.get("from")})
detail_up[rid] = prev.get("from")
reverted += 1
if ups:
tables.update_cells(RENT_KEY, ups, who="登记收款同步最近收款月份")
batch["receipt_sync"] = recs
# 批次明细快照里的「最近收款月份」一起跟进,避免页面出现两个口径
for d in batch.get("details") or []:
if d.get("row_id") in detail_up:
d["last_paid"] = detail_up[d["row_id"]] or ""
return {"updated": len(ups) - reverted, "reverted": reverted,
"month": target, "rows": rows_out}
def rollback_receipt_sync(batch):
"""把该批次登记收款时同步过的「最近收款月份」退回原值(删除批次时调用)。"""
recs = _sync_records(batch)
if not recs:
return {"reverted": 0, "skipped": 0}
col = tables.BILL_COLS["last_paid"]
table = tables.load_table(RENT_KEY)
cur = {r.get("_id"): r.get(col) for r in (table or {}).get("rows") or []}
ups = []
for r in recs:
rid = r.get("row_id")
if rid in cur and str(cur.get(rid) or "")[:7] == str(r.get("to") or ""):
ups.append((rid, col, r.get("from")))
res = tables.update_cells(RENT_KEY, ups, who="删除批次回滚收款月份")
return {"reverted": res.get("updated", 0),
"skipped": len(recs) - res.get("updated", 0)}
def set_receipt(batch_id, index, paid_at=None, invoiced=None, invoiced_at=None): def set_receipt(batch_id, index, paid_at=None, invoiced=None, invoiced_at=None):
"""登记某一份单据的「收款日期」与「是否开票」。 """登记某一份单据的「收款日期」与「是否开票」。
paid_at "" 或 YYYY-MM-DD(空串 = 清空,表示还没收到钱) paid_at "" 或 YYYY-MM-DD(空串 = 清空,表示还没收到钱)
invoiced True/False;勾上时若没给 invoiced_at 就默认记今天 invoiced True/False;勾上时若没给 invoiced_at 就默认记今天
返回最新批次;越界的 index 抛 ValueError。 越界的 index 抛 ValueError。
房租车位费批次还会把台账「最近收款月份」同步成收款日期所在月。
返回 (batch, sync) —— sync 给出这次同步改了几行。
""" """
batch = store.load_batch(batch_id) batch = store.load_batch(batch_id)
if not batch: if not batch:
...@@ -555,9 +649,17 @@ def set_receipt(batch_id, index, paid_at=None, invoiced=None, invoiced_at=None): ...@@ -555,9 +649,17 @@ def set_receipt(batch_id, index, paid_at=None, invoiced=None, invoiced_at=None):
elif invoiced_at is not None and doc.get("invoiced"): elif invoiced_at is not None and doc.get("invoiced"):
doc["invoiced_at"] = str(invoiced_at or "").strip()[:10] doc["invoiced_at"] = str(invoiced_at or "").strip()[:10]
# ---- 同步台账「最近收款月份」(仅房租车位费批次;失败不阻塞登记)
sync = {"updated": 0, "reverted": 0, "month": "", "rows": [], "error": ""}
if paid_at is not None and (batch.get("kind") or "elec") == RENT_KEY:
try:
sync = sync_receipt_month(batch, i, doc)
except Exception as e: # noqa: BLE001
sync["error"] = str(e)
batch["receipt_updated_at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") batch["receipt_updated_at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
store.save_batch(batch) store.save_batch(batch)
return batch return batch, sync
def receipt_summary(batch): def receipt_summary(batch):
......
# -*- coding: utf-8 -*-
"""
账号 / 角色(只读访客)单元测试
================================
对应需求(2026-10-13 巴特菲):
「创建只能浏览不能修改的访客账户」
覆盖面:
· 首次启动播种:admin(管理员)+ guest(只读访客)
· 账密校验 / 角色归一化(未知角色一律按只读处理)
· 账号增删改:主账号不可删、重名报错、密码长度校验、
访客账号被删后不会自动复活(guest_seeded)
· 会话携带角色;老会话缺 role 按管理员处理(当时只有管理员能登录)
· 下发清单不含哈希 / 盐
· **服务端写闸门**:真起一个 HTTP 服务,实测 guest 的所有写接口 403、
读接口正常、退出登录可用;admin 同一接口能通过闸门(用不存在的批次验证)
运行:python -m unittest discover -s tools -p "test_*.py"
"""
import json
import os
import shutil
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.parse
import urllib.request
from http.server import ThreadingHTTPServer
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from rentos import auth # noqa: E402
from rentos import config as C # noqa: E402
from rentos import server # noqa: E402
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
class AccountCase(unittest.TestCase):
"""把配置与会话文件换到临时目录,绝不动真实 data/。"""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="rentos-auth-")
self.addCleanup(lambda: shutil.rmtree(self.tmp, ignore_errors=True))
self._p = [
mock.patch.object(C, "CONFIG_FILE", os.path.join(self.tmp, "config.json")),
mock.patch.object(C, "SESSION_FILE", os.path.join(self.tmp, "sessions.json")),
]
for p in self._p:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._p])
# ================================================================ 1. 播种与校验
class TestSeeding(AccountCase):
def test_first_boot_seeds_admin_and_guest(self):
rows = {a["username"]: a for a in auth.accounts()}
self.assertIn("admin", rows)
self.assertIn("guest", rows)
self.assertEqual(rows["admin"]["role"], auth.ROLE_ADMIN)
self.assertEqual(rows["guest"]["role"], auth.ROLE_VIEWER)
def test_default_credentials_work(self):
self.assertEqual(auth.authenticate("admin", "1989@rent05")["role"],
auth.ROLE_ADMIN)
self.assertEqual(auth.authenticate("guest", "guest@2026")["role"],
auth.ROLE_VIEWER)
def test_wrong_password_rejected(self):
self.assertIsNone(auth.authenticate("admin", "bad"))
self.assertIsNone(auth.authenticate("guest", "guest"))
self.assertIsNone(auth.authenticate("", ""))
self.assertIsNone(auth.authenticate("nobody", "guest@2026"))
self.assertFalse(auth.verify("guest", "GUEST@2026")) # 大小写敏感
def test_seeding_is_idempotent(self):
auth.accounts()
n1 = len(auth.accounts())
auth.ensure_default_account()
auth.ensure_default_account()
self.assertEqual(len(auth.accounts()), n1)
def test_password_not_stored_in_plaintext(self):
auth.accounts() # 触发首次落盘
with open(C.CONFIG_FILE, encoding="utf-8") as f:
raw = f.read()
self.assertNotIn("guest@2026", raw)
self.assertNotIn("1989@rent05", raw)
def test_public_accounts_hides_secrets(self):
for a in auth.public_accounts():
self.assertNotIn("salt", a)
self.assertNotIn("password_hash", a)
self.assertIn(a["role"], auth.ROLES)
# ================================================================ 2. 角色归一化
class TestRole(unittest.TestCase):
def test_known_roles(self):
self.assertEqual(auth.normalize_role("admin"), "admin")
self.assertEqual(auth.normalize_role("viewer"), "viewer")
self.assertEqual(auth.normalize_role("ADMIN"), "admin")
self.assertEqual(auth.normalize_role(" Viewer "), "viewer")
def test_unknown_role_falls_back_to_viewer(self):
for bad in (None, "", "root", "superuser", 1, {}):
self.assertEqual(auth.normalize_role(bad), "viewer")
# ================================================================ 3. 账号增删改
class TestAccountCrud(AccountCase):
def test_add_viewer(self):
auth.add_account("reader", "reader123", note="财务只读")
row = auth.find("reader")
self.assertEqual(row["role"], auth.ROLE_VIEWER)
self.assertEqual(row["note"], "财务只读")
self.assertEqual(auth.authenticate("reader", "reader123")["role"], "viewer")
def test_add_duplicate_raises(self):
with self.assertRaises(ValueError):
auth.add_account("guest", "whatever1")
def test_add_short_password_raises(self):
with self.assertRaises(ValueError):
auth.add_account("shorty", "12345")
def test_add_empty_username_raises(self):
with self.assertRaises(ValueError):
auth.add_account(" ", "123456")
def test_add_admin_role(self):
auth.add_account("boss", "boss@2026", role="admin")
self.assertEqual(auth.authenticate("boss", "boss@2026")["role"], "admin")
def test_add_unknown_role_is_viewer(self):
auth.add_account("x", "xxxxxx1", role="root")
self.assertEqual(auth.authenticate("x", "xxxxxx1")["role"], "viewer")
def test_remove_account(self):
auth.remove_account("guest")
self.assertIsNone(auth.find("guest"))
def test_removed_guest_does_not_come_back(self):
"""管理员删掉访客后不该被 ensure 逻辑复活。"""
auth.remove_account("guest")
auth.accounts()
self.assertIsNone(auth.find("guest"))
def test_primary_admin_cannot_be_removed(self):
with self.assertRaises(ValueError):
auth.remove_account("admin")
def test_remove_unknown_raises(self):
with self.assertRaises(ValueError):
auth.remove_account("nobody")
def test_set_password_for_specific_account(self):
auth.set_password("guest@new1", username="guest")
self.assertEqual(auth.authenticate("guest", "guest@new1")["role"], "viewer")
self.assertIsNone(auth.authenticate("guest", "guest@2026"))
# 管理员密码不受影响
self.assertIsNotNone(auth.authenticate("admin", "1989@rent05"))
def test_set_password_for_primary_syncs_legacy_fields(self):
auth.set_password("brand@new9")
with open(C.CONFIG_FILE, encoding="utf-8") as f:
cfg = json.load(f)
self.assertTrue(cfg["auth"]["password_hash"])
self.assertIsNotNone(auth.authenticate("admin", "brand@new9"))
def test_set_password_unknown_account_raises(self):
with self.assertRaises(ValueError):
auth.set_password("whatever1", username="nobody")
# ================================================================ 4. 会话
class TestSession(AccountCase):
def test_session_carries_role(self):
tok = auth.create_session("guest", auth.ROLE_VIEWER)
s = auth.check_session(tok)
self.assertEqual(s["username"], "guest")
self.assertEqual(s["role"], "viewer")
def test_legacy_session_without_role_is_admin(self):
"""老会话(本功能上线前创建的)没有 role —— 当时只有管理员能登录。"""
tok = auth.create_session("admin", auth.ROLE_ADMIN)
with open(C.SESSION_FILE, encoding="utf-8") as f:
sessions = json.load(f)
sessions[tok].pop("role", None)
with open(C.SESSION_FILE, "w", encoding="utf-8") as f:
json.dump(sessions, f)
self.assertEqual(auth.check_session(tok)["role"], "admin")
def test_unknown_session_role_is_viewer(self):
tok = auth.create_session("guest", auth.ROLE_VIEWER)
with open(C.SESSION_FILE, encoding="utf-8") as f:
sessions = json.load(f)
sessions[tok]["role"] = "root"
with open(C.SESSION_FILE, "w", encoding="utf-8") as f:
json.dump(sessions, f)
self.assertEqual(auth.check_session(tok)["role"], "viewer")
def test_drop_session(self):
tok = auth.create_session("guest", auth.ROLE_VIEWER)
auth.drop_session(tok)
self.assertIsNone(auth.check_session(tok))
def test_bad_token(self):
self.assertIsNone(auth.check_session(""))
self.assertIsNone(auth.check_session("nope"))
# ================================================================ 5. 服务端写闸门(真 HTTP)
class TestWriteGateOverHttp(AccountCase):
"""起一个真实 HTTP 服务,实测「只读访客不能改任何东西」。"""
def setUp(self):
super().setUp()
self.srv = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
self.srv.daemon_threads = True
self.port = self.srv.server_address[1]
t = threading.Thread(target=self.srv.serve_forever, daemon=True)
t.start()
self.addCleanup(self.srv.server_close) # 后加的先跑:先 shutdown 再 close
self.addCleanup(self.srv.shutdown)
def call(self, method, path, body=None, token=None):
path = urllib.parse.quote(path, safe="/?=&:%")
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request("http://127.0.0.1:%d%s" % (self.port, path),
data=data, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("Connection", "close")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with _OPENER.open(req, timeout=30) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
raw = e.read().decode()
return e.code, (json.loads(raw) if raw.strip() else {})
def login(self, user, pwd):
st, d = self.call("POST", "/api/login", {"username": user, "password": pwd})
self.assertEqual(st, 200, d)
return d["token"], d
# ---- 写接口清单:guest 一律 403
WRITES = [
("PUT", "/api/config", {"notify": {"enabled": True}}),
("PUT", "/api/owners", {"owners": {}}),
("POST", "/api/batches", {"rows": [{"unit": "1"}]}),
("POST", "/api/rent/batches", {}),
("DELETE", "/api/batches/20260101-000000", {"rollback": True}),
("POST", "/api/batches/20260101-000000/rerender", {}),
("POST", "/api/batches/20260101-000000/receipt", {"index": 0, "paid_at": "2026-10-13"}),
("POST", "/api/batches/20260101-000000/send", {}),
("POST", "/api/scheduler/fire", {}),
("PUT", "/api/tables/rent_parking/cell",
{"row_id": "rent_parking-0001", "col": "AB", "value": "2026-10", "confirm": True}),
("POST", "/api/tables/rent_parking/row", {"confirm": True}),
("DELETE", "/api/tables/rent_parking/row", {"row_id": "x", "confirm": True}),
("POST", "/api/tables/rent_parking/restore", {"file": "x.json"}),
("POST", "/api/companies", {"name": "X 公司"}),
("PUT", "/api/companies/X", {"name": "Y"}),
("DELETE", "/api/companies/X", {}),
("POST", "/api/owners/reset", {}),
("POST", "/api/attachments", {"data": "data:image/png;base64,AA"}),
("DELETE", "/api/attachments/abc", {}),
("POST", "/api/password", {"new_password": "hacked123"}),
("POST", "/api/accounts", {"username": "hacker", "password": "hacker1"}),
("DELETE", "/api/accounts/guest", {}),
("POST", "/api/accounts/guest/password", {"new_password": "hacked123"}),
]
def test_login_returns_role(self):
_, d = self.login("guest", "guest@2026")
self.assertEqual(d["role"], "viewer")
self.assertFalse(d["can_write"])
def test_guest_cannot_write_anything(self):
tok, _ = self.login("guest", "guest@2026")
for method, path, body in self.WRITES:
st, d = self.call(method, path, body, token=tok)
self.assertEqual(st, 403, "%s %s → %s %s" % (method, path, st, d))
self.assertTrue(d.get("readonly"), "%s %s" % (method, path))
def test_guest_can_read(self):
tok, _ = self.login("guest", "guest@2026")
st, d = self.call("GET", "/api/session", token=tok)
self.assertEqual(st, 200)
self.assertEqual(d["role"], "viewer")
self.assertFalse(d["can_write"])
self.assertEqual(self.call("GET", "/api/batches", token=tok)[0], 200)
# 账号清单属于管理入口,访客也读不到
self.assertEqual(self.call("GET", "/api/accounts", token=tok)[0], 403)
def test_guest_can_still_log_out(self):
tok, _ = self.login("guest", "guest@2026")
self.assertEqual(self.call("POST", "/api/logout", {}, token=tok)[0], 200)
self.assertEqual(self.call("GET", "/api/session", token=tok)[1]["logged_in"], False)
def test_download_endpoints_stay_readable_for_guest(self):
"""下载 / 导出属于只读,不能被闸门误伤(批次不存在时是 404,不是 403)。"""
tok, _ = self.login("guest", "guest@2026")
st, _ = self.call("GET", "/api/batches/20260101-000000/file/0", token=tok)
self.assertEqual(st, 404)
def test_admin_passes_the_gate(self):
tok, d = self.login("admin", "1989@rent05")
self.assertEqual(d["role"], "admin")
self.assertTrue(d["can_write"])
# 用一个不存在的批次验证「闸门放行了」:报的是业务错(400),不是 403
st, _ = self.call("POST", "/api/batches/NO-SUCH/receipt",
{"index": 0, "paid_at": "2026-10-13"}, token=tok)
self.assertEqual(st, 400)
def test_admin_can_manage_accounts(self):
"""管理员能列出 / 停用访客账号(用临时配置,不动真实数据)。"""
tok, _ = self.login("admin", "1989@rent05")
st, d = self.call("GET", "/api/accounts", token=tok)
self.assertEqual(st, 200)
self.assertIn("guest", [a["username"] for a in d["accounts"]])
st, d = self.call("POST", "/api/accounts",
{"username": "reader2", "password": "reader123"}, token=tok)
self.assertEqual(st, 200)
self.assertIn("reader2", [a["username"] for a in d["accounts"]])
st, d = self.call("DELETE", "/api/accounts/reader2", token=tok)
self.assertEqual(st, 200)
self.assertNotIn("reader2", [a["username"] for a in d["accounts"]])
def test_unauthenticated_is_401(self):
for method, path, body in self.WRITES[:6]:
self.assertEqual(self.call(method, path, body)[0], 401,
"%s %s" % (method, path))
if __name__ == "__main__":
unittest.main()
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
运行:python -m unittest discover -s tools -p "test_*.py" 运行:python -m unittest discover -s tools -p "test_*.py"
""" """
import datetime as dt import datetime as dt
import json
import os import os
import shutil import shutil
import sys import sys
...@@ -82,7 +83,7 @@ class TestReceipt(FakeBatchBackend): ...@@ -82,7 +83,7 @@ class TestReceipt(FakeBatchBackend):
def test_set_paid_at(self): def test_set_paid_at(self):
b = self._batch() b = self._batch()
self.assertEqual(len(b["documents"]), 2) self.assertEqual(len(b["documents"]), 2)
b2 = service.set_receipt(b["batch_id"], 1, paid_at="2026-10-15") b2, sync = service.set_receipt(b["batch_id"], 1, paid_at="2026-10-15")
self.assertEqual(b2["documents"][1]["paid_at"], "2026-10-15") self.assertEqual(b2["documents"][1]["paid_at"], "2026-10-15")
s = service.receipt_summary(b2) s = service.receipt_summary(b2)
self.assertEqual(s["doc_count"], 2) self.assertEqual(s["doc_count"], 2)
...@@ -92,10 +93,15 @@ class TestReceipt(FakeBatchBackend): ...@@ -92,10 +93,15 @@ class TestReceipt(FakeBatchBackend):
round(b2["documents"][1]["total_amount"], 2), places=2) round(b2["documents"][1]["total_amount"], 2), places=2)
self.assertAlmostEqual(s["unpaid_amount"], self.assertAlmostEqual(s["unpaid_amount"],
round(b2["documents"][0]["total_amount"], 2), places=2) round(b2["documents"][0]["total_amount"], 2), places=2)
# 收款日期 → 台账「最近收款月份」= 收款日期所在月
self.assertEqual(sync["month"], "2026-10")
self.assertEqual(sync["updated"], 1)
rid = b2["documents"][1]["row_ids"][0]
self.assertEqual(self.row(rid)["AB"], "2026-10")
def test_invoice_checked_records_today(self): def test_invoice_checked_records_today(self):
b = self._batch() b = self._batch()
b2 = service.set_receipt(b["batch_id"], 0, invoiced=True) b2, _ = service.set_receipt(b["batch_id"], 0, invoiced=True)
d = b2["documents"][0] d = b2["documents"][0]
self.assertTrue(d["invoiced"]) self.assertTrue(d["invoiced"])
self.assertEqual(d["invoiced_at"], dt.date.today().strftime("%Y-%m-%d")) self.assertEqual(d["invoiced_at"], dt.date.today().strftime("%Y-%m-%d"))
...@@ -105,28 +111,28 @@ class TestReceipt(FakeBatchBackend): ...@@ -105,28 +111,28 @@ class TestReceipt(FakeBatchBackend):
def test_invoice_with_explicit_date(self): def test_invoice_with_explicit_date(self):
b = self._batch() b = self._batch()
b2 = service.set_receipt(b["batch_id"], 0, invoiced=True, invoiced_at="2026-10-20") b2, _ = service.set_receipt(b["batch_id"], 0, invoiced=True, invoiced_at="2026-10-20")
self.assertEqual(b2["documents"][0]["invoiced_at"], "2026-10-20") self.assertEqual(b2["documents"][0]["invoiced_at"], "2026-10-20")
def test_uncheck_clears_invoice_date(self): def test_uncheck_clears_invoice_date(self):
b = self._batch() b = self._batch()
b = service.set_receipt(b["batch_id"], 0, invoiced=True, invoiced_at="2026-10-20") b, _ = service.set_receipt(b["batch_id"], 0, invoiced=True, invoiced_at="2026-10-20")
b = service.set_receipt(b["batch_id"], 0, invoiced=False) b, _ = service.set_receipt(b["batch_id"], 0, invoiced=False)
self.assertFalse(b["documents"][0]["invoiced"]) self.assertFalse(b["documents"][0]["invoiced"])
self.assertEqual(b["documents"][0]["invoiced_at"], "") self.assertEqual(b["documents"][0]["invoiced_at"], "")
self.assertEqual(service.receipt_summary(b)["invoiced_count"], 0) self.assertEqual(service.receipt_summary(b)["invoiced_count"], 0)
def test_clear_paid_at(self): def test_clear_paid_at(self):
b = self._batch() b = self._batch()
b = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-15") b, _ = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-15")
b = service.set_receipt(b["batch_id"], 0, paid_at="") b, _ = service.set_receipt(b["batch_id"], 0, paid_at="")
self.assertEqual(b["documents"][0]["paid_at"], "") self.assertEqual(b["documents"][0]["paid_at"], "")
self.assertEqual(service.receipt_summary(b)["paid_count"], 0) self.assertEqual(service.receipt_summary(b)["paid_count"], 0)
self.assertEqual(service.receipt_summary(b)["last_paid_at"], "") self.assertEqual(service.receipt_summary(b)["last_paid_at"], "")
def test_receipt_updated_at_stamped(self): def test_receipt_updated_at_stamped(self):
b = self._batch() b = self._batch()
b2 = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-15") b2, _ = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-15")
self.assertTrue(b2.get("receipt_updated_at")) self.assertTrue(b2.get("receipt_updated_at"))
def test_index_out_of_range(self): def test_index_out_of_range(self):
...@@ -146,6 +152,104 @@ class TestReceipt(FakeBatchBackend): ...@@ -146,6 +152,104 @@ class TestReceipt(FakeBatchBackend):
self.assertEqual(s["last_paid_at"], "") self.assertEqual(s["last_paid_at"], "")
# ================================================================ 1b. 收款 → 台账账期同步
class TestReceiptLedgerSync(FakeBatchBackend):
"""登记收款日期 → 台账「最近收款月份」(AB) 同步为收款日期所在月。"""
def _batch(self):
return service.ingest_rent(row_ids=["r3", "r6"], on_date="2026-10-09",
notice_date="2026-10-09", advance=False)
def test_month_is_taken_from_paid_date(self):
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
self.assertEqual(self.row(rid)["AB"], None) # 出单前台账没有收款月份
_, sync = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.assertEqual(sync["month"], "2026-10")
self.assertEqual(self.row(rid)["AB"], "2026-10")
self.assertEqual(sync["updated"], 1)
def test_month_follows_changed_paid_date(self):
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.assertEqual(self.row(rid)["AB"], "2026-10")
service.set_receipt(b["batch_id"], 0, paid_at="2026-11-05")
self.assertEqual(self.row(rid)["AB"], "2026-11")
def test_clearing_paid_date_reverts_ledger(self):
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
self.row(rid)["AB"] = "2026-09" # 台账原值
service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.assertEqual(self.row(rid)["AB"], "2026-10")
_, sync = service.set_receipt(b["batch_id"], 0, paid_at="")
self.assertEqual(sync["reverted"], 1)
self.assertEqual(self.row(rid)["AB"], "2026-09") # 退回同步前的值
def test_manual_edit_is_not_clobbered_on_clear(self):
"""期间被人工改过的行,清空收款日期时不动它。"""
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.row(rid)["AB"] = "2026-12" # 人工改成别的值
_, sync = service.set_receipt(b["batch_id"], 0, paid_at="")
self.assertEqual(sync["reverted"], 0)
self.assertEqual(self.row(rid)["AB"], "2026-12")
def test_all_rows_of_document_are_synced(self):
b = self._batch()
doc = b["documents"][0]
_, sync = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
for rid in doc["row_ids"]:
self.assertEqual(self.row(rid)["AB"], "2026-10")
self.assertEqual(sync["updated"], len(doc["row_ids"]))
def test_empty_paid_at_does_not_write(self):
b = self._batch()
before = [json.loads(json.dumps(r)) for r in self.ledger]
service.set_receipt(b["batch_id"], 0, paid_at="")
self.assertEqual(before, self.ledger)
def test_batch_snapshot_last_paid_follows(self):
"""批次明细快照里的「最近收款月份」跟台账一起走,页面不会出现两个口径。"""
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
b2, _ = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
det = [d for d in b2["details"] if d["row_id"] == rid][0]
self.assertEqual(det["last_paid"], "2026-10")
self.assertEqual(det["last_paid"], self.row(rid)["AB"])
def test_rollback_receipt_sync(self):
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
self.row(rid)["AB"] = "2026-08"
b2, _ = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.assertEqual(self.row(rid)["AB"], "2026-10")
res = service.rollback_receipt_sync(b2)
self.assertGreaterEqual(res["reverted"], 1)
self.assertEqual(self.row(rid)["AB"], "2026-08")
def test_rollback_skips_manually_changed(self):
b = self._batch()
rid = b["documents"][0]["row_ids"][0]
b2, _ = service.set_receipt(b["batch_id"], 0, paid_at="2026-10-13")
self.row(rid)["AB"] = "2027-01" # 人工又改过
res = service.rollback_receipt_sync(b2)
self.assertEqual(res["reverted"], 0)
self.assertEqual(res["skipped"], len(b2.get("receipt_sync") or []))
self.assertEqual(self.row(rid)["AB"], "2027-01")
def test_elec_batch_does_not_touch_ledger(self):
"""电费批次不受影响(只房租车位费批次同步账期)。"""
b = {"batch_id": "E1", "kind": "elec", "documents": [
{"row_ids": ["r3"], "paid_at": ""}], "details": []}
self.batches["E1"] = b
_, sync = service.set_receipt("E1", 0, paid_at="2026-10-13")
self.assertEqual(sync["updated"], 0)
self.assertIsNone(self.row("r3")["AB"])
# ================================================================ 2. 批次数据导出 # ================================================================ 2. 批次数据导出
class TestExport(FakeBatchBackend): class TestExport(FakeBatchBackend):
def _batch(self): def _batch(self):
......
# -*- coding: utf-8 -*-
"""
第六轮需求的端到端验证(2026-10-13)
====================================
对应需求:
1. 批次详情里更新「收款日期」→ 数据表(台账)「最近收款月份」(AB) 同步更新
2. 新增「只能浏览不能修改」的只读访客账户
做法:
* **自带沙箱**:把 data/ 复制到临时目录并改写路径常量后,在 8901 端口另起一个服务,
真实 data/ 与线上台账全程零改动。
* 真浏览器(Playwright/Chromium)跑:访客界面 → 写操作被拒;管理员界面 → 收款同步台账。
* 同时校验服务端硬拦截(访客用 fetch 直打写接口必须 403)。
运行:python tools/ui_verify7.py
"""
import io
import json
import os
import shutil
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*"
from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402
PORT = int(os.environ.get("RENTOS_VERIFY_PORT", "8901"))
BASE = "http://127.0.0.1:%d" % PORT
OUT = os.path.join(ROOT, "output", "ui_shots")
OK, FAIL = [], []
def ck(name, cond, extra=""):
(OK if cond else FAIL).append(name)
print(" [%s] %s%s" % ("ok" if cond else "FAIL", name,
(" → %s" % extra) if extra != "" else ""))
# ================================================================ 沙箱服务
def start_sandbox():
"""复制 data/ 到临时目录,改路径常量,起一个独立服务。返回 (srv, tmpdir)。"""
tmp = tempfile.mkdtemp(prefix="rentos-verify7-")
shutil.copytree(os.path.join(ROOT, "data"), os.path.join(tmp, "data"))
data = os.path.join(tmp, "data")
for name, val in {
"DATA_DIR": data,
"BATCH_DIR": os.path.join(data, "batches"),
"TABLES_DIR": os.path.join(data, "tables"),
"ATTACH_DIR": os.path.join(data, "attachments"),
"CONFIG_FILE": os.path.join(data, "config.json"),
"SEND_LOG_FILE": os.path.join(data, "send_log.json"),
"SESSION_FILE": os.path.join(data, "sessions.json"),
"UNIT_MAP_FILE": os.path.join(data, "unit_map.json"),
"UPLOAD_DIR": os.path.join(tmp, "uploads"),
"OUTPUT_DIR": os.path.join(tmp, "output", "elec"),
"OUTPUT_RENT_DIR": os.path.join(tmp, "output", "rent"),
"TABLE_EXPORT_DIR": os.path.join(tmp, "output", "tables"),
"BATCH_EXPORT_DIR": os.path.join(tmp, "output", "exports"),
"LOG_DIR": os.path.join(tmp, "logs"),
}.items():
setattr(C, name, val)
C.ensure_dirs()
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, tmp
# ================================================================ 主流程
def main():
os.makedirs(OUT, exist_ok=True)
srv, tmp = start_sandbox()
print("沙箱数据目录:%s" % tmp)
print("沙箱服务:%s(真实 data/ 未被触碰)" % BASE)
from playwright.sync_api import sync_playwright
errors = []
try:
with sync_playwright() as pw:
b = pw.chromium.launch()
p = b.new_page(viewport={"width": 1680, "height": 980})
p.on("console", lambda m: errors.append("console:" + m.text)
if (m.type == "error" and "favicon" not in m.text.lower()
and "Failed to load resource" not in m.text) else None)
p.on("pageerror", lambda e: errors.append("pageerror:" + str(e)))
def js(fn, *a):
return p.evaluate(fn, *a) if a else p.evaluate(fn)
def login(user, pwd):
p.goto(BASE + "/login.html")
p.fill("#u", user)
p.fill("#p", pwd)
p.click("button[type=submit]")
p.wait_for_url("**/admin.html")
p.wait_for_function("() => location.hash.length > 2", timeout=20000)
p.wait_for_timeout(600)
def api_call(method, path, body=None):
"""在浏览器里带着当前 token 直接打接口,返回 [status, json]。"""
return js("""async ([m, path, body]) => {
const t = localStorage.getItem('rentos_token');
const r = await fetch(path, {method: m,
headers: Object.assign({'Content-Type': 'application/json'},
t ? {Authorization: 'Bearer ' + t} : {}),
body: body === null ? undefined : JSON.stringify(body)});
let d = {}; try { d = await r.json(); } catch (e) {}
return [r.status, d];
}""", [method, path, body])
def ab_state():
st, d = api_call("GET", "/api/tables/rent_parking")
tb = d.get("table") or d
return {r["_id"]: r.get("AB") for r in (tb.get("rows") or [])}
# ============================================ A. 只读访客
print("\nA. 只读访客 guest / guest@2026")
login("guest", "guest@2026")
ck("登录后进入后台", "/admin.html" in p.url)
ck("body 带 role-viewer 标记",
js("() => document.body.classList.contains('role-viewer')"))
ck("顶栏显示「只读访客」徽标", p.is_visible("#roChip"))
ck("页面顶部有只读提示条", p.is_visible("#roBanner"))
ck("用户名后缀标注只读", "只读" in p.inner_text("#who"), p.inner_text("#who"))
ck("概览「立即触发自动发送」已隐藏", not p.is_visible("#btn-fire"))
try:
p.wait_for_function(
"() => { const v = document.querySelector('#ov-batch');"
" return v && v.textContent.trim() !== '–' && v.textContent.trim() !== ''; }",
timeout=30000)
ov_ok = True
except Exception: # noqa: BLE001
ov_ok = False
ck("概览可正常浏览(统计已渲染)", ov_ok, p.inner_text("#ov-batch"))
p.goto(BASE + "/admin.html#/elec")
p.wait_for_selector("#batchList", timeout=20000)
p.wait_for_timeout(800)
ck("电费页「录入本期电费」表单已隐藏",
not p.is_visible("#page-elec .card:first-child"))
ck("电费页批次台账仍可浏览", p.is_visible("#batchList"))
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1200)
ck("台账「编辑模式」开关被禁用", p.eval_on_selector("#tbEdit", "e=>e.disabled"))
ck("台账锁定条提示只读访客", "只读访客" in p.inner_text("#tbLockBar"))
ck("台账「新增一行」已隐藏", not p.is_visible("#btn-tb-add"))
ck("台账「还原备份」已隐藏", not p.is_visible("#btn-tb-restore"))
ck("台账仍可导出 xlsx", p.is_visible("#btn-tb-export"))
ck("台账数据可读(有行)",
p.eval_on_selector_all("#tbWrap tbody tr", "e=>e.length") > 0)
# 打开一个既有批次(复制的数据里有)
st, d = api_call("GET", "/api/batches?kind=rent_parking")
blist = d.get("batches") or []
if blist:
js("(id) => openBatch(id, 'rent_parking')", blist[0]["batch_id"])
p.wait_for_selector("#rpDetailBody table", timeout=20000)
p.wait_for_timeout(600)
DOCS = "#rpDetailBody > .table-wrap"
ck("批次详情可打开(只读也能看)", p.is_visible(DOCS))
ck("收款日期输入框被禁用",
p.eval_on_selector(DOCS + " tbody tr input[data-paid]", "e=>e.disabled"))
ck("「是否开票」勾选框被禁用",
p.eval_on_selector(DOCS + " tbody tr input[data-inv]", "e=>e.disabled"))
ck("单据表「发送」按钮已隐藏",
p.eval_on_selector_all(DOCS + " tbody tr button[data-send]",
"es=>es.every(b=>b.offsetParent===null)"))
ck("单据表「下载」按钮仍可用",
p.eval_on_selector_all(DOCS + " tbody tr button[data-dl]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
ck("「发送全部待发」已隐藏", not p.is_visible("#rp-btn-send-all"))
ck("「按最新配置重出单据」已隐藏", not p.is_visible("#rp-btn-rerender"))
ck("「导出批次数据」仍可见", p.is_visible("#rp-link-export"))
else:
print(" (沙箱里没有房租批次,跳过详情检查)")
p.goto(BASE + "/admin.html#/companies")
p.wait_for_selector("#coList", timeout=20000)
p.wait_for_timeout(900)
ck("公司管理「录入」按钮已隐藏", not p.is_visible("#btn-co-add"))
ck("公司管理「从台账补充」已隐藏", not p.is_visible("#btn-co-sync"))
ck("公司管理名册仍可浏览", p.is_visible("#coList"))
p.goto(BASE + "/admin.html#/settings")
p.wait_for_selector("#planRows", timeout=20000)
p.wait_for_timeout(900)
ck("设置页「保存计划」已隐藏", not p.is_visible("#btn-save-notify"))
ck("设置页「修改密码」整卡已隐藏", not p.is_visible("#np"))
ck("设置页「账号管理」整卡已隐藏", not p.is_visible("#acList"))
print("\nA2. 服务端硬拦截(前端隐藏之外的第二道闸门)")
WRITES = [
("POST", "/api/rent/batches", {"row_ids": []}),
("PUT", "/api/tables/rent_parking/cell",
{"row_id": "rent_parking-0001", "col": "AB", "value": "2099-01", "confirm": True}),
("PUT", "/api/config", {"notify": {"enabled": True}}),
("POST", "/api/batches/20260101-000000/receipt", {"index": 0, "paid_at": "2026-10-13"}),
("POST", "/api/scheduler/fire", {}),
("POST", "/api/accounts", {"username": "hacker", "password": "hacker1"}),
("POST", "/api/password", {"new_password": "hacked123"}),
("DELETE", "/api/tables/rent_parking/row", {"row_id": "x", "confirm": True}),
]
for m, path, body in WRITES:
st, d = api_call(m, path, body)
ck("访客 %s %s → 403" % (m, path), st == 403 and d.get("readonly"),
"status=%s %s" % (st, d.get("error", "")))
st, d = api_call("GET", "/api/accounts")
ck("访客读不到账号清单(403)", st == 403, str(st))
p.screenshot(path=os.path.join(OUT, "round6_guest_readonly.png"), full_page=True)
print(" 截图 → output/ui_shots/round6_guest_readonly.png")
# 退出 → 换管理员
p.click("#btn-logout")
p.wait_for_url("**/login.html")
# ============================================ B. 管理员
print("\nB. 管理员 admin")
before = {}
created = None
try:
login("admin", "1989@rent05")
ck("管理员无 role-viewer 标记",
not js("() => document.body.classList.contains('role-viewer')"))
ck("管理员看不到只读提示条", not p.is_visible("#roBanner"))
ck("管理员可见「立即触发自动发送」", p.is_visible("#btn-fire"))
p.goto(BASE + "/admin.html#/settings")
p.wait_for_selector("#acList table", timeout=60000)
p.wait_for_timeout(400)
ck("设置页有「账号管理」卡片", p.is_visible("#acList"))
acc_txt = p.inner_text("#acList")
ck("账号清单里有 admin 与 guest",
("admin" in acc_txt) and ("guest" in acc_txt), acc_txt.replace("\n", " ")[:140])
ck("guest 标为只读访客", "只读访客" in acc_txt, acc_txt.replace("\n", " ")[:140])
ck("主账号不可删除(无删除按钮)",
p.eval_on_selector_all('#acList tr',
"rows=>rows.filter(r=>/admin/.test(r.innerText))"
".every(r=>!r.querySelector('[data-ac-del]'))"))
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1200)
ck("管理员可见台账「编辑模式」开关", p.is_visible("#tbEdit"))
before = ab_state()
ck("管理员读得到台账", bool(before), "%d 行" % len(before))
# ---- 出一期(1 行);台账已推进到 2027 年,基准日取远期
pv = api_call("GET", "/api/rent/preview?on=2027-12-31")[1]
cand = [r for r in (pv.get("rows") or []) if r.get("billable")]
ck("预览里有可出单行", len(cand) > 0, "%d 行" % len(cand))
if not cand:
raise SystemExit(1)
rid, unit = cand[0]["row_id"], cand[0].get("unit")
print(" 选中:%s(%s)" % (unit, rid))
st, got = api_call("POST", "/api/rent/batches",
{"row_ids": [rid], "on_date": "2027-12-31",
"notice_date": "2027-12-01", "due_date": "2027-12-31"})
ck("出单成功", bool(got.get("ok")), got.get("error", ""))
created = (got.get("batch") or {}).get("batch_id")
if not created:
raise SystemExit(1)
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#rpBatchList", timeout=20000)
p.wait_for_timeout(1200)
js("(id) => openBatch(id, 'rent_parking')", created)
p.wait_for_selector("#rpDetailBody table", timeout=20000)
p.wait_for_timeout(600)
DOCS = "#rpDetailBody > .table-wrap"
# ============================================ C. 收款日期 → 台账同步
print("\nC. 登记收款日期 → 台账「最近收款月份」同步")
p.eval_on_selector(DOCS + " tbody tr input[data-paid]",
"e=>{e.value='2026-12-20';"
" e.dispatchEvent(new Event('change',{bubbles:true}));}")
p.wait_for_timeout(1600)
after = ab_state()
ck("台账 AB 已同步为收款月份 2026-12",
after.get(rid) == "2026-12", "%s → %s" % (before.get(rid), after.get(rid)))
ck("同批次其它行未被误改",
all(after[k] == before[k] for k in before if k != rid))
# 注意:原始明细在折叠的 <details> 里,innerText 取不到 → 读 textContent
raw = js("() => (document.querySelector('#rawDet') || {}).textContent || ''")
ck("批次明细留档里的最近收款月份同步显示",
"2026-12" in raw, raw.replace("\n", " ")[:160])
p.screenshot(path=os.path.join(OUT, "round6_receipt_sync.png"), full_page=True)
print(" 截图 → output/ui_shots/round6_receipt_sync.png")
# 改收款日期 → 跟着走
p.eval_on_selector(DOCS + " tbody tr input[data-paid]",
"e=>{e.value='2027-01-08';"
" e.dispatchEvent(new Event('change',{bubbles:true}));}")
p.wait_for_timeout(1600)
ck("改收款日期后台账跟着走(2027-01)",
ab_state().get(rid) == "2027-01", str(ab_state().get(rid)))
# 清空 → 退回
p.eval_on_selector(DOCS + " tbody tr input[data-paid]",
"e=>{e.value='';"
" e.dispatchEvent(new Event('change',{bubbles:true}));}")
p.wait_for_timeout(1600)
ck("清空收款日期后台账退回原值",
ab_state().get(rid) == before.get(rid),
"%s vs 原值 %s" % (ab_state().get(rid), before.get(rid)))
# 再登记一次,供「删除批次回滚」验证
p.eval_on_selector(DOCS + " tbody tr input[data-paid]",
"e=>{e.value='2026-12-20';"
" e.dispatchEvent(new Event('change',{bubbles:true}));}")
p.wait_for_timeout(1600)
ck("重新登记后台账再次同步",
ab_state().get(rid) == "2026-12", str(ab_state().get(rid)))
finally:
print("\nD. 删除批次 → 账期与收款月份一并回滚")
if created:
st, res = api_call("DELETE", "/api/batches/" + created, {"rollback": True})
ck("批次已删除", bool(res.get("ok")), res.get("error", ""))
final = ab_state()
diff = [k for k in before if before[k] != final.get(k)]
ck("台账 AB/AC/AJ 逐格回滚到跑前", not diff, "差异行 %s" % str(diff[:5]))
p.goto(BASE + "/admin.html#/settings")
p.wait_for_selector("#acList table", timeout=60000)
p.wait_for_timeout(600)
# 账号管理:新增一个只读账号 → 能用 → 删除
p.fill("#ac-name", "viewer_tmp")
p.fill("#ac-pwd", "tmp@2026x")
p.select_option("#ac-role", "viewer")
p.click("#btn-ac-add")
p.wait_for_timeout(1500)
ck("账号管理可新增访客账号", "viewer_tmp" in p.inner_text("#acList"),
p.inner_text("#acList").replace("\n", " ")[:120])
api_call("DELETE", "/api/accounts/viewer_tmp")
p.wait_for_timeout(400)
print("\nE. 页面无 JS 报错")
ck("无 console / pageerror", not errors, errors[:3])
b.close()
finally:
srv.shutdown()
srv.server_close()
shutil.rmtree(tmp, ignore_errors=True)
print("\n" + "=" * 62)
print("通过 %d 项 / 失败 %d 项" % (len(OK), len(FAIL)))
for f in FAIL:
print(" FAIL · " + f)
print("=" * 62)
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())
...@@ -70,6 +70,7 @@ ...@@ -70,6 +70,7 @@
</div> </div>
<div class="right"> <div class="right">
<span class="chip" id="wcMini"><i class="dot"></i><span class="txt">企业微信</span></span> <span class="chip" id="wcMini"><i class="dot"></i><span class="txt">企业微信</span></span>
<span class="chip" id="roChip" style="display:none"><i class="dot"></i><span class="txt">只读访客</span></span>
<div class="user"> <div class="user">
<span class="avatar" id="whoAvatar">–</span> <span class="avatar" id="whoAvatar">–</span>
<span class="uname" id="who">—</span> <span class="uname" id="who">—</span>
...@@ -80,6 +81,11 @@ ...@@ -80,6 +81,11 @@
<main> <main>
<div class="banner ro" id="roBanner" style="display:none">
<b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> ——
出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。
</div>
<!-- ==================== 概览 ==================== --> <!-- ==================== 概览 ==================== -->
<section class="page active" id="page-overview"> <section class="page active" id="page-overview">
<div id="ovBanner"></div> <div id="ovBanner"></div>
...@@ -95,7 +101,7 @@ ...@@ -95,7 +101,7 @@
<h2>定时发送 <span class="hint">两张账单各自排期,到点自动推送</span></h2> <h2>定时发送 <span class="hint">两张账单各自排期,到点自动推送</span></h2>
<div id="ov-scheduler" class="desc">加载中…</div> <div id="ov-scheduler" class="desc">加载中…</div>
<div class="row"> <div class="row">
<div style="flex:0 0 auto"><button id="btn-fire">立即触发一次自动发送</button></div> <div style="flex:0 0 auto"><button id="btn-fire" data-ro>立即触发一次自动发送</button></div>
</div> </div>
</div> </div>
...@@ -117,7 +123,7 @@ ...@@ -117,7 +123,7 @@
<!-- ==================== 电费通知单 ==================== --> <!-- ==================== 电费通知单 ==================== -->
<section class="page" id="page-elec"> <section class="page" id="page-elec">
<div class="card"> <div class="card" data-ro>
<h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2> <h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2>
<div class="desc"> <div class="desc">
计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>; 计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>;
...@@ -181,8 +187,8 @@ ...@@ -181,8 +187,8 @@
<div class="card" id="detailCard" style="display:none"> <div class="card" id="detailCard" style="display:none">
<h2>批次详情 <span class="hint" id="detailTitle"></span></h2> <h2>批次详情 <span class="hint" id="detailTitle"></span></h2>
<div class="row" style="margin-bottom:12px"> <div class="row" style="margin-bottom:12px">
<div style="flex:0 0 auto"><button id="btn-send-all" class="primary">发送全部待发</button></div> <div style="flex:0 0 auto"><button id="btn-send-all" class="primary" data-ro>发送全部待发</button></div>
<div style="flex:0 0 auto"><button id="btn-rerender">按最新配置重出单据</button></div> <div style="flex:0 0 auto"><button id="btn-rerender" data-ro>按最新配置重出单据</button></div>
<div style="flex:0 0 auto"><a id="link-zip"><button>打包下载(zip)</button></a></div> <div style="flex:0 0 auto"><a id="link-zip"><button>打包下载(zip)</button></a></div>
<div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div> <div style="flex:0 0 auto"><a id="link-export"><button>导出批次数据(xlsx)</button></a></div>
</div> </div>
...@@ -214,7 +220,7 @@ ...@@ -214,7 +220,7 @@
</label> </label>
</div> </div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-rp-refresh">刷新预览</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-rp-refresh">刷新预览</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-rp-submit">生成收费通知书</button></div> <div style="flex:0 0 auto" data-ro><label>&nbsp;</label><button class="primary" id="btn-rp-submit">生成收费通知书</button></div>
<div style="flex:0 0 auto" class="muted" id="rpSum"></div> <div style="flex:0 0 auto" class="muted" id="rpSum"></div>
</div> </div>
<div id="rpMsg"></div> <div id="rpMsg"></div>
...@@ -241,8 +247,8 @@ ...@@ -241,8 +247,8 @@
<div class="card" id="rpDetailCard" style="display:none"> <div class="card" id="rpDetailCard" style="display:none">
<h2>批次详情 <span class="hint" id="rpDetailTitle"></span></h2> <h2>批次详情 <span class="hint" id="rpDetailTitle"></span></h2>
<div class="row" style="margin-bottom:12px"> <div class="row" style="margin-bottom:12px">
<div style="flex:0 0 auto"><button id="rp-btn-send-all" class="primary">发送全部待发</button></div> <div style="flex:0 0 auto"><button id="rp-btn-send-all" class="primary" data-ro>发送全部待发</button></div>
<div style="flex:0 0 auto"><button id="rp-btn-rerender">按最新配置重出单据</button></div> <div style="flex:0 0 auto"><button id="rp-btn-rerender" data-ro>按最新配置重出单据</button></div>
<div style="flex:0 0 auto"><a id="rp-link-zip"><button>打包下载(zip)</button></a></div> <div style="flex:0 0 auto"><a id="rp-link-zip"><button>打包下载(zip)</button></a></div>
<div style="flex:0 0 auto"><a id="rp-link-export"><button>导出批次数据(xlsx)</button></a></div> <div style="flex:0 0 auto"><a id="rp-link-export"><button>导出批次数据(xlsx)</button></a></div>
</div> </div>
...@@ -260,14 +266,14 @@ ...@@ -260,14 +266,14 @@
</div> </div>
<div style="flex:0 0 auto"> <div style="flex:0 0 auto">
<label>&nbsp;</label> <label>&nbsp;</label>
<label class="chk lock" id="tbLockWrap" title="默认锁定,防止误改"> <label class="chk lock" id="tbLockWrap" title="默认锁定,防止误改" data-ro>
<input type="checkbox" id="tbEdit"> 编辑模式 <input type="checkbox" id="tbEdit"> 编辑模式
</label> </label>
</div> </div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-tb-add" disabled>+ 新增一行</button></div> <div style="flex:0 0 auto" data-ro><label>&nbsp;</label><button class="primary" id="btn-tb-add" disabled>+ 新增一行</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-tb-undo" disabled>撤销上一次修改</button></div> <div style="flex:0 0 auto" data-ro><label>&nbsp;</label><button id="btn-tb-undo" disabled>撤销上一次修改</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-tb-export">导出 xlsx</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-tb-export">导出 xlsx</button></div>
<div style="flex:0 0 auto"> <div style="flex:0 0 auto" data-ro>
<label>备份</label> <label>备份</label>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<select id="tbBackup" style="min-width:190px"><option value="">(无备份)</option></select> <select id="tbBackup" style="min-width:190px"><option value="">(无备份)</option></select>
...@@ -310,7 +316,7 @@ ...@@ -310,7 +316,7 @@
企业租客与自然人租客一起收录。电费录入的「单位名称」、台账「现租客」列都会用这里做候选, 企业租客与自然人租客一起收录。电费录入的「单位名称」、台账「现租客」列都会用这里做候选,
输入几个字即可联想。<b>不在册的名称仍可手工输入</b>,录完可回这里补一条正式记录。 输入几个字即可联想。<b>不在册的名称仍可手工输入</b>,录完可回这里补一条正式记录。
</div> </div>
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end" data-ro>
<div style="flex:1 1 280px"><label>公司名称 / 姓名 *</label> <div style="flex:1 1 280px"><label>公司名称 / 姓名 *</label>
<input type="text" id="co-name" placeholder="如 杭州逢林科技有限公司"></div> <input type="text" id="co-name" placeholder="如 杭州逢林科技有限公司"></div>
<div style="flex:0 0 120px"><label>类型</label> <div style="flex:0 0 120px"><label>类型</label>
...@@ -325,7 +331,7 @@ ...@@ -325,7 +331,7 @@
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end">
<div style="flex:0 0 280px"><label>搜索</label> <div style="flex:0 0 280px"><label>搜索</label>
<input type="search" id="co-search" placeholder="按名称或简称过滤"></div> <input type="search" id="co-search" placeholder="按名称或简称过滤"></div>
<div style="flex:0 0 auto"><label>&nbsp;</label> <div style="flex:0 0 auto" data-ro><label>&nbsp;</label>
<button id="btn-co-sync" title="把台账「现租客」列里还没入册的名字补进来">从台账补充</button></div> <button id="btn-co-sync" title="把台账「现租客」列里还没入册的名字补进来">从台账补充</button></div>
<div style="flex:0 0 auto" class="muted" id="coSum"></div> <div style="flex:0 0 auto" class="muted" id="coSum"></div>
</div> </div>
...@@ -339,7 +345,7 @@ ...@@ -339,7 +345,7 @@
出租方就是收款主体。台账的「出租方」列用它做归属,通知书底部的「汇款信息」三行也取自它。 出租方就是收款主体。台账的「出租方」列用它做归属,通知书底部的「汇款信息」三行也取自它。
新增后即可在台账与电费录入里选用;<b>改简称会同步改写台账 A 列</b>,不会留下悬空引用。 新增后即可在台账与电费录入里选用;<b>改简称会同步改写台账 A 列</b>,不会留下悬空引用。
</div> </div>
<div class="row" style="align-items:flex-end"> <div class="row" style="align-items:flex-end" data-ro>
<div style="flex:0 0 110px"><label>简称 *</label><input type="text" id="ow-key" placeholder="精锐"></div> <div style="flex:0 0 110px"><label>简称 *</label><input type="text" id="ow-key" placeholder="精锐"></div>
<div style="flex:1 1 230px"><label>公司全称 *</label><input type="text" id="ow-name" placeholder="精锐 (上海) 仓储有限公司"></div> <div style="flex:1 1 230px"><label>公司全称 *</label><input type="text" id="ow-name" placeholder="精锐 (上海) 仓储有限公司"></div>
<div style="flex:1 1 190px"><label>开户银行</label><input type="text" id="ow-bank"></div> <div style="flex:1 1 190px"><label>开户银行</label><input type="text" id="ow-bank"></div>
...@@ -351,7 +357,7 @@ ...@@ -351,7 +357,7 @@
<div class="spacer"></div> <div class="spacer"></div>
<div id="owList"><div class="empty">加载中…</div></div> <div id="owList"><div class="empty">加载中…</div></div>
<div class="spacer"></div> <div class="spacer"></div>
<button id="btn-ow-reset" title="把出租方名单恢复成模板默认的三大主体">恢复模板默认三家</button> <button id="btn-ow-reset" title="把出租方名单恢复成模板默认的三大主体" data-ro>恢复模板默认三家</button>
</div> </div>
</section> </section>
...@@ -372,7 +378,7 @@ ...@@ -372,7 +378,7 @@
<label>发送间隔(秒)</label> <label>发送间隔(秒)</label>
<input type="number" id="st-interval" min="0" max="60" step="1"> <input type="number" id="st-interval" min="0" max="60" step="1">
</div> </div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-save-notify">保存计划</button></div> <div style="flex:0 0 auto" data-ro><label>&nbsp;</label><button class="primary" id="btn-save-notify">保存计划</button></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-reload-plan">刷新状态</button></div> <div style="flex:0 0 auto"><label>&nbsp;</label><button id="btn-reload-plan">刷新状态</button></div>
</div> </div>
...@@ -415,8 +421,8 @@ ...@@ -415,8 +421,8 @@
<option value="markdown">只发摘要文字</option> <option value="markdown">只发摘要文字</option>
</select> </select>
</div> </div>
<div style="flex:0 0 auto"><button class="primary" id="btn-save-wecom">保存</button></div> <div style="flex:0 0 auto" data-ro><button class="primary" id="btn-save-wecom">保存</button></div>
<div style="flex:0 0 auto"><button id="btn-test-wecom">发送测试消息</button></div> <div style="flex:0 0 auto" data-ro><button id="btn-test-wecom">发送测试消息</button></div>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div id="wecomHint" class="muted" style="font-size:12px"></div> <div id="wecomHint" class="muted" style="font-size:12px"></div>
...@@ -431,13 +437,37 @@ ...@@ -431,13 +437,37 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card" data-ro>
<h2>修改密码</h2> <h2>修改密码 <span class="hint">仅改管理员自己的登录密码</span></h2>
<div class="row"> <div class="row">
<div><label>新密码(至少 6 位)</label><input type="password" id="np" autocomplete="new-password"></div> <div><label>新密码(至少 6 位)</label><input type="password" id="np" autocomplete="new-password"></div>
<div style="flex:0 0 auto"><button id="btn-save-pwd">更新密码</button></div> <div style="flex:0 0 auto"><button id="btn-save-pwd">更新密码</button></div>
</div> </div>
</div> </div>
<div class="card" data-ro>
<h2>账号管理 <span class="hint">管理员可读写;访客只能浏览</span></h2>
<div class="desc">
<b>角色</b>:<b>管理员</b>可出单、发送、改台账与配置;<b>只读访客</b>只能浏览,
所有写接口在服务端一律返回 403(前端隐藏按钮只是为了不误导)。
访客账号(<span class="mono-sm">guest</span>)用于给财务 / 同事查看台账与批次,密码可随时在此重置。
</div>
<div class="row" style="align-items:flex-end">
<div style="flex:0 0 160px"><label>账号 *</label><input type="text" id="ac-name" placeholder="如 guest2"></div>
<div style="flex:0 0 180px"><label>密码(至少 6 位)*</label>
<input type="text" id="ac-pwd" placeholder="初始密码"></div>
<div style="flex:0 0 150px"><label>角色</label>
<select id="ac-role">
<option value="viewer">只读访客</option>
<option value="admin">管理员</option>
</select></div>
<div style="flex:1 1 180px"><label>备注(可选)</label><input type="text" id="ac-note" placeholder="给谁用 / 用途"></div>
<div style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-ac-add">+ 新增账号</button></div>
</div>
<div id="acMsg"></div>
<div class="spacer"></div>
<div id="acList"><div class="empty">加载中…</div></div>
</div>
</section> </section>
</main> </main>
......
...@@ -7,9 +7,11 @@ let CFG = null; // 当前配置 ...@@ -7,9 +7,11 @@ let CFG = null; // 当前配置
let DIR = null; // 公司目录快照(租客名册 + 出租方 · 汇款公司名单) let DIR = null; // 公司目录快照(租客名册 + 出租方 · 汇款公司名单)
let BATCH = null; // 当前打开的批次 let BATCH = null; // 当前打开的批次
let SESSIONS = []; // 企微可发送会话 let SESSIONS = []; // 企微可发送会话
let ROLE = 'admin'; // 当前角色:admin 可读写 | viewer 只读访客
/* ---------------- 基础 ---------------- */ /* ---------------- 基础 ---------------- */
function token() { return localStorage.getItem(TOKEN_KEY) || ''; } function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
function isViewer() { return ROLE === 'viewer'; }
function toast(msg, kind) { function toast(msg, kind) {
const t = $('#toast'); const t = $('#toast');
...@@ -21,6 +23,12 @@ function toast(msg, kind) { ...@@ -21,6 +23,12 @@ function toast(msg, kind) {
async function api(path, opts = {}) { async function api(path, opts = {}) {
const o = Object.assign({ headers: {} }, opts); const o = Object.assign({ headers: {} }, opts);
const method = String(o.method || 'GET').toUpperCase();
// 只读访客:前端先拦一道(服务端另有 403 硬拦截,这里只是给个清楚提示)
if (isViewer() && method !== 'GET' && !path.startsWith('/api/logout')) {
toast('只读访客账号:无修改权限', 'err');
throw new Error('只读访客账号:无修改权限');
}
o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers); o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers);
if (token()) o.headers['Authorization'] = 'Bearer ' + token(); if (token()) o.headers['Authorization'] = 'Bearer ' + token();
if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body); if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body);
...@@ -961,7 +969,7 @@ function renderBatchTable(list, withAction) { ...@@ -961,7 +969,7 @@ function renderBatchTable(list, withAction) {
<td class="mono-sm">${esc(b.notice_date || '')}</td> <td class="mono-sm">${esc(b.notice_date || '')}</td>
<td>${stateTag(b.send_state)}</td> <td>${stateTag(b.send_state)}</td>
${withAction ? `<td><button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button> ${withAction ? `<td><button class="sm ghost" data-open="${esc(b.batch_id)}">详情</button>
<button class="sm danger" data-del="${esc(b.batch_id)}">删除</button></td>` : ''} <button class="sm danger" data-del="${esc(b.batch_id)}" data-ro>删除</button></td>` : ''}
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`; </tbody></table></div>`;
} }
...@@ -1004,11 +1012,11 @@ function docRowsHtml(docs) { ...@@ -1004,11 +1012,11 @@ function docRowsHtml(docs) {
<td class="num">¥${fmt(d.total_amount)}</td> <td class="num">¥${fmt(d.total_amount)}</td>
${rent ? `<td class="rc-cell"> ${rent ? `<td class="rc-cell">
<input type="date" class="rc-ipt" data-paid="${i}" value="${esc(d.paid_at || '')}" <input type="date" class="rc-ipt" data-paid="${i}" value="${esc(d.paid_at || '')}"
title="登记收款日期"> title="登记收款日期(会同步台账「最近收款月份」)"${isViewer() ? ' disabled' : ''}>
</td> </td>
<td class="rc-cell"> <td class="rc-cell">
<label class="chk" title="勾选后自动记录开票日期"> <label class="chk" title="勾选后自动记录开票日期">
<input type="checkbox" data-inv="${i}"${d.invoiced ? ' checked' : ''}> 已开票 <input type="checkbox" data-inv="${i}"${d.invoiced ? ' checked' : ''}${isViewer() ? ' disabled' : ''}> 已开票
</label> </label>
<div class="mono-sm muted" data-invdate="${i}">${d.invoiced_at ? '开票 ' + esc(d.invoiced_at) : ''}</div> <div class="mono-sm muted" data-invdate="${i}">${d.invoiced_at ? '开票 ' + esc(d.invoiced_at) : ''}</div>
</td>` : ''} </td>` : ''}
...@@ -1017,9 +1025,9 @@ function docRowsHtml(docs) { ...@@ -1017,9 +1025,9 @@ function docRowsHtml(docs) {
${(d.rentfree || []).length ? `<br><span class="tag skip">免租期 ${esc((d.rentfree || []).map(r => r.unit).join('、'))}</span>` : ''} ${(d.rentfree || []).length ? `<br><span class="tag skip">免租期 ${esc((d.rentfree || []).map(r => r.unit).join('、'))}</span>` : ''}
${d.send_error ? `<br><span class="muted mono-sm">${esc(d.send_error)}</span>` : ''}</td> ${d.send_error ? `<br><span class="muted mono-sm">${esc(d.send_error)}</span>` : ''}</td>
<td> <td>
<button class="sm primary" data-send="${i}">发送</button> <button class="sm primary" data-send="${i}" data-ro>发送</button>
<button class="sm" data-dl="${i}">下载</button> <button class="sm" data-dl="${i}">下载</button>
${d.send_state === '已跳过' ? `<button class="sm" data-force="${i}" title="忽略免租期规则,强制发送">强制发送</button>` : ''} ${d.send_state === '已跳过' ? `<button class="sm" data-force="${i}" data-ro title="忽略免租期规则,强制发送">强制发送</button>` : ''}
</td> </td>
</tr>`).join(''); </tr>`).join('');
} }
...@@ -1045,7 +1053,7 @@ function refreshReceiptBar() { ...@@ -1045,7 +1053,7 @@ function refreshReceiptBar() {
box.innerHTML = receiptBarHtml(); box.innerHTML = receiptBarHtml();
} }
/** 登记某份单据的收款日期 / 是否开票 */ /** 登记某份单据的收款日期 / 是否开票(房租批次会同步台账「最近收款月份」) */
async function setReceipt(i, payload) { async function setReceipt(i, payload) {
try { try {
const d = await api(`/api/batches/${BATCH.batch_id}/receipt`, const d = await api(`/api/batches/${BATCH.batch_id}/receipt`,
...@@ -1058,10 +1066,24 @@ async function setReceipt(i, payload) { ...@@ -1058,10 +1066,24 @@ async function setReceipt(i, payload) {
if (cell) cell.textContent = doc.invoiced_at ? '开票 ' + doc.invoiced_at : ''; if (cell) cell.textContent = doc.invoiced_at ? '开票 ' + doc.invoiced_at : '';
const paid = document.querySelector(`[data-paid="${i}"]`); const paid = document.querySelector(`[data-paid="${i}"]`);
if (paid && payload.paid_at !== undefined) paid.value = doc.paid_at || ''; if (paid && payload.paid_at !== undefined) paid.value = doc.paid_at || '';
toast('已登记', 'ok'); // 台账「最近收款月份」可能已被同步 → 明细留档块跟着重绘
const rd = document.getElementById('rawDet');
if (rd) rd.innerHTML = rawDetailHtml(BATCH.details || []);
toast(syncMsg(d.sync), d.sync && d.sync.error ? 'err' : 'ok');
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
} }
/** 收款登记 → 台账同步的提示文案 */
function syncMsg(s) {
if (!s) return '已登记';
if (s.error) return '已登记,但台账「最近收款月份」同步失败:' + s.error;
if (s.reverted) return `已清空收款,台账「最近收款月份」退回 ${s.reverted} 行`;
if (s.month && s.updated) {
return `已登记收款,台账「最近收款月份」同步为 ${s.month}(${s.updated} 行)`;
}
return '已登记';
}
/** 原始明细(留档内容) */ /** 原始明细(留档内容) */
function rawDetailHtml(det) { function rawDetailHtml(det) {
if (BKIND === 'rent_parking') { if (BKIND === 'rent_parking') {
...@@ -1134,7 +1156,7 @@ function renderDetail() { ...@@ -1134,7 +1156,7 @@ function renderDetail() {
<details> <details>
<summary style="cursor:pointer;color:var(--text-2)">查看原始明细(${det.length} 行,留档于 JSON)</summary> <summary style="cursor:pointer;color:var(--text-2)">查看原始明细(${det.length} 行,留档于 JSON)</summary>
<div class="spacer"></div> <div class="spacer"></div>
${rawDetailHtml(det)} <div id="rawDet">${rawDetailHtml(det)}</div>
</details>`; </details>`;
const root = $(C0.body); const root = $(C0.body);
...@@ -1459,9 +1481,113 @@ async function loadSettings() { ...@@ -1459,9 +1481,113 @@ async function loadSettings() {
? `机器人「${esc(id.bot_name || '')}」最近有 ${SESSIONS.length} 个可发送会话。` ? `机器人「${esc(id.bot_name || '')}」最近有 ${SESSIONS.length} 个可发送会话。`
: `机器人「${esc(id.bot_name || '')}」当前<b>没有最近往来的会话</b>(sessions_count=0), : `机器人「${esc(id.bot_name || '')}」当前<b>没有最近往来的会话</b>(sessions_count=0),
只能发送给授权人本人。要发给租户群,先把机器人拉进群并产生一条消息,再回到这里选择。`; 只能发送给授权人本人。要发给租户群,先把机器人拉进群并产生一条消息,再回到这里选择。`;
if (!isViewer()) loadAccounts(); // 账号管理只给管理员看(服务端同样只放管理员)
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
} }
/* ---------------- 账号管理(管理员) ---------------- */
let ACCOUNTS = [];
async function loadAccounts() {
if (isViewer()) return;
const box = $('#acList');
if (!box) return;
try {
const d = await api('/api/accounts');
ACCOUNTS = d.accounts || [];
renderAccounts();
} catch (e) {
box.innerHTML = `<div class="empty">${esc(e.message)}</div>`;
}
}
function roleTag(r) {
return r === 'admin' ? '<span class="tag warn">管理员</span>'
: '<span class="tag info">只读访客</span>';
}
function renderAccounts() {
const box = $('#acList');
if (!box) return;
if (!ACCOUNTS.length) { box.innerHTML = '<div class="empty">没有账号</div>'; return; }
box.innerHTML = `<div class="table-wrap"><table>
<thead><tr><th>账号</th><th style="width:100px">角色</th><th>备注</th>
<th style="width:160px">创建时间</th><th style="width:230px"></th></tr></thead>
<tbody>${ACCOUNTS.map(a => `
<tr>
<td class="mono-sm">${esc(a.username)}${a.primary
? ' <span class="tag info">主账号</span>' : ''}</td>
<td>${roleTag(a.role)}</td>
<td class="muted">${esc(a.note || '')}</td>
<td class="mono-sm muted">${esc(a.created_at || '')}</td>
<td>
<button class="sm" data-ac-pwd="${esc(a.username)}">重置密码</button>
${a.primary ? ''
: `<button class="sm danger" data-ac-del="${esc(a.username)}">删除</button>`}
</td>
</tr>`).join('')}
</tbody></table></div>`;
$$('[data-ac-pwd]').forEach(b =>
b.addEventListener('click', () => resetAccountPwd(b.dataset.acPwd)));
$$('[data-ac-del]').forEach(b =>
b.addEventListener('click', () => delAccount(b.dataset.acDel)));
}
function acMsg(html, kind) {
const el = $('#acMsg');
if (el) el.innerHTML = `<div class="banner ${kind || ''}" style="margin-top:12px">${html}</div>`;
}
async function resetAccountPwd(name) {
const np = prompt(`为「${name}」设置新密码(至少 6 位)`);
if (np === null) return;
if (np.length < 6) return acMsg('新密码至少 6 位。', 'err');
try {
await api(`/api/accounts/${encodeURIComponent(name)}/password`,
{ method: 'POST', body: { new_password: np } });
acMsg(`账号「${esc(name)}」的密码已重置。`, 'ok');
toast('密码已重置', 'ok');
} catch (e) { acMsg(esc(e.message), 'err'); }
}
async function delAccount(name) {
const ok = await confirmBox({
title: '删除账号',
rows: [{ k: '账号', v: esc(name) }],
warn: '删除后该账号无法再登录(已登录的会话在过期前仍有效,最长 12 小时)。',
okText: '确认删除',
danger: true,
});
if (!ok) return;
try {
const d = await api(`/api/accounts/${encodeURIComponent(name)}`, { method: 'DELETE' });
ACCOUNTS = d.accounts || [];
renderAccounts();
acMsg(`已删除账号「${esc(name)}」。`, 'ok');
} catch (e) { acMsg(esc(e.message), 'err'); }
}
$('#btn-ac-add').addEventListener('click', async () => {
const name = $('#ac-name').value.trim();
const pwd = $('#ac-pwd').value;
const role = $('#ac-role').value;
if (!name) return acMsg('请填写账号。', 'err');
if (pwd.length < 6) return acMsg('密码至少 6 位。', 'err');
try {
const d = await api('/api/accounts', {
method: 'POST',
body: { username: name, password: pwd, role,
note: $('#ac-note').value.trim() },
});
ACCOUNTS = d.accounts || [];
renderAccounts();
$('#ac-name').value = ''; $('#ac-pwd').value = ''; $('#ac-note').value = '';
acMsg(`已新增${role === 'admin' ? '管理员' : '只读访客'}「${esc(name)}」。`, 'ok');
toast('账号已新增', 'ok');
} catch (e) { acMsg(esc(e.message), 'err'); }
});
let PLANS = []; // 两张账单的计划(含运行状态) let PLANS = []; // 两张账单的计划(含运行状态)
function renderPlans(list) { function renderPlans(list) {
...@@ -1600,7 +1726,7 @@ function coViewRow(c) { ...@@ -1600,7 +1726,7 @@ function coViewRow(c) {
<td class="mono-sm">${esc(c.short || '')}</td> <td class="mono-sm">${esc(c.short || '')}</td>
<td class="muted">${esc(c.note || '')}</td> <td class="muted">${esc(c.note || '')}</td>
<td class="num">${c.units || 0}</td> <td class="num">${c.units || 0}</td>
<td> <td data-ro>
<button class="sm ghost" data-co-edit="${esc(c.name)}">编辑</button> <button class="sm ghost" data-co-edit="${esc(c.name)}">编辑</button>
<button class="sm danger" data-co-del="${esc(c.name)}">删除</button> <button class="sm danger" data-co-del="${esc(c.name)}">删除</button>
</td></tr>`; </td></tr>`;
...@@ -1722,7 +1848,7 @@ function owViewRow(o) { ...@@ -1722,7 +1848,7 @@ function owViewRow(o) {
<td class="mono-sm">${esc(o.acct) || miss}</td> <td class="mono-sm">${esc(o.acct) || miss}</td>
<td class="muted">${esc(o.address)}</td> <td class="muted">${esc(o.address)}</td>
<td class="num">${o.units || 0}</td> <td class="num">${o.units || 0}</td>
<td> <td data-ro>
<button class="sm ghost" data-ow-edit="${esc(o.key)}">编辑</button> <button class="sm ghost" data-ow-edit="${esc(o.key)}">编辑</button>
<button class="sm danger" data-ow-del="${esc(o.key)}">删除</button> <button class="sm danger" data-ow-del="${esc(o.key)}">删除</button>
</td></tr>`; </td></tr>`;
...@@ -2183,6 +2309,14 @@ function bindGridEvents() { ...@@ -2183,6 +2309,14 @@ function bindGridEvents() {
/* ---------------- 编辑锁 ---------------- */ /* ---------------- 编辑锁 ---------------- */
function renderLockBar() { function renderLockBar() {
$('#tbEdit').checked = TB_EDIT; $('#tbEdit').checked = TB_EDIT;
if (isViewer()) {
$('#tbLockBar').innerHTML = `<div class="lockbar"><i class="dot"></i>
<span><b>只读访客账号</b>:台账可以浏览与导出,但不能修改。
登记收款 / 出单 / 改单元格等写操作在服务端一律被拒绝。</span></div>`;
$('#btn-tb-add').disabled = true;
updateUndoBtn();
return;
}
$('#tbLockBar').innerHTML = TB_EDIT $('#tbLockBar').innerHTML = TB_EDIT
? `<div class="lockbar on"><i class="dot"></i> ? `<div class="lockbar on"><i class="dot"></i>
<span><b>编辑模式已开启</b>:单击单元格可修改;每次保存前都会弹出确认框(行 / 列 / 旧值 / 新值), <span><b>编辑模式已开启</b>:单击单元格可修改;每次保存前都会弹出确认框(行 / 列 / 旧值 / 新值),
...@@ -2424,6 +2558,12 @@ $('#tbSearch').addEventListener('input', e => { ...@@ -2424,6 +2558,12 @@ $('#tbSearch').addEventListener('input', e => {
}); });
$('#tbEdit').addEventListener('change', e => { $('#tbEdit').addEventListener('change', e => {
if (isViewer()) {
e.target.checked = false;
TB_EDIT = false;
renderLockBar();
return toast('只读访客账号:无修改权限', 'err');
}
TB_EDIT = e.target.checked; TB_EDIT = e.target.checked;
renderLockBar(); renderLockBar();
if (TB) renderGrid(); if (TB) renderGrid();
...@@ -2489,15 +2629,38 @@ $('#btn-tb-restore').addEventListener('click', async () => { ...@@ -2489,15 +2629,38 @@ $('#btn-tb-restore').addEventListener('click', async () => {
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
}); });
/* ---------------- 角色(管理员 / 只读访客) ---------------- */
/** 只读访客:把写操作入口从界面上拿掉 —— 服务端另有 403 硬拦截兜底。 */
function applyRole() {
const viewer = isViewer();
document.body.classList.toggle('role-viewer', viewer);
const chip = $('#roChip');
if (chip) chip.style.display = viewer ? '' : 'none';
const ban = $('#roBanner');
if (ban) ban.style.display = viewer ? '' : 'none';
if (viewer) {
TB_EDIT = false; // 台账强制只读
const t = $('#tbEdit');
if (t) { t.checked = false; t.disabled = true; }
}
}
function viewerSuffix(name) {
return isViewer() ? (name + '(只读)') : name;
}
/* ---------------- 启动 ---------------- */ /* ---------------- 启动 ---------------- */
(async function boot() { (async function boot() {
if (!token()) { location.href = '/login.html'; return; } if (!token()) { location.href = '/login.html'; return; }
try { try {
const s = await api('/api/session'); const s = await api('/api/session');
if (!s.logged_in) { location.href = '/login.html'; return; } if (!s.logged_in) { location.href = '/login.html'; return; }
ROLE = s.role || 'admin';
applyRole();
const name = s.username || ''; const name = s.username || '';
$('#who').textContent = name; $('#who').textContent = viewerSuffix(name);
$('#whoAvatar').textContent = (name[0] || '–').toUpperCase(); $('#whoAvatar').textContent = (name[0] || '–').toUpperCase();
if (isViewer()) toast('已以只读访客身份登录:只能浏览,不能修改', '');
renderElecRows(); renderElecRows();
loadUnits(); loadUnits();
loadElecDefaults(); loadElecDefaults();
......
...@@ -421,6 +421,12 @@ td.mono { font-variant-numeric: tabular-nums; } ...@@ -421,6 +421,12 @@ td.mono { font-variant-numeric: tabular-nums; }
.banner.warn { background: #fffbeb; border-color: #fde68a; color: #92400e; } .banner.warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.banner.ok { background: #f0fdf4; border-color: #bbf7d0; color: #166534; } .banner.ok { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.banner.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; } .banner.err { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
.banner.ro { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); }
/* ---- 只读访客:隐藏一切写操作入口(服务端另有硬拦截,这里只是不误导人) ---- */
body.role-viewer [data-ro] { display: none !important; }
#roChip { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); }
#roChip .dot { background: var(--brand); }
.muted { color: var(--text-2); } .muted { color: var(--text-2); }
.mono-sm { font-size: 12px; font-variant-numeric: tabular-nums; } .mono-sm { font-size: 12px; font-variant-numeric: tabular-nums; }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment