Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 42 additions & 17 deletions app/routes/admin/sponsor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button } from "~/components/ui/button";
import { confirmSubmit, promptDialog } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Select } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { UserAvatar } from "~/components/user-avatar";
import { pageTitle } from "~/share-meta";
import { fromWallClock, unixNow, wallClock } from "~/time";
Expand Down Expand Up @@ -58,7 +59,8 @@ const DONE: Record<string, string> = {
confirm: "已确认",
amount: "已修改金额",
reject: "已标为未收到",
message: "已删除留言",
message: "已保存留言",
anonymous: "已修改",
delete: "已删除",
};

Expand Down Expand Up @@ -100,7 +102,9 @@ export async function action({ request, context }: Route.ActionArgs) {
? { amount: fen() }
: intent === "reject"
? { status: "rejected", reason: field("reason") }
: { message: null },
: intent === "anonymous"
? { anonymous: field("anonymous") === "on" }
: { message: field("message") },
});
if (res.ok) return { intent, ok: true, message: DONE[intent] ?? "已保存" };
return { intent, ok: false, message: (await res.json()).error.message };
Expand Down Expand Up @@ -213,20 +217,39 @@ function Row({ item: r }: { item: Item }) {
</>
)}
</p>
{r.message && (
<p className="mt-2 rounded-xl bg-secondary/50 px-3 py-2 text-sm break-words">{r.message}</p>
)}
{/* Saved empty, the message is taken down. The boxes are keyed by what's saved, so a change
from anywhere replaces what they hold; they are off while one is sent, or what is typed
meanwhile would be replaced too. */}
<Form method="post" className="mt-2 flex items-start gap-2">
<input type="hidden" name="id" value={r.id} />
<Textarea
key={r.message ?? ""}
name="message"
aria-label="留言"
placeholder="无留言"
defaultValue={r.message ?? ""}
maxLength={MAX_MESSAGE}
disabled={busy}
className="min-h-0 flex-1"
/>
<Button name="intent" value="message" size="sm" variant="outline" disabled={busy}>
保存留言
</Button>
</Form>
{r.status === "rejected" && r.reason && (
<p className="mt-2 text-meta text-muted-foreground">理由:{r.reason}</p>
)}
<Form method="post" className="mt-3 flex flex-wrap items-center gap-2">
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="anonymous" value={r.anonymous ? "" : "on"} />
<span className="flex items-center gap-1.5 text-sm">
实收 ¥
<Input
key={r.amount}
{...AMOUNT_INPUT}
aria-label="实收金额"
defaultValue={yuan(r.amount)}
disabled={busy}
className="w-24"
/>
</span>
Expand All @@ -250,18 +273,20 @@ function Row({ item: r }: { item: Item }) {
{confirmed ? "撤销确认" : "未收到"}
</Button>
)}
{r.message && (
<Button
name="intent"
value="message"
size="sm"
variant="ghost"
disabled={busy}
onClick={confirmSubmit("确定删除这条留言?删除后无法恢复。", "删除")}
>
删除留言
</Button>
)}
<Button
name="intent"
value="anonymous"
size="sm"
variant="outline"
disabled={busy}
onClick={
r.anonymous
? confirmSubmit("确定在感谢墙上显示 TA 的名字?请先确认是本人的意思。", "显示名字")
: undefined
}
>
{r.anonymous ? "显示名字" : "改为匿名"}
</Button>
<Button
name="intent"
value="delete"
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ token 由站长在后台创建,只显示一次。每个 token 绑定一个 bot
| GET | `/admin/sponsorships` | 赞助记录(总体设计 §8.21),每页 30 条:`status`(默认 `pending`;`confirmed`、`rejected`)、`cursor`;待确认的先登记的在前,其余最近登记的在前。每条同 `/me/sponsorships`,另带 `user`(匿名的也给,带 `qq`)、`reviewer`、`reviewed_at`、`created_at` | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| GET | `/admin/sponsorships/count` | 待确认的赞助有几条:`{ count }`,后台的菜单上显示 | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| POST | `/admin/sponsorships` | 补录:`{ user, method, amount, paid_at, message }`,`paid_at` 不能晚于现在;直接是已确认,匿名;本人收到通知。返回这条记录 | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| PATCH | `/admin/sponsorships/{id}` | 只改带了的字段:`status`(`confirmed` 确认,`rejected` 没收到或撤销确认,可附 `reason`,最多 200 个字)、`amount`、`message`(只能为 `null`,删除留言)。状态变了时本人收到通知 | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| PATCH | `/admin/sponsorships/{id}` | 只改带了的字段:`status`(`confirmed` 确认,`rejected` 没收到或撤销确认,可附 `reason`,最多 200 个字)、`amount`、`anonymous`(感谢墙上是否匿名)、`message`(新的留言,最多 50 个字;为 `null` 或空时删除留言)。状态变了时本人收到通知 | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| DELETE | `/admin/sponsorships/{id}` | 删掉记错的赞助记录 | 站长、收款人(须是未冻结的管理员),只能在网页上操作 |
| DELETE | `/admin/archive-flags/{id}` | 处理完一层的反馈,`id` 是楼层的 ID:清掉它收到的全部反馈,楼层要隐藏的另外用 `PATCH`;没有反馈返回 `404 archive_flags_not_found`;记操作日志 | 版主 / `moderate` |
| GET | `/admin/suggestions` | 待处理的修改建议,最新的在前,每页 30 条:`cursor`(上一页最后一条的 ID);每条同 `GET /threads/{id}` 的 `suggestions`,另带帖子现在的 `thread`(`id`、`title`、`board`、`tags`)。帖子删了的不列 | 版主 / `moderate` |
Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ QQ 群的 2000 人规模、服务器、域名和 bot 用的模型都要花钱,
- 付了没登记的、以前赞助过的,收款人或站长补录一条,直接是已确认,默认匿名;本人收到通知,可以自己改成显示名字。
- 只通知收款人,不催赞助的人,登记了没付的也不提醒。站长不收通知,看后台菜单上标出的待确认条数。
- **感谢墙**:只列确认过的,最近登记的在前,排成多列错落的便签。每张是档位名、留言、名字(匿名的显示"匿名鼠鼠")和日期,不显示金额,免得互相比。档位越高底色越暖,旁边画一周七个点,亮几个是几天。档位按金额算:¥24 起是一周,¥14 起是 4 天,¥7 起是 2 天,不到 ¥7 的显示"一份心意"。设了"我的内容不给访客看"的人,访客看不到 TA 的那一张(第 8.10 节)。
- **改记录**:本人随时可以改匿名、删除留言;留言不能改,待确认的要改就撤回重新登记,这样公开的留言都经收款人看过。收款人和站长可以改金额、删除留言,也可以删除记错的记录。金额只有本人、收款人和站长看得到。这些改动都记操作日志。
- **改记录**:本人随时可以改匿名、删除留言,但不能改留言:待确认的要改就撤回重新登记,上了墙的找收款人改,这样公开的留言都经收款人看过。收款人和站长可以改金额、改留言(清空即删除)、改是否匿名(改成显示名字前先确认是本人的意思),也可以删除记错的记录。金额只有本人、收款人和站长看得到。这些改动都记操作日志。
- **徽章**:有一笔确认过、不匿名的赞助,得到铜徽章「投喂鼠」(第 8.8 节);匿名的不算,否则从徽章看得出匿名的是谁。

## 9. 页面
Expand Down
29 changes: 28 additions & 1 deletion server/modules/sponsors/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@ async function register(who: Who, amount = 700, anonymous = false) {
return (await res.json()).id;
}

function review(id: number, json: { status?: "confirmed" | "rejected"; amount?: number }) {
function review(
id: number,
json: {
status?: "confirmed" | "rejected";
amount?: number;
anonymous?: boolean;
message?: string | null;
},
) {
return api.admin.sponsorships[":id"].$patch({ param: { id: String(id) }, json }, payee.who);
}

Expand Down Expand Up @@ -167,6 +175,25 @@ describe("登记和确认", () => {
expect(late.status).toBe(409);
});

test("收款人可以改墙上的留言,清空就删掉", async () => {
const someone = await member();
const id = await register(someone.who);
await review(id, { status: "confirmed" });
expect((await review(id, { message: " 祝大家都上岸 " })).status).toBe(204);
expect((await wall()).find((t) => t.id === id)).toMatchObject({ message: "祝大家都上岸" });
await review(id, { message: "" });
expect((await wall()).find((t) => t.id === id)).toMatchObject({ message: null });
});

test("收款人可以改是否匿名", async () => {
const someone = await member();
const id = await register(someone.who, 700, true);
await review(id, { status: "confirmed" });
expect((await wall()).find((t) => t.id === id)).toMatchObject({ user: null });
expect((await review(id, { anonymous: false })).status).toBe(204);
expect((await wall()).find((t) => t.id === id)).toMatchObject({ user: { id: someone.id } });
});

test("没收到的附理由通知本人,不上墙", async () => {
const someone = await member();
const id = await register(someone.who);
Expand Down
5 changes: 4 additions & 1 deletion server/modules/sponsors/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,10 @@ export const sponsorsRoutes = createRouter()
.transform((r) => r || null)
.openapi({ description: "标为没收到的理由" }),
amount: Amount.optional(),
message: z.null().optional().openapi({ description: "为 null 时删掉留言" }),
anonymous: z.boolean().optional().openapi({ description: "感谢墙上是否匿名" }),
message: Message.optional().openapi({
description: `新的留言,最多 ${MAX_MESSAGE} 个字;为 null 或空时删掉留言`,
}),
}),
},
},
Expand Down
20 changes: 14 additions & 6 deletions server/modules/sponsors/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ async function lockOne(trx: Transaction<DB>, id: number) {
);
}

// Whoever paid may go anonymous or not at any time, and take the message down; one shown on the
// wall was read by the payee when confirming it, so it can't be changed afterwards.
// Whoever paid may go anonymous or not at any time, and take the message down, but not reword it:
// every message on the wall is one the payee has read, so corrections go through the payee.
export async function editOwnSponsorship(
actor: Actor,
id: number,
Expand Down Expand Up @@ -219,16 +219,17 @@ export async function addSponsorship(
});
}

// Confirmed with what really came, turned down with why, a confirmation taken back, or a message
// taken down. Whoever paid hears when its status changes.
// Confirmed with what really came, turned down with why, a confirmation taken back, the message
// corrected or taken down, or anonymity set right. Whoever paid hears when its status changes.
export async function reviewSponsorship(
actor: Actor,
id: number,
change: {
status?: "confirmed" | "rejected";
reason?: string | null;
amount?: number;
message?: null;
anonymous?: boolean;
message?: string | null;
},
) {
await db.transaction().execute(async (trx) => {
Expand All @@ -249,6 +250,7 @@ export async function reviewSponsorship(
.updateTable("sponsorships")
.set({
amount: after.amount,
anonymous: after.anonymous,
message: after.message,
status: after.status,
reason: after.reason,
Expand All @@ -273,7 +275,13 @@ export async function reviewSponsorship(
amount: after.amount,
...(rest.amount !== undefined &&
rest.amount !== before.amount && { amount_was: before.amount }),
...(rest.message === null && before.message !== null && { message_was: before.message }),
...(rest.anonymous !== undefined &&
rest.anonymous !== before.anonymous && { anonymous: after.anonymous }),
...(rest.message !== undefined &&
rest.message !== before.message && {
message: after.message,
message_was: before.message,
}),
...(reviewed && { status_was: before.status, reason: after.reason }),
},
});
Expand Down
Loading