feat: update admin platform resource and user tables
This commit is contained in:
parent
a33474ec5f
commit
39d7e527cb
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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} />
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user