- 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
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
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;
|
||
}
|