feat: add admin work visibility controls
This commit is contained in:
@@ -13,10 +13,13 @@ import type {
|
||||
AdminOverviewResponse,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
@@ -194,6 +197,27 @@ export function upsertAdminCreationEntryConfig(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminWorkVisibility(token: string) {
|
||||
return request<AdminWorkVisibilityListResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminWorkVisibility(
|
||||
token: string,
|
||||
payload: AdminUpdateWorkVisibilityRequest,
|
||||
) {
|
||||
return request<AdminUpdateWorkVisibilityResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
|
||||
@@ -177,6 +177,36 @@ export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
categorySortOrder: number;
|
||||
}
|
||||
|
||||
export interface AdminWorkVisibilityEntryPayload {
|
||||
sourceType: string;
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
coverImageSrc?: string | null;
|
||||
visible: boolean;
|
||||
publishedAtMicros?: number | null;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminWorkVisibilityListResponse {
|
||||
entries: AdminWorkVisibilityEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateWorkVisibilityRequest {
|
||||
sourceType: string;
|
||||
profileId: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUpdateWorkVisibilityResponse {
|
||||
entry: AdminWorkVisibilityEntryPayload;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
|
||||
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
@@ -205,6 +206,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'work-visibility' ? (
|
||||
<AdminWorkVisibilityPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'tasks' ? (
|
||||
<AdminTaskConfigPage
|
||||
result={taskConfigResult}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BadgeDollarSign,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
SlidersHorizontal,
|
||||
@@ -35,6 +36,7 @@ const routeIcons = {
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -7,7 +7,8 @@ export type AdminRouteId =
|
||||
| 'invite'
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'creation-entry';
|
||||
| 'creation-entry'
|
||||
| 'work-visibility';
|
||||
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
@@ -25,6 +26,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
];
|
||||
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
|
||||
269
apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx
Normal file
269
apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {Eye, EyeOff, RefreshCcw} from 'lucide-react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {
|
||||
listAdminWorkVisibility,
|
||||
updateAdminWorkVisibility,
|
||||
} from '../api/adminApiClient';
|
||||
import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes';
|
||||
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
interface AdminWorkVisibilityPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
puzzle: '拼图',
|
||||
'custom-world': '自定义世界',
|
||||
'jump-hop': '跳一跳',
|
||||
'wooden-fish': '敲木鱼',
|
||||
match3d: '抓大鹅',
|
||||
'square-hole': '方洞挑战',
|
||||
'visual-novel': '视觉小说',
|
||||
'big-fish': '大鱼吃小鱼',
|
||||
'bark-battle': '汪汪声浪',
|
||||
};
|
||||
|
||||
export function AdminWorkVisibilityPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminWorkVisibilityPageProps) {
|
||||
const [entries, setEntries] = useState<AdminWorkVisibilityEntryPayload[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [savingKey, setSavingKey] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEntries();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return entries;
|
||||
}
|
||||
return entries.filter((entry) =>
|
||||
[
|
||||
entry.sourceType,
|
||||
sourceLabels[entry.sourceType] ?? '',
|
||||
entry.title,
|
||||
entry.subtitle,
|
||||
entry.authorDisplayName,
|
||||
entry.publicWorkCode,
|
||||
entry.profileId,
|
||||
entry.workId,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword),
|
||||
);
|
||||
}, [entries, keyword]);
|
||||
|
||||
async function refreshEntries() {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminWorkVisibility(token);
|
||||
setEntries(sortEntries(response.entries));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(entry: AdminWorkVisibilityEntryPayload) {
|
||||
const nextVisible = !entry.visible;
|
||||
const target = entry.title.trim() || entry.publicWorkCode || entry.profileId;
|
||||
const confirmed = await confirmWrite({
|
||||
action: nextVisible ? '显示作品' : '隐藏作品',
|
||||
target,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = buildEntryKey(entry);
|
||||
setSavingKey(rowKey);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await updateAdminWorkVisibility(token, {
|
||||
sourceType: entry.sourceType,
|
||||
profileId: entry.profileId,
|
||||
visible: nextVisible,
|
||||
});
|
||||
upsertEntry(response.entry);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setSavingKey('');
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEntry(next: AdminWorkVisibilityEntryPayload) {
|
||||
setEntries((current) =>
|
||||
sortEntries([
|
||||
...current.filter((entry) => buildEntryKey(entry) !== buildEntryKey(next)),
|
||||
next,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>作品可见性</h2>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshEntries}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="admin-panel">
|
||||
<label className="admin-field">
|
||||
<span>搜索</span>
|
||||
<input
|
||||
placeholder="标题 / 作者 / 公开码 / profileId"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>玩法</th>
|
||||
<th>作品</th>
|
||||
<th>作者</th>
|
||||
<th>公开码</th>
|
||||
<th>更新时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((entry) => {
|
||||
const rowKey = buildEntryKey(entry);
|
||||
const isSaving = savingKey === rowKey;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>
|
||||
<span className="admin-tag">
|
||||
{sourceLabels[entry.sourceType] ?? entry.sourceType}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{entry.title || entry.profileId}</strong>
|
||||
<small>{entry.subtitle || entry.profileId}</small>
|
||||
</td>
|
||||
<td>
|
||||
{entry.authorDisplayName || '玩家'}
|
||||
<small>{entry.ownerUserId}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-table-cell-ellipsis">
|
||||
{entry.publicWorkCode}
|
||||
</span>
|
||||
<small>{entry.profileId}</small>
|
||||
</td>
|
||||
<td>{formatMicros(entry.updatedAtMicros)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
entry.visible
|
||||
? 'admin-status admin-status-ok'
|
||||
: 'admin-status admin-status-error'
|
||||
}
|
||||
>
|
||||
{entry.visible ? '显示' : '隐藏'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={
|
||||
entry.visible
|
||||
? 'admin-danger-button'
|
||||
: 'admin-secondary-button'
|
||||
}
|
||||
disabled={isSaving}
|
||||
type="button"
|
||||
onClick={() => handleToggle(entry)}
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{isSaving
|
||||
? '处理中'
|
||||
: entry.visible
|
||||
? '隐藏'
|
||||
: '显示'}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isLoading && filteredEntries.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无作品</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: AdminWorkVisibilityEntryPayload[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const timeCompare = right.updatedAtMicros - left.updatedAtMicros;
|
||||
if (timeCompare !== 0) {
|
||||
return timeCompare;
|
||||
}
|
||||
const sourceCompare = left.sourceType.localeCompare(right.sourceType);
|
||||
if (sourceCompare !== 0) {
|
||||
return sourceCompare;
|
||||
}
|
||||
return left.profileId.localeCompare(right.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function buildEntryKey(entry: AdminWorkVisibilityEntryPayload) {
|
||||
return `${entry.sourceType}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function formatMicros(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(Math.floor(value / 1000));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {hour12: false});
|
||||
}
|
||||
Reference in New Issue
Block a user