火箭,bi,币商相关

This commit is contained in:
zhx 2026-06-06 00:24:58 +08:00
parent cf062aab5d
commit 945e2e7047
21 changed files with 435 additions and 200 deletions

View File

@ -446,6 +446,28 @@
"x-permissions": ["agency:status"] "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": { "/admin/agencies/{agency_id}/join-enabled": {
"post": { "post": {
"operationId": "setAgencyJoinEnabled", "operationId": "setAgencyJoinEnabled",

View File

@ -7,14 +7,16 @@ import { formatDateTime } from "../utils/time.js";
export function createDashboardModel(overview, { appCode, countryId, range }) { export function createDashboardModel(overview, { appCode, countryId, range }) {
const source = overview || {}; const source = overview || {};
const comparisonCaption = isTodayRange(range) ? "与昨日相比" : "近 7 日"; 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 newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
const activeUsers = readNumber(source, "active_users", "activeUsers"); const activeUsers = readNumber(source, "active_users", "activeUsers");
const paidUsers = readNumber(source, "paid_users", "paidUsers"); 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 newUsers = readNumber(source, "new_users", "newUsers", "visitors");
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor"); const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor"); const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent"); const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin");
const luckyGiftPools = normalizeLuckyGiftPools(source); const luckyGiftPools = normalizeLuckyGiftPools(source);
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools); const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover"); 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, appCode,
businessKpis: [ businessKpis: [
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption), 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("幸运礼物流水", 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("幸运礼物返奖", 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" }), 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: 1, value: newUsers },
{ name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers }, { name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers }, { 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: [ gameMetrics: [
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) }, { caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
@ -145,23 +148,34 @@ function normalizeRevenueSeries(source) {
return []; return [];
} }
if (Array.isArray(source.daily_series) && source.daily_series.length) { if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => ({ return source.daily_series.map((item) => {
coin_seller: numberValue(item.coin_seller ?? item.coinSeller), const coinSeller = 0;
google: numberValue(item.google), const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor);
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"), const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor);
lineTotal: numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? item.total ?? item.recharge_usd_minor), const channelTotal = google + mifapay;
mifapay: numberValue(item.mifapay ?? item.mifa_pay), const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal);
total: numberValue(item.total ?? item.recharge_usd_minor) 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 [ return [
{ {
coin_seller: numberValue(source.coin_seller_recharge_usd_minor), coin_seller: coinSeller,
google: numberValue(source.google_recharge_usd_minor), google,
label: "当前", label: "当前",
lineTotal: numberValue(source.recharge_usd_minor), lineTotal: total,
mifapay: numberValue(source.mifapay_recharge_usd_minor), mifapay,
total: numberValue(source.recharge_usd_minor) total
} }
]; ];
} }
@ -177,7 +191,7 @@ function normalizeCountries(source, countryId) {
x: 56, x: 56,
y: 52 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 return rows
.map((item, index) => ({ .map((item, index) => ({
...normalizeCountryRow(item, index, totalRecharge) ...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 code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`); const country = stripCountryFlagPrefix(item.country || item.country_name || code || `国家 ${index + 1}`);
const meta = resolveCountryMeta({ code, name: country }); const meta = resolveCountryMeta({ code, name: country });
const recharge = numberValue(item.recharge_usd_minor); const recharge = effectiveRechargeUSDMinor(item);
return { return {
active_users: numberValue(item.active_users), active_users: numberValue(item.active_users),
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor), arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
arppu_usd_minor: numberValue(item.arppu_usd_minor), arppu_usd_minor: numberValue(item.arppu_usd_minor),
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor), 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: meta?.label || country,
country_code: code || meta?.code || "", country_code: code || meta?.code || "",
flag: countryFlag(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), payer_rate: ratio(item.paid_users, item.active_users),
recharge_usd_minor: recharge, recharge_usd_minor: recharge,
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate), 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, share: recharge / totalRecharge,
trend_rate: numberValue(item.trend_rate ?? item.trendRate), trend_rate: numberValue(item.trend_rate ?? item.trendRate),
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers) 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) { function normalizeGiftRanking(source) {
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) { if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
return source.gift_ranking.map((item) => ({ return source.gift_ranking.map((item) => ({

View File

@ -22,6 +22,37 @@ test("uses lucky gift pool breakdown to aggregate business metrics", () => {
expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3); 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) { function metricValue(model, label) {
return model.businessKpis.find((item) => item.label === label)?.value; return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
} }

View File

@ -636,18 +636,24 @@
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72); box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
} }
.metric-grid, .metric-grid {
.business-metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.business-metric-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 16px;
}
.business-metric-grid .metric-card { .business-metric-grid .metric-card {
height: var(--business-metric-row-height); height: var(--business-metric-row-height);
grid-column: span 1;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: stretch; align-items: stretch;
padding: 10px 16px; padding: 10px 14px;
} }
.business-metric-grid .metric-content { .business-metric-grid .metric-content {
@ -693,7 +699,7 @@
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
margin-top: 3px; margin-top: 3px;
font-size: clamp(18px, 1vw, 20px); font-size: clamp(17px, 0.92vw, 20px);
line-height: 1.25; line-height: 1.25;
} }

View File

@ -29,6 +29,7 @@ export const PERMISSIONS = {
agencyView: "agency:view", agencyView: "agency:view",
agencyCreate: "agency:create", agencyCreate: "agency:create",
agencyStatus: "agency:status", agencyStatus: "agency:status",
agencyDelete: "agency:delete",
bdView: "bd:view", bdView: "bd:view",
bdCreate: "bd:create", bdCreate: "bd:create",
bdUpdate: "bd:update", bdUpdate: "bd:update",

View File

@ -6,6 +6,7 @@ import {
createCoinSeller, createCoinSeller,
createRegion, createRegion,
creditCoinSellerStock, creditCoinSellerStock,
deleteAgency,
disableRegion, disableRegion,
listAgencies, listAgencies,
listBDs, listBDs,
@ -39,7 +40,6 @@ test("host org list APIs use generated admin paths and filters", async () => {
await createBDLeader({ await createBDLeader({
commandId: "bd-leader-test", commandId: "bd-leader-test",
positionAlias: "Senior Admin", positionAlias: "Senior Admin",
regionId: 7,
targetUserId: 1002, targetUserId: 1002,
}); });
await updateBDLeaderPositionAlias(1002, { await updateBDLeaderPositionAlias(1002, {
@ -54,6 +54,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
rechargeAmount: "100.000000", rechargeAmount: "100.000000",
type: "usdt_purchase", type: "usdt_purchase",
}); });
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0]; const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
const [agencyUrl] = vi.mocked(fetch).mock.calls[1]; 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 [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7]; const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8]; 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("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21"); 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(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
expect(bdLeaderCreateInit?.method).toBe("POST"); expect(bdLeaderCreateInit?.method).toBe("POST");
expect(JSON.parse(String(bdLeaderCreateInit?.body))).toMatchObject({ positionAlias: "Senior Admin" }); 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(String(bdLeaderAliasUrl)).toContain("/api/v1/admin/bd-leaders/1002/position-alias");
expect(bdLeaderAliasInit?.method).toBe("PATCH"); expect(bdLeaderAliasInit?.method).toBe("PATCH");
expect(JSON.parse(String(bdLeaderAliasInit?.body))).toMatchObject({ positionAlias: "Regional Admin" }); 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(coinSellerStatusInit?.method).toBe("PATCH");
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits"); expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
expect(coinSellerStockInit?.method).toBe("POST"); 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 () => { test("country and region APIs use generated admin paths", async () => {

View File

@ -309,6 +309,17 @@ export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Pr
); );
} }
export function deleteAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.deleteAgency;
return apiRequest<AgencyDto, HostCommandPayload>(
apiEndpointPath(API_OPERATIONS.deleteAgency, { agency_id: agencyId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> { export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.setAgencyJoinEnabled; const endpoint = API_ENDPOINTS.setAgencyJoinEnabled;
return apiRequest<AgencyDto, AgencyJoinEnabledPayload>( return apiRequest<AgencyDto, AgencyJoinEnabledPayload>(

View File

@ -1,33 +1,27 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation"; import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.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 { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useAgencyAbilities } from "@/features/host-org/permissions.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 = () => ({ const emptyAgencyForm = () => ({
commandId: makeCommandId("agency"), commandId: makeCommandId("agency"),
joinEnabled: true, joinEnabled: true,
maxHosts: 0,
name: "", name: "",
ownerUserId: "", ownerUserId: "",
parentBdUserId: "", parentBdUserId: "",
reason: "", reason: "",
}); });
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
const emptyJoinEnabledForm = () => ({
agencyId: "",
commandId: makeCommandId("agency-join"),
joinEnabled: true,
reason: "",
});
const pageSize = 50; const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostAgenciesPage() { export function useHostAgenciesPage() {
const abilities = useAgencyAbilities(); const abilities = useAgencyAbilities();
const confirm = useConfirm();
const { showToast } = useToast(); const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions(); const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -36,8 +30,8 @@ export function useHostAgenciesPage() {
const [parentBdUserId, setParentBdUserId] = useState(""); const [parentBdUserId, setParentBdUserId] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm); const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
const [closeForm, setCloseForm] = useState(emptyCloseForm); const [rowPatches, setRowPatches] = useState({});
const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm); const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set());
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const filters = useMemo( const filters = useMemo(
@ -62,6 +56,26 @@ export function useHostAgenciesPage() {
pageSize, pageSize,
queryKey: ["host-org", "agencies", filters, page], 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) => { const changeQuery = (value) => {
setQuery(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) => { const closeAgencyFromList = async (agency) => {
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") { if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
return; return;
} }
await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => { await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => {
await closeAgency(agency.agencyId, { const data = await closeAgency(agency.agencyId, {
commandId: makeCommandId("agency-close"), commandId: makeCommandId("agency-close"),
reason: "close agency from list switch", reason: "close agency from list switch",
}); });
await reload(); patchAgencyRow(agency.agencyId, data || { status: "closed", joinEnabled: false });
return data;
}); });
}; };
const submitJoinEnabled = async (event) => { const toggleAgencyJoinEnabledFromList = async (agency, joinEnabled) => {
event.preventDefault(); if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
await runAction("agency-join", "入会开关已更新", async () => { return;
const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm); }
const { agencyId, ...body } = payload; await runAction(`agency-join-${agency.agencyId}`, "入会开关已更新", async () => {
const data = await setAgencyJoinEnabled(agencyId, body); const data = await setAgencyJoinEnabled(agency.agencyId, {
setJoinEnabledForm(emptyJoinEnabledForm()); commandId: makeCommandId("agency-join"),
setActiveAction(""); joinEnabled,
await reload(); 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; return data;
}); });
}; };
@ -163,10 +195,8 @@ export function useHostAgenciesPage() {
changeRegionId, changeRegionId,
changeStatus, changeStatus,
closeAgencyFromList, closeAgencyFromList,
closeForm, data: viewData,
data,
error, error,
joinEnabledForm,
loading, loading,
loadingAction, loadingAction,
loadingRegions, loadingRegions,
@ -179,16 +209,12 @@ export function useHostAgenciesPage() {
resetFilters, resetFilters,
closeAction: () => setActiveAction(""), closeAction: () => setActiveAction(""),
openAgencyForm: () => setActiveAction("agency"), openAgencyForm: () => setActiveAction("agency"),
openCloseForm: () => setActiveAction("agency-close"), removeAgencyFromList,
openJoinEnabledForm: () => setActiveAction("agency-join"),
setAgencyForm, setAgencyForm,
setCloseForm,
setJoinEnabledForm,
setPage, setPage,
status, status,
submitAgency, submitAgency,
submitClose, toggleAgencyJoinEnabledFromList,
submitJoinEnabled,
}; };
} }

View File

@ -22,7 +22,6 @@ const emptyBDLeaderForm = () => ({
commandId: makeCommandId("bd-leader"), commandId: makeCommandId("bd-leader"),
positionAlias: "", positionAlias: "",
reason: "", reason: "",
regionId: "",
targetUserId: "", targetUserId: "",
}); });
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" }); const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });

View File

@ -1,6 +1,5 @@
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import BlockOutlined from "@mui/icons-material/BlockOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js"; 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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx"; import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js"; 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 { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css"; import styles from "@/features/host-org/host-org.module.css";
@ -48,40 +48,29 @@ const agencyColumns = [
{ {
key: "joinEnabled", key: "joinEnabled",
label: "入会", label: "入会",
render: (item) => ( render: (item, _index, context) => <AgencyJoinSwitch item={item} page={context?.page} />,
<span className={item.joinEnabled ? styles.positive : styles.negative}> width: "minmax(90px, 0.65fr)",
{item.joinEnabled ? "允许" : "禁止"}
</span>
),
width: "minmax(80px, 0.6fr)",
}, },
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
{ {
key: "updatedAtMs", key: "updatedAtMs",
label: "更新时间", label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs), render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)", width: "minmax(170px, 1fr)",
}, },
{
key: "actions",
label: "操作",
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
width: "minmax(80px, 0.45fr)",
},
]; ];
export function HostAgenciesPage() { export function HostAgenciesPage() {
const page = useHostAgenciesPage(); const page = useHostAgenciesPage();
const createDisabled = !page.abilities.canCreate; const createDisabled = !page.abilities.canCreate;
const statusDisabled = !page.abilities.canStatus;
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const toolbarActions = [ const toolbarActions = [
page.abilities.canStatus
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
: null,
page.abilities.canStatus
? {
icon: <BlockOutlined fontSize="small" />,
label: "关闭 Agency",
onClick: page.openCloseForm,
tone: "danger",
}
: null,
page.abilities.canCreate page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" } ? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
: null, : null,
@ -134,7 +123,7 @@ export function HostAgenciesPage() {
columns={columns} columns={columns}
context={{ page, regionOptions: page.regionOptions }} context={{ page, regionOptions: page.regionOptions }}
items={items} items={items}
minWidth="1250px" minWidth="1180px"
pagination={ pagination={
total > 0 total > 0
? { ? {
@ -182,13 +171,6 @@ export function HostAgenciesPage() {
value={page.agencyForm.parentBdUserId} value={page.agencyForm.parentBdUserId}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })}
/> />
<TextField
disabled={createDisabled}
label="最大主播数"
type="number"
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
<AdminSwitch <AdminSwitch
checked={page.agencyForm.joinEnabled} checked={page.agencyForm.joinEnabled}
checkedLabel="允许" checkedLabel="允许"
@ -208,77 +190,6 @@ export function HostAgenciesPage() {
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })} onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
/> />
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-join"}
open={page.activeAction === "agency-join"}
sectionTitle="入会设置"
onClose={page.closeAction}
onSubmit={page.submitJoinEnabled}
title="入会开关"
>
<TextField
disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.joinEnabledForm.agencyId}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
}
/>
<AdminSwitch
checked={page.joinEnabledForm.joinEnabled}
checkedLabel="允许"
disabled={statusDisabled}
label="入会状态"
uncheckedLabel="禁止"
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
/>
<TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
value={page.joinEnabledForm.reason}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })
}
/>
</HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-close"}
open={page.activeAction === "agency-close"}
sectionTitle="关闭信息"
onClose={page.closeAction}
onSubmit={page.submitClose}
submitLabel="确认关闭"
submitVariant="danger"
title="关闭 Agency"
>
<TextField
disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.closeForm.agencyId}
onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })}
/>
<TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
required
value={page.closeForm.reason}
onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })}
/>
</HostOrgActionModal>
</section> </section>
); );
} }
@ -320,3 +231,41 @@ function AgencyStatusSwitch({ item, page }) {
/> />
); );
} }
function AgencyJoinSwitch({ item, page }) {
const active = item.status === "active";
return (
<AdminSwitch
checked={Boolean(item.joinEnabled)}
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `agency-join-${item.agencyId}`}
inputProps={{ "aria-label": item.joinEnabled ? "禁止入会" : "允许入会" }}
onChange={(event) => page.toggleAgencyJoinEnabledFromList(item, event.target.checked)}
/>
);
}
function AgencyActions({ item, page }) {
return (
<AdminRowActions>
{page?.abilities.canDelete ? (
<AdminActionIconButton
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
label="删除 Agency"
sx={dangerActionSx}
onClick={() => page.removeAgencyFromList(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
const dangerActionSx = {
borderColor: "var(--danger-border)",
color: "var(--danger)",
"&:hover": {
borderColor: "var(--danger-border-strong)",
backgroundColor: "var(--danger-surface)",
},
};

View File

@ -12,7 +12,6 @@ import { HostOrgActionModal } from "@/features/host-org/components/HostOrgAction
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx"; import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.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 { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
import styles from "@/features/host-org/host-org.module.css"; 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, targetUserId: event.target.value })
} }
/> />
<RegionSelect
disabled={createDisabled}
emptyLabel="选择区域"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={page.bdLeaderForm.regionId}
onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })}
/>
<TextField <TextField
disabled={createDisabled} disabled={createDisabled}
label="职位别名" label="职位别名"

View File

@ -28,6 +28,7 @@ export function useAgencyAbilities() {
return { return {
canCreate: can(PERMISSIONS.agencyCreate), canCreate: can(PERMISSIONS.agencyCreate),
canDelete: can(PERMISSIONS.agencyDelete),
canStatus: can(PERMISSIONS.agencyStatus), canStatus: can(PERMISSIONS.agencyStatus),
canView: can(PERMISSIONS.agencyView), canView: can(PERMISSIONS.agencyView),
}; };

View File

@ -70,7 +70,6 @@ const regionBaseSchema = z
export const createBDLeaderSchema = commandContactSchema.extend({ export const createBDLeaderSchema = commandContactSchema.extend({
positionAlias: optionalTextSchema(64, "职位别名不能超过 64 个字符"), positionAlias: optionalTextSchema(64, "职位别名不能超过 64 个字符"),
regionId: regionIdSchema,
targetUserId: userIdSchema, targetUserId: userIdSchema,
}); });
@ -116,7 +115,6 @@ export const coinSellerStockCreditSchema = z
export const createAgencySchema = commandBaseSchema.extend({ export const createAgencySchema = commandBaseSchema.extend({
joinEnabled: z.boolean().default(true), joinEnabled: z.boolean().default(true),
maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0),
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
ownerUserId: userIdSchema, ownerUserId: userIdSchema,
parentBdUserId: optionalUserIdSchema, parentBdUserId: optionalUserIdSchema,

View File

@ -283,6 +283,33 @@
white-space: nowrap; white-space: nowrap;
} }
.resourceCell {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.resourceThumb {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.resourceThumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.statusCell { .statusCell {
display: inline-flex; display: inline-flex;
min-width: 0; min-width: 0;

View File

@ -1,5 +1,6 @@
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
@ -646,10 +647,16 @@ function ResourceLabel({ id, resources }) {
return <span className={styles.emptyValue}>未配置</span>; return <span className={styles.emptyValue}>未配置</span>;
} }
const resource = resources.find((item) => Number(item.resourceId) === Number(id)); const resource = resources.find((item) => Number(item.resourceId) === Number(id));
const preview = resourceImageURL(resource);
return ( return (
<div className={styles.stack}> <div className={styles.resourceCell}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span> <span className={styles.resourceThumb}>
<span className={styles.meta}>{resource ? resourceMetaLabel(resource, id) : `ID ${id}`}</span> {preview ? <img alt="" src={preview} /> : <Inventory2Outlined fontSize="small" />}
</span>
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>{resource ? resourceMetaLabel(resource, id) : `ID ${id}`}</span>
</div>
</div> </div>
); );
} }
@ -700,6 +707,18 @@ function resourceMetaLabel(resource, id) {
return `ID ${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) { function badgeFormFromMetadata(metadataJson) {
if (!metadataJson) { if (!metadataJson) {
return ""; return "";

View File

@ -10,7 +10,11 @@ import { roomPinCreateSchema } from "@/features/rooms/schema";
const pageSize = 50; const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 }; 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() { export function useRoomPinsPage() {
const abilities = useRoomAbilities(); const abilities = useRoomAbilities();
@ -224,3 +228,18 @@ function roomOptionLabel(room) {
} }
return [room.title, room.roomShortId, room.roomId].filter(Boolean).join(" / "); 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("");
}

View File

@ -3,6 +3,7 @@ import CloseOutlined from "@mui/icons-material/CloseOutlined";
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined"; import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined"; import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
@ -24,6 +25,11 @@ const pinStatusOptions = [
{ label: "全部", value: "all" }, { label: "全部", value: "all" },
]; ];
const pinTypeOptions = [
{ label: "区域置顶", value: "region" },
{ label: "全区置顶", value: "global" },
];
const columns = [ const columns = [
{ {
key: "room", key: "room",
@ -45,10 +51,22 @@ const columns = [
}, },
{ {
key: "weight", key: "weight",
label: "权重", label: "排序",
width: "minmax(100px, 0.5fr)", width: "minmax(100px, 0.5fr)",
render: (pin) => Number(pin.weight || 0).toLocaleString("zh-CN"), 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", key: "remaining",
label: "剩余时间", label: "剩余时间",
@ -132,7 +150,7 @@ export function RoomPinsPage() {
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
items={items} items={items}
minWidth="1240px" minWidth="1480px"
rowKey={(pin) => pin.id} rowKey={(pin) => pin.id}
pagination={ pagination={
total > 0 total > 0
@ -172,6 +190,7 @@ export function RoomPinsPage() {
error={Boolean(page.roomOptionsError)} error={Boolean(page.roomOptionsError)}
helperText={page.roomOptionsError} helperText={page.roomOptionsError}
label="选择房间" label="选择房间"
placeholder="输入用户短ID / 房间ID"
required required
/> />
)} )}
@ -193,18 +212,40 @@ export function RoomPinsPage() {
}} }}
/> />
<TextField <TextField
label="权重" label="置顶类型"
required
select
value={page.form.pinType}
onChange={(event) => page.setForm({ ...page.form, pinType: event.target.value })}
>
{pinTypeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
label="排序"
required required
type="number" type="number"
value={page.form.weight} value={page.form.weight}
onChange={(event) => page.setForm({ ...page.form, weight: event.target.value })} onChange={(event) => page.setForm({ ...page.form, weight: event.target.value })}
/> />
<TextField <TextField
label="置顶时间(天)" InputLabelProps={{ shrink: true }}
label="开始时间"
required required
type="number" type="datetime-local"
value={page.form.durationDays} value={page.form.pinnedAtMs}
onChange={(event) => page.setForm({ ...page.form, durationDays: event.target.value })} onChange={(event) => page.setForm({ ...page.form, pinnedAtMs: event.target.value })}
/>
<TextField
InputLabelProps={{ shrink: true }}
label="结束时间"
required
type="datetime-local"
value={page.form.expiresAtMs}
onChange={(event) => page.setForm({ ...page.form, expiresAtMs: event.target.value })}
/> />
</AdminFormDialog> </AdminFormDialog>
</section> </section>
@ -287,3 +328,7 @@ function formatRemaining(pin) {
} }
return `${Math.max(hours, 1)}小时`; return `${Math.max(hours, 1)}小时`;
} }
function pinTypeLabel(pinType) {
return pinType === "global" ? "全区置顶" : "区域置顶";
}

View File

@ -2,6 +2,22 @@ import { z } from "zod";
const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default(""); const optionalText = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
const roomSeatCandidates = [10, 15, 20, 25, 30] as const; 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({ export const roomUpdateSchema = z.object({
coverUrl: optionalText(512, "房间头像不能超过 512 个字符"), coverUrl: optionalText(512, "房间头像不能超过 512 个字符"),
@ -41,11 +57,18 @@ export const roomConfigSchema = z
path: ["defaultSeatCount"], path: ["defaultSeatCount"],
}); });
export const roomPinCreateSchema = z.object({ export const roomPinCreateSchema = z
durationDays: z.coerce.number().int().min(1, "请输入置顶天数").max(3650, "置顶时间不能超过 3650 天").default(30), .object({
roomId: z.string().trim().min(1, "请选择房间").max(64, "房间 ID 不正确"), expiresAtMs: dateTimeMsSchema,
weight: z.coerce.number().int().min(0, "权重不能小于 0").default(0), 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<typeof roomUpdateSchema>; export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;
export type RoomStatusForm = z.infer<typeof roomStatusSchema>; export type RoomStatusForm = z.infer<typeof roomStatusSchema>;

View File

@ -56,6 +56,7 @@ export const API_OPERATIONS = {
createUser: "createUser", createUser: "createUser",
creditCoinSellerStock: "creditCoinSellerStock", creditCoinSellerStock: "creditCoinSellerStock",
dashboardOverview: "dashboardOverview", dashboardOverview: "dashboardOverview",
deleteAgency: "deleteAgency",
deleteAppVersion: "deleteAppVersion", deleteAppVersion: "deleteAppVersion",
deleteBanner: "deleteBanner", deleteBanner: "deleteBanner",
deleteBDLeader: "deleteBDLeader", deleteBDLeader: "deleteBDLeader",
@ -521,6 +522,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "overview:view", permission: "overview:view",
permissions: ["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: { deleteAppVersion: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteAppVersion, operationId: API_OPERATIONS.deleteAppVersion,

View File

@ -372,6 +372,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/agencies/{agency_id}/join-enabled": {
parameters: { parameters: {
query?: never; query?: never;
@ -3143,6 +3159,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
deleteAgency: {
parameters: {
query?: never;
header?: never;
path: {
agency_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
setAgencyJoinEnabled: { setAgencyJoinEnabled: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -650,6 +650,7 @@ export interface RoomPinDto {
createdByAdminId?: number; createdByAdminId?: number;
expiresAtMs?: number; expiresAtMs?: number;
id: EntityId; id: EntityId;
pinType?: string;
pinnedAtMs?: number; pinnedAtMs?: number;
remainingMs?: number; remainingMs?: number;
room: RoomPinRoomDto; room: RoomPinRoomDto;
@ -661,6 +662,9 @@ export interface RoomPinDto {
export interface RoomPinPayload { export interface RoomPinPayload {
durationDays?: number; durationDays?: number;
expiresAtMs?: number;
pinType?: string;
pinnedAtMs?: number;
roomId: string; roomId: string;
weight: number; weight: number;
} }
@ -816,7 +820,6 @@ export interface HostCommandPayload {
export interface CreateBDLeaderPayload extends HostCommandPayload { export interface CreateBDLeaderPayload extends HostCommandPayload {
positionAlias?: string; positionAlias?: string;
regionId: number;
targetUserId: number; targetUserId: number;
} }
@ -844,7 +847,6 @@ export interface CoinSellerStatusPayload extends BDStatusPayload {
export interface CreateAgencyPayload extends HostCommandPayload { export interface CreateAgencyPayload extends HostCommandPayload {
joinEnabled: boolean; joinEnabled: boolean;
maxHosts?: number;
name: string; name: string;
ownerUserId: number; ownerUserId: number;
parentBdUserId: number; parentBdUserId: number;