火箭,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"]
}
},
"/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",

View File

@ -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) => ({

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);
});
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;
}

View File

@ -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;
}

View File

@ -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",

View File

@ -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 () => {

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> {
const endpoint = API_ENDPOINTS.setAgencyJoinEnabled;
return apiRequest<AgencyDto, AgencyJoinEnabledPayload>(

View File

@ -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,
};
}

View File

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

View File

@ -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) => (
<span className={item.joinEnabled ? styles.positive : styles.negative}>
{item.joinEnabled ? "允许" : "禁止"}
</span>
),
width: "minmax(80px, 0.6fr)",
render: (item, _index, context) => <AgencyJoinSwitch item={item} page={context?.page} />,
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) => <AgencyActions item={item} page={context?.page} />,
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: <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
? { icon: <Add fontSize="small" />, 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 })}
/>
<TextField
disabled={createDisabled}
label="最大主播数"
type="number"
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
<AdminSwitch
checked={page.agencyForm.joinEnabled}
checkedLabel="允许"
@ -208,77 +190,6 @@ export function HostAgenciesPage() {
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
/>
</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>
);
}
@ -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 { 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 })
}
/>
<RegionSelect
disabled={createDisabled}
emptyLabel="选择区域"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={page.bdLeaderForm.regionId}
onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })}
/>
<TextField
disabled={createDisabled}
label="职位别名"

View File

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

View File

@ -70,7 +70,6 @@ const regionBaseSchema = z
export const createBDLeaderSchema = commandContactSchema.extend({
positionAlias: optionalTextSchema(64, "职位别名不能超过 64 个字符"),
regionId: regionIdSchema,
targetUserId: userIdSchema,
});
@ -116,7 +115,6 @@ export const coinSellerStockCreditSchema = z
export const createAgencySchema = commandBaseSchema.extend({
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 个字符"),
ownerUserId: userIdSchema,
parentBdUserId: optionalUserIdSchema,

View File

@ -283,6 +283,33 @@
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 {
display: inline-flex;
min-width: 0;

View File

@ -1,5 +1,6 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
@ -646,10 +647,16 @@ function ResourceLabel({ id, resources }) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
const preview = resourceImageURL(resource);
return (
<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 className={styles.resourceCell}>
<span className={styles.resourceThumb}>
{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>
);
}
@ -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 "";

View File

@ -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("");
}

View File

@ -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() {
<DataTable
columns={tableColumns}
items={items}
minWidth="1240px"
minWidth="1480px"
rowKey={(pin) => 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() {
}}
/>
<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
type="number"
value={page.form.weight}
onChange={(event) => page.setForm({ ...page.form, weight: event.target.value })}
/>
<TextField
label="置顶时间(天)"
InputLabelProps={{ shrink: true }}
label="开始时间"
required
type="number"
value={page.form.durationDays}
onChange={(event) => 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 })}
/>
<TextField
InputLabelProps={{ shrink: true }}
label="结束时间"
required
type="datetime-local"
value={page.form.expiresAtMs}
onChange={(event) => page.setForm({ ...page.form, expiresAtMs: event.target.value })}
/>
</AdminFormDialog>
</section>
@ -287,3 +328,7 @@ function formatRemaining(pin) {
}
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 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<typeof roomUpdateSchema>;
export type RoomStatusForm = z.infer<typeof roomStatusSchema>;

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;

View File

@ -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;