Compare commits

..

No commits in common. "743e4dc1c8b26277f45b7f7dd26e7d2213da96da" and "ec252cec2c8e09e680c0c7023a68b9401cc1a6a1" have entirely different histories.

10 changed files with 34 additions and 344 deletions

View File

@ -21,7 +21,6 @@ import {
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
updateManager,
} from "./api";
afterEach(() => {
@ -61,8 +60,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
region_id: 7,
status: "active",
});
await createManager({ canBlockUser: false, contact: "+63", targetUserId: "1003" });
await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, contact: "+63" });
await createManager({ contact: "+63", targetUserId: "1003" });
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
await listCoinSellers({
page: 1,
@ -100,16 +98,15 @@ test("host org list APIs use generated admin paths and filters", async () => {
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
const [managerUrl, managerInit] = vi.mocked(fetch).mock.calls[2];
const [managerCreateUrl, managerCreateInit] = vi.mocked(fetch).mock.calls[3];
const [managerUpdateUrl, managerUpdateInit] = vi.mocked(fetch).mock.calls[4];
const [hostUrl] = vi.mocked(fetch).mock.calls[5];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[6];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[7];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[8];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[9];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[10];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[11];
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[12];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[13];
const [hostUrl] = vi.mocked(fetch).mock.calls[4];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[5];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[6];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[8];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[9];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[10];
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[11];
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[12];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -130,18 +127,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(managerInit?.method).toBe("GET");
expect(String(managerCreateUrl)).toContain("/api/v1/admin/managers");
expect(managerCreateInit?.method).toBe("POST");
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
canBlockUser: false,
contact: "+63",
targetUserId: "1003",
});
expect(String(managerUpdateUrl)).toContain("/api/v1/admin/managers/1003");
expect(managerUpdateInit?.method).toBe("PUT");
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
canBlockUser: false,
canGrantVehicle: true,
contact: "+63",
});
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({ contact: "+63", targetUserId: "1003" });
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
expect(String(hostUrl)).toContain("agency_id=41");
expect(String(hostUrl)).toContain("sort_by=diamond");

View File

@ -38,7 +38,6 @@ import type {
SalaryWalletAdjustPayload,
SalaryWalletAdjustResultDto,
SalaryWalletHistoryDto,
UpdateManagerPayload,
} from "@/shared/api/types";
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
@ -187,13 +186,6 @@ export function createManager(payload: CreateManagerPayload): Promise<ManagerDto
});
}
export function updateManager(userId: EntityId, payload: UpdateManagerPayload): Promise<ManagerDto> {
return apiRequest<ManagerDto, UpdateManagerPayload>(`/v1/admin/managers/${encodeURIComponent(String(userId))}`, {
body: payload,
method: "PUT",
});
}
export function listHosts(query: PageQuery = {}): Promise<ApiPage<HostProfileDto>> {
const endpoint = API_ENDPOINTS.listHosts;
return apiRequest<ApiPage<HostProfileDto>>(apiEndpointPath(API_OPERATIONS.listHosts), {
@ -218,13 +210,10 @@ export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<
}
export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise<SalaryWalletAdjustResultDto> {
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>(
"/v1/admin/host-org/salary-wallets/adjust",
{
body: payload,
method: "POST",
},
);
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>("/v1/admin/host-org/salary-wallets/adjust", {
body: payload,
method: "POST",
});
}
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {

View File

@ -2,42 +2,20 @@ import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { createManager, listManagers, updateManager } from "@/features/host-org/api";
import { createManager, listManagers } from "@/features/host-org/api";
import { useBDAbilities } from "@/features/host-org/permissions.js";
import { createManagerSchema } from "@/features/host-org/schema";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export const managerPermissionFields = [
"canGrantAvatarFrame",
"canGrantVehicle",
"canUpdateUserLevel",
"canAddBdLeader",
"canAddAdmin",
"canAddSuperadmin",
"canBlockUser",
];
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermissions() });
const managerPermissionPayload = (form) =>
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
const managerFormFromItem = (item) => ({
contact: item?.contact || "",
targetUserId: String(item?.userId || ""),
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
});
const emptyManagerForm = () => ({ contact: "", targetUserId: "" });
export function useHostManagersPage() {
const abilities = useBDAbilities();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [activeAction, setActiveAction] = useState("");
const [editingManager, setEditingManager] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [managerForm, setManagerForm] = useState(emptyManagerForm);
const [query, setQuery] = useState("");
@ -90,30 +68,16 @@ export function useHostManagersPage() {
const submitManager = async (event) => {
event.preventDefault();
const isEdit = activeAction === "manager-edit" && editingManager?.userId;
setLoadingAction(isEdit ? "manager-update" : "manager-create");
setLoadingAction("manager-create");
try {
const payload = parseForm(createManagerSchema, managerForm);
if (isEdit) {
await updateManager(editingManager.userId, {
contact: payload.contact,
...managerPermissionPayload(payload),
});
showToast("经理权限已更新", "success");
} else {
await createManager({
contact: payload.contact,
targetUserId: payload.targetUserId,
...managerPermissionPayload(payload),
});
showToast("经理已添加", "success");
}
await createManager(payload);
setManagerForm(emptyManagerForm());
setActiveAction("");
setEditingManager(null);
showToast("经理已添加", "success");
await reload();
} catch (err) {
showToast(err.message || (isEdit ? "更新经理权限失败" : "添加经理失败"), "error");
showToast(err.message || "添加经理失败", "error");
} finally {
setLoadingAction("");
}
@ -121,22 +85,9 @@ export function useHostManagersPage() {
const closeAction = () => {
setActiveAction("");
setEditingManager(null);
setManagerForm(emptyManagerForm());
};
const openManagerForm = () => {
setEditingManager(null);
setManagerForm(emptyManagerForm());
setActiveAction("manager");
};
const openManagerEdit = (item) => {
setEditingManager(item);
setManagerForm(managerFormFromItem(item));
setActiveAction("manager-edit");
};
return {
abilities,
activeAction,
@ -145,14 +96,12 @@ export function useHostManagersPage() {
changeStatus,
closeAction,
data,
editingManager,
error,
loadingAction,
loading,
loadingRegions,
managerForm,
openManagerEdit,
openManagerForm,
openManagerForm: () => setActiveAction("manager"),
page,
query,
regionId,

View File

@ -104,69 +104,6 @@
gap: var(--space-2);
}
.permissionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.permissionSwitch {
display: flex;
min-width: 0;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.permissionSwitch span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.permissionList {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.permissionPill {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 5px;
padding: 0 8px;
border: 1px solid var(--success-border);
border-radius: var(--radius-pill);
background: var(--success-surface);
color: var(--success);
font-size: 12px;
font-weight: 720;
line-height: 1;
white-space: nowrap;
}
.permissionPill span {
color: inherit;
font-weight: 780;
}
.permissionPillOff {
border-color: var(--border);
background: var(--bg-input);
color: var(--text-tertiary);
}
.rateDrawerForm {
display: flex;
flex-direction: column;

View File

@ -1,11 +1,7 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { formatMillis } from "@/shared/utils/time.js";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { managerStatusFilters, statusLabels } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
@ -15,16 +11,6 @@ import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPa
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
const managerPermissionOptions = [
{ key: "canGrantAvatarFrame", label: "头像框" },
{ key: "canGrantVehicle", label: "Car" },
{ key: "canUpdateUserLevel", label: "升级等级" },
{ key: "canAddBdLeader", label: "BD Leader" },
{ key: "canAddAdmin", label: "Admin" },
{ key: "canAddSuperadmin", label: "Superadmin" },
{ key: "canBlockUser", label: "Block User" },
];
const managerColumns = [
{
key: "userId",
@ -44,12 +30,6 @@ const managerColumns = [
render: (item) => item.contact || "-",
width: "minmax(150px, 0.85fr)",
},
{
key: "permissions",
label: "权限详情",
render: (item) => <ManagerPermissions item={item} />,
width: "minmax(270px, 1.35fr)",
},
{
key: "status",
label: "状态",
@ -74,19 +54,11 @@ const managerColumns = [
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
{
key: "actions",
label: "操作",
render: (item, _index, context) => <ManagerActions item={item} page={context?.page} />,
resizable: false,
width: "88px",
},
];
export function HostManagersPage() {
const page = useHostManagersPage();
const isEditing = page.activeAction === "manager-edit";
const formDisabled = isEditing ? !page.abilities.canUpdate : !page.abilities.canCreate;
const createDisabled = !page.abilities.canCreate;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
@ -129,7 +101,7 @@ export function HostManagersPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1640px"
minWidth="1240px"
pagination={
total > 0
? {
@ -146,16 +118,16 @@ export function HostManagersPage() {
</DataState>
</div>
<HostOrgActionModal
disabled={formDisabled}
loading={["manager-create", "manager-update"].includes(page.loadingAction)}
open={page.activeAction === "manager" || page.activeAction === "manager-edit"}
disabled={createDisabled}
loading={page.loadingAction === "manager-create"}
open={page.activeAction === "manager"}
sectionTitle="经理信息"
onClose={page.closeAction}
onSubmit={page.submitManager}
title={isEditing ? "编辑经理权限" : "添加经理"}
title="添加经理"
>
<TextField
disabled={formDisabled || isEditing}
disabled={createDisabled}
label="用户短 ID"
required
type="number"
@ -163,12 +135,11 @@ export function HostManagersPage() {
onChange={(event) => page.setManagerForm({ ...page.managerForm, targetUserId: event.target.value })}
/>
<TextField
disabled={formDisabled}
disabled={createDisabled}
label="联系方式"
value={page.managerForm.contact}
onChange={(event) => page.setManagerForm({ ...page.managerForm, contact: event.target.value })}
/>
<PermissionSwitchGrid disabled={formDisabled} form={page.managerForm} onChange={page.setManagerForm} />
</HostOrgActionModal>
</section>
);
@ -193,59 +164,3 @@ function ManagerStatus({ item }) {
</span>
);
}
function ManagerPermissions({ item }) {
return (
<div className={styles.permissionList}>
{managerPermissionOptions.map((permission) => {
const enabled = item?.[permission.key] !== false;
return (
<span
className={
enabled ? styles.permissionPill : `${styles.permissionPill} ${styles.permissionPillOff}`
}
key={permission.key}
>
{permission.label}
<span>{enabled ? "开" : "关"}</span>
</span>
);
})}
</div>
);
}
function ManagerActions({ item, page }) {
const disabled = !page?.abilities?.canUpdate;
return (
<div className={styles.rowActions}>
<Tooltip title={disabled ? "无编辑权限" : "编辑权限"}>
<span>
<IconButton disabled={disabled} label="编辑权限" onClick={() => page.openManagerEdit(item)}>
<EditOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div>
);
}
function PermissionSwitchGrid({ disabled, form, onChange }) {
return (
<div className={styles.permissionGrid}>
{managerPermissionOptions.map((permission) => (
<label className={styles.permissionSwitch} key={permission.key}>
<span>{permission.label}</span>
<AdminSwitch
checked={form?.[permission.key] !== false}
checkedLabel="开"
disabled={disabled}
label={permission.label}
uncheckedLabel="关"
onChange={(event) => onChange({ ...form, [permission.key]: event.target.checked })}
/>
</label>
))}
</div>
);
}

View File

@ -18,10 +18,7 @@ const userIdSchema = z.preprocess(
}
return String(value).trim();
},
z
.string()
.min(1, "请输入有效用户短 ID")
.regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
z.string().min(1, "请输入有效用户短 ID").regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
);
const entityIdSchema = z.preprocess(
(value) => {
@ -109,13 +106,6 @@ export const createCoinSellerSchema = commandContactSchema.extend({
});
export const createManagerSchema = z.object({
canAddAdmin: z.boolean().optional().default(true),
canAddBdLeader: z.boolean().optional().default(true),
canAddSuperadmin: z.boolean().optional().default(true),
canBlockUser: z.boolean().optional().default(true),
canGrantAvatarFrame: z.boolean().optional().default(true),
canGrantVehicle: z.boolean().optional().default(true),
canUpdateUserLevel: z.boolean().optional().default(true),
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
targetUserId: userIdSchema,
});

View File

@ -19,7 +19,7 @@ const probabilityAffectingFields = new Set([
"rtpValueCoins",
]);
export const wheelPools = [
{ key: "classic", label: "Classic", tierCount: 8 },
{ key: "classic", label: "Classic", tierCount: 9 },
{ key: "luxury", label: "Luxury", tierCount: 8 },
{ key: "advanced", label: "Advanced", tierCount: 12 },
];

View File

@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { wheelConfigFormSchema } from "@/features/operations/schema";
import { generateWheelTierProbabilities, wheelPools } from "@/features/operations/hooks/useWheelPage.js";
import { generateWheelTierProbabilities } from "@/features/operations/hooks/useWheelPage.js";
function tier(overrides) {
return {
@ -35,56 +34,6 @@ function expectedPayoutCoins(tiers) {
}
describe("wheel prize probability generation", () => {
test("uses eight prize slots for the classic wheel", () => {
const classicPool = wheelPools.find((pool) => pool.key === "classic");
const tiers = Array.from({ length: 8 }, (_, index) =>
tier({
displayName: `奖品 ${index + 1}`,
rewardCoins: 100,
rewardId: "",
rewardType: "coin",
rtpValueCoins: 100,
tierId: `classic-${index + 1}`,
}),
);
expect(classicPool?.tierCount).toBe(8);
expect(
wheelConfigFormSchema.safeParse({
drawPriceCoins: 100,
effectiveFromMs: 0,
enabled: true,
initialPoolCoins: 0,
maxSingleRTPPayout: 0,
poolRatePercent: 90,
poolReserveCoins: 0,
settlementWindowDraws: 1000,
targetRTPPercent: 90,
tiers: generateWheelTierProbabilities({ drawPriceCoins: 100, targetRTPPercent: 90, tiers }),
wheelId: "classic",
}).success,
).toBe(true);
expect(
wheelConfigFormSchema.safeParse({
drawPriceCoins: 100,
effectiveFromMs: 0,
enabled: true,
initialPoolCoins: 0,
maxSingleRTPPayout: 0,
poolRatePercent: 90,
poolReserveCoins: 0,
settlementWindowDraws: 1000,
targetRTPPercent: 90,
tiers: generateWheelTierProbabilities({
drawPriceCoins: 100,
targetRTPPercent: 90,
tiers: [...tiers, tier({ rewardCoins: 100, rewardId: "", rewardType: "coin", rtpValueCoins: 100, tierId: "classic-9" })],
}),
wheelId: "classic",
}).success,
).toBe(false);
});
test("uses the base RTP to generate coin and gift probabilities while keeping dress and prop random", () => {
const result = generateWheelTierProbabilities({
drawPriceCoins: 10000,

View File

@ -4,7 +4,7 @@ const rewardTypes = ["coin", "gift", "prop", "dress"] as const;
const propRewardTypes = new Set(["prop", "dress"]);
const wheelTierCounts: Record<string, number> = {
advanced: 12,
classic: 8,
classic: 9,
luxury: 8,
};

View File

@ -561,13 +561,6 @@ export interface AgencyDto {
export interface ManagerDto {
avatar?: string;
bdLeaderCount?: number;
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
createdAtMs?: number;
displayUserId?: string;
@ -581,28 +574,10 @@ export interface ManagerDto {
}
export interface CreateManagerPayload {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
targetUserId: string;
}
export interface UpdateManagerPayload {
canAddAdmin?: boolean;
canAddBdLeader?: boolean;
canAddSuperadmin?: boolean;
canBlockUser?: boolean;
canGrantAvatarFrame?: boolean;
canGrantVehicle?: boolean;
canUpdateUserLevel?: boolean;
contact?: string;
}
export interface HostProfileDto {
avatar?: string;
createdAtMs?: number;