From 8f1689bbbcf24bb379a9b1c673a1d244b1e5644e Mon Sep 17 00:00:00 2001 From: quanac-lcx Date: Wed, 26 Aug 2026 21:06:02 +0800 Subject: [PATCH 1/3] feat(judgement): add owner history hiding --- .../entities/judgement-visibility-request.ts | 17 ++++ .../backend/src/routers/judgement.router.ts | 12 +++ .../backend/src/services/judgement.service.ts | 25 ++++- packages/backend/src/shared/judgement.ts | 20 +++- packages/backend/test/judgement.test.ts | 35 +++++++ packages/frontend/src/api/judgement.ts | 10 ++ .../src/views/judgement/JudgementView.vue | 97 +++++++++++++++++-- .../src/views/user/UserProfileView.vue | 7 +- spec/authentication.spec.md | 2 + spec/judgement-system.spec.md | 38 +++++++- 10 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/entities/judgement-visibility-request.ts diff --git a/packages/backend/src/entities/judgement-visibility-request.ts b/packages/backend/src/entities/judgement-visibility-request.ts new file mode 100644 index 0000000..b92c4a0 --- /dev/null +++ b/packages/backend/src/entities/judgement-visibility-request.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { BaseEntity } from './base'; + +@Entity({ name: 'judgement_visibility_request' }) +export class JudgementVisibilityRequest extends BaseEntity { + @PrimaryColumn({ type: 'int', unsigned: true }) + uid: number; + + @Column({ name: 'hidden_until', type: 'int', unsigned: true }) + hiddenUntil: number; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt: Date; +} diff --git a/packages/backend/src/routers/judgement.router.ts b/packages/backend/src/routers/judgement.router.ts index 37c4147..7f0c4a3 100644 --- a/packages/backend/src/routers/judgement.router.ts +++ b/packages/backend/src/routers/judgement.router.ts @@ -1,6 +1,9 @@ import Router from 'koa-router'; import { Context, DefaultState } from 'koa'; import { JudgementService } from '@/services/judgement.service'; +import { requiresPermission } from '@/middlewares/authorization'; +import { Permission } from '@/shared/permission'; +import { RegisteredUserService } from '@/services/registered-user.service'; import { JudgementQueryError, parseJudgementPagination, @@ -31,4 +34,13 @@ router.get('/stats', async (ctx: Context) => { ctx.success(await JudgementService.stats()); }); +router.post('/hide-mine', requiresPermission(Permission.LOGIN), async (ctx: Context) => { + const registeredUser = await RegisteredUserService.getById(ctx.user.id); + if (!registeredUser) { + ctx.fail(401, 'Unauthorized'); + return; + } + ctx.success(await JudgementService.hideHistory(registeredUser.luoguUid)); +}); + export default router; diff --git a/packages/backend/src/services/judgement.service.ts b/packages/backend/src/services/judgement.service.ts index 1b109c9..f5e9075 100644 --- a/packages/backend/src/services/judgement.service.ts +++ b/packages/backend/src/services/judgement.service.ts @@ -1,6 +1,7 @@ import { AppDataSource } from '@/data-source'; import { JudgementFetchLog } from '@/entities/judgement-fetch-log'; import { JudgementRecord } from '@/entities/judgement-record'; +import { JudgementVisibilityRequest } from '@/entities/judgement-visibility-request'; import { logger } from '@/lib/logger'; import { createJudgementDedupKey, @@ -173,8 +174,18 @@ export class JudgementService { }); const [records, total] = await builder.getManyAndCount(); + const visibilityRequests = records.length + ? await JudgementVisibilityRequest.getRepository().findBy({ + uid: In([...new Set(records.map(record => record.uid))]) + }) + : []; + const hiddenUntilByUid = new Map( + visibilityRequests.map(request => [request.uid, request.hiddenUntil]) + ); return { - items: records.map(toJudgementListItem), + items: records.map(record => + toJudgementListItem(record, record.time <= (hiddenUntilByUid.get(record.uid) ?? 0)) + ), pagination: { page: query.page, limit: query.limit, @@ -184,6 +195,18 @@ export class JudgementService { }; } + static async hideHistory(uid: number) { + const hiddenUntil = Math.floor(Date.now() / 1000); + const repository = JudgementVisibilityRequest.getRepository(); + const existing = await repository.findOneBy({ uid }); + const request = repository.create({ + uid, + hiddenUntil: Math.max(existing?.hiddenUntil ?? 0, hiddenUntil) + }); + const saved = await repository.save(request); + return { uid: saved.uid, hiddenUntil: saved.hiddenUntil }; + } + static async listLogs(query: JudgementPaginationQuery) { const [logs, total] = await JudgementFetchLog.getRepository().findAndCount({ order: { id: 'DESC' }, diff --git a/packages/backend/src/shared/judgement.ts b/packages/backend/src/shared/judgement.ts index 734c7c6..4f6c5a9 100644 --- a/packages/backend/src/shared/judgement.ts +++ b/packages/backend/src/shared/judgement.ts @@ -64,10 +64,28 @@ interface JudgementListRecord { createdAt: Date; } -export function toJudgementListItem(record: JudgementListRecord) { +export function toJudgementListItem(record: JudgementListRecord, hidden = false) { + if (hidden) { + return { + id: record.id, + uid: record.uid, + hidden: true as const, + name: record.name, + reason: '此记录已被账号所有者要求隐藏', + revoked_permission: 0, + added_permission: 0, + time: record.time, + user: record.userSnapshot, + fetch_log_id: record.fetchLogId, + log_fetched_at: record.fetchLog?.fetchedAt ?? null, + created_at: record.createdAt + }; + } + return { id: record.id, uid: record.uid, + hidden: false as const, name: record.name, reason: record.reason, revoked_permission: record.revokedPermission, diff --git a/packages/backend/test/judgement.test.ts b/packages/backend/test/judgement.test.ts index d9527bf..97ff3a9 100644 --- a/packages/backend/test/judgement.test.ts +++ b/packages/backend/test/judgement.test.ts @@ -85,6 +85,7 @@ describe('judgement domain helpers', () => { const item = toJudgementListItem({ id: 1540, uid: 123456, + hidden: false, name: 'ExampleUser', reason: 'reason', revokedPermission: 32768, @@ -102,6 +103,7 @@ describe('judgement domain helpers', () => { expect(item).toEqual({ id: 1540, uid: 123456, + hidden: false, name: 'ExampleUser', reason: 'reason', revoked_permission: 32768, @@ -115,6 +117,39 @@ describe('judgement domain helpers', () => { expect(item).not.toHaveProperty('full_record'); }); + it('redacts hidden record reason and permissions while preserving identity and time', () => { + const item = toJudgementListItem( + { + id: 1540, + uid: 123456, + name: 'ExampleUser', + reason: 'reason', + revokedPermission: 32768, + addedPermission: 0, + time: 1786942821, + userSnapshot: { uid: 123456, name: 'ExampleUser' }, + fetchLogId: 16, + createdAt: new Date('2026-08-17T00:40:01.938Z') + }, + true + ); + + expect(item).toEqual({ + id: 1540, + uid: 123456, + hidden: true, + name: 'ExampleUser', + reason: '此记录已被账号所有者要求隐藏', + revoked_permission: 0, + added_permission: 0, + time: 1786942821, + user: { uid: 123456, name: 'ExampleUser' }, + fetch_log_id: 16, + log_fetched_at: null, + created_at: new Date('2026-08-17T00:40:01.938Z') + }); + }); + it('rejects upstream values that cannot fit unsigned database columns', () => { const record = { user: { uid: 1, name: 'user' }, diff --git a/packages/frontend/src/api/judgement.ts b/packages/frontend/src/api/judgement.ts index 7cb2bb8..07e8302 100644 --- a/packages/frontend/src/api/judgement.ts +++ b/packages/frontend/src/api/judgement.ts @@ -14,6 +14,7 @@ export interface JudgementItem { fetch_log_id: number; log_fetched_at: string | null; created_at: string; + hidden: boolean; } export interface JudgementUser { @@ -78,3 +79,12 @@ export async function getJudgementLogs( export async function getJudgementStats(): Promise> { return (await apiFetch.get('/judgement/stats')) as ApiResponse; } + +export async function hideMyJudgementHistory(): Promise< + ApiResponse<{ uid: number; hiddenUntil: number }> +> { + return (await apiFetch.post('/judgement/hide-mine')) as ApiResponse<{ + uid: number; + hiddenUntil: number; + }>; +} diff --git a/packages/frontend/src/views/judgement/JudgementView.vue b/packages/frontend/src/views/judgement/JudgementView.vue index f79bda4..a187238 100644 --- a/packages/frontend/src/views/judgement/JudgementView.vue +++ b/packages/frontend/src/views/judgement/JudgementView.vue @@ -16,19 +16,21 @@ import { NPagination, NSelect, NSpin, + useDialog, useMessage } from 'naive-ui'; import type { DataTableColumns } from 'naive-ui'; -import { Hammer, ExternalLink, RefreshCw, Search } from 'lucide-vue-next'; +import { Hammer, ExternalLink, RefreshCw, Search, Trash2 } from 'lucide-vue-next'; import Card from '@/components/Card.vue'; import CardTitle from '@/components/CardTitle.vue'; import UserBadge from '@/components/UserBadge.vue'; import UserPrizeBadge from '@/components/UserPrizeBadge.vue'; -import { getJudgements, type JudgementItem } from '@/api/judgement.ts'; +import { getJudgements, hideMyJudgementHistory, type JudgementItem } from '@/api/judgement.ts'; import { JUDGEMENT_DISPLAY_OPTIONS_STORAGE_KEY } from '@/utils/constants.ts'; import { formatDate } from '@/utils/render.ts'; import { getJudgementPermissionNames, judgementPermissions } from '@/utils/judgement.ts'; import { useLocalStorage } from '@/composables/useLocalStorage.ts'; +import { isAuthenticated, startCpOAuthLogin } from '@/utils/auth.ts'; const PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500].map(value => ({ label: `${value} 条/页`, @@ -39,7 +41,9 @@ const DEFAULT_PAGE_SIZE = 50; type ExpandedPanel = 'permissions' | 'display' | null; const message = useMessage(); +const dialog = useDialog(); const loading = ref(false); +const hidingHistory = ref(false); const errorMessage = ref(null); const judgements = ref([]); const total = ref(0); @@ -235,6 +239,53 @@ function handleLimitChange(nextLimit: number) { handleSearch(); } +function handleHideHistory() { + if (!isAuthenticated.value) { + dialog.warning({ + title: '需要登录', + content: '申请删除陶片放逐记录需要登录,是否前往登录?', + positiveText: '去登录', + negativeText: '取消', + onPositiveClick: () => startCpOAuthLogin('/judgement') + }); + return; + } + + dialog.warning({ + title: '申请删除陶片放逐记录', + content: () => + h('div', { class: 'hide-history-dialog' }, [ + h('p', '注意:'), + h('ul', [ + h('li', '仅影响当前登录账号绑定的洛谷 UID。'), + h('li', '将一次性隐藏申请时刻之前的全部历史记录,不能选择部分记录。'), + h('li', '原始数据仍保留在数据库中,以满足审计、安全与合规的合理需求。'), + h( + 'li', + '为避免时间线混乱,记录本身仍会显示;但授予或撤销的具体权限及原始原因将被隐藏。公开列表、个人主页和 API 均会显示“此记录已被账号所有者要求隐藏”。申请后新产生的记录不会被隐藏。' + ), + h('li', '点击确认后将立即生效,且无法撤销。') + ]), + h('p', '确认申请吗?') + ]), + positiveText: '确认', + negativeText: '取消', + onPositiveClick: async () => { + hidingHistory.value = true; + try { + const response = await hideMyJudgementHistory(); + if (response.code !== 200) throw new Error(response.message || '申请删除失败'); + message.success('操作成功'); + await loadJudgements(); + } catch (error) { + message.error(getRequestErrorMessage(error) || '申请删除失败'); + } finally { + hidingHistory.value = false; + } + } + }); +} + const columns = computed>(() => { const result: DataTableColumns = []; if (mobileViewport.value) { @@ -263,7 +314,14 @@ const columns = computed>(() => { } return result; } - if (displayOptions.value.uid) result.push({ title: 'UID', key: 'uid', width: 100 }); + if (displayOptions.value.uid) { + result.push({ + title: 'UID', + key: 'uid', + width: 100, + render: row => row.uid + }); + } if (displayOptions.value.avatar) { result.push({ title: '头像', @@ -456,10 +514,21 @@ onUnmounted(() => {
共 {{ total }} 条记录 - - - 刷新 - +
+ + + 刷新 + + + + 申请删除 + +
获取记录失败:{{ errorMessage }} @@ -544,6 +613,16 @@ onUnmounted(() => { .load-error { margin-bottom: 12px; } +.hide-history-dialog p { + margin: 0; +} +.hide-history-dialog ul { + margin: 10px 0; + padding-left: 22px; +} +.hide-history-dialog li + li { + margin-top: 6px; +} .filter-actions, .table-toolbar, .pagination-wrap { @@ -595,6 +674,10 @@ onUnmounted(() => { gap: 12px; margin-bottom: 12px; } +.table-toolbar-actions { + display: flex; + gap: 8px; +} .pagination-wrap { justify-content: center; margin-top: 16px; diff --git a/packages/frontend/src/views/user/UserProfileView.vue b/packages/frontend/src/views/user/UserProfileView.vue index d907572..8cb6a93 100755 --- a/packages/frontend/src/views/user/UserProfileView.vue +++ b/packages/frontend/src/views/user/UserProfileView.vue @@ -508,7 +508,10 @@ onUnmounted(() => { :time="formatDate(item.time * 1000)" >
{ + {{ name }}
-
+
记录未包含权限位变更

diff --git a/spec/authentication.spec.md b/spec/authentication.spec.md index 84cb8bf..448ea46 100644 --- a/spec/authentication.spec.md +++ b/spec/authentication.spec.md @@ -104,6 +104,8 @@ The `auth.cpOAuth` configuration object SHALL contain: | `scopes` | string[] | `['openid', 'profile', 'link:luogu']` | Scopes requested from CP OAuth | | `stateExpireSeconds` | number | 600 | Redis TTL for OAuth state and PKCE verifier | +For the checked-in local `config.yml`, `redirectUri` SHALL be `http://127.0.0.1:30010/auth/cp/callback`, which is the configured backend listener, and `frontendRedirectUri` SHALL be `http://localhost:5173/auth/callback`, which is the Vite development-server route. The CP OAuth client registration SHALL contain the same backend `redirectUri` value. + ### 6.2 GET /auth/cp/login Start the CP OAuth authorization code flow. diff --git a/spec/judgement-system.spec.md b/spec/judgement-system.spec.md index 1e08211..68bb4bf 100644 --- a/spec/judgement-system.spec.md +++ b/spec/judgement-system.spec.md @@ -2,7 +2,7 @@ ## 1. Scope -The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, read-only APIs, and one-time import of the legacy SQLite database. +The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, public read APIs, account-owner history hiding, and one-time import of the legacy SQLite database. The legacy Express server, legacy static pages, the legacy SQLite runtime, and browser requests to `jdmt.luogu.me` are outside the integrated runtime. @@ -48,6 +48,19 @@ Table name: `judgement_fetch_log`. Every persisted judgement record SHALL reference an existing fetch log. +### 2.3 Judgement Visibility Request + +Table name: `judgement_visibility_request`. + +| Column | Type | Constraints | Description | +| -------------- | ------------ | ----------- | ------------------------------------------------- | +| `uid` | INT UNSIGNED | PRIMARY KEY | Luogu UID that owns the requested hidden history | +| `hidden_until` | INT UNSIGNED | NOT NULL | Inclusive Luogu event-time cutoff in Unix seconds | +| `created_at` | DATETIME | NOT NULL | First request persistence time | +| `updated_at` | DATETIME | NOT NULL | Most recent request persistence time | + +The table SHALL contain at most one row for one Luogu UID. It SHALL retain the request independently of the judgement records; neither a visibility request nor a hide operation SHALL delete or modify `judgement_record`, its snapshots, or its fetch-log association. + ## 3. Worker Handler Upstream Fetch The `JudgementHandler` module SHALL request the fixed URL `https://www.luogu.com.cn/judgement` itself with: @@ -81,7 +94,7 @@ The method SHALL: The persistence service SHALL not perform upstream HTTP requests or response validation. Runtime logs SHALL contain counts and identifiers but SHALL NOT contain raw responses or snapshots. -## 5. Read-only HTTP API +## 5. HTTP API ### 5.1 Common Pagination and Validation @@ -104,7 +117,9 @@ The endpoint SHALL accept these optional filters: All supplied filters SHALL be combined with AND. `%`, `_`, and the SQL escape character in `name` and `reason` SHALL be treated literally. An omitted time boundary SHALL leave that side of the interval unbounded. If both time boundaries are supplied, `start_time` SHALL NOT exceed `end_time`. Each supplied time boundary SHALL NOT exceed `4294967295`. Results SHALL be ordered by `time DESC, id DESC`. -The endpoint SHALL call `ctx.success` with: +For a record whose `time` is less than or equal to `hidden_until` in the matching visibility-request row, the endpoint SHALL retain its UID, name, user snapshot, event time, fetch-log metadata, and creation time; return `hidden: true`; return `reason: "此记录已被账号所有者要求隐藏"`; and return both permission fields as `0`. It SHALL NOT return `full_record`. + +For every other record, the endpoint SHALL return `hidden: false` and its persisted display fields. The endpoint SHALL call `ctx.success` with list-item fields: ```typescript { @@ -120,6 +135,7 @@ The endpoint SHALL call `ctx.success` with: fetch_log_id: number; log_fetched_at: Date | null; created_at: Date; + hidden: boolean; }>; pagination: { page: number; @@ -132,11 +148,23 @@ The endpoint SHALL call `ctx.success` with: The endpoint SHALL NOT return `full_record`. The `full_record` database column SHALL remain the complete immutable upstream snapshot for persistence and forensic recovery. The list query SHALL NOT select the `full_record` column from the database. -### 5.3 GET /judgement/logs +### 5.3 POST /judgement/hide-mine + +The endpoint SHALL require `Permission.LOGIN`. It SHALL derive the requesting Luogu UID from the authenticated registered user's `luogu_uid`; it SHALL accept no UID or record-ID input. + +On each request, the service SHALL set that UID's `hidden_until` to the current Unix second, creating the visibility-request row when absent. The update SHALL use the greater of the stored cutoff and the current Unix second. It SHALL return `{ uid, hiddenUntil }`. + +The operation applies to every current and later-read record for that UID with an event time not later than `hiddenUntil`; it SHALL not support selecting individual records. A record with an event time after the cutoff, including a record first fetched after this request, SHALL remain visible unless a later request advances the cutoff. + +The frontend judgement page SHALL expose one button labelled `申请删除`. It SHALL require login. Before requesting the endpoint, it SHALL present all of these facts in its confirmation UI: only the current logged-in Luogu account's records are affected; every historical record through the request time is hidden as one set and partial selection is unavailable; records are retained on the server; hidden historical entries show `此记录已被账号所有者要求隐藏` on the public list, user profile, and API; and records created after the request time remain visible. + +After a successful request, the frontend SHALL reload the active judgement query. For a returned item with `hidden: true`, the judgement list SHALL preserve the UID, avatar, username, and time; render `—` for permission changes; and render the API-supplied placeholder reason. The user-profile judgement timeline SHALL preserve its time and other record layout, omit permission-change tags, and render the API-supplied placeholder reason. + +### 5.4 GET /judgement/logs This endpoint SHALL use common pagination, order by `id DESC`, and return fetch-log metadata without `raw_response`. -### 5.4 GET /judgement/stats +### 5.5 GET /judgement/stats This endpoint SHALL return `totalJudgements`, `totalFetchLogs`, `lastFetchAt`, and `lastFetchStatus`. From e894656948e59f22bdb804c121f85bb5312c3a75 Mon Sep 17 00:00:00 2001 From: quanac-lcx Date: Thu, 24 Sep 2026 20:16:03 +0800 Subject: [PATCH 2/3] feat(judgement): move hiding controls to admin --- packages/backend/src/routers/admin.router.ts | 18 ++++ .../backend/src/routers/judgement.router.ts | 12 --- .../backend/src/services/judgement.service.ts | 27 ++++-- packages/backend/src/shared/judgement.ts | 31 +++++++ packages/backend/test/judgement.test.ts | 8 ++ packages/frontend/src/api/admin.ts | 12 +++ packages/frontend/src/api/judgement.ts | 9 -- .../frontend/src/routers/modules/admin.ts | 6 ++ .../frontend/src/views/admin/AdminView.vue | 1 + .../admin/panels/AdminJudgementPanel.vue | 84 ++++++++++++++++++ .../src/views/judgement/JudgementView.vue | 88 ++----------------- spec/judgement-system.spec.md | 18 ++-- 12 files changed, 195 insertions(+), 119 deletions(-) create mode 100644 packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue diff --git a/packages/backend/src/routers/admin.router.ts b/packages/backend/src/routers/admin.router.ts index 53796bb..47dd997 100644 --- a/packages/backend/src/routers/admin.router.ts +++ b/packages/backend/src/routers/admin.router.ts @@ -9,6 +9,8 @@ import { AnnouncementService } from '@/services/announcement.service'; import { SiteNotificationService } from '@/services/site-notification.service'; import { AdvertisementService } from '@/services/advertisement.service'; import { DeletionRequestService } from '@/services/deletion-request.service'; +import { JudgementService } from '@/services/judgement.service'; +import { JudgementQueryError, parseJudgementVisibilityUids } from '@/shared/judgement'; const router = new Router({ prefix: '/admin' }); @@ -190,6 +192,22 @@ router.get( } ); +router.post( + '/judgements/hide', + requiresPermission(Permission.MANAGE_CONTENT), + async (ctx: Context) => { + try { + const { uids } = (ctx.request.body || {}) as { uids?: unknown }; + ctx.success({ + items: await JudgementService.hideHistories(parseJudgementVisibilityUids(uids)) + }); + } catch (error) { + if (!(error instanceof JudgementQueryError)) throw error; + ctx.fail(error.status, error.message); + } + } +); + router.post( '/deletion-requests/:id/approve', requiresPermission(Permission.MANAGE_CONTENT), diff --git a/packages/backend/src/routers/judgement.router.ts b/packages/backend/src/routers/judgement.router.ts index 7f0c4a3..37c4147 100644 --- a/packages/backend/src/routers/judgement.router.ts +++ b/packages/backend/src/routers/judgement.router.ts @@ -1,9 +1,6 @@ import Router from 'koa-router'; import { Context, DefaultState } from 'koa'; import { JudgementService } from '@/services/judgement.service'; -import { requiresPermission } from '@/middlewares/authorization'; -import { Permission } from '@/shared/permission'; -import { RegisteredUserService } from '@/services/registered-user.service'; import { JudgementQueryError, parseJudgementPagination, @@ -34,13 +31,4 @@ router.get('/stats', async (ctx: Context) => { ctx.success(await JudgementService.stats()); }); -router.post('/hide-mine', requiresPermission(Permission.LOGIN), async (ctx: Context) => { - const registeredUser = await RegisteredUserService.getById(ctx.user.id); - if (!registeredUser) { - ctx.fail(401, 'Unauthorized'); - return; - } - ctx.success(await JudgementService.hideHistory(registeredUser.luoguUid)); -}); - export default router; diff --git a/packages/backend/src/services/judgement.service.ts b/packages/backend/src/services/judgement.service.ts index f5e9075..b05a2ad 100644 --- a/packages/backend/src/services/judgement.service.ts +++ b/packages/backend/src/services/judgement.service.ts @@ -195,16 +195,27 @@ export class JudgementService { }; } - static async hideHistory(uid: number) { + static async hideHistories(uids: number[]) { const hiddenUntil = Math.floor(Date.now() / 1000); - const repository = JudgementVisibilityRequest.getRepository(); - const existing = await repository.findOneBy({ uid }); - const request = repository.create({ - uid, - hiddenUntil: Math.max(existing?.hiddenUntil ?? 0, hiddenUntil) + return AppDataSource.transaction(async manager => { + for (const uid of uids) { + await manager.query( + `INSERT INTO judgement_visibility_request (uid, hidden_until) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE + hidden_until = GREATEST(hidden_until, VALUES(hidden_until)), + updated_at = CURRENT_TIMESTAMP`, + [uid, hiddenUntil] + ); + } + const requests = await manager.getRepository(JudgementVisibilityRequest).findBy({ + uid: In(uids) + }); + const hiddenUntilByUid = new Map( + requests.map(request => [request.uid, request.hiddenUntil]) + ); + return uids.map(uid => ({ uid, hiddenUntil: hiddenUntilByUid.get(uid)! })); }); - const saved = await repository.save(request); - return { uid: saved.uid, hiddenUntil: saved.hiddenUntil }; } static async listLogs(query: JudgementPaginationQuery) { diff --git a/packages/backend/src/shared/judgement.ts b/packages/backend/src/shared/judgement.ts index 4f6c5a9..c929f41 100644 --- a/packages/backend/src/shared/judgement.ts +++ b/packages/backend/src/shared/judgement.ts @@ -50,6 +50,37 @@ export interface JudgementPaginationQuery { limit: number; } +export function parseJudgementVisibilityUids(value: unknown): number[] { + if (typeof value !== 'string') { + throw new JudgementQueryError('uids must be a string'); + } + + const values = value + .split(/[\r\n,]+/) + .map(item => item.trim()) + .filter(Boolean); + if (!values.length) { + throw new JudgementQueryError('At least one Luogu UID is required'); + } + + const uids: number[] = []; + const seen = new Set(); + for (const rawUid of values) { + if (!/^[1-9]\d*$/.test(rawUid)) { + throw new JudgementQueryError('uids must contain positive Luogu UIDs'); + } + const uid = Number(rawUid); + if (!Number.isSafeInteger(uid) || uid > UINT32_MAX) { + throw new JudgementQueryError('uids is out of range'); + } + if (!seen.has(uid)) { + seen.add(uid); + uids.push(uid); + } + } + return uids; +} + interface JudgementListRecord { id: number; uid: number; diff --git a/packages/backend/test/judgement.test.ts b/packages/backend/test/judgement.test.ts index 97ff3a9..7e160c6 100644 --- a/packages/backend/test/judgement.test.ts +++ b/packages/backend/test/judgement.test.ts @@ -4,6 +4,7 @@ import { escapeLikeLiteral, LuoguJudgementResponseSchema, parseJudgementQuery, + parseJudgementVisibilityUids, toJudgementListItem } from '../src/shared/judgement'; @@ -62,6 +63,13 @@ describe('judgement domain helpers', () => { expect(() => parseJudgementQuery({ start_time: '4294967296' })).toThrow(); }); + it('parses comma- and newline-separated visibility UIDs', () => { + expect(parseJudgementVisibilityUids(' 3,7\n3\r\n 11,, ')).toEqual([3, 7, 11]); + expect(() => parseJudgementVisibilityUids('3,0')).toThrow(); + expect(() => parseJudgementVisibilityUids('3 abc')).toThrow(); + expect(() => parseJudgementVisibilityUids(' ,\n ')).toThrow(); + }); + it('validates required upstream fields while preserving snapshots', () => { const parsed = LuoguJudgementResponseSchema.parse({ logs: [ diff --git a/packages/frontend/src/api/admin.ts b/packages/frontend/src/api/admin.ts index 3964e19..feb0084 100644 --- a/packages/frontend/src/api/admin.ts +++ b/packages/frontend/src/api/admin.ts @@ -53,6 +53,11 @@ export interface AdminAdvertisement { updatedAt?: string; } +export interface AdminJudgementVisibilityItem { + uid: number; + hiddenUntil: number; +} + interface CreateWorkflowTemplateResponse { workflowId: string; taskIds: Record; @@ -104,6 +109,13 @@ export async function restorePaste(id: string) { })) as ApiResponse<{ id: string; restored: boolean }>; } +export async function hideAdminJudgementHistories(uids: string) { + return (await apiFetch('/admin/judgements/hide', { + method: 'POST', + data: { uids } + })) as ApiResponse<{ items: AdminJudgementVisibilityItem[] }>; +} + export async function getAdminAnnouncement() { return (await apiFetch('/admin/announcement')) as ApiResponse; } diff --git a/packages/frontend/src/api/judgement.ts b/packages/frontend/src/api/judgement.ts index 07e8302..a834308 100644 --- a/packages/frontend/src/api/judgement.ts +++ b/packages/frontend/src/api/judgement.ts @@ -79,12 +79,3 @@ export async function getJudgementLogs( export async function getJudgementStats(): Promise> { return (await apiFetch.get('/judgement/stats')) as ApiResponse; } - -export async function hideMyJudgementHistory(): Promise< - ApiResponse<{ uid: number; hiddenUntil: number }> -> { - return (await apiFetch.post('/judgement/hide-mine')) as ApiResponse<{ - uid: number; - hiddenUntil: number; - }>; -} diff --git a/packages/frontend/src/routers/modules/admin.ts b/packages/frontend/src/routers/modules/admin.ts index 1280fa2..9cd0616 100644 --- a/packages/frontend/src/routers/modules/admin.ts +++ b/packages/frontend/src/routers/modules/admin.ts @@ -21,6 +21,12 @@ export default [ component: () => import('@/views/admin/panels/AdminReviewPanel.vue'), meta: { activeMenu: 'admin', title: '内容审核' } }, + { + path: 'judgements', + name: 'admin-judgements', + component: () => import('@/views/admin/panels/AdminJudgementPanel.vue'), + meta: { activeMenu: 'admin', title: '陶片记录' } + }, { path: 'site', name: 'admin-site', diff --git a/packages/frontend/src/views/admin/AdminView.vue b/packages/frontend/src/views/admin/AdminView.vue index 00a8846..f911a38 100644 --- a/packages/frontend/src/views/admin/AdminView.vue +++ b/packages/frontend/src/views/admin/AdminView.vue @@ -14,6 +14,7 @@ const router = useRouter(); const TABS = [ { name: 'admin', label: '概览' }, { name: 'admin-review', label: '内容审核' }, + { name: 'admin-judgements', label: '陶片记录' }, { name: 'admin-site', label: '公告与通知' }, { name: 'admin-users', label: '用户管理' }, { name: 'admin-ops', label: '系统运维' } diff --git a/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue b/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue new file mode 100644 index 0000000..b3f5b0e --- /dev/null +++ b/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/frontend/src/views/judgement/JudgementView.vue b/packages/frontend/src/views/judgement/JudgementView.vue index a187238..aa47bdf 100644 --- a/packages/frontend/src/views/judgement/JudgementView.vue +++ b/packages/frontend/src/views/judgement/JudgementView.vue @@ -16,21 +16,19 @@ import { NPagination, NSelect, NSpin, - useDialog, useMessage } from 'naive-ui'; import type { DataTableColumns } from 'naive-ui'; -import { Hammer, ExternalLink, RefreshCw, Search, Trash2 } from 'lucide-vue-next'; +import { Hammer, ExternalLink, RefreshCw, Search } from 'lucide-vue-next'; import Card from '@/components/Card.vue'; import CardTitle from '@/components/CardTitle.vue'; import UserBadge from '@/components/UserBadge.vue'; import UserPrizeBadge from '@/components/UserPrizeBadge.vue'; -import { getJudgements, hideMyJudgementHistory, type JudgementItem } from '@/api/judgement.ts'; +import { getJudgements, type JudgementItem } from '@/api/judgement.ts'; import { JUDGEMENT_DISPLAY_OPTIONS_STORAGE_KEY } from '@/utils/constants.ts'; import { formatDate } from '@/utils/render.ts'; import { getJudgementPermissionNames, judgementPermissions } from '@/utils/judgement.ts'; import { useLocalStorage } from '@/composables/useLocalStorage.ts'; -import { isAuthenticated, startCpOAuthLogin } from '@/utils/auth.ts'; const PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500].map(value => ({ label: `${value} 条/页`, @@ -41,9 +39,7 @@ const DEFAULT_PAGE_SIZE = 50; type ExpandedPanel = 'permissions' | 'display' | null; const message = useMessage(); -const dialog = useDialog(); const loading = ref(false); -const hidingHistory = ref(false); const errorMessage = ref(null); const judgements = ref([]); const total = ref(0); @@ -239,53 +235,6 @@ function handleLimitChange(nextLimit: number) { handleSearch(); } -function handleHideHistory() { - if (!isAuthenticated.value) { - dialog.warning({ - title: '需要登录', - content: '申请删除陶片放逐记录需要登录,是否前往登录?', - positiveText: '去登录', - negativeText: '取消', - onPositiveClick: () => startCpOAuthLogin('/judgement') - }); - return; - } - - dialog.warning({ - title: '申请删除陶片放逐记录', - content: () => - h('div', { class: 'hide-history-dialog' }, [ - h('p', '注意:'), - h('ul', [ - h('li', '仅影响当前登录账号绑定的洛谷 UID。'), - h('li', '将一次性隐藏申请时刻之前的全部历史记录,不能选择部分记录。'), - h('li', '原始数据仍保留在数据库中,以满足审计、安全与合规的合理需求。'), - h( - 'li', - '为避免时间线混乱,记录本身仍会显示;但授予或撤销的具体权限及原始原因将被隐藏。公开列表、个人主页和 API 均会显示“此记录已被账号所有者要求隐藏”。申请后新产生的记录不会被隐藏。' - ), - h('li', '点击确认后将立即生效,且无法撤销。') - ]), - h('p', '确认申请吗?') - ]), - positiveText: '确认', - negativeText: '取消', - onPositiveClick: async () => { - hidingHistory.value = true; - try { - const response = await hideMyJudgementHistory(); - if (response.code !== 200) throw new Error(response.message || '申请删除失败'); - message.success('操作成功'); - await loadJudgements(); - } catch (error) { - message.error(getRequestErrorMessage(error) || '申请删除失败'); - } finally { - hidingHistory.value = false; - } - } - }); -} - const columns = computed>(() => { const result: DataTableColumns = []; if (mobileViewport.value) { @@ -514,21 +463,10 @@ onUnmounted(() => {

共 {{ total }} 条记录 -
- - - 刷新 - - - - 申请删除 - -
+ + + 刷新 +
获取记录失败:{{ errorMessage }} @@ -613,16 +551,6 @@ onUnmounted(() => { .load-error { margin-bottom: 12px; } -.hide-history-dialog p { - margin: 0; -} -.hide-history-dialog ul { - margin: 10px 0; - padding-left: 22px; -} -.hide-history-dialog li + li { - margin-top: 6px; -} .filter-actions, .table-toolbar, .pagination-wrap { @@ -674,10 +602,6 @@ onUnmounted(() => { gap: 12px; margin-bottom: 12px; } -.table-toolbar-actions { - display: flex; - gap: 8px; -} .pagination-wrap { justify-content: center; margin-top: 16px; diff --git a/spec/judgement-system.spec.md b/spec/judgement-system.spec.md index 68bb4bf..b43006f 100644 --- a/spec/judgement-system.spec.md +++ b/spec/judgement-system.spec.md @@ -2,7 +2,7 @@ ## 1. Scope -The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, public read APIs, account-owner history hiding, and one-time import of the legacy SQLite database. +The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, public read APIs, administrator-controlled history hiding, and one-time import of the legacy SQLite database. The legacy Express server, legacy static pages, the legacy SQLite runtime, and browser requests to `jdmt.luogu.me` are outside the integrated runtime. @@ -54,7 +54,7 @@ Table name: `judgement_visibility_request`. | Column | Type | Constraints | Description | | -------------- | ------------ | ----------- | ------------------------------------------------- | -| `uid` | INT UNSIGNED | PRIMARY KEY | Luogu UID that owns the requested hidden history | +| `uid` | INT UNSIGNED | PRIMARY KEY | Luogu UID whose history an administrator hid | | `hidden_until` | INT UNSIGNED | NOT NULL | Inclusive Luogu event-time cutoff in Unix seconds | | `created_at` | DATETIME | NOT NULL | First request persistence time | | `updated_at` | DATETIME | NOT NULL | Most recent request persistence time | @@ -148,17 +148,19 @@ For every other record, the endpoint SHALL return `hidden: false` and its persis The endpoint SHALL NOT return `full_record`. The `full_record` database column SHALL remain the complete immutable upstream snapshot for persistence and forensic recovery. The list query SHALL NOT select the `full_record` column from the database. -### 5.3 POST /judgement/hide-mine +### 5.3 POST /admin/judgements/hide -The endpoint SHALL require `Permission.LOGIN`. It SHALL derive the requesting Luogu UID from the authenticated registered user's `luogu_uid`; it SHALL accept no UID or record-ID input. +The endpoint SHALL require `Permission.MANAGE_CONTENT`. The request body SHALL be `{ uids: string }`. -On each request, the service SHALL set that UID's `hidden_until` to the current Unix second, creating the visibility-request row when absent. The update SHALL use the greater of the stored cutoff and the current Unix second. It SHALL return `{ uid, hiddenUntil }`. +`uids` SHALL contain one or more decimal positive Luogu UIDs, separated by commas, CRLF, or LF. Whitespace surrounding a UID is ignored. Empty segments caused by consecutive separators are ignored. Every UID SHALL be an integer from `1` through `4294967295`. Duplicate UIDs SHALL be processed once in first-occurrence order. Any invalid non-empty segment, an empty result, or a non-string body field SHALL produce application error code `400`. -The operation applies to every current and later-read record for that UID with an event time not later than `hiddenUntil`; it SHALL not support selecting individual records. A record with an event time after the cutoff, including a record first fetched after this request, SHALL remain visible unless a later request advances the cutoff. +On each request, the service SHALL set every submitted UID's `hidden_until` to the current Unix second, creating a visibility-request row when absent. An update SHALL retain the greater of the stored cutoff and the current Unix second. The response SHALL be `{ items: Array<{ uid: number; hiddenUntil: number }> }`, in normalized input order. -The frontend judgement page SHALL expose one button labelled `申请删除`. It SHALL require login. Before requesting the endpoint, it SHALL present all of these facts in its confirmation UI: only the current logged-in Luogu account's records are affected; every historical record through the request time is hidden as one set and partial selection is unavailable; records are retained on the server; hidden historical entries show `此记录已被账号所有者要求隐藏` on the public list, user profile, and API; and records created after the request time remain visible. +The operation applies to every current and later-read record for each submitted UID with an event time not later than its `hiddenUntil`; it SHALL not support selecting individual records. A record with an event time after the cutoff, including a record first fetched after this request, SHALL remain visible unless a later administrator request advances the cutoff. -After a successful request, the frontend SHALL reload the active judgement query. For a returned item with `hidden: true`, the judgement list SHALL preserve the UID, avatar, username, and time; render `—` for permission changes; and render the API-supplied placeholder reason. The user-profile judgement timeline SHALL preserve its time and other record layout, omit permission-change tags, and render the API-supplied placeholder reason. +The public judgement page SHALL NOT expose an account-owner deletion or hiding action. The administrator console SHALL expose a `MANAGE_CONTENT`-guarded control that accepts comma- or newline-separated UIDs and invokes this endpoint. + +For a returned item with `hidden: true`, the judgement list SHALL preserve the UID, avatar, username, and time; render `—` for permission changes; and render the API-supplied placeholder reason. The user-profile judgement timeline SHALL preserve its time and other record layout, omit permission-change tags, and render the API-supplied placeholder reason. ### 5.4 GET /judgement/logs From 048a3101f8029f8e4a27d0ff92db27968bdb0068 Mon Sep 17 00:00:00 2001 From: quanac-lcx Date: Thu, 24 Sep 2026 20:35:24 +0800 Subject: [PATCH 3/3] feat(admin): add judgement management navigation --- packages/frontend/src/routers/modules/admin.ts | 6 ++++++ .../src/views/admin/panels/AdminOverviewPanel.vue | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/routers/modules/admin.ts b/packages/frontend/src/routers/modules/admin.ts index 9cd0616..c3442e3 100644 --- a/packages/frontend/src/routers/modules/admin.ts +++ b/packages/frontend/src/routers/modules/admin.ts @@ -39,6 +39,12 @@ export default [ component: () => import('@/views/admin/panels/AdminUsersPanel.vue'), meta: { activeMenu: 'admin', title: '用户管理' } }, + { + path: 'judgements', + name: 'admin-judgements', + component: () => import('@/views/admin/panels/AdminJudgementPanel.vue'), + meta: { activeMenu: 'admin', title: '陶片管理' } + }, { path: 'ops', name: 'admin-ops', diff --git a/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue b/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue index e008a09..f975ed1 100644 --- a/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue +++ b/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import { NBadge, NIcon, NSpace, NTag } from 'naive-ui'; -import { CircleCheck, Wrench, Megaphone, Users } from 'lucide-vue-next'; +import { CircleCheck, Wrench, Megaphone, Users, Hammer } from 'lucide-vue-next'; import Card from '@/components/Card.vue'; import { getAdminDeletionRequests } from '@/api/deletion-request.ts'; import { currentAuth } from '@/utils/auth.ts'; @@ -58,6 +58,13 @@ const entries = computed(() => [ icon: Users, badge: 0 }, + { + name: 'admin-judgements', + title: '陶片管理', + description: '隐藏陶片放逐记录', + icon: Hammer, + badge: 0 + }, { name: 'admin-ops', title: '系统运维',