8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
584 lines
18 KiB
TypeScript
584 lines
18 KiB
TypeScript
import {
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminEmptyState,
|
|
AdminField,
|
|
AdminFormRow,
|
|
AdminInfoList,
|
|
AdminListPanel,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
AdminPanel,
|
|
formatAdminDateTime,
|
|
} from '@genarrative/shared/components';
|
|
import { ChevronDown, PowerOff, RefreshCcw, Save } from 'lucide-react';
|
|
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
disableProfileTaskConfig,
|
|
listAdminTrackingEventKeys,
|
|
listProfileTaskConfigs,
|
|
upsertProfileTaskConfig,
|
|
} from '../api/adminApiClient';
|
|
import type {
|
|
AdminTrackingEventKeyPayload,
|
|
ProfileTaskConfigAdminResponse,
|
|
ProfileTaskCycle,
|
|
TrackingScopeKind,
|
|
} from '../api/adminApiTypes';
|
|
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
|
import {
|
|
buildAdminTrackingEventKeyOptions,
|
|
filterAdminTrackingEventKeyOptions,
|
|
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 [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
|
|
[],
|
|
);
|
|
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();
|
|
void refreshTrackingEventKeys();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [token]);
|
|
|
|
const eventKeyOptions = useMemo(
|
|
() =>
|
|
buildAdminTrackingEventKeyOptions(eventKeys).filter((option) =>
|
|
option.scopeKinds.includes(profileTaskScopeKind),
|
|
),
|
|
[eventKeys],
|
|
);
|
|
const selectedEventOption = useMemo(
|
|
() =>
|
|
eventKeyOptions.find((option) => option.key === eventKey) ??
|
|
findAdminTrackingEventDefinition(eventKey),
|
|
[eventKey, eventKeyOptions],
|
|
);
|
|
const filteredEventDefinitions = useMemo(
|
|
() => filterAdminTrackingEventKeyOptions(eventKeyOptions, eventKeySearch),
|
|
[eventKeyOptions, eventKeySearch],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (eventKeySearch !== eventKey) {
|
|
return;
|
|
}
|
|
const nextOption =
|
|
eventKeyOptions.find((option) => option.key === eventKey) ??
|
|
findAdminTrackingEventDefinition(eventKey);
|
|
if (nextOption) {
|
|
setEventKeySearch(nextOption.title);
|
|
}
|
|
}, [eventKey, eventKeyOptions, eventKeySearch]);
|
|
|
|
async function refreshTrackingEventKeys() {
|
|
try {
|
|
const response = await listAdminTrackingEventKeys(token);
|
|
setEventKeys(response.eventKeys);
|
|
} catch {
|
|
setEventKeys([]);
|
|
}
|
|
}
|
|
|
|
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 nextOption =
|
|
eventKeyOptions.find((option) => option.key === entry.eventKey) ??
|
|
findAdminTrackingEventDefinition(entry.eventKey);
|
|
setEventKeySearch(nextOption?.title ?? entry.eventKey);
|
|
setIsEventKeyPickerOpen(false);
|
|
}
|
|
|
|
function selectEventKey(nextEventKey: string) {
|
|
const nextOption =
|
|
eventKeyOptions.find((option) => option.key === nextEventKey) ??
|
|
findAdminTrackingEventDefinition(nextEventKey);
|
|
setEventKey(nextEventKey);
|
|
if (nextOption) {
|
|
setEventKeySearch(nextOption.title);
|
|
} else {
|
|
setEventKeySearch(nextEventKey);
|
|
}
|
|
setIsEventKeyPickerOpen(false);
|
|
}
|
|
|
|
return (
|
|
<AdminPage>
|
|
<AdminPageHeading
|
|
title="任务配置"
|
|
description="默认每日登录任务"
|
|
actions={
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={refreshTaskConfigs}
|
|
>
|
|
<RefreshCcw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
|
|
|
|
<div className="admin-two-column admin-two-column-wide">
|
|
<AdminPanel as="form" onSubmit={handleSave}>
|
|
<AdminFormRow>
|
|
<AdminField label="Task ID" fill>
|
|
<input
|
|
value={taskId}
|
|
onChange={(event) => setTaskId(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<label className="admin-switch-field">
|
|
<input
|
|
checked={enabled}
|
|
type="checkbox"
|
|
onChange={(event) => setEnabled(event.target.checked)}
|
|
/>
|
|
<span>启用</span>
|
|
</label>
|
|
</AdminFormRow>
|
|
|
|
<AdminFormRow>
|
|
<AdminField label="标题">
|
|
<input
|
|
value={title}
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="Event Key">
|
|
<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() ? (
|
|
<AdminButton
|
|
variant="text"
|
|
type="button"
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() =>
|
|
selectEventKey(eventKeySearch.trim())
|
|
}
|
|
>
|
|
使用自定义 key
|
|
</AdminButton>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{selectedEventOption ? (
|
|
<small className="admin-field-note">
|
|
{selectedEventOption.remark}
|
|
</small>
|
|
) : (
|
|
<small className="admin-field-note">
|
|
自定义埋点 key,保存前请确认后端已有对应埋点写入。
|
|
</small>
|
|
)}
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
|
|
<AdminField label="描述">
|
|
<textarea
|
|
rows={3}
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
|
|
<AdminFormRow>
|
|
<AdminField label="周期">
|
|
<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>
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
|
|
<AdminFormRow>
|
|
<AdminField label="完成阈值">
|
|
<input
|
|
min={1}
|
|
step={1}
|
|
type="number"
|
|
value={threshold}
|
|
onChange={(event) => setThreshold(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="奖励泥点">
|
|
<input
|
|
min={1}
|
|
step={1}
|
|
type="number"
|
|
value={rewardPoints}
|
|
onChange={(event) => setRewardPoints(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
</AdminFormRow>
|
|
|
|
<AdminField label="排序" compact>
|
|
<input
|
|
step={1}
|
|
type="number"
|
|
value={sortOrder}
|
|
onChange={(event) => setSortOrder(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
|
|
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
|
|
|
<AdminButton
|
|
variant="primary"
|
|
disabled={
|
|
isSaving ||
|
|
!taskId.trim() ||
|
|
!title.trim() ||
|
|
!eventKey.trim() ||
|
|
!parsePositiveInteger(threshold) ||
|
|
!parsePositiveInteger(rewardPoints)
|
|
}
|
|
type="submit"
|
|
>
|
|
<Save size={17} aria-hidden="true" />
|
|
<span>{isSaving ? '保存中' : '保存'}</span>
|
|
</AdminButton>
|
|
</AdminPanel>
|
|
|
|
<div className="admin-stack">
|
|
<AdminListPanel
|
|
columns={[
|
|
{
|
|
key: 'title',
|
|
label: '任务',
|
|
sortable: true,
|
|
sortValue: (entry) => entry.title || entry.taskId,
|
|
},
|
|
{
|
|
key: 'rewardPoints',
|
|
label: '奖励',
|
|
sortable: true,
|
|
sortValue: (entry) => entry.rewardPoints,
|
|
},
|
|
{
|
|
key: 'enabled',
|
|
label: '状态',
|
|
sortable: true,
|
|
sortDescription: '启用状态',
|
|
sortValue: (entry) => (entry.enabled ? 1 : 0),
|
|
},
|
|
]}
|
|
renderRow={(entry) => (
|
|
<tr key={entry.taskId}>
|
|
<td>
|
|
<AdminButton
|
|
variant="text"
|
|
type="button"
|
|
onClick={() => fillForm(entry)}
|
|
>
|
|
{entry.title || entry.taskId}
|
|
</AdminButton>
|
|
<small>{entry.taskId}</small>
|
|
</td>
|
|
<td>{entry.rewardPoints}</td>
|
|
<td>{entry.enabled ? '启用' : '停用'}</td>
|
|
</tr>
|
|
)}
|
|
title="配置列表"
|
|
count={entries.length}
|
|
rows={entries}
|
|
busy={isLoading}
|
|
busyText="加载中"
|
|
emptyText="暂无配置"
|
|
size="compact"
|
|
/>
|
|
|
|
<AdminPanel as="form" onSubmit={handleDisable}>
|
|
<AdminField label="停用 Task ID">
|
|
<input
|
|
value={disableTaskId}
|
|
onChange={(event) => setDisableTaskId(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
{disableErrorMessage ? (
|
|
<AdminAlert>{disableErrorMessage}</AdminAlert>
|
|
) : null}
|
|
<AdminButton
|
|
variant="danger"
|
|
disabled={isDisabling || !disableTaskId.trim()}
|
|
type="submit"
|
|
>
|
|
<PowerOff size={17} aria-hidden="true" />
|
|
<span>{isDisabling ? '停用中' : '停用'}</span>
|
|
</AdminButton>
|
|
</AdminPanel>
|
|
|
|
<AdminPanel
|
|
title="记录"
|
|
count={result?.taskId ?? '-'}
|
|
className="admin-result-panel"
|
|
>
|
|
{result ? (
|
|
<AdminInfoList
|
|
items={[
|
|
{
|
|
label: <>Task ID</>,
|
|
value: <>{result.taskId}</>,
|
|
},
|
|
{
|
|
label: <>Event Key</>,
|
|
value: <>{result.eventKey}</>,
|
|
},
|
|
{
|
|
label: <>奖励</>,
|
|
value: <>{result.rewardPoints}</>,
|
|
},
|
|
{
|
|
label: <>阈值</>,
|
|
value: <>{result.threshold}</>,
|
|
},
|
|
{
|
|
label: <>状态</>,
|
|
value: <>{result.enabled ? '启用' : '停用'}</>,
|
|
},
|
|
{
|
|
label: <>更新人</>,
|
|
value: <>{result.updatedByDisplayName}</>,
|
|
},
|
|
{
|
|
label: <>更新</>,
|
|
value: <>{formatAdminDateTime(result.updatedAt)}</>,
|
|
},
|
|
]}
|
|
/>
|
|
) : (
|
|
<AdminEmptyState>暂无记录</AdminEmptyState>
|
|
)}
|
|
</AdminPanel>
|
|
</div>
|
|
</div>
|
|
{confirmDialog}
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|