feat: add admin creation entry switches
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
260
apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
Normal file
260
apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user