成就,等级配置

This commit is contained in:
zhx 2026-06-26 16:10:59 +08:00
parent 0112303b88
commit 8c9f5d35b2
22 changed files with 842 additions and 624 deletions

View File

@ -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"]
} }

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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);
}

View File

@ -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");
} }

View File

@ -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;
}

View File

@ -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,

View File

@ -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,
}; };

View 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]);
});

View File

@ -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 "";

View File

@ -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, "排序必须是整数");
}); });

View File

@ -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");

View File

@ -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 || "-";
} }

View File

@ -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;

View File

@ -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", "游戏等级"],

View File

@ -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 "";

View File

@ -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 "";

View File

@ -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) {

View File

@ -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",

View File

@ -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,

View File

@ -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;