fix(ui): print the spendable half in the ops member balance column - #277
Merged
Merged
Conversation
`/api/ops/users` returns both `balance` (permanent points) and
`gift_balance`, and the ops members table rendered only `u.balance`
while its column head carries the site-wide unqualified wording
"余额(点数)" / "Balance (pts)" — the wording the site binds to the
SPENDABLE amount (`available = balance + gift_balance`, the same half
`common.balance` / `dash.balance` / `wallet.balance` name).
So the operator read a smaller number than the user reads for the same
account, and the difference was exactly the gift half (spendable,
expiring, really deducted by the gift sweep).
Fix: fold `gift_balance` into the cell — byte-identical in shape to the
sibling table that already got it right (`admin.emp.col.avail`), zero
new i18n keys. The admin "permanent points" cell (`admin.emp.col.perm`)
deliberately keeps the bare half; it is the reverse control.
CI: `src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names`
(three rules, each with an independent tooth: the value expression must
read both `balance` and `gift_balance` by identifier token; both packs'
column head must live in the spendable family, whose marker is derived
from the `wallet.balance` pack value; and the reverse leg pins
`admin.emp.col.perm` to the bare half) plus a teeth test.
Scope note: the gate is lexical — it proves the value READS both fields
and that the label lives in the spendable family; it does not prove the
arithmetic is `+`. That half is pinned by the jsdom instrument, whose
A/B ran the baseline red on exactly {A1,A1zh,A2,A2zh} while both
legitimate fix trees stay green and three near-miss trees are rejected.
Tests: cargo test 316 passed / 0 failed; cargo fmt --check clean;
clippy --all-targets -D warnings clean.
Owner
Author
|
Self-review (Committer, Verified on this exact head
Scope, stated honestly: the CI gate is lexical. It proves the cell's value expression reads |
argszero
deleted the
fix/ops-balance-column-names-the-spendable-half
branch
September 21, 2026 15:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The ops members table's balance column is captioned with the site-wide wording for the
spendable balance —
ops.users.col.balance= "余额(点数)" / "Balance (pts)" — but its cellprints only
u.balance(the permanent half). The API has been returning both halves for along time (
/api/ops/users→COALESCE(q.balance,0), COALESCE(q.gift_balance,0)), so with a userat
balance=100, gift_balance=1the operator sees 100 while that same user sees 101 intheir own sidebar/wallet. The difference is exactly the gift half — which is spendable, expires,
and is really deducted (
gift::expire_past_gifts).Every other place in the app binds the unqualified "点数余额 / Points balance" to the spendable
amount (
available = balance + gift_balance); the ops column used the same word but a differentnumber.
Root cause and provenance — drift, not a deliberate trade-off
85982e8(feat(frontend): P2-C 部门/成员管理 + 加额审批 + 用量报表 + 运营者视图(v0.3.3) #80, "P2-C") wrote the column whenbalancewas the balance — the gift conceptdid not exist yet. When gifting landed, "余额" was redefined as
availableand this column didnot follow.
git log --oneline -S'ops.users.col.balance'shows only68f9f70(feat(ui): i18n — zh/en language packs with switching (v1.21) #86, the i18nextraction) and
4bcddca(fix(ui): drive table card-mode labels from i18n instead of hardcoded zh strings #163, moving the hard-coded label into the key) — the wording has beenverbatim unchanged since feat(frontend): P2-C 部门/成员管理 + 加额审批 + 用量报表 + 运营者视图(v0.3.3) #80, so there is no sign the author later changed their mind.
it was written one PR earlier:
f525242(feat(frontend): P2-B wire views to real API — market/sharing/transactions/dashboard/api-keys/admin (v0.3.2) #79) hasadmin.emp.col.perm→u.balance,admin.emp.col.gift→u.gift_balance,admin.emp.col.avail→(u.balance || 0) + (u.gift_balance || 0).So the correct pattern was on screen while the ops column was being written (the
"scan the sibling row first" fingerprint).
/api/ops/usersresponse has carriedgift_balanceall along; nothing in the whole repo hasever read it on the ops side (
git log -S'gift_balance' -- ui/js/app.jshits only the admin tableand the C2142 total-balance fix).
Why this is fixed in the value, not in the caption (direction "甲")
Both one-line fixes are self-consistent, so the deciding evidence is an asymmetry:
places bind the unqualified wording to
available(common.balance,dash.balance,wallet.balance), and two bind "永久点数 / Permanent points" tobalance(wallet.forever,admin.emp.col.perm). The ops caption uses the former ⇒ it is the established term.the caption to another equally-existing term would change what the column declares: a design
change, not a repair.
seeing 101 while the operator sees 100 is exactly the shape that family keeps fixing.
Counter-evidence, recorded honestly (it is the support for the alternative fix "乙"): the same
#79 sibling table names
u.balance"永久点数"; the ops page's job is to grant credit (and itgrants permanent points —
ops.users.subandops.users.topup.okboth say "永久有效"). If theproduct actually wants "the ops page is a permanent-balance ledger", then changing the two captions
to "永久点数 / Permanent points" is equally a one-liner with zero new keys, reusing an existing
canonical name. The instruments for both directions are in hand, so switching costs ≈ nothing —
but this PR ships the direction the evidence supports.
Changes
ui/js/app.js(ops members balance cell)D.fmt(u.balance || 0)→D.fmt((u.balance || 0) + (u.gift_balance || 0))— the trailing+ " " + T("common.points")suffix is preserved; the expression is byte-identical in shape to the sibling admin "available" cell. One line.src/state_gate.rsthe_ops_members_balance_cell_is_the_half_its_caption_names(3 rules) + its ruler self-check testthe_ops_balance_half_checkers_have_teeth+ synthetic inputsui/README.mdui/index.html?v=by procedure (read the live value withgrep -n 'js/app\.js?v='and write a strictly greater one — do not copy a literal)i18n_packconstants and the reachability sunset list are untouched)The gate (E2) — three rules, each with its own tooth
permanent half is this defect; reading only the gift half is the axis reversed. Matched by
identifier token (
gift_balanceends withbalance, so substring matching would be blind)with comments stripped first.
ops.users.col.balancemust still land in the spendablefamily. The markers are derived from the pack's own values for
wallet.balance/wallet.forever(their longest common substring is the shared noun —points/点数— and whatremains on each side is its marker:
balancevspermanent,余额vs永久). This blocks the"rename the caption so the two sides agree" escape.
the permanent half — blocking the "widen everything to
available" over-correction.The value extractor anchors on the
data-labelclosing token and"</td>", and fails loudly(
None⇒ the test errors) instead of silently returning an empty set on which an assertion wouldpass vacuously. The marker derivation refuses to degrade to empty markers for the same reason
(an empty marker makes
containsalways true and rule 2 silently mute).Evidence — A/B
Gate A/B (
c2173_gate_ab.py, 9 trees; re-run this round)Each tree = the real
state_gate.rswith the two fragments spliced at their insertion points,plus one mutated copy of the UI. The runner counts "which test went red" and "which rule spoke" as
two separate facts, so a red ruler self-check is never scored as "the gate has teeth".
Result:
RESULT: ALL TREES AS DECLARED.basefix(甲)(balance) + (gift_balance)m_gift_onlym_comment_onlygift_balancem_delete_fillm_rename_zhm_rename_bothm_cosmeticm_admin_perm_wideavailableclippy-driver --edition 2021 --test -D warningson the assembled tree: rc=0.DOM probe (
c2173_probe.js, jsdom, 19 legs; runnerc2173_ab.py)Fixture: one user with
balance=100, gift_balance=1, role=ops, with/api/ops/users,/api/admin/usersand/api/walletanswering in the real backend field shapes; any requestthat is not explicitly stubbed lands in
__fellThroughand fails legP4(so the instrument cannotbe silently mute). The two word families' markers are derived from rendered output (the wallet
hero cell's own two labels), so the probe carries no wording constants and cannot be circular.
Result:
RESULT: ALL LEGS AS DECLAREDbaseui/js/app.js{A1, A1zh, A2, A2zh}red; controlsC1–C6, preconditionsP0–P6, measurementM1greenjia(甲)app.jsvalue → both halvesyi(乙)en_only{A1zh, A2zh}cosmetic{A1, A1zh}yi_plusavailable{A1, A1zh}The unfixed tree's own closing measurement line, verbatim:
sidebar=101 opsRow=100 dash=101 walletHero=101 walletForever=100 | admin perm=100 avail=101 | zh sidebar=101 opsRow=100 head="余额(点数)"— i.e. on the same instrument, eight of the nine balance cells agree and only the ops row is the
other half.
Both instruments agree on the direction (they accept 甲, and reject 乙 / cosmetic /
self-contradictory shapes). They are not redundant: the gate sees three things the probe cannot —
a value that happens to equal the gift half exactly, a deleted fill site (the probe reads it as an
empty table), and the admin column being widened when
balance == gift_balance(the probe'sC4would be blind to that). The gate is stricter than the probe; neither can be dropped.
Tests
cargo test— 316 passed / 0 failed on this exact head (baselinemain= 314; the +2 arethe new gate test and its ruler self-check).
cargo fmt --check— clean (rc=0).cargo clippy --all-targets -- -D warnings— clean (rc=0).RESULT: ALL TREES AS DECLARED(9 trees); DOM probe A/Bre-run this round:
RESULT: ALL LEGS AS DECLARED(baseline red on exactly{A1, A1zh, A2, A2zh}).What this PR deliberately does NOT do
reachability gate's constants and its sunset list, are unchanged.
Related Issue
(No upstream issue — internal consistency fix found by the maintenance pass.)
Checklist
fix/).fix(ui): …).