diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ea75ae0..c7d1970 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1687,6 +1687,18 @@ "x-permissions": ["coin-ledger:view"] } }, + "/admin/operations/coin-seller-ledger": { + "get": { + "operationId": "listCoinSellerLedger", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "coin-seller-ledger:view", + "x-permissions": ["coin-seller-ledger:view"] + } + }, "/admin/operations/reports": { "get": { "operationId": "listReports", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 9c9f7f4..8104840 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -146,10 +146,11 @@ export const fallbackNavigation = [ label: "运营管理", children: [ routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }), + routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }), routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }), + routeNavItem("lucky-gift", { icon: RedeemOutlined }), routeNavItem("operation-reports", { icon: FlagOutlined }), routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }), - routeNavItem("lucky-gift", { icon: RedeemOutlined }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index b9ce024..729a060 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -38,6 +38,7 @@ export const PERMISSIONS = { coinSellerStockCredit: "coin-seller:stock-credit", coinSellerExchangeRate: "coin-seller:exchange-rate", coinLedgerView: "coin-ledger:view", + coinSellerLedgerView: "coin-seller-ledger:view", coinAdjustmentView: "coin-adjustment:view", coinAdjustmentCreate: "coin-adjustment:create", reportView: "report:view", @@ -174,6 +175,7 @@ export const MENU_CODES = { emojiPackList: "emoji-pack-list", operations: "operations", operationCoinLedger: "operation-coin-ledger", + operationCoinSellerLedger: "operation-coin-seller-ledger", operationCoinAdjustment: "operation-coin-adjustment", operationReports: "operation-reports", operationGiftDiamond: "operation-gift-diamond", diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index 298d2bc..e2c4d14 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -15,6 +15,7 @@ import { listRegions, replaceRegionCountries, setCoinSellerStatus, + updateBDLeaderPositionAlias, updateCountry, } from "./api"; @@ -41,6 +42,10 @@ test("host org list APIs use generated admin paths and filters", async () => { regionId: 7, targetUserId: 1002, }); + await updateBDLeaderPositionAlias(1002, { + commandId: "bd-leader-position-alias-test", + positionAlias: "Regional Admin", + }); await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 }); await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" }); await creditCoinSellerStock(1001, { @@ -55,9 +60,10 @@ test("host org list APIs use generated admin paths and filters", async () => { const [hostUrl] = vi.mocked(fetch).mock.calls[2]; const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3]; const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4]; - const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[5]; - const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[6]; - const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[7]; + const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[5]; + const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6]; + const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7]; + const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8]; expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("parent_leader_user_id=21"); @@ -77,6 +83,9 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders"); expect(bdLeaderCreateInit?.method).toBe("POST"); expect(JSON.parse(String(bdLeaderCreateInit?.body))).toMatchObject({ positionAlias: "Senior Admin" }); + expect(String(bdLeaderAliasUrl)).toContain("/api/v1/admin/bd-leaders/1002/position-alias"); + expect(bdLeaderAliasInit?.method).toBe("PATCH"); + expect(JSON.parse(String(bdLeaderAliasInit?.body))).toMatchObject({ positionAlias: "Regional Admin" }); expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers"); expect(coinSellerCreateInit?.method).toBe("POST"); expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status"); diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index be53cee..3e6fa15 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -6,6 +6,7 @@ import type { AgencyJoinEnabledPayload, ApiList, ApiPage, + BDLeaderPositionAliasPayload, BDProfileDto, BDStatusPayload, CoinSellerDto, @@ -200,6 +201,19 @@ export function deleteBDLeader(userId: EntityId): Promise { }); } +export function updateBDLeaderPositionAlias( + userId: EntityId, + payload: BDLeaderPositionAliasPayload, +): Promise { + return apiRequest( + `/v1/admin/bd-leaders/${encodeURIComponent(String(userId))}/position-alias`, + { + body: payload, + method: "PATCH", + }, + ); +} + export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise { const endpoint = API_ENDPOINTS.setBDLeaderStatus; return apiRequest( diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js index b8b800c..5c4df1d 100644 --- a/src/features/host-org/hooks/useHostBdsPage.js +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -11,6 +11,7 @@ import { listBDs, setBDLeaderStatus, setBDStatus, + updateBDLeaderPositionAlias, } from "@/features/host-org/api"; import { listAppUsers } from "@/features/app-users/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; @@ -48,6 +49,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { const [selectedLeader, setSelectedLeader] = useState(null); const [loadingAction, setLoadingAction] = useState(""); const [activeAction, setActiveAction] = useState(""); + const [profileOverrides, setProfileOverrides] = useState({}); const filters = useMemo( () => ({ keyword: query, @@ -71,6 +73,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { pageSize, queryKey: ["host-org", "bds", profileType, filters, page], }); + const displayData = useMemo( + () => ({ + ...data, + items: (data.items || []).map((item) => ({ + ...item, + ...(profileOverrides[String(item.userId)] || {}), + })), + }), + [data, profileOverrides], + ); const loadLeaderBDs = useCallback(async (leader) => { if (!leader?.userId) { @@ -222,6 +234,44 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { }); }; + const saveBDLeaderPositionAlias = useCallback( + async (leader, rawAlias) => { + const userId = leader?.userId; + const positionAlias = String(rawAlias ?? "").trim(); + const currentAlias = String(leader?.positionAlias ?? "").trim(); + if (!userId || positionAlias === currentAlias) { + return leader; + } + if (Array.from(positionAlias).length > 64) { + const err = new Error("职位别名不能超过 64 个字符"); + showToast(err.message, "error"); + throw err; + } + + try { + const profile = await updateBDLeaderPositionAlias(userId, { + commandId: makeCommandId("bd-leader-position-alias"), + positionAlias, + reason: "", + }); + setProfileOverrides((current) => ({ + ...current, + [String(userId)]: { + ...(current[String(userId)] || {}), + ...(profile || {}), + positionAlias: profile?.positionAlias ?? positionAlias, + }, + })); + showToast("职位别名已保存", "success"); + return profile; + } catch (err) { + showToast(err.message || "保存职位别名失败", "error"); + throw err; + } + }, + [showToast], + ); + const removeBDLeader = async (leader) => { const ok = await confirm({ confirmText: "删除", @@ -284,7 +334,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { changeQuery, changeRegionId, changeStatus, - data, + data: displayData, error, loading, loadingAction, @@ -301,6 +351,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) { reload, removeBDLeader, resetFilters, + saveBDLeaderPositionAlias, closeAction, loadLeaderBDs: () => loadLeaderBDs(selectedLeader), openAddLeaderBD, diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js index 7e94cb2..2163555 100644 --- a/src/features/host-org/hooks/useHostCoinSellersPage.js +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -134,6 +134,14 @@ export function useHostCoinSellersPage() { setActiveAction("stock"); }; + const openSellerLedger = (seller) => { + if (!seller?.userId || !abilities.canLedger) { + return; + } + setSelectedSeller(seller); + setActiveAction("ledger"); + }; + const openRateSettings = async () => { if (!abilities.canExchangeRate) { return; @@ -309,6 +317,7 @@ export function useHostCoinSellersPage() { openRateSettings, openCreateSeller, openEditSeller, + openSellerLedger, openStockCredit, page, query, diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css index fc5af6c..b944a20 100644 --- a/src/features/host-org/host-org.module.css +++ b/src/features/host-org/host-org.module.css @@ -162,6 +162,46 @@ font-weight: 650; } +.inlineAliasText { + color: var(--text-secondary); + font-weight: 650; +} + +.inlineAliasButton { + display: inline-flex; + max-width: 100%; + min-height: 28px; + align-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-weight: 650; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inlineAliasButton:hover { + color: var(--primary); +} + +.inlineAliasInput { + width: min(180px, 100%); + min-height: 32px; + padding: 0 var(--space-2); + border: 1px solid var(--primary); + border-radius: var(--radius-control); + outline: 0; + background: var(--bg-card); + color: var(--text-primary); + font: inherit; + font-weight: 650; +} + .sellerIdentity { display: inline-flex; min-width: 0; diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx index 7edc3a9..d604a7f 100644 --- a/src/features/host-org/pages/HostBdLeadersPage.jsx +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -1,6 +1,7 @@ import Add from "@mui/icons-material/Add"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined"; +import { useEffect, useRef, useState } from "react"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { formatMillis } from "@/shared/utils/time.js"; @@ -31,7 +32,7 @@ const bdLeaderBaseColumns = [ { key: "positionAlias", label: "职位别名", - render: (item) => item.positionAlias || "-", + render: (item, _index, context) => , width: "minmax(130px, 0.75fr)", }, { @@ -268,6 +269,96 @@ function BDLeaderStatusSwitch({ item, page }) { ); } +function PositionAliasCell({ item, page }) { + const inputRef = useRef(null); + const cancelSaveRef = useRef(false); + const originalValue = String(item.positionAlias || "").trim(); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [value, setValue] = useState(originalValue); + const canEdit = Boolean(page?.abilities.canUpdate); + + useEffect(() => { + if (!editing) { + setValue(originalValue); + } + }, [editing, originalValue]); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [editing]); + + const save = async () => { + if (cancelSaveRef.current) { + cancelSaveRef.current = false; + setValue(originalValue); + setEditing(false); + return; + } + const nextValue = value.trim(); + if (nextValue === originalValue) { + setValue(originalValue); + setEditing(false); + return; + } + setSaving(true); + try { + await page.saveBDLeaderPositionAlias(item, nextValue); + setEditing(false); + } catch { + setValue(originalValue); + setEditing(false); + } finally { + setSaving(false); + } + }; + + if (editing) { + return ( + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + if (event.key === "Escape") { + cancelSaveRef.current = true; + setValue(originalValue); + setEditing(false); + } + }} + /> + ); + } + + if (!canEdit) { + return {originalValue || "-"}; + } + + return ( + + ); +} + function SubBDCount({ item, page }) { return (