Commit 061dc393c5c24eb8dc941d841a329afbb82a9398
1 parent
92ffd6ee
Add T31: surface characters' totalSegments/remainingSegments/status
The schema field existed since T1 but nothing read or wrote it — admin-characters.html now shows a 片段配额 column (remainingSegments/ totalSegments, "-" for admin_seed/unlimited characters) so an operator can see mode 3's (T35) purchased-segment quota once it exists. No server.js change needed: PATCH already allows arbitrary field updates (mountCrud's isPatch branch), this is purely making the existing plumbing visible plus a round-trip test proving it actually holds the values mode 3 will write. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
2 changed files
with
40 additions
and
0 deletions
admin-characters.html
| @@ -69,6 +69,7 @@ | @@ -69,6 +69,7 @@ | ||
| 69 | <th>默认</th> | 69 | <th>默认</th> |
| 70 | <th>来源</th> | 70 | <th>来源</th> |
| 71 | <th>状态</th> | 71 | <th>状态</th> |
| 72 | + <th>片段配额</th> | ||
| 72 | <th>创建时间</th> | 73 | <th>创建时间</th> |
| 73 | <th>操作</th> | 74 | <th>操作</th> |
| 74 | </tr> | 75 | </tr> |
| @@ -97,6 +98,14 @@ function escapeHtml(s) { | @@ -97,6 +98,14 @@ function escapeHtml(s) { | ||
| 97 | return div.innerHTML; | 98 | return div.innerHTML; |
| 98 | } | 99 | } |
| 99 | 100 | ||
| 101 | +// T31: totalSegments/remainingSegments are only ever non-null for | ||
| 102 | +// source:"user_paid" characters (mode 3, T35) — admin_seed characters like | ||
| 103 | +// the default one stay null forever (unlimited plays, not a purchased quota). | ||
| 104 | +function segmentsLabel(c) { | ||
| 105 | + if (c.totalSegments == null) return "-"; | ||
| 106 | + return `${c.remainingSegments ?? 0}/${c.totalSegments}`; | ||
| 107 | +} | ||
| 108 | + | ||
| 100 | async function uploadImage(file) { | 109 | async function uploadImage(file) { |
| 101 | const fd = new FormData(); | 110 | const fd = new FormData(); |
| 102 | fd.append("file", file); | 111 | fd.append("file", file); |
| @@ -134,6 +143,7 @@ function render() { | @@ -134,6 +143,7 @@ function render() { | ||
| 134 | <td><input type="checkbox" data-field="isDefault" ${c.isDefault ? "checked" : ""} /></td> | 143 | <td><input type="checkbox" data-field="isDefault" ${c.isDefault ? "checked" : ""} /></td> |
| 135 | <td>${c.source}</td> | 144 | <td>${c.source}</td> |
| 136 | <td>${c.status}</td> | 145 | <td>${c.status}</td> |
| 146 | + <td>${segmentsLabel(c)}</td> | ||
| 137 | <td>${new Date(c.createdAt).toLocaleDateString()}</td> | 147 | <td>${new Date(c.createdAt).toLocaleDateString()}</td> |
| 138 | <td class="row-actions"> | 148 | <td class="row-actions"> |
| 139 | <button data-save="${c.id}">保存</button> | 149 | <button data-save="${c.id}">保存</button> |
| @@ -150,6 +160,7 @@ function render() { | @@ -150,6 +160,7 @@ function render() { | ||
| 150 | <td>${c.isDefault ? '<span class="default-badge">默认</span>' : ""}</td> | 160 | <td>${c.isDefault ? '<span class="default-badge">默认</span>' : ""}</td> |
| 151 | <td>${c.source}</td> | 161 | <td>${c.source}</td> |
| 152 | <td>${c.status}</td> | 162 | <td>${c.status}</td> |
| 163 | + <td>${segmentsLabel(c)}</td> | ||
| 153 | <td>${new Date(c.createdAt).toLocaleDateString()}</td> | 164 | <td>${new Date(c.createdAt).toLocaleDateString()}</td> |
| 154 | <td class="row-actions"> | 165 | <td class="row-actions"> |
| 155 | <button class="secondary" data-edit="${c.id}">编辑</button> | 166 | <button class="secondary" data-edit="${c.id}">编辑</button> |
dressup-crud.test.js
| @@ -122,6 +122,35 @@ test("characters: setting isDefault on a second character clears it on the first | @@ -122,6 +122,35 @@ test("characters: setting isDefault on a second character clears it on the first | ||
| 122 | ); | 122 | ); |
| 123 | }); | 123 | }); |
| 124 | 124 | ||
| 125 | +// T31 (dev-plan-5.md): totalSegments/remainingSegments/status were already | ||
| 126 | +// part of the schema (T1) but never actually read/written until mode 3 | ||
| 127 | +// (T35) exists. This just proves the plumbing (PATCH, admin.html's | ||
| 128 | +// segmentsLabel()) has something real to render — admin_seed characters | ||
| 129 | +// stay null/active by default (unaffected). | ||
| 130 | +test("characters: totalSegments/remainingSegments/status round-trip via PATCH (mode 3's shape, before T35 exists)", async () => { | ||
| 131 | + const created = await fetch( | ||
| 132 | + `${BASE}/api/admin/characters`, | ||
| 133 | + authed({ method: "POST", body: JSON.stringify({ name: "付费人物占位", imageUrl: "/x.png", appearanceDescriptor: "d" }) }) | ||
| 134 | + ).then((r) => r.json()); | ||
| 135 | + assert.equal(created.totalSegments, null); | ||
| 136 | + assert.equal(created.remainingSegments, null); | ||
| 137 | + assert.equal(created.status, "active"); | ||
| 138 | + | ||
| 139 | + const patched = await fetch( | ||
| 140 | + `${BASE}/api/admin/characters/${created.id}`, | ||
| 141 | + authed({ method: "PATCH", body: JSON.stringify({ totalSegments: 3, remainingSegments: 3, status: "active" }) }) | ||
| 142 | + ).then((r) => r.json()); | ||
| 143 | + assert.equal(patched.totalSegments, 3); | ||
| 144 | + assert.equal(patched.remainingSegments, 3); | ||
| 145 | + | ||
| 146 | + const expired = await fetch( | ||
| 147 | + `${BASE}/api/admin/characters/${created.id}`, | ||
| 148 | + authed({ method: "PATCH", body: JSON.stringify({ remainingSegments: 0, status: "expired" }) }) | ||
| 149 | + ).then((r) => r.json()); | ||
| 150 | + assert.equal(expired.status, "expired"); | ||
| 151 | + assert.equal(expired.totalSegments, 3, "totalSegments is history, not reset by going expired"); | ||
| 152 | +}); | ||
| 153 | + | ||
| 125 | test("garments: create/list round-trip with defaults", async () => { | 154 | test("garments: create/list round-trip with defaults", async () => { |
| 126 | const res = await fetch( | 155 | const res = await fetch( |
| 127 | `${BASE}/api/admin/garments`, | 156 | `${BASE}/api/admin/garments`, |