成就,等级配置
This commit is contained in:
parent
0112303b88
commit
8c9f5d35b2
@ -46,7 +46,27 @@
|
||||
"name": "achievement_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "achievement:update",
|
||||
"x-permissions": ["achievement:update"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteAchievementDefinition",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "achievement_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@ -2514,6 +2534,44 @@
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Page"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/PageSize"
|
||||
},
|
||||
{
|
||||
"name": "user_keyword",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "biz_type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_at_ms",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end_at_ms",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "coin-ledger:view",
|
||||
"x-permissions": ["coin-ledger:view"]
|
||||
}
|
||||
|
||||
@ -49,3 +49,121 @@
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badgeCover {
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badgeCover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.resourceField :global(.MuiInputBase-input:not(:disabled)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resourceDrawer.resourceDrawer {
|
||||
width: min(640px, 100vw);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.resourceDrawerBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resourceGrid {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.resourceCard {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 78px;
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
box-shadow var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.resourceCard:hover {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-surface);
|
||||
}
|
||||
|
||||
.resourceCard:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.resourceCardSelected,
|
||||
.resourceCardSelected:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-surface-strong);
|
||||
box-shadow: inset 0 0 0 1px var(--primary-border);
|
||||
}
|
||||
|
||||
.resourceCard .badgeCover {
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.resourceName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.resourceGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.resourceCard {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +96,16 @@ export function updateAchievementDefinition(
|
||||
).then(normalizeAchievement);
|
||||
}
|
||||
|
||||
export function deleteAchievementDefinition(achievementId: EntityId): Promise<AchievementDefinitionDto> {
|
||||
const endpoint = API_ENDPOINTS.deleteAchievementDefinition;
|
||||
return apiRequest<unknown>(
|
||||
apiEndpointPath(API_OPERATIONS.deleteAchievementDefinition, { achievement_id: achievementId }),
|
||||
{
|
||||
method: endpoint.method,
|
||||
},
|
||||
).then(normalizeAchievement);
|
||||
}
|
||||
|
||||
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
|
||||
return {
|
||||
...page,
|
||||
|
||||
@ -8,8 +8,8 @@ export const achievementStatusOptions = [
|
||||
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
|
||||
|
||||
export const collectionTypeOptions = [
|
||||
["ordinary", "普通成就"],
|
||||
["activity_limited", "活动成就"],
|
||||
["ordinary", "普通荣耀成就"],
|
||||
["event", "活动成就"],
|
||||
];
|
||||
|
||||
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
|
||||
@ -17,8 +17,8 @@ export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionT
|
||||
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
|
||||
|
||||
export const achievementTypeOptions = [
|
||||
["ordinary", "普通成就"],
|
||||
["activity_limited", "限时活动"],
|
||||
["ordinary", "普通荣耀成就"],
|
||||
["activity_limited", "活动成就"],
|
||||
];
|
||||
|
||||
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { listResourceGroups, listResources } from "@/features/resources/api";
|
||||
import { listResources } from "@/features/resources/api";
|
||||
import {
|
||||
createAchievementDefinition,
|
||||
deleteAchievementDefinition,
|
||||
listAchievementDefinitions,
|
||||
updateAchievementDefinition,
|
||||
} from "@/features/achievements/api";
|
||||
@ -14,35 +16,16 @@ import { achievementFormSchema } from "@/features/achievements/schema";
|
||||
const pageSize = 50;
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
|
||||
const groupPageQuery = { page: 1, page_size: 500, status: "active" };
|
||||
|
||||
const emptyCondition = (condition = {}) => ({
|
||||
dimensionFilterJson: condition.dimensionFilterJson || "{}",
|
||||
metricType: condition.metricType || "",
|
||||
targetUnit: condition.targetUnit || "count",
|
||||
targetValue: condition.targetValue === 0 || condition.targetValue ? String(condition.targetValue) : "",
|
||||
});
|
||||
|
||||
const emptyForm = (achievement = {}) => ({
|
||||
achievementType: achievement.achievementType || "ordinary",
|
||||
autoPinPolicy: achievement.autoPinPolicy || "if_empty",
|
||||
collectionId: achievement.collectionId || "",
|
||||
collectionType: achievement.collectionType || "ordinary",
|
||||
conditions: achievement.conditions?.length ? achievement.conditions.map(emptyCondition) : [emptyCondition()],
|
||||
description: achievement.description || "",
|
||||
displayConfigJson: achievement.displayConfigJson || "{}",
|
||||
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
|
||||
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
|
||||
honorVisibility: achievement.honorVisibility ?? true,
|
||||
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
||||
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
|
||||
sortOrder: valueOrDefault(achievement.sortOrder, 0),
|
||||
status: achievement.status || "draft",
|
||||
title: achievement.title || "",
|
||||
});
|
||||
|
||||
export function useAchievementsPage() {
|
||||
const abilities = useAchievementAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const [collectionType, setCollectionType] = useState("");
|
||||
const [collectionId, setCollectionId] = useState("");
|
||||
@ -52,7 +35,6 @@ export function useAchievementsPage() {
|
||||
const [form, setForm] = useState(() => emptyForm());
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [badgeResources, setBadgeResources] = useState([]);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
@ -77,12 +59,8 @@ export function useAchievementsPage() {
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
try {
|
||||
const [resourcePage, groupPage] = await Promise.all([
|
||||
listResources(resourcePageQuery),
|
||||
listResourceGroups(groupPageQuery),
|
||||
]);
|
||||
const resourcePage = await listResources(resourcePageQuery);
|
||||
setBadgeResources(resourcePage.items || []);
|
||||
setResourceGroups(groupPage.items || []);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载资源选项失败", "error");
|
||||
}
|
||||
@ -144,46 +122,25 @@ export function useAchievementsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
|
||||
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
|
||||
const deleteAchievement = async (achievement) => {
|
||||
if (!abilities.canUpdate || !achievement?.achievementId) {
|
||||
return;
|
||||
}
|
||||
const nextStatus = nextEnabled ? "active" : "paused";
|
||||
if (achievement.status === nextStatus) {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: `${achievement.title || achievement.achievementId} 会从成就配置列表和 App 成就列表移除,用户历史徽章展示保留。`,
|
||||
title: "删除成就",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
|
||||
await updateAchievementDefinition(
|
||||
achievement.achievementId,
|
||||
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
|
||||
);
|
||||
await runAction(`delete-${achievement.achievementId}`, "成就已删除", async () => {
|
||||
await deleteAchievementDefinition(achievement.achievementId);
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
setForm((current) => ({ ...current, conditions: [...current.conditions, emptyCondition()] }));
|
||||
};
|
||||
|
||||
const removeCondition = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
conditions:
|
||||
current.conditions.length > 1
|
||||
? current.conditions.filter((_, itemIndex) => itemIndex !== index)
|
||||
: current.conditions,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCondition = (index, key, value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
conditions: current.conditions.map((condition, itemIndex) =>
|
||||
itemIndex === index ? { ...condition, [key]: value } : condition,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (action, successMessage, submitter) => {
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
@ -199,7 +156,6 @@ export function useAchievementsPage() {
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
addCondition,
|
||||
badgeResources,
|
||||
changeCollectionId,
|
||||
changeCollectionType,
|
||||
@ -215,39 +171,33 @@ export function useAchievementsPage() {
|
||||
openEdit,
|
||||
page,
|
||||
reload: reloadAll,
|
||||
removeCondition,
|
||||
resetFilters,
|
||||
resourceGroups,
|
||||
selectedAchievement,
|
||||
setForm,
|
||||
setPage,
|
||||
deleteAchievement,
|
||||
submitAchievement,
|
||||
toggleAchievement,
|
||||
updateCondition,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAchievementPayload(form) {
|
||||
const achievementType = form.achievementType;
|
||||
const collectionType = achievementType === "activity_limited" ? "event" : "ordinary";
|
||||
return {
|
||||
achievement_type: form.achievementType,
|
||||
auto_pin_policy: form.autoPinPolicy,
|
||||
collection_id: String(form.collectionId || "").trim(),
|
||||
collection_type: form.collectionType,
|
||||
conditions: form.conditions.map((condition) => ({
|
||||
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
|
||||
metric_type: String(condition.metricType || "").trim(),
|
||||
target_unit: String(condition.targetUnit || "").trim(),
|
||||
target_value: Number(condition.targetValue || 0),
|
||||
})),
|
||||
achievement_type: achievementType,
|
||||
auto_pin_policy: "none",
|
||||
collection_id: collectionType === "ordinary" ? "ordinary" : "",
|
||||
collection_type: collectionType,
|
||||
conditions: [],
|
||||
description: String(form.description || "").trim(),
|
||||
display_config_json: normalizedJson(form.displayConfigJson),
|
||||
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
||||
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
||||
honor_visibility: Boolean(form.honorVisibility),
|
||||
display_config_json: "{}",
|
||||
effective_from_ms: 0,
|
||||
effective_to_ms: 0,
|
||||
honor_visibility: true,
|
||||
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||
sort_order: Number(form.sortOrder || 0),
|
||||
status: form.status,
|
||||
reward_resource_group_id: 0,
|
||||
sort_order: 0,
|
||||
status: "active",
|
||||
title: String(form.title || "").trim(),
|
||||
};
|
||||
}
|
||||
@ -259,36 +209,6 @@ function resetSetter(setter, setPage) {
|
||||
};
|
||||
}
|
||||
|
||||
function msToDatetimeLocal(value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(Number(value));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function datetimeLocalToMs(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = new Date(trimmed).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizedJson(value) {
|
||||
const raw = String(value || "").trim();
|
||||
return raw || "{}";
|
||||
}
|
||||
|
||||
function optionalId(value) {
|
||||
return value ? String(value) : "";
|
||||
}
|
||||
|
||||
function valueOrDefault(value, fallback) {
|
||||
return value === 0 || value ? String(value) : String(fallback);
|
||||
}
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import Add from "@mui/icons-material/Add";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormList,
|
||||
AdminFormListRow,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { useState } from "react";
|
||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
@ -22,99 +16,46 @@ import {
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
AdminSearchBox,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import {
|
||||
achievementStatusLabels,
|
||||
achievementStatusOptions,
|
||||
achievementTypeLabels,
|
||||
achievementTypeOptions,
|
||||
autoPinPolicyLabels,
|
||||
autoPinPolicyOptions,
|
||||
collectionTypeFilterOptions,
|
||||
collectionTypeLabels,
|
||||
collectionTypeOptions,
|
||||
targetUnitLabels,
|
||||
targetUnitOptions,
|
||||
} from "@/features/achievements/constants.js";
|
||||
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
|
||||
import styles from "@/features/achievements/achievements.module.css";
|
||||
|
||||
const baseColumns = [
|
||||
{
|
||||
key: "achievement",
|
||||
label: "成就",
|
||||
width: "minmax(260px, 1.4fr)",
|
||||
render: (achievement) => <AchievementIdentity achievement={achievement} />,
|
||||
},
|
||||
{
|
||||
key: "condition",
|
||||
label: "达成条件",
|
||||
width: "minmax(240px, 1.2fr)",
|
||||
render: (achievement) => <ConditionSummary conditions={achievement.conditions} />,
|
||||
},
|
||||
{
|
||||
key: "badge",
|
||||
label: "展示徽章",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
label: "徽章",
|
||||
width: "96px",
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励资源组",
|
||||
width: "minmax(190px, 0.9fr)",
|
||||
key: "title",
|
||||
label: "成就名称",
|
||||
width: "minmax(180px, 1fr)",
|
||||
render: (achievement) => <span className={styles.name}>{achievement.title || achievement.achievementId}</span>,
|
||||
},
|
||||
{
|
||||
key: "display",
|
||||
label: "展示策略",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
key: "achievementType",
|
||||
label: "成就类型",
|
||||
width: "minmax(140px, 0.55fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
|
||||
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
|
||||
</div>
|
||||
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "effective",
|
||||
label: "有效期",
|
||||
width: "minmax(220px, 1.05fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"}</span>
|
||||
<span className={styles.meta}>
|
||||
{achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "版本 / 排序",
|
||||
width: "minmax(120px, 0.6fr)",
|
||||
render: (achievement) => (
|
||||
<div className={styles.stack}>
|
||||
<span>v{achievement.version || 1}</span>
|
||||
<span className={styles.meta}>{achievement.sortOrder || 0}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(116px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "更新时间",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (achievement) => formatMillis(achievement.updatedAtMs || achievement.createdAtMs),
|
||||
key: "description",
|
||||
label: "成就描述",
|
||||
width: "minmax(260px, 1.4fr)",
|
||||
render: (achievement) => achievement.description || <span className={styles.emptyValue}>未填写</span>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(76px, 0.35fr)",
|
||||
width: "112px",
|
||||
},
|
||||
];
|
||||
|
||||
@ -128,24 +69,10 @@ export function AchievementConfigPage() {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => (
|
||||
<ResourceLabel id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
|
||||
<BadgeCover id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
|
||||
),
|
||||
};
|
||||
}
|
||||
if (column.key === "reward") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => (
|
||||
<ResourceGroupLabel groups={page.resourceGroups} id={achievement.rewardResourceGroupId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
render: (achievement) => <AchievementStatusSwitch achievement={achievement} page={page} />,
|
||||
};
|
||||
}
|
||||
if (column.key === "actions") {
|
||||
return {
|
||||
...column,
|
||||
@ -161,20 +88,12 @@ export function AchievementConfigPage() {
|
||||
filters={
|
||||
<>
|
||||
<AdminFilterSelect
|
||||
label="合集类型"
|
||||
label="成就类型"
|
||||
options={collectionTypeFilterOptions}
|
||||
value={page.collectionType}
|
||||
onChange={page.changeCollectionType}
|
||||
/>
|
||||
<AdminSearchBox
|
||||
placeholder="搜索合集 ID"
|
||||
value={page.collectionId}
|
||||
onChange={page.changeCollectionId}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.collectionType && !page.collectionId}
|
||||
onClick={page.resetFilters}
|
||||
/>
|
||||
<AdminFilterResetButton disabled={!page.collectionType} onClick={page.resetFilters} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
@ -190,7 +109,7 @@ export function AchievementConfigPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1710px"
|
||||
minWidth="900px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -219,56 +138,6 @@ export function AchievementConfigPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementIdentity({ achievement }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
||||
<div className={styles.identityText}>
|
||||
<span className={styles.name}>{achievement.title || achievement.achievementId}</span>
|
||||
<span className={styles.meta}>{achievement.achievementId}</span>
|
||||
<span className={styles.meta}>
|
||||
{collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionSummary({ conditions = [] }) {
|
||||
const first = conditions[0] || {};
|
||||
if (!first.metricType) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{first.metricType}</span>
|
||||
<span className={styles.meta}>
|
||||
{formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)}
|
||||
{conditions.length > 1 ? ` · 共 ${conditions.length} 条` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementStatusSwitch({ achievement, page }) {
|
||||
const checked = achievement.status === "active";
|
||||
const disabled =
|
||||
!page.abilities.canUpdate ||
|
||||
achievement.status === "archived" ||
|
||||
page.loadingAction === `status-${achievement.achievementId}`;
|
||||
return (
|
||||
<div className={styles.statusCell}>
|
||||
<AdminSwitch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": checked ? "暂停成就" : "启用成就" }}
|
||||
onChange={(event) => page.toggleAchievement(achievement, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.meta}>{achievementStatusLabel(achievement.status)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementRowActions({ achievement, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
@ -279,6 +148,13 @@ function AchievementRowActions({ achievement, page }) {
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page.abilities.canUpdate || page.loadingAction === `delete-${achievement.achievementId}`}
|
||||
label="删除成就"
|
||||
onClick={() => page.deleteAchievement(achievement)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
@ -297,20 +173,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
||||
>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
<BadgeResourceSelectField
|
||||
disabled={disabled}
|
||||
label="合集类型"
|
||||
required
|
||||
select
|
||||
value={form.collectionType}
|
||||
onChange={changeField(page.setForm, "collectionType")}
|
||||
>
|
||||
{collectionTypeOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
label="选择徽章资源"
|
||||
resources={page.badgeResources}
|
||||
value={form.primaryBadgeResourceId}
|
||||
onChange={(value) => page.setForm((current) => ({ ...current, primaryBadgeResourceId: value }))}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="成就类型"
|
||||
@ -325,26 +194,6 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="合集 ID"
|
||||
value={form.collectionId}
|
||||
onChange={changeField(page.setForm, "collectionId")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
required
|
||||
select
|
||||
value={form.status}
|
||||
onChange={changeField(page.setForm, "status")}
|
||||
>
|
||||
{achievementStatusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="成就名称"
|
||||
@ -360,189 +209,90 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection
|
||||
actions={
|
||||
<AdminActionIconButton
|
||||
disabled={disabled}
|
||||
label="添加条件"
|
||||
type="button"
|
||||
onClick={page.addCondition}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
}
|
||||
title="达成条件"
|
||||
>
|
||||
<AdminFormList>
|
||||
{form.conditions.map((condition, index) => (
|
||||
<AdminFormListRow
|
||||
key={index}
|
||||
columns="minmax(180px, 1fr) minmax(110px, 0.55fr) minmax(120px, 0.6fr) minmax(220px, 1.1fr) 44px"
|
||||
>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="指标类型"
|
||||
required
|
||||
value={condition.metricType}
|
||||
onChange={(event) => page.updateCondition(index, "metricType", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="目标值"
|
||||
required
|
||||
type="number"
|
||||
value={condition.targetValue}
|
||||
onChange={(event) => page.updateCondition(index, "targetValue", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标单位"
|
||||
required
|
||||
select
|
||||
value={condition.targetUnit}
|
||||
onChange={(event) => page.updateCondition(index, "targetUnit", event.target.value)}
|
||||
>
|
||||
{targetUnitOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="维度过滤 JSON"
|
||||
value={condition.dimensionFilterJson}
|
||||
onChange={(event) =>
|
||||
page.updateCondition(index, "dimensionFilterJson", event.target.value)
|
||||
}
|
||||
/>
|
||||
<AdminActionIconButton
|
||||
disabled={disabled || form.conditions.length <= 1}
|
||||
label="删除条件"
|
||||
type="button"
|
||||
onClick={() => page.removeCondition(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminFormListRow>
|
||||
))}
|
||||
</AdminFormList>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="徽章与奖励">
|
||||
<AdminFormFieldGrid>
|
||||
<ResourceSelect
|
||||
disabled={disabled}
|
||||
label="达成展示徽章"
|
||||
resources={page.badgeResources}
|
||||
value={form.primaryBadgeResourceId}
|
||||
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelectField
|
||||
allowEmpty
|
||||
disabled={disabled}
|
||||
groups={page.resourceGroups}
|
||||
label="达成奖励资源组"
|
||||
value={form.rewardResourceGroupId}
|
||||
onChange={(value) => page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="展示策略"
|
||||
select
|
||||
value={form.autoPinPolicy}
|
||||
onChange={changeField(page.setForm, "autoPinPolicy")}
|
||||
>
|
||||
{autoPinPolicyOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AdminFormSwitchField
|
||||
checked={form.honorVisibility}
|
||||
checkedLabel="显示"
|
||||
disabled={disabled}
|
||||
label="荣誉墙"
|
||||
uncheckedLabel="隐藏"
|
||||
onChange={(checked) => page.setForm((current) => ({ ...current, honorVisibility: checked }))}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="时间与展示">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="开始时间"
|
||||
type="datetime-local"
|
||||
value={form.effectiveFrom}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={changeField(page.setForm, "effectiveFrom")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="结束时间"
|
||||
type="datetime-local"
|
||||
value={form.effectiveTo}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={changeField(page.setForm, "effectiveTo")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={form.sortOrder}
|
||||
onChange={changeField(page.setForm, "sortOrder")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="展示配置 JSON"
|
||||
value={form.displayConfigJson}
|
||||
onChange={changeField(page.setForm, "displayConfigJson")}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
||||
const options = withSelectedResource(resources, value);
|
||||
function BadgeCover({ id, resources }) {
|
||||
const resource = findResource(resources, id);
|
||||
const imageUrl = resourceImageUrl(resource);
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
||||
<MenuItem value="">不配置</MenuItem>
|
||||
{options.map((resource) => (
|
||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
||||
{resource.name || resource.resourceCode || resource.resourceId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<span className={styles.badgeCover}>
|
||||
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabel({ id, resources }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function BadgeResourceSelectField({ disabled, label, onChange, resources, value }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const normalizedValue = String(value || "");
|
||||
const selected = findResource(resources, normalizedValue);
|
||||
const displayValue = selected ? resourceName(selected) : normalizedValue ? `资源 #${normalizedValue}` : "";
|
||||
const options = withSelectedResource(resources, normalizedValue);
|
||||
|
||||
const openDrawer = () => {
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (resourceId) => {
|
||||
onChange(String(resourceId || ""));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
function ResourceGroupLabel({ groups, id }) {
|
||||
if (!id) {
|
||||
return <span className={styles.emptyValue}>未配置</span>;
|
||||
}
|
||||
const group = groups.find((item) => Number(item.groupId) === Number(id));
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
|
||||
<span className={styles.meta}>ID {id}</span>
|
||||
</div>
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
className={styles.resourceField}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
required
|
||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||
value={displayValue}
|
||||
onClick={openDrawer}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDrawer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SideDrawer
|
||||
className={styles.resourceDrawer}
|
||||
contentClassName={styles.resourceDrawerBody}
|
||||
drawerProps={{ sx: { zIndex: 1600 } }}
|
||||
open={open}
|
||||
title="选择徽章资源"
|
||||
width="wide"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div className={styles.resourceGrid}>
|
||||
{options.map((resource) => {
|
||||
const resourceValue = String(resource.resourceId);
|
||||
const selectedResource = resourceValue === normalizedValue;
|
||||
return (
|
||||
<button
|
||||
className={[styles.resourceCard, selectedResource ? styles.resourceCardSelected : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={resource.resourceId}
|
||||
type="button"
|
||||
onClick={() => selectResource(resource.resourceId)}
|
||||
>
|
||||
<BadgeCover id={resource.resourceId} resources={options} />
|
||||
<span className={styles.resourceName}>{resourceName(resource)}</span>
|
||||
<span className={styles.meta}>
|
||||
{resource.resourceCode || `ID ${resource.resourceId}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!options.length ? <div className={styles.emptyState}>当前无数据</div> : null}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -561,26 +311,18 @@ function withSelectedResource(resources, value) {
|
||||
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||
}
|
||||
|
||||
function achievementStatusLabel(status) {
|
||||
return achievementStatusLabels[status] || status || "-";
|
||||
}
|
||||
|
||||
function achievementTypeLabel(type) {
|
||||
return achievementTypeLabels[type] || type || "-";
|
||||
}
|
||||
|
||||
function autoPinPolicyLabel(policy) {
|
||||
return autoPinPolicyLabels[policy] || policy || "-";
|
||||
function findResource(resources, id) {
|
||||
return resources.find((item) => Number(item.resourceId) === Number(id));
|
||||
}
|
||||
|
||||
function collectionTypeLabel(type) {
|
||||
return collectionTypeLabels[type] || type || "-";
|
||||
function resourceImageUrl(resource) {
|
||||
return resource?.previewUrl || resource?.assetUrl || resource?.animationUrl || "";
|
||||
}
|
||||
|
||||
function targetUnitLabel(unit) {
|
||||
return targetUnitLabels[unit] || unit || "";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
function resourceName(resource) {
|
||||
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
|
||||
}
|
||||
|
||||
@ -2,60 +2,17 @@ import { z } from "zod";
|
||||
|
||||
const numberInput = z.union([z.string(), z.number()]);
|
||||
const optionalNumberInput = numberInput.optional();
|
||||
const statusSchema = z.enum(["draft", "active", "paused", "archived"]);
|
||||
const collectionTypeSchema = z.enum(["ordinary", "activity_limited"]);
|
||||
const achievementTypeSchema = z.enum(["ordinary", "activity_limited"]);
|
||||
const autoPinPolicySchema = z.enum(["none", "if_empty", "always"]);
|
||||
|
||||
const conditionSchema = z.object({
|
||||
dimensionFilterJson: z.string().optional(),
|
||||
metricType: z.string().trim().min(1, "请输入指标类型").max(80, "指标类型不能超过 80 个字符"),
|
||||
targetUnit: z.string().trim().min(1, "请选择目标单位").max(32, "目标单位不能超过 32 个字符"),
|
||||
targetValue: numberInput,
|
||||
});
|
||||
|
||||
export const achievementFormSchema = z
|
||||
.object({
|
||||
achievementType: achievementTypeSchema,
|
||||
autoPinPolicy: autoPinPolicySchema,
|
||||
collectionId: z.string().trim().max(80, "合集 ID 不能超过 80 个字符").optional(),
|
||||
collectionType: collectionTypeSchema,
|
||||
conditions: z.array(conditionSchema).min(1, "至少配置一个达成条件").max(10, "最多配置 10 个达成条件"),
|
||||
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
|
||||
displayConfigJson: z.string().optional(),
|
||||
effectiveFrom: z.string().optional(),
|
||||
effectiveTo: z.string().optional(),
|
||||
honorVisibility: z.boolean(),
|
||||
primaryBadgeResourceId: optionalNumberInput,
|
||||
rewardResourceGroupId: optionalNumberInput,
|
||||
sortOrder: optionalNumberInput,
|
||||
status: statusSchema,
|
||||
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
|
||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "奖励资源组必须是有效资源组");
|
||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||
validateJsonObject(context, "displayConfigJson", value.displayConfigJson, "展示配置 JSON 不正确");
|
||||
const fromMs = datetimeToMs(value.effectiveFrom);
|
||||
const toMs = datetimeToMs(value.effectiveTo);
|
||||
if (fromMs > 0 && toMs > 0 && toMs <= fromMs) {
|
||||
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
|
||||
}
|
||||
value.conditions.forEach((condition, index) => {
|
||||
validatePositiveInteger(
|
||||
context,
|
||||
`conditions.${index}.targetValue`,
|
||||
condition.targetValue,
|
||||
"目标值必须是正整数",
|
||||
);
|
||||
validateJsonObject(
|
||||
context,
|
||||
`conditions.${index}.dimensionFilterJson`,
|
||||
condition.dimensionFilterJson,
|
||||
"维度过滤 JSON 不正确",
|
||||
);
|
||||
});
|
||||
validateRequiredId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "请选择徽章资源");
|
||||
});
|
||||
|
||||
export type AchievementForm = z.infer<typeof achievementFormSchema>;
|
||||
@ -67,41 +24,11 @@ function validatePositiveInteger(context: z.RefinementCtx, path: string, value:
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
function validateRequiredId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
return;
|
||||
}
|
||||
validatePositiveInteger(context, path, raw, message);
|
||||
}
|
||||
|
||||
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isInteger(numeric)) {
|
||||
context.addIssue({ code: "custom", message, path: [path] });
|
||||
}
|
||||
}
|
||||
|
||||
function validateJsonObject(context: z.RefinementCtx, path: string, value: unknown, message: string) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
context.addIssue({ code: "custom", message, path: path.split(".") });
|
||||
}
|
||||
} catch {
|
||||
context.addIssue({ code: "custom", message, path: path.split(".") });
|
||||
}
|
||||
}
|
||||
|
||||
function datetimeToMs(value: unknown) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = new Date(raw).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
@ -9,10 +9,12 @@ export interface LevelRuleDto {
|
||||
createdAtMs?: number;
|
||||
displayConfigJson?: string;
|
||||
level: number;
|
||||
avatarFrameResourceId?: number;
|
||||
longBadgeResourceId?: number;
|
||||
name?: string;
|
||||
requiredValue: number;
|
||||
rewardResourceGroupId?: number;
|
||||
shortBadgeResourceId?: number;
|
||||
sortOrder?: number;
|
||||
status?: LevelConfigStatus | string;
|
||||
track: LevelTrackCode;
|
||||
@ -59,10 +61,12 @@ export interface LevelTrackPayload {
|
||||
}
|
||||
|
||||
export interface LevelRulePayload {
|
||||
avatar_frame_resource_id?: number;
|
||||
long_badge_resource_id?: number;
|
||||
name: string;
|
||||
required_value: number;
|
||||
reward_resource_group_id?: number;
|
||||
short_badge_resource_id?: number;
|
||||
sort_order: number;
|
||||
status: LevelConfigStatus;
|
||||
}
|
||||
@ -160,6 +164,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
|
||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||
displayConfigJson: jsonString(displayConfig),
|
||||
level: numberValue(source.level),
|
||||
avatarFrameResourceId: numberValue(
|
||||
source.avatarFrameResourceId ??
|
||||
source.avatar_frame_resource_id ??
|
||||
displayConfigObject.avatarFrameResourceId ??
|
||||
displayConfigObject.avatar_frame_resource_id,
|
||||
),
|
||||
longBadgeResourceId: numberValue(
|
||||
source.longBadgeResourceId ??
|
||||
source.long_badge_resource_id ??
|
||||
@ -169,6 +179,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
|
||||
name: stringValue(source.name),
|
||||
requiredValue: numberValue(source.requiredValue ?? source.required_value),
|
||||
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
|
||||
shortBadgeResourceId: numberValue(
|
||||
source.shortBadgeResourceId ??
|
||||
source.short_badge_resource_id ??
|
||||
displayConfigObject.shortBadgeResourceId ??
|
||||
displayConfigObject.short_badge_resource_id,
|
||||
),
|
||||
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
|
||||
status: stringValue(source.status),
|
||||
track: stringValue(source.track) as LevelTrackCode,
|
||||
|
||||
@ -14,12 +14,16 @@ import {
|
||||
const emptyConfig = { serverTimeMs: 0, tracks: [] };
|
||||
const defaultTrack = "wealth";
|
||||
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
|
||||
const badgeResourcePageQuery = { ...resourcePageQuery, resource_type: "badge" };
|
||||
const avatarFrameResourcePageQuery = { ...resourcePageQuery, resource_type: "avatar_frame" };
|
||||
|
||||
export function useLevelConfigPage() {
|
||||
const abilities = useLevelConfigAbilities();
|
||||
const { showToast } = useToast();
|
||||
const [config, setConfig] = useState(emptyConfig);
|
||||
const [resources, setResources] = useState([]);
|
||||
const [badgeResources, setBadgeResources] = useState([]);
|
||||
const [avatarFrameResources, setAvatarFrameResources] = useState([]);
|
||||
const [resourceGroups, setResourceGroups] = useState([]);
|
||||
const [track, setTrack] = useState(defaultTrack);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -37,13 +41,19 @@ export function useLevelConfigPage() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextConfig, resourcePage, groupPage] = await Promise.all([
|
||||
const [nextConfig, badgeResourcePage, avatarFrameResourcePage, groupPage] = await Promise.all([
|
||||
listLevelConfig(),
|
||||
listResources(resourcePageQuery),
|
||||
// 等级配置只需要徽章和头像框;后端分页按类型过滤,避免 active 资源超过第一页时漏掉等级徽章。
|
||||
listResources(badgeResourcePageQuery),
|
||||
listResources(avatarFrameResourcePageQuery),
|
||||
listResourceGroups(resourcePageQuery),
|
||||
]);
|
||||
const nextBadgeResources = badgeResourcePage.items || [];
|
||||
const nextAvatarFrameResources = avatarFrameResourcePage.items || [];
|
||||
setConfig(nextConfig);
|
||||
setResources(resourcePage.items || []);
|
||||
setBadgeResources(nextBadgeResources);
|
||||
setAvatarFrameResources(nextAvatarFrameResources);
|
||||
setResources([...nextBadgeResources, ...nextAvatarFrameResources]);
|
||||
setResourceGroups(groupPage.items || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载等级配置失败");
|
||||
@ -60,15 +70,6 @@ export function useLevelConfigPage() {
|
||||
() => (config.tracks || []).filter((item) => item.track === track),
|
||||
[config.tracks, track],
|
||||
);
|
||||
const badgeResources = useMemo(
|
||||
() => resources.filter((resource) => resource.resourceType === "badge"),
|
||||
[resources],
|
||||
);
|
||||
const avatarFrameResources = useMemo(
|
||||
() => resources.filter((resource) => resource.resourceType === "avatar_frame"),
|
||||
[resources],
|
||||
);
|
||||
|
||||
const changeTrack = (value) => setTrack(value);
|
||||
|
||||
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
||||
@ -245,11 +246,13 @@ function buildRuleForm(rule = null, levelTrack = null) {
|
||||
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
|
||||
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
|
||||
return {
|
||||
avatarFrameResourceId: optionalId(rule?.avatarFrameResourceId),
|
||||
level: valueOrEmpty(nextLevel),
|
||||
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
|
||||
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}级`,
|
||||
requiredValue: valueOrEmpty(rule?.requiredValue),
|
||||
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
|
||||
shortBadgeResourceId: optionalId(rule?.shortBadgeResourceId),
|
||||
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
|
||||
status: rule?.status || "active",
|
||||
track: trackCode,
|
||||
@ -284,10 +287,12 @@ function buildTrackForm(levelTrack = null) {
|
||||
|
||||
function buildRulePayload(form) {
|
||||
return {
|
||||
avatar_frame_resource_id: Number(form.avatarFrameResourceId || 0),
|
||||
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
|
||||
name: String(form.name || "").trim(),
|
||||
required_value: Number(form.requiredValue || 0),
|
||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||
short_badge_resource_id: Number(form.shortBadgeResourceId || 0),
|
||||
sort_order: Number(form.sortOrder || 0),
|
||||
status: form.status,
|
||||
};
|
||||
|
||||
85
src/features/level-config/hooks/useLevelConfigPage.test.jsx
Normal file
85
src/features/level-config/hooks/useLevelConfigPage.test.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createTier: vi.fn(),
|
||||
listLevelConfig: vi.fn(),
|
||||
listResourceGroups: vi.fn(),
|
||||
listResources: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
updateTier: vi.fn(),
|
||||
updateTrack: vi.fn(),
|
||||
upsertRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resources/api", () => ({
|
||||
listResourceGroups: mocks.listResourceGroups,
|
||||
listResources: mocks.listResources,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/level-config/api", () => ({
|
||||
createTier: mocks.createTier,
|
||||
listLevelConfig: mocks.listLevelConfig,
|
||||
updateTier: mocks.updateTier,
|
||||
updateTrack: mocks.updateTrack,
|
||||
upsertRule: mocks.upsertRule,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/level-config/permissions.js", () => ({
|
||||
useLevelConfigAbilities: () => ({ canUpdate: true, canView: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||
useToast: () => ({ showToast: mocks.showToast }),
|
||||
}));
|
||||
|
||||
import { useLevelConfigPage } from "./useLevelConfigPage.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("loads level badges through backend resource type filtering", async () => {
|
||||
const badgeResource = {
|
||||
badgeForm: "strip",
|
||||
badgeKind: "level",
|
||||
levelTrack: "wealth",
|
||||
name: "财富长徽章",
|
||||
resourceId: 397,
|
||||
resourceType: "badge",
|
||||
};
|
||||
const avatarFrameResource = {
|
||||
name: "等级头像框",
|
||||
resourceId: 812,
|
||||
resourceType: "avatar_frame",
|
||||
};
|
||||
|
||||
mocks.listLevelConfig.mockResolvedValue({
|
||||
serverTimeMs: 0,
|
||||
tracks: [{ rules: [], tiers: [], track: "wealth" }],
|
||||
});
|
||||
mocks.listResources.mockImplementation(async (query) => {
|
||||
if (query.resource_type === "badge") {
|
||||
return { items: [badgeResource], page: 1, pageSize: 500, total: 1 };
|
||||
}
|
||||
if (query.resource_type === "avatar_frame") {
|
||||
return { items: [avatarFrameResource], page: 1, pageSize: 500, total: 1 };
|
||||
}
|
||||
throw new Error(`unexpected resource query ${JSON.stringify(query)}`);
|
||||
});
|
||||
mocks.listResourceGroups.mockResolvedValue({ items: [], page: 1, pageSize: 500, total: 0 });
|
||||
|
||||
const { result } = renderHook(() => useLevelConfigPage());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(mocks.listResources).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.listResources).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, page_size: 500, resource_type: "badge", status: "active" }),
|
||||
);
|
||||
expect(mocks.listResources).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, page_size: 500, resource_type: "avatar_frame", status: "active" }),
|
||||
);
|
||||
expect(result.current.badgeResources).toEqual([badgeResource]);
|
||||
expect(result.current.avatarFrameResources).toEqual([avatarFrameResource]);
|
||||
});
|
||||
@ -222,6 +222,18 @@ function buildRuleColumns(page) {
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
|
||||
},
|
||||
{
|
||||
key: "avatarFrame",
|
||||
label: "等级头像框",
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (rule) => <ResourceLabel id={rule.avatarFrameResourceId} resources={page.avatarFrameResources} />,
|
||||
},
|
||||
{
|
||||
key: "shortBadge",
|
||||
label: "短徽章素材",
|
||||
width: "minmax(200px, 1fr)",
|
||||
render: (rule) => <ResourceLabel id={rule.shortBadgeResourceId} resources={page.badgeResources} />,
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "升级礼物",
|
||||
@ -413,7 +425,15 @@ function RuleFormDialog({ page }) {
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
label="等级类型"
|
||||
value={form.track}
|
||||
onChange={changeField(page.setRuleForm, "track")}
|
||||
onChange={(event) =>
|
||||
page.setRuleForm((current) => ({
|
||||
...current,
|
||||
avatarFrameResourceId: "",
|
||||
longBadgeResourceId: "",
|
||||
shortBadgeResourceId: "",
|
||||
track: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{levelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
@ -454,6 +474,20 @@ function RuleFormDialog({ page }) {
|
||||
value={form.longBadgeResourceId}
|
||||
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
|
||||
/>
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="等级头像框素材"
|
||||
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
|
||||
value={form.avatarFrameResourceId}
|
||||
onChange={changeField(page.setRuleForm, "avatarFrameResourceId")}
|
||||
/>
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="短徽章素材"
|
||||
resources={levelShortBadgeResourcesForTrack(page.badgeResources, form.track)}
|
||||
value={form.shortBadgeResourceId}
|
||||
onChange={changeField(page.setRuleForm, "shortBadgeResourceId")}
|
||||
/>
|
||||
<ResourceGroupSelectField
|
||||
allowEmpty
|
||||
disabled={!page.abilities.canUpdate}
|
||||
@ -510,7 +544,13 @@ function TierFormDialog({ page }) {
|
||||
disabled={editing || !page.abilities.canUpdate}
|
||||
label="等级类型"
|
||||
value={form.track}
|
||||
onChange={changeField(page.setTierForm, "track")}
|
||||
onChange={(event) =>
|
||||
page.setTierForm((current) => ({
|
||||
...current,
|
||||
displayAvatarFrameResourceId: "",
|
||||
track: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{levelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
@ -547,7 +587,7 @@ function TierFormDialog({ page }) {
|
||||
<ResourceSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="头像框素材"
|
||||
resources={page.avatarFrameResources}
|
||||
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
|
||||
value={form.displayAvatarFrameResourceId}
|
||||
onChange={changeField(page.setTierForm, "displayAvatarFrameResourceId")}
|
||||
/>
|
||||
@ -698,11 +738,37 @@ function levelBadgeResourcesForTrack(resources, track) {
|
||||
});
|
||||
}
|
||||
|
||||
function levelShortBadgeResourcesForTrack(resources, track) {
|
||||
return (resources || []).filter((resource) => {
|
||||
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
|
||||
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
|
||||
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
return badgeForm === "tile" && badgeKind === "level" && levelTrack === track;
|
||||
});
|
||||
}
|
||||
|
||||
function levelAvatarFrameResourcesForTrack(resources, track) {
|
||||
return (resources || []).filter((resource) => {
|
||||
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
|
||||
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
return resource.resourceType === "avatar_frame" && avatarFrameKind === "level" && levelTrack === track;
|
||||
});
|
||||
}
|
||||
|
||||
function resourceMetaLabel(resource, id) {
|
||||
if (resource.resourceType === "avatar_frame") {
|
||||
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
|
||||
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
if (avatarFrameKind === "level" && levelTrack) {
|
||||
return `ID ${id} · 等级头像框 · ${levelTrackLabels[levelTrack] || levelTrack}`;
|
||||
}
|
||||
}
|
||||
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
|
||||
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
|
||||
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) {
|
||||
return `ID ${id} · 等级徽章 · ${levelTrackLabels[levelTrack] || levelTrack}`;
|
||||
const badgeFormLabel = badgeForm === "tile" ? "短徽章" : "长徽章";
|
||||
return `ID ${id} · ${badgeFormLabel} · ${levelTrackLabels[levelTrack] || levelTrack}`;
|
||||
}
|
||||
return `ID ${id}`;
|
||||
}
|
||||
@ -746,6 +812,21 @@ function badgeKindFromMetadata(metadataJson) {
|
||||
}
|
||||
}
|
||||
|
||||
function avatarFrameKindFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(metadataJson);
|
||||
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function levelTrackFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
|
||||
@ -17,11 +17,13 @@ export const levelTrackFormSchema = z
|
||||
|
||||
export const levelRuleFormSchema = z
|
||||
.object({
|
||||
avatarFrameResourceId: numberInput.optional(),
|
||||
level: numberInput,
|
||||
longBadgeResourceId: numberInput.optional(),
|
||||
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
|
||||
requiredValue: numberInput,
|
||||
rewardResourceGroupId: numberInput.optional(),
|
||||
shortBadgeResourceId: numberInput.optional(),
|
||||
sortOrder: numberInput.optional(),
|
||||
status: statusSchema,
|
||||
track: trackSchema,
|
||||
@ -29,8 +31,10 @@ export const levelRuleFormSchema = z
|
||||
.superRefine((value, context) => {
|
||||
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
|
||||
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
|
||||
validateOptionalId(context, "avatarFrameResourceId", value.avatarFrameResourceId, "等级头像框素材必须是有效资源");
|
||||
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
|
||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
|
||||
validateOptionalId(context, "shortBadgeResourceId", value.shortBadgeResourceId, "短徽章素材必须是有效资源");
|
||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
getGiftDiamondRatios,
|
||||
getWheelConfig,
|
||||
getWheelDrawSummary,
|
||||
listCoinLedger,
|
||||
listCoinAdjustments,
|
||||
listCoinSellerLedger,
|
||||
listWheelDraws,
|
||||
@ -125,6 +126,29 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
||||
expect(createInit?.method).toBe("POST");
|
||||
});
|
||||
|
||||
test("coin ledger API sends type filter", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
|
||||
),
|
||||
);
|
||||
|
||||
await listCoinLedger({
|
||||
biz_type: "game_debit",
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
user_keyword: "168359",
|
||||
});
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
|
||||
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-ledger?");
|
||||
expect(String(listUrl)).toContain("user_keyword=168359");
|
||||
expect(String(listUrl)).toContain("biz_type=game_debit");
|
||||
expect(listInit?.method).toBe("GET");
|
||||
});
|
||||
|
||||
test("coin seller ledger API uses generated admin path and filters", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@ -175,7 +199,9 @@ test("coin seller ledger export API sends current filters without pagination", a
|
||||
test("wheel APIs use activity paths and generated methods", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } }))),
|
||||
vi.fn(
|
||||
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
|
||||
),
|
||||
);
|
||||
|
||||
await getWheelConfig("default");
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { listCoinLedger } from "@/features/operations/api";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
@ -12,11 +8,47 @@ import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const coinLedgerBizTypeOptions = [
|
||||
{ label: "人工入账", value: "manual_credit" },
|
||||
{ label: "礼物扣费", value: "gift_debit" },
|
||||
{ label: "私聊礼物扣费", value: "direct_gift_debit" },
|
||||
{ label: "机器人礼物扣费", value: "robot_gift_debit" },
|
||||
{ label: "币商转账", value: "coin_seller_transfer" },
|
||||
{ label: "任务奖励", value: "task_reward" },
|
||||
{ label: "幸运礼物奖励", value: "lucky_gift_reward" },
|
||||
{ label: "转盘抽奖扣费", value: "wheel_draw_debit" },
|
||||
{ label: "转盘奖励", value: "wheel_reward" },
|
||||
{ label: "房间流水奖励", value: "room_turnover_reward" },
|
||||
{ label: "邀请活动奖励", value: "invite_activity_reward" },
|
||||
{ label: "开业活动奖励", value: "agency_opening_reward" },
|
||||
{ label: "工资兑换金币", value: "salary_exchange_to_coin" },
|
||||
{ label: "Google 充值", value: "google_play_recharge" },
|
||||
{ label: "MifaPay 充值", value: "mifapay" },
|
||||
{ label: "V5Pay 充值", value: "v5pay" },
|
||||
{ label: "USDT 充值", value: "usdt_trc20" },
|
||||
{ label: "游戏入账", value: "game_credit" },
|
||||
{ label: "游戏扣费", value: "game_debit" },
|
||||
{ label: "游戏退款", value: "game_refund" },
|
||||
{ label: "游戏冲正", value: "game_reverse" },
|
||||
{ label: "主播工资结算", value: "host_salary_settlement" },
|
||||
{ label: "CP 解除费用", value: "cp_breakup_fee" },
|
||||
{ label: "VIP 购买", value: "vip_purchase" },
|
||||
{ label: "VIP 发放", value: "vip_grant" },
|
||||
{ label: "资源发放", value: "resource_grant" },
|
||||
{ label: "资源回收", value: "resource_grant_revoke" },
|
||||
{ label: "资源商店购买", value: "resource_shop_purchase" },
|
||||
{ label: "团队工资结算", value: "team_salary_settlement" },
|
||||
];
|
||||
|
||||
const coinLedgerBizTypeLabels = Object.fromEntries(
|
||||
coinLedgerBizTypeOptions.map((option) => [option.value, option.label]),
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
@ -110,16 +142,18 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
||||
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||
const [bizType, setBizType] = useState("");
|
||||
const [userKeyword, setUserKeyword] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
biz_type: bizType,
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
user_keyword: fixedUserId || userKeyword,
|
||||
}),
|
||||
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
[bizType, fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取金币流水失败",
|
||||
@ -132,8 +166,12 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const hasMutableFilters = Boolean(userKeyword || timeRange.startMs || timeRange.endMs);
|
||||
const hasMutableFilters = Boolean(userKeyword || bizType || timeRange.startMs || timeRange.endMs);
|
||||
|
||||
const changeBizType = (value) => {
|
||||
setBizType(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeUserKeyword = (value) => {
|
||||
setUserKeyword(value);
|
||||
setPage(1);
|
||||
@ -143,6 +181,7 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setBizType("");
|
||||
setUserKeyword("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
@ -153,18 +192,31 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
||||
|
||||
const tableColumns = columns
|
||||
.filter((column) => !(column.key === "user" && fixedUserId))
|
||||
.map((column) =>
|
||||
column.key === "user"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: changeUserKeyword,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: changeUserKeyword,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "bizType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
emptyLabel: "全部",
|
||||
options: coinLedgerBizTypeOptions,
|
||||
placeholder: "流水类型",
|
||||
value: bizType,
|
||||
onChange: changeBizType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.ledgerTableShell}>
|
||||
@ -476,17 +528,5 @@ function metadataLabel(key) {
|
||||
}
|
||||
|
||||
function bizTypeLabel(value) {
|
||||
const labels = {
|
||||
coin_seller_transfer: "币商转账",
|
||||
game_credit: "游戏入账",
|
||||
game_debit: "游戏扣费",
|
||||
game_refund: "游戏退款",
|
||||
game_reverse: "游戏冲正",
|
||||
gift_debit: "礼物扣费",
|
||||
manual_credit: "人工入账",
|
||||
resource_grant: "资源发放",
|
||||
task_reward: "任务奖励",
|
||||
vip_purchase: "VIP 购买",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
return coinLedgerBizTypeLabels[value] || value || "-";
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ResourceDto {
|
||||
walletAssetAmount?: number;
|
||||
priceType?: string;
|
||||
coinPrice?: number;
|
||||
avatarFrameKind?: string;
|
||||
badgeForm?: string;
|
||||
badgeKind?: string;
|
||||
levelTrack?: string;
|
||||
@ -34,6 +35,7 @@ export interface ResourcePayload {
|
||||
amount: number;
|
||||
animationUrl?: string;
|
||||
assetUrl: string;
|
||||
avatarFrameKind?: string;
|
||||
badgeForm?: string;
|
||||
badgeKind?: string;
|
||||
coinPrice: number;
|
||||
|
||||
@ -72,6 +72,13 @@ export const badgeKindOptions = [
|
||||
|
||||
export const badgeKindLabels = Object.fromEntries(badgeKindOptions);
|
||||
|
||||
export const avatarFrameKindOptions = [
|
||||
["normal", "普通头像框"],
|
||||
["level", "等级头像框"],
|
||||
];
|
||||
|
||||
export const avatarFrameKindLabels = Object.fromEntries(avatarFrameKindOptions);
|
||||
|
||||
export const badgeLevelTrackOptions = [
|
||||
["wealth", "财富等级"],
|
||||
["game", "游戏等级"],
|
||||
|
||||
@ -71,6 +71,10 @@ const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
|
||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyResourceForm = (resource = {}) => ({
|
||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||
avatarFrameKind:
|
||||
resource.resourceType === "avatar_frame"
|
||||
? resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal"
|
||||
: "normal",
|
||||
badgeForm:
|
||||
resource.resourceType === "badge"
|
||||
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
|
||||
@ -82,7 +86,7 @@ const emptyResourceForm = (resource = {}) => ({
|
||||
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||
enabled: resource.status ? resource.status === "active" : true,
|
||||
levelTrack:
|
||||
resource.resourceType === "badge"
|
||||
resource.resourceType === "badge" || resource.resourceType === "avatar_frame"
|
||||
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
|
||||
: "",
|
||||
managerGrantEnabled:
|
||||
@ -1727,15 +1731,18 @@ function resetSetter(setValue, setPage) {
|
||||
function buildResourcePayload(form) {
|
||||
const resourceType = form.resourceType;
|
||||
const isCoin = resourceType === "coin";
|
||||
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
|
||||
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
|
||||
const animationUrl = form.animationUrl.trim();
|
||||
return {
|
||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||
animationUrl,
|
||||
assetUrl: animationUrl,
|
||||
avatarFrameKind: resourceType === "avatar_frame" ? form.avatarFrameKind || "normal" : undefined,
|
||||
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
||||
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
|
||||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
|
||||
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
|
||||
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
||||
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
||||
name: form.name.trim(),
|
||||
@ -1784,6 +1791,24 @@ function badgeKindFromMetadata(metadataJson) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function avatarFrameKindFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(metadataJson);
|
||||
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (avatarFrameKind === "level" || avatarFrameKind === "normal") {
|
||||
return avatarFrameKind;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function levelTrackFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
|
||||
@ -36,6 +36,8 @@ import {
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import {
|
||||
avatarFrameKindLabels,
|
||||
avatarFrameKindOptions,
|
||||
badgeKindLabels,
|
||||
badgeKindOptions,
|
||||
badgeLevelTrackLabels,
|
||||
@ -107,9 +109,9 @@ const baseColumns = [
|
||||
},
|
||||
{
|
||||
key: "badgeForm",
|
||||
label: "徽章属性",
|
||||
label: "资源属性",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (resource) => badgeFormLabel(resource),
|
||||
render: (resource) => resourceAttributeLabel(resource),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@ -1012,9 +1014,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
const resourceType = event.target.value;
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
avatarFrameKind:
|
||||
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal",
|
||||
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
|
||||
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
|
||||
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
|
||||
levelTrack:
|
||||
resourceType === "badge" ||
|
||||
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
|
||||
? previous.levelTrack || ""
|
||||
: "",
|
||||
metadataJson:
|
||||
resourceType === "profile_card"
|
||||
? previous.metadataJson
|
||||
@ -1094,6 +1102,46 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{form.resourceType === "avatar_frame" ? (
|
||||
<>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="头像框类型"
|
||||
required
|
||||
select
|
||||
value={form.avatarFrameKind || "normal"}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
avatarFrameKind: event.target.value,
|
||||
levelTrack: event.target.value === "level" ? form.levelTrack : "",
|
||||
})
|
||||
}
|
||||
>
|
||||
{avatarFrameKindOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.avatarFrameKind === "level" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="所属等级"
|
||||
required
|
||||
select
|
||||
value={form.levelTrack}
|
||||
onChange={(event) => setForm({ ...form, levelTrack: event.target.value })}
|
||||
>
|
||||
{badgeLevelTrackOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="价格">
|
||||
@ -1297,6 +1345,16 @@ function resourcePriceLabel(resource) {
|
||||
return "未配置";
|
||||
}
|
||||
|
||||
function resourceAttributeLabel(resource) {
|
||||
if (resource.resourceType === "badge") {
|
||||
return badgeFormLabel(resource);
|
||||
}
|
||||
if (resource.resourceType === "avatar_frame") {
|
||||
return avatarFrameKindLabel(resource);
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function badgeFormLabel(resource) {
|
||||
if (resource.resourceType !== "badge") {
|
||||
return "-";
|
||||
@ -1309,6 +1367,14 @@ function badgeFormLabel(resource) {
|
||||
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function avatarFrameKindLabel(resource) {
|
||||
const kind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal";
|
||||
const kindLabel = avatarFrameKindLabels[kind] || avatarFrameKindLabels.normal;
|
||||
const track = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||
const trackLabel = kind === "level" && track ? badgeLevelTrackLabels[track] : "";
|
||||
return [kindLabel, trackLabel].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function badgeFormFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
@ -1340,6 +1406,21 @@ function badgeKindFromMetadata(metadataJson) {
|
||||
}
|
||||
}
|
||||
|
||||
function avatarFrameKindFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const metadata = JSON.parse(metadataJson);
|
||||
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function levelTrackFromMetadata(metadataJson) {
|
||||
if (!metadataJson) {
|
||||
return "";
|
||||
|
||||
@ -17,6 +17,7 @@ const resourceTypes = [
|
||||
];
|
||||
const walletAssetTypes = ["COIN"];
|
||||
const resourcePriceTypes = ["coin", "free"];
|
||||
const avatarFrameKinds = ["normal", "level"];
|
||||
const badgeForms = ["strip", "tile"];
|
||||
const badgeKinds = ["normal", "level"];
|
||||
const badgeLevelTracks = ["wealth", "game", "charm"];
|
||||
@ -35,6 +36,7 @@ const optionalWalletAssetTypeSchema = z
|
||||
export const resourceCreateFormSchema = z
|
||||
.object({
|
||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
|
||||
badgeForm: z.enum(badgeForms).optional(),
|
||||
badgeKind: z.enum(badgeKinds).optional(),
|
||||
enabled: z.boolean(),
|
||||
@ -65,6 +67,13 @@ export const resourceCreateFormSchema = z
|
||||
path: ["levelTrack"],
|
||||
});
|
||||
}
|
||||
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "请选择等级头像框所属等级",
|
||||
path: ["levelTrack"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const amount = Number(value.walletAssetAmount);
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
|
||||
@ -80,6 +80,39 @@ describe("resource form schema", () => {
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("requires level track for level avatar frame resources", () => {
|
||||
const payload = parseForm(resourceFormSchema, {
|
||||
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
|
||||
avatarFrameKind: "level",
|
||||
enabled: true,
|
||||
levelTrack: "game",
|
||||
managerGrantEnabled: true,
|
||||
name: "Game Level Frame",
|
||||
previewUrl: "https://media.haiyihy.com/resource/frame.png",
|
||||
priceType: "free",
|
||||
resourceCode: "game_level_frame",
|
||||
resourceType: "avatar_frame",
|
||||
walletAssetAmount: "",
|
||||
});
|
||||
|
||||
expect(payload.avatarFrameKind).toBe("level");
|
||||
expect(payload.levelTrack).toBe("game");
|
||||
expect(() =>
|
||||
parseForm(resourceFormSchema, {
|
||||
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
|
||||
avatarFrameKind: "level",
|
||||
enabled: true,
|
||||
managerGrantEnabled: true,
|
||||
name: "Game Level Frame",
|
||||
previewUrl: "https://media.haiyihy.com/resource/frame.png",
|
||||
priceType: "free",
|
||||
resourceCode: "game_level_frame",
|
||||
resourceType: "avatar_frame",
|
||||
walletAssetAmount: "",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("requires explicit confirmation before saving mp4 alpha layout", () => {
|
||||
const basePayload = {
|
||||
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
|
||||
|
||||
@ -65,6 +65,7 @@ export const API_OPERATIONS = {
|
||||
creditCoinSellerStock: "creditCoinSellerStock",
|
||||
dashboardOverview: "dashboardOverview",
|
||||
debitCoinSellerStock: "debitCoinSellerStock",
|
||||
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||
deleteAgency: "deleteAgency",
|
||||
deleteAppVersion: "deleteAppVersion",
|
||||
deleteBanner: "deleteBanner",
|
||||
@ -668,6 +669,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "coin-seller:stock-credit",
|
||||
permissions: ["coin-seller:stock-credit"],
|
||||
},
|
||||
deleteAchievementDefinition: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteAchievementDefinition,
|
||||
path: "/v1/admin/activity/achievements/{achievement_id}",
|
||||
permission: "achievement:update",
|
||||
permissions: ["achievement:update"],
|
||||
},
|
||||
deleteAgency: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.deleteAgency,
|
||||
|
||||
27
src/shared/api/generated/schema.d.ts
vendored
27
src/shared/api/generated/schema.d.ts
vendored
@ -30,7 +30,7 @@ export interface paths {
|
||||
get?: never;
|
||||
put: operations["updateAchievementDefinition"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteAchievementDefinition"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@ -3871,7 +3871,21 @@ export interface operations {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
achievement_id: number;
|
||||
achievement_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deleteAchievementDefinition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
achievement_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
@ -5914,7 +5928,14 @@ export interface operations {
|
||||
};
|
||||
listCoinLedger: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
page?: components["parameters"]["Page"];
|
||||
page_size?: components["parameters"]["PageSize"];
|
||||
user_keyword?: string;
|
||||
biz_type?: string;
|
||||
start_at_ms?: number;
|
||||
end_at_ms?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user