diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index a5ed83b..755e83e 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -34,8 +34,23 @@ test("host org list APIs use generated admin paths and filters", async () => { ), ); - await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" }); - await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 }); + await listBDs({ + page: 1, + page_size: 10, + parent_leader_user_id: 21, + region_id: 7, + sort_by: "salary_wallet", + sort_direction: "asc", + status: "active", + }); + await listAgencies({ + keyword: "agency", + page: 2, + page_size: 10, + parent_bd_user_id: 31, + sort_by: "salary_wallet", + sort_direction: "desc", + }); await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" }); await listCoinSellers({ page: 1, @@ -84,10 +99,14 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("parent_leader_user_id=21"); expect(String(bdUrl)).toContain("region_id=7"); + expect(String(bdUrl)).toContain("sort_by=salary_wallet"); + expect(String(bdUrl)).toContain("sort_direction=asc"); expect(String(bdUrl)).toContain("status=active"); expect(bdInit?.method).toBe("GET"); expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?"); expect(String(agencyUrl)).toContain("parent_bd_user_id=31"); + expect(String(agencyUrl)).toContain("sort_by=salary_wallet"); + expect(String(agencyUrl)).toContain("sort_direction=desc"); expect(String(hostUrl)).toContain("/api/v1/admin/hosts?"); expect(String(hostUrl)).toContain("agency_id=41"); expect(String(hostUrl)).toContain("sort_by=diamond"); diff --git a/src/features/host-org/components/HostOrgSortHeader.jsx b/src/features/host-org/components/HostOrgSortHeader.jsx new file mode 100644 index 0000000..4913637 --- /dev/null +++ b/src/features/host-org/components/HostOrgSortHeader.jsx @@ -0,0 +1,32 @@ +import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; +import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; +import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined"; + +export function HostOrgSortHeader({ field, label, onToggle, sortBy, sortDirection }) { + const active = sortBy === field; + const SortIcon = + active && sortDirection === "asc" + ? ArrowUpwardOutlined + : active && sortDirection === "desc" + ? ArrowDownwardOutlined + : SwapVertOutlined; + const directionLabel = sortDirection === "asc" ? "升序" : "降序"; + const nextDirectionLabel = active && sortDirection === "desc" ? "升序" : "降序"; + + return ( + + ); +} diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js index a298da3..57dc744 100644 --- a/src/features/host-org/hooks/useHostAgenciesPage.js +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -28,6 +28,8 @@ export function useHostAgenciesPage() { const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); const [parentBdUserId, setParentBdUserId] = useState(""); + const [sortBy, setSortBy] = useState(""); + const [sortDirection, setSortDirection] = useState(""); const [page, setPage] = useState(1); const [agencyForm, setAgencyForm] = useState(emptyAgencyForm); const [rowPatches, setRowPatches] = useState({}); @@ -39,9 +41,11 @@ export function useHostAgenciesPage() { keyword: query, parent_bd_user_id: parentBdUserId, region_id: regionId, + sort_by: sortBy, + sort_direction: sortDirection, status, }), - [parentBdUserId, query, regionId, status], + [parentBdUserId, query, regionId, sortBy, sortDirection, status], ); const { data = emptyData, @@ -97,11 +101,19 @@ export function useHostAgenciesPage() { setPage(1); }; + const changeSort = (field) => { + setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc")); + setSortBy(field); + setPage(1); + }; + const resetFilters = () => { setQuery(""); setStatus(""); setRegionId(""); setParentBdUserId(""); + setSortBy(""); + setSortDirection(""); setPage(1); }; @@ -212,8 +224,11 @@ export function useHostAgenciesPage() { removeAgencyFromList, setAgencyForm, setPage, + sortBy, + sortDirection, status, submitAgency, + changeSort, toggleAgencyJoinEnabledFromList, }; } diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js index aeb0b2a..acf4176 100644 --- a/src/features/host-org/hooks/useHostBdsPage.js +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -38,6 +38,8 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); const [parentLeaderUserId, setParentLeaderUserId] = useState(""); + const [sortBy, setSortBy] = useState(""); + const [sortDirection, setSortDirection] = useState(""); const [page, setPage] = useState(1); const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm); const [bdForm, setBDForm] = useState(emptyBDForm); @@ -54,9 +56,11 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { keyword: query, parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "", region_id: regionId, + sort_by: sortBy, + sort_direction: sortDirection, status, }), - [parentLeaderUserId, profileType, query, regionId, status], + [parentLeaderUserId, profileType, query, regionId, sortBy, sortDirection, status], ); const fetcher = profileType === "bd" ? listBDs : listBDLeaders; const { @@ -158,11 +162,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { setPage(1); }; + const changeSort = (field) => { + setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc")); + setSortBy(field); + setPage(1); + }; + const resetFilters = () => { setQuery(""); setStatus(""); setRegionId(""); setParentLeaderUserId(""); + setSortBy(""); + setSortDirection(""); setPage(1); }; @@ -361,10 +373,13 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { setBDForm, setBDLeaderForm, setPage, + sortBy, + sortDirection, status, submitBD, submitBDLeader, submitLeaderBD, + changeSort, toggleBD, toggleBDLeader, }; diff --git a/src/features/host-org/hooks/useHostHostsPage.js b/src/features/host-org/hooks/useHostHostsPage.js index 5e1f405..db84c7e 100644 --- a/src/features/host-org/hooks/useHostHostsPage.js +++ b/src/features/host-org/hooks/useHostHostsPage.js @@ -14,18 +14,19 @@ export function useHostHostsPage() { const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); const [agencyId, setAgencyId] = useState(""); - const [diamondSortDirection, setDiamondSortDirection] = useState(""); + const [sortBy, setSortBy] = useState(""); + const [sortDirection, setSortDirection] = useState(""); const [page, setPage] = useState(1); const filters = useMemo( () => ({ agency_id: agencyId, keyword: query, region_id: regionId, - sort_by: diamondSortDirection ? "diamond" : "", - sort_direction: diamondSortDirection, + sort_by: sortBy, + sort_direction: sortDirection, status, }), - [agencyId, diamondSortDirection, query, regionId, status], + [agencyId, query, regionId, sortBy, sortDirection, status], ); const { data = emptyData, @@ -61,8 +62,9 @@ export function useHostHostsPage() { setPage(1); }; - const toggleDiamondSort = () => { - setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc")); + const changeSort = (field) => { + setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc")); + setSortBy(field); setPage(1); }; @@ -71,7 +73,8 @@ export function useHostHostsPage() { setStatus(""); setRegionId(""); setAgencyId(""); - setDiamondSortDirection(""); + setSortBy(""); + setSortDirection(""); setPage(1); }; @@ -83,7 +86,7 @@ export function useHostHostsPage() { changeRegionId, changeStatus, data, - diamondSortDirection, + diamondSortDirection: sortBy === "diamond" ? sortDirection : "", error, loading, loadingRegions, @@ -94,7 +97,10 @@ export function useHostHostsPage() { reload, resetFilters, setPage, + sortBy, + sortDirection, status, - toggleDiamondSort, + changeSort, + toggleDiamondSort: () => changeSort("diamond"), }; } diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index f558812..b1c2047 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -7,6 +7,7 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { agencyStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx"; import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; @@ -31,6 +32,8 @@ const agencyColumns = [ { key: "salaryWallet", label: "工资钱包", + sortable: true, + sortField: "salary_wallet", render: (item) => ( + ), + }; + } return column; }); return (
- +
@@ -191,9 +206,7 @@ export function HostAgenciesPage() { disabled={createDisabled} label="入会状态" uncheckedLabel="禁止" - onChange={(event) => - page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked }) - } + onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })} /> ( + ), + }; + } return column; }); const toolbarActions = page.abilities.canCreate diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx index b072700..5e9c36d 100644 --- a/src/features/host-org/pages/HostBdsPage.jsx +++ b/src/features/host-org/pages/HostBdsPage.jsx @@ -6,6 +6,7 @@ import { DataState } from "@/shared/ui/DataState.jsx"; import { bdStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx"; import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx"; import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; @@ -25,13 +26,10 @@ const bdColumns = [ { key: "salaryWallet", label: "工资钱包", + sortable: true, + sortField: "salary_wallet", render: (item) => ( - + ), width: "minmax(140px, 0.85fr)", }, @@ -93,47 +91,61 @@ export function HostBdsPage() { width: "minmax(80px, 0.45fr)", } : null, - ].filter(Boolean).map((column) => { - if (column.key === "user") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索用户 ID、展示 ID、用户名", - value: page.query, - onChange: page.changeQuery, - }), - }; - } - if (column.key === "parentLeaderUserId") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索 Leader 用户 ID", - value: page.parentLeaderUserId, - onChange: page.changeParentLeaderUserId, - }), - }; - } - if (column.key === "status") { - return { - ...column, - filter: createOptionsColumnFilter({ - options: bdStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - }; - } - return column; - }); + ] + .filter(Boolean) + .map((column) => { + if (column.key === "user") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索用户 ID、展示 ID、用户名", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + if (column.key === "parentLeaderUserId") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索 Leader 用户 ID", + value: page.parentLeaderUserId, + onChange: page.changeParentLeaderUserId, + }), + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: bdStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + }; + } + if (column.sortable) { + return { + ...column, + header: ( + + ), + }; + } + return column; + }); return (
- +
diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx index d331644..153b44f 100644 --- a/src/features/host-org/pages/HostHostsPage.jsx +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -1,11 +1,9 @@ -import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; -import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; -import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx"; import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; @@ -29,12 +27,16 @@ const hostColumns = [ { key: "diamond", label: "钻石", + sortable: true, + sortField: "diamond", render: (item) => formatNumber(item.diamond), width: "minmax(110px, 0.7fr)", }, { key: "salaryWallet", label: "工资钱包", + sortable: true, + sortField: "salary_wallet", render: (item) => ( { - if (column.key === "user") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索用户 ID、展示 ID、用户名、Agency", - value: page.query, - onChange: page.changeQuery, - }), - }; - } - if (column.key === "currentAgencyId") { - return { - ...column, - filter: createTextColumnFilter({ - placeholder: "搜索 Agency ID", - value: page.agencyId, - onChange: page.changeAgencyId, - }), - }; - } - if (column.key === "status") { - return { - ...column, - filter: createOptionsColumnFilter({ - options: hostStatusFilters, - placeholder: "搜索状态", - value: page.status, - onChange: page.changeStatus, - }), - }; - } - if (column.key === "diamond") { - return { - ...column, - header: ( - - ), - }; - } - return column; - }); + ] + .filter(Boolean) + .map((column) => { + if (column.key === "user") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索用户 ID、展示 ID、用户名、Agency", + value: page.query, + onChange: page.changeQuery, + }), + }; + } + if (column.key === "currentAgencyId") { + return { + ...column, + filter: createTextColumnFilter({ + placeholder: "搜索 Agency ID", + value: page.agencyId, + onChange: page.changeAgencyId, + }), + }; + } + if (column.key === "status") { + return { + ...column, + filter: createOptionsColumnFilter({ + options: hostStatusFilters, + placeholder: "搜索状态", + value: page.status, + onChange: page.changeStatus, + }), + }; + } + if (column.key === "diamond") { + return { + ...column, + header: ( + + ), + }; + } + if (column.sortable) { + return { + ...column, + header: ( + + ), + }; + } + return column; + }); return (
@@ -170,28 +191,6 @@ function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } -function DiamondSortHeader({ direction, onToggle }) { - const SortIcon = - direction === "asc" ? ArrowUpwardOutlined : direction === "desc" ? ArrowDownwardOutlined : SwapVertOutlined; - const directionLabel = direction === "asc" ? "升序" : "降序"; - const nextDirectionLabel = direction === "desc" ? "升序" : "降序"; - return ( - - ); -} - function HostUser({ item }) { return ( ({ + useHostAgenciesPage: vi.fn(), +})); + +vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({ + useHostBdsPage: vi.fn(), +})); + +vi.mock("@/features/host-org/hooks/useHostHostsPage.js", () => ({ + useHostHostsPage: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("agency list salary wallet header requests salary wallet sorting", () => { + const changeSort = vi.fn(); + vi.mocked(useHostAgenciesPage).mockReturnValue(agencyPageFixture({ changeSort })); + + render(); + fireEvent.click(screen.getByLabelText("按工资钱包降序排序")); + + expect(changeSort).toHaveBeenCalledWith("salary_wallet"); +}); + +test("bd leader list salary wallet header requests salary wallet sorting", () => { + const changeSort = vi.fn(); + vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "leader" })); + + render(); + fireEvent.click(screen.getByLabelText("按工资钱包降序排序")); + + expect(changeSort).toHaveBeenCalledWith("salary_wallet"); +}); + +test("bd list salary wallet header requests salary wallet sorting", () => { + const changeSort = vi.fn(); + vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "bd" })); + + render(); + fireEvent.click(screen.getByLabelText("按工资钱包降序排序")); + + expect(changeSort).toHaveBeenCalledWith("salary_wallet"); +}); + +test("host list salary wallet header requests salary wallet sorting", () => { + const changeSort = vi.fn(); + vi.mocked(useHostHostsPage).mockReturnValue(hostPageFixture({ changeSort })); + + render(); + fireEvent.click(screen.getByLabelText("按工资钱包降序排序")); + + expect(changeSort).toHaveBeenCalledWith("salary_wallet"); +}); + +function agencyPageFixture(patch = {}) { + return { + abilities: { canCreate: false, canDelete: false, canSalaryAdjust: false, canStatus: false }, + activeAction: "", + agencyForm: { joinEnabled: true, name: "", ownerUserId: "", parentBdUserId: "", reason: "" }, + changeParentBdUserId: vi.fn(), + changeQuery: vi.fn(), + changeRegionId: vi.fn(), + changeSort: vi.fn(), + changeStatus: vi.fn(), + closeAction: vi.fn(), + closeAgencyFromList: vi.fn(), + data: { items: [agencyFixture()], page: 1, pageSize: 50, total: 1 }, + error: null, + loading: false, + loadingAction: "", + loadingRegions: false, + openAgencyForm: vi.fn(), + page: 1, + parentBdUserId: "", + query: "", + regionId: "", + regionOptions: [], + reload: vi.fn(), + removeAgencyFromList: vi.fn(), + resetFilters: vi.fn(), + setAgencyForm: vi.fn(), + setPage: vi.fn(), + sortBy: "", + sortDirection: "", + status: "", + submitAgency: vi.fn(), + toggleAgencyJoinEnabledFromList: vi.fn(), + ...patch, + }; +} + +function bdPageFixture(patch = {}) { + return { + abilities: { canCreate: false, canSalaryAdjust: false, canUpdate: false, canView: true }, + activeAction: "", + bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" }, + bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" }, + bdUserLookup: { error: "", loading: false, user: null }, + changeParentLeaderUserId: vi.fn(), + changeQuery: vi.fn(), + changeRegionId: vi.fn(), + changeSort: vi.fn(), + changeStatus: vi.fn(), + closeAction: vi.fn(), + data: { items: [bdFixture()], page: 1, pageSize: 50, total: 1 }, + error: null, + leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 }, + leaderBDsError: "", + leaderBDsLoading: false, + loadLeaderBDs: vi.fn(), + loading: false, + loadingAction: "", + loadingRegions: false, + openAddLeaderBD: vi.fn(), + openBDForm: vi.fn(), + openBDLeaderForm: vi.fn(), + openLeaderBDs: vi.fn(), + page: 1, + parentLeaderUserId: "", + profileType: "bd", + query: "", + regionId: "", + regionOptions: [], + reload: vi.fn(), + removeBDLeader: vi.fn(), + resetFilters: vi.fn(), + saveBDLeaderPositionAlias: vi.fn(), + selectedLeader: null, + setBDForm: vi.fn(), + setBDLeaderForm: vi.fn(), + setPage: vi.fn(), + sortBy: "", + sortDirection: "", + status: "", + submitBD: vi.fn(), + submitBDLeader: vi.fn(), + submitLeaderBD: vi.fn(), + toggleBD: vi.fn(), + toggleBDLeader: vi.fn(), + ...patch, + }; +} + +function hostPageFixture(patch = {}) { + return { + abilities: { canSalaryAdjust: false, canView: true }, + agencyId: "", + changeAgencyId: vi.fn(), + changeQuery: vi.fn(), + changeRegionId: vi.fn(), + changeSort: vi.fn(), + changeStatus: vi.fn(), + data: { items: [hostFixture()], page: 1, pageSize: 50, total: 1 }, + diamondSortDirection: "", + error: null, + loading: false, + loadingRegions: false, + page: 1, + query: "", + regionId: "", + regionOptions: [], + reload: vi.fn(), + resetFilters: vi.fn(), + setPage: vi.fn(), + sortBy: "", + sortDirection: "", + status: "", + toggleDiamondSort: vi.fn(), + ...patch, + }; +} + +function agencyFixture() { + return { + agencyId: "9001", + joinEnabled: true, + name: "Agency One", + ownerDisplayUserId: "160001", + ownerUserId: "1001", + ownerUsername: "agency_owner", + parentBdUserId: "2001", + regionName: "中东区", + salaryWallet: { availableAmount: 1200 }, + status: "active", + updatedAtMs: 1760000000000, + }; +} + +function bdFixture() { + return { + createdAtMs: 1760000000000, + displayUserId: "160002", + parentLeaderUserId: "3001", + regionName: "中东区", + role: "bd", + salaryWallet: { availableAmount: 1200 }, + status: "active", + subBdCount: 0, + updatedAtMs: 1760000000000, + userId: "2001", + username: "bd_user", + }; +} + +function hostFixture() { + return { + currentAgencyId: "9001", + diamond: 100, + displayUserId: "160003", + firstBecameHostAtMs: 1760000000000, + regionName: "中东区", + salaryWallet: { availableAmount: 1200 }, + source: "agency_invite", + status: "active", + updatedAtMs: 1760000000000, + userId: "3001", + username: "host_user", + }; +}