feat: update admin platform resource and user tables

This commit is contained in:
zhx 2026-06-05 13:58:54 +08:00
parent a33474ec5f
commit 39d7e527cb
9 changed files with 328 additions and 37 deletions

View File

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

View File

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

View File

@ -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) => <UserIdentity user={user} />,
},
{ key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) },
{
key: "status",
label: "状态",
width: "minmax(92px, 0.6fr)",
render: (user) => <UserStatus status={user.status} />,
},
{
key: "time",
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
),
},
];
export function AppUserListPage() {
@ -68,6 +53,7 @@ export function AppUserListPage() {
const tableColumns = [
{
key: "select",
className: styles.selectColumn,
header: (
<Checkbox
checked={allChecked}
@ -77,7 +63,8 @@ export function AppUserListPage() {
onChange={(event) => 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) => <UserLocation page={page} user={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: (
<SortHeader
field="coin"
label="金币"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
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) => <UserStatus status={user.status} />,
},
{
key: "time",
header: (
<SortHeader
field="created_at"
label="创建 / 活跃"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
),
},
{
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 (
<button
aria-label={`${label}${nextDirection === "asc" ? "正序" : "倒序"}排序`}
className={[styles.sortHeader, active ? styles.sortHeaderActive : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => onClick(field)}
>
<span>{label}</span>
<Icon className={styles.sortIcon} fontSize="inherit" />
</button>
);
}
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
return (
<AdminFormDialog

View File

@ -944,6 +944,32 @@ export function useGiftListPage() {
}
};
const saveGiftSortOrder = async (gift, rawSortOrder) => {
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(),

View File

@ -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) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
if (column.key === "sortOrder") {
return {
...column,
render: (gift) => <GiftSortOrderCell gift={gift} page={page} />,
};
}
if (column.key === "gift") {
return {
...column,
@ -185,7 +196,7 @@ export function GiftListPage() {
<DataTable
columns={tableColumns}
items={items}
minWidth="1480px"
minWidth="1560px"
pagination={
total > 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 (
<button
className={styles.inlineSortButton}
disabled={!editable}
title={editable ? "点击编辑排序" : undefined}
type="button"
onClick={() => {
if (editable) {
setEditing(true);
}
}}
>
{originalValue}
</button>
);
}
return (
<TextField
autoFocus
className={styles.inlineSortInput}
disabled={saving}
inputProps={{ min: 0, step: 1 }}
size="small"
type="number"
value={value}
onBlur={save}
onChange={(event) => 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;

View File

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

View File

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

View File

@ -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 ? (
<span className="admin-cell__head-label">{content}</span>
<span className={hasCustomHeader ? "admin-cell__head-custom" : "admin-cell__head-label"}>{content}</span>
) : (
<FilterHeaderButton column={column} onOpenFilter={onOpenFilter} />
);

View File

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