diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 48452d4..bc7dc02 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -446,6 +446,28 @@ "x-permissions": ["agency:status"] } }, + "/admin/agencies/{agency_id}/delete": { + "post": { + "operationId": "deleteAgency", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "agency_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "agency:delete", + "x-permissions": ["agency:delete"] + } + }, "/admin/agencies/{agency_id}/join-enabled": { "post": { "operationId": "setAgencyJoinEnabled", diff --git a/databi/src/data/createDashboardModel.js b/databi/src/data/createDashboardModel.js index e5679c0..93b40ff 100644 --- a/databi/src/data/createDashboardModel.js +++ b/databi/src/data/createDashboardModel.js @@ -7,14 +7,16 @@ import { formatDateTime } from "../utils/time.js"; export function createDashboardModel(overview, { appCode, countryId, range }) { const source = overview || {}; const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日"; - const recharge = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor"); + const recharge = effectiveRechargeUSDMinor(source); const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"); const activeUsers = readNumber(source, "active_users", "activeUsers"); const paidUsers = readNumber(source, "paid_users", "paidUsers"); + const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"); const newUsers = readNumber(source, "new_users", "newUsers", "visitors"); const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor"); const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor"); const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent"); + const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin"); const luckyGiftPools = normalizeLuckyGiftPools(source); const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools); const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover"); @@ -31,6 +33,7 @@ export function createDashboardModel(overview, { appCode, countryId, range }) { appCode, businessKpis: [ coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption), + coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption), coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools" }), @@ -42,7 +45,7 @@ export function createDashboardModel(overview, { appCode, countryId, range }) { { name: "访客", rate: 1, value: newUsers }, { name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers }, { name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers }, - { name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) } + { name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers } ], gameMetrics: [ { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) }, @@ -145,23 +148,34 @@ function normalizeRevenueSeries(source) { return []; } if (Array.isArray(source.daily_series) && source.daily_series.length) { - return source.daily_series.map((item) => ({ - coin_seller: numberValue(item.coin_seller ?? item.coinSeller), - google: numberValue(item.google), - label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"), - lineTotal: numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? item.total ?? item.recharge_usd_minor), - mifapay: numberValue(item.mifapay ?? item.mifa_pay), - total: numberValue(item.total ?? item.recharge_usd_minor) - })); + return source.daily_series.map((item) => { + const coinSeller = 0; + const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor); + const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor); + const channelTotal = google + mifapay; + const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal); + return { + coin_seller: coinSeller, + google, + label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"), + lineTotal: Math.max(numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? total), channelTotal), + mifapay, + total + }; + }); } + const coinSeller = 0; + const google = numberValue(source.google_recharge_usd_minor ?? source.googleRechargeUsdMinor); + const mifapay = numberValue(source.mifapay_recharge_usd_minor ?? source.mifaPayRechargeUsdMinor); + const total = effectiveRechargeUSDMinor(source); return [ { - coin_seller: numberValue(source.coin_seller_recharge_usd_minor), - google: numberValue(source.google_recharge_usd_minor), + coin_seller: coinSeller, + google, label: "当前", - lineTotal: numberValue(source.recharge_usd_minor), - mifapay: numberValue(source.mifapay_recharge_usd_minor), - total: numberValue(source.recharge_usd_minor) + lineTotal: total, + mifapay, + total } ]; } @@ -177,7 +191,7 @@ function normalizeCountries(source, countryId) { x: 56, y: 52 }] : []); - const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1; + const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1; return rows .map((item, index) => ({ ...normalizeCountryRow(item, index, totalRecharge) @@ -189,12 +203,13 @@ function normalizeCountryRow(item, index, totalRecharge) { const code = item.country_code || item.countryCode || item.iso_code || item.isoCode; const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`); const meta = resolveCountryMeta({ code, name: country }); - const recharge = numberValue(item.recharge_usd_minor); + const recharge = effectiveRechargeUSDMinor(item); return { active_users: numberValue(item.active_users), arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor), arppu_usd_minor: numberValue(item.arppu_usd_minor), avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor), + coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin), country: meta?.label || country, country_code: code || meta?.code || "", flag: countryFlag(code || meta?.code), @@ -204,13 +219,20 @@ function normalizeCountryRow(item, index, totalRecharge) { payer_rate: ratio(item.paid_users, item.active_users), recharge_usd_minor: recharge, recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate), - recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers), + recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers), share: recharge / totalRecharge, trend_rate: numberValue(item.trend_rate ?? item.trendRate), visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers) }; } +function effectiveRechargeUSDMinor(source) { + const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor"); + const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") + + readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor"); + return Math.max(direct, channelTotal); +} + function normalizeGiftRanking(source) { if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) { return source.gift_ranking.map((item) => ({ diff --git a/databi/src/data/createDashboardModel.test.js b/databi/src/data/createDashboardModel.test.js index 221fdbf..878fff6 100644 --- a/databi/src/data/createDashboardModel.test.js +++ b/databi/src/data/createDashboardModel.test.js @@ -22,6 +22,37 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => { expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3); }); +test("keeps coin seller transfer out of USD recharge and exposes coin metric", () => { + const model = createDashboardModel({ + coin_seller_recharge_usd_minor: 200, + coin_seller_transfer_coin: 160_000, + country_breakdown: [ + { + coin_seller_recharge_usd_minor: 200, + coin_seller_transfer_coin: 160_000, + country: "阿富汗", + google_recharge_usd_minor: 150, + recharge_usd_minor: 150 + } + ], + daily_series: [ + { + coin_seller: 200, + google: 150, + label: "当前", + recharge_usd_minor: 150 + } + ], + google_recharge_usd_minor: 150, + recharge_usd_minor: 150 + }, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } }); + + expect(metricValue(model, "总充值")).toBe("$2"); + expect(metricValue(model, "币商转账金币")).toBe("160,000"); + expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 0, google: 150, lineTotal: 150, total: 150 })); + expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 150 })); +}); + function metricValue(model, label) { - return model.businessKpis.find((item) => item.label === label)?.value; + return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value; } diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index 18765ec..2c3e4a6 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -636,18 +636,24 @@ box-shadow: 0 0 14px rgba(247, 163, 58, 0.72); } -.metric-grid, -.business-metric-grid { +.metric-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 16px; } +.business-metric-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 16px; +} + .business-metric-grid .metric-card { height: var(--business-metric-row-height); + grid-column: span 1; grid-template-columns: minmax(0, 1fr); align-items: stretch; - padding: 10px 16px; + padding: 10px 14px; } .business-metric-grid .metric-content { @@ -693,7 +699,7 @@ grid-column: 1; grid-row: 2; margin-top: 3px; - font-size: clamp(18px, 1vw, 20px); + font-size: clamp(17px, 0.92vw, 20px); line-height: 1.25; } diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 9e8c4d5..d93009f 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -29,6 +29,7 @@ export const PERMISSIONS = { agencyView: "agency:view", agencyCreate: "agency:create", agencyStatus: "agency:status", + agencyDelete: "agency:delete", bdView: "bd:view", bdCreate: "bd:create", bdUpdate: "bd:update", diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index e2c4d14..db4fc20 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -6,6 +6,7 @@ import { createCoinSeller, createRegion, creditCoinSellerStock, + deleteAgency, disableRegion, listAgencies, listBDs, @@ -39,7 +40,6 @@ test("host org list APIs use generated admin paths and filters", async () => { await createBDLeader({ commandId: "bd-leader-test", positionAlias: "Senior Admin", - regionId: 7, targetUserId: 1002, }); await updateBDLeaderPositionAlias(1002, { @@ -54,6 +54,7 @@ test("host org list APIs use generated admin paths and filters", async () => { rechargeAmount: "100.000000", type: "usdt_purchase", }); + await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" }); const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0]; const [agencyUrl] = vi.mocked(fetch).mock.calls[1]; @@ -64,6 +65,7 @@ test("host org list APIs use generated admin paths and filters", async () => { 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]; + const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[9]; expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("parent_leader_user_id=21"); @@ -83,6 +85,7 @@ 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(JSON.parse(String(bdLeaderCreateInit?.body))).not.toHaveProperty("regionId"); 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" }); @@ -92,6 +95,9 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(coinSellerStatusInit?.method).toBe("PATCH"); expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits"); expect(coinSellerStockInit?.method).toBe("POST"); + expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete"); + expect(agencyDeleteInit?.method).toBe("POST"); + expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" }); }); test("country and region APIs use generated admin paths", async () => { diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index 3e6fa15..9f5e6d8 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -309,6 +309,17 @@ export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Pr ); } +export function deleteAgency(agencyId: EntityId, payload: HostCommandPayload): Promise { + const endpoint = API_ENDPOINTS.deleteAgency; + return apiRequest( + apiEndpointPath(API_OPERATIONS.deleteAgency, { agency_id: agencyId }), + { + body: payload, + method: endpoint.method, + }, + ); +} + export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise { const endpoint = API_ENDPOINTS.setAgencyJoinEnabled; return apiRequest( diff --git a/src/features/host-org/hooks/useHostAgenciesPage.js b/src/features/host-org/hooks/useHostAgenciesPage.js index 587ed74..3817986 100644 --- a/src/features/host-org/hooks/useHostAgenciesPage.js +++ b/src/features/host-org/hooks/useHostAgenciesPage.js @@ -1,33 +1,27 @@ import { useMemo, useState } from "react"; import { parseForm } from "@/shared/forms/validation"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; -import { closeAgency, createAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api"; +import { closeAgency, createAgency, deleteAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useAgencyAbilities } from "@/features/host-org/permissions.js"; -import { agencyCloseSchema, agencyJoinEnabledSchema, createAgencySchema } from "@/features/host-org/schema"; +import { createAgencySchema } from "@/features/host-org/schema"; const emptyAgencyForm = () => ({ commandId: makeCommandId("agency"), joinEnabled: true, - maxHosts: 0, name: "", ownerUserId: "", parentBdUserId: "", reason: "", }); -const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" }); -const emptyJoinEnabledForm = () => ({ - agencyId: "", - commandId: makeCommandId("agency-join"), - joinEnabled: true, - reason: "", -}); const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; export function useHostAgenciesPage() { const abilities = useAgencyAbilities(); + const confirm = useConfirm(); const { showToast } = useToast(); const { loadingRegions, regionOptions } = useRegionOptions(); const [query, setQuery] = useState(""); @@ -36,8 +30,8 @@ export function useHostAgenciesPage() { const [parentBdUserId, setParentBdUserId] = useState(""); const [page, setPage] = useState(1); const [agencyForm, setAgencyForm] = useState(emptyAgencyForm); - const [closeForm, setCloseForm] = useState(emptyCloseForm); - const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm); + const [rowPatches, setRowPatches] = useState({}); + const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set()); const [loadingAction, setLoadingAction] = useState(""); const [activeAction, setActiveAction] = useState(""); const filters = useMemo( @@ -62,6 +56,26 @@ export function useHostAgenciesPage() { pageSize, queryKey: ["host-org", "agencies", filters, page], }); + const viewData = useMemo(() => { + const rawItems = Array.isArray(data.items) ? data.items : []; + const items = rawItems + .filter((item) => !removedAgencyIds.has(String(item.agencyId))) + .map((item) => ({ ...item, ...(rowPatches[String(item.agencyId)] || {}) })); + const visibleRemovedCount = rawItems.length - items.length; + + return { + ...data, + items, + total: Math.max(0, Number(data.total || 0) - visibleRemovedCount), + }; + }, [data, removedAgencyIds, rowPatches]); + + const patchAgencyRow = (agencyId, patch) => { + setRowPatches((current) => { + const key = String(agencyId); + return { ...current, [key]: { ...(current[key] || {}), ...patch } }; + }); + }; const changeQuery = (value) => { setQuery(value); @@ -103,41 +117,59 @@ export function useHostAgenciesPage() { }); }; - const submitClose = async (event) => { - event.preventDefault(); - await runAction("agency-close", "Agency 已关闭", async () => { - const payload = parseForm(agencyCloseSchema, closeForm); - const { agencyId, ...body } = payload; - const data = await closeAgency(agencyId, body); - setCloseForm(emptyCloseForm()); - setActiveAction(""); - await reload(); - return data; - }); - }; - const closeAgencyFromList = async (agency) => { if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { return; } await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => { - await closeAgency(agency.agencyId, { + const data = await closeAgency(agency.agencyId, { commandId: makeCommandId("agency-close"), reason: "close agency from list switch", }); - await reload(); + patchAgencyRow(agency.agencyId, data || { status: "closed", joinEnabled: false }); + return data; }); }; - const submitJoinEnabled = async (event) => { - event.preventDefault(); - await runAction("agency-join", "入会开关已更新", async () => { - const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm); - const { agencyId, ...body } = payload; - const data = await setAgencyJoinEnabled(agencyId, body); - setJoinEnabledForm(emptyJoinEnabledForm()); - setActiveAction(""); - await reload(); + const toggleAgencyJoinEnabledFromList = async (agency, joinEnabled) => { + if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { + return; + } + await runAction(`agency-join-${agency.agencyId}`, "入会开关已更新", async () => { + const data = await setAgencyJoinEnabled(agency.agencyId, { + commandId: makeCommandId("agency-join"), + joinEnabled, + reason: "set agency join enabled from list switch", + }); + patchAgencyRow(agency.agencyId, data || { joinEnabled }); + return data; + }); + }; + + const removeAgencyFromList = async (agency) => { + if (!abilities.canDelete || !agency?.agencyId) { + return; + } + const ok = await confirm({ + confirmText: "删除", + message: `确认删除 Agency ${agency.name || agency.agencyId}?只删除 Agency 身份,不删除主播;下属主播会解除关联。`, + title: "删除 Agency", + tone: "danger", + }); + if (!ok) { + return; + } + + await runAction(`agency-delete-${agency.agencyId}`, "Agency 已删除", async () => { + const data = await deleteAgency(agency.agencyId, { + commandId: makeCommandId("agency-delete"), + reason: "delete agency from list", + }); + setRemovedAgencyIds((current) => { + const next = new Set(current); + next.add(String(agency.agencyId)); + return next; + }); return data; }); }; @@ -163,10 +195,8 @@ export function useHostAgenciesPage() { changeRegionId, changeStatus, closeAgencyFromList, - closeForm, - data, + data: viewData, error, - joinEnabledForm, loading, loadingAction, loadingRegions, @@ -179,16 +209,12 @@ export function useHostAgenciesPage() { resetFilters, closeAction: () => setActiveAction(""), openAgencyForm: () => setActiveAction("agency"), - openCloseForm: () => setActiveAction("agency-close"), - openJoinEnabledForm: () => setActiveAction("agency-join"), + removeAgencyFromList, setAgencyForm, - setCloseForm, - setJoinEnabledForm, setPage, status, submitAgency, - submitClose, - submitJoinEnabled, + toggleAgencyJoinEnabledFromList, }; } diff --git a/src/features/host-org/hooks/useHostBdsPage.js b/src/features/host-org/hooks/useHostBdsPage.js index 5c4df1d..aeb0b2a 100644 --- a/src/features/host-org/hooks/useHostBdsPage.js +++ b/src/features/host-org/hooks/useHostBdsPage.js @@ -22,7 +22,6 @@ const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), positionAlias: "", reason: "", - regionId: "", targetUserId: "", }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx index f527b81..5486702 100644 --- a/src/features/host-org/pages/HostAgenciesPage.jsx +++ b/src/features/host-org/pages/HostAgenciesPage.jsx @@ -1,6 +1,5 @@ import Add from "@mui/icons-material/Add"; -import BlockOutlined from "@mui/icons-material/BlockOutlined"; -import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; import { formatMillis } from "@/shared/utils/time.js"; @@ -11,6 +10,7 @@ import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js"; +import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import styles from "@/features/host-org/host-org.module.css"; @@ -48,40 +48,29 @@ const agencyColumns = [ { key: "joinEnabled", label: "入会", - render: (item) => ( - - {item.joinEnabled ? "允许" : "禁止"} - - ), - width: "minmax(80px, 0.6fr)", + render: (item, _index, context) => , + width: "minmax(90px, 0.65fr)", }, - { key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" }, { key: "updatedAtMs", label: "更新时间", render: (item) => formatMillis(item.updatedAtMs), width: "minmax(170px, 1fr)", }, + { + key: "actions", + label: "操作", + render: (item, _index, context) => , + width: "minmax(80px, 0.45fr)", + }, ]; export function HostAgenciesPage() { const page = useHostAgenciesPage(); const createDisabled = !page.abilities.canCreate; - const statusDisabled = !page.abilities.canStatus; const items = page.data.items || []; const total = page.data.total || 0; const toolbarActions = [ - page.abilities.canStatus - ? { icon: , label: "入会开关", onClick: page.openJoinEnabledForm } - : null, - page.abilities.canStatus - ? { - icon: , - label: "关闭 Agency", - onClick: page.openCloseForm, - tone: "danger", - } - : null, page.abilities.canCreate ? { icon: , label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" } : null, @@ -134,7 +123,7 @@ export function HostAgenciesPage() { columns={columns} context={{ page, regionOptions: page.regionOptions }} items={items} - minWidth="1250px" + minWidth="1180px" pagination={ total > 0 ? { @@ -182,13 +171,6 @@ export function HostAgenciesPage() { value={page.agencyForm.parentBdUserId} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} /> - page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })} - /> page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })} /> - - - - page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value }) - } - /> - - page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked }) - } - /> - - page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value }) - } - /> - - - - page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })} - /> - page.setCloseForm({ ...page.closeForm, reason: event.target.value })} - /> - ); } @@ -320,3 +231,41 @@ function AgencyStatusSwitch({ item, page }) { /> ); } + +function AgencyJoinSwitch({ item, page }) { + const active = item.status === "active"; + return ( + page.toggleAgencyJoinEnabledFromList(item, event.target.checked)} + /> + ); +} + +function AgencyActions({ item, page }) { + return ( + + {page?.abilities.canDelete ? ( + page.removeAgencyFromList(item)} + > + + + ) : null} + + ); +} + +const dangerActionSx = { + borderColor: "var(--danger-border)", + color: "var(--danger)", + "&:hover": { + borderColor: "var(--danger-border-strong)", + backgroundColor: "var(--danger-surface)", + }, +}; diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx index d604a7f..2a6cb1d 100644 --- a/src/features/host-org/pages/HostBdLeadersPage.jsx +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -12,7 +12,6 @@ import { HostOrgActionModal } from "@/features/host-org/components/HostOrgAction import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; -import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js"; import styles from "@/features/host-org/host-org.module.css"; @@ -135,15 +134,6 @@ export function HostBdLeadersPage() { page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value }) } /> - page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })} - /> 未配置; } const resource = resources.find((item) => Number(item.resourceId) === Number(id)); + const preview = resourceImageURL(resource); return ( -
- {resource?.name || resource?.resourceCode || `资源 #${id}`} - {resource ? resourceMetaLabel(resource, id) : `ID ${id}`} +
+ + {preview ? : } + +
+ {resource?.name || resource?.resourceCode || `资源 #${id}`} + {resource ? resourceMetaLabel(resource, id) : `ID ${id}`} +
); } @@ -700,6 +707,18 @@ function resourceMetaLabel(resource, id) { return `ID ${id}`; } +function resourceImageURL(resource) { + return imageURL(resource?.previewUrl) || imageURL(resource?.assetUrl) || imageURL(resource?.animationUrl); +} + +function imageURL(value) { + const url = String(value || "").trim(); + if (!url) { + return ""; + } + return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : ""; +} + function badgeFormFromMetadata(metadataJson) { if (!metadataJson) { return ""; diff --git a/src/features/rooms/hooks/useRoomPinsPage.js b/src/features/rooms/hooks/useRoomPinsPage.js index 6e72318..25aec28 100644 --- a/src/features/rooms/hooks/useRoomPinsPage.js +++ b/src/features/rooms/hooks/useRoomPinsPage.js @@ -10,7 +10,11 @@ import { roomPinCreateSchema } from "@/features/rooms/schema"; const pageSize = 50; const emptyData = { items: [], page: 1, pageSize, total: 0 }; -const emptyForm = () => ({ durationDays: "30", roomId: "", weight: "0" }); +const emptyForm = () => { + const start = new Date(); + const end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000); + return { expiresAtMs: formatDateTimeInputValue(end), pinType: "region", pinnedAtMs: formatDateTimeInputValue(start), roomId: "", weight: "0" }; +}; export function useRoomPinsPage() { const abilities = useRoomAbilities(); @@ -224,3 +228,18 @@ function roomOptionLabel(room) { } return [room.title, room.roomShortId, room.roomId].filter(Boolean).join(" / "); } + +function formatDateTimeInputValue(date) { + const pad = (value) => String(value).padStart(2, "0"); + return [ + date.getFullYear(), + "-", + pad(date.getMonth() + 1), + "-", + pad(date.getDate()), + "T", + pad(date.getHours()), + ":", + pad(date.getMinutes()), + ].join(""); +} diff --git a/src/features/rooms/pages/RoomPinsPage.jsx b/src/features/rooms/pages/RoomPinsPage.jsx index 1de861e..9b9fd87 100644 --- a/src/features/rooms/pages/RoomPinsPage.jsx +++ b/src/features/rooms/pages/RoomPinsPage.jsx @@ -3,6 +3,7 @@ import CloseOutlined from "@mui/icons-material/CloseOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; import Autocomplete from "@mui/material/Autocomplete"; +import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { Button } from "@/shared/ui/Button.jsx"; @@ -24,6 +25,11 @@ const pinStatusOptions = [ { label: "全部", value: "all" }, ]; +const pinTypeOptions = [ + { label: "区域置顶", value: "region" }, + { label: "全区置顶", value: "global" }, +]; + const columns = [ { key: "room", @@ -45,10 +51,22 @@ const columns = [ }, { key: "weight", - label: "权重", + label: "排序", width: "minmax(100px, 0.5fr)", render: (pin) => Number(pin.weight || 0).toLocaleString("zh-CN"), }, + { + key: "pinType", + label: "置顶类型", + width: "minmax(130px, 0.7fr)", + render: (pin) => pinTypeLabel(pin.pinType), + }, + { + key: "pinnedAt", + label: "置顶时间", + width: "minmax(170px, 0.9fr)", + render: (pin) => formatMillis(pin.pinnedAtMs), + }, { key: "remaining", label: "剩余时间", @@ -132,7 +150,7 @@ export function RoomPinsPage() { pin.id} pagination={ total > 0 @@ -172,6 +190,7 @@ export function RoomPinsPage() { error={Boolean(page.roomOptionsError)} helperText={page.roomOptionsError} label="选择房间" + placeholder="输入用户短ID / 房间ID" required /> )} @@ -193,18 +212,40 @@ export function RoomPinsPage() { }} /> page.setForm({ ...page.form, pinType: event.target.value })} + > + {pinTypeOptions.map((option) => ( + + {option.label} + + ))} + + page.setForm({ ...page.form, weight: event.target.value })} /> page.setForm({ ...page.form, durationDays: event.target.value })} + type="datetime-local" + value={page.form.pinnedAtMs} + onChange={(event) => page.setForm({ ...page.form, pinnedAtMs: event.target.value })} + /> + page.setForm({ ...page.form, expiresAtMs: event.target.value })} /> @@ -287,3 +328,7 @@ function formatRemaining(pin) { } return `${Math.max(hours, 1)}小时`; } + +function pinTypeLabel(pinType) { + return pinType === "global" ? "全区置顶" : "区域置顶"; +} diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts index ffaf78b..42562e6 100644 --- a/src/features/rooms/schema.ts +++ b/src/features/rooms/schema.ts @@ -2,6 +2,22 @@ import { z } from "zod"; const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); const roomSeatCandidates = [10, 15, 20, 25, 30] as const; +const pinTypeSchema = z + .string() + .trim() + .refine((value) => value === "region" || value === "global", "请选择置顶类型") + .default("region"); +const dateTimeMsSchema = z.union([z.string(), z.number()]).transform((value, context) => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + const ms = Date.parse(String(value || "").trim()); + if (!Number.isFinite(ms)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "请选择置顶时间" }); + return z.NEVER; + } + return ms; +}); export const roomUpdateSchema = z.object({ coverUrl: optionalText(512, "房间头像不能超过 512 个字符"), @@ -41,11 +57,18 @@ export const roomConfigSchema = z path: ["defaultSeatCount"], }); -export const roomPinCreateSchema = z.object({ - durationDays: z.coerce.number().int().min(1, "请输入置顶天数").max(3650, "置顶时间不能超过 3650 天").default(30), - roomId: z.string().trim().min(1, "请选择房间").max(64, "房间 ID 不正确"), - weight: z.coerce.number().int().min(0, "权重不能小于 0").default(0), -}); +export const roomPinCreateSchema = z + .object({ + expiresAtMs: dateTimeMsSchema, + pinType: pinTypeSchema, + pinnedAtMs: dateTimeMsSchema, + roomId: z.string().trim().min(1, "请选择房间").max(64, "房间 ID 不正确"), + weight: z.coerce.number().int().min(0, "排序不能小于 0").default(0), + }) + .refine((value) => value.expiresAtMs > value.pinnedAtMs, { + message: "结束时间必须晚于开始时间", + path: ["expiresAtMs"], + }); export type RoomUpdateForm = z.infer; export type RoomStatusForm = z.infer; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 9dd34c5..f30e61e 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -56,6 +56,7 @@ export const API_OPERATIONS = { createUser: "createUser", creditCoinSellerStock: "creditCoinSellerStock", dashboardOverview: "dashboardOverview", + deleteAgency: "deleteAgency", deleteAppVersion: "deleteAppVersion", deleteBanner: "deleteBanner", deleteBDLeader: "deleteBDLeader", @@ -521,6 +522,13 @@ export const API_ENDPOINTS: Record = { permission: "overview:view", permissions: ["overview:view"] }, + deleteAgency: { + method: "POST", + operationId: API_OPERATIONS.deleteAgency, + path: "/v1/admin/agencies/{agency_id}/delete", + permission: "agency:delete", + permissions: ["agency:delete"] + }, deleteAppVersion: { method: "DELETE", operationId: API_OPERATIONS.deleteAppVersion, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 555e78b..26f21ee 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -372,6 +372,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/agencies/{agency_id}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["deleteAgency"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/agencies/{agency_id}/join-enabled": { parameters: { query?: never; @@ -3143,6 +3159,20 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + deleteAgency: { + parameters: { + query?: never; + header?: never; + path: { + agency_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; setAgencyJoinEnabled: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 64b7134..2459a51 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -650,6 +650,7 @@ export interface RoomPinDto { createdByAdminId?: number; expiresAtMs?: number; id: EntityId; + pinType?: string; pinnedAtMs?: number; remainingMs?: number; room: RoomPinRoomDto; @@ -661,6 +662,9 @@ export interface RoomPinDto { export interface RoomPinPayload { durationDays?: number; + expiresAtMs?: number; + pinType?: string; + pinnedAtMs?: number; roomId: string; weight: number; } @@ -816,7 +820,6 @@ export interface HostCommandPayload { export interface CreateBDLeaderPayload extends HostCommandPayload { positionAlias?: string; - regionId: number; targetUserId: number; } @@ -844,7 +847,6 @@ export interface CoinSellerStatusPayload extends BDStatusPayload { export interface CreateAgencyPayload extends HostCommandPayload { joinEnabled: boolean; - maxHosts?: number; name: string; ownerUserId: number; parentBdUserId: number;