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
17 changes: 17 additions & 0 deletions packages/backend/src/entities/judgement-visibility-request.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions packages/backend/src/routers/admin.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultState, Context>({ prefix: '/admin' });

Expand Down Expand Up @@ -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),
Expand Down
36 changes: 35 additions & 1 deletion packages/backend/src/services/judgement.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -184,6 +195,29 @@ export class JudgementService {
};
}

static async hideHistories(uids: number[]) {
const hiddenUntil = Math.floor(Date.now() / 1000);
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)! }));
});
}

static async listLogs(query: JudgementPaginationQuery) {
const [logs, total] = await JudgementFetchLog.getRepository().findAndCount({
order: { id: 'DESC' },
Expand Down
51 changes: 50 additions & 1 deletion packages/backend/src/shared/judgement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
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;
Expand All @@ -64,10 +95,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,
Expand Down
12 changes: 12 additions & 0 deletions packages/frontend/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export interface AdminAdvertisement {
updatedAt?: string;
}

export interface AdminJudgementVisibilityItem {
uid: number;
hiddenUntil: number;
}

interface CreateWorkflowTemplateResponse {
workflowId: string;
taskIds: Record<string, string>;
Expand Down Expand Up @@ -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<Announcement>;
}
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/api/judgement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface JudgementItem {
fetch_log_id: number;
log_fetched_at: string | null;
created_at: string;
hidden: boolean;
}

export interface JudgementUser {
Expand Down
12 changes: 12 additions & 0 deletions packages/frontend/src/routers/modules/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -33,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',
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/views/admin/AdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: '系统运维' }
Expand Down
84 changes: 84 additions & 0 deletions packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { NAlert, NButton, NInput, useDialog, useMessage } from 'naive-ui';
import Card from '@/components/Card.vue';
import { hideAdminJudgementHistories } from '@/api/admin.ts';
import { currentAuth } from '@/utils/auth.ts';
import { hasPermission, Permission } from '@/utils/permissions.ts';

const message = useMessage();
const dialog = useDialog();
const uidInput = ref('');
const submitting = ref(false);
const canManageContent = computed(() =>
hasPermission(currentAuth.value?.role, Permission.MANAGE_CONTENT)
);

function submit() {
if (!uidInput.value.trim()) {
message.warning('请输入至少一个洛谷 UID');
return;
}

dialog.warning({
title: '隐藏陶片放逐记录',
content: '提交后会立即隐藏所有输入 UID 在当前时刻之前的记录,且无法撤销。确认继续吗?',
positiveText: '确认隐藏',
negativeText: '取消',
onPositiveClick: async () => {
submitting.value = true;
try {
const response = await hideAdminJudgementHistories(uidInput.value);
if (response.code !== 200) throw new Error(response.message || '隐藏记录失败');
message.success(`已隐藏 ${response.data.items.length} 个 UID 的历史记录`);
uidInput.value = '';
} catch (error) {
message.error(error instanceof Error ? error.message : '隐藏记录失败');
} finally {
submitting.value = false;
}
}
});
}
</script>

<template>
<div class="admin-judgement-panel">
<n-alert v-if="!canManageContent" type="warning" title="缺少 MANAGE_CONTENT">
当前账号没有管理陶片放逐记录的权限。
</n-alert>

<Card v-else title="隐藏陶片放逐记录">
<p class="description">
输入一个或多个洛谷 UID。可使用英文逗号或换行分隔;所有 UID 的历史记录会立即隐藏。
</p>
<n-input
v-model:value="uidInput"
type="textarea"
placeholder="例如:123456, 234567&#10;345678"
:autosize="{ minRows: 5, maxRows: 12 }"
/>
<div class="actions">
<n-button type="error" :loading="submitting" @click="submit">隐藏历史记录</n-button>
</div>
</Card>
</div>
</template>

<style scoped>
.admin-judgement-panel {
display: grid;
gap: 16px;
}

.description {
margin: 0 0 12px;
color: var(--ui-secondary-text-color);
}

.actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +58,13 @@ const entries = computed(() => [
icon: Users,
badge: 0
},
{
name: 'admin-judgements',
title: '陶片管理',
description: '隐藏陶片放逐记录',
icon: Hammer,
badge: 0
},
{
name: 'admin-ops',
title: '系统运维',
Expand Down
9 changes: 8 additions & 1 deletion packages/frontend/src/views/judgement/JudgementView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,14 @@ const columns = computed<DataTableColumns<JudgementItem>>(() => {
}
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: '头像',
Expand Down
7 changes: 5 additions & 2 deletions packages/frontend/src/views/user/UserProfileView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,10 @@ onUnmounted(() => {
:time="formatDate(item.time * 1000)"
>
<div
v-if="item.revoked_permission || item.added_permission"
v-if="
!item.hidden &&
(item.revoked_permission || item.added_permission)
"
class="judgement-permissions"
>
<n-tag
Expand All @@ -533,7 +536,7 @@ onUnmounted(() => {
+ {{ name }}
</n-tag>
</div>
<div v-else class="judgement-no-permission">
<div v-else-if="!item.hidden" class="judgement-no-permission">
记录未包含权限位变更
</div>
<p v-if="item.reason" class="judgement-reason">
Expand Down
2 changes: 2 additions & 0 deletions spec/authentication.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading