Commit 061dc393c5c24eb8dc941d841a329afbb82a9398

Authored by yibo
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
admin-characters.html
... ... @@ -69,6 +69,7 @@
69 69 <th>默认</th>
70 70 <th>来源</th>
71 71 <th>状态</th>
  72 + <th>片段配额</th>
72 73 <th>创建时间</th>
73 74 <th>操作</th>
74 75 </tr>
... ... @@ -97,6 +98,14 @@ function escapeHtml(s) {
97 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 109 async function uploadImage(file) {
101 110 const fd = new FormData();
102 111 fd.append("file", file);
... ... @@ -134,6 +143,7 @@ function render() {
134 143 <td><input type="checkbox" data-field="isDefault" ${c.isDefault ? "checked" : ""} /></td>
135 144 <td>${c.source}</td>
136 145 <td>${c.status}</td>
  146 + <td>${segmentsLabel(c)}</td>
137 147 <td>${new Date(c.createdAt).toLocaleDateString()}</td>
138 148 <td class="row-actions">
139 149 <button data-save="${c.id}">保存</button>
... ... @@ -150,6 +160,7 @@ function render() {
150 160 <td>${c.isDefault ? '<span class="default-badge">默认</span>' : ""}</td>
151 161 <td>${c.source}</td>
152 162 <td>${c.status}</td>
  163 + <td>${segmentsLabel(c)}</td>
153 164 <td>${new Date(c.createdAt).toLocaleDateString()}</td>
154 165 <td class="row-actions">
155 166 <button class="secondary" data-edit="${c.id}">编辑</button>
... ...
dressup-crud.test.js
... ... @@ -122,6 +122,35 @@ test(&quot;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 154 test("garments: create/list round-trip with defaults", async () => {
126 155 const res = await fetch(
127 156 `${BASE}/api/admin/garments`,
... ...