From 4b4bc898bd49dab0bce064995b729a24d0409e39 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 25 Jun 2026 23:48:29 +0800 Subject: [PATCH] Support editing user space quota in admin --- src/admin/pages/UserDetailPage.tsx | 72 ++- src/api/client.ts | 1 + src/lib/routes.ts | 9 + src/ops/components/OpsLayout.tsx | 93 ++++ src/ops/lib/site.ts | 4 + src/ops/ops.css | 710 +++++++++++++++++++++++++++++ src/ops/pages/CreatorsPage.tsx | 95 ++++ src/ops/pages/FeaturedPage.tsx | 88 ++++ src/ops/pages/ReportsPage.tsx | 83 ++++ src/ops/pages/ReviewPage.tsx | 238 ++++++++++ 10 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 src/lib/routes.ts create mode 100644 src/ops/components/OpsLayout.tsx create mode 100644 src/ops/lib/site.ts create mode 100644 src/ops/ops.css create mode 100644 src/ops/pages/CreatorsPage.tsx create mode 100644 src/ops/pages/FeaturedPage.tsx create mode 100644 src/ops/pages/ReportsPage.tsx create mode 100644 src/ops/pages/ReviewPage.tsx diff --git a/src/admin/pages/UserDetailPage.tsx b/src/admin/pages/UserDetailPage.tsx index 467745d..987a5c4 100644 --- a/src/admin/pages/UserDetailPage.tsx +++ b/src/admin/pages/UserDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, Navigate, useParams } from 'react-router-dom'; import { rechargeUser, updateAdminUser } from '../../api/client'; import { CapabilitySettings } from '../../components/CapabilitySettings'; @@ -7,13 +7,30 @@ import { SkillSettings } from '../../components/SkillSettings'; import { useAdminUsers } from '../hooks/useAdminUsers'; import { formatYuan } from '../utils/format'; +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + export function UserDetailPage() { const { userId = '' } = useParams(); const { users, loading, error, reload, setError } = useAdminUsers(); const [message, setMessage] = useState(null); const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' }); + const [spaceQuotaMb, setSpaceQuotaMb] = useState('5'); const user = users.find((row) => row.id === userId); + const userSpace = (user ?? null) as (typeof user & { + spaceQuotaBytes?: number; + spaceUsedBytes?: number; + spaceAvailableBytes?: number; + }) | null; + + useEffect(() => { + if (!userSpace?.spaceQuotaBytes) return; + setSpaceQuotaMb(String(Math.max(1, Math.round(userSpace.spaceQuotaBytes / 1024 / 1024)))); + }, [userSpace?.spaceQuotaBytes]); const handleRecharge = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,6 +52,28 @@ export function UserDetailPage() { return ; } + const currentQuotaMb = Math.max( + 1, + Math.round((userSpace?.spaceQuotaBytes ?? 5 * 1024 * 1024) / 1024 / 1024), + ); + + const handleSpaceQuotaSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + setMessage(null); + setError(null); + try { + const quotaMb = Math.floor(Number(spaceQuotaMb)); + await updateAdminUser(user.id, { + spaceQuotaBytes: quotaMb * 1024 * 1024, + }); + setMessage('空间已更新'); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : '空间更新失败'); + } + }; + return (
@@ -74,6 +113,19 @@ export function UserDetailPage() {
余额
¥{formatYuan(user.balanceCents)}
+
+
空间配额
+
+ {userSpace?.spaceQuotaBytes ? formatBytes(userSpace.spaceQuotaBytes) : '—'} + {userSpace?.spaceUsedBytes !== undefined && ( + + {' '} + · 已用 {formatBytes(userSpace.spaceUsedBytes)} · 剩余{' '} + {formatBytes(userSpace.spaceAvailableBytes ?? 0)} + + )} +
+
工作目录
{user.workspaceRoot}
@@ -102,6 +154,24 @@ export function UserDetailPage() { +
+

调整空间

+

按 MB 设置用户空间总额度,前台购买会在这个基础上继续累加。

+
+ setSpaceQuotaMb(e.target.value)} + /> + +
+

当前总额约 {currentQuotaMb} MB。

+
+ diff --git a/src/api/client.ts b/src/api/client.ts index d0c36c8..d0e69e1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -351,6 +351,7 @@ export async function updateAdminUser( status: 'active' | 'suspended' | 'disabled'; workspaceRoot: string; balanceCents: number; + spaceQuotaBytes: number; role: 'user' | 'admin'; }>, ): Promise { diff --git a/src/lib/routes.ts b/src/lib/routes.ts new file mode 100644 index 0000000..4c24fe4 --- /dev/null +++ b/src/lib/routes.ts @@ -0,0 +1,9 @@ +export const routerBasename = (() => { + const raw = String(import.meta.env.VITE_BASE_PATH ?? '').trim().replace(/\/$/, ''); + if (!raw || raw === '/') return ''; + return raw.startsWith('/') ? raw : `/${raw}`; +})(); + +export function defaultHomePath(role: string | undefined) { + return role === 'admin' ? '/' : '/ops'; +} diff --git a/src/ops/components/OpsLayout.tsx b/src/ops/components/OpsLayout.tsx new file mode 100644 index 0000000..f1e26f8 --- /dev/null +++ b/src/ops/components/OpsLayout.tsx @@ -0,0 +1,93 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import type { PortalUser } from '../../types'; +import '../ops.css'; + +const opsSections = [ + { + label: '内容处理', + items: [{ to: '/ops', label: '审核队列', end: true }], + }, + { + label: '内容管理', + items: [ + { to: '/ops/reports', label: '举报处理' }, + { to: '/ops/featured', label: '精选管理' }, + { to: '/ops/creators', label: '创作者' }, + ], + }, + { + label: '数据洞察', + items: [{ to: '/ops/analytics', label: '数据看板' }], + }, +]; + +export function OpsLayout({ + user, + onLogout, +}: { + user: PortalUser; + onLogout: () => void; +}) { + return ( +
+
+ + +
+
+
+

Operations Workspace

+

Plaza 运营工作台

+
+

左侧导航固定,主区聚焦处理任务

+
+ +
+
+
+ ); +} diff --git a/src/ops/lib/site.ts b/src/ops/lib/site.ts new file mode 100644 index 0000000..affb52c --- /dev/null +++ b/src/ops/lib/site.ts @@ -0,0 +1,4 @@ +export function plazaPreviewUrl(postId: string): string { + const base = String(import.meta.env.VITE_PLAZA_BASE ?? 'https://plaza.tkmind.cn').replace(/\/$/, ''); + return `${base}/plaza/p/${encodeURIComponent(postId)}`; +} diff --git a/src/ops/ops.css b/src/ops/ops.css new file mode 100644 index 0000000..c03a6c3 --- /dev/null +++ b/src/ops/ops.css @@ -0,0 +1,710 @@ +:root { + color-scheme: dark; + color: #e9efe9; + background: + radial-gradient(circle at top left, rgba(78, 168, 120, 0.16), transparent 36%), + radial-gradient(circle at top right, rgba(34, 197, 94, 0.1), transparent 32%), + linear-gradient(180deg, #08120f 0%, #0f1714 100%); + font-family: + 'Avenir Next', + 'PingFang SC', + 'Noto Sans SC', + 'Helvetica Neue', + system-ui, + sans-serif; + font-size: 15px; + line-height: 1.5; + font-weight: 400; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --ops-bg: #08120f; + --ops-surface: rgba(14, 24, 21, 0.88); + --ops-surface-strong: rgba(18, 30, 26, 0.98); + --ops-surface-soft: rgba(255, 255, 255, 0.04); + --ops-border: rgba(170, 196, 182, 0.16); + --ops-border-strong: rgba(170, 196, 182, 0.28); + --ops-text: #edf5ef; + --ops-text-muted: #a5b6ab; + --ops-text-soft: #7f9386; + --ops-accent: #66c79b; + --ops-accent-strong: #3f9b73; + --ops-danger: #ff6d5e; + --ops-warn: #f7b955; + --ops-shadow: 0 24px 64px rgba(0, 0, 0, 0.32); +} + +* { + box-sizing: border-box; +} + +html { + background: var(--ops-bg); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ops-text); + background: + radial-gradient(circle at top left, rgba(102, 199, 155, 0.14), transparent 24%), + radial-gradient(circle at 80% 0%, rgba(90, 140, 120, 0.12), transparent 22%), + var(--ops-bg); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 2px solid rgba(102, 199, 155, 0.9); + outline-offset: 2px; +} + +.layout { + position: relative; + max-width: 1280px; + margin: 0 auto; + padding: 28px 18px 56px; +} + +.layout::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.4), transparent 82%); + opacity: 0.35; +} + +.page-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 24px; + margin-bottom: 18px; + border: 1px solid var(--ops-border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--ops-surface); + box-shadow: var(--ops-shadow); + backdrop-filter: blur(14px); +} + +.page-title { + margin: 0; + font-size: clamp(1.8rem, 2vw, 2.4rem); + line-height: 1.1; + letter-spacing: -0.03em; +} + +.page-subtitle, +.muted { + color: var(--ops-text-muted); +} + +.caption { + font-size: 12px; + line-height: 1.4; +} + +.page-subtitle { + margin: 8px 0 0; +} + +.page-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding: 5px 10px; + border: 1px solid rgba(102, 199, 155, 0.2); + border-radius: 999px; + background: rgba(102, 199, 155, 0.08); + color: #c7eadb; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-line { + margin: 10px 0 0; + font-size: 13px; +} + +.ops-shell { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.ops-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 16px; + align-self: start; + padding: 18px; + border: 1px solid var(--ops-border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--ops-surface); + box-shadow: var(--ops-shadow); + backdrop-filter: blur(14px); +} + +.ops-brand { + padding-bottom: 2px; +} + +.ops-brand-title { + margin: 8px 0 0; + font-size: 28px; + line-height: 1.05; + letter-spacing: -0.04em; +} + +.ops-brand-subtitle { + margin: 10px 0 0; + color: var(--ops-text-muted); + font-size: 13px; +} + +.ops-identity { + display: grid; + gap: 4px; + padding: 14px; + background: var(--ops-surface-strong); +} + +.ops-identity-label, +.ops-topbar-kicker { + font-size: 12px; + color: var(--ops-text-soft); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.ops-identity-name { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.ops-identity-meta { + color: var(--ops-text-muted); + font-size: 13px; +} + +.ops-nav { + display: flex; + flex-direction: column; + gap: 8px; + padding: 2px 0; +} + +.ops-nav-section { + display: grid; + gap: 8px; +} + +.ops-nav-section + .ops-nav-section { + margin-top: 8px; +} + +.ops-nav-section-label { + padding: 0 2px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ops-text-soft); +} + +.ops-nav-section-items { + display: grid; + gap: 8px; +} + +.ops-nav-link { + display: flex; + align-items: center; + min-height: 48px; + padding: 0 14px; + border: 1px solid var(--ops-border); + border-radius: 16px; + color: var(--ops-text-muted); + background: rgba(255, 255, 255, 0.03); +} + +.ops-nav-link:hover { + color: var(--ops-text); + border-color: var(--ops-border-strong); + background: rgba(255, 255, 255, 0.06); +} + +.ops-nav-link.active { + color: #08120f; + border-color: transparent; + background: linear-gradient(180deg, #7ce2b3, #5dc590); + box-shadow: 0 12px 24px rgba(61, 168, 117, 0.18); +} + +.ops-sidebar-footer { + display: grid; + gap: 10px; +} + +.ops-back-link { + justify-content: center; +} + +.ops-back-link.active { + color: var(--ops-text); + border-color: var(--ops-border-strong); + background: rgba(255, 255, 255, 0.08); +} + +.ops-main { + min-width: 0; + display: grid; + gap: 16px; +} + +.ops-topbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border: 1px solid var(--ops-border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--ops-surface); + box-shadow: var(--ops-shadow); + backdrop-filter: blur(14px); +} + +.ops-topbar-title { + margin: 6px 0 0; + font-size: 22px; + letter-spacing: -0.03em; +} + +.ops-topbar-note { + margin: 0; + color: var(--ops-text-muted); + font-size: 13px; +} + +.ops-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.ops-toolbar .search-input { + flex: 1 1 280px; + min-width: 220px; +} + +.ops-table-wrap { + overflow: auto; + border: 1px solid var(--ops-border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.02); +} + +.ops-table { + width: 100%; + border-collapse: collapse; + min-width: 920px; +} + +.ops-table th, +.ops-table td { + padding: 14px 16px; + border-bottom: 1px solid rgba(170, 196, 182, 0.09); + vertical-align: top; + text-align: left; +} + +.ops-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(10, 17, 14, 0.96); + color: var(--ops-text-muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ops-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.ops-table-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ops-cell-muted { + color: var(--ops-text-muted); +} + +.ops-stack-mini { + display: grid; + gap: 6px; +} + +.ops-checkbox-cell { + width: 42px; +} + +.ops-status-chip { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + color: var(--ops-text); + font-size: 12px; + font-weight: 700; +} + +.ops-status-chip.warn { + background: rgba(247, 185, 85, 0.12); + color: #ffd895; +} + +.ops-summary-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; + align-items: center; +} + +.grid { + display: grid; + gap: 16px; +} + +.card { + border: 1px solid var(--ops-border); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--ops-surface); + box-shadow: var(--ops-shadow); + backdrop-filter: blur(14px); + padding: 18px; +} + +.card h1, +.card h2, +.card h3, +.card h4, +.card p { + margin-top: 0; +} + +.card h3 { + margin-bottom: 10px; + font-size: 18px; + letter-spacing: -0.02em; +} + +.card p { + color: var(--ops-text); +} + +.card strong { + color: #f9fbf9; +} + +.card ul { + margin: 0; + padding-left: 18px; + color: var(--ops-text-muted); +} + +.card li + li { + margin-top: 10px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.toolbar > * { + min-width: 0; +} + +.search-input { + flex: 1 1 240px; + min-width: 200px; +} + +.stack { + display: grid; + gap: 12px; +} + +.split { + display: flex; + justify-content: space-between; + gap: 14px; +} + +.split > div { + min-width: 0; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid transparent; + background: linear-gradient(180deg, var(--ops-accent), var(--ops-accent-strong)); + color: #07110d; + font-weight: 700; + cursor: pointer; + box-shadow: 0 10px 22px rgba(61, 168, 117, 0.22); +} + +.btn:hover:not(:disabled) { + box-shadow: 0 14px 28px rgba(61, 168, 117, 0.28); +} + +.btn.secondary { + border-color: var(--ops-border-strong); + background: rgba(255, 255, 255, 0.04); + color: var(--ops-text); + box-shadow: none; +} + +.btn.secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.btn.danger { + border-color: rgba(255, 109, 94, 0.22); + background: linear-gradient(180deg, #ff8578, #ff6251); + color: #180a08; + box-shadow: 0 10px 22px rgba(255, 109, 94, 0.18); +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.45; + transform: none; + box-shadow: none; +} + +.ghost-btn, +.ghost-btn.secondary { + border: 1px solid var(--ops-border-strong); + background: rgba(255, 255, 255, 0.03); + color: var(--ops-text); + box-shadow: none; +} + +.ghost-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.logout-btn { + min-width: 96px; +} + +input, +select, +textarea { + width: 100%; + min-height: 42px; + border: 1px solid var(--ops-border); + border-radius: 14px; + padding: 0 14px; + color: var(--ops-text); + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +textarea { + min-height: 108px; + padding: 12px 14px; + resize: vertical; +} + +input::placeholder, +textarea::placeholder { + color: var(--ops-text-soft); +} + +input:focus, +select:focus, +textarea:focus { + border-color: rgba(102, 199, 155, 0.55); + box-shadow: 0 0 0 4px rgba(102, 199, 155, 0.16); +} + +input[type='checkbox'] { + width: 18px; + height: 18px; + min-height: 18px; + padding: 0; + accent-color: var(--ops-accent); +} + +.warn, +.alert { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 8px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.warn { + border: 1px solid rgba(247, 185, 85, 0.22); + background: rgba(247, 185, 85, 0.1); + color: #ffd895; +} + +.alert { + border: 1px solid rgba(255, 109, 94, 0.22); + background: rgba(255, 109, 94, 0.1); + color: #ffc1ba; +} + +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.metric { + padding: 16px; + border: 1px solid var(--ops-border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); +} + +.metric p { + margin-bottom: 6px; + color: var(--ops-text-muted); + font-size: 13px; +} + +.metric strong { + display: block; + font-size: 30px; + line-height: 1; + letter-spacing: -0.04em; +} + +.section-title { + margin: 0 0 8px; + font-size: 14px; + color: var(--ops-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 180px; + color: var(--ops-text-muted); + text-align: center; +} + +.link-button { + color: var(--ops-accent); +} + +@media (max-width: 860px) { + .layout { + padding: 18px 14px 36px; + } + + .ops-shell { + grid-template-columns: 1fr; + } + + .ops-sidebar { + position: static; + } + + .page-header, + .card { + padding: 16px; + border-radius: 18px; + } + + .ops-topbar { + align-items: flex-start; + flex-direction: column; + } + + .ops-table { + min-width: 760px; + } + + .split { + flex-direction: column; + } + + .toolbar { + align-items: stretch; + } +} diff --git a/src/ops/pages/CreatorsPage.tsx b/src/ops/pages/CreatorsPage.tsx new file mode 100644 index 0000000..aae9164 --- /dev/null +++ b/src/ops/pages/CreatorsPage.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { fetchCreators, updateCreator } from '../api/client'; + +export function CreatorsPage() { + const [creators, setCreators] = useState>['creators']>([]); + const [keyword, setKeyword] = useState(''); + const [error, setError] = useState(null); + + const load = async () => { + try { + const data = await fetchCreators(keyword); + setCreators(data.creators); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { + void load(); + }, []); + + return ( +
+
+ setKeyword(e.target.value)} placeholder="搜索创作者" /> + +
+ {error ?

{error}

: null} +
+ + + + + + + + + + + {creators.map((creator) => ( + + + + + + + ))} + +
创作者数据状态操作
+
+ {creator.display_name} + @{creator.slug} +
+
+
+ {creator.post_count} 篇帖子 + {creator.follower_count} 粉丝 +
+
+
+ + {creator.verified ? '已认证' : '未认证'} + + + {creator.post_banned ? '禁发中' : '可发帖'} + +
+
+
+ + +
+
+
+
+ ); +} diff --git a/src/ops/pages/FeaturedPage.tsx b/src/ops/pages/FeaturedPage.tsx new file mode 100644 index 0000000..65f887c --- /dev/null +++ b/src/ops/pages/FeaturedPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { fetchFeatured, removeFeatured, setFeatured } from '../api/client'; + +export function FeaturedPage() { + const [items, setItems] = useState>['items']>([]); + const [postId, setPostId] = useState(''); + const [position, setPosition] = useState('trending'); + const [error, setError] = useState(null); + + const load = async () => { + try { + const data = await fetchFeatured(); + setItems(data.items); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { + void load(); + }, []); + + return ( +
+
+

添加精选

+
+ setPostId(e.target.value)} placeholder="帖子 ID" /> + + +
+
+ {error ?

{error}

: null} +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
位置内容作者操作
+ {item.position} + +
+ {item.title} + 帖子 ID:{item.post_id} +
+
{item.author} +
+ +
+
+
+
+ ); +} diff --git a/src/ops/pages/ReportsPage.tsx b/src/ops/pages/ReportsPage.tsx new file mode 100644 index 0000000..625aeb6 --- /dev/null +++ b/src/ops/pages/ReportsPage.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { fetchReports, processReport } from '../api/client'; + +export function ReportsPage() { + const [reports, setReports] = useState>['reports']>([]); + const [error, setError] = useState(null); + + const load = async () => { + try { + const data = await fetchReports(); + setReports(data.reports); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { + void load(); + }, []); + + return ( +
+ {error ?

{error}

: null} + {reports.length === 0 ?
暂无待处理举报
: null} +
+ + + + + + + + + + + + {reports.map((report) => ( + + + + + + + + ))} + +
目标原因详情频次操作
+
+ {report.target_type} + 目标 ID:{report.target_id} +
+
+
+ {report.reason} +
+
{report.detail || '无补充说明'} + 3 ? ' warn' : ''}`}> + {report.target_report_count > 3 ? `高频 (${report.target_report_count})` : `${report.target_report_count} 次`} + + +
+ + +
+
+
+
+ ); +} diff --git a/src/ops/pages/ReviewPage.tsx b/src/ops/pages/ReviewPage.tsx new file mode 100644 index 0000000..57d33c8 --- /dev/null +++ b/src/ops/pages/ReviewPage.tsx @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useState } from 'react'; +import { fetchReviewQueue, reviewPost, batchReviewPosts, type ReviewPost } from '../api/client'; +import { plazaPreviewUrl } from '../lib/site'; + +const TABS = [ + { key: 'pending_review', label: '待审核' }, + { key: 'published', label: '已通过' }, + { key: 'rejected', label: '已拒绝' }, +] as const; + +type TabKey = (typeof TABS)[number]['key']; + +export function ReviewPage() { + const [tab, setTab] = useState('pending_review'); + const [posts, setPosts] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [batchBusy, setBatchBusy] = useState(false); + const [keyword, setKeyword] = useState(''); + + const load = async (status: TabKey = tab, search = keyword) => { + setError(null); + try { + const query = new URLSearchParams({ status }); + if (search.trim()) query.set('keyword', search.trim()); + const data = await fetchReviewQueue(query.toString()); + setPosts(data.posts); + setSelected(new Set()); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { + void load(tab); + }, [tab]); + + const pendingCount = useMemo( + () => (tab === 'pending_review' ? posts.length : null), + [tab, posts.length], + ); + + const allSelected = useMemo( + () => posts.length > 0 && posts.every((post) => selected.has(post.id)), + [posts, selected], + ); + + const toggleSelect = (id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (allSelected) { + setSelected(new Set()); + return; + } + setSelected(new Set(posts.map((post) => post.id))); + }; + + const handleReview = async (id: string, action: 'approve' | 'reject') => { + setBusyId(id); + try { + const reason = + action === 'reject' + ? window.prompt('拒绝原因(必填)', '低质内容') ?? undefined + : undefined; + if (action === 'reject' && !reason) return; + await reviewPost(id, action, reason); + await load(tab); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusyId(null); + } + }; + + const handleBatchApprove = async () => { + const ids = [...selected]; + if (ids.length === 0) return; + if (!window.confirm(`确认批量通过 ${ids.length} 条帖子?`)) return; + setBatchBusy(true); + setError(null); + try { + await batchReviewPosts(ids, 'approve'); + await load(tab); + } catch (err) { + setError(err instanceof Error ? err.message : '批量操作失败'); + } finally { + setBatchBusy(false); + } + }; + + return ( +
+
+
+
+ {TABS.map((item) => ( + + ))} +
+ {tab === 'pending_review' && posts.length > 0 ? ( +
+ + +
+ ) : null} +
+
+ setKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void load(tab, keyword); + }} + className="search-input" + /> + +
+
+ {error ?

{error}

: null} + {posts.length === 0 ?
暂无{tab === 'pending_review' ? '待审核' : ''}帖子
: null} +
+ + + + {tab === 'pending_review' ? : null} + + + + + + + + + {posts.map((post) => ( + + {tab === 'pending_review' ? ( + + ) : null} + + + + + + + ))} + +
标题分类 / 作者摘要状态操作
+ toggleSelect(post.id)} + aria-label={`选择 ${post.title}`} + /> + +
+ {post.title} + + 预览帖子 + +
+
+
+
{post.category.icon} {post.category.name}
+
@{post.author.slug}
+
+
+
+
{post.summary || '无摘要'}
+
+
+
+ + {tab === 'pending_review' ? '待处理' : tab === 'published' ? '已通过' : '已拒绝'} + + {post.sla_warning ? 已超过 2 小时未审核 : null} +
+
+
+ {tab === 'pending_review' ? ( + <> + + + + ) : null} + + 查看 + +
+
+
+
+ ); +}