feat: add admin creation entry switches
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-11 12:02:39 +08:00
parent d23cf3807d
commit 0461c0ee41
18 changed files with 745 additions and 4 deletions

View File

@@ -1,4 +1,6 @@
import type {
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
@@ -167,6 +169,28 @@ export function listAdminTrackingEvents(
);
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
{token},
);
}
export function upsertAdminCreationEntryConfig(
token: string,
payload: AdminUpsertCreationEntryTypeConfigRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',

View File

@@ -141,6 +141,34 @@ export interface AdminTrackingEventListQuery {
limit?: number;
}
export interface AdminCreationEntryConfigResponse {
entries: AdminCreationEntryTypeConfigPayload[];
}
export interface AdminCreationEntryTypeConfigPayload {
id: string;
title: string;
subtitle: string;
badge: string;
imageSrc: string;
visible: boolean;
open: boolean;
sortOrder: number;
updatedAtMicros: number;
}
export interface AdminUpsertCreationEntryTypeConfigRequest {
id: string;
title: string;
subtitle: string;
badge: string;
imageSrc: string;
visible: boolean;
open: boolean;
sortOrder: number;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;

View File

@@ -17,6 +17,7 @@ import {
getStoredAdminToken,
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
@@ -192,6 +193,12 @@ export function AdminApp() {
onResultChange={setInviteResult}
/>
) : null}
{routeId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}

View File

@@ -4,6 +4,7 @@ import {
LogOut,
ShieldCheck,
ListChecks,
SlidersHorizontal,
Database,
Table2,
TicketCheck,
@@ -31,6 +32,7 @@ const routeIcons = {
redeem: TicketPercent,
invite: TicketCheck,
tasks: ListChecks,
'creation-entry': SlidersHorizontal,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({

View File

@@ -5,7 +5,8 @@ export type AdminRouteId =
| 'tracking'
| 'redeem'
| 'invite'
| 'tasks';
| 'tasks'
| 'creation-entry';
export interface AdminRouteDefinition {
id: AdminRouteId;
@@ -21,6 +22,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
];
export function resolveAdminRoute(hash: string): AdminRouteId {

View File

@@ -0,0 +1,260 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryConfig,
} from '../api/adminApiClient';
import type {AdminCreationEntryTypeConfigPayload} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminCreationEntrySwitchPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminCreationEntrySwitchPage({
token,
onUnauthorized,
}: AdminCreationEntrySwitchPageProps) {
const [entries, setEntries] = useState<AdminCreationEntryTypeConfigPayload[]>([]);
const [selectedId, setSelectedId] = useState('puzzle');
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
const [badge, setBadge] = useState('');
const [imageSrc, setImageSrc] = useState('');
const [visible, setVisible] = useState(true);
const [open, setOpen] = useState(true);
const [sortOrder, setSortOrder] = useState('30');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshEntries();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshEntries() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await getAdminCreationEntryConfig(token);
const nextEntries = sortEntries(response.entries);
setEntries(nextEntries);
fillForm(
nextEntries.find((entry) => entry.id === selectedId) ?? nextEntries[0] ?? null,
);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const targetId = selectedId.trim();
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存创作入口开关',
target: targetId,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertAdminCreationEntryConfig(token, {
id: targetId,
title: title.trim(),
subtitle: subtitle.trim(),
badge: badge.trim(),
imageSrc: imageSrc.trim(),
visible,
open,
sortOrder: parseInteger(sortOrder),
});
const nextEntries = sortEntries(response.entries);
setEntries(nextEntries);
fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) {
if (!entry) {
return;
}
setSelectedId(entry.id);
setTitle(entry.title);
setSubtitle(entry.subtitle);
setBadge(entry.badge);
setImageSrc(entry.imageSrc);
setVisible(entry.visible);
setOpen(entry.open);
setSortOrder(String(entry.sortOrder));
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshEntries}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span> ID</span>
<input
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={visible}
type="checkbox"
onChange={(event) => setVisible(event.target.checked)}
/>
<span></span>
</label>
<label className="admin-switch-field">
<input
checked={open}
type="checkbox"
onChange={(event) => setOpen(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label className="admin-field">
<span></span>
<input value={badge} onChange={(event) => setBadge(event.target.value)} />
</label>
</div>
<label className="admin-field">
<span></span>
<input value={subtitle} onChange={(event) => setSubtitle(event.target.value)} />
</label>
<label className="admin-field">
<span></span>
<input value={imageSrc} onChange={(event) => setImageSrc(event.target.value)} />
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-form-actions">
<button className="admin-primary-button" disabled={isSaving} type="submit">
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存入库'}</span>
</button>
</div>
</form>
<section className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
<td>
<button
className="admin-link-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.id}
</button>
</td>
<td>{entry.visible ? '是' : '否'}</td>
<td>{entry.open ? '是' : '否'}</td>
<td>{entry.sortOrder}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
{confirmDialog}
</section>
);
}
function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) {
return [...entries].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.id.localeCompare(right.id);
});
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return 0;
}
return parsed;
}