Compare commits

..

No commits in common. "0be921409d5f603a751b331e142d663d75800bb8" and "f5d06e1e52aa047b2564bb19218f538d99808f5a" have entirely different histories.

3 changed files with 132 additions and 286 deletions

View File

@ -9,7 +9,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions"; import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { deleteDiceRobot, generateDiceRobots, listDiceRobots, setDiceRobotStatus } from "@/features/games/api"; import { deleteDiceRobot, generateDiceRobots, listDiceRobots, setDiceRobotStatus } from "@/features/games/api";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
import styles from "@/features/games/games.module.css"; import styles from "@/features/games/games.module.css";
import { import {
AdminActionIconButton, AdminActionIconButton,
@ -29,7 +28,6 @@ import { formatMillis } from "@/shared/utils/time.js";
const defaultGenerateForm = { const defaultGenerateForm = {
count: 20, count: 20,
country: "SA",
nicknameLanguage: "arabic", nicknameLanguage: "arabic",
}; };
const maxGenerateRobotCount = 1000; const maxGenerateRobotCount = 1000;
@ -41,7 +39,6 @@ export function GameRobotPage() {
const canCreate = can(PERMISSIONS.gameCreate); const canCreate = can(PERMISSIONS.gameCreate);
const canUpdate = can(PERMISSIONS.gameUpdate); const canUpdate = can(PERMISSIONS.gameUpdate);
const canDelete = can(PERMISSIONS.gameDelete) || canUpdate; const canDelete = can(PERMISSIONS.gameDelete) || canUpdate;
const { countryOptions, loadingCountries } = useCountryOptions();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [nextCursor, setNextCursor] = useState(""); const [nextCursor, setNextCursor] = useState("");
@ -70,21 +67,6 @@ export function GameRobotPage() {
load("", false); load("", false);
}, [load]); }, [load]);
useEffect(() => {
setGenerateForm((current) => {
if (countryOptions.length === 0) {
return current;
}
const currentCountryEnabled = countryOptions.some((option) => option.value === current.country);
if (current.country && currentCountryEnabled) {
return current;
}
// 沿 SA user-service
const defaultCountry = countryOptions.find((option) => option.value === "SA")?.value || countryOptions[0].value;
return { ...current, country: defaultCountry };
});
}, [countryOptions]);
const changeStatus = useCallback( const changeStatus = useCallback(
async (robot, checked) => { async (robot, checked) => {
setLoadingAction(`status-${robot.userId}`); setLoadingAction(`status-${robot.userId}`);
@ -196,11 +178,7 @@ export function GameRobotPage() {
event.preventDefault(); event.preventDefault();
setLoadingAction("generate"); setLoadingAction("generate");
try { try {
const enabledCountry = countryOptions.some((option) => option.value === generateForm.country)
? generateForm.country
: countryOptions[0]?.value || generateForm.country || "SA";
const result = await generateDiceRobots({ const result = await generateDiceRobots({
country: enabledCountry,
count: Number(generateForm.count || 0), count: Number(generateForm.count || 0),
nicknameLanguage: generateForm.nicknameLanguage || "arabic", nicknameLanguage: generateForm.nicknameLanguage || "arabic",
}); });
@ -266,8 +244,6 @@ export function GameRobotPage() {
</AdminListBody> </AdminListBody>
<GenerateDialog <GenerateDialog
form={generateForm} form={generateForm}
countryOptions={countryOptions}
loadingCountries={loadingCountries}
loading={loadingAction === "generate"} loading={loadingAction === "generate"}
open={dialogOpen} open={dialogOpen}
setForm={setGenerateForm} setForm={setGenerateForm}
@ -278,11 +254,7 @@ export function GameRobotPage() {
); );
} }
function GenerateDialog({ countryOptions, form, loading, loadingCountries, open, setForm, onClose, onSubmit }) { function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) {
const selectCountryOptions = countryOptions.length > 0 ? countryOptions : [{ label: "SA", value: "SA" }];
const selectedCountry = selectCountryOptions.some((country) => country.value === form.country)
? form.country
: selectCountryOptions[0]?.value || "SA";
return ( return (
<AdminFormDialog <AdminFormDialog
loading={loading} loading={loading}
@ -310,19 +282,6 @@ function GenerateDialog({ countryOptions, form, loading, loadingCountries, open,
<MenuItem value="arabic">阿拉伯语账号</MenuItem> <MenuItem value="arabic">阿拉伯语账号</MenuItem>
<MenuItem value="english">英语账号</MenuItem> <MenuItem value="english">英语账号</MenuItem>
</TextField> </TextField>
<TextField
disabled={loadingCountries}
label="国家"
select
value={selectedCountry}
onChange={(event) => setForm({ ...form, country: event.target.value })}
>
{selectCountryOptions.map((country) => (
<MenuItem key={country.value} value={country.value}>
{country.label}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid> </AdminFormFieldGrid>
</AdminFormDialog> </AdminFormDialog>
); );

View File

@ -7,6 +7,7 @@ import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js"; import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
import { ThirdPartyPaymentContent } from "@/features/payment/pages/ThirdPartyPaymentPage.jsx";
import styles from "@/features/payment/payment.module.css"; import styles from "@/features/payment/payment.module.css";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { import {
@ -50,25 +51,10 @@ const audienceOptions = [
["coin_seller", "币商"], ["coin_seller", "币商"],
]; ];
const productTabConfigs = {
google: {
createLabel: "新增 Google 商品",
dialogEditTitle: "编辑 Google 商品",
dialogCreateTitle: "新增 Google 商品",
platform: "android",
},
third_party: {
createLabel: "新增三方商品",
dialogEditTitle: "编辑三方商品",
dialogCreateTitle: "新增三方商品",
platform: "web",
},
};
export function RechargeProductConfigPage() { export function RechargeProductConfigPage() {
const page = useRechargeProductsPage(); const page = useRechargeProductsPage();
const [activeProductTab, setActiveProductTab] = useState("google"); const [activeProductTab, setActiveProductTab] = useState("google");
const activeProductConfig = productTabConfigs[activeProductTab] || productTabConfigs.google; const googleProductPlatform = "android";
const { platform, setPlatform } = page; const { platform, setPlatform } = page;
const items = page.data?.items || []; const items = page.data?.items || [];
const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]); const regionLabels = useMemo(() => regionLabelMap(page.regionOptions), [page.regionOptions]);
@ -78,15 +64,14 @@ export function RechargeProductConfigPage() {
const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct; const canSubmit = page.editingItem ? page.abilities.canUpdateProduct : page.abilities.canCreateProduct;
const openCreate = () => { const openCreate = () => {
page.openCreate(); page.openCreate();
page.setForm({ platform: activeProductConfig.platform }); page.setForm({ platform: googleProductPlatform });
}; };
useEffect(() => { useEffect(() => {
// platform Google / tab if (activeProductTab === "google" && platform !== googleProductPlatform) {
if (platform !== activeProductConfig.platform) { setPlatform(googleProductPlatform);
setPlatform(activeProductConfig.platform);
} }
}, [activeProductConfig.platform, platform, setPlatform]); }, [activeProductTab, googleProductPlatform, platform, setPlatform]);
return ( return (
<AdminListPage> <AdminListPage>
@ -96,127 +81,140 @@ export function RechargeProductConfigPage() {
<Tab label="三方" value="third_party" /> <Tab label="三方" value="third_party" />
</Tabs> </Tabs>
</div> </div>
<AdminListToolbar {activeProductTab === "google" ? (
actions={ <>
page.abilities.canCreateProduct ? ( <AdminListToolbar
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={openCreate}> actions={
{activeProductConfig.createLabel} page.abilities.canCreateProduct ? (
</Button> <Button
) : null startIcon={<AddOutlined fontSize="small" />}
} variant="primary"
/> onClick={openCreate}
<DataState error={page.error} loading={page.loading} onRetry={page.reload}> >
<AdminListBody> 新增 Google 商品
<DataTable </Button>
columns={columns} ) : null
items={items} }
minWidth="1180px"
pagination={{
page: page.page,
pageSize: page.pageSize,
total: page.data?.total || 0,
onPageChange: page.setPage,
}}
rowKey={(item) => item.productId}
/> />
</AdminListBody> <DataState error={page.error} loading={page.loading} onRetry={page.reload}>
</DataState> <AdminListBody>
<AdminFormDialog <DataTable
loading={saving} columns={columns}
open={Boolean(page.activeAction)} items={items}
size="compact" minWidth="1180px"
submitDisabled={!canSubmit || saving} pagination={{
title={page.editingItem ? activeProductConfig.dialogEditTitle : activeProductConfig.dialogCreateTitle} page: page.page,
onClose={page.closeDialog} pageSize: page.pageSize,
onSubmit={page.submitProduct} total: page.data?.total || 0,
> onPageChange: page.setPage,
<AdminFormSection title="商品信息"> }}
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> rowKey={(item) => item.productId}
<TextField />
</AdminListBody>
</DataState>
</>
) : (
<ThirdPartyPaymentContent embedded />
)}
{activeProductTab === "google" ? (
<AdminFormDialog
loading={saving}
open={Boolean(page.activeAction)}
size="compact"
submitDisabled={!canSubmit || saving}
title={page.editingItem ? "编辑 Google 商品" : "新增 Google 商品"}
onClose={page.closeDialog}
onSubmit={page.submitProduct}
>
<AdminFormSection title="商品信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!canSubmit}
label="产品名称"
required
value={page.form.productName}
onChange={(event) => page.setForm({ productName: event.target.value })}
/>
<TextField
disabled
label="平台"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="web">Web</MenuItem>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
disabled={!canSubmit}
label="用户类型"
required
select
value={page.form.audienceType}
onChange={(event) => page.setForm({ audienceType: event.target.value })}
>
<MenuItem value="normal">普通用户</MenuItem>
<MenuItem value="coin_seller">币商</MenuItem>
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormAmountField
disabled={!canSubmit}
inputProps={{ inputMode: "decimal" }}
label="金额USDT"
required
unit="USDT"
value={page.form.amountUsdt}
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
/>
<TextField
disabled={!canSubmit}
inputProps={{ min: 1, step: 1 }}
label="获得金币数"
required
type="number"
value={page.form.coinAmount}
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放设置">
<MultiValueAutocomplete
disabled={!canSubmit} disabled={!canSubmit}
label="产品名称" label="支持区域"
labels={regionLabels}
loading={page.loadingRegions}
options={regionValues}
required required
value={page.form.productName} value={page.form.regionIds}
onChange={(event) => page.setForm({ productName: event.target.value })} onChange={(regionIds) => page.setForm({ regionIds })}
/> />
<TextField <AdminFormSwitchField
disabled checked={Boolean(page.form.enabled)}
label="平台" checkedLabel="上架"
required
select
value={page.form.platform}
onChange={(event) => page.setForm({ platform: event.target.value })}
>
<MenuItem value="web">Web</MenuItem>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField
disabled={!canSubmit} disabled={!canSubmit}
label="用户类型" label="是否上架"
required uncheckedLabel="下架"
select onChange={(checked) => page.setForm({ enabled: checked })}
value={page.form.audienceType}
onChange={(event) => page.setForm({ audienceType: event.target.value })}
>
<MenuItem value="normal">普通用户</MenuItem>
<MenuItem value="coin_seller">币商</MenuItem>
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormAmountField
disabled={!canSubmit}
inputProps={{ inputMode: "decimal" }}
label="金额USDT"
required
unit="USDT"
value={page.form.amountUsdt}
onChange={(event) => page.setForm({ amountUsdt: event.target.value })}
/> />
</AdminFormSection>
<AdminFormSection title="描述">
<TextField <TextField
disabled={!canSubmit} disabled={!canSubmit}
inputProps={{ min: 1, step: 1 }} label="描述"
label="获得金币数" multiline
required minRows={2}
type="number" value={page.form.description}
value={page.form.coinAmount} onChange={(event) => page.setForm({ description: event.target.value })}
onChange={(event) => page.setForm({ coinAmount: event.target.value })}
/> />
</AdminFormFieldGrid> </AdminFormSection>
</AdminFormSection> </AdminFormDialog>
<AdminFormSection title="投放设置"> ) : null}
<MultiValueAutocomplete
disabled={!canSubmit}
label="支持区域"
labels={regionLabels}
loading={page.loadingRegions}
options={regionValues}
required
value={page.form.regionIds}
onChange={(regionIds) => page.setForm({ regionIds })}
/>
<AdminFormSwitchField
checked={Boolean(page.form.enabled)}
checkedLabel="上架"
disabled={!canSubmit}
label="是否上架"
uncheckedLabel="下架"
onChange={(checked) => page.setForm({ enabled: checked })}
/>
</AdminFormSection>
<AdminFormSection title="描述">
<TextField
disabled={!canSubmit}
label="描述"
multiline
minRows={2}
value={page.form.description}
onChange={(event) => page.setForm({ description: event.target.value })}
/>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage> </AdminListPage>
); );
} }

View File

@ -1,111 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { RechargeProductConfigPage } from "@/features/payment/pages/RechargeProductConfigPage.jsx";
import { useRechargeProductsPage } from "@/features/payment/hooks/useRechargeProductsPage.js";
vi.mock("@/features/payment/hooks/useRechargeProductsPage.js", () => ({
useRechargeProductsPage: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("recharge product page defaults to google product list", () => {
vi.mocked(useRechargeProductsPage).mockReturnValue(pageFixture());
render(<RechargeProductConfigPage />);
expect(screen.getByRole("tab", { name: "Google", selected: true })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "新增 Google 商品" })).toBeInTheDocument();
expect(screen.getAllByText("coins_480000").length).toBeGreaterThan(0);
expect(screen.queryByText("支付汇率全球同步")).not.toBeInTheDocument();
});
test("third-party tab reuses product dialog and locks create platform to web", () => {
const page = pageFixture();
vi.mocked(useRechargeProductsPage).mockReturnValue(page);
render(<RechargeProductConfigPage />);
fireEvent.click(screen.getByRole("tab", { name: "三方" }));
expect(screen.getByRole("tab", { name: "三方", selected: true })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "新增三方商品" })).toBeInTheDocument();
expect(screen.queryByText("支付汇率全球同步")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "新增三方商品" }));
expect(page.openCreate).toHaveBeenCalledTimes(1);
expect(page.setForm).toHaveBeenCalledWith({ platform: "web" });
});
function pageFixture() {
return {
abilities: {
canCreateProduct: true,
canDeleteProduct: true,
canUpdateProduct: true,
},
activeAction: "",
audienceType: "",
closeDialog: vi.fn(),
data: {
items: [
{
amountUsdt: "5.99",
audienceType: "normal",
coinAmount: 480000,
description: "480,000 coins",
enabled: true,
platform: "android",
productCode: "coins_480000",
productId: 1,
productName: "coins_480000",
regionIds: [1, 2],
updatedAtMs: 1760000000000,
},
],
total: 1,
},
editingItem: null,
error: null,
form: {
amountUsdt: "",
audienceType: "normal",
coinAmount: "",
description: "",
enabled: true,
platform: "android",
productName: "",
regionIds: [],
},
keyword: "",
loading: false,
loadingAction: "",
loadingRegions: false,
openCreate: vi.fn(),
openEdit: vi.fn(),
page: 1,
pageSize: 50,
platform: "android",
regionId: "",
regionOptions: [
{ label: "中东 · ME · 1", name: "中东", value: "1" },
{ label: "欧洲美区 · EU · 2", name: "欧洲美区", value: "2" },
],
reload: vi.fn(),
removeProduct: vi.fn(),
resetFilters: vi.fn(),
setAudienceType: vi.fn(),
setForm: vi.fn(),
setKeyword: vi.fn(),
setPage: vi.fn(),
setPlatform: vi.fn(),
setRegionId: vi.fn(),
setStatus: vi.fn(),
status: "",
submitProduct: vi.fn(),
toggleProductEnabled: vi.fn(),
};
}