270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
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});
|
|
}
|