feat: add admin work visibility controls
This commit is contained in:
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