成就,等级配置
This commit is contained in:
parent
0112303b88
commit
8c9f5d35b2
@ -46,7 +46,27 @@
|
|||||||
"name": "achievement_id",
|
"name": "achievement_id",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"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"
|
"$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-permission": "coin-ledger:view",
|
||||||
"x-permissions": ["coin-ledger:view"]
|
"x-permissions": ["coin-ledger:view"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,3 +49,121 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
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);
|
).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> {
|
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
|
|||||||
@ -8,8 +8,8 @@ export const achievementStatusOptions = [
|
|||||||
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
|
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
|
||||||
|
|
||||||
export const collectionTypeOptions = [
|
export const collectionTypeOptions = [
|
||||||
["ordinary", "普通成就"],
|
["ordinary", "普通荣耀成就"],
|
||||||
["activity_limited", "活动成就"],
|
["event", "活动成就"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
|
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
|
||||||
@ -17,8 +17,8 @@ export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionT
|
|||||||
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
|
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
|
||||||
|
|
||||||
export const achievementTypeOptions = [
|
export const achievementTypeOptions = [
|
||||||
["ordinary", "普通成就"],
|
["ordinary", "普通荣耀成就"],
|
||||||
["activity_limited", "限时活动"],
|
["activity_limited", "活动成就"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);
|
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { listResourceGroups, listResources } from "@/features/resources/api";
|
import { listResources } from "@/features/resources/api";
|
||||||
import {
|
import {
|
||||||
createAchievementDefinition,
|
createAchievementDefinition,
|
||||||
|
deleteAchievementDefinition,
|
||||||
listAchievementDefinitions,
|
listAchievementDefinitions,
|
||||||
updateAchievementDefinition,
|
updateAchievementDefinition,
|
||||||
} from "@/features/achievements/api";
|
} from "@/features/achievements/api";
|
||||||
@ -14,35 +16,16 @@ import { achievementFormSchema } from "@/features/achievements/schema";
|
|||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
|
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 = {}) => ({
|
const emptyForm = (achievement = {}) => ({
|
||||||
achievementType: achievement.achievementType || "ordinary",
|
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 || "",
|
description: achievement.description || "",
|
||||||
displayConfigJson: achievement.displayConfigJson || "{}",
|
|
||||||
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
|
|
||||||
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
|
|
||||||
honorVisibility: achievement.honorVisibility ?? true,
|
|
||||||
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
|
||||||
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
|
|
||||||
sortOrder: valueOrDefault(achievement.sortOrder, 0),
|
|
||||||
status: achievement.status || "draft",
|
|
||||||
title: achievement.title || "",
|
title: achievement.title || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useAchievementsPage() {
|
export function useAchievementsPage() {
|
||||||
const abilities = useAchievementAbilities();
|
const abilities = useAchievementAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [collectionType, setCollectionType] = useState("");
|
const [collectionType, setCollectionType] = useState("");
|
||||||
const [collectionId, setCollectionId] = useState("");
|
const [collectionId, setCollectionId] = useState("");
|
||||||
@ -52,7 +35,6 @@ export function useAchievementsPage() {
|
|||||||
const [form, setForm] = useState(() => emptyForm());
|
const [form, setForm] = useState(() => emptyForm());
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [badgeResources, setBadgeResources] = useState([]);
|
const [badgeResources, setBadgeResources] = useState([]);
|
||||||
const [resourceGroups, setResourceGroups] = useState([]);
|
|
||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -77,12 +59,8 @@ export function useAchievementsPage() {
|
|||||||
|
|
||||||
const loadOptions = useCallback(async () => {
|
const loadOptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [resourcePage, groupPage] = await Promise.all([
|
const resourcePage = await listResources(resourcePageQuery);
|
||||||
listResources(resourcePageQuery),
|
|
||||||
listResourceGroups(groupPageQuery),
|
|
||||||
]);
|
|
||||||
setBadgeResources(resourcePage.items || []);
|
setBadgeResources(resourcePage.items || []);
|
||||||
setResourceGroups(groupPage.items || []);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "加载资源选项失败", "error");
|
showToast(err.message || "加载资源选项失败", "error");
|
||||||
}
|
}
|
||||||
@ -144,46 +122,25 @@ export function useAchievementsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
|
const deleteAchievement = async (achievement) => {
|
||||||
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
|
if (!abilities.canUpdate || !achievement?.achievementId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextStatus = nextEnabled ? "active" : "paused";
|
const ok = await confirm({
|
||||||
if (achievement.status === nextStatus) {
|
confirmText: "删除",
|
||||||
|
message: `${achievement.title || achievement.achievementId} 会从成就配置列表和 App 成就列表移除,用户历史徽章展示保留。`,
|
||||||
|
title: "删除成就",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
|
await runAction(`delete-${achievement.achievementId}`, "成就已删除", async () => {
|
||||||
await updateAchievementDefinition(
|
await deleteAchievementDefinition(achievement.achievementId);
|
||||||
achievement.achievementId,
|
|
||||||
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
|
|
||||||
);
|
|
||||||
await reload();
|
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) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -199,7 +156,6 @@ export function useAchievementsPage() {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
addCondition,
|
|
||||||
badgeResources,
|
badgeResources,
|
||||||
changeCollectionId,
|
changeCollectionId,
|
||||||
changeCollectionType,
|
changeCollectionType,
|
||||||
@ -215,39 +171,33 @@ export function useAchievementsPage() {
|
|||||||
openEdit,
|
openEdit,
|
||||||
page,
|
page,
|
||||||
reload: reloadAll,
|
reload: reloadAll,
|
||||||
removeCondition,
|
|
||||||
resetFilters,
|
resetFilters,
|
||||||
resourceGroups,
|
|
||||||
selectedAchievement,
|
selectedAchievement,
|
||||||
setForm,
|
setForm,
|
||||||
setPage,
|
setPage,
|
||||||
|
deleteAchievement,
|
||||||
submitAchievement,
|
submitAchievement,
|
||||||
toggleAchievement,
|
|
||||||
updateCondition,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAchievementPayload(form) {
|
function buildAchievementPayload(form) {
|
||||||
|
const achievementType = form.achievementType;
|
||||||
|
const collectionType = achievementType === "activity_limited" ? "event" : "ordinary";
|
||||||
return {
|
return {
|
||||||
achievement_type: form.achievementType,
|
achievement_type: achievementType,
|
||||||
auto_pin_policy: form.autoPinPolicy,
|
auto_pin_policy: "none",
|
||||||
collection_id: String(form.collectionId || "").trim(),
|
collection_id: collectionType === "ordinary" ? "ordinary" : "",
|
||||||
collection_type: form.collectionType,
|
collection_type: collectionType,
|
||||||
conditions: form.conditions.map((condition) => ({
|
conditions: [],
|
||||||
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
|
|
||||||
metric_type: String(condition.metricType || "").trim(),
|
|
||||||
target_unit: String(condition.targetUnit || "").trim(),
|
|
||||||
target_value: Number(condition.targetValue || 0),
|
|
||||||
})),
|
|
||||||
description: String(form.description || "").trim(),
|
description: String(form.description || "").trim(),
|
||||||
display_config_json: normalizedJson(form.displayConfigJson),
|
display_config_json: "{}",
|
||||||
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
|
effective_from_ms: 0,
|
||||||
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
|
effective_to_ms: 0,
|
||||||
honor_visibility: Boolean(form.honorVisibility),
|
honor_visibility: true,
|
||||||
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
|
||||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
reward_resource_group_id: 0,
|
||||||
sort_order: Number(form.sortOrder || 0),
|
sort_order: 0,
|
||||||
status: form.status,
|
status: "active",
|
||||||
title: String(form.title || "").trim(),
|
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) {
|
function optionalId(value) {
|
||||||
return value ? String(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 Add from "@mui/icons-material/Add";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import { useState } from "react";
|
||||||
AdminFormDialog,
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
AdminFormFieldGrid,
|
|
||||||
AdminFormList,
|
|
||||||
AdminFormListRow,
|
|
||||||
AdminFormSection,
|
|
||||||
AdminFormSwitchField,
|
|
||||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import {
|
import {
|
||||||
@ -22,99 +16,46 @@ import {
|
|||||||
AdminListPage,
|
AdminListPage,
|
||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
AdminSearchBox,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
|
||||||
import {
|
import {
|
||||||
achievementStatusLabels,
|
|
||||||
achievementStatusOptions,
|
|
||||||
achievementTypeLabels,
|
achievementTypeLabels,
|
||||||
achievementTypeOptions,
|
achievementTypeOptions,
|
||||||
autoPinPolicyLabels,
|
|
||||||
autoPinPolicyOptions,
|
|
||||||
collectionTypeFilterOptions,
|
collectionTypeFilterOptions,
|
||||||
collectionTypeLabels,
|
|
||||||
collectionTypeOptions,
|
|
||||||
targetUnitLabels,
|
|
||||||
targetUnitOptions,
|
|
||||||
} from "@/features/achievements/constants.js";
|
} from "@/features/achievements/constants.js";
|
||||||
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
|
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
|
||||||
import styles from "@/features/achievements/achievements.module.css";
|
import styles from "@/features/achievements/achievements.module.css";
|
||||||
|
|
||||||
const baseColumns = [
|
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",
|
key: "badge",
|
||||||
label: "展示徽章",
|
label: "徽章",
|
||||||
width: "minmax(190px, 0.9fr)",
|
width: "96px",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "reward",
|
key: "title",
|
||||||
label: "奖励资源组",
|
label: "成就名称",
|
||||||
width: "minmax(190px, 0.9fr)",
|
width: "minmax(180px, 1fr)",
|
||||||
|
render: (achievement) => <span className={styles.name}>{achievement.title || achievement.achievementId}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "display",
|
key: "achievementType",
|
||||||
label: "展示策略",
|
label: "成就类型",
|
||||||
width: "minmax(150px, 0.7fr)",
|
width: "minmax(140px, 0.55fr)",
|
||||||
render: (achievement) => (
|
render: (achievement) => (
|
||||||
<div className={styles.stack}>
|
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
|
||||||
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
|
|
||||||
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "effective",
|
key: "description",
|
||||||
label: "有效期",
|
label: "成就描述",
|
||||||
width: "minmax(220px, 1.05fr)",
|
width: "minmax(260px, 1.4fr)",
|
||||||
render: (achievement) => (
|
render: (achievement) => achievement.description || <span className={styles.emptyValue}>未填写</span>,
|
||||||
<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: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
width: "minmax(76px, 0.35fr)",
|
width: "112px",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -128,24 +69,10 @@ export function AchievementConfigPage() {
|
|||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
render: (achievement) => (
|
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") {
|
if (column.key === "actions") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
@ -161,20 +88,12 @@ export function AchievementConfigPage() {
|
|||||||
filters={
|
filters={
|
||||||
<>
|
<>
|
||||||
<AdminFilterSelect
|
<AdminFilterSelect
|
||||||
label="合集类型"
|
label="成就类型"
|
||||||
options={collectionTypeFilterOptions}
|
options={collectionTypeFilterOptions}
|
||||||
value={page.collectionType}
|
value={page.collectionType}
|
||||||
onChange={page.changeCollectionType}
|
onChange={page.changeCollectionType}
|
||||||
/>
|
/>
|
||||||
<AdminSearchBox
|
<AdminFilterResetButton disabled={!page.collectionType} onClick={page.resetFilters} />
|
||||||
placeholder="搜索合集 ID"
|
|
||||||
value={page.collectionId}
|
|
||||||
onChange={page.changeCollectionId}
|
|
||||||
/>
|
|
||||||
<AdminFilterResetButton
|
|
||||||
disabled={!page.collectionType && !page.collectionId}
|
|
||||||
onClick={page.resetFilters}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@ -190,7 +109,7 @@ export function AchievementConfigPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1710px"
|
minWidth="900px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
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 }) {
|
function AchievementRowActions({ achievement, page }) {
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
@ -279,6 +148,13 @@ function AchievementRowActions({ achievement, page }) {
|
|||||||
>
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canUpdate || page.loadingAction === `delete-${achievement.achievementId}`}
|
||||||
|
label="删除成就"
|
||||||
|
onClick={() => page.deleteAchievement(achievement)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
</AdminRowActions>
|
</AdminRowActions>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -297,20 +173,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="基础信息">
|
<AdminFormSection title="基础信息">
|
||||||
<AdminFormFieldGrid>
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<BadgeResourceSelectField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="合集类型"
|
label="选择徽章资源"
|
||||||
required
|
resources={page.badgeResources}
|
||||||
select
|
value={form.primaryBadgeResourceId}
|
||||||
value={form.collectionType}
|
onChange={(value) => page.setForm((current) => ({ ...current, primaryBadgeResourceId: value }))}
|
||||||
onChange={changeField(page.setForm, "collectionType")}
|
/>
|
||||||
>
|
|
||||||
{collectionTypeOptions.map(([value, label]) => (
|
|
||||||
<MenuItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="成就类型"
|
label="成就类型"
|
||||||
@ -325,26 +194,6 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="成就名称"
|
label="成就名称"
|
||||||
@ -360,189 +209,90 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
|
|||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceSelect({ disabled, label, onChange, resources, value }) {
|
function BadgeCover({ id, resources }) {
|
||||||
const options = withSelectedResource(resources, value);
|
const resource = findResource(resources, id);
|
||||||
|
const imageUrl = resourceImageUrl(resource);
|
||||||
return (
|
return (
|
||||||
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
|
<span className={styles.badgeCover}>
|
||||||
<MenuItem value="">不配置</MenuItem>
|
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
|
||||||
{options.map((resource) => (
|
</span>
|
||||||
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
|
|
||||||
{resource.name || resource.resourceCode || resource.resourceId}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceLabel({ id, resources }) {
|
function BadgeResourceSelectField({ disabled, label, onChange, resources, value }) {
|
||||||
if (!id) {
|
const [open, setOpen] = useState(false);
|
||||||
return <span className={styles.emptyValue}>未配置</span>;
|
const normalizedValue = String(value || "");
|
||||||
}
|
const selected = findResource(resources, normalizedValue);
|
||||||
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
|
const displayValue = selected ? resourceName(selected) : normalizedValue ? `资源 #${normalizedValue}` : "";
|
||||||
return (
|
const options = withSelectedResource(resources, normalizedValue);
|
||||||
<div className={styles.stack}>
|
|
||||||
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
|
const openDrawer = () => {
|
||||||
<span className={styles.meta}>ID {id}</span>
|
if (!disabled) {
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className={styles.stack}>
|
<>
|
||||||
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
|
<TextField
|
||||||
<span className={styles.meta}>ID {id}</span>
|
fullWidth
|
||||||
</div>
|
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];
|
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
|
||||||
}
|
}
|
||||||
|
|
||||||
function achievementStatusLabel(status) {
|
|
||||||
return achievementStatusLabels[status] || status || "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function achievementTypeLabel(type) {
|
function achievementTypeLabel(type) {
|
||||||
return achievementTypeLabels[type] || type || "-";
|
return achievementTypeLabels[type] || type || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoPinPolicyLabel(policy) {
|
function findResource(resources, id) {
|
||||||
return autoPinPolicyLabels[policy] || policy || "-";
|
return resources.find((item) => Number(item.resourceId) === Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectionTypeLabel(type) {
|
function resourceImageUrl(resource) {
|
||||||
return collectionTypeLabels[type] || type || "-";
|
return resource?.previewUrl || resource?.assetUrl || resource?.animationUrl || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetUnitLabel(unit) {
|
function resourceName(resource) {
|
||||||
return targetUnitLabels[unit] || unit || "";
|
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
|
||||||
return Number(value || 0).toLocaleString("zh-CN");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,60 +2,17 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const numberInput = z.union([z.string(), z.number()]);
|
const numberInput = z.union([z.string(), z.number()]);
|
||||||
const optionalNumberInput = numberInput.optional();
|
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 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
|
export const achievementFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
achievementType: achievementTypeSchema,
|
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(),
|
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,
|
primaryBadgeResourceId: optionalNumberInput,
|
||||||
rewardResourceGroupId: optionalNumberInput,
|
|
||||||
sortOrder: optionalNumberInput,
|
|
||||||
status: statusSchema,
|
|
||||||
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
|
validateRequiredId(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 不正确",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AchievementForm = z.infer<typeof achievementFormSchema>;
|
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();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
context.addIssue({ code: "custom", message, path: [path] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
validatePositiveInteger(context, path, raw, message);
|
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;
|
createdAtMs?: number;
|
||||||
displayConfigJson?: string;
|
displayConfigJson?: string;
|
||||||
level: number;
|
level: number;
|
||||||
|
avatarFrameResourceId?: number;
|
||||||
longBadgeResourceId?: number;
|
longBadgeResourceId?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
requiredValue: number;
|
requiredValue: number;
|
||||||
rewardResourceGroupId?: number;
|
rewardResourceGroupId?: number;
|
||||||
|
shortBadgeResourceId?: number;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
status?: LevelConfigStatus | string;
|
status?: LevelConfigStatus | string;
|
||||||
track: LevelTrackCode;
|
track: LevelTrackCode;
|
||||||
@ -59,10 +61,12 @@ export interface LevelTrackPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelRulePayload {
|
export interface LevelRulePayload {
|
||||||
|
avatar_frame_resource_id?: number;
|
||||||
long_badge_resource_id?: number;
|
long_badge_resource_id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
required_value: number;
|
required_value: number;
|
||||||
reward_resource_group_id?: number;
|
reward_resource_group_id?: number;
|
||||||
|
short_badge_resource_id?: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
status: LevelConfigStatus;
|
status: LevelConfigStatus;
|
||||||
}
|
}
|
||||||
@ -160,6 +164,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
|
|||||||
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||||
displayConfigJson: jsonString(displayConfig),
|
displayConfigJson: jsonString(displayConfig),
|
||||||
level: numberValue(source.level),
|
level: numberValue(source.level),
|
||||||
|
avatarFrameResourceId: numberValue(
|
||||||
|
source.avatarFrameResourceId ??
|
||||||
|
source.avatar_frame_resource_id ??
|
||||||
|
displayConfigObject.avatarFrameResourceId ??
|
||||||
|
displayConfigObject.avatar_frame_resource_id,
|
||||||
|
),
|
||||||
longBadgeResourceId: numberValue(
|
longBadgeResourceId: numberValue(
|
||||||
source.longBadgeResourceId ??
|
source.longBadgeResourceId ??
|
||||||
source.long_badge_resource_id ??
|
source.long_badge_resource_id ??
|
||||||
@ -169,6 +179,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
|
|||||||
name: stringValue(source.name),
|
name: stringValue(source.name),
|
||||||
requiredValue: numberValue(source.requiredValue ?? source.required_value),
|
requiredValue: numberValue(source.requiredValue ?? source.required_value),
|
||||||
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
|
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),
|
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
|
||||||
status: stringValue(source.status),
|
status: stringValue(source.status),
|
||||||
track: stringValue(source.track) as LevelTrackCode,
|
track: stringValue(source.track) as LevelTrackCode,
|
||||||
|
|||||||
@ -14,12 +14,16 @@ import {
|
|||||||
const emptyConfig = { serverTimeMs: 0, tracks: [] };
|
const emptyConfig = { serverTimeMs: 0, tracks: [] };
|
||||||
const defaultTrack = "wealth";
|
const defaultTrack = "wealth";
|
||||||
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
|
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() {
|
export function useLevelConfigPage() {
|
||||||
const abilities = useLevelConfigAbilities();
|
const abilities = useLevelConfigAbilities();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [config, setConfig] = useState(emptyConfig);
|
const [config, setConfig] = useState(emptyConfig);
|
||||||
const [resources, setResources] = useState([]);
|
const [resources, setResources] = useState([]);
|
||||||
|
const [badgeResources, setBadgeResources] = useState([]);
|
||||||
|
const [avatarFrameResources, setAvatarFrameResources] = useState([]);
|
||||||
const [resourceGroups, setResourceGroups] = useState([]);
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
const [track, setTrack] = useState(defaultTrack);
|
const [track, setTrack] = useState(defaultTrack);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -37,13 +41,19 @@ export function useLevelConfigPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [nextConfig, resourcePage, groupPage] = await Promise.all([
|
const [nextConfig, badgeResourcePage, avatarFrameResourcePage, groupPage] = await Promise.all([
|
||||||
listLevelConfig(),
|
listLevelConfig(),
|
||||||
listResources(resourcePageQuery),
|
// 等级配置只需要徽章和头像框;后端分页按类型过滤,避免 active 资源超过第一页时漏掉等级徽章。
|
||||||
|
listResources(badgeResourcePageQuery),
|
||||||
|
listResources(avatarFrameResourcePageQuery),
|
||||||
listResourceGroups(resourcePageQuery),
|
listResourceGroups(resourcePageQuery),
|
||||||
]);
|
]);
|
||||||
|
const nextBadgeResources = badgeResourcePage.items || [];
|
||||||
|
const nextAvatarFrameResources = avatarFrameResourcePage.items || [];
|
||||||
setConfig(nextConfig);
|
setConfig(nextConfig);
|
||||||
setResources(resourcePage.items || []);
|
setBadgeResources(nextBadgeResources);
|
||||||
|
setAvatarFrameResources(nextAvatarFrameResources);
|
||||||
|
setResources([...nextBadgeResources, ...nextAvatarFrameResources]);
|
||||||
setResourceGroups(groupPage.items || []);
|
setResourceGroups(groupPage.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || "加载等级配置失败");
|
setError(err.message || "加载等级配置失败");
|
||||||
@ -60,15 +70,6 @@ export function useLevelConfigPage() {
|
|||||||
() => (config.tracks || []).filter((item) => item.track === track),
|
() => (config.tracks || []).filter((item) => item.track === track),
|
||||||
[config.tracks, 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 changeTrack = (value) => setTrack(value);
|
||||||
|
|
||||||
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
|
||||||
@ -245,11 +246,13 @@ function buildRuleForm(rule = null, levelTrack = null) {
|
|||||||
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
|
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
|
||||||
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
|
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
|
||||||
return {
|
return {
|
||||||
|
avatarFrameResourceId: optionalId(rule?.avatarFrameResourceId),
|
||||||
level: valueOrEmpty(nextLevel),
|
level: valueOrEmpty(nextLevel),
|
||||||
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
|
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
|
||||||
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}级`,
|
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}级`,
|
||||||
requiredValue: valueOrEmpty(rule?.requiredValue),
|
requiredValue: valueOrEmpty(rule?.requiredValue),
|
||||||
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
|
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
|
||||||
|
shortBadgeResourceId: optionalId(rule?.shortBadgeResourceId),
|
||||||
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
|
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
|
||||||
status: rule?.status || "active",
|
status: rule?.status || "active",
|
||||||
track: trackCode,
|
track: trackCode,
|
||||||
@ -284,10 +287,12 @@ function buildTrackForm(levelTrack = null) {
|
|||||||
|
|
||||||
function buildRulePayload(form) {
|
function buildRulePayload(form) {
|
||||||
return {
|
return {
|
||||||
|
avatar_frame_resource_id: Number(form.avatarFrameResourceId || 0),
|
||||||
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
|
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
|
||||||
name: String(form.name || "").trim(),
|
name: String(form.name || "").trim(),
|
||||||
required_value: Number(form.requiredValue || 0),
|
required_value: Number(form.requiredValue || 0),
|
||||||
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
|
||||||
|
short_badge_resource_id: Number(form.shortBadgeResourceId || 0),
|
||||||
sort_order: Number(form.sortOrder || 0),
|
sort_order: Number(form.sortOrder || 0),
|
||||||
status: form.status,
|
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)",
|
width: "minmax(200px, 1fr)",
|
||||||
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
|
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",
|
key: "reward",
|
||||||
label: "升级礼物",
|
label: "升级礼物",
|
||||||
@ -413,7 +425,15 @@ function RuleFormDialog({ page }) {
|
|||||||
disabled={editing || !page.abilities.canUpdate}
|
disabled={editing || !page.abilities.canUpdate}
|
||||||
label="等级类型"
|
label="等级类型"
|
||||||
value={form.track}
|
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]) => (
|
{levelTrackOptions.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
@ -454,6 +474,20 @@ function RuleFormDialog({ page }) {
|
|||||||
value={form.longBadgeResourceId}
|
value={form.longBadgeResourceId}
|
||||||
onChange={changeField(page.setRuleForm, "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
|
<ResourceGroupSelectField
|
||||||
allowEmpty
|
allowEmpty
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
@ -510,7 +544,13 @@ function TierFormDialog({ page }) {
|
|||||||
disabled={editing || !page.abilities.canUpdate}
|
disabled={editing || !page.abilities.canUpdate}
|
||||||
label="等级类型"
|
label="等级类型"
|
||||||
value={form.track}
|
value={form.track}
|
||||||
onChange={changeField(page.setTierForm, "track")}
|
onChange={(event) =>
|
||||||
|
page.setTierForm((current) => ({
|
||||||
|
...current,
|
||||||
|
displayAvatarFrameResourceId: "",
|
||||||
|
track: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{levelTrackOptions.map(([value, label]) => (
|
{levelTrackOptions.map(([value, label]) => (
|
||||||
<MenuItem key={value} value={value}>
|
<MenuItem key={value} value={value}>
|
||||||
@ -547,7 +587,7 @@ function TierFormDialog({ page }) {
|
|||||||
<ResourceSelect
|
<ResourceSelect
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canUpdate}
|
||||||
label="头像框素材"
|
label="头像框素材"
|
||||||
resources={page.avatarFrameResources}
|
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
|
||||||
value={form.displayAvatarFrameResourceId}
|
value={form.displayAvatarFrameResourceId}
|
||||||
onChange={changeField(page.setTierForm, "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) {
|
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 badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
|
||||||
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
|
||||||
if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) {
|
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}`;
|
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) {
|
function levelTrackFromMetadata(metadataJson) {
|
||||||
if (!metadataJson) {
|
if (!metadataJson) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@ -17,11 +17,13 @@ export const levelTrackFormSchema = z
|
|||||||
|
|
||||||
export const levelRuleFormSchema = z
|
export const levelRuleFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
avatarFrameResourceId: numberInput.optional(),
|
||||||
level: numberInput,
|
level: numberInput,
|
||||||
longBadgeResourceId: numberInput.optional(),
|
longBadgeResourceId: numberInput.optional(),
|
||||||
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
|
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
|
||||||
requiredValue: numberInput,
|
requiredValue: numberInput,
|
||||||
rewardResourceGroupId: numberInput.optional(),
|
rewardResourceGroupId: numberInput.optional(),
|
||||||
|
shortBadgeResourceId: numberInput.optional(),
|
||||||
sortOrder: numberInput.optional(),
|
sortOrder: numberInput.optional(),
|
||||||
status: statusSchema,
|
status: statusSchema,
|
||||||
track: trackSchema,
|
track: trackSchema,
|
||||||
@ -29,8 +31,10 @@ export const levelRuleFormSchema = z
|
|||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
|
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
|
||||||
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
|
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
|
||||||
|
validateOptionalId(context, "avatarFrameResourceId", value.avatarFrameResourceId, "等级头像框素材必须是有效资源");
|
||||||
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
|
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
|
||||||
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
|
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
|
||||||
|
validateOptionalId(context, "shortBadgeResourceId", value.shortBadgeResourceId, "短徽章素材必须是有效资源");
|
||||||
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getGiftDiamondRatios,
|
getGiftDiamondRatios,
|
||||||
getWheelConfig,
|
getWheelConfig,
|
||||||
getWheelDrawSummary,
|
getWheelDrawSummary,
|
||||||
|
listCoinLedger,
|
||||||
listCoinAdjustments,
|
listCoinAdjustments,
|
||||||
listCoinSellerLedger,
|
listCoinSellerLedger,
|
||||||
listWheelDraws,
|
listWheelDraws,
|
||||||
@ -125,6 +126,29 @@ test("coin adjustment APIs use generated admin paths", async () => {
|
|||||||
expect(createInit?.method).toBe("POST");
|
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 () => {
|
test("coin seller ledger API uses generated admin path and filters", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"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 () => {
|
test("wheel APIs use activity paths and generated methods", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"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");
|
await getWheelConfig("default");
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { listCoinLedger } from "@/features/operations/api";
|
import { listCoinLedger } from "@/features/operations/api";
|
||||||
import {
|
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
AdminFilterResetButton,
|
|
||||||
AdminListBody,
|
|
||||||
AdminListToolbar,
|
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
|
||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.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 { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
import { TimeText } from "@/shared/ui/TimeText.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";
|
import styles from "@/features/operations/operations.module.css";
|
||||||
|
|
||||||
const pageSize = 50;
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: "user",
|
key: "user",
|
||||||
@ -110,16 +142,18 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
|||||||
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
|
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||||
|
const [bizType, setBizType] = useState("");
|
||||||
const [userKeyword, setUserKeyword] = useState("");
|
const [userKeyword, setUserKeyword] = useState("");
|
||||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
biz_type: bizType,
|
||||||
end_at_ms: timeRange.endMs || "",
|
end_at_ms: timeRange.endMs || "",
|
||||||
start_at_ms: timeRange.startMs || "",
|
start_at_ms: timeRange.startMs || "",
|
||||||
user_keyword: fixedUserId || userKeyword,
|
user_keyword: fixedUserId || userKeyword,
|
||||||
}),
|
}),
|
||||||
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
[bizType, fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
||||||
);
|
);
|
||||||
const query = usePaginatedQuery({
|
const query = usePaginatedQuery({
|
||||||
errorMessage: "获取金币流水失败",
|
errorMessage: "获取金币流水失败",
|
||||||
@ -132,8 +166,12 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
|||||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const total = data.total || 0;
|
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) => {
|
const changeUserKeyword = (value) => {
|
||||||
setUserKeyword(value);
|
setUserKeyword(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -143,6 +181,7 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
|
setBizType("");
|
||||||
setUserKeyword("");
|
setUserKeyword("");
|
||||||
setTimeRange({ endMs: "", startMs: "" });
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -153,18 +192,31 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
|||||||
|
|
||||||
const tableColumns = columns
|
const tableColumns = columns
|
||||||
.filter((column) => !(column.key === "user" && fixedUserId))
|
.filter((column) => !(column.key === "user" && fixedUserId))
|
||||||
.map((column) =>
|
.map((column) => {
|
||||||
column.key === "user"
|
if (column.key === "user") {
|
||||||
? {
|
return {
|
||||||
...column,
|
...column,
|
||||||
filter: createTextColumnFilter({
|
filter: createTextColumnFilter({
|
||||||
placeholder: "长 ID / 短 ID / 昵称",
|
placeholder: "长 ID / 短 ID / 昵称",
|
||||||
value: userKeyword,
|
value: userKeyword,
|
||||||
onChange: changeUserKeyword,
|
onChange: changeUserKeyword,
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
: column,
|
}
|
||||||
);
|
if (column.key === "bizType") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
emptyLabel: "全部",
|
||||||
|
options: coinLedgerBizTypeOptions,
|
||||||
|
placeholder: "流水类型",
|
||||||
|
value: bizType,
|
||||||
|
onChange: changeBizType,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.ledgerTableShell}>
|
<div className={styles.ledgerTableShell}>
|
||||||
@ -476,17 +528,5 @@ function metadataLabel(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bizTypeLabel(value) {
|
function bizTypeLabel(value) {
|
||||||
const labels = {
|
return coinLedgerBizTypeLabels[value] || value || "-";
|
||||||
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 || "-";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface ResourceDto {
|
|||||||
walletAssetAmount?: number;
|
walletAssetAmount?: number;
|
||||||
priceType?: string;
|
priceType?: string;
|
||||||
coinPrice?: number;
|
coinPrice?: number;
|
||||||
|
avatarFrameKind?: string;
|
||||||
badgeForm?: string;
|
badgeForm?: string;
|
||||||
badgeKind?: string;
|
badgeKind?: string;
|
||||||
levelTrack?: string;
|
levelTrack?: string;
|
||||||
@ -34,6 +35,7 @@ export interface ResourcePayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
animationUrl?: string;
|
animationUrl?: string;
|
||||||
assetUrl: string;
|
assetUrl: string;
|
||||||
|
avatarFrameKind?: string;
|
||||||
badgeForm?: string;
|
badgeForm?: string;
|
||||||
badgeKind?: string;
|
badgeKind?: string;
|
||||||
coinPrice: number;
|
coinPrice: number;
|
||||||
|
|||||||
@ -72,6 +72,13 @@ export const badgeKindOptions = [
|
|||||||
|
|
||||||
export const badgeKindLabels = Object.fromEntries(badgeKindOptions);
|
export const badgeKindLabels = Object.fromEntries(badgeKindOptions);
|
||||||
|
|
||||||
|
export const avatarFrameKindOptions = [
|
||||||
|
["normal", "普通头像框"],
|
||||||
|
["level", "等级头像框"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const avatarFrameKindLabels = Object.fromEntries(avatarFrameKindOptions);
|
||||||
|
|
||||||
export const badgeLevelTrackOptions = [
|
export const badgeLevelTrackOptions = [
|
||||||
["wealth", "财富等级"],
|
["wealth", "财富等级"],
|
||||||
["game", "游戏等级"],
|
["game", "游戏等级"],
|
||||||
|
|||||||
@ -71,6 +71,10 @@ const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
|
|||||||
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
||||||
const emptyResourceForm = (resource = {}) => ({
|
const emptyResourceForm = (resource = {}) => ({
|
||||||
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
animationUrl: resource.animationUrl || resource.assetUrl || "",
|
||||||
|
avatarFrameKind:
|
||||||
|
resource.resourceType === "avatar_frame"
|
||||||
|
? resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal"
|
||||||
|
: "normal",
|
||||||
badgeForm:
|
badgeForm:
|
||||||
resource.resourceType === "badge"
|
resource.resourceType === "badge"
|
||||||
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
|
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
|
||||||
@ -82,7 +86,7 @@ const emptyResourceForm = (resource = {}) => ({
|
|||||||
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
|
||||||
enabled: resource.status ? resource.status === "active" : true,
|
enabled: resource.status ? resource.status === "active" : true,
|
||||||
levelTrack:
|
levelTrack:
|
||||||
resource.resourceType === "badge"
|
resource.resourceType === "badge" || resource.resourceType === "avatar_frame"
|
||||||
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
|
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
|
||||||
: "",
|
: "",
|
||||||
managerGrantEnabled:
|
managerGrantEnabled:
|
||||||
@ -1727,15 +1731,18 @@ function resetSetter(setValue, setPage) {
|
|||||||
function buildResourcePayload(form) {
|
function buildResourcePayload(form) {
|
||||||
const resourceType = form.resourceType;
|
const resourceType = form.resourceType;
|
||||||
const isCoin = resourceType === "coin";
|
const isCoin = resourceType === "coin";
|
||||||
|
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
|
||||||
|
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
|
||||||
const animationUrl = form.animationUrl.trim();
|
const animationUrl = form.animationUrl.trim();
|
||||||
return {
|
return {
|
||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
animationUrl,
|
animationUrl,
|
||||||
assetUrl: animationUrl,
|
assetUrl: animationUrl,
|
||||||
|
avatarFrameKind: resourceType === "avatar_frame" ? form.avatarFrameKind || "normal" : undefined,
|
||||||
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
|
||||||
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
|
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
|
||||||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
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),
|
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
||||||
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@ -1784,6 +1791,24 @@ function badgeKindFromMetadata(metadataJson) {
|
|||||||
return "";
|
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) {
|
function levelTrackFromMetadata(metadataJson) {
|
||||||
if (!metadataJson) {
|
if (!metadataJson) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import {
|
|||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import {
|
import {
|
||||||
|
avatarFrameKindLabels,
|
||||||
|
avatarFrameKindOptions,
|
||||||
badgeKindLabels,
|
badgeKindLabels,
|
||||||
badgeKindOptions,
|
badgeKindOptions,
|
||||||
badgeLevelTrackLabels,
|
badgeLevelTrackLabels,
|
||||||
@ -107,9 +109,9 @@ const baseColumns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "badgeForm",
|
key: "badgeForm",
|
||||||
label: "徽章属性",
|
label: "资源属性",
|
||||||
width: "minmax(120px, 0.65fr)",
|
width: "minmax(120px, 0.65fr)",
|
||||||
render: (resource) => badgeFormLabel(resource),
|
render: (resource) => resourceAttributeLabel(resource),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@ -1012,9 +1014,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
const resourceType = event.target.value;
|
const resourceType = event.target.value;
|
||||||
setForm((previous) => ({
|
setForm((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
|
avatarFrameKind:
|
||||||
|
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal",
|
||||||
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
|
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
|
||||||
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
|
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
|
||||||
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
|
levelTrack:
|
||||||
|
resourceType === "badge" ||
|
||||||
|
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
|
||||||
|
? previous.levelTrack || ""
|
||||||
|
: "",
|
||||||
metadataJson:
|
metadataJson:
|
||||||
resourceType === "profile_card"
|
resourceType === "profile_card"
|
||||||
? previous.metadataJson
|
? previous.metadataJson
|
||||||
@ -1094,6 +1102,46 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : 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>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="价格">
|
<AdminFormSection title="价格">
|
||||||
@ -1297,6 +1345,16 @@ function resourcePriceLabel(resource) {
|
|||||||
return "未配置";
|
return "未配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceAttributeLabel(resource) {
|
||||||
|
if (resource.resourceType === "badge") {
|
||||||
|
return badgeFormLabel(resource);
|
||||||
|
}
|
||||||
|
if (resource.resourceType === "avatar_frame") {
|
||||||
|
return avatarFrameKindLabel(resource);
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
function badgeFormLabel(resource) {
|
function badgeFormLabel(resource) {
|
||||||
if (resource.resourceType !== "badge") {
|
if (resource.resourceType !== "badge") {
|
||||||
return "-";
|
return "-";
|
||||||
@ -1309,6 +1367,14 @@ function badgeFormLabel(resource) {
|
|||||||
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
|
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) {
|
function badgeFormFromMetadata(metadataJson) {
|
||||||
if (!metadataJson) {
|
if (!metadataJson) {
|
||||||
return "";
|
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) {
|
function levelTrackFromMetadata(metadataJson) {
|
||||||
if (!metadataJson) {
|
if (!metadataJson) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const resourceTypes = [
|
|||||||
];
|
];
|
||||||
const walletAssetTypes = ["COIN"];
|
const walletAssetTypes = ["COIN"];
|
||||||
const resourcePriceTypes = ["coin", "free"];
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
|
const avatarFrameKinds = ["normal", "level"];
|
||||||
const badgeForms = ["strip", "tile"];
|
const badgeForms = ["strip", "tile"];
|
||||||
const badgeKinds = ["normal", "level"];
|
const badgeKinds = ["normal", "level"];
|
||||||
const badgeLevelTracks = ["wealth", "game", "charm"];
|
const badgeLevelTracks = ["wealth", "game", "charm"];
|
||||||
@ -35,6 +36,7 @@ const optionalWalletAssetTypeSchema = z
|
|||||||
export const resourceCreateFormSchema = z
|
export const resourceCreateFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
||||||
|
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
|
||||||
badgeForm: z.enum(badgeForms).optional(),
|
badgeForm: z.enum(badgeForms).optional(),
|
||||||
badgeKind: z.enum(badgeKinds).optional(),
|
badgeKind: z.enum(badgeKinds).optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@ -65,6 +67,13 @@ export const resourceCreateFormSchema = z
|
|||||||
path: ["levelTrack"],
|
path: ["levelTrack"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "请选择等级头像框所属等级",
|
||||||
|
path: ["levelTrack"],
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const amount = Number(value.walletAssetAmount);
|
const amount = Number(value.walletAssetAmount);
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
|||||||
@ -80,6 +80,39 @@ describe("resource form schema", () => {
|
|||||||
).toThrow(FormValidationError);
|
).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", () => {
|
test("requires explicit confirmation before saving mp4 alpha layout", () => {
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
|
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export const API_OPERATIONS = {
|
|||||||
creditCoinSellerStock: "creditCoinSellerStock",
|
creditCoinSellerStock: "creditCoinSellerStock",
|
||||||
dashboardOverview: "dashboardOverview",
|
dashboardOverview: "dashboardOverview",
|
||||||
debitCoinSellerStock: "debitCoinSellerStock",
|
debitCoinSellerStock: "debitCoinSellerStock",
|
||||||
|
deleteAchievementDefinition: "deleteAchievementDefinition",
|
||||||
deleteAgency: "deleteAgency",
|
deleteAgency: "deleteAgency",
|
||||||
deleteAppVersion: "deleteAppVersion",
|
deleteAppVersion: "deleteAppVersion",
|
||||||
deleteBanner: "deleteBanner",
|
deleteBanner: "deleteBanner",
|
||||||
@ -668,6 +669,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "coin-seller:stock-credit",
|
permission: "coin-seller:stock-credit",
|
||||||
permissions: ["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: {
|
deleteAgency: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.deleteAgency,
|
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;
|
get?: never;
|
||||||
put: operations["updateAchievementDefinition"];
|
put: operations["updateAchievementDefinition"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete: operations["deleteAchievementDefinition"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
@ -3871,7 +3871,21 @@ export interface operations {
|
|||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
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;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
@ -5914,7 +5928,14 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
listCoinLedger: {
|
listCoinLedger: {
|
||||||
parameters: {
|
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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user