diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index cfc7d3e..74d98b9 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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"] } diff --git a/src/features/achievements/achievements.module.css b/src/features/achievements/achievements.module.css index b51fe5e..11e405c 100644 --- a/src/features/achievements/achievements.module.css +++ b/src/features/achievements/achievements.module.css @@ -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; + } +} diff --git a/src/features/achievements/api.ts b/src/features/achievements/api.ts index 9cd5ff9..5e1de06 100644 --- a/src/features/achievements/api.ts +++ b/src/features/achievements/api.ts @@ -96,6 +96,16 @@ export function updateAchievementDefinition( ).then(normalizeAchievement); } +export function deleteAchievementDefinition(achievementId: EntityId): Promise { + const endpoint = API_ENDPOINTS.deleteAchievementDefinition; + return apiRequest( + apiEndpointPath(API_OPERATIONS.deleteAchievementDefinition, { achievement_id: achievementId }), + { + method: endpoint.method, + }, + ).then(normalizeAchievement); +} + function normalizeAchievementPage(page: ApiPage): ApiPage { return { ...page, diff --git a/src/features/achievements/constants.js b/src/features/achievements/constants.js index d0e4738..38541ba 100644 --- a/src/features/achievements/constants.js +++ b/src/features/achievements/constants.js @@ -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); diff --git a/src/features/achievements/hooks/useAchievementsPage.js b/src/features/achievements/hooks/useAchievementsPage.js index 35c378c..8f02274 100644 --- a/src/features/achievements/hooks/useAchievementsPage.js +++ b/src/features/achievements/hooks/useAchievementsPage.js @@ -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); -} diff --git a/src/features/achievements/pages/AchievementConfigPage.jsx b/src/features/achievements/pages/AchievementConfigPage.jsx index fc0175a..06aab97 100644 --- a/src/features/achievements/pages/AchievementConfigPage.jsx +++ b/src/features/achievements/pages/AchievementConfigPage.jsx @@ -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) => , - }, - { - key: "condition", - label: "达成条件", - width: "minmax(240px, 1.2fr)", - render: (achievement) => , - }, { 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) => {achievement.title || achievement.achievementId}, }, { - key: "display", - label: "展示策略", - width: "minmax(150px, 0.7fr)", + key: "achievementType", + label: "成就类型", + width: "minmax(140px, 0.55fr)", render: (achievement) => ( -
- {autoPinPolicyLabel(achievement.autoPinPolicy)} - {achievement.honorVisibility ? "荣誉墙可见" : "荣誉墙隐藏"} -
+ {achievementTypeLabel(achievement.achievementType)} ), }, { - key: "effective", - label: "有效期", - width: "minmax(220px, 1.05fr)", - render: (achievement) => ( -
- {achievement.effectiveFromMs ? formatMillis(achievement.effectiveFromMs) : "立即生效"} - - {achievement.effectiveToMs ? formatMillis(achievement.effectiveToMs) : "长期有效"} - -
- ), - }, - { - key: "version", - label: "版本 / 排序", - width: "minmax(120px, 0.6fr)", - render: (achievement) => ( -
- v{achievement.version || 1} - {achievement.sortOrder || 0} -
- ), - }, - { - 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 || 未填写, }, { key: "actions", label: "操作", - width: "minmax(76px, 0.35fr)", + width: "112px", }, ]; @@ -128,24 +69,10 @@ export function AchievementConfigPage() { return { ...column, render: (achievement) => ( - + ), }; } - if (column.key === "reward") { - return { - ...column, - render: (achievement) => ( - - ), - }; - } - if (column.key === "status") { - return { - ...column, - render: (achievement) => , - }; - } if (column.key === "actions") { return { ...column, @@ -161,20 +88,12 @@ export function AchievementConfigPage() { filters={ <> - - + } actions={ @@ -190,7 +109,7 @@ export function AchievementConfigPage() { 0 ? { @@ -219,56 +138,6 @@ export function AchievementConfigPage() { ); } -function AchievementIdentity({ achievement }) { - return ( -
- {achievementTypeLabel(achievement.achievementType)} -
- {achievement.title || achievement.achievementId} - {achievement.achievementId} - - {collectionTypeLabel(achievement.collectionType)} · {achievement.collectionId || "ordinary"} - -
-
- ); -} - -function ConditionSummary({ conditions = [] }) { - const first = conditions[0] || {}; - if (!first.metricType) { - return 未配置; - } - return ( -
- {first.metricType} - - {formatNumber(first.targetValue)} {targetUnitLabel(first.targetUnit)} - {conditions.length > 1 ? ` · 共 ${conditions.length} 条` : ""} - -
- ); -} - -function AchievementStatusSwitch({ achievement, page }) { - const checked = achievement.status === "active"; - const disabled = - !page.abilities.canUpdate || - achievement.status === "archived" || - page.loadingAction === `status-${achievement.achievementId}`; - return ( -
- page.toggleAchievement(achievement, event.target.checked)} - /> - {achievementStatusLabel(achievement.status)} -
- ); -} - function AchievementRowActions({ achievement, page }) { return ( @@ -279,6 +148,13 @@ function AchievementRowActions({ achievement, page }) { > + page.deleteAchievement(achievement)} + > + + ); } @@ -297,20 +173,13 @@ function AchievementFormDialog({ disabled, form, loading, mode, onClose, onSubmi > - - {collectionTypeOptions.map(([value, label]) => ( - - {label} - - ))} - + label="选择徽章资源" + resources={page.badgeResources} + value={form.primaryBadgeResourceId} + onChange={(value) => page.setForm((current) => ({ ...current, primaryBadgeResourceId: value }))} + /> ))} - - - {achievementStatusOptions.map(([value, label]) => ( - - {label} - - ))} - - - - - } - title="达成条件" - > - - {form.conditions.map((condition, index) => ( - - page.updateCondition(index, "metricType", event.target.value)} - /> - page.updateCondition(index, "targetValue", event.target.value)} - /> - page.updateCondition(index, "targetUnit", event.target.value)} - > - {targetUnitOptions.map(([value, label]) => ( - - {label} - - ))} - - - page.updateCondition(index, "dimensionFilterJson", event.target.value) - } - /> - page.removeCondition(index)} - > - - - - ))} - - - - - - page.setForm((current) => ({ ...current, rewardResourceGroupId: value }))} - /> - - {autoPinPolicyOptions.map(([value, label]) => ( - - {label} - - ))} - - page.setForm((current) => ({ ...current, honorVisibility: checked }))} - /> - - - - - - - - - - ); } -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 ( - - 不配置 - {options.map((resource) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - + + {imageUrl ? : } + ); } -function ResourceLabel({ id, resources }) { - if (!id) { - return 未配置; - } - const resource = resources.find((item) => Number(item.resourceId) === Number(id)); - return ( -
- {resource?.name || resource?.resourceCode || `资源 #${id}`} - ID {id} -
- ); -} +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 未配置; - } - const group = groups.find((item) => Number(item.groupId) === Number(id)); return ( -
- {group?.name || group?.groupCode || `资源组 #${id}`} - ID {id} -
+ <> + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDrawer(); + } + }} + /> + setOpen(false)} + > +
+ {options.map((resource) => { + const resourceValue = String(resource.resourceId); + const selectedResource = resourceValue === normalizedValue; + return ( + + ); + })} + {!options.length ?
当前无数据
: null} +
+
+ ); } @@ -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 || "-"}`; } diff --git a/src/features/achievements/schema.ts b/src/features/achievements/schema.ts index 8112753..cbe87c4 100644 --- a/src/features/achievements/schema.ts +++ b/src/features/achievements/schema.ts @@ -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; @@ -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; -} diff --git a/src/features/level-config/api.ts b/src/features/level-config/api.ts index 61e8e8c..4678196 100644 --- a/src/features/level-config/api.ts +++ b/src/features/level-config/api.ts @@ -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, diff --git a/src/features/level-config/hooks/useLevelConfigPage.js b/src/features/level-config/hooks/useLevelConfigPage.js index dd87e9b..1b5042b 100644 --- a/src/features/level-config/hooks/useLevelConfigPage.js +++ b/src/features/level-config/hooks/useLevelConfigPage.js @@ -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, }; diff --git a/src/features/level-config/hooks/useLevelConfigPage.test.jsx b/src/features/level-config/hooks/useLevelConfigPage.test.jsx new file mode 100644 index 0000000..b5f63a1 --- /dev/null +++ b/src/features/level-config/hooks/useLevelConfigPage.test.jsx @@ -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]); +}); diff --git a/src/features/level-config/pages/LevelConfigPage.jsx b/src/features/level-config/pages/LevelConfigPage.jsx index d89eb92..e5a15d8 100644 --- a/src/features/level-config/pages/LevelConfigPage.jsx +++ b/src/features/level-config/pages/LevelConfigPage.jsx @@ -222,6 +222,18 @@ function buildRuleColumns(page) { width: "minmax(200px, 1fr)", render: (rule) => , }, + { + key: "avatarFrame", + label: "等级头像框", + width: "minmax(200px, 1fr)", + render: (rule) => , + }, + { + key: "shortBadge", + label: "短徽章素材", + width: "minmax(200px, 1fr)", + render: (rule) => , + }, { 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]) => ( @@ -454,6 +474,20 @@ function RuleFormDialog({ page }) { value={form.longBadgeResourceId} onChange={changeField(page.setRuleForm, "longBadgeResourceId")} /> + + + page.setTierForm((current) => ({ + ...current, + displayAvatarFrameResourceId: "", + track: event.target.value, + })) + } > {levelTrackOptions.map(([value, label]) => ( @@ -547,7 +587,7 @@ function TierFormDialog({ page }) { @@ -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 ""; diff --git a/src/features/level-config/schema.ts b/src/features/level-config/schema.ts index c567ad8..c2a249d 100644 --- a/src/features/level-config/schema.ts +++ b/src/features/level-config/schema.ts @@ -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, "排序必须是整数"); }); diff --git a/src/features/operations/api.test.ts b/src/features/operations/api.test.ts index a9e629c..e10cfde 100644 --- a/src/features/operations/api.test.ts +++ b/src/features/operations/api.test.ts @@ -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"); diff --git a/src/features/operations/components/CoinLedgerTable.jsx b/src/features/operations/components/CoinLedgerTable.jsx index a270008..791bf76 100644 --- a/src/features/operations/components/CoinLedgerTable.jsx +++ b/src/features/operations/components/CoinLedgerTable.jsx @@ -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 (
@@ -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 || "-"; } diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 8a8b1b2..ea745f7 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -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; diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index c448119..db2151b 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -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", "游戏等级"], diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 5ca1320..66b3ed8 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -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 ""; diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index 4ef3833..2956552 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -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" ? ( + <> + + setForm({ + ...form, + avatarFrameKind: event.target.value, + levelTrack: event.target.value === "level" ? form.levelTrack : "", + }) + } + > + {avatarFrameKindOptions.map(([value, label]) => ( + + {label} + + ))} + + {form.avatarFrameKind === "level" ? ( + setForm({ ...form, levelTrack: event.target.value })} + > + {badgeLevelTrackOptions.map(([value, label]) => ( + + {label} + + ))} + + ) : null} + + ) : null} @@ -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 ""; diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index a5da48b..b1ec2b1 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -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) { diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index a6d2193..94e8a9d 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -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", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index f501a52..a4d7826 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 1ed50bf..30c7950 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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;