后台游戏管理与审核页排版修复,安全下架入口收敛到游戏管理页
- 游戏管理页:下架改为独立面板(原因必填、校验提示、仍走二次确认),操作列与作者列排版调整,gameId 省略号加 title - 游戏管理页测试:拆成空原因不提交、面板取消不请求、面板确认后仍需二次确认三条 - 审核页:移除行内游戏级安全下架及其状态与二次确认接线,理由弹窗只服务拒绝,gameId 截断加 title - 审核页测试:删除两条安全下架用例,替换为行内无该动作、microsZ 时间列渲染、窄视口 gameId 截断 - 新增 adminGameDistributionReview.css:审核页专属列宽与动作列 nowrap,含 1366/980 两档断点 - admin-web admin.css:新增游戏管理表格 5 条排版规则 - shared AdminFormat:新增 normalizeAdminTimestamp,兼容后端 seconds.microsZ 时间口径 - shared AdminFormat 测试:新增 5 条覆盖微秒口径、补零、ISO 与 epoch 兼容、非法值回落 - shared admin.css:admin-status 改为 nowrap - 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作
This commit is contained in:
@@ -14,7 +14,6 @@ import {
|
||||
getAdminGameDistributionVersion,
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionReviewEntry,
|
||||
@@ -37,8 +36,6 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
getAdminGameDistributionVersion: vi.fn(),
|
||||
listAdminGameDistributionReviews: vi.fn(),
|
||||
reviewAdminGameDistributionVersion: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
|
||||
}));
|
||||
const entry: AdminGameDistributionReviewEntry = {
|
||||
versionId: 'version-1',
|
||||
@@ -49,8 +46,9 @@ const entry: AdminGameDistributionReviewEntry = {
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
|
||||
createdAt: '1791008678.817257Z',
|
||||
updatedAt: '1791008678.817257Z',
|
||||
};
|
||||
|
||||
const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
@@ -84,7 +82,6 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminGameDistributionReviews).mockReset();
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
||||
vi.mocked(getAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
@@ -172,17 +169,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
status: 'suspended',
|
||||
publicationRevision: 5,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -191,35 +178,35 @@ test('安全下架需要先填写原因,再二次确认并携带公开修订
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
);
|
||||
// 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页;
|
||||
// 审核一个新版本时不该出现该动作。
|
||||
expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull();
|
||||
expect(screen.queryByText('继续下架')).toBeNull();
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
const row = screen.getByText('game_1').closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
|
||||
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
|
||||
expect(submittedCell?.textContent).not.toBe('-');
|
||||
expect(submittedCell?.textContent).toMatch(
|
||||
/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u,
|
||||
);
|
||||
const [token, gameId, idempotencyKey, payload] =
|
||||
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(gameId).toBe('game_1');
|
||||
expect(String(idempotencyKey)).toContain('game_1');
|
||||
expect(payload).toEqual({
|
||||
expectedPublicationRevision: 4,
|
||||
reason: '盗用素材',
|
||||
});
|
||||
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('取消理由输入时不做审核操作', async () => {
|
||||
@@ -239,7 +226,7 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('取消安全下架确认时不下架', async () => {
|
||||
test('窄视口下 gameId 截断显示,完整值由 title 暴露', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -248,30 +235,15 @@ test('取消安全下架确认时不下架', async () => {
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
);
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
const gameIdCell = screen.getByTitle('game_1');
|
||||
expect(gameIdCell.textContent).toBe('game_1');
|
||||
});
|
||||
|
||||
test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
|
||||
previewUrl: 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './adminGameDistributionReview.css';
|
||||
|
||||
import {
|
||||
AdminActionRow,
|
||||
AdminAlert,
|
||||
@@ -19,14 +21,12 @@ import {
|
||||
getAdminGameDistributionVersion,
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameDetail,
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameDistributionReviewPageProps {
|
||||
@@ -35,7 +35,6 @@ interface AdminGameDistributionReviewPageProps {
|
||||
}
|
||||
|
||||
interface ReviewReasonPrompt {
|
||||
decision: 'reject' | 'suspend';
|
||||
entry: AdminGameDistributionReviewEntry;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,10 @@ function applyFrozenGameMetadata(
|
||||
? item.objectKey
|
||||
: null,
|
||||
)
|
||||
.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
|
||||
.filter(
|
||||
(value): value is string =>
|
||||
typeof value === 'string' && Boolean(value.trim()),
|
||||
)
|
||||
: game.screenshots;
|
||||
const deviceSupport =
|
||||
metadata.deviceSupport && typeof metadata.deviceSupport === 'object'
|
||||
@@ -60,12 +62,18 @@ function applyFrozenGameMetadata(
|
||||
return {
|
||||
...game,
|
||||
title: typeof metadata.title === 'string' ? metadata.title : game.title,
|
||||
summary: typeof metadata.summary === 'string' ? metadata.summary : game.summary,
|
||||
summary:
|
||||
typeof metadata.summary === 'string' ? metadata.summary : game.summary,
|
||||
description:
|
||||
typeof metadata.description === 'string' ? metadata.description : game.description,
|
||||
category: typeof metadata.category === 'string' ? metadata.category : game.category,
|
||||
typeof metadata.description === 'string'
|
||||
? metadata.description
|
||||
: game.description,
|
||||
category:
|
||||
typeof metadata.category === 'string' ? metadata.category : game.category,
|
||||
tags: Array.isArray(metadata.tags)
|
||||
? metadata.tags.filter((value): value is string => typeof value === 'string')
|
||||
? metadata.tags.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: game.tags,
|
||||
coverObjectKey:
|
||||
typeof metadata.coverObjectKey === 'string'
|
||||
@@ -74,10 +82,14 @@ function applyFrozenGameMetadata(
|
||||
screenshots,
|
||||
deviceSupport,
|
||||
inputModes: Array.isArray(metadata.inputModes)
|
||||
? metadata.inputModes.filter((value): value is string => typeof value === 'string')
|
||||
? metadata.inputModes.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: game.inputModes,
|
||||
orientation:
|
||||
typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation,
|
||||
typeof metadata.orientation === 'string'
|
||||
? metadata.orientation
|
||||
: game.orientation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,14 +103,6 @@ function formatBytes(value: number) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
function createSuspendIdempotencyKey(gameId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `game-suspend-${gameId}-${random}`.slice(0, 128);
|
||||
}
|
||||
|
||||
function createReviewIdempotencyKey(versionId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
@@ -123,8 +127,6 @@ export function AdminGameDistributionReviewPage({
|
||||
);
|
||||
const [reasonDraft, setReasonDraft] = useState('');
|
||||
const [reasonError, setReasonError] = useState('');
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
const [selectedEntry, setSelectedEntry] =
|
||||
useState<AdminGameDistributionReviewEntry | null>(null);
|
||||
const [detail, setDetail] =
|
||||
@@ -172,9 +174,10 @@ export function AdminGameDistributionReviewPage({
|
||||
);
|
||||
const reviewDetail = { ...response, game: reviewGame };
|
||||
setDetail(reviewDetail);
|
||||
const objectKeys = [reviewGame.coverObjectKey, ...reviewGame.screenshots].filter(
|
||||
(value): value is string => Boolean(value?.trim()),
|
||||
);
|
||||
const objectKeys = [
|
||||
reviewGame.coverObjectKey,
|
||||
...reviewGame.screenshots,
|
||||
].filter((value): value is string => Boolean(value?.trim()));
|
||||
const resolvedUrls = await Promise.all(
|
||||
objectKeys.map(async (objectKey) => {
|
||||
try {
|
||||
@@ -267,48 +270,13 @@ export function AdminGameDistributionReviewPage({
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
||||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
||||
* 待审版本行内只做版本级动作(详情 / 通过 / 拒绝)。管理员「安全下架」是游戏级
|
||||
* 动作(强制下线线上版本),入口在后台「游戏管理」页,不在审核新版本的语境里。
|
||||
*/
|
||||
async function suspendGame(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
reason: string,
|
||||
) {
|
||||
const trimmedReason = reason.trim();
|
||||
const confirmed = await writeConfirm.confirmWrite({
|
||||
action: '安全下架游戏',
|
||||
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
setBusyGameId(entry.gameId);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await suspendAdminGameDistributionGame(
|
||||
token,
|
||||
entry.gameId,
|
||||
createSuspendIdempotencyKey(entry.gameId),
|
||||
{
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
...(trimmedReason ? { reason: trimmedReason } : {}),
|
||||
},
|
||||
);
|
||||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setBusyGameId('');
|
||||
}
|
||||
}
|
||||
|
||||
function openReasonPrompt(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
decision: ReviewReasonPrompt['decision'],
|
||||
) {
|
||||
function openReasonPrompt(entry: AdminGameDistributionReviewEntry) {
|
||||
setReasonDraft('');
|
||||
setReasonError('');
|
||||
setReasonPrompt({ decision, entry });
|
||||
setReasonPrompt({ entry });
|
||||
}
|
||||
|
||||
function closeReasonPrompt() {
|
||||
@@ -321,18 +289,14 @@ export function AdminGameDistributionReviewPage({
|
||||
if (!reasonPrompt) return;
|
||||
|
||||
const reason = reasonDraft.trim();
|
||||
if (reasonPrompt.decision === 'reject' && !reason) {
|
||||
if (!reason) {
|
||||
setReasonError('拒绝审核必须填写理由');
|
||||
return;
|
||||
}
|
||||
|
||||
const { decision, entry } = reasonPrompt;
|
||||
const { entry } = reasonPrompt;
|
||||
closeReasonPrompt();
|
||||
if (decision === 'reject') {
|
||||
void submitReview(entry, 'reject', reason);
|
||||
return;
|
||||
}
|
||||
void suspendGame(entry, reason);
|
||||
void submitReview(entry, 'reject', reason);
|
||||
}
|
||||
|
||||
if (selectedEntry) {
|
||||
@@ -355,7 +319,7 @@ export function AdminGameDistributionReviewPage({
|
||||
}
|
||||
: selectedEntry;
|
||||
closeDetails();
|
||||
openReasonPrompt(expectedEntry, 'reject');
|
||||
openReasonPrompt(expectedEntry);
|
||||
}}
|
||||
onApprove={() => {
|
||||
const expectedEntry = detail
|
||||
@@ -407,6 +371,7 @@ export function AdminGameDistributionReviewPage({
|
||||
extra={<span className="admin-muted-text">共 {entries.length} 条</span>}
|
||||
rows={entries}
|
||||
size="wide"
|
||||
tableClassName="admin-game-review-table"
|
||||
columns={[
|
||||
{ key: 'gameId', label: '游戏' },
|
||||
{ key: 'versionNumber', label: '版本' },
|
||||
@@ -419,7 +384,12 @@ export function AdminGameDistributionReviewPage({
|
||||
return (
|
||||
<tr key={entry.versionId}>
|
||||
<td>
|
||||
<code>{entry.gameId}</code>
|
||||
<code
|
||||
className="admin-game-review-game-id"
|
||||
title={entry.gameId}
|
||||
>
|
||||
{entry.gameId}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
v{entry.versionNumber}
|
||||
@@ -435,7 +405,7 @@ export function AdminGameDistributionReviewPage({
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<td className="admin-game-review-cell--actions">
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
@@ -457,18 +427,10 @@ export function AdminGameDistributionReviewPage({
|
||||
variant="ghost"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => openReasonPrompt(entry, 'reject')}
|
||||
onClick={() => openReasonPrompt(entry)}
|
||||
>
|
||||
拒绝
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
type="button"
|
||||
disabled={busy || busyGameId === entry.gameId}
|
||||
onClick={() => openReasonPrompt(entry, 'suspend')}
|
||||
>
|
||||
{busyGameId === entry.gameId ? '正在下架…' : '安全下架'}
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -478,7 +440,7 @@ export function AdminGameDistributionReviewPage({
|
||||
{reasonPrompt ? (
|
||||
<Modal
|
||||
open
|
||||
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
|
||||
title="拒绝审核"
|
||||
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
|
||||
closeLabel="关闭理由输入"
|
||||
onClose={closeReasonPrompt}
|
||||
@@ -500,12 +462,10 @@ export function AdminGameDistributionReviewPage({
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant={
|
||||
reasonPrompt.decision === 'reject' ? 'ghost' : 'danger'
|
||||
}
|
||||
variant="ghost"
|
||||
onClick={confirmReasonPrompt}
|
||||
>
|
||||
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
|
||||
确认拒绝
|
||||
</AdminButton>
|
||||
</div>
|
||||
}
|
||||
@@ -513,7 +473,7 @@ export function AdminGameDistributionReviewPage({
|
||||
<TextField
|
||||
autoFocus
|
||||
multiline
|
||||
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
|
||||
label="拒绝理由"
|
||||
value={reasonDraft}
|
||||
error={reasonError}
|
||||
rows={4}
|
||||
@@ -524,7 +484,6 @@ export function AdminGameDistributionReviewPage({
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
{writeConfirm.confirmDialog}
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
@@ -559,7 +518,9 @@ function AdminGameDistributionReviewDetailView({
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading title="审核详情" />
|
||||
{isDetailLoading ? <AdminAlert>正在加载审核详情…</AdminAlert> : null}
|
||||
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
|
||||
{detailError ? (
|
||||
<AdminAlert tone="warning">{detailError}</AdminAlert>
|
||||
) : null}
|
||||
<AdminButton variant="secondary" type="button" onClick={onBack}>
|
||||
返回审核列表
|
||||
</AdminButton>
|
||||
@@ -577,7 +538,9 @@ function AdminGameDistributionReviewDetailView({
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
|
||||
{detailError ? (
|
||||
<AdminAlert tone="warning">{detailError}</AdminAlert>
|
||||
) : null}
|
||||
<GameDetailDisplay
|
||||
className="platform-theme platform-theme--light admin-game-review-detail"
|
||||
game={detail.game}
|
||||
@@ -588,10 +551,16 @@ function AdminGameDistributionReviewDetailView({
|
||||
versionLabel={`v${detail.version.versionNumber}`}
|
||||
infoCards={[
|
||||
{ label: '状态', value: detail.version.status },
|
||||
{ label: '提交时间', value: formatAdminDateTime(detail.version.createdAt) },
|
||||
{
|
||||
label: '提交时间',
|
||||
value: formatAdminDateTime(detail.version.createdAt),
|
||||
},
|
||||
{ label: '包大小', value: formatBytes(detail.version.packageBytes) },
|
||||
{ label: '文件数', value: detail.version.packageFileCount },
|
||||
{ label: 'SHA-256', value: <code>{detail.version.packageSha256}</code> },
|
||||
{
|
||||
label: 'SHA-256',
|
||||
value: <code>{detail.version.packageSha256}</code>,
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
@@ -615,10 +584,13 @@ function AdminGameDistributionReviewDetailView({
|
||||
<section>
|
||||
<h2>版本审核</h2>
|
||||
<p>
|
||||
发布者:{detail.game.author.name} · <code>{detail.game.author.id}</code>
|
||||
发布者:{detail.game.author.name} ·{' '}
|
||||
<code>{detail.game.author.id}</code>
|
||||
</p>
|
||||
{detail.version.reviewReason ? (
|
||||
<AdminAlert tone="warning">审核理由:{detail.version.reviewReason}</AdminAlert>
|
||||
<AdminAlert tone="warning">
|
||||
审核理由:{detail.version.reviewReason}
|
||||
</AdminAlert>
|
||||
) : null}
|
||||
{detail.version.frozenMetadata ? (
|
||||
<details>
|
||||
@@ -628,7 +600,9 @@ function AdminGameDistributionReviewDetailView({
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{previewError ? <AdminAlert tone="warning">{previewError}</AdminAlert> : null}
|
||||
{previewError ? (
|
||||
<AdminAlert tone="warning">{previewError}</AdminAlert>
|
||||
) : null}
|
||||
{previewUrl ? (
|
||||
<iframe
|
||||
title={`${detail.game.title} 待审版本试玩`}
|
||||
|
||||
@@ -218,20 +218,62 @@ test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列
|
||||
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
|
||||
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
// 操作列不再内嵌输入框,点按钮才打开面板。
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
const reasonInput = within(dialog).getByLabelText('下架原因');
|
||||
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
|
||||
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
|
||||
});
|
||||
|
||||
test('下架面板取消不触发请求', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
|
||||
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
AdminButton,
|
||||
AdminDialog,
|
||||
AdminField,
|
||||
AdminFieldGroup,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
@@ -60,6 +59,9 @@ const STATUS_FILTER_OPTIONS: {
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
/** 下架原因必填;面板校验与提交前兜底共用同一句提示。 */
|
||||
const SUSPEND_REASON_REQUIRED_MESSAGE = '请填写下架原因';
|
||||
|
||||
interface GameFilters {
|
||||
keyword: string;
|
||||
owner: string;
|
||||
@@ -128,9 +130,11 @@ export function AdminGameManagementPage({
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
// 下架原因在下架面板内填写;面板打开状态、草稿与校验错误都由页面持有。
|
||||
const [suspendTarget, setSuspendTarget] =
|
||||
useState<AdminGameDistributionGameEntry | null>(null);
|
||||
const [suspendReasonDraft, setSuspendReasonDraft] = useState('');
|
||||
const [suspendReasonError, setSuspendReasonError] = useState('');
|
||||
const [versionGame, setVersionGame] =
|
||||
useState<AdminGameDistributionGameEntry | null>(null);
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
@@ -237,10 +241,40 @@ export function AdminGameManagementPage({
|
||||
filters.keyword || filters.owner || filters.status,
|
||||
);
|
||||
|
||||
async function suspendGame(entry: AdminGameDistributionGameEntry) {
|
||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
||||
function openSuspendPrompt(entry: AdminGameDistributionGameEntry) {
|
||||
setSuspendReasonDraft('');
|
||||
setSuspendReasonError('');
|
||||
setSuspendTarget(entry);
|
||||
}
|
||||
|
||||
function closeSuspendPrompt() {
|
||||
setSuspendTarget(null);
|
||||
setSuspendReasonDraft('');
|
||||
setSuspendReasonError('');
|
||||
}
|
||||
|
||||
/** 面板确认:先做必填校验,再走原有的二次确认与下架请求。 */
|
||||
function confirmSuspendPrompt() {
|
||||
if (!suspendTarget) return;
|
||||
|
||||
const reason = suspendReasonDraft.trim();
|
||||
if (!reason) {
|
||||
setErrorMessage('下架原因不能为空');
|
||||
setSuspendReasonError(SUSPEND_REASON_REQUIRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = suspendTarget;
|
||||
closeSuspendPrompt();
|
||||
void suspendGame(entry, reason);
|
||||
}
|
||||
|
||||
async function suspendGame(
|
||||
entry: AdminGameDistributionGameEntry,
|
||||
reason: string,
|
||||
) {
|
||||
const trimmedReason = reason.trim();
|
||||
if (!trimmedReason) {
|
||||
setErrorMessage(SUSPEND_REASON_REQUIRED_MESSAGE);
|
||||
setStatusMessage('');
|
||||
return;
|
||||
}
|
||||
@@ -261,14 +295,10 @@ export function AdminGameManagementPage({
|
||||
createGameActionIdempotencyKey('suspend', entry.gameId),
|
||||
{
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
reason,
|
||||
reason: trimmedReason,
|
||||
},
|
||||
);
|
||||
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: '',
|
||||
}));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
@@ -418,6 +448,7 @@ export function AdminGameManagementPage({
|
||||
}
|
||||
rows={games}
|
||||
size="wide"
|
||||
tableClassName="admin-game-management-table"
|
||||
columns={[
|
||||
{ key: 'title', label: '标题' },
|
||||
{ key: 'author', label: '作者' },
|
||||
@@ -459,7 +490,7 @@ export function AdminGameManagementPage({
|
||||
<td>
|
||||
<strong>{entry.title?.trim() || '—'}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<td className="admin-game-author-cell">
|
||||
<div
|
||||
className="admin-database-user-cell"
|
||||
style={{
|
||||
@@ -484,14 +515,23 @@ export function AdminGameManagementPage({
|
||||
authorInitial(entry)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>{authorName(entry)}</span>
|
||||
<small>{entry.author?.id?.trim() || '—'}</small>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
<span>{authorName(entry)}</span>
|
||||
<small title={entry.author?.id?.trim() || undefined}>
|
||||
{entry.author?.id?.trim() || '—'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{entry.gameId}</code>
|
||||
<code
|
||||
className="admin-table-cell-ellipsis"
|
||||
title={entry.gameId}
|
||||
>
|
||||
{entry.gameId}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<AdminStatusPill tone={status.tone}>
|
||||
@@ -505,7 +545,7 @@ export function AdminGameManagementPage({
|
||||
</td>
|
||||
<td>{entry.versionCount}</td>
|
||||
<td>{entry.playCount}</td>
|
||||
<td>
|
||||
<td className="admin-game-actions-cell">
|
||||
<AdminActionRow>
|
||||
{isDeleted ? null : isSuspended ? (
|
||||
<AdminButton
|
||||
@@ -517,32 +557,14 @@ export function AdminGameManagementPage({
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</AdminButton>
|
||||
) : (
|
||||
<>
|
||||
<AdminFieldGroup
|
||||
label="下架原因"
|
||||
htmlFor={`game-suspend-reason-${entry.gameId}`}
|
||||
>
|
||||
<input
|
||||
id={`game-suspend-reason-${entry.gameId}`}
|
||||
value={suspendReasonByGame[entry.gameId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</AdminFieldGroup>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void suspendGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '下架'}
|
||||
</AdminButton>
|
||||
</>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => openSuspendPrompt(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '下架'}
|
||||
</AdminButton>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
@@ -570,6 +592,58 @@ export function AdminGameManagementPage({
|
||||
}}
|
||||
/>
|
||||
|
||||
{suspendTarget ? (
|
||||
<AdminDialog
|
||||
as="form"
|
||||
closeLabel="关闭下架面板"
|
||||
description={
|
||||
<>
|
||||
{suspendTarget.title?.trim() || '—'}({suspendTarget.gameId})
|
||||
</>
|
||||
}
|
||||
panelClassName="admin-confirm-panel"
|
||||
title="下架游戏"
|
||||
titleProps={{ id: 'admin-game-suspend-title' }}
|
||||
onClose={closeSuspendPrompt}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
confirmSuspendPrompt();
|
||||
}}
|
||||
>
|
||||
<AdminField label="下架原因(必填)">
|
||||
<textarea
|
||||
autoFocus
|
||||
aria-label="下架原因"
|
||||
aria-invalid={suspendReasonError ? true : undefined}
|
||||
placeholder="填写下架原因,提交后记录在游戏状态变更里"
|
||||
rows={3}
|
||||
value={suspendReasonDraft}
|
||||
onChange={(event) => {
|
||||
setSuspendReasonDraft(event.target.value);
|
||||
if (suspendReasonError) setSuspendReasonError('');
|
||||
}}
|
||||
/>
|
||||
</AdminField>
|
||||
{suspendReasonError ? (
|
||||
<AdminAlert role="alert" tone="warning">
|
||||
{suspendReasonError}
|
||||
</AdminAlert>
|
||||
) : null}
|
||||
<div className="admin-confirm-actions">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={closeSuspendPrompt}
|
||||
>
|
||||
取消
|
||||
</AdminButton>
|
||||
<AdminButton variant="danger" type="submit">
|
||||
确认下架
|
||||
</AdminButton>
|
||||
</div>
|
||||
</AdminDialog>
|
||||
) : null}
|
||||
|
||||
{versionGame ? (
|
||||
<AdminDialog
|
||||
closeLabel="关闭版本历史"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 「游戏审核」待审版本表的页面专属样式。
|
||||
*
|
||||
* 目标:1280 / 1440 / 1920 三档视口下,「详情 / 通过 / 拒绝」三个版本级动作
|
||||
* 都在面板可视带内、单行不换行,不依赖面板内横向滚动。
|
||||
*
|
||||
* 做法:只在本页表格上加自己的类,覆盖 `.admin-table-wide` 的 min-width 并固定列宽,
|
||||
* 把动作列按内容取宽;gameId 最长(37 字符)用省略号截断并保留 title 作为完整值出口。
|
||||
* 不改共享 `.admin-table-wide` 的全局语义,也不影响其它后台页面。
|
||||
*/
|
||||
|
||||
/* `.admin-table-wide` 是共享类,用元素+类提升优先级覆盖它,只作用于本页表格。 */
|
||||
table.admin-game-review-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2) {
|
||||
/* `pending_review` 是最长状态值(约 120px + 单元格内边距),必须给足,否则会压到「发行包」列。 */
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3) {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 固定列宽下,任何溢出都应该在本列内被裁掉,而不是压到相邻列上。 */
|
||||
table.admin-game-review-table td {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table.admin-game-review-table td .admin-muted-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions .admin-review-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* gameId 是 37 字符的定长标识:窄视口截断显示,靠 title 暴露完整值。 */
|
||||
.admin-game-review-game-id {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 窄视口(≤1366)下把 gameId 降到 12px:13px 时它的文本宽 259px 只比列宽多 1px 的余量,
|
||||
* 字体回退或换行差异都会让它变成省略号;降到 12px 留出约 19px 余量,完整标识仍然可见。
|
||||
*/
|
||||
@media (max-width: 1366px) {
|
||||
table.admin-game-review-table td:first-child code {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 gameId 的宽度。 */
|
||||
@media (max-width: 980px) {
|
||||
table.admin-game-review-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2),
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3),
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4),
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -3526,3 +3526,36 @@ button:disabled {
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
|
||||
* 不影响其它后台页面:
|
||||
* - 操作列按内容取宽且按钮组不换行,避免「查看评价」被挤到第二行;
|
||||
* - 单元格垂直居中,行高不再随最长单元格跳动;
|
||||
* - 标题列给最小宽度,避免被长 gameId 挤压成一行一个字。
|
||||
* 这里用 `.admin-table.admin-game-management-table` 复合选择器:共享样式表在 dev 下
|
||||
* 由组件模块后注入,单类选择器会被 `.admin-table th, .admin-table td` 反超。
|
||||
*/
|
||||
.admin-table.admin-game-management-table th,
|
||||
.admin-table.admin-game-management-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-game-management-table td:first-child {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.admin-game-management-table th:last-child,
|
||||
.admin-game-management-table td.admin-game-actions-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-actions-cell .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-game-author-cell {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- 发布者、游戏资料、版本包摘要和冻结资料展示。
|
||||
- 单个待审版本的短期隔离试玩会话。
|
||||
- 试玩资源、Cookie、sandbox、CSP 和版本绑定边界。
|
||||
- 通过、拒绝、安全下架动作与现有 CAS/幂等规则对齐。
|
||||
- 版本级审核动作(通过、拒绝)与现有 CAS/幂等规则对齐;游戏级安全下架入口留在「游戏管理」页。
|
||||
- admin-web、api-server、shared-contracts 和相关测试的契约同步。
|
||||
|
||||
## 不在范围内
|
||||
@@ -41,7 +41,7 @@
|
||||
- [ ] 审核员可以打开当前 `versionId` 试玩,且 JS/CSS/图片/音频等同包资源可加载。
|
||||
- [ ] 预览 Token 绑定版本、短期有效、过期和越权读取均失败。
|
||||
- [ ] 预览不读取平台 Cookie,不授予 `allow-same-origin`,保留发行 sandbox/CSP/storage 约束。
|
||||
- [ ] 通过、拒绝、安全下架继续执行理由、CAS、幂等和刷新规则。
|
||||
- [ ] 审核页的通过/拒绝继续执行理由、CAS、幂等和刷新规则;行内不出现游戏级安全下架。
|
||||
- [ ] 无 SpacetimeDB schema、migration、公开契约破坏性变化。
|
||||
|
||||
## 证据要求
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { formatAdminDate, formatAdminDateTime } from './AdminFormat';
|
||||
|
||||
describe('formatAdminDateTime', () => {
|
||||
test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => {
|
||||
// 游戏分发后台接口下发的是 shared-kernel `format_timestamp_micros` 的
|
||||
// `seconds.microsZ` 文本(非 ISO 8601),必须与等价 ISO 时刻渲染一致。
|
||||
const microsText = '1791008678.817257Z';
|
||||
const equivalentIso = new Date(1791008678 * 1000 + 817).toISOString();
|
||||
|
||||
expect(formatAdminDateTime(microsText)).toBe(
|
||||
formatAdminDateTime(equivalentIso),
|
||||
);
|
||||
expect(formatAdminDateTime(microsText)).not.toBe('-');
|
||||
});
|
||||
|
||||
test('微秒位数不足时按毫秒补零,而不是丢位', () => {
|
||||
expect(formatAdminDateTime('1791008678.8Z')).toBe(
|
||||
formatAdminDateTime(new Date(1791008678 * 1000 + 800).toISOString()),
|
||||
);
|
||||
});
|
||||
|
||||
test('ISO 字符串、epoch 毫秒与 Date 仍按原语义渲染', () => {
|
||||
const iso = '2026-09-20T08:00:00Z';
|
||||
expect(formatAdminDateTime(iso)).toBe(formatAdminDateTime(new Date(iso)));
|
||||
expect(formatAdminDateTime(Date.parse(iso))).toBe(
|
||||
formatAdminDateTime(new Date(iso)),
|
||||
);
|
||||
});
|
||||
|
||||
test('无法解析的值回落 fallback', () => {
|
||||
expect(formatAdminDateTime('not-a-time')).toBe('-');
|
||||
expect(formatAdminDateTime('')).toBe('-');
|
||||
expect(formatAdminDateTime(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAdminDate', () => {
|
||||
test('同样接受 seconds.microsZ 口径', () => {
|
||||
expect(formatAdminDate('1791008678.817257Z')).toBe(
|
||||
formatAdminDate(new Date(1791008678 * 1000 + 817).toISOString()),
|
||||
);
|
||||
expect(formatAdminDate('1791008678.817257Z')).not.toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,28 @@
|
||||
/**
|
||||
* 后台统一的时间展示:ISO 字符串 / epoch 毫秒 / Date → `zh-CN` 本地时间。
|
||||
* 部分后台接口下发的微秒时间戳是 `seconds.microsZ` 文本口径(见 shared-kernel 的
|
||||
* `format_timestamp_micros`,例如 `1791008678.817257Z`)。它不是 ISO 8601,
|
||||
* JS `new Date()` 会直接判成 `Invalid Date`,因此先归一成 ISO 字符串再解析。
|
||||
* 其它输入原样返回,交给 `new Date()` 处理。
|
||||
*/
|
||||
function normalizeAdminTimestamp(value: string | number): string | number {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const micros = /^(\d+)\.(\d{1,9})Z$/.exec(value.trim());
|
||||
if (!micros) {
|
||||
return value;
|
||||
}
|
||||
const seconds = Number(micros[1]);
|
||||
const fraction = micros[2] ?? '';
|
||||
if (!Number.isSafeInteger(seconds)) {
|
||||
return value;
|
||||
}
|
||||
const millis = Number(fraction.padEnd(9, '0').slice(0, 3));
|
||||
return new Date(seconds * 1000 + millis).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台统一的时间展示:ISO 字符串 / `seconds.microsZ` / epoch 毫秒 / Date → `zh-CN` 本地时间。
|
||||
* 解析不出来时回落到 `fallback`,避免页面上出现 `Invalid Date` 或裸 ISO 串。
|
||||
*/
|
||||
export function formatAdminDateTime(
|
||||
@@ -10,7 +33,8 @@ export function formatAdminDateTime(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const date =
|
||||
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -30,7 +54,7 @@ export function formatAdminDateTime(
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台统一的日期展示(不含时分秒)。 */
|
||||
/** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */
|
||||
export function formatAdminDate(
|
||||
value: string | number | Date | null | undefined,
|
||||
fallback = '-',
|
||||
@@ -39,7 +63,8 @@ export function formatAdminDate(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const date =
|
||||
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -431,7 +431,8 @@
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
/* 状态标签是短徽标:必须单行,避免在窄列里被拆成每行一两个字。 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-status-ok {
|
||||
|
||||
Reference in New Issue
Block a user