From 39d7e527cb0c43295f075c8030ab2d1f9b1123ee Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 5 Jun 2026 13:58:54 +0800 Subject: [PATCH] feat: update admin platform resource and user tables --- src/features/app-users/app-users.module.css | 50 +++++++++ .../app-users/hooks/useAppUsersPage.js | 17 ++- .../app-users/pages/AppUserListPage.jsx | 106 ++++++++++++------ .../resources/hooks/useResourcePages.js | 48 ++++++++ src/features/resources/pages/GiftListPage.jsx | 85 +++++++++++++- src/features/resources/resources.module.css | 45 ++++++++ src/shared/api/types.ts | 4 + src/shared/ui/DataTable.jsx | 3 +- src/styles/shared-ui.css | 7 ++ 9 files changed, 328 insertions(+), 37 deletions(-) diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 83e330f..990e394 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -46,6 +46,56 @@ font-weight: 650; } +.selectColumn { + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + padding-right: var(--space-2) !important; + padding-left: var(--space-2) !important; +} + +.selectColumn :global(.admin-cell__head-content) { + justify-content: center; +} + +.sortHeader { + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-1); + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + font-weight: inherit; +} + +.sortHeader:hover, +.sortHeaderActive { + color: var(--text-primary); +} + +.sortHeader span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sortIcon { + flex: 0 0 auto; + color: var(--text-tertiary); + font-size: 18px; +} + +.sortHeaderActive .sortIcon { + color: var(--primary); +} + .filters { display: flex; min-width: 0; diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index a108eb8..243667c 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -21,6 +21,8 @@ export function useAppUsersPage() { const { countryOptions, loadingCountries } = useCountryOptions(); const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); + const [sortBy, setSortBy] = useState("created_at"); + const [sortDirection, setSortDirection] = useState("desc"); const [page, setPage] = useState(1); const [activeAction, setActiveAction] = useState(""); const [activeUser, setActiveUser] = useState(null); @@ -33,9 +35,11 @@ export function useAppUsersPage() { const filters = useMemo( () => ({ keyword: query, + sort_by: sortBy, + sort_direction: sortDirection, status, }), - [query, status], + [query, sortBy, sortDirection, status], ); const { data: queryData = emptyData, @@ -61,7 +65,7 @@ export function useAppUsersPage() { useEffect(() => { setSelectedUserIds([]); setPatchedUsers({}); - }, [page, query, status]); + }, [page, query, sortBy, sortDirection, status]); const changeQuery = (value) => { setQuery(value); @@ -73,6 +77,12 @@ export function useAppUsersPage() { setPage(1); }; + const changeSort = (field) => { + setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc")); + setSortBy(field); + setPage(1); + }; + const resetFilters = () => { setQuery(""); setStatus(""); @@ -255,7 +265,10 @@ export function useAppUsersPage() { setPage, setPasswordForm, setSelectedUserIds, + sortBy, + sortDirection, status, + changeSort, submitCountry, submitEdit, submitPassword, diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index 88455b9..07990a9 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -1,8 +1,11 @@ import BlockOutlined from "@mui/icons-material/BlockOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; +import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; +import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined"; import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined"; import PasswordOutlined from "@mui/icons-material/PasswordOutlined"; import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; +import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined"; import Autocomplete from "@mui/material/Autocomplete"; import Checkbox from "@mui/material/Checkbox"; import MenuItem from "@mui/material/MenuItem"; @@ -27,24 +30,6 @@ const columns = [ width: "minmax(240px, 1.5fr)", render: (user) => , }, - { key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) }, - { - key: "status", - label: "状态", - width: "minmax(92px, 0.6fr)", - render: (user) => , - }, - { - key: "time", - label: "创建 / 活跃", - width: "minmax(180px, 1fr)", - render: (user) => ( -
- {formatMillis(user.createdAtMs)} - {formatMillis(user.lastActiveAtMs)} -
- ), - }, ]; export function AppUserListPage() { @@ -68,6 +53,7 @@ export function AppUserListPage() { const tableColumns = [ { key: "select", + className: styles.selectColumn, header: ( toggleAll(event.target.checked)} /> ), - width: "52px", + resizable: false, + width: "64px", render: (user) => { const banned = isBannedUser(user); return ( @@ -110,19 +97,53 @@ export function AppUserListPage() { width: "minmax(170px, 1fr)", render: (user) => , }, - ...columns.slice(1).map((column) => - column.key === "status" - ? { - ...column, - filter: createOptionsColumnFilter({ - options: appUserStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - } - : column, - ), + { + key: "coin", + header: ( + + ), + label: "金币", + width: "minmax(90px, 0.6fr)", + render: (user) => formatNumber(user.coin), + }, + { + key: "status", + label: "状态", + width: "minmax(92px, 0.6fr)", + filter: createOptionsColumnFilter({ + options: appUserStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + render: (user) => , + }, + { + key: "time", + header: ( + + ), + label: "创建 / 活跃", + width: "minmax(180px, 1fr)", + render: (user) => ( +
+ {formatMillis(user.createdAtMs)} + {formatMillis(user.lastActiveAtMs)} +
+ ), + }, { key: "actions", label: "操作", @@ -335,6 +356,27 @@ function UserStatus({ status }) { ); } +function SortHeader({ field, label, onClick, sortBy, sortDirection }) { + const active = sortBy === field; + const nextDirection = active && sortDirection === "desc" ? "asc" : "desc"; + const Icon = !active + ? UnfoldMoreOutlined + : sortDirection === "asc" + ? KeyboardArrowUpOutlined + : KeyboardArrowDownOutlined; + return ( + + ); +} + function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) { return ( { + if (!abilities.canUpdateGift || !gift?.giftId) { + return false; + } + const sortOrder = Number(rawSortOrder); + if (!Number.isInteger(sortOrder) || sortOrder < 0) { + showToast("排序必须是大于等于 0 的整数", "error"); + return false; + } + if (sortOrder === Number(gift.sortOrder || 0)) { + return true; + } + setLoadingAction(`gift-sort-${gift.giftId}`); + try { + await updateGift(gift.giftId, buildGiftInlineSortPayload(gift, sortOrder)); + showToast("排序已保存", "success"); + await result.reload(); + return true; + } catch (err) { + showToast(err.message || "保存排序失败", "error"); + return false; + } finally { + setLoadingAction(""); + } + }; + const resetFilters = () => { setQuery(""); setStatus(""); @@ -978,6 +1004,7 @@ export function useGiftListPage() { resetFilters, selectGiftResource, setForm, + saveGiftSortOrder, submitGiftTypes, status, submitGift, @@ -1427,6 +1454,27 @@ function buildGiftPayload(form) { }; } +function buildGiftInlineSortPayload(gift, sortOrder) { + return { + chargeAssetType: gift.chargeAssetType || "COIN", + coinPrice: Number(gift.coinPrice || 0), + effectiveAtMs: Date.now(), + effectiveFromMs: Number(gift.effectiveFromMs || 0), + effectiveToMs: Number(gift.effectiveToMs || 0), + effectTypes: Array.isArray(gift.effectTypes) ? gift.effectTypes : [], + giftId: String(gift.giftId || "").trim(), + giftPointAmount: Number(gift.giftPointAmount ?? gift.coinPrice ?? 0), + giftTypeCode: gift.giftTypeCode || "normal", + name: String(gift.name || gift.resource?.name || gift.giftId || "").trim(), + presentationJson: String(gift.presentationJson || "{}").trim() || "{}", + priceVersion: String(gift.priceVersion || "default").trim(), + regionIds: Array.isArray(gift.regionIds) && gift.regionIds.length ? gift.regionIds.map(Number) : [0], + resourceId: Number(gift.resourceId || 0), + sortOrder, + status: gift.status || "active", + }; +} + function buildGiftTypePayload(form) { return { displayName: form.displayName.trim(), diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index cfabe8b..ec70bea 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -11,7 +11,7 @@ import MenuItem from "@mui/material/MenuItem"; import Popover from "@mui/material/Popover"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AdminFormDialog, AdminFormFieldGrid, @@ -81,6 +81,11 @@ const baseColumns = (giftTypeOptions) => [ label: "区域", width: "minmax(160px, 0.8fr)", }, + { + key: "sortOrder", + label: "排序", + width: "minmax(96px, 0.45fr)", + }, { key: "effects", label: "有效期 / 特效", @@ -143,6 +148,12 @@ export function GiftListPage() { render: (gift) => , }; } + if (column.key === "sortOrder") { + return { + ...column, + render: (gift) => , + }; + } if (column.key === "gift") { return { ...column, @@ -185,7 +196,7 @@ export function GiftListPage() { 0 ? { @@ -251,6 +262,76 @@ function GiftStatusSwitch({ gift, page }) { ); } +function GiftSortOrderCell({ gift, page }) { + const originalValue = String(gift.sortOrder ?? 0); + const saving = page.loadingAction === `gift-sort-${gift.giftId}`; + const editable = page.abilities.canUpdateGift && !saving; + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(originalValue); + + useEffect(() => { + if (!editing) { + setValue(originalValue); + } + }, [editing, originalValue]); + + const save = async () => { + const nextValue = String(value || "0").trim() || "0"; + const saved = await page.saveGiftSortOrder(gift, nextValue); + if (saved) { + setEditing(false); + setValue(nextValue); + return; + } + setValue(originalValue); + setEditing(false); + }; + + if (!editing) { + return ( + + ); + } + + return ( + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setValue(originalValue); + setEditing(false); + } + }} + /> + ); +} + function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) { const submitDisabled = disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading; diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css index 735b38a..2d17e5a 100644 --- a/src/features/resources/resources.module.css +++ b/src/features/resources/resources.module.css @@ -76,6 +76,51 @@ min-height: var(--control-height); } +.inlineSortButton { + display: inline-flex; + width: 100%; + max-width: 76px; + height: 30px; + align-items: center; + justify-content: flex-start; + padding: 0 var(--space-2); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-weight: 650; + text-align: left; +} + +.inlineSortButton:hover { + border-color: var(--primary-border); + background: var(--primary-surface); + color: var(--primary); +} + +.inlineSortButton:disabled { + cursor: default; + color: var(--text-tertiary); +} + +.inlineSortButton:disabled:hover { + border-color: transparent; + background: transparent; + color: var(--text-tertiary); +} + +.inlineSortInput { + width: 76px; +} + +.inlineSortInput :global(.MuiInputBase-input) { + padding-right: var(--space-2); + padding-left: var(--space-2); + text-align: left; +} + .datePopover { display: grid; width: 320px; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 7cba106..387a8f0 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -29,6 +29,10 @@ export interface PageQuery { region_id?: EntityId; regionId?: EntityId; role?: string; + sort_by?: string; + sort_direction?: string; + sortBy?: string; + sortDirection?: string; status?: string; } diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index 5e11139..0797614 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -419,8 +419,9 @@ function displayValue(value) { function HeaderLabel({ column, onOpenFilter, trailingAction }) { const content = column.header || column.label; + const hasCustomHeader = column.header !== undefined && column.header !== null; const label = !column.filter ? ( - {content} + {content} ) : ( ); diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index 97ba5f1..47288f0 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1229,6 +1229,13 @@ white-space: nowrap; } +.admin-cell__head-custom { + display: inline-flex; + min-width: 0; + align-items: center; + overflow: visible; +} + .admin-cell__head-filter { color: var(--primary); font-weight: 700;