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;