-
;
+export function HostOrgTable({ columns, regionFilter, ...props }) {
+ const tableColumns = regionFilter
+ ? columns.map((column) => (column.key === "regionId" ? { ...column, filter: regionFilter } : column))
+ : columns;
+
+ return
;
}
diff --git a/src/features/host-org/hooks/useHostRegionsPage.js b/src/features/host-org/hooks/useHostRegionsPage.js
index d43c577..7b21f45 100644
--- a/src/features/host-org/hooks/useHostRegionsPage.js
+++ b/src/features/host-org/hooks/useHostRegionsPage.js
@@ -21,23 +21,23 @@ const emptyRegionForm = () => ({
sortOrder: 0
});
const emptyData = { items: [], total: 0 };
+const globalRegionCode = "GLOBAL";
export function useHostRegionsPage() {
const abilities = useRegionAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
- const [regionForm, setRegionForm] = useState(emptyRegionForm);
+ const [regionForm, setRegionFormState] = useState(emptyRegionForm);
const [editingRegion, setEditingRegion] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
- const filters = useMemo(() => ({ status }), [status]);
- const queryFn = useCallback(() => listRegions(filters), [filters]);
+ const queryFn = useCallback(() => listRegions(), []);
const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, {
errorMessage: "加载区域列表失败",
initialData: emptyData,
keepPreviousData: true,
- queryKey: ["host-org", "regions", filters]
+ queryKey: ["host-org", "regions"]
});
const countryQueryFn = useCallback(() => listCountries(), []);
const { data: countriesData = emptyData, loading: loadingCountries } = useAdminQuery(countryQueryFn, {
@@ -46,10 +46,23 @@ export function useHostRegionsPage() {
queryKey: ["host-org", "countries", "region-options"],
staleTime: 5 * 60 * 1000
});
- const items = useMemo(() => filterRegions(data?.items || [], query), [data?.items, query]);
+ const items = useMemo(() => filterRegions(data?.items || [], query, status), [data?.items, query, status]);
+ const assignedCountries = useMemo(() => {
+ const currentRegionId = editingRegion?.regionId;
+ const assigned = new Set();
+ (data?.items || []).forEach((region) => {
+ if (region.status !== "active" || region.regionId === currentRegionId || isGlobalRegionCode(region.regionCode)) {
+ return;
+ }
+ (region.countries || []).forEach((country) => assigned.add(country));
+ });
+ return assigned;
+ }, [data?.items, editingRegion?.regionId]);
const countryOptions = useMemo(() => {
- return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))].sort();
- }, [countriesData?.items]);
+ return [...new Set((countriesData?.items || []).map((country) => country.countryCode).filter(Boolean))]
+ .filter((countryCode) => !assignedCountries.has(countryCode))
+ .sort();
+ }, [assignedCountries, countriesData?.items]);
const countryLabels = useMemo(() => {
return (countriesData?.items || []).reduce((labels, country) => {
if (country.countryCode) {
@@ -58,6 +71,12 @@ export function useHostRegionsPage() {
return labels;
}, {});
}, [countriesData?.items]);
+ const isGlobalRegionForm = isGlobalRegionCode(regionForm.regionCode);
+
+ const setRegionForm = (nextForm) => {
+ const normalized = isGlobalRegionCode(nextForm.regionCode) ? { ...nextForm, countries: [] } : nextForm;
+ setRegionFormState(normalized);
+ };
const changeQuery = (value) => {
setQuery(value);
@@ -69,13 +88,13 @@ export function useHostRegionsPage() {
const openCreateRegion = () => {
setEditingRegion(null);
- setRegionForm(emptyRegionForm());
+ setRegionFormState(emptyRegionForm());
setActiveAction("create");
};
const openEditRegion = (region) => {
setEditingRegion(region);
- setRegionForm({
+ setRegionFormState({
countries: region.countries || [],
name: region.name || "",
regionCode: region.regionCode || "",
@@ -90,7 +109,7 @@ export function useHostRegionsPage() {
await runAction(isEdit ? "region-edit" : "region-create", isEdit ? "区域已更新" : "区域已创建", async () => {
const payload = parseForm(isEdit ? regionUpdateSchema : regionCreateSchema, regionForm);
const data = isEdit ? await updateExistingRegion(editingRegion, payload) : await createRegion(payload);
- setRegionForm(emptyRegionForm());
+ setRegionFormState(emptyRegionForm());
setEditingRegion(null);
setActiveAction("");
await reload();
@@ -99,7 +118,7 @@ export function useHostRegionsPage() {
};
const toggleRegion = async (region, nextActive = region.status !== "active") => {
- if (!abilities.canStatus || (region.status === "active") === nextActive) {
+ if (isGlobalRegionCode(region.regionCode) || !abilities.canStatus || (region.status === "active") === nextActive) {
return;
}
await runAction(`region-status-${region.regionId}`, nextActive ? "区域已启用" : "区域已停用", async () => {
@@ -135,6 +154,7 @@ export function useHostRegionsPage() {
loading,
loadingAction,
loadingCountries,
+ isGlobalRegionForm,
openCreateRegion,
openEditRegion,
query,
@@ -162,14 +182,21 @@ function hasCountryChanges(previousCountries, nextCountries) {
return previous.length !== next.length || previous.some((country, index) => country !== next[index]);
}
-function filterRegions(items, query) {
+function filterRegions(items, query, status) {
const keyword = query.trim().toLowerCase();
- if (!keyword) {
- return items;
- }
return items.filter((item) => {
+ if (status && item.status !== status) {
+ return false;
+ }
+ if (!keyword) {
+ return true;
+ }
return [item.regionCode, item.name, ...(item.countries || [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword));
});
}
+
+function isGlobalRegionCode(regionCode) {
+ return String(regionCode || "").trim().toUpperCase() === globalRegionCode;
+}
diff --git a/src/features/host-org/host-org.module.css b/src/features/host-org/host-org.module.css
index 25afe30..d601920 100644
--- a/src/features/host-org/host-org.module.css
+++ b/src/features/host-org/host-org.module.css
@@ -99,27 +99,6 @@
gap: var(--space-2);
}
-.countryList {
- display: flex;
- min-width: 0;
- align-items: center;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-
-.countryBadge {
- display: inline-flex;
- height: var(--status-height);
- align-items: center;
- border: 1px solid var(--border);
- border-radius: var(--radius-pill);
- background: var(--bg-input);
- color: var(--text-secondary);
- font-size: var(--admin-font-size);
- font-weight: 650;
- padding: 0 var(--space-2);
-}
-
.flagCell {
font-size: var(--admin-font-size);
}
diff --git a/src/features/host-org/pages/HostAgenciesPage.jsx b/src/features/host-org/pages/HostAgenciesPage.jsx
index d33f8fd..78ff52d 100644
--- a/src/features/host-org/pages/HostAgenciesPage.jsx
+++ b/src/features/host-org/pages/HostAgenciesPage.jsx
@@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { agencyStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@@ -81,6 +82,12 @@ export function HostAgenciesPage() {
item.agencyId}
/>
{total > 0 ? : null}
diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx
index c82276d..2938116 100644
--- a/src/features/host-org/pages/HostBdLeadersPage.jsx
+++ b/src/features/host-org/pages/HostBdLeadersPage.jsx
@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@@ -62,6 +63,12 @@ export function HostBdLeadersPage() {
`bd-leader-${item.userId}`}
/>
{total > 0 ? : null}
diff --git a/src/features/host-org/pages/HostBdsPage.jsx b/src/features/host-org/pages/HostBdsPage.jsx
index 4b9a680..cdbc2b4 100644
--- a/src/features/host-org/pages/HostBdsPage.jsx
+++ b/src/features/host-org/pages/HostBdsPage.jsx
@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { bdStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@@ -70,6 +71,12 @@ export function HostBdsPage() {
`bd-${item.userId}`}
/>
{total > 0 ? : null}
diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx
index f111219..8d8522f 100644
--- a/src/features/host-org/pages/HostCoinSellersPage.jsx
+++ b/src/features/host-org/pages/HostCoinSellersPage.jsx
@@ -8,6 +8,7 @@ import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
+import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
@@ -78,7 +79,19 @@ export function HostCoinSellersPage() {
-
item.userId} />
+ item.userId}
+ />
{total > 0 ? : null}
diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx
index 8abadbe..8785801 100644
--- a/src/features/host-org/pages/HostHostsPage.jsx
+++ b/src/features/host-org/pages/HostHostsPage.jsx
@@ -1,6 +1,7 @@
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
@@ -55,6 +56,12 @@ export function HostHostsPage() {
item.userId}
/>
{total > 0 ? : null}
diff --git a/src/features/host-org/pages/HostRegionsPage.jsx b/src/features/host-org/pages/HostRegionsPage.jsx
index 8a2e8b6..57db3cc 100644
--- a/src/features/host-org/pages/HostRegionsPage.jsx
+++ b/src/features/host-org/pages/HostRegionsPage.jsx
@@ -1,12 +1,12 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
-import Autocomplete from "@mui/material/Autocomplete";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
+import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import { regionStatusFilters } from "@/features/host-org/constants.js";
@@ -106,7 +106,8 @@ export function HostRegionsPage() {
page.setRegionForm({ ...page.regionForm, name: event.target.value })} />
page.setRegionForm({ ...page.regionForm, sortOrder: event.target.value })} />
{page.abilities.canUpdate ? (
@@ -137,44 +141,42 @@ function RegionActions({ item, page }) {
function RegionStatusSwitch({ item, page }) {
const checked = item.status === "active";
+ const isGlobal = item.regionCode === "GLOBAL";
return (
page.toggleRegion(item, event.target.checked)}
/>
);
}
-function CountryCodeSelect({ disabled, labels, loading, onChange, options, value }) {
+function CountryCodeSelect({ disabled, label, labels, loading, onChange, options, value }) {
const selected = value || [];
const mergedOptions = [...new Set([...options, ...selected])].sort();
return (
- labels[option] || option}
+ label={label}
+ labels={labels}
loading={loading}
options={mergedOptions}
- renderInput={(params) => }
- size="small"
value={selected}
- onChange={(_, countries) => onChange(countries)}
+ onChange={onChange}
/>
);
}
function CountryCodes({ countries = [] }) {
- if (!countries.length) {
+ const items = Array.isArray(countries) ? countries : [];
+ if (!items.length) {
return "-";
}
return (
-
- {countries.map((country) => (
-
+
+ {items.map((country) => (
+
{country}
))}
diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts
index cce29b9..31d20f5 100644
--- a/src/features/host-org/schema.ts
+++ b/src/features/host-org/schema.ts
@@ -19,15 +19,41 @@ const countryCodesSchema = z.array(z.string()).transform((values, ctx) => {
const countries = values
.map((item) => item.trim().toUpperCase())
.filter(Boolean);
- if (!countries.length) {
- ctx.addIssue({ code: "custom", message: "请选择国家码" });
- return countries;
- }
if (new Set(countries).size !== countries.length) {
ctx.addIssue({ code: "custom", message: "国家码不能重复" });
}
return countries;
});
+const retiredRegionCodes = new Set([
+ "AUSTRALIA AND NEW ZEALAND",
+ "CARIBBEAN",
+ "MELANESIA",
+ "MICRONESIA",
+ "POLYNESIA",
+ "SOUTHERN AFRICA",
+ "UNSPECIFIED"
+]);
+const requiredCountryCodesSchema = countryCodesSchema.superRefine((countries, ctx) => {
+ if (!countries.length) {
+ ctx.addIssue({ code: "custom", message: "请选择国家码" });
+ }
+});
+const regionBaseSchema = z.object({
+ countries: countryCodesSchema,
+ name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
+ regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
+ sortOrder: sortOrderSchema
+}).superRefine((value, ctx) => {
+ if (retiredRegionCodes.has(value.regionCode)) {
+ ctx.addIssue({ code: "custom", message: "该区域已移除", path: ["regionCode"] });
+ }
+ if (value.regionCode !== "GLOBAL" && !value.countries.length) {
+ ctx.addIssue({ code: "custom", message: "请选择国家码", path: ["countries"] });
+ }
+ if (value.regionCode === "GLOBAL" && value.countries.length) {
+ ctx.addIssue({ code: "custom", message: "GLOBAL 区域不需要选择国家", path: ["countries"] });
+ }
+});
export const createBDLeaderSchema = commandContactSchema.extend({
regionId: regionIdSchema,
@@ -88,22 +114,12 @@ export const countryUpdateSchema = countryCreateSchema.omit({
enabled: true
});
-export const regionCreateSchema = z.object({
- countries: countryCodesSchema,
- name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
- regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
- sortOrder: sortOrderSchema
-});
+export const regionCreateSchema = regionBaseSchema;
-export const regionUpdateSchema = z.object({
- countries: countryCodesSchema,
- name: z.string().trim().min(1, "请输入区域名称").max(80, "区域名称不能超过 80 个字符"),
- regionCode: codeSchema("区域编码").transform((value) => value.toUpperCase()),
- sortOrder: sortOrderSchema
-});
+export const regionUpdateSchema = regionBaseSchema;
export const regionCountriesSchema = z.object({
- countries: countryCodesSchema
+ countries: requiredCountryCodesSchema
});
export type CreateBDLeaderForm = z.infer
;
diff --git a/src/features/payment/api.ts b/src/features/payment/api.ts
new file mode 100644
index 0000000..506bdee
--- /dev/null
+++ b/src/features/payment/api.ts
@@ -0,0 +1,11 @@
+import { apiRequest } from "@/shared/api/request";
+import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
+import type { ApiPage, PageQuery, RechargeBillDto } from "@/shared/api/types";
+
+export function listRechargeBills(query: PageQuery = {}): Promise> {
+ const endpoint = API_ENDPOINTS.listRechargeBills;
+ return apiRequest>(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
+ method: endpoint.method,
+ query
+ });
+}
diff --git a/src/features/payment/pages/PaymentBillListPage.jsx b/src/features/payment/pages/PaymentBillListPage.jsx
new file mode 100644
index 0000000..a6daab9
--- /dev/null
+++ b/src/features/payment/pages/PaymentBillListPage.jsx
@@ -0,0 +1,208 @@
+import TextField from "@mui/material/TextField";
+import { useMemo, useState } from "react";
+import { listRechargeBills } from "@/features/payment/api";
+import {
+ AdminFilterSelect,
+ AdminListBody,
+ AdminListPage,
+ AdminListToolbar,
+ AdminSearchBox,
+ adminListClasses
+} from "@/shared/ui/AdminListLayout.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { DataTable } from "@/shared/ui/DataTable.jsx";
+import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
+import { formatMillis } from "@/shared/utils/time.js";
+
+const pageSize = 20;
+const statusOptions = [
+ ["", "全部状态"],
+ ["succeeded", "成功"]
+];
+const rechargeTypeOptions = [
+ ["", "全部途径"],
+ ["coin_seller_transfer", "币商充值"]
+];
+
+const columns = [
+ {
+ key: "bill",
+ label: "账单",
+ width: "minmax(240px, 1.4fr)",
+ render: (bill) => (
+
+ )
+ },
+ {
+ key: "users",
+ label: "用户 / 来源",
+ width: "minmax(180px, 0.9fr)",
+ render: (bill) => (
+
+ )
+ },
+ {
+ key: "amount",
+ label: "金币 / 金额",
+ width: "minmax(150px, 0.75fr)",
+ render: (bill) => (
+
+ )
+ },
+ {
+ key: "policy",
+ label: "兑换口径",
+ width: "minmax(170px, 0.9fr)",
+ render: (bill) => (
+
+ )
+ },
+ {
+ key: "region",
+ label: "区域",
+ width: "minmax(110px, 0.55fr)",
+ render: (bill) =>
+ },
+ {
+ key: "state",
+ label: "途径 / 状态",
+ width: "minmax(150px, 0.75fr)",
+ render: (bill) => (
+ } />
+ )
+ },
+ {
+ key: "time",
+ label: "创建时间",
+ width: "minmax(160px, 0.8fr)",
+ render: (bill) => formatMillis(bill.createdAtMs)
+ }
+];
+
+export function PaymentBillListPage() {
+ const [page, setPage] = useState(1);
+ const [keyword, setKeyword] = useState("");
+ const [status, setStatus] = useState("");
+ const [rechargeType, setRechargeType] = useState("");
+ const [userId, setUserId] = useState("");
+ const [sellerUserId, setSellerUserId] = useState("");
+
+ const filters = useMemo(
+ () => ({
+ keyword,
+ recharge_type: rechargeType,
+ seller_user_id: sellerUserId,
+ status,
+ user_id: userId
+ }),
+ [keyword, rechargeType, sellerUserId, status, userId]
+ );
+ const query = usePaginatedQuery({
+ errorMessage: "获取账单列表失败",
+ fetcher: listRechargeBills,
+ filters,
+ page,
+ pageSize,
+ queryKey: ["payment-bills", filters, page]
+ });
+ const data = query.data || { items: [], total: 0, page, pageSize };
+ const items = data.items || [];
+ const total = data.total || 0;
+
+ const resetPage = (setter) => (value) => {
+ setter(value);
+ setPage(1);
+ };
+
+ return (
+
+
+
+ resetPage(setUserId)(digitsOnly(event.target.value))}
+ />
+ resetPage(setSellerUserId)(digitsOnly(event.target.value))}
+ />
+
+
+ >
+ }
+ />
+
+
+ bill.transactionId} />
+ {total > 0 ? : null}
+
+
+
+ );
+}
+
+function Stack({ primary, secondary }) {
+ return (
+
+ {primary || "-"}
+ {secondary || "-"}
+
+ );
+}
+
+function StatusBadge({ status }) {
+ const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
+ return (
+
+
+ {statusLabel(status)}
+
+ );
+}
+
+function rechargeTypeLabel(value) {
+ return rechargeTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
+}
+
+function statusLabel(value) {
+ if (value === "succeeded") {
+ return "成功";
+ }
+ if (value === "failed") {
+ return "失败";
+ }
+ return value || "-";
+}
+
+function formatNumber(value) {
+ if (value === 0 || value) {
+ return Number(value).toLocaleString("zh-CN");
+ }
+ return "-";
+}
+
+function formatMoney(value, currency = "USD") {
+ if (!(value === 0 || value)) {
+ return "-";
+ }
+ return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
+ maximumFractionDigits: 2,
+ minimumFractionDigits: 2
+ })}`;
+}
+
+function digitsOnly(value) {
+ return value.replace(/\D/g, "");
+}
diff --git a/src/features/payment/routes.js b/src/features/payment/routes.js
new file mode 100644
index 0000000..5e3e57e
--- /dev/null
+++ b/src/features/payment/routes.js
@@ -0,0 +1,12 @@
+import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
+
+export const paymentRoutes = [
+ {
+ label: "账单列表",
+ loader: () => import("./pages/PaymentBillListPage.jsx").then((module) => module.PaymentBillListPage),
+ menuCode: MENU_CODES.paymentBillList,
+ pageKey: "payment-bill-list",
+ path: "/payment/bills",
+ permission: PERMISSIONS.paymentBillView
+ }
+];
diff --git a/src/features/resources/api.test.ts b/src/features/resources/api.test.ts
index ff286f7..0684a1e 100644
--- a/src/features/resources/api.test.ts
+++ b/src/features/resources/api.test.ts
@@ -56,10 +56,15 @@ test("resource APIs use generated admin paths", async () => {
});
await listGifts({ page: 1, page_size: 10, region_id: 1001 });
await createGift({
+ chargeAssetType: "COIN",
coinPrice: 10,
effectiveAtMs: 1777766400000,
+ effectiveFromMs: 0,
+ effectiveToMs: 0,
+ effectTypes: ["animation"],
giftId: "rose",
giftPointAmount: 1,
+ giftTypeCode: "normal",
heatValue: 1,
name: "Rose",
presentationJson: "{}",
@@ -103,10 +108,15 @@ test("resource APIs use generated admin paths", async () => {
await enableResourceGroup(22);
await disableResourceGroup(22);
await updateGift("rose", {
+ chargeAssetType: "COIN",
coinPrice: 10,
effectiveAtMs: 1777766400000,
+ effectiveFromMs: 0,
+ effectiveToMs: 0,
+ effectTypes: ["animation"],
giftId: "rose",
giftPointAmount: 1,
+ giftTypeCode: "normal",
heatValue: 1,
name: "Rose",
presentationJson: "{}",
diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts
index 840c2a7..992b8dc 100644
--- a/src/features/resources/api.ts
+++ b/src/features/resources/api.ts
@@ -69,9 +69,14 @@ export interface GiftDto {
sortOrder?: number;
presentationJson?: string;
priceVersion?: string;
+ giftTypeCode?: string;
+ chargeAssetType?: string;
coinPrice?: number;
giftPointAmount?: number;
heatValue?: number;
+ effectiveFromMs?: number;
+ effectiveToMs?: number;
+ effectTypes?: string[];
createdAtMs?: number;
updatedAtMs?: number;
regionIds?: number[];
@@ -85,10 +90,15 @@ export interface GiftPayload {
sortOrder: number;
presentationJson: string;
priceVersion: string;
+ giftTypeCode: string;
+ chargeAssetType: string;
coinPrice: number;
giftPointAmount: number;
heatValue: number;
effectiveAtMs: number;
+ effectiveFromMs: number;
+ effectiveToMs: number;
+ effectTypes: string[];
regionIds: number[];
}
diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js
index 13081b2..aefce33 100644
--- a/src/features/resources/hooks/useResourcePages.js
+++ b/src/features/resources/hooks/useResourcePages.js
@@ -68,10 +68,15 @@ const emptyGroupWalletAssetItem = (walletAssetType) => ({
walletAssetType,
});
const emptyGiftForm = (gift = {}) => ({
+ chargeAssetType: gift.chargeAssetType || "COIN",
coinPrice: gift.coinPrice === 0 || gift.coinPrice ? String(gift.coinPrice) : "",
+ effectTypes: gift.effectTypes || [],
+ effectiveFrom: msToDatetimeLocal(gift.effectiveFromMs),
+ effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
enabled: gift.status ? gift.status === "active" : true,
giftId: gift.giftId || "",
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
+ giftTypeCode: gift.giftTypeCode || "normal",
heatValue: gift.heatValue === 0 || gift.heatValue ? String(gift.heatValue) : "0",
name: gift.name || "",
presentationJson: gift.presentationJson || "{}",
@@ -727,10 +732,15 @@ function buildResourceGroupPayload(form) {
function buildGiftPayload(form) {
return {
+ chargeAssetType: form.chargeAssetType,
coinPrice: Number(form.coinPrice),
effectiveAtMs: Date.now(),
+ effectiveFromMs: datetimeLocalToMs(form.effectiveFrom),
+ effectiveToMs: datetimeLocalToMs(form.effectiveTo),
+ effectTypes: form.effectTypes || [],
giftId: form.giftId.trim(),
giftPointAmount: Number(form.giftPointAmount || 0),
+ giftTypeCode: form.giftTypeCode,
heatValue: Number(form.heatValue || 0),
name: form.name.trim(),
presentationJson: form.presentationJson?.trim() || "{}",
@@ -742,6 +752,27 @@ function buildGiftPayload(form) {
};
}
+function msToDatetimeLocal(value) {
+ if (!value) {
+ return "";
+ }
+ const date = new Date(Number(value));
+ if (Number.isNaN(date.getTime())) {
+ return "";
+ }
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
+ return local.toISOString().slice(0, 16);
+}
+
+function datetimeLocalToMs(value) {
+ const trimmed = String(value || "").trim();
+ if (!trimmed) {
+ return 0;
+ }
+ const parsed = new Date(trimmed).getTime();
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
function buildGrantPayload(form) {
const targetUserId = Number(form.targetUserId);
const reason = form.reason.trim();
diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx
index 66f2702..56e2670 100644
--- a/src/features/resources/pages/GiftListPage.jsx
+++ b/src/features/resources/pages/GiftListPage.jsx
@@ -1,14 +1,23 @@
import Add from "@mui/icons-material/Add";
+import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
+import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
+import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
+import IconButton from "@mui/material/IconButton";
+import InputAdornment from "@mui/material/InputAdornment";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
+import Popover from "@mui/material/Popover";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
-import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
+import { useMemo, useState } from "react";
+import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
+import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
-import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
+import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
+import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
import {
AdminActionIconButton,
AdminFilterSelect,
@@ -24,6 +33,30 @@ import { resourceStatusFilters } from "@/features/resources/constants.js";
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
+const giftTypeOptions = [
+ ["normal", "普通礼物"],
+ ["cp", "CP礼物"],
+ ["lucky", "幸运礼物"],
+ ["super_lucky", "超级幸运礼物"],
+ ["exclusive", "专属礼物"],
+ ["noble", "贵族礼物"],
+ ["flag", "国旗礼物"],
+ ["activity", "活动礼物"],
+ ["magic", "魔法礼物"],
+ ["custom", "定制礼物"]
+];
+const chargeAssetOptions = [
+ ["COIN", "金币"],
+ ["DIAMOND", "钻石"]
+];
+const effectOptions = [
+ ["animation", "动画"],
+ ["music", "音乐"],
+ ["global_broadcast", "全局广播"]
+];
+const effectLabels = Object.fromEntries(effectOptions);
+const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
+
const baseColumns = [
{
key: "gift",
@@ -39,9 +72,14 @@ const baseColumns = [
},
{
key: "price",
- label: "价格",
- width: "minmax(120px, 0.65fr)",
- render: (gift) => formatNumber(gift.coinPrice)
+ label: "类型 / 价格",
+ width: "minmax(150px, 0.75fr)",
+ render: (gift) => (
+
+ {giftTypeLabel(gift.giftTypeCode)}
+ {chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
+
+ )
},
{
key: "income",
@@ -57,7 +95,18 @@ const baseColumns = [
{
key: "regions",
label: "区域",
- width: "minmax(180px, 0.95fr)"
+ width: "minmax(160px, 0.8fr)"
+ },
+ {
+ key: "effects",
+ label: "有效期 / 特效",
+ width: "minmax(210px, 1.1fr)",
+ render: (gift) => (
+
+ {effectiveRangeLabel(gift)}
+ {effectTypesLabel(gift.effectTypes)}
+
+ )
},
{
key: "status",
@@ -88,6 +137,12 @@ export function GiftListPage() {
if (column.key === "regions") {
return {
...column,
+ filter: createRegionColumnFilter({
+ loading: page.loadingRegions,
+ options: giftRegionOptions,
+ value: page.regionId,
+ onChange: page.changeRegionId
+ }),
render: (gift) =>
};
}
@@ -113,7 +168,7 @@ export function GiftListPage() {
<>
-
- gift.giftId} />
+ gift.giftId} />
{total > 0 ? (
- page.setForm({ ...form, name: event.target.value })}
- />
- page.setForm({ ...form, giftId: event.target.value })}
- />
- page.setForm({ ...form, resourceId: event.target.value })}
- >
- {page.resourceOptions.map((resource) => (
-
- ))}
-
- page.setForm({ ...form, coinPrice: event.target.value })}
- />
- page.setForm({ ...form, giftPointAmount: event.target.value })}
- />
- page.setForm({ ...form, heatValue: event.target.value })}
- />
- page.setForm({ ...form, sortOrder: event.target.value })}
- />
- page.setForm({ ...form, priceVersion: event.target.value })}
- />
- page.setForm({ ...form, regionIds })}
- />
- page.setForm({ ...form, presentationJson: event.target.value })}
- />
- page.setForm({ ...form, enabled: event.target.checked })}
- />
- }
- label={form.enabled ? "启用" : "禁用"}
- />
+
+ page.setForm({ ...form, name: event.target.value })}
+ />
+ page.setForm({ ...form, giftId: event.target.value })}
+ />
+ page.setForm({ ...form, giftTypeCode: event.target.value })}
+ >
+ {giftTypeOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setForm({ ...form, resourceId: event.target.value })}
+ >
+ {page.resourceOptions.map((resource) => (
+
+ ))}
+
+ page.setForm({ ...form, chargeAssetType: event.target.value })}
+ >
+ {chargeAssetOptions.map(([value, label]) => (
+
+ ))}
+
+ page.setForm({ ...form, coinPrice: event.target.value })}
+ />
+ page.setForm({ ...form, giftPointAmount: event.target.value })}
+ />
+ page.setForm({ ...form, heatValue: event.target.value })}
+ />
+ page.setForm({ ...form, sortOrder: event.target.value })}
+ />
+ page.setForm({ ...form, regionIds })}
+ />
+ page.setForm({ ...form, effectiveFrom })}
+ />
+ page.setForm({ ...form, effectiveTo })}
+ />
+ page.setForm({ ...form, effectTypes })}
+ />
+ page.setForm({ ...form, enabled: event.target.checked })}
+ />
+ }
+ label={form.enabled ? "启用" : "禁用"}
+ />
+
);
}
+function DateTimeField({ disabled, label, onChange, value }) {
+ const parsedValue = parseLocalDateTime(value);
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [draftDate, setDraftDate] = useState(parsedValue || new Date());
+ const [viewDate, setViewDate] = useState(parsedValue || new Date());
+ const days = useMemo(() => calendarDays(viewDate), [viewDate]);
+ const open = Boolean(anchorEl);
+
+ const openPicker = (event) => {
+ if (disabled) {
+ return;
+ }
+ const baseDate = parseLocalDateTime(value) || new Date();
+ setDraftDate(baseDate);
+ setViewDate(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
+ setAnchorEl(event.currentTarget);
+ };
+
+ const closePicker = () => setAnchorEl(null);
+ const changeMonth = (offset) => {
+ setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
+ };
+ const changeDay = (day) => {
+ setDraftDate((current) => new Date(
+ day.getFullYear(),
+ day.getMonth(),
+ day.getDate(),
+ current.getHours(),
+ current.getMinutes()
+ ));
+ setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
+ };
+ const changeTime = (unit, nextValue) => {
+ setDraftDate((current) => {
+ const nextDate = new Date(current);
+ if (unit === "hour") {
+ nextDate.setHours(Number(nextValue));
+ } else {
+ nextDate.setMinutes(Number(nextValue));
+ }
+ return nextDate;
+ });
+ };
+
+ return (
+ <>
+
+
+
+ )
+ }}
+ label={label}
+ placeholder="长期有效"
+ value={formatDateTimeInputLabel(value)}
+ onClick={openPicker}
+ />
+
+
+
+ changeMonth(-1)}>
+
+
+ {monthTitle(viewDate)}
+ changeMonth(1)}>
+
+
+
+
+ {weekLabels.map((weekday) => (
+ {weekday}
+ ))}
+ {days.map((day) => {
+ const isOutside = day.getMonth() !== viewDate.getMonth();
+ const isSelected = sameDay(day, draftDate);
+ return (
+
+ );
+ })}
+
+
+ changeTime("hour", event.target.value)}
+ >
+ {Array.from({ length: 24 }, (_, hour) => (
+
+ ))}
+
+ changeTime("minute", event.target.value)}
+ >
+ {Array.from({ length: 60 }, (_, minute) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
const selected = value || [];
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
(left, right) => Number(left) - Number(right)
);
return (
-
- selectedValue.map((regionId) => labels[String(regionId)] || regionId).join("、")
- }}
value={selected}
- onChange={(event) => {
- const nextValue = event.target.value;
- onChange(typeof nextValue === "string" ? nextValue.split(",") : nextValue);
- }}
- >
- {mergedOptions.map((regionId) => (
-
- ))}
-
+ onChange={onChange}
+ />
+ );
+}
+
+function GiftEffectSelect({ disabled, onChange, value }) {
+ const selected = value || [];
+ return (
+ optionValue)}
+ value={selected}
+ onChange={onChange}
+ />
);
}
@@ -325,9 +562,9 @@ function RegionTags({ regionIds = [], regionLabelById }) {
return "-";
}
return (
-
+
{items.map((regionId) => (
- {regionLabelById[String(regionId)] || regionId}
+ {regionLabelById[String(regionId)] || regionId}
))}
);
@@ -370,6 +607,94 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
+function giftTypeLabel(value) {
+ return giftTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "普通礼物";
+}
+
+function chargeAssetLabel(value) {
+ return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
+}
+
+function effectTypeLabel(value) {
+ return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value;
+}
+
+function effectTypesLabel(values = []) {
+ return values.length ? values.map(effectTypeLabel).join("、") : "无特效";
+}
+
+function parseLocalDateTime(value) {
+ const normalizedValue = String(value || "").trim();
+ if (!normalizedValue) {
+ return null;
+ }
+ const [datePart, timePart = "00:00"] = normalizedValue.split("T");
+ const [year, month, day] = datePart.split("-").map(Number);
+ const [hour = 0, minute = 0] = timePart.split(":").map(Number);
+ if (![year, month, day, hour, minute].every(Number.isFinite)) {
+ return null;
+ }
+ const date = new Date(year, month - 1, day, hour, minute);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function toDatetimeLocalValue(date) {
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
+}
+
+function formatDateTimeInputLabel(value) {
+ const date = parseLocalDateTime(value);
+ if (!date) {
+ return "";
+ }
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
+}
+
+function calendarDays(viewDate) {
+ const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
+ const offset = (firstDay.getDay() + 6) % 7;
+ const startDate = new Date(firstDay);
+ startDate.setDate(firstDay.getDate() - offset);
+ return Array.from({ length: 42 }, (_, index) => {
+ const day = new Date(startDate);
+ day.setDate(startDate.getDate() + index);
+ return day;
+ });
+}
+
+function sameDay(left, right) {
+ return left.getFullYear() === right.getFullYear()
+ && left.getMonth() === right.getMonth()
+ && left.getDate() === right.getDate();
+}
+
+function monthTitle(date) {
+ return `${date.getFullYear()}年 ${date.getMonth() + 1}月`;
+}
+
+function twoDigits(value) {
+ return String(value).padStart(2, "0");
+}
+
+function effectiveRangeLabel(gift) {
+ if (!gift.effectiveFromMs && !gift.effectiveToMs) {
+ return "长期有效";
+ }
+ return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`;
+}
+
+function shortDateTime(value) {
+ if (!value) {
+ return "";
+ }
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit"
+ }).format(new Date(Number(value)));
+}
+
function buildRegionLabelMap(options = []) {
return options.reduce((acc, option) => {
acc[option.value] = shortRegionLabel(option.label);
diff --git a/src/features/resources/pages/ResourceGroupListPage.jsx b/src/features/resources/pages/ResourceGroupListPage.jsx
index cbc4007..834c7a2 100644
--- a/src/features/resources/pages/ResourceGroupListPage.jsx
+++ b/src/features/resources/pages/ResourceGroupListPage.jsx
@@ -344,13 +344,13 @@ function GroupItems({ group }) {
return "-";
}
return (
-
+
{items.slice(0, 4).map((item) => {
if (item.itemType === "wallet_asset") {
const label = resourceGroupAssetLabels[item.walletAssetType] || item.walletAssetType || "钱包资产";
return (
{label} {formatNumber(item.walletAssetAmount)}
@@ -360,12 +360,12 @@ function GroupItems({ group }) {
const resource = item.resource || {};
const type = resourceTypeLabels[resource.resourceType] || resource.resourceType || "资源";
return (
-
+
{resource.name || item.resourceId} · {formatDurationDays(item.durationMs)}天 · {type}
);
})}
- {items.length > 4 ? +{items.length - 4} : null}
+ {items.length > 4 ? +{items.length - 4} : null}
);
}
diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx
index f73fb9a..2b1b00f 100644
--- a/src/features/resources/pages/ResourceListPage.jsx
+++ b/src/features/resources/pages/ResourceListPage.jsx
@@ -293,9 +293,9 @@ function TagList({ values = [] }) {
return "-";
}
return (
-
+
{items.map((value) => (
-
+
{value}
))}
diff --git a/src/features/resources/resources.module.css b/src/features/resources/resources.module.css
index 9e549f3..bfd82ea 100644
--- a/src/features/resources/resources.module.css
+++ b/src/features/resources/resources.module.css
@@ -48,25 +48,87 @@
white-space: nowrap;
}
-.tagList {
- display: flex;
- min-width: 0;
- flex-wrap: wrap;
+.dateTimeField {
+ width: 100%;
+}
+
+.giftSwitch {
+ min-height: var(--control-height);
+}
+
+.datePopover {
+ display: grid;
+ width: 320px;
+ gap: var(--space-3);
+ padding: var(--space-3);
+}
+
+.calendarHeader {
+ display: grid;
+ grid-template-columns: var(--control-height) 1fr var(--control-height);
+ align-items: center;
gap: var(--space-2);
}
-.tag {
- display: inline-flex;
- max-width: 100%;
- align-items: center;
- overflow: hidden;
- border: 1px solid var(--border);
- border-radius: 999px;
- background: var(--neutral-surface);
- color: var(--text-secondary);
- font-size: var(--admin-font-size);
- line-height: 1;
- padding: 5px 8px;
- text-overflow: ellipsis;
- white-space: nowrap;
+.calendarTitle {
+ color: var(--text-primary);
+ font-weight: 700;
+ text-align: center;
+}
+
+.calendarGrid {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 4px;
+}
+
+.weekday {
+ color: var(--text-tertiary);
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 24px;
+ text-align: center;
+}
+
+.dayButton {
+ display: inline-flex;
+ height: 34px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-primary);
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+}
+
+.dayButton:hover {
+ border-color: var(--primary-border);
+ background: var(--primary-surface);
+ color: var(--primary);
+}
+
+.dayOutside {
+ color: var(--text-tertiary);
+}
+
+.daySelected,
+.daySelected:hover {
+ border-color: var(--primary);
+ background: var(--primary);
+ color: var(--primary-contrast);
+}
+
+.timeGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-2);
+}
+
+.popoverActions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-2);
}
diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js
index 9041298..d0d4d16 100644
--- a/src/features/resources/schema.js
+++ b/src/features/resources/schema.js
@@ -111,10 +111,15 @@ export const resourceGroupCreateFormSchema = z
export const giftFormSchema = z
.object({
+ chargeAssetType: z.enum(["COIN", "DIAMOND"]),
coinPrice: z.union([z.string(), z.number()]),
+ effectTypes: z.array(z.enum(["animation", "music", "global_broadcast"])).optional(),
+ effectiveFrom: z.string().optional(),
+ effectiveTo: z.string().optional(),
enabled: z.boolean(),
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
giftPointAmount: z.union([z.string(), z.number()]).optional(),
+ giftTypeCode: z.enum(["normal", "cp", "lucky", "super_lucky", "exclusive", "noble", "flag", "activity", "magic", "custom"]),
heatValue: z.union([z.string(), z.number()]).optional(),
name: z.string().trim().min(1, "请输入礼物名称").max(128, "礼物名称不能超过 128 个字符"),
presentationJson: z.string().trim().optional(),
@@ -128,6 +133,8 @@ export const giftFormSchema = z
const coinPrice = Number(value.coinPrice);
const giftPointAmount = Number(value.giftPointAmount || 0);
const heatValue = Number(value.heatValue || 0);
+ const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
+ const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
@@ -157,6 +164,27 @@ export const giftFormSchema = z
path: ["heatValue"],
});
}
+ if (effectiveFromMs < 0) {
+ context.addIssue({
+ code: "custom",
+ message: "请选择有效开始时间",
+ path: ["effectiveFrom"],
+ });
+ }
+ if (effectiveToMs < 0) {
+ context.addIssue({
+ code: "custom",
+ message: "请选择有效结束时间",
+ path: ["effectiveTo"],
+ });
+ }
+ if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
+ context.addIssue({
+ code: "custom",
+ message: "有效结束时间必须晚于开始时间",
+ path: ["effectiveTo"],
+ });
+ }
value.regionIds.forEach((regionId, index) => {
const normalizedRegionId = Number(regionId);
if (!Number.isInteger(normalizedRegionId) || normalizedRegionId < 0) {
@@ -180,6 +208,15 @@ export const giftFormSchema = z
}
});
+function datetimeLocalToMs(value) {
+ const trimmed = String(value || "").trim();
+ if (!trimmed) {
+ return 0;
+ }
+ const parsed = new Date(trimmed).getTime();
+ return Number.isFinite(parsed) ? parsed : -1;
+}
+
export const resourceGrantFormSchema = z
.object({
durationDays: z.union([z.string(), z.number()]).optional(),
diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts
index 97cfc80..85ec680 100644
--- a/src/features/resources/schema.test.ts
+++ b/src/features/resources/schema.test.ts
@@ -49,10 +49,15 @@ describe("resource form schema", () => {
test("validates editable gift form fields", () => {
const payload = parseForm(giftFormSchema, {
+ chargeAssetType: "COIN",
coinPrice: "10",
+ effectTypes: ["animation"],
+ effectiveFrom: "",
+ effectiveTo: "",
enabled: true,
giftId: "rose",
giftPointAmount: "1",
+ giftTypeCode: "normal",
heatValue: "2",
name: "Rose",
presentationJson: "{}",
diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx
index 25c8c0a..984170c 100644
--- a/src/features/rooms/pages/RoomListPage.jsx
+++ b/src/features/rooms/pages/RoomListPage.jsx
@@ -10,6 +10,7 @@ import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
+import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { SearchBox } from "@/shared/ui/SearchBox.jsx";
import { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
@@ -64,7 +65,17 @@ export function RoomListPage() {
const total = page.data.total || 0;
const tableColumns = [
...columns.map((column) =>
- column.key === "status"
+ column.key === "region"
+ ? {
+ ...column,
+ filter: createRegionColumnFilter({
+ loading: page.loadingRegions,
+ options: page.regionOptions,
+ value: page.regionId,
+ onChange: page.changeRegionId
+ })
+ }
+ : column.key === "status"
? {
...column,
render: (room) => (
@@ -93,7 +104,7 @@ export function RoomListPage() {
value={page.status}
onChange={page.changeStatus}
/>
- = {
permission: "agency:create",
permissions: ["agency:create"]
},
+ createBanner: {
+ method: "POST",
+ operationId: API_OPERATIONS.createBanner,
+ path: "/v1/admin/app-config/banners",
+ permission: "app-config:update",
+ permissions: ["app-config:update"]
+ },
createBD: {
method: "POST",
operationId: API_OPERATIONS.createBD,
@@ -300,6 +312,13 @@ export const API_ENDPOINTS: Record = {
permission: "overview:view",
permissions: ["overview:view"]
},
+ deleteBanner: {
+ method: "DELETE",
+ operationId: API_OPERATIONS.deleteBanner,
+ path: "/v1/admin/app-config/banners/{banner_id}",
+ permission: "app-config:update",
+ permissions: ["app-config:update"]
+ },
deleteCountry: {
method: "DELETE",
operationId: API_OPERATIONS.deleteCountry,
@@ -508,6 +527,13 @@ export const API_ENDPOINTS: Record = {
operationId: API_OPERATIONS.listApps,
path: "/v1/admin/apps"
},
+ listBanners: {
+ method: "GET",
+ operationId: API_OPERATIONS.listBanners,
+ path: "/v1/admin/app-config/banners",
+ permission: "app-config:view",
+ permissions: ["app-config:view"]
+ },
listBDLeaders: {
method: "GET",
operationId: API_OPERATIONS.listBDLeaders,
@@ -592,6 +618,13 @@ export const API_ENDPOINTS: Record = {
permission: "permission:view",
permissions: ["permission:view","role:permission","role:manage"]
},
+ listRechargeBills: {
+ method: "GET",
+ operationId: API_OPERATIONS.listRechargeBills,
+ path: "/v1/admin/payment/recharge-bills",
+ permission: "payment-bill:view",
+ permissions: ["payment-bill:view"]
+ },
listRegions: {
method: "GET",
operationId: API_OPERATIONS.listRegions,
@@ -762,6 +795,13 @@ export const API_ENDPOINTS: Record = {
permission: "permission:sync",
permissions: ["permission:sync"]
},
+ updateBanner: {
+ method: "PUT",
+ operationId: API_OPERATIONS.updateBanner,
+ path: "/v1/admin/app-config/banners/{banner_id}",
+ permission: "app-config:update",
+ permissions: ["app-config:update"]
+ },
updateCountry: {
method: "PATCH",
operationId: API_OPERATIONS.updateCountry,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index d33546d..e724ffa 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -52,6 +52,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/admin/app-config/banners": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["listBanners"];
+ put?: never;
+ post: operations["createBanner"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/app-config/banners/{banner_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put: operations["updateBanner"];
+ post?: never;
+ delete: operations["deleteBanner"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/admin/app-config/h5-links": {
parameters: {
query?: never;
@@ -356,6 +388,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/admin/payment/recharge-bills": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["listRechargeBills"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/admin/regions": {
parameters: {
query?: never;
@@ -1840,6 +1888,58 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
+ listBanners: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
+ createBanner: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
+ updateBanner: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ banner_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
+ deleteBanner: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ banner_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
listH5Links: {
parameters: {
query?: never;
@@ -2206,6 +2306,18 @@ export interface operations {
200: components["responses"]["HostProfilePageResponse"];
};
};
+ listRechargeBills: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
listRegions: {
parameters: {
query?: never;
diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts
index f2eccf4..a29ceb3 100644
--- a/src/shared/api/types.ts
+++ b/src/shared/api/types.ts
@@ -195,6 +195,27 @@ export interface ChangePasswordPayload {
oldPassword: string;
}
+export interface RechargeBillDto {
+ appCode?: string;
+ coinAmount: number;
+ commandId?: string;
+ createdAtMs?: number;
+ currencyCode?: string;
+ exchangeCoinAmount?: number;
+ exchangeUsdMinorAmount?: number;
+ externalRef?: string;
+ policyId?: number;
+ policyVersion?: string;
+ rechargeType?: string;
+ sellerRegionId?: number;
+ sellerUserId?: number;
+ status?: string;
+ targetRegionId?: number;
+ transactionId: string;
+ usdMinorAmount: number;
+ userId: number;
+}
+
export interface BDProfileDto {
createdAtMs?: number;
createdByUserId?: number;
@@ -350,6 +371,34 @@ export interface H5LinkConfigUpdatePayload {
items: H5LinkConfigPayload[];
}
+export interface AppBannerDto {
+ appCode?: string;
+ bannerType: "h5" | "app" | string;
+ countryCode?: string;
+ coverUrl: string;
+ createdAtMs?: number;
+ description?: string;
+ id: number;
+ param?: string;
+ platform: "android" | "ios" | string;
+ regionId?: number;
+ sortOrder?: number;
+ status: "active" | "disabled" | string;
+ updatedAtMs?: number;
+}
+
+export interface AppBannerPayload {
+ bannerType: "h5" | "app";
+ countryCode?: string;
+ coverUrl: string;
+ description?: string;
+ param?: string;
+ platform: "android" | "ios";
+ regionId?: number;
+ sortOrder?: number;
+ status: "active" | "disabled";
+}
+
export interface AgencyMembershipDto {
agencyId?: number;
createdAtMs?: number;
diff --git a/src/shared/hooks/useRegionOptions.js b/src/shared/hooks/useRegionOptions.js
index d8f4661..fb06d6a 100644
--- a/src/shared/hooks/useRegionOptions.js
+++ b/src/shared/hooks/useRegionOptions.js
@@ -14,13 +14,15 @@ export function useRegionOptions() {
});
const regionOptions = useMemo(() => {
- return (data?.items || []).map((region) => ({
- label: formatRegionLabel(region),
- name: region.name,
- regionCode: region.regionCode,
- regionId: region.regionId,
- value: String(region.regionId)
- }));
+ return (data?.items || [])
+ .filter((region) => region.regionCode !== "GLOBAL")
+ .map((region) => ({
+ label: formatRegionLabel(region),
+ name: region.name,
+ regionCode: region.regionCode,
+ regionId: region.regionId,
+ value: String(region.regionId)
+ }));
}, [data?.items]);
return { loadingRegions, regionOptions };
diff --git a/src/shared/ui/AdminFormDialog.module.css b/src/shared/ui/AdminFormDialog.module.css
index 2211a46..f93d37f 100644
--- a/src/shared/ui/AdminFormDialog.module.css
+++ b/src/shared/ui/AdminFormDialog.module.css
@@ -16,11 +16,21 @@
--admin-form-field-width: 220px;
}
+.narrow {
+ --admin-form-dialog-width: 420px;
+ --admin-form-field-width: 100%;
+}
+
.wide {
--admin-form-dialog-width: 680px;
--admin-form-field-width: 220px;
}
+.large {
+ --admin-form-dialog-width: 760px;
+ --admin-form-field-width: 240px;
+}
+
.form {
display: flex;
max-height: calc(100vh - 64px);
diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx
index 6908796..1ffa094 100644
--- a/src/shared/ui/DataTable.jsx
+++ b/src/shared/ui/DataTable.jsx
@@ -1,17 +1,129 @@
+import SearchOutlined from "@mui/icons-material/SearchOutlined";
+import MenuItem from "@mui/material/MenuItem";
+import Popover from "@mui/material/Popover";
+import TextField from "@mui/material/TextField";
+import { useEffect, useMemo, useState } from "react";
+
+const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
+const DEFAULT_RESIZE_MIN_WIDTH = 72;
+const DEFAULT_RESIZE_MAX_WIDTH = 960;
+
export function DataTable({
className = "",
columns,
context,
emptyLabel = "当前无数据",
+ fixedEdges = true,
getRowProps,
items,
minWidth = "980px",
+ onColumnWidthsChange,
+ resizableColumns = true,
rowKey,
tableClassName = "",
title,
total
}) {
- const gridTemplate = columns.map((column) => column.width || "minmax(120px, 1fr)").join(" ");
+ const [columnWidths, setColumnWidths] = useState({});
+ const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" });
+ const [filterQuery, setFilterQuery] = useState("");
+ const [resizeSession, setResizeSession] = useState(null);
+ const safeItems = Array.isArray(items) ? items : [];
+
+ const preparedColumns = useMemo(
+ () =>
+ columns.map((column, index) => ({
+ ...column,
+ fixed: resolveColumnFixed(column, index, columns.length, fixedEdges),
+ width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH
+ })),
+ [columnWidths, columns, fixedEdges]
+ );
+
+ const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
+ const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
+ const activeFilter = activeFilterColumn?.filter || null;
+ const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]);
+
+ useEffect(() => {
+ if (!resizeSession) {
+ return undefined;
+ }
+
+ const moveColumn = (event) => {
+ const delta = event.clientX - resizeSession.startX;
+ const nextWidth = clamp(resizeSession.startWidth + delta, resizeSession.minWidth, resizeSession.maxWidth);
+ const cssWidth = `${Math.round(nextWidth)}px`;
+
+ setColumnWidths((current) => {
+ if (current[resizeSession.key] === cssWidth) {
+ return current;
+ }
+ const next = { ...current, [resizeSession.key]: cssWidth };
+ onColumnWidthsChange?.(next);
+ return next;
+ });
+ };
+
+ const finishResize = () => {
+ setResizeSession(null);
+ };
+
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ window.addEventListener("pointermove", moveColumn);
+ window.addEventListener("pointerup", finishResize, { once: true });
+ window.addEventListener("pointercancel", finishResize, { once: true });
+
+ return () => {
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ window.removeEventListener("pointermove", moveColumn);
+ window.removeEventListener("pointerup", finishResize);
+ window.removeEventListener("pointercancel", finishResize);
+ };
+ }, [onColumnWidthsChange, resizeSession]);
+
+ const startResize = (event, column) => {
+ if (!resizableColumns || column.resizable === false) {
+ return;
+ }
+
+ const headerCell = event.currentTarget.closest("[data-table-header-cell]");
+ const startWidth = headerCell?.getBoundingClientRect().width;
+ if (!startWidth) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ setResizeSession({
+ key: column.key,
+ maxWidth: resolveResizeMaxWidth(column),
+ minWidth: resolveResizeMinWidth(column),
+ startWidth,
+ startX: event.clientX
+ });
+ };
+
+ const openColumnFilter = (event, column) => {
+ if (!column.filter) {
+ return;
+ }
+ event.preventDefault();
+ setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key });
+ setFilterQuery("");
+ };
+
+ const closeColumnFilter = () => {
+ setFilterMenu({ anchorEl: null, columnKey: "" });
+ setFilterQuery("");
+ };
+
+ const changeFilter = (value) => {
+ activeFilter?.onChange?.(value);
+ closeColumnFilter();
+ };
return (
@@ -23,25 +135,38 @@ export function DataTable({
) : null}
- {columns.map((column) => (
-
- {column.header || column.label}
+ {preparedColumns.map((column) => (
+
+
+ {resizableColumns && column.resizable !== false ? (
+
))}
- {items.length ? (
- items.map((item, index) => {
+ {safeItems.length ? (
+ safeItems.map((item, index) => {
const rowProps = getRowProps ? getRowProps(item, index) : {};
const { className: rowClassName = "", ...restRowProps } = rowProps;
return (
- {columns.map((column) => (
-
+ {preparedColumns.map((column) => (
+
{column.render ? column.render(item, index, context) : displayValue(item[column.key])}
))}
@@ -55,6 +180,43 @@ export function DataTable({
)}
+
+ {activeFilterColumn ? (
+
+
setFilterQuery(event.target.value)}
+ />
+
+ {filterOptions.length ? (
+ filterOptions.map((option) => (
+
+ ))
+ ) : (
+
{activeFilter.loading ? "加载中..." : "无匹配选项"}
+ )}
+
+
+ ) : null}
+
);
}
@@ -62,3 +224,126 @@ export function DataTable({
function displayValue(value) {
return value === 0 || value === false || value ? value : "-";
}
+
+function HeaderLabel({ column, onOpenFilter }) {
+ const content = column.header || column.label;
+ if (!column.filter) {
+ return
{content};
+ }
+
+ const active = isFilterActive(column.filter);
+ return (
+
+ );
+}
+
+function cellClassName(column, extraClassName = "") {
+ const extraClassNames = Array.isArray(extraClassName) ? extraClassName : [extraClassName];
+ return [
+ "admin-cell",
+ column.fixed === "left" ? "admin-cell--fixed-left" : "",
+ column.fixed === "right" ? "admin-cell--fixed-right" : "",
+ column.className || "",
+ ...extraClassNames
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
+
+function resolveColumnFixed(column, index, total, fixedEdges) {
+ if (column.fixed === "left" || column.fixed === "right") {
+ return column.fixed;
+ }
+ if (!fixedEdges || total < 2) {
+ return "";
+ }
+ if (index === 0) {
+ return "left";
+ }
+ if (index === total - 1) {
+ return "right";
+ }
+ return "";
+}
+
+function resolveResizeMinWidth(column) {
+ return readPixelWidth(column.resizeMinWidth) || readPixelWidth(column.minWidth) || readMinWidth(column.width) || DEFAULT_RESIZE_MIN_WIDTH;
+}
+
+function resolveResizeMaxWidth(column) {
+ return readPixelWidth(column.resizeMaxWidth) || readPixelWidth(column.maxWidth) || DEFAULT_RESIZE_MAX_WIDTH;
+}
+
+function readMinWidth(width) {
+ const pixelWidth = readPixelWidth(width);
+ if (pixelWidth) {
+ return pixelWidth;
+ }
+ if (typeof width === "number") {
+ return width;
+ }
+ if (typeof width !== "string") {
+ return 0;
+ }
+
+ const minmaxMatch = width.match(/minmax\(\s*(\d+(?:\.\d+)?)px/i);
+ if (minmaxMatch) {
+ return Number(minmaxMatch[1]);
+ }
+
+ return 0;
+}
+
+function readPixelWidth(width) {
+ if (typeof width === "number") {
+ return width;
+ }
+ if (typeof width !== "string") {
+ return 0;
+ }
+ const pixelMatch = width.match(/^(\d+(?:\.\d+)?)px$/i);
+ return pixelMatch ? Number(pixelMatch[1]) : 0;
+}
+
+function clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
+
+function isFilterActive(filter) {
+ return filter && filter.value !== undefined && filter.value !== null && String(filter.value) !== "";
+}
+
+function filterVisibleOptions(filter, query) {
+ if (!filter) {
+ return [];
+ }
+
+ const normalizedOptions = normalizeFilterOptions(filter);
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) {
+ return normalizedOptions;
+ }
+
+ return normalizedOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery));
+}
+
+function normalizeFilterOptions(filter) {
+ const options = (filter.options || []).map((option) => {
+ if (Array.isArray(option)) {
+ return { label: String(option[1] ?? option[0] ?? ""), value: String(option[0] ?? "") };
+ }
+ return { label: String(option.label ?? option.name ?? option.value ?? ""), value: String(option.value ?? "") };
+ });
+
+ if (filter.emptyLabel !== undefined && !options.some((option) => option.value === "")) {
+ return [{ label: filter.emptyLabel, value: "" }, ...options];
+ }
+ return options;
+}
diff --git a/src/shared/ui/MultiValueAutocomplete.jsx b/src/shared/ui/MultiValueAutocomplete.jsx
new file mode 100644
index 0000000..dda0545
--- /dev/null
+++ b/src/shared/ui/MultiValueAutocomplete.jsx
@@ -0,0 +1,46 @@
+import Autocomplete from "@mui/material/Autocomplete";
+import TextField from "@mui/material/TextField";
+import { useMemo } from "react";
+
+export function MultiValueAutocomplete({
+ disabled = false,
+ getOptionLabel,
+ label,
+ labels,
+ loading = false,
+ onChange,
+ options = [],
+ required = false,
+ value
+}) {
+ const selected = useMemo(() => (Array.isArray(value) ? value : []), [value]);
+ const mergedOptions = useMemo(() => {
+ const seen = new Set();
+ return [...options, ...selected].filter((option) => {
+ const key = String(option);
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+ }, [options, selected]);
+ const formatOption = getOptionLabel || ((option) => labels?.[String(option)] || String(option));
+
+ return (
+
String(option) === String(selectedOption)}
+ loading={loading}
+ options={mergedOptions}
+ renderInput={(params) => }
+ size="small"
+ value={selected}
+ onChange={(_, nextValue) => onChange(nextValue)}
+ />
+ );
+}
diff --git a/src/shared/ui/RegionFilterControl.jsx b/src/shared/ui/RegionFilterControl.jsx
new file mode 100644
index 0000000..210eb66
--- /dev/null
+++ b/src/shared/ui/RegionFilterControl.jsx
@@ -0,0 +1,65 @@
+import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
+import Tooltip from "@mui/material/Tooltip";
+import { IconButton } from "@/shared/ui/IconButton.jsx";
+import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
+
+export function RegionFilterControl({
+ className = "",
+ disabled = false,
+ emptyLabel = "全部区域",
+ loading = false,
+ onChange,
+ options = [],
+ resetLabel = "重置区域",
+ selectClassName = "",
+ value,
+ ...props
+}) {
+ const selectedValue = value === undefined || value === null ? "" : String(value);
+ const canReset = selectedValue !== "" && !disabled && !loading;
+
+ return (
+
+
+
+
+ onChange?.("")}
+ >
+
+
+
+
+
+ );
+}
+
+export function createRegionColumnFilter({
+ emptyLabel = "全部区域",
+ loading = false,
+ onChange,
+ options = [],
+ placeholder = "搜索区域",
+ value
+}) {
+ return {
+ emptyLabel,
+ loading,
+ onChange,
+ options,
+ placeholder,
+ value
+ };
+}
diff --git a/src/styles/base.css b/src/styles/base.css
index 2d59317..9f0574a 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -38,5 +38,7 @@ select:focus-visible {
}
#app {
+ height: 100vh;
+ height: 100dvh;
min-height: 100vh;
}
diff --git a/src/styles/layout.css b/src/styles/layout.css
index e852eca..e9fa9b6 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -1,7 +1,10 @@
.app-shell {
display: grid;
grid-template-rows: var(--header-height) 1fr;
- min-height: 100vh;
+ height: 100vh;
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
background: var(--bg-page);
}
@@ -394,15 +397,20 @@
.body-grid {
display: grid;
grid-template-columns: 248px 1fr;
+ height: 100%;
min-height: 0;
+ overflow: hidden;
transition: grid-template-columns var(--motion-slow) var(--ease-emphasized);
}
.sidebar {
position: relative;
z-index: var(--z-sidebar);
+ display: flex;
+ height: 100%;
min-height: 0;
- overflow: visible;
+ flex-direction: column;
+ overflow: hidden;
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--border);
background: var(--bg-sidebar);
@@ -442,6 +450,7 @@
}
.sidebar--collapsed {
+ overflow: visible;
padding: var(--space-4) var(--space-3);
}
@@ -454,7 +463,20 @@
.nav-list {
display: grid;
+ flex: 1;
gap: var(--space-3);
+ min-height: 0;
+ align-content: start;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding-right: var(--space-1);
+ scrollbar-gutter: stable;
+}
+
+.sidebar--collapsed .nav-list {
+ overflow: visible;
+ padding-right: 0;
}
.nav-group {
diff --git a/src/styles/responsive.css b/src/styles/responsive.css
index 7ee4ece..6816a0f 100644
--- a/src/styles/responsive.css
+++ b/src/styles/responsive.css
@@ -5,7 +5,15 @@
.app-shell,
.body-grid {
+ height: auto;
min-height: auto;
+ overflow: visible;
+ }
+
+ .sidebar,
+ .nav-list {
+ height: auto;
+ overflow: visible;
}
.workspace {
diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css
index 0c197e5..696c1d6 100644
--- a/src/styles/shared-ui.css
+++ b/src/styles/shared-ui.css
@@ -117,6 +117,24 @@
flex: 0 0 180px;
}
+.region-filter-control {
+ display: inline-flex;
+ min-width: 0;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.region-filter-control .region-select.MuiTextField-root,
+.region-filter-control .region-select.MuiFormControl-root {
+ flex: 0 0 180px;
+}
+
+.region-filter-control__reset.icon-button {
+ flex: 0 0 var(--control-height);
+ color: var(--text-tertiary);
+}
+
.MuiInputBase-root.MuiOutlinedInput-root:not(.MuiInputBase-multiline) {
height: var(--control-height);
min-height: var(--control-height);
@@ -239,7 +257,6 @@
}
.MuiAutocomplete-root {
- --admin-autocomplete-chip-height: 28px;
--admin-autocomplete-input-height: 24px;
}
@@ -269,16 +286,18 @@
.MuiAutocomplete-root .MuiAutocomplete-tag {
max-width: calc(100% - var(--space-2));
- height: var(--admin-autocomplete-chip-height);
+ height: var(--admin-tag-height);
margin: 0;
- border-radius: var(--radius-pill);
- background: var(--neutral-surface-strong);
- color: var(--text-primary);
- font-size: var(--admin-font-size);
+ border: 1px solid var(--admin-tag-border);
+ border-radius: var(--admin-tag-radius);
+ background: var(--admin-tag-bg);
+ color: var(--admin-tag-color);
+ font-size: var(--admin-tag-font-size);
+ font-weight: var(--admin-tag-font-weight);
}
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-label {
- padding: 0 var(--space-2);
+ padding: 0 var(--admin-tag-padding-x);
}
.MuiAutocomplete-root .MuiAutocomplete-tag .MuiChip-deleteIcon {
@@ -288,6 +307,32 @@
color: var(--text-tertiary);
}
+.admin-tag-list {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.admin-tag {
+ display: inline-flex;
+ max-width: 100%;
+ height: var(--admin-tag-height);
+ align-items: center;
+ overflow: hidden;
+ border: 1px solid var(--admin-tag-border);
+ border-radius: var(--admin-tag-radius);
+ background: var(--admin-tag-bg);
+ color: var(--admin-tag-color);
+ font-size: var(--admin-tag-font-size);
+ font-weight: var(--admin-tag-font-weight);
+ line-height: 1;
+ padding: 0 var(--admin-tag-padding-x);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.MuiAutocomplete-popper .MuiAutocomplete-paper {
overflow: hidden;
border: 1px solid var(--border);
@@ -360,6 +405,18 @@
flex: 1 1 100%;
}
+ .region-filter-control {
+ width: 100%;
+ flex: 1 1 100%;
+ }
+
+ .region-filter-control .region-select.MuiTextField-root,
+ .region-filter-control .region-select.MuiFormControl-root {
+ width: auto;
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
}
.kpi-card {
@@ -592,6 +649,17 @@
color: var(--text-tertiary);
}
+.cell-stack {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-1);
+}
+
+.cell-stack > * {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
.row-actions {
display: flex;
align-items: center;
@@ -850,11 +918,12 @@
}
.admin-table {
+ --admin-table-cell-x: var(--space-2);
display: flex;
flex-direction: column;
- width: 100%;
+ width: max(100%, var(--admin-table-min-width, 900px));
min-height: 100%;
- min-width: var(--admin-table-min-width, 900px);
+ min-width: max(100%, var(--admin-table-min-width, 900px));
}
.admin-row {
@@ -863,10 +932,10 @@
align-items: center;
grid-template-columns: var(--admin-table-columns);
min-height: var(--table-row-height);
- padding: 0 var(--space-5);
+ padding: 0;
border-bottom: 1px solid var(--row-border);
color: var(--text-secondary);
- column-gap: var(--space-4);
+ column-gap: 0;
font-size: var(--admin-font-size);
animation: row-enter var(--motion-slow) var(--ease-emphasized) both;
}
@@ -884,10 +953,176 @@
}
.admin-cell {
+ box-sizing: border-box;
min-width: 0;
+ padding: 0 var(--admin-table-cell-x);
overflow-wrap: anywhere;
}
+.admin-cell:first-child {
+ padding-left: var(--admin-table-cell-x);
+}
+
+.admin-cell:last-child {
+ padding-right: var(--admin-table-cell-x);
+}
+
+.admin-cell--head {
+ position: relative;
+ display: flex;
+ min-height: var(--table-head-height);
+ align-items: center;
+ padding-right: calc(var(--admin-table-cell-x) + 12px);
+}
+
+.admin-cell--head:last-child {
+ padding-right: calc(var(--admin-table-cell-x) + 12px);
+}
+
+.admin-cell__head-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-cell__head-trigger {
+ display: inline-flex;
+ max-width: 100%;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ font-weight: inherit;
+}
+
+.admin-cell__head-trigger:hover,
+.admin-cell__head-trigger--active {
+ color: var(--text-primary);
+}
+
+.admin-cell__head-icon {
+ flex: 0 0 auto;
+ color: var(--text-tertiary);
+ font-size: 16px;
+}
+
+.admin-cell__head-trigger--active .admin-cell__head-icon {
+ color: var(--primary);
+}
+
+.admin-cell--fixed-left,
+.admin-cell--fixed-right {
+ position: sticky;
+ z-index: 2;
+ display: flex;
+ min-height: var(--table-row-height);
+ align-items: center;
+ background: var(--bg-card);
+}
+
+.admin-cell--fixed-left {
+ left: 0;
+ box-shadow: 10px 0 18px -18px rgb(30 41 59 / 0.4);
+}
+
+.admin-cell--fixed-right {
+ right: 0;
+ box-shadow: -10px 0 18px -18px rgb(30 41 59 / 0.4);
+}
+
+.admin-row--head .admin-cell--fixed-left,
+.admin-row--head .admin-cell--fixed-right {
+ z-index: 4;
+ min-height: var(--table-head-height);
+ background: var(--bg-card-strong);
+}
+
+.admin-column-resizer {
+ position: absolute;
+ top: 9px;
+ right: 0;
+ bottom: 9px;
+ width: 10px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: col-resize;
+ touch-action: none;
+}
+
+.admin-column-resizer::after {
+ position: absolute;
+ top: 0;
+ right: 4px;
+ bottom: 0;
+ width: 1px;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ content: "";
+ transition:
+ background var(--motion-fast) var(--ease-standard),
+ box-shadow var(--motion-fast) var(--ease-standard);
+}
+
+.admin-column-resizer:hover::after,
+.admin-cell--resizing .admin-column-resizer::after {
+ background: var(--primary);
+ box-shadow: 0 0 0 1px var(--primary-surface);
+}
+
+.admin-table--resizing {
+ cursor: col-resize;
+ user-select: none;
+}
+
+.admin-table-filter-popover .MuiPopover-paper {
+ width: 240px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-card);
+ background: var(--bg-flyout);
+ box-shadow: var(--shadow-panel);
+}
+
+.admin-table-filter {
+ display: grid;
+ gap: var(--space-2);
+ padding: var(--space-2);
+}
+
+.admin-table-filter__search.MuiTextField-root {
+ width: 100%;
+}
+
+.admin-table-filter__options {
+ max-height: 280px;
+ overflow: auto;
+}
+
+.admin-table-filter__options .MuiMenuItem-root {
+ min-height: 34px;
+ border-radius: var(--radius-control);
+ color: var(--text-secondary);
+}
+
+.admin-table-filter__options .MuiMenuItem-root.Mui-selected {
+ background: var(--primary-hover);
+ color: var(--text-primary);
+}
+
+.admin-table-filter__empty {
+ padding: var(--space-4) var(--space-2);
+ color: var(--text-tertiary);
+ font-size: var(--admin-font-size);
+ text-align: center;
+}
+
.admin-table .empty-state--table {
flex: 1;
}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index c72efe4..c1fcb4a 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -3,6 +3,11 @@
--admin-line-height: 20px;
--control-height: 36px;
--status-height: 24px;
+ --admin-tag-height: var(--status-height);
+ --admin-tag-radius: var(--radius-pill);
+ --admin-tag-padding-x: var(--space-2);
+ --admin-tag-font-size: var(--admin-font-size);
+ --admin-tag-font-weight: 650;
--table-head-height: 44px;
--table-row-height: 58px;
--switch-width: 46px;
@@ -77,6 +82,9 @@
--neutral-surface: rgba(100, 116, 139, 0.1);
--neutral-surface-strong: rgba(100, 116, 139, 0.16);
--neutral-border: rgba(100, 116, 139, 0.22);
+ --admin-tag-bg: var(--neutral-surface);
+ --admin-tag-border: var(--neutral-border);
+ --admin-tag-color: var(--text-secondary);
--chart-blue: #2563eb;
--chart-green: #16a34a;
diff --git a/src/theme.js b/src/theme.js
index 83de685..4cd96ad 100644
--- a/src/theme.js
+++ b/src/theme.js
@@ -93,10 +93,13 @@ export const theme = createTheme({
MuiChip: {
styleOverrides: {
root: {
- height: token("status-height"),
- borderRadius: token("radius-pill"),
- fontSize: 14,
- backgroundColor: token("neutral-surface"),
+ height: token("admin-tag-height"),
+ border: `1px solid ${token("admin-tag-border")}`,
+ borderRadius: token("admin-tag-radius"),
+ backgroundColor: token("admin-tag-bg"),
+ color: token("admin-tag-color"),
+ fontSize: token("admin-tag-font-size"),
+ fontWeight: token("admin-tag-font-weight"),
transition:
"background-color 150ms cubic-bezier(0.2, 0, 0, 1), border-color 150ms cubic-bezier(0.2, 0, 0, 1), color 150ms cubic-bezier(0.2, 0, 0, 1), transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
},
@@ -105,8 +108,8 @@ export const theme = createTheme({
marginRight: -2
},
label: {
- paddingLeft: 8,
- paddingRight: 10
+ paddingLeft: token("admin-tag-padding-x"),
+ paddingRight: token("admin-tag-padding-x")
}
}
},