Files
Genarrative/apps/admin-web/src/pages/AdminTaskConfigPage.tsx
kdletters 9f3e34e81a feat: add invite code validity controls
- Add invite code starts/expires fields across contracts, API, Spacetime bindings, and admin UI
- Enforce pending/expired invite code redemption behavior and expose admin status
- Add admin write-operation confirmation guard and documentation
- Add invite code contract/runtime tests
2026-05-04 13:54:40 +08:00

542 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
disableProfileTaskConfig,
listProfileTaskConfigs,
upsertProfileTaskConfig,
} from '../api/adminApiClient';
import type {
ProfileTaskConfigAdminResponse,
ProfileTaskCycle,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
interface AdminTaskConfigPageProps {
token: string;
result: ProfileTaskConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
}
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
{value: 'daily', label: '每日'},
];
const profileTaskScopeKind = 'user' satisfies TrackingScopeKind;
export function AdminTaskConfigPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminTaskConfigPageProps) {
const [entries, setEntries] = useState<ProfileTaskConfigAdminResponse[]>([]);
const [taskId, setTaskId] = useState('daily_login');
const [title, setTitle] = useState('每日登录');
const [description, setDescription] = useState('');
const [eventKey, setEventKey] = useState('daily_login');
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
const [threshold, setThreshold] = useState('1');
const [rewardPoints, setRewardPoints] = useState('10');
const [sortOrder, setSortOrder] = useState('10');
const [enabled, setEnabled] = useState(true);
const [disableTaskId, setDisableTaskId] = useState('daily_login');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshTaskConfigs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectedEventDefinition = useMemo(
() => findAdminTrackingEventDefinition(eventKey),
[eventKey],
);
const filteredEventDefinitions = useMemo(
() => filterAdminTrackingEventDefinitions(eventKeySearch),
[eventKeySearch],
);
async function refreshTaskConfigs() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileTaskConfigs(token);
setEntries(response.entries);
const dailyLogin = response.entries.find(
(entry) => entry.taskId === 'daily_login',
);
if (dailyLogin) {
fillForm(dailyLogin);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存任务配置',
target: taskId.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileTaskConfig(token, {
taskId: taskId.trim(),
title: title.trim(),
description,
eventKey: eventKey.trim(),
cycle,
scopeKind: profileTaskScopeKind,
threshold: parsePositiveInteger(threshold),
rewardPoints: parsePositiveInteger(rewardPoints),
enabled,
sortOrder: parseInteger(sortOrder),
});
onResultChange(response);
upsertEntry(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
async function handleDisable(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isDisabling) {
return;
}
setDisableErrorMessage('');
const confirmed = await confirmWrite({
action: '停用任务配置',
target: disableTaskId.trim(),
});
if (!confirmed) {
return;
}
setIsDisabling(true);
try {
const response = await disableProfileTaskConfig(token, {
taskId: disableTaskId.trim(),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
function upsertEntry(next: ProfileTaskConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.taskId !== next.taskId);
return [...rest, next].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.taskId.localeCompare(right.taskId);
});
});
}
function fillForm(entry: ProfileTaskConfigAdminResponse) {
setTaskId(entry.taskId);
setTitle(entry.title);
setDescription(entry.description);
setEventKey(entry.eventKey);
setCycle(entry.cycle);
setThreshold(String(entry.threshold));
setRewardPoints(String(entry.rewardPoints));
setSortOrder(String(entry.sortOrder));
setEnabled(entry.enabled);
setDisableTaskId(entry.taskId);
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
setIsEventKeyPickerOpen(false);
}
function selectEventKey(nextEventKey: string) {
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
setEventKey(nextEventKey);
if (nextDefinition) {
setEventKeySearch(nextDefinition.title);
} else {
setEventKeySearch(nextEventKey);
}
setIsEventKeyPickerOpen(false);
}
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={refreshTaskConfigs}
>
<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>Task ID</span>
<input
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(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>Event Key</span>
<div
className="admin-combobox"
onBlur={(event) => {
const nextTarget = event.relatedTarget;
if (
!(nextTarget instanceof Node) ||
!event.currentTarget.contains(nextTarget)
) {
setIsEventKeyPickerOpen(false);
}
}}
>
<div className="admin-combobox-control">
<input
aria-label="Event Key"
value={eventKeySearch || eventKey}
onChange={(event) => {
const nextValue = event.target.value;
setEventKeySearch(nextValue);
setIsEventKeyPickerOpen(true);
}}
onFocus={() => setIsEventKeyPickerOpen(true)}
/>
<button
aria-label="展开 Event Key"
className="admin-combobox-toggle"
type="button"
onClick={() =>
setIsEventKeyPickerOpen((current) => !current)
}
>
<ChevronDown size={16} aria-hidden="true" />
</button>
</div>
{isEventKeyPickerOpen ? (
<div className="admin-combobox-menu" role="listbox">
{filteredEventDefinitions.length ? (
filteredEventDefinitions.map((definition) => (
<button
key={definition.key}
className="admin-combobox-option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectEventKey(definition.key)}
>
<strong>{definition.title}</strong>
<span>{definition.key}</span>
<small>{definition.remark}</small>
</button>
))
) : (
<div className="admin-combobox-empty">
{eventKeySearch.trim() ? (
<button
className="admin-text-button"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
selectEventKey(eventKeySearch.trim())
}
>
使 key
</button>
) : null}
</div>
)}
</div>
) : null}
</div>
{selectedEventDefinition ? (
<small className="admin-field-note">
{selectedEventDefinition.remark}
</small>
) : (
<small className="admin-field-note">
key
</small>
)}
</label>
</div>
<label className="admin-field">
<span></span>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={cycle}
onChange={(event) =>
setCycle(event.target.value as ProfileTaskCycle)
}
>
{taskCycles.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={threshold}
onChange={(event) => setThreshold(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
</div>
<label className="admin-field admin-field-compact">
<span></span>
<input
step={1}
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!taskId.trim() ||
!title.trim() ||
!eventKey.trim() ||
!parsePositiveInteger(threshold) ||
!parsePositiveInteger(rewardPoints)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.taskId}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.taskId}
</button>
<small>{entry.taskId}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无配置'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Task ID</span>
<input
value={disableTaskId}
onChange={(event) => setDisableTaskId(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableTaskId.trim()}
type="submit"
>
<PowerOff size={17} aria-hidden="true" />
<span>{isDisabling ? '停用中' : '停用'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.taskId ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Task ID</dt>
<dd>{result.taskId}</dd>
</div>
<div>
<dt>Event Key</dt>
<dd>{result.eventKey}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.threshold}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedBy}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
{confirmDialog}
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}