成就,等级配置

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",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
"x-permission": "achievement:update",
"x-permissions": ["achievement:update"]
},
"delete": {
"operationId": "deleteAchievementDefinition",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "achievement_id",
"required": true,
"schema": {
"type": "string"
}
}
],
@ -2514,6 +2534,44 @@
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"$ref": "#/components/parameters/Page"
},
{
"$ref": "#/components/parameters/PageSize"
},
{
"name": "user_keyword",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "biz_type",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "start_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "end_at_ms",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"x-permission": "coin-ledger:view",
"x-permissions": ["coin-ledger:view"]
}

View File

@ -49,3 +49,121 @@
gap: 8px;
min-width: 0;
}
.badgeCover {
display: inline-flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.badgeCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.resourceField :global(.MuiInputBase-input:not(:disabled)) {
cursor: pointer;
}
.resourceDrawer.resourceDrawer {
width: min(640px, 100vw);
height: 100%;
}
.resourceDrawerBody {
flex: 1;
min-height: 0;
overflow: hidden;
}
.resourceGrid {
display: grid;
min-height: 0;
align-content: start;
align-items: start;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-2);
overflow-y: auto;
padding-right: var(--space-1);
}
.resourceCard {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
min-width: 0;
min-height: 78px;
align-items: center;
gap: var(--space-1) var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.resourceCard:hover {
border-color: var(--primary-border);
background: var(--primary-surface);
}
.resourceCard:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.resourceCardSelected,
.resourceCardSelected:hover {
border-color: var(--primary);
background: var(--primary-surface-strong);
box-shadow: inset 0 0 0 1px var(--primary-border);
}
.resourceCard .badgeCover {
grid-row: 1 / 3;
}
.resourceName {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
min-height: 180px;
grid-column: 1 / -1;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-weight: 650;
}
@media (max-width: 640px) {
.resourceGrid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.resourceCard {
transition: none;
}
}

View File

@ -96,6 +96,16 @@ export function updateAchievementDefinition(
).then(normalizeAchievement);
}
export function deleteAchievementDefinition(achievementId: EntityId): Promise<AchievementDefinitionDto> {
const endpoint = API_ENDPOINTS.deleteAchievementDefinition;
return apiRequest<unknown>(
apiEndpointPath(API_OPERATIONS.deleteAchievementDefinition, { achievement_id: achievementId }),
{
method: endpoint.method,
},
).then(normalizeAchievement);
}
function normalizeAchievementPage(page: ApiPage<unknown>): ApiPage<AchievementDefinitionDto> {
return {
...page,

View File

@ -8,8 +8,8 @@ export const achievementStatusOptions = [
export const achievementStatusLabels = Object.fromEntries(achievementStatusOptions);
export const collectionTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "活动成就"],
["ordinary", "普通荣耀成就"],
["event", "活动成就"],
];
export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionTypeOptions];
@ -17,8 +17,8 @@ export const collectionTypeFilterOptions = [["", "全部合集"], ...collectionT
export const collectionTypeLabels = Object.fromEntries(collectionTypeOptions);
export const achievementTypeOptions = [
["ordinary", "普通成就"],
["activity_limited", "限时活动"],
["ordinary", "普通荣耀成就"],
["activity_limited", "活动成就"],
];
export const achievementTypeLabels = Object.fromEntries(achievementTypeOptions);

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { listResourceGroups, listResources } from "@/features/resources/api";
import { listResources } from "@/features/resources/api";
import {
createAchievementDefinition,
deleteAchievementDefinition,
listAchievementDefinitions,
updateAchievementDefinition,
} from "@/features/achievements/api";
@ -14,35 +16,16 @@ import { achievementFormSchema } from "@/features/achievements/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const resourcePageQuery = { page: 1, page_size: 500, resource_type: "badge", status: "active" };
const groupPageQuery = { page: 1, page_size: 500, status: "active" };
const emptyCondition = (condition = {}) => ({
dimensionFilterJson: condition.dimensionFilterJson || "{}",
metricType: condition.metricType || "",
targetUnit: condition.targetUnit || "count",
targetValue: condition.targetValue === 0 || condition.targetValue ? String(condition.targetValue) : "",
});
const emptyForm = (achievement = {}) => ({
achievementType: achievement.achievementType || "ordinary",
autoPinPolicy: achievement.autoPinPolicy || "if_empty",
collectionId: achievement.collectionId || "",
collectionType: achievement.collectionType || "ordinary",
conditions: achievement.conditions?.length ? achievement.conditions.map(emptyCondition) : [emptyCondition()],
description: achievement.description || "",
displayConfigJson: achievement.displayConfigJson || "{}",
effectiveFrom: msToDatetimeLocal(achievement.effectiveFromMs),
effectiveTo: msToDatetimeLocal(achievement.effectiveToMs),
honorVisibility: achievement.honorVisibility ?? true,
primaryBadgeResourceId: optionalId(achievement.primaryBadgeResourceId),
rewardResourceGroupId: optionalId(achievement.rewardResourceGroupId),
sortOrder: valueOrDefault(achievement.sortOrder, 0),
status: achievement.status || "draft",
title: achievement.title || "",
});
export function useAchievementsPage() {
const abilities = useAchievementAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const [collectionType, setCollectionType] = useState("");
const [collectionId, setCollectionId] = useState("");
@ -52,7 +35,6 @@ export function useAchievementsPage() {
const [form, setForm] = useState(() => emptyForm());
const [loadingAction, setLoadingAction] = useState("");
const [badgeResources, setBadgeResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const filters = useMemo(
() => ({
@ -77,12 +59,8 @@ export function useAchievementsPage() {
const loadOptions = useCallback(async () => {
try {
const [resourcePage, groupPage] = await Promise.all([
listResources(resourcePageQuery),
listResourceGroups(groupPageQuery),
]);
const resourcePage = await listResources(resourcePageQuery);
setBadgeResources(resourcePage.items || []);
setResourceGroups(groupPage.items || []);
} catch (err) {
showToast(err.message || "加载资源选项失败", "error");
}
@ -144,46 +122,25 @@ export function useAchievementsPage() {
});
};
const toggleAchievement = async (achievement, nextEnabled = achievement.status !== "active") => {
if (!abilities.canUpdate || !achievement?.achievementId || achievement.status === "archived") {
const deleteAchievement = async (achievement) => {
if (!abilities.canUpdate || !achievement?.achievementId) {
return;
}
const nextStatus = nextEnabled ? "active" : "paused";
if (achievement.status === nextStatus) {
const ok = await confirm({
confirmText: "删除",
message: `${achievement.title || achievement.achievementId} 会从成就配置列表和 App 成就列表移除,用户历史徽章展示保留。`,
title: "删除成就",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`status-${achievement.achievementId}`, nextEnabled ? "成就已启用" : "成就已暂停", async () => {
await updateAchievementDefinition(
achievement.achievementId,
buildAchievementPayload(emptyForm({ ...achievement, status: nextStatus })),
);
await runAction(`delete-${achievement.achievementId}`, "成就已删除", async () => {
await deleteAchievementDefinition(achievement.achievementId);
await reload();
});
};
const addCondition = () => {
setForm((current) => ({ ...current, conditions: [...current.conditions, emptyCondition()] }));
};
const removeCondition = (index) => {
setForm((current) => ({
...current,
conditions:
current.conditions.length > 1
? current.conditions.filter((_, itemIndex) => itemIndex !== index)
: current.conditions,
}));
};
const updateCondition = (index, key, value) => {
setForm((current) => ({
...current,
conditions: current.conditions.map((condition, itemIndex) =>
itemIndex === index ? { ...condition, [key]: value } : condition,
),
}));
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
@ -199,7 +156,6 @@ export function useAchievementsPage() {
return {
abilities,
activeAction,
addCondition,
badgeResources,
changeCollectionId,
changeCollectionType,
@ -215,39 +171,33 @@ export function useAchievementsPage() {
openEdit,
page,
reload: reloadAll,
removeCondition,
resetFilters,
resourceGroups,
selectedAchievement,
setForm,
setPage,
deleteAchievement,
submitAchievement,
toggleAchievement,
updateCondition,
};
}
function buildAchievementPayload(form) {
const achievementType = form.achievementType;
const collectionType = achievementType === "activity_limited" ? "event" : "ordinary";
return {
achievement_type: form.achievementType,
auto_pin_policy: form.autoPinPolicy,
collection_id: String(form.collectionId || "").trim(),
collection_type: form.collectionType,
conditions: form.conditions.map((condition) => ({
dimension_filter_json: normalizedJson(condition.dimensionFilterJson),
metric_type: String(condition.metricType || "").trim(),
target_unit: String(condition.targetUnit || "").trim(),
target_value: Number(condition.targetValue || 0),
})),
achievement_type: achievementType,
auto_pin_policy: "none",
collection_id: collectionType === "ordinary" ? "ordinary" : "",
collection_type: collectionType,
conditions: [],
description: String(form.description || "").trim(),
display_config_json: normalizedJson(form.displayConfigJson),
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
honor_visibility: Boolean(form.honorVisibility),
display_config_json: "{}",
effective_from_ms: 0,
effective_to_ms: 0,
honor_visibility: true,
primary_badge_resource_id: Number(form.primaryBadgeResourceId || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
reward_resource_group_id: 0,
sort_order: 0,
status: "active",
title: String(form.title || "").trim(),
};
}
@ -259,36 +209,6 @@ function resetSetter(setter, setPage) {
};
}
function msToDatetimeLocal(value) {
if (!value) {
return "";
}
const date = new Date(Number(value));
if (Number.isNaN(date.getTime())) {
return "";
}
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function datetimeLocalToMs(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return 0;
}
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function normalizedJson(value) {
const raw = String(value || "").trim();
return raw || "{}";
}
function optionalId(value) {
return value ? String(value) : "";
}
function valueOrDefault(value, fallback) {
return value === 0 || value ? String(value) : String(fallback);
}

View File

@ -1,17 +1,11 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormList,
AdminFormListRow,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { useState } from "react";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -22,99 +16,46 @@ import {
AdminListPage,
AdminListToolbar,
AdminRowActions,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import {
achievementStatusLabels,
achievementStatusOptions,
achievementTypeLabels,
achievementTypeOptions,
autoPinPolicyLabels,
autoPinPolicyOptions,
collectionTypeFilterOptions,
collectionTypeLabels,
collectionTypeOptions,
targetUnitLabels,
targetUnitOptions,
} from "@/features/achievements/constants.js";
import { useAchievementsPage } from "@/features/achievements/hooks/useAchievementsPage.js";
import styles from "@/features/achievements/achievements.module.css";
const baseColumns = [
{
key: "achievement",
label: "成就",
width: "minmax(260px, 1.4fr)",
render: (achievement) => <AchievementIdentity achievement={achievement} />,
},
{
key: "condition",
label: "达成条件",
width: "minmax(240px, 1.2fr)",
render: (achievement) => <ConditionSummary conditions={achievement.conditions} />,
},
{
key: "badge",
label: "展示徽章",
width: "minmax(190px, 0.9fr)",
label: "徽章",
width: "96px",
},
{
key: "reward",
label: "奖励资源组",
width: "minmax(190px, 0.9fr)",
key: "title",
label: "成就名称",
width: "minmax(180px, 1fr)",
render: (achievement) => <span className={styles.name}>{achievement.title || achievement.achievementId}</span>,
},
{
key: "display",
label: "展示策略",
width: "minmax(150px, 0.7fr)",
key: "achievementType",
label: "成就类型",
width: "minmax(140px, 0.55fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{autoPinPolicyLabel(achievement.autoPinPolicy)}</span>
<span className={styles.meta}>{achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"}</span>
</div>
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
),
},
{
key: "effective",
label: "有效期",
width: "minmax(220px, 1.05fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>{achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>
{achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"}
</span>
</div>
),
},
{
key: "version",
label: "版本 / 排序",
width: "minmax(120px, 0.6fr)",
render: (achievement) => (
<div className={styles.stack}>
<span>v{achievement.version || 1}</span>
<span className={styles.meta}>{achievement.sortOrder || 0}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.55fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.8fr)",
render: (achievement) => formatMillis(achievement.updatedAtMs || achievement.createdAtMs),
key: "description",
label: "成就描述",
width: "minmax(260px, 1.4fr)",
render: (achievement) => achievement.description || <span className={styles.emptyValue}>未填写</span>,
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.35fr)",
width: "112px",
},
];
@ -128,24 +69,10 @@ export function AchievementConfigPage() {
return {
...column,
render: (achievement) => (
<ResourceLabel id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
<BadgeCover id={achievement.primaryBadgeResourceId} resources={page.badgeResources} />
),
};
}
if (column.key === "reward") {
return {
...column,
render: (achievement) => (
<ResourceGroupLabel groups={page.resourceGroups} id={achievement.rewardResourceGroupId} />
),
};
}
if (column.key === "status") {
return {
...column,
render: (achievement) => <AchievementStatusSwitch achievement={achievement} page={page} />,
};
}
if (column.key === "actions") {
return {
...column,
@ -161,20 +88,12 @@ export function AchievementConfigPage() {
filters={
<>
<AdminFilterSelect
label="合集类型"
label="成就类型"
options={collectionTypeFilterOptions}
value={page.collectionType}
onChange={page.changeCollectionType}
/>
<AdminSearchBox
placeholder="搜索合集 ID"
value={page.collectionId}
onChange={page.changeCollectionId}
/>
<AdminFilterResetButton
disabled={!page.collectionType && !page.collectionId}
onClick={page.resetFilters}
/>
<AdminFilterResetButton disabled={!page.collectionType} onClick={page.resetFilters} />
</>
}
actions={
@ -190,7 +109,7 @@ export function AchievementConfigPage() {
<DataTable
columns={columns}
items={items}
minWidth="1710px"
minWidth="900px"
pagination={
total > 0
? {
@ -219,56 +138,6 @@ export function AchievementConfigPage() {
);
}
function AchievementIdentity({ achievement }) {
return (
<div className={styles.identity}>
<span className={styles.typeBadge}>{achievementTypeLabel(achievement.achievementType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{achievement.title || achievement.achievementId}</span>
<span className={styles.meta}>{achievement.achievementId}</span>
<span className={styles.meta}>
{collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"}
</span>
</div>
</div>
);
}
function ConditionSummary({ conditions = [] }) {
const first = conditions[0] || {};
if (!first.metricType) {
return <span className={styles.emptyValue}>未配置</span>;
}
return (
<div className={styles.stack}>
<span>{first.metricType}</span>
<span className={styles.meta}>
{formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)}
{conditions.length > 1 ? ` · 共 ${conditions.length}` : ""}
</span>
</div>
);
}
function AchievementStatusSwitch({ achievement, page }) {
const checked = achievement.status === "active";
const disabled =
!page.abilities.canUpdate ||
achievement.status === "archived" ||
page.loadingAction === `status-${achievement.achievementId}`;
return (
<div className={styles.statusCell}>
<AdminSwitch
checked={checked}
disabled={disabled}
inputProps={{ "aria-label": checked ? "暂停成就" : "启用成就" }}
onChange={(event) => page.toggleAchievement(achievement, event.target.checked)}
/>
<span className={styles.meta}>{achievementStatusLabel(achievement.status)}</span>
</div>
);
}
function AchievementRowActions({ achievement, page }) {
return (
<AdminRowActions>
@ -279,6 +148,13 @@ function AchievementRowActions({ achievement, page }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canUpdate || page.loadingAction === `delete-${achievement.achievementId}`}
label="删除成就"
onClick={() => page.deleteAchievement(achievement)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
@ -297,20 +173,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid>
<TextField
<BadgeResourceSelectField
disabled={disabled}
label="合集类型"
required
select
value={form.collectionType}
onChange={changeField(page.setForm, "collectionType")}
>
{collectionTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
label="选择徽章资源"
resources={page.badgeResources}
value={form.primaryBadgeResourceId}
onChange={(value) => page.setForm((current) => ({ ...current, primaryBadgeResourceId: value }))}
/>
<TextField
disabled={disabled}
label="成就类型"
@ -325,26 +194,6 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="合集 ID"
value={form.collectionId}
onChange={changeField(page.setForm, "collectionId")}
/>
<TextField
disabled={disabled}
label="状态"
required
select
value={form.status}
onChange={changeField(page.setForm, "status")}
>
{achievementStatusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="成就名称"
@ -360,189 +209,90 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection
actions={
<AdminActionIconButton
disabled={disabled}
label="添加条件"
type="button"
onClick={page.addCondition}
>
<Add fontSize="small" />
</AdminActionIconButton>
}
title="达成条件"
>
<AdminFormList>
{form.conditions.map((condition, index) => (
<AdminFormListRow
key={index}
columns="minmax(180px, 1fr) minmax(110px, 0.55fr) minmax(120px, 0.6fr) minmax(220px, 1.1fr) 44px"
>
<TextField
disabled={disabled}
label="指标类型"
required
value={condition.metricType}
onChange={(event) => page.updateCondition(index, "metricType", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="目标值"
required
type="number"
value={condition.targetValue}
onChange={(event) => page.updateCondition(index, "targetValue", event.target.value)}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={condition.targetUnit}
onChange={(event) => page.updateCondition(index, "targetUnit", event.target.value)}
>
{targetUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="维度过滤 JSON"
value={condition.dimensionFilterJson}
onChange={(event) =>
page.updateCondition(index, "dimensionFilterJson", event.target.value)
}
/>
<AdminActionIconButton
disabled={disabled || form.conditions.length <= 1}
label="删除条件"
type="button"
onClick={() => page.removeCondition(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminFormListRow>
))}
</AdminFormList>
</AdminFormSection>
<AdminFormSection title="徽章与奖励">
<AdminFormFieldGrid>
<ResourceSelect
disabled={disabled}
label="达成展示徽章"
resources={page.badgeResources}
value={form.primaryBadgeResourceId}
onChange={changeField(page.setForm, "primaryBadgeResourceId")}
/>
<ResourceGroupSelectField
allowEmpty
disabled={disabled}
groups={page.resourceGroups}
label="达成奖励资源组"
value={form.rewardResourceGroupId}
onChange={(value) => page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))}
/>
<TextField
disabled={disabled}
label="展示策略"
select
value={form.autoPinPolicy}
onChange={changeField(page.setForm, "autoPinPolicy")}
>
{autoPinPolicyOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<AdminFormSwitchField
checked={form.honorVisibility}
checkedLabel="显示"
disabled={disabled}
label="荣誉墙"
uncheckedLabel="隐藏"
onChange={(checked) => page.setForm((current) => ({ ...current, honorVisibility: checked }))}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="时间与展示">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="开始时间"
type="datetime-local"
value={form.effectiveFrom}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveFrom")}
/>
<TextField
disabled={disabled}
label="结束时间"
type="datetime-local"
value={form.effectiveTo}
InputLabelProps={{ shrink: true }}
onChange={changeField(page.setForm, "effectiveTo")}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={changeField(page.setForm, "sortOrder")}
/>
<TextField
disabled={disabled}
label="展示配置 JSON"
value={form.displayConfigJson}
onChange={changeField(page.setForm, "displayConfigJson")}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function ResourceSelect({ disabled, label, onChange, resources, value }) {
const options = withSelectedResource(resources, value);
function BadgeCover({ id, resources }) {
const resource = findResource(resources, id);
const imageUrl = resourceImageUrl(resource);
return (
<TextField select disabled={disabled} label={label} value={String(value || "")} onChange={onChange}>
<MenuItem value="">不配置</MenuItem>
{options.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
<span className={styles.badgeCover}>
{imageUrl ? <img alt="" src={imageUrl} /> : <Inventory2Outlined fontSize="small" />}
</span>
);
}
function ResourceLabel({ id, resources }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
);
}
function BadgeResourceSelectField({ disabled, label, onChange, resources, value }) {
const [open, setOpen] = useState(false);
const normalizedValue = String(value || "");
const selected = findResource(resources, normalizedValue);
const displayValue = selected ? resourceName(selected) : normalizedValue ? `资源 #${normalizedValue}` : "";
const options = withSelectedResource(resources, normalizedValue);
const openDrawer = () => {
if (!disabled) {
setOpen(true);
}
};
const selectResource = (resourceId) => {
onChange(String(resourceId || ""));
setOpen(false);
};
function ResourceGroupLabel({ groups, id }) {
if (!id) {
return <span className={styles.emptyValue}>未配置</span>;
}
const group = groups.find((item) => Number(item.groupId) === Number(id));
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{group?.name || group?.groupCode || `资源组 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
</div>
<>
<TextField
fullWidth
className={styles.resourceField}
disabled={disabled}
label={label}
required
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
className={styles.resourceDrawer}
contentClassName={styles.resourceDrawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open}
title="选择徽章资源"
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.resourceGrid}>
{options.map((resource) => {
const resourceValue = String(resource.resourceId);
const selectedResource = resourceValue === normalizedValue;
return (
<button
className={[styles.resourceCard, selectedResource ? styles.resourceCardSelected : ""]
.filter(Boolean)
.join(" ")}
key={resource.resourceId}
type="button"
onClick={() => selectResource(resource.resourceId)}
>
<BadgeCover id={resource.resourceId} resources={options} />
<span className={styles.resourceName}>{resourceName(resource)}</span>
<span className={styles.meta}>
{resource.resourceCode || `ID ${resource.resourceId}`}
</span>
</button>
);
})}
{!options.length ? <div className={styles.emptyState}>当前无数据</div> : null}
</div>
</SideDrawer>
</>
);
}
@ -561,26 +311,18 @@ function withSelectedResource(resources, value) {
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function achievementStatusLabel(status) {
return achievementStatusLabels[status] || status || "-";
}
function achievementTypeLabel(type) {
return achievementTypeLabels[type] || type || "-";
}
function autoPinPolicyLabel(policy) {
return autoPinPolicyLabels[policy] || policy || "-";
function findResource(resources, id) {
return resources.find((item) => Number(item.resourceId) === Number(id));
}
function collectionTypeLabel(type) {
return collectionTypeLabels[type] || type || "-";
function resourceImageUrl(resource) {
return resource?.previewUrl || resource?.assetUrl || resource?.animationUrl || "";
}
function targetUnitLabel(unit) {
return targetUnitLabels[unit] || unit || "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
function resourceName(resource) {
return resource?.name || resource?.resourceCode || `资源 #${resource?.resourceId || "-"}`;
}

View File

@ -2,60 +2,17 @@ import { z } from "zod";
const numberInput = z.union([z.string(), z.number()]);
const optionalNumberInput = numberInput.optional();
const statusSchema = z.enum(["draft", "active", "paused", "archived"]);
const collectionTypeSchema = z.enum(["ordinary", "activity_limited"]);
const achievementTypeSchema = z.enum(["ordinary", "activity_limited"]);
const autoPinPolicySchema = z.enum(["none", "if_empty", "always"]);
const conditionSchema = z.object({
dimensionFilterJson: z.string().optional(),
metricType: z.string().trim().min(1, "请输入指标类型").max(80, "指标类型不能超过 80 个字符"),
targetUnit: z.string().trim().min(1, "请选择目标单位").max(32, "目标单位不能超过 32 个字符"),
targetValue: numberInput,
});
export const achievementFormSchema = z
.object({
achievementType: achievementTypeSchema,
autoPinPolicy: autoPinPolicySchema,
collectionId: z.string().trim().max(80, "合集 ID 不能超过 80 个字符").optional(),
collectionType: collectionTypeSchema,
conditions: z.array(conditionSchema).min(1, "至少配置一个达成条件").max(10, "最多配置 10 个达成条件"),
description: z.string().trim().max(240, "描述不能超过 240 个字符").optional(),
displayConfigJson: z.string().optional(),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
honorVisibility: z.boolean(),
primaryBadgeResourceId: optionalNumberInput,
rewardResourceGroupId: optionalNumberInput,
sortOrder: optionalNumberInput,
status: statusSchema,
title: z.string().trim().min(1, "请输入成就名称").max(64, "成就名称不能超过 64 个字符"),
})
.superRefine((value, context) => {
validateOptionalId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "展示徽章必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "奖励资源组必须是有效资源组");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
validateJsonObject(context, "displayConfigJson", value.displayConfigJson, "展示配置 JSON 不正确");
const fromMs = datetimeToMs(value.effectiveFrom);
const toMs = datetimeToMs(value.effectiveTo);
if (fromMs > 0 && toMs > 0 && toMs <= fromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
value.conditions.forEach((condition, index) => {
validatePositiveInteger(
context,
`conditions.${index}.targetValue`,
condition.targetValue,
"目标值必须是正整数",
);
validateJsonObject(
context,
`conditions.${index}.dimensionFilterJson`,
condition.dimensionFilterJson,
"维度过滤 JSON 不正确",
);
});
validateRequiredId(context, "primaryBadgeResourceId", value.primaryBadgeResourceId, "请选择徽章资源");
});
export type AchievementForm = z.infer<typeof achievementFormSchema>;
@ -67,41 +24,11 @@ function validatePositiveInteger(context: z.RefinementCtx, path: string, value:
}
}
function validateOptionalId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
function validateRequiredId(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value ?? "").trim();
if (!raw) {
context.addIssue({ code: "custom", message, path: [path] });
return;
}
validatePositiveInteger(context, path, raw, message);
}
function validateInteger(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const numeric = Number(value);
if (!Number.isInteger(numeric)) {
context.addIssue({ code: "custom", message, path: [path] });
}
}
function validateJsonObject(context: z.RefinementCtx, path: string, value: unknown, message: string) {
const raw = String(value || "").trim();
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
} catch {
context.addIssue({ code: "custom", message, path: path.split(".") });
}
}
function datetimeToMs(value: unknown) {
const raw = String(value || "").trim();
if (!raw) {
return 0;
}
const parsed = new Date(raw).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@ -9,10 +9,12 @@ export interface LevelRuleDto {
createdAtMs?: number;
displayConfigJson?: string;
level: number;
avatarFrameResourceId?: number;
longBadgeResourceId?: number;
name?: string;
requiredValue: number;
rewardResourceGroupId?: number;
shortBadgeResourceId?: number;
sortOrder?: number;
status?: LevelConfigStatus | string;
track: LevelTrackCode;
@ -59,10 +61,12 @@ export interface LevelTrackPayload {
}
export interface LevelRulePayload {
avatar_frame_resource_id?: number;
long_badge_resource_id?: number;
name: string;
required_value: number;
reward_resource_group_id?: number;
short_badge_resource_id?: number;
sort_order: number;
status: LevelConfigStatus;
}
@ -160,6 +164,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
createdAtMs: numberValue(source.createdAtMs ?? source.created_at_ms),
displayConfigJson: jsonString(displayConfig),
level: numberValue(source.level),
avatarFrameResourceId: numberValue(
source.avatarFrameResourceId ??
source.avatar_frame_resource_id ??
displayConfigObject.avatarFrameResourceId ??
displayConfigObject.avatar_frame_resource_id,
),
longBadgeResourceId: numberValue(
source.longBadgeResourceId ??
source.long_badge_resource_id ??
@ -169,6 +179,12 @@ function normalizeRule(raw: unknown): LevelRuleDto {
name: stringValue(source.name),
requiredValue: numberValue(source.requiredValue ?? source.required_value),
rewardResourceGroupId: numberValue(source.rewardResourceGroupId ?? source.reward_resource_group_id),
shortBadgeResourceId: numberValue(
source.shortBadgeResourceId ??
source.short_badge_resource_id ??
displayConfigObject.shortBadgeResourceId ??
displayConfigObject.short_badge_resource_id,
),
sortOrder: numberValue(source.sortOrder ?? source.sort_order),
status: stringValue(source.status),
track: stringValue(source.track) as LevelTrackCode,

View File

@ -14,12 +14,16 @@ import {
const emptyConfig = { serverTimeMs: 0, tracks: [] };
const defaultTrack = "wealth";
const resourcePageQuery = { page: 1, page_size: 500, status: "active" };
const badgeResourcePageQuery = { ...resourcePageQuery, resource_type: "badge" };
const avatarFrameResourcePageQuery = { ...resourcePageQuery, resource_type: "avatar_frame" };
export function useLevelConfigPage() {
const abilities = useLevelConfigAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(emptyConfig);
const [resources, setResources] = useState([]);
const [badgeResources, setBadgeResources] = useState([]);
const [avatarFrameResources, setAvatarFrameResources] = useState([]);
const [resourceGroups, setResourceGroups] = useState([]);
const [track, setTrack] = useState(defaultTrack);
const [loading, setLoading] = useState(true);
@ -37,13 +41,19 @@ export function useLevelConfigPage() {
setLoading(true);
setError("");
try {
const [nextConfig, resourcePage, groupPage] = await Promise.all([
const [nextConfig, badgeResourcePage, avatarFrameResourcePage, groupPage] = await Promise.all([
listLevelConfig(),
listResources(resourcePageQuery),
// 等级配置只需要徽章和头像框;后端分页按类型过滤,避免 active 资源超过第一页时漏掉等级徽章。
listResources(badgeResourcePageQuery),
listResources(avatarFrameResourcePageQuery),
listResourceGroups(resourcePageQuery),
]);
const nextBadgeResources = badgeResourcePage.items || [];
const nextAvatarFrameResources = avatarFrameResourcePage.items || [];
setConfig(nextConfig);
setResources(resourcePage.items || []);
setBadgeResources(nextBadgeResources);
setAvatarFrameResources(nextAvatarFrameResources);
setResources([...nextBadgeResources, ...nextAvatarFrameResources]);
setResourceGroups(groupPage.items || []);
} catch (err) {
setError(err.message || "加载等级配置失败");
@ -60,15 +70,6 @@ export function useLevelConfigPage() {
() => (config.tracks || []).filter((item) => item.track === track),
[config.tracks, track],
);
const badgeResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "badge"),
[resources],
);
const avatarFrameResources = useMemo(
() => resources.filter((resource) => resource.resourceType === "avatar_frame"),
[resources],
);
const changeTrack = (value) => setTrack(value);
const openRuleCreate = (trackCode = resolveDefaultTrack(config, track)) => {
@ -245,11 +246,13 @@ function buildRuleForm(rule = null, levelTrack = null) {
const nextLevel = rule ? rule.level : nextRuleLevel(levelTrack);
const trackCode = rule?.track || levelTrack?.track || defaultTrack;
return {
avatarFrameResourceId: optionalId(rule?.avatarFrameResourceId),
level: valueOrEmpty(nextLevel),
longBadgeResourceId: optionalId(rule?.longBadgeResourceId),
name: rule?.name || `${levelTrackLabels[trackCode] || "等级"} ${nextLevel}`,
requiredValue: valueOrEmpty(rule?.requiredValue),
rewardResourceGroupId: optionalId(rule?.rewardResourceGroupId),
shortBadgeResourceId: optionalId(rule?.shortBadgeResourceId),
sortOrder: valueOrDefault(rule?.sortOrder, nextLevel),
status: rule?.status || "active",
track: trackCode,
@ -284,10 +287,12 @@ function buildTrackForm(levelTrack = null) {
function buildRulePayload(form) {
return {
avatar_frame_resource_id: Number(form.avatarFrameResourceId || 0),
long_badge_resource_id: Number(form.longBadgeResourceId || 0),
name: String(form.name || "").trim(),
required_value: Number(form.requiredValue || 0),
reward_resource_group_id: Number(form.rewardResourceGroupId || 0),
short_badge_resource_id: Number(form.shortBadgeResourceId || 0),
sort_order: Number(form.sortOrder || 0),
status: form.status,
};

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)",
render: (rule) => <ResourceLabel id={rule.longBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "avatarFrame",
label: "等级头像框",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.avatarFrameResourceId} resources={page.avatarFrameResources} />,
},
{
key: "shortBadge",
label: "短徽章素材",
width: "minmax(200px, 1fr)",
render: (rule) => <ResourceLabel id={rule.shortBadgeResourceId} resources={page.badgeResources} />,
},
{
key: "reward",
label: "升级礼物",
@ -413,7 +425,15 @@ function RuleFormDialog({ page }) {
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setRuleForm, "track")}
onChange={(event) =>
page.setRuleForm((current) => ({
...current,
avatarFrameResourceId: "",
longBadgeResourceId: "",
shortBadgeResourceId: "",
track: event.target.value,
}))
}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -454,6 +474,20 @@ function RuleFormDialog({ page }) {
value={form.longBadgeResourceId}
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
/>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="等级头像框素材"
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
value={form.avatarFrameResourceId}
onChange={changeField(page.setRuleForm, "avatarFrameResourceId")}
/>
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="短徽章素材"
resources={levelShortBadgeResourcesForTrack(page.badgeResources, form.track)}
value={form.shortBadgeResourceId}
onChange={changeField(page.setRuleForm, "shortBadgeResourceId")}
/>
<ResourceGroupSelectField
allowEmpty
disabled={!page.abilities.canUpdate}
@ -510,7 +544,13 @@ function TierFormDialog({ page }) {
disabled={editing || !page.abilities.canUpdate}
label="等级类型"
value={form.track}
onChange={changeField(page.setTierForm, "track")}
onChange={(event) =>
page.setTierForm((current) => ({
...current,
displayAvatarFrameResourceId: "",
track: event.target.value,
}))
}
>
{levelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
@ -547,7 +587,7 @@ function TierFormDialog({ page }) {
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="头像框素材"
resources={page.avatarFrameResources}
resources={levelAvatarFrameResourcesForTrack(page.avatarFrameResources, form.track)}
value={form.displayAvatarFrameResourceId}
onChange={changeField(page.setTierForm, "displayAvatarFrameResourceId")}
/>
@ -698,11 +738,37 @@ function levelBadgeResourcesForTrack(resources, track) {
});
}
function levelShortBadgeResourcesForTrack(resources, track) {
return (resources || []).filter((resource) => {
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
return badgeForm === "tile" && badgeKind === "level" && levelTrack === track;
});
}
function levelAvatarFrameResourcesForTrack(resources, track) {
return (resources || []).filter((resource) => {
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
return resource.resourceType === "avatar_frame" && avatarFrameKind === "level" && levelTrack === track;
});
}
function resourceMetaLabel(resource, id) {
if (resource.resourceType === "avatar_frame") {
const avatarFrameKind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
if (avatarFrameKind === "level" && levelTrack) {
return `ID ${id} · 等级头像框 · ${levelTrackLabels[levelTrack] || levelTrack}`;
}
}
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) {
return `ID ${id} · 等级徽章 · ${levelTrackLabels[levelTrack] || levelTrack}`;
const badgeFormLabel = badgeForm === "tile" ? "短徽章" : "长徽章";
return `ID ${id} · ${badgeFormLabel} · ${levelTrackLabels[levelTrack] || levelTrack}`;
}
return `ID ${id}`;
}
@ -746,6 +812,21 @@ function badgeKindFromMetadata(metadataJson) {
}
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -17,11 +17,13 @@ export const levelTrackFormSchema = z
export const levelRuleFormSchema = z
.object({
avatarFrameResourceId: numberInput.optional(),
level: numberInput,
longBadgeResourceId: numberInput.optional(),
name: z.string().trim().min(1, "请输入等级展示名称").max(64, "等级展示名称不能超过 64 个字符"),
requiredValue: numberInput,
rewardResourceGroupId: numberInput.optional(),
shortBadgeResourceId: numberInput.optional(),
sortOrder: numberInput.optional(),
status: statusSchema,
track: trackSchema,
@ -29,8 +31,10 @@ export const levelRuleFormSchema = z
.superRefine((value, context) => {
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
validateOptionalId(context, "avatarFrameResourceId", value.avatarFrameResourceId, "等级头像框素材必须是有效资源");
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
validateOptionalId(context, "shortBadgeResourceId", value.shortBadgeResourceId, "短徽章素材必须是有效资源");
validateInteger(context, "sortOrder", value.sortOrder || 0, "排序必须是整数");
});

View File

@ -7,6 +7,7 @@ import {
getGiftDiamondRatios,
getWheelConfig,
getWheelDrawSummary,
listCoinLedger,
listCoinAdjustments,
listCoinSellerLedger,
listWheelDraws,
@ -125,6 +126,29 @@ test("coin adjustment APIs use generated admin paths", async () => {
expect(createInit?.method).toBe("POST");
});
test("coin ledger API sends type filter", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await listCoinLedger({
biz_type: "game_debit",
page: 1,
page_size: 20,
user_keyword: "168359",
});
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-ledger?");
expect(String(listUrl)).toContain("user_keyword=168359");
expect(String(listUrl)).toContain("biz_type=game_debit");
expect(listInit?.method).toBe("GET");
});
test("coin seller ledger API uses generated admin path and filters", async () => {
vi.stubGlobal(
"fetch",
@ -175,7 +199,9 @@ test("coin seller ledger export API sends current filters without pagination", a
test("wheel APIs use activity paths and generated methods", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } }))),
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await getWheelConfig("default");

View File

@ -1,10 +1,6 @@
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -12,11 +8,47 @@ import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/operations/operations.module.css";
const pageSize = 50;
const coinLedgerBizTypeOptions = [
{ label: "人工入账", value: "manual_credit" },
{ label: "礼物扣费", value: "gift_debit" },
{ label: "私聊礼物扣费", value: "direct_gift_debit" },
{ label: "机器人礼物扣费", value: "robot_gift_debit" },
{ label: "币商转账", value: "coin_seller_transfer" },
{ label: "任务奖励", value: "task_reward" },
{ label: "幸运礼物奖励", value: "lucky_gift_reward" },
{ label: "转盘抽奖扣费", value: "wheel_draw_debit" },
{ label: "转盘奖励", value: "wheel_reward" },
{ label: "房间流水奖励", value: "room_turnover_reward" },
{ label: "邀请活动奖励", value: "invite_activity_reward" },
{ label: "开业活动奖励", value: "agency_opening_reward" },
{ label: "工资兑换金币", value: "salary_exchange_to_coin" },
{ label: "Google 充值", value: "google_play_recharge" },
{ label: "MifaPay 充值", value: "mifapay" },
{ label: "V5Pay 充值", value: "v5pay" },
{ label: "USDT 充值", value: "usdt_trc20" },
{ label: "游戏入账", value: "game_credit" },
{ label: "游戏扣费", value: "game_debit" },
{ label: "游戏退款", value: "game_refund" },
{ label: "游戏冲正", value: "game_reverse" },
{ label: "主播工资结算", value: "host_salary_settlement" },
{ label: "CP 解除费用", value: "cp_breakup_fee" },
{ label: "VIP 购买", value: "vip_purchase" },
{ label: "VIP 发放", value: "vip_grant" },
{ label: "资源发放", value: "resource_grant" },
{ label: "资源回收", value: "resource_grant_revoke" },
{ label: "资源商店购买", value: "resource_shop_purchase" },
{ label: "团队工资结算", value: "team_salary_settlement" },
];
const coinLedgerBizTypeLabels = Object.fromEntries(
coinLedgerBizTypeOptions.map((option) => [option.value, option.label]),
);
const columns = [
{
key: "user",
@ -110,16 +142,18 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
const [page, setPage] = useState(1);
const [selectedEntry, setSelectedEntry] = useState(null);
const [bizType, setBizType] = useState("");
const [userKeyword, setUserKeyword] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
biz_type: bizType,
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
user_keyword: fixedUserId || userKeyword,
}),
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
[bizType, fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
);
const query = usePaginatedQuery({
errorMessage: "获取金币流水失败",
@ -132,8 +166,12 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const hasMutableFilters = Boolean(userKeyword || timeRange.startMs || timeRange.endMs);
const hasMutableFilters = Boolean(userKeyword || bizType || timeRange.startMs || timeRange.endMs);
const changeBizType = (value) => {
setBizType(value);
setPage(1);
};
const changeUserKeyword = (value) => {
setUserKeyword(value);
setPage(1);
@ -143,6 +181,7 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
setPage(1);
};
const resetFilters = () => {
setBizType("");
setUserKeyword("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
@ -153,18 +192,31 @@ export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
const tableColumns = columns
.filter((column) => !(column.key === "user" && fixedUserId))
.map((column) =>
column.key === "user"
? {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeUserKeyword,
}),
}
: column,
);
.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: userKeyword,
onChange: changeUserKeyword,
}),
};
}
if (column.key === "bizType") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部",
options: coinLedgerBizTypeOptions,
placeholder: "流水类型",
value: bizType,
onChange: changeBizType,
}),
};
}
return column;
});
return (
<div className={styles.ledgerTableShell}>
@ -476,17 +528,5 @@ function metadataLabel(key) {
}
function bizTypeLabel(value) {
const labels = {
coin_seller_transfer: "币商转账",
game_credit: "游戏入账",
game_debit: "游戏扣费",
game_refund: "游戏退款",
game_reverse: "游戏冲正",
gift_debit: "礼物扣费",
manual_credit: "人工入账",
resource_grant: "资源发放",
task_reward: "任务奖励",
vip_purchase: "VIP 购买",
};
return labels[value] || value || "-";
return coinLedgerBizTypeLabels[value] || value || "-";
}

View File

@ -17,6 +17,7 @@ export interface ResourceDto {
walletAssetAmount?: number;
priceType?: string;
coinPrice?: number;
avatarFrameKind?: string;
badgeForm?: string;
badgeKind?: string;
levelTrack?: string;
@ -34,6 +35,7 @@ export interface ResourcePayload {
amount: number;
animationUrl?: string;
assetUrl: string;
avatarFrameKind?: string;
badgeForm?: string;
badgeKind?: string;
coinPrice: number;

View File

@ -72,6 +72,13 @@ export const badgeKindOptions = [
export const badgeKindLabels = Object.fromEntries(badgeKindOptions);
export const avatarFrameKindOptions = [
["normal", "普通头像框"],
["level", "等级头像框"],
];
export const avatarFrameKindLabels = Object.fromEntries(avatarFrameKindOptions);
export const badgeLevelTrackOptions = [
["wealth", "财富等级"],
["game", "游戏等级"],

View File

@ -71,6 +71,10 @@ const cpPresentationLabelKeys = ["cp_relation_label", "cpRelationLabel"];
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyResourceForm = (resource = {}) => ({
animationUrl: resource.animationUrl || resource.assetUrl || "",
avatarFrameKind:
resource.resourceType === "avatar_frame"
? resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal"
: "normal",
badgeForm:
resource.resourceType === "badge"
? resource.badgeForm || badgeFormFromMetadata(resource.metadataJson) || "tile"
@ -82,7 +86,7 @@ const emptyResourceForm = (resource = {}) => ({
coinPrice: resource.coinPrice === 0 || resource.coinPrice ? String(resource.coinPrice) : "",
enabled: resource.status ? resource.status === "active" : true,
levelTrack:
resource.resourceType === "badge"
resource.resourceType === "badge" || resource.resourceType === "avatar_frame"
? resource.levelTrack || levelTrackFromMetadata(resource.metadataJson) || ""
: "",
managerGrantEnabled:
@ -1727,15 +1731,18 @@ function resetSetter(setValue, setPage) {
function buildResourcePayload(form) {
const resourceType = form.resourceType;
const isCoin = resourceType === "coin";
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
const animationUrl = form.animationUrl.trim();
return {
amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl,
assetUrl: animationUrl,
avatarFrameKind: resourceType === "avatar_frame" ? form.avatarFrameKind || "normal" : undefined,
badgeForm: resourceType === "badge" ? form.badgeForm : undefined,
badgeKind: resourceType === "badge" ? form.badgeKind || "normal" : undefined,
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: resourceType === "badge" && form.badgeKind === "level" ? form.levelTrack : undefined,
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
managerGrantEnabled: Boolean(form.managerGrantEnabled),
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
name: form.name.trim(),
@ -1784,6 +1791,24 @@ function badgeKindFromMetadata(metadataJson) {
return "";
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
if (avatarFrameKind === "level" || avatarFrameKind === "normal") {
return avatarFrameKind;
}
} catch {
return "";
}
return "";
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -36,6 +36,8 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
avatarFrameKindLabels,
avatarFrameKindOptions,
badgeKindLabels,
badgeKindOptions,
badgeLevelTrackLabels,
@ -107,9 +109,9 @@ const baseColumns = [
},
{
key: "badgeForm",
label: "徽章属性",
label: "资源属性",
width: "minmax(120px, 0.65fr)",
render: (resource) => badgeFormLabel(resource),
render: (resource) => resourceAttributeLabel(resource),
},
{
key: "status",
@ -1012,9 +1014,15 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
const resourceType = event.target.value;
setForm((previous) => ({
...previous,
avatarFrameKind:
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal",
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
levelTrack: resourceType === "badge" ? previous.levelTrack || "" : "",
levelTrack:
resourceType === "badge" ||
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
? previous.levelTrack || ""
: "",
metadataJson:
resourceType === "profile_card"
? previous.metadataJson
@ -1094,6 +1102,46 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
) : null}
</>
) : null}
{form.resourceType === "avatar_frame" ? (
<>
<TextField
disabled={disabled}
label="头像框类型"
required
select
value={form.avatarFrameKind || "normal"}
onChange={(event) =>
setForm({
...form,
avatarFrameKind: event.target.value,
levelTrack: event.target.value === "level" ? form.levelTrack : "",
})
}
>
{avatarFrameKindOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{form.avatarFrameKind === "level" ? (
<TextField
disabled={disabled}
label="所属等级"
required
select
value={form.levelTrack}
onChange={(event) => setForm({ ...form, levelTrack: event.target.value })}
>
{badgeLevelTrackOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : null}
</>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格">
@ -1297,6 +1345,16 @@ function resourcePriceLabel(resource) {
return "未配置";
}
function resourceAttributeLabel(resource) {
if (resource.resourceType === "badge") {
return badgeFormLabel(resource);
}
if (resource.resourceType === "avatar_frame") {
return avatarFrameKindLabel(resource);
}
return "-";
}
function badgeFormLabel(resource) {
if (resource.resourceType !== "badge") {
return "-";
@ -1309,6 +1367,14 @@ function badgeFormLabel(resource) {
return [formLabel, kindLabel, trackLabel].filter(Boolean).join(" · ");
}
function avatarFrameKindLabel(resource) {
const kind = resource.avatarFrameKind || avatarFrameKindFromMetadata(resource.metadataJson) || "normal";
const kindLabel = avatarFrameKindLabels[kind] || avatarFrameKindLabels.normal;
const track = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
const trackLabel = kind === "level" && track ? badgeLevelTrackLabels[track] : "";
return [kindLabel, trackLabel].filter(Boolean).join(" · ");
}
function badgeFormFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
@ -1340,6 +1406,21 @@ function badgeKindFromMetadata(metadataJson) {
}
}
function avatarFrameKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const avatarFrameKind = String(metadata?.avatar_frame_kind || "")
.trim()
.toLowerCase();
return avatarFrameKind === "level" || avatarFrameKind === "normal" ? avatarFrameKind : "";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";

View File

@ -17,6 +17,7 @@ const resourceTypes = [
];
const walletAssetTypes = ["COIN"];
const resourcePriceTypes = ["coin", "free"];
const avatarFrameKinds = ["normal", "level"];
const badgeForms = ["strip", "tile"];
const badgeKinds = ["normal", "level"];
const badgeLevelTracks = ["wealth", "game", "charm"];
@ -35,6 +36,7 @@ const optionalWalletAssetTypeSchema = z
export const resourceCreateFormSchema = z
.object({
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
badgeForm: z.enum(badgeForms).optional(),
badgeKind: z.enum(badgeKinds).optional(),
enabled: z.boolean(),
@ -65,6 +67,13 @@ export const resourceCreateFormSchema = z
path: ["levelTrack"],
});
}
if (value.resourceType === "avatar_frame" && value.avatarFrameKind === "level" && !value.levelTrack) {
context.addIssue({
code: "custom",
message: "请选择等级头像框所属等级",
path: ["levelTrack"],
});
}
} else {
const amount = Number(value.walletAssetAmount);
if (!Number.isInteger(amount) || amount <= 0) {

View File

@ -80,6 +80,39 @@ describe("resource form schema", () => {
).toThrow(FormValidationError);
});
test("requires level track for level avatar frame resources", () => {
const payload = parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
avatarFrameKind: "level",
enabled: true,
levelTrack: "game",
managerGrantEnabled: true,
name: "Game Level Frame",
previewUrl: "https://media.haiyihy.com/resource/frame.png",
priceType: "free",
resourceCode: "game_level_frame",
resourceType: "avatar_frame",
walletAssetAmount: "",
});
expect(payload.avatarFrameKind).toBe("level");
expect(payload.levelTrack).toBe("game");
expect(() =>
parseForm(resourceFormSchema, {
animationUrl: "https://media.haiyihy.com/resource/frame.pag",
avatarFrameKind: "level",
enabled: true,
managerGrantEnabled: true,
name: "Game Level Frame",
previewUrl: "https://media.haiyihy.com/resource/frame.png",
priceType: "free",
resourceCode: "game_level_frame",
resourceType: "avatar_frame",
walletAssetAmount: "",
}),
).toThrow(FormValidationError);
});
test("requires explicit confirmation before saving mp4 alpha layout", () => {
const basePayload = {
animationUrl: "https://media.haiyihy.com/resource/gift.mp4",

View File

@ -65,6 +65,7 @@ export const API_OPERATIONS = {
creditCoinSellerStock: "creditCoinSellerStock",
dashboardOverview: "dashboardOverview",
debitCoinSellerStock: "debitCoinSellerStock",
deleteAchievementDefinition: "deleteAchievementDefinition",
deleteAgency: "deleteAgency",
deleteAppVersion: "deleteAppVersion",
deleteBanner: "deleteBanner",
@ -668,6 +669,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-seller:stock-credit",
permissions: ["coin-seller:stock-credit"],
},
deleteAchievementDefinition: {
method: "DELETE",
operationId: API_OPERATIONS.deleteAchievementDefinition,
path: "/v1/admin/activity/achievements/{achievement_id}",
permission: "achievement:update",
permissions: ["achievement:update"],
},
deleteAgency: {
method: "POST",
operationId: API_OPERATIONS.deleteAgency,

View File

@ -30,7 +30,7 @@ export interface paths {
get?: never;
put: operations["updateAchievementDefinition"];
post?: never;
delete?: never;
delete: operations["deleteAchievementDefinition"];
options?: never;
head?: never;
patch?: never;
@ -3871,7 +3871,21 @@ export interface operations {
query?: never;
header?: never;
path: {
achievement_id: number;
achievement_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
deleteAchievementDefinition: {
parameters: {
query?: never;
header?: never;
path: {
achievement_id: string;
};
cookie?: never;
};
@ -5914,7 +5928,14 @@ export interface operations {
};
listCoinLedger: {
parameters: {
query?: never;
query?: {
page?: components["parameters"]["Page"];
page_size?: components["parameters"]["PageSize"];
user_keyword?: string;
biz_type?: string;
start_at_ms?: number;
end_at_ms?: number;
};
header?: never;
path?: never;
cookie?: never;