Commit 35f0fbce authored by wenwen.tang's avatar wenwen.tang 😕

feat(权限): 访客账号改按页授权 —— guest 仅「电费通知单」可操作

能力模型:账号/会话新增 perms(目前只有 elec = 电费通知单)。管理员隐含全部能力;访客按 perms 逐接口放行,perms 为空即纯只读。

写闸门重构:server._write_cap(method,path) 逐接口解析所需能力,_guard_write(user,cap) 按能力判定并给出「账号 X 只能操作:电费通知单」的明确提示。批次类接口再按批次 kind 复核 —— 房租车位费批次的删/重出/发送/收款登记仍只有管理员能动。

前端能力感知:body 加 cap-elec 类,电费页控件由 data-ro 改 data-cap=elec;批次列表/单据表的按钮按 kind 区分;api() 前端拦截改为按能力;顶栏与只读横幅按能力显示「访客 · 仅电费」。

账号管理:新增「权限」列(管理员=全部 / 访客=能力标签 / 空=只读),新增账号可勾选「电费通知单」,已有访客可一键开通/关闭;新增 PUT /api/accounts/{name} 改角色/能力/备注;主账号不可降级且强制清空 perms。

一次性回填:老配置没有 perms 字段 → ensure_default_account 按 guest_perms_seeded 标记补上默认能力(之后管理员关掉不会再被打开)。

guest 落地:密码 guest@2026,perms=['elec'],备注「电费通知单操作员」(本地已改,线上部署后同步)。

修 bug:删除不存在的批次时 service.rollback_receipt_sync(None) 抛 AttributeError → 500;现静默返回 0。

验证:单测 245 项全绿(accounts 60);新增 ui_verify10(69 项,guest/admin/纯只读三方对照)全绿;ui_verify7(访客,改成用纯只读样本 + 新增 guest 段)69/0;ui_verify2 72/0;ui_verify 全绿。
parent 61436563
...@@ -14,14 +14,18 @@ ...@@ -14,14 +14,18 @@
}, },
{ {
"username": "guest", "username": "guest",
"salt": "d232110691983659", "salt": "0a0fee8da62f24c2",
"password_hash": "a588e529a46faffc0fcd660b9a1ee5546297fbbb48ecfb979e8a7199e48750f9", "password_hash": "fd9b38792c10f0af6f70e954124a9c4e9925ea5da69fb67b515fa973cbdd02e3",
"role": "viewer", "role": "viewer",
"note": "只读访客", "note": "电费通知单操作员",
"created_at": "2026-10-13 11:55:31" "created_at": "2026-10-13 11:55:31",
"perms": [
"elec"
]
} }
], ],
"guest_seeded": true "guest_seeded": true,
"guest_perms_seeded": true
}, },
"notify": { "notify": {
"enabled": true, "enabled": true,
......
This diff is collapsed.
...@@ -47,12 +47,15 @@ DEFAULT_CONFIG = { ...@@ -47,12 +47,15 @@ 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" 只读访客 # 多账号 + 角色。role: "admin" 可读写 | "viewer" 访客(写权限由 perms 逐项授予)
# [{"username","salt","password_hash","role","note","created_at"}] # [{"username","salt","password_hash","role","perms","note","created_at"}]
# 首次启动会播种 admin(1989@rent05)与 guest(guest@2026,只读) # perms 目前只有 "elec"(电费通知单可操作);空 = 纯只读
# 首次启动会播种 admin(1989@rent05)与 guest(guest@2026,电费通知单操作员)
"accounts": [], "accounts": [],
# 访客账号只播种一次;管理员删掉后不再自动复活 # 访客账号只播种一次;管理员删掉后不再自动复活
"guest_seeded": False, "guest_seeded": False,
# 访客「能力」也只回填一次(老配置没有 perms 字段时补默认值)
"guest_perms_seeded": False,
}, },
"notify": { "notify": {
# 定时发送总开关;关闭后两张账单都不自动发送,只能手动发 # 定时发送总开关;关闭后两张账单都不自动发送,只能手动发
......
...@@ -7,11 +7,14 @@ Python 标准库 http.server,零外部依赖。启动: ...@@ -7,11 +7,14 @@ 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(含 role:admin 可读写 / viewer 只读) 权限:role="admin" 全量;role="viewer" 由 perms 逐项授权
(perms=["elec"] → 只放行「电费通知单」页的写操作,其余写接口一律 403)
POST /api/login 账密登录 → token(含 role / perms)
POST /api/logout 退出 POST /api/logout 退出
GET /api/session 当前会话(含 role / can_write) GET /api/session 当前会话(含 role / perms / cap_labels / can_write)
GET /api/accounts 账号清单(仅管理员) GET /api/accounts 账号清单(仅管理员)
POST /api/accounts 新增账号(仅管理员) POST /api/accounts 新增账号(仅管理员,可带 perms)
PUT /api/accounts/{username} 改角色 / 能力 / 备注(仅管理员)
DELETE /api/accounts/{username} 删除账号(仅管理员;主账号不可删) DELETE /api/accounts/{username} 删除账号(仅管理员;主账号不可删)
POST /api/accounts/{username}/password 重置指定账号密码(仅管理员) POST /api/accounts/{username}/password 重置指定账号密码(仅管理员)
GET /api/overview 看板 GET /api/overview 看板
...@@ -148,6 +151,14 @@ class Handler(BaseHTTPRequestHandler): ...@@ -148,6 +151,14 @@ class Handler(BaseHTTPRequestHandler):
return "" return ""
return auth.normalize_role(user.get("role") or auth.ROLE_ADMIN) return auth.normalize_role(user.get("role") or auth.ROLE_ADMIN)
@staticmethod
def _caps(user):
"""会话能力集合(管理员 → {"*"})。"""
return auth.caps(user)
def _can(self, user, cap):
return auth.can(user, cap)
def _guard(self): def _guard(self):
u = self._user() u = self._user()
if not u: if not u:
...@@ -155,12 +166,43 @@ class Handler(BaseHTTPRequestHandler): ...@@ -155,12 +166,43 @@ class Handler(BaseHTTPRequestHandler):
return None return None
return u return u
def _guard_write(self, user): @staticmethod
"""写操作闸门:只读访客一律拒绝(服务端硬拦截,不依赖前端隐藏按钮)。""" def _batch_kind(bid):
if self._role(user) == auth.ROLE_ADMIN: """批次的业务类型(读不到按电费处理)。"""
try:
b = store.load_batch(bid) or {}
except Exception: # noqa: BLE001
return "elec"
return str(b.get("kind") or "elec")
def _write_cap(self, method, path):
"""写接口 → 所需能力(auth.CAP_* 或 "*" 表示仅管理员)。
只有「电费通知单」页的写操作授予 auth.CAP_ELEC;
批次类接口再按批次 kind 复核一遍 —— 房租车位费的批次仍只有管理员能删 / 重出 / 发送。
"""
if method == "POST" and path in ("/api/batches", "/api/attachments"):
return auth.CAP_ELEC
m = re.match(r"^/api/attachments/([A-Za-z0-9]+)$", path)
if m and method == "DELETE":
return auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)/(rerender|send)$", path)
if m and method == "POST":
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
m = re.match(r"^/api/batches/([^/]+)$", path)
if m and method == "DELETE":
return "*" if self._batch_kind(m.group(1)) == service.RENT_KEY else auth.CAP_ELEC
return "*"
def _guard_write(self, user, cap="*"):
"""写操作闸门:管理员一律放行;访客只有命中该接口所需能力才放行。"""
if self._can(user, cap or "*"):
return True return True
self._send(403, {"ok": False, "readonly": True, who = (user or {}).get("username") or "访客"
"error": "只读访客账号:仅可浏览,无修改权限"}) owned = auth.cap_labels(sorted(self._caps(user) - {auth.CAP_ALL}))
msg = ("账号「%s」只能操作:%s" % (who, "、".join(owned))) if owned \
else "只读账号:仅可浏览,无修改权限"
self._send(403, {"ok": False, "readonly": True, "error": msg})
return False return False
_CTYPES = { _CTYPES = {
...@@ -235,9 +277,12 @@ class Handler(BaseHTTPRequestHandler): ...@@ -235,9 +277,12 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, {"ok": True, "app": "rentos", "version": "0.1.0"}) return self._send(200, {"ok": True, "app": "rentos", "version": "0.1.0"})
if path == "/api/session": if path == "/api/session":
u = self._user() u = self._user()
cs = sorted(self._caps(u) - {auth.CAP_ALL})
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), "role": self._role(u),
"perms": cs,
"cap_labels": auth.cap_labels(cs),
"can_write": self._role(u) == auth.ROLE_ADMIN}) "can_write": self._role(u) == auth.ROLE_ADMIN})
# ---- 需登录 # ---- 需登录
...@@ -441,9 +486,11 @@ class Handler(BaseHTTPRequestHandler): ...@@ -441,9 +486,11 @@ class Handler(BaseHTTPRequestHandler):
if not acc: if not acc:
return self._send(401, {"ok": False, "error": "账号或密码不正确"}) return self._send(401, {"ok": False, "error": "账号或密码不正确"})
role = acc.get("role") or auth.ROLE_ADMIN role = acc.get("role") or auth.ROLE_ADMIN
token = auth.create_session(acc["username"], role) perms = acc.get("perms") or []
token = auth.create_session(acc["username"], role, perms=perms)
return self._send(200, {"ok": True, "token": token, return self._send(200, {"ok": True, "token": token,
"username": acc["username"], "role": role, "username": acc["username"], "role": role,
"perms": list(perms),
"can_write": role == auth.ROLE_ADMIN}) "can_write": role == auth.ROLE_ADMIN})
user = self._guard() user = self._guard()
...@@ -454,8 +501,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -454,8 +501,8 @@ 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): if not self._guard_write(user, self._write_cap(method, path)):
return return
if path == "/api/password" and method == "POST": if path == "/api/password" and method == "POST":
...@@ -470,10 +517,25 @@ class Handler(BaseHTTPRequestHandler): ...@@ -470,10 +517,25 @@ class Handler(BaseHTTPRequestHandler):
row = auth.add_account(str(body.get("username") or ""), row = auth.add_account(str(body.get("username") or ""),
str(body.get("password") or ""), str(body.get("password") or ""),
role=str(body.get("role") or auth.ROLE_VIEWER), role=str(body.get("role") or auth.ROLE_VIEWER),
note=body.get("note") or "") note=body.get("note") or "",
perms=body.get("perms"))
return self._send(200, {"ok": True,
"account": {"username": row["username"],
"role": row["role"],
"perms": row.get("perms") or [],
"note": row.get("note") or ""},
"accounts": auth.public_accounts()})
m = re.match(r"^/api/accounts/([^/]+)$", path)
if m and method in ("PUT", "POST"):
row = auth.set_account(m.group(1),
role=body.get("role"),
perms=body.get("perms"),
note=body.get("note"))
return self._send(200, {"ok": True, return self._send(200, {"ok": True,
"account": {"username": row["username"], "account": {"username": row["username"],
"role": row["role"], "role": row["role"],
"perms": row.get("perms") or [],
"note": row.get("note") or ""}, "note": row.get("note") or ""},
"accounts": auth.public_accounts()}) "accounts": auth.public_accounts()})
......
...@@ -578,6 +578,8 @@ def _month_of(s): ...@@ -578,6 +578,8 @@ def _month_of(s):
def _sync_records(batch): def _sync_records(batch):
if not isinstance(batch, dict): # 批次不存在 / 参数为空 → 当作没有记录
return []
rec = batch.get("receipt_sync") rec = batch.get("receipt_sync")
return rec if isinstance(rec, list) else [] return rec if isinstance(rec, list) else []
...@@ -641,7 +643,10 @@ def sync_receipt_month(batch, index, doc): ...@@ -641,7 +643,10 @@ def sync_receipt_month(batch, index, doc):
def rollback_receipt_sync(batch): def rollback_receipt_sync(batch):
"""把该批次登记收款时同步过的「最近收款月份」退回原值(删除批次时调用)。""" """把该批次登记收款时同步过的「最近收款月份」退回原值(删除批次时调用)。
批次不存在(已删 / 传空)时静默返回 0,不抛错。
"""
recs = _sync_records(batch) recs = _sync_records(batch)
if not recs: if not recs:
return {"reverted": 0, "skipped": 0} return {"reverted": 0, "skipped": 0}
......
This diff is collapsed.
This diff is collapsed.
...@@ -28,6 +28,7 @@ sys.path.insert(0, ROOT) ...@@ -28,6 +28,7 @@ sys.path.insert(0, ROOT)
os.environ["no_proxy"] = "*" os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*" os.environ["NO_PROXY"] = "*"
from rentos import auth # noqa: E402
from rentos import config as C # noqa: E402 from rentos import config as C # noqa: E402
from rentos import server as S # noqa: E402 from rentos import server as S # noqa: E402
...@@ -68,6 +69,11 @@ def start_sandbox(): ...@@ -68,6 +69,11 @@ def start_sandbox():
}.items(): }.items():
setattr(C, name, val) setattr(C, name, val)
C.ensure_dirs() C.ensure_dirs()
# 造一个「纯只读访客」(无任何能力):guest 自 2026-10-13 起改成
# 「电费通知单操作员」,不再适合当只读样本。
if not auth.find("ro_verify"):
auth.add_account("ro_verify", "roverify1", role=auth.ROLE_VIEWER,
note="只读访客(回归样本)", perms=[])
srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler) srv = ThreadingHTTPServer(("127.0.0.1", PORT), S.Handler)
srv.daemon_threads = True srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start() threading.Thread(target=srv.serve_forever, daemon=True).start()
...@@ -123,9 +129,9 @@ def main(): ...@@ -123,9 +129,9 @@ def main():
tb = d.get("table") or d tb = d.get("table") or d
return {r["_id"]: r.get("AB") for r in (tb.get("rows") or [])} return {r["_id"]: r.get("AB") for r in (tb.get("rows") or [])}
# ============================================ A. 只读访客 # ============================================ A. 只读访客(无任何能力)
print("\nA. 只读访客 guest / guest@2026") print("\nA. 只读访客 ro_verify / roverify1(无任何能力)")
login("guest", "guest@2026") login("ro_verify", "roverify1")
ck("登录后进入后台", "/admin.html" in p.url) ck("登录后进入后台", "/admin.html" in p.url)
ck("body 带 role-viewer 标记", ck("body 带 role-viewer 标记",
js("() => document.body.classList.contains('role-viewer')")) js("() => document.body.classList.contains('role-viewer')"))
...@@ -154,7 +160,8 @@ def main(): ...@@ -154,7 +160,8 @@ def main():
p.wait_for_selector("#tbWrap", timeout=20000) p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1200) p.wait_for_timeout(1200)
ck("台账「编辑模式」开关被禁用", p.eval_on_selector("#tbEdit", "e=>e.disabled")) ck("台账「编辑模式」开关被禁用", p.eval_on_selector("#tbEdit", "e=>e.disabled"))
ck("台账锁定条提示只读访客", "只读访客" in p.inner_text("#tbLockBar")) ck("台账锁定条提示访客不可修改", "访客账号" in p.inner_text("#tbLockBar"),
p.inner_text("#tbLockBar").replace("\n", " ")[:80])
ck("台账「新增一行」已隐藏", not p.is_visible("#btn-tb-add")) ck("台账「新增一行」已隐藏", not p.is_visible("#btn-tb-add"))
ck("台账「还原备份」已隐藏", not p.is_visible("#btn-tb-restore")) ck("台账「还原备份」已隐藏", not p.is_visible("#btn-tb-restore"))
ck("台账仍可导出 xlsx", p.is_visible("#btn-tb-export")) ck("台账仍可导出 xlsx", p.is_visible("#btn-tb-export"))
...@@ -226,6 +233,47 @@ def main(): ...@@ -226,6 +233,47 @@ def main():
p.click("#btn-logout") p.click("#btn-logout")
p.wait_for_url("**/login.html") p.wait_for_url("**/login.html")
# ============================================ A3. guest:仅电费通知单可操作
print("\nA3. guest / guest@2026(电费通知单操作员)")
login("guest", "guest@2026")
st, s = api_call("GET", "/api/session")
ck("guest 的 perms = ['elec']", s.get("perms") == ["elec"], str(s.get("perms")))
ck("guest body 带 cap-elec",
js("() => document.body.classList.contains('cap-elec')"))
p.goto(BASE + "/admin.html#/elec")
p.wait_for_selector("#batchList", timeout=20000)
p.wait_for_timeout(900)
ck("guest 看得到「录入本期电费」表单", p.is_visible("#btn-e-submit"))
_st, _bl = api_call("GET", "/api/batches?kind=elec")
_elec = (_bl.get("batches") or [])
if _elec:
ck("guest 看得到电费批次的「删除」按钮",
p.eval_on_selector_all("#batchList tbody tr button[data-del]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
js("(id) => openBatch(id, 'elec')", _elec[0]["batch_id"])
p.wait_for_selector("#detailBody table", timeout=20000)
p.wait_for_timeout(700)
ck("guest 看得到「发送全部待发」", p.is_visible("#btn-send-all"))
ck("guest 看得到单据行的「发送」按钮",
p.eval_on_selector_all("#detailBody tbody tr button[data-send]",
"es=>es.length>0 && es.every(b=>b.offsetParent!==null)"))
else:
ck("guest 看得到电费批次的「删除」按钮", True, "(沙箱无电费批次,跳过)")
ck("guest 看得到「发送全部待发」", True, "(沙箱无电费批次,跳过)")
ck("guest 看得到单据行的「发送」按钮", True, "(沙箱无电费批次,跳过)")
p.goto(BASE + "/admin.html#/table/rent_parking")
p.wait_for_selector("#tbWrap", timeout=20000)
p.wait_for_timeout(1000)
ck("guest 仍看不到房租「生成收费通知书」", not p.is_visible("#btn-rp-submit"))
ck("guest 仍看不到台账编辑开关", not p.is_visible("#tbEdit"))
st, _ = api_call("POST", "/api/rent/batches", {"row_ids": []})
ck("guest 房租出单接口 → 403", st == 403, str(st))
st, _ = api_call("POST", "/api/batches", {"rows": []})
ck("guest 电费出单接口通过闸门(不是 403)", st != 403, str(st))
p.click("#btn-logout")
p.wait_for_url("**/login.html")
# ============================================ B. 管理员 # ============================================ B. 管理员
print("\nB. 管理员 admin") print("\nB. 管理员 admin")
before = {} before = {}
...@@ -244,7 +292,8 @@ def main(): ...@@ -244,7 +292,8 @@ def main():
acc_txt = p.inner_text("#acList") acc_txt = p.inner_text("#acList")
ck("账号清单里有 admin 与 guest", ck("账号清单里有 admin 与 guest",
("admin" in acc_txt) and ("guest" in acc_txt), acc_txt.replace("\n", " ")[:140]) ("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("guest 标为「电费通知单」权限", "电费通知单" in acc_txt,
acc_txt.replace("\n", " ")[:160])
ck("主账号不可删除(无删除按钮)", ck("主账号不可删除(无删除按钮)",
p.eval_on_selector_all('#acList tr', p.eval_on_selector_all('#acList tr',
"rows=>rows.filter(r=>/admin/.test(r.innerText))" "rows=>rows.filter(r=>/admin/.test(r.innerText))"
......
...@@ -85,7 +85,6 @@ ...@@ -85,7 +85,6 @@
<b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> —— <b>只读访客模式</b>:可以浏览全部页面与数据,但<b>不能做任何修改</b> ——
出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。 出单 / 发送 / 登记收款 / 改台账 / 改配置 / 改密码都会被服务端拒绝。
</div> </div>
<!-- ==================== 概览 ==================== --> <!-- ==================== 概览 ==================== -->
<section class="page active" id="page-overview"> <section class="page active" id="page-overview">
<div id="ovBanner"></div> <div id="ovBanner"></div>
...@@ -123,7 +122,7 @@ ...@@ -123,7 +122,7 @@
<!-- ==================== 电费通知单 ==================== --> <!-- ==================== 电费通知单 ==================== -->
<section class="page" id="page-elec"> <section class="page" id="page-elec">
<div class="card" data-ro> <div class="card" data-cap="elec">
<h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2> <h2>录入本期电费 <span class="hint">逐行手工录入,系统按规则计费并出单</span></h2>
<div class="desc"> <div class="desc">
计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>; 计费口径:<b>用量 = 本期抄表数 − 上期抄表数</b>;
...@@ -187,8 +186,8 @@ ...@@ -187,8 +186,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" data-ro>发送全部待发</button></div> <div style="flex:0 0 auto"><button id="btn-send-all" class="primary" data-cap="elec">发送全部待发</button></div>
<div style="flex:0 0 auto"><button id="btn-rerender" data-ro>按最新配置重出单据</button></div> <div style="flex:0 0 auto"><button id="btn-rerender" data-cap="elec">按最新配置重出单据</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>
...@@ -452,22 +451,26 @@ ...@@ -452,22 +451,26 @@
</div> </div>
<div class="card" data-ro> <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>可出单、发送、改台账与配置;<b>只读访客</b>只能浏览, <b>角色</b>:<b>管理员</b>可出单、发送、改台账与配置;<b>访客</b>只能浏览,另可按下面的
所有写接口在服务端一律返回 403(前端隐藏按钮只是为了不误导)。 <b>权限</b>勾选开放个别页面(目前支持「电费通知单」:录入出单 / 发送 / 重出单据 / 删批次)。
访客账号(<span class="mono-sm">guest</span>)用于给财务 / 同事查看台账与批次,密码可随时在此重置。 未勾选的页面,其写接口在服务端一律返回 403(前端隐藏按钮只是为了不误导)。
访客账号(<span class="mono-sm">guest</span>)默认已开通「电费通知单」权限,密码可随时在此重置。
</div> </div>
<div class="row" style="align-items:flex-end"> <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 150px"><label>账号 *</label><input type="text" id="ac-name" placeholder="如 guest2"></div>
<div style="flex:0 0 180px"><label>密码(至少 6 位)*</label> <div style="flex:0 0 170px"><label>密码(至少 6 位)*</label>
<input type="text" id="ac-pwd" placeholder="初始密码"></div> <input type="text" id="ac-pwd" placeholder="初始密码"></div>
<div style="flex:0 0 150px"><label>角色</label> <div style="flex:0 0 130px"><label>角色</label>
<select id="ac-role"> <select id="ac-role">
<option value="viewer">只读访客</option> <option value="viewer">访客</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
</select></div> </select></div>
<div style="flex:1 1 180px"><label>备注(可选)</label><input type="text" id="ac-note" placeholder="给谁用 / 用途"></div> <div style="flex:0 0 130px"><label>权限(仅访客)</label>
<label class="chk" style="margin-top:8px" title="允许在该页面做录入 / 出单 / 发送等操作">
<input type="checkbox" id="ac-perm-elec"> 电费通知单</label></div>
<div style="flex:1 1 140px"><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 style="flex:0 0 auto"><label>&nbsp;</label><button class="primary" id="btn-ac-add">+ 新增账号</button></div>
</div> </div>
<div id="acMsg"></div> <div id="acMsg"></div>
......
This diff is collapsed.
...@@ -425,6 +425,9 @@ td.mono { font-variant-numeric: tabular-nums; } ...@@ -425,6 +425,9 @@ td.mono { font-variant-numeric: tabular-nums; }
/* ---- 只读访客:隐藏一切写操作入口(服务端另有硬拦截,这里只是不误导人) ---- */ /* ---- 只读访客:隐藏一切写操作入口(服务端另有硬拦截,这里只是不误导人) ---- */
body.role-viewer [data-ro] { display: none !important; } body.role-viewer [data-ro] { display: none !important; }
/* 能力门控:账号没有该能力(且不是管理员)→ 隐藏对应页面的操作控件。
服务端另有 403 硬拦截,这里只是不误导操作者。 */
body:not(.cap-elec) [data-cap="elec"] { display: none !important; }
#roChip { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); } #roChip { background: var(--brand-soft); border-color: var(--brand-line); color: var(--brand-d); }
#roChip .dot { background: var(--brand); } #roChip .dot { background: var(--brand); }
......
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