diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index bf82489..c1bf04f 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -104,6 +104,78 @@ ] } }, + "/admin/app-config/banners": { + "get": { + "operationId": "listBanners", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:view", + "x-permissions": [ + "app-config:view" + ] + }, + "post": { + "operationId": "createBanner", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "app-config:update", + "x-permissions": [ + "app-config:update" + ] + } + }, + "/admin/app-config/banners/{banner_id}": { + "put": { + "operationId": "updateBanner", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "banner_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-config:update", + "x-permissions": [ + "app-config:update" + ] + }, + "delete": { + "operationId": "deleteBanner", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "banner_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "app-config:update", + "x-permissions": [ + "app-config:update" + ] + } + }, "/admin/app-config/h5-links": { "get": { "operationId": "listH5Links", @@ -629,6 +701,20 @@ ] } }, + "/admin/payment/recharge-bills": { + "get": { + "operationId": "listRechargeBills", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "payment-bill:view", + "x-permissions": [ + "payment-bill:view" + ] + } + }, "/admin/regions": { "get": { "operationId": "listRegions", diff --git a/src/app/layout/Header.jsx b/src/app/layout/Header.jsx index 7b747d5..f216a6a 100644 --- a/src/app/layout/Header.jsx +++ b/src/app/layout/Header.jsx @@ -1,5 +1,6 @@ import ExpandMore from "@mui/icons-material/ExpandMore"; import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import LockReset from "@mui/icons-material/LockReset"; import Logout from "@mui/icons-material/Logout"; import Menu from "@mui/icons-material/Menu"; import MenuOpen from "@mui/icons-material/MenuOpen"; @@ -15,17 +16,24 @@ import { useNavigate } from "react-router-dom"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { useAuth } from "@/app/auth/AuthProvider.jsx"; import { PERMISSIONS } from "@/app/permissions"; +import { changePassword } from "@/features/auth/api"; import { listNotifications } from "@/features/notifications/api"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; const SearchDialog = lazy(() => import("@/features/search/components/SearchDialog.jsx").then((module) => ({ default: module.SearchDialog }))); +const emptyPasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" }; export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSidebar }) { const [searchOpen, setSearchOpen] = useState(false); const [searchMounted, setSearchMounted] = useState(false); const [userMenuAnchor, setUserMenuAnchor] = useState(null); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passwordForm, setPasswordForm] = useState(emptyPasswordForm); + const [passwordError, setPasswordError] = useState(""); + const [changingPassword, setChangingPassword] = useState(false); const navigate = useNavigate(); const { can, logout, user } = useAuth(); const appScope = useAppScope(); @@ -60,12 +68,68 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid setUserMenuAnchor(null); }; + const openPasswordDialog = () => { + closeUserMenu(); + setPasswordForm(emptyPasswordForm); + setPasswordError(""); + setPasswordDialogOpen(true); + }; + + const closePasswordDialog = () => { + if (changingPassword) { + return; + } + setPasswordDialogOpen(false); + setPasswordError(""); + }; + const handleLogout = async () => { closeUserMenu(); await logout(); navigate("/login", { replace: true }); }; + const handlePasswordFieldChange = (field) => (event) => { + setPasswordForm((current) => ({ ...current, [field]: event.target.value })); + if (passwordError) { + setPasswordError(""); + } + }; + + const handlePasswordSubmit = async (event) => { + event.preventDefault(); + const { oldPassword, newPassword, confirmPassword } = passwordForm; + + if (!oldPassword || !newPassword || !confirmPassword) { + setPasswordError("请填写原密码、新密码和确认密码"); + return; + } + if (newPassword.length < 6) { + setPasswordError("新密码至少 6 位"); + return; + } + if (newPassword.length > 72) { + setPasswordError("新密码不能超过 72 位"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError("两次输入的新密码不一致"); + return; + } + + setChangingPassword(true); + try { + await changePassword({ oldPassword, newPassword }); + showToast("密码已修改,请重新登录", "success"); + await logout(); + navigate("/login", { replace: true }); + } catch (err) { + setPasswordError(err.message || "修改密码失败"); + } finally { + setChangingPassword(false); + } + }; + const handleSearchSelect = (result) => { if (result?.target) { navigate(result.target); @@ -145,6 +209,10 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid transformOrigin={{ horizontal: "right", vertical: "top" }} anchorOrigin={{ horizontal: "right", vertical: "bottom" }} > + + + 修改密码 + 退出 @@ -158,6 +226,52 @@ export function Header({ activeLabel, isSidebarCollapsed, onRefresh, onToggleSid setSearchOpen(false)} onSelect={handleSearchSelect} /> ) : null} + + + + + ); } diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 1a9b460..15c9ea7 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -6,6 +6,7 @@ import DashboardOutlined from "@mui/icons-material/DashboardOutlined"; import GroupsOutlined from "@mui/icons-material/GroupsOutlined"; import HistoryOutlined from "@mui/icons-material/HistoryOutlined"; import HubOutlined from "@mui/icons-material/HubOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; import LoginOutlined from "@mui/icons-material/LoginOutlined"; import ManageAccountsOutlined from "@mui/icons-material/ManageAccountsOutlined"; @@ -32,6 +33,7 @@ const iconMap = { network: HubOutlined, team: GroupsOutlined, history: HistoryOutlined, + image: ImageOutlined, login: LoginOutlined, map: MapOutlined, menu: MenuOpenOutlined, @@ -84,7 +86,8 @@ export const fallbackNavigation = [ id: "app-config", label: "APP配置", children: [ - routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }) + routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }), + routeNavItem("app-config-banners", { icon: ImageOutlined }) ] }, { @@ -99,6 +102,15 @@ export const fallbackNavigation = [ routeNavItem("resource-grant-list", { icon: SendOutlined }) ] }, + { + code: "payment", + icon: WalletOutlined, + id: "payment", + label: "支付管理", + children: [ + routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }) + ] + }, { code: "logs", icon: HistoryOutlined, diff --git a/src/app/permissions.ts b/src/app/permissions.ts index f74a60b..0358cc4 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -24,6 +24,7 @@ export const PERMISSIONS = { coinSellerView: "coin-seller:view", coinSellerCreate: "coin-seller:create", coinSellerUpdate: "coin-seller:update", + paymentBillView: "payment-bill:view", roleView: "role:view", roleCreate: "role:create", roleUpdate: "role:update", @@ -95,6 +96,7 @@ export const MENU_CODES = { roomList: "room-list", appConfig: "app-config", appConfigH5: "app-config-h5", + appConfigBanners: "app-config-banners", resources: "resources", resourceList: "resource-list", resourceGroupList: "resource-group-list", @@ -109,6 +111,8 @@ export const MENU_CODES = { hostOrgBds: "host-org-bds", hostOrgHosts: "host-org-hosts", hostOrgCoinSellers: "host-org-coin-sellers", + payment: "payment", + paymentBillList: "payment-bill-list", jobs: "jobs" } as const; diff --git a/src/app/router/routeConfig.ts b/src/app/router/routeConfig.ts index 417b2b9..f67d000 100644 --- a/src/app/router/routeConfig.ts +++ b/src/app/router/routeConfig.ts @@ -6,6 +6,7 @@ import { hostOrgRoutes } from "@/features/host-org/routes.js"; import { logsRoutes } from "@/features/logs/routes.js"; import { menusRoutes } from "@/features/menus/routes.js"; import { notificationsRoutes } from "@/features/notifications/routes.js"; +import { paymentRoutes } from "@/features/payment/routes.js"; import { resourceRoutes } from "@/features/resources/routes.js"; import { rolesRoutes } from "@/features/roles/routes.js"; import { roomRoutes } from "@/features/rooms/routes.js"; @@ -21,6 +22,7 @@ export const adminRoutes: AdminRoute[] = [ ...roomRoutes, ...appConfigRoutes, ...resourceRoutes, + ...paymentRoutes, ...usersRoutes, ...hostOrgRoutes, ...rolesRoutes, diff --git a/src/features/app-config/api.ts b/src/features/app-config/api.ts index 46d235f..b46abb3 100644 --- a/src/features/app-config/api.ts +++ b/src/features/app-config/api.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@/shared/api/request"; import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; -import type { ApiList, H5LinkConfigDto, H5LinkConfigUpdatePayload } from "@/shared/api/types"; +import type { ApiList, AppBannerDto, AppBannerPayload, EntityId, H5LinkConfigDto, H5LinkConfigUpdatePayload, PageQuery } from "@/shared/api/types"; export function listH5Links(): Promise> { const endpoint = API_ENDPOINTS.listH5Links; @@ -16,3 +16,34 @@ export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise> { + const endpoint = API_ENDPOINTS.listBanners; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listBanners), { + method: endpoint.method, + query + }); +} + +export function createBanner(payload: AppBannerPayload): Promise { + const endpoint = API_ENDPOINTS.createBanner; + return apiRequest(apiEndpointPath(API_OPERATIONS.createBanner), { + body: payload, + method: endpoint.method + }); +} + +export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise { + const endpoint = API_ENDPOINTS.updateBanner; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), { + body: payload, + method: endpoint.method + }); +} + +export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> { + const endpoint = API_ENDPOINTS.deleteBanner; + return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteBanner, { banner_id: bannerId }), { + method: endpoint.method + }); +} diff --git a/src/features/app-config/app-config.module.css b/src/features/app-config/app-config.module.css index 4cf26cb..bac16b2 100644 --- a/src/features/app-config/app-config.module.css +++ b/src/features/app-config/app-config.module.css @@ -5,3 +5,21 @@ text-overflow: ellipsis; white-space: nowrap; } + +.bannerCover, +.bannerCoverEmpty { + display: inline-flex; + width: 88px; + height: 50px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-card-strong); + color: var(--text-tertiary); +} + +.bannerCover { + object-fit: cover; +} diff --git a/src/features/app-config/hooks/useBannerConfigPage.js b/src/features/app-config/hooks/useBannerConfigPage.js new file mode 100644 index 0000000..0529f8d --- /dev/null +++ b/src/features/app-config/hooks/useBannerConfigPage.js @@ -0,0 +1,179 @@ +import { useCallback, useMemo, useState } from "react"; +import { createBanner, deleteBanner, listBanners, updateBanner } from "@/features/app-config/api"; +import { useAppConfigAbilities } from "@/features/app-config/permissions.js"; +import { appBannerSchema } from "@/features/app-config/schema"; +import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js"; +import { parseForm } from "@/shared/forms/validation"; +import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; +import { useToast } from "@/shared/ui/ToastProvider.jsx"; + +const emptyData = { items: [], total: 0 }; + +const emptyForm = () => ({ + bannerType: "h5", + countryCode: "", + coverUrl: "", + description: "", + param: "", + platform: "android", + regionId: "", + sortOrder: "0", + status: "active" +}); + +export function useBannerConfigPage() { + const abilities = useAppConfigAbilities(); + const confirm = useConfirm(); + const { showToast } = useToast(); + const { countryOptions, loadingCountries } = useCountryOptions(); + const { loadingRegions, regionOptions } = useRegionOptions(); + const [keyword, setKeyword] = useState(""); + const [status, setStatus] = useState(""); + const [platform, setPlatform] = useState(""); + const [regionId, setRegionId] = useState(""); + const [countryCode, setCountryCode] = useState(""); + const [activeAction, setActiveAction] = useState(""); + const [editingItem, setEditingItem] = useState(null); + const [form, setFormState] = useState(emptyForm); + const [loadingAction, setLoadingAction] = useState(""); + + const filters = useMemo( + () => ({ + countryCode, + keyword, + platform, + regionId, + status + }), + [countryCode, keyword, platform, regionId, status] + ); + const queryFn = useCallback(() => listBanners(filters), [filters]); + const { data = emptyData, error, loading, reload } = useAdminQuery(queryFn, { + errorMessage: "加载 BANNER 配置失败", + initialData: emptyData, + queryKey: ["app-config", "banners", filters] + }); + + const setForm = (patch) => { + setFormState((current) => ({ ...current, ...patch })); + }; + + const openCreate = () => { + setEditingItem(null); + setFormState(emptyForm()); + setActiveAction("create"); + }; + + const openEdit = (item) => { + setEditingItem(item); + setFormState(formFromBanner(item)); + setActiveAction("edit"); + }; + + const closeDialog = () => { + setActiveAction(""); + setEditingItem(null); + setFormState(emptyForm()); + }; + + const submitBanner = async (event) => { + event.preventDefault(); + const payload = parseForm(appBannerSchema, normalizeForm(form)); + const action = editingItem ? "edit" : "create"; + setLoadingAction(action); + try { + if (editingItem) { + await updateBanner(editingItem.id, payload); + showToast("BANNER 已更新", "success"); + } else { + await createBanner(payload); + showToast("BANNER 已新增", "success"); + } + closeDialog(); + await reload(); + } catch (err) { + showToast(err.message || "操作失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const removeBanner = async (item) => { + const ok = await confirm({ + confirmText: "删除", + message: item.description || item.param || `BANNER ${item.id}`, + title: "删除BANNER", + tone: "danger" + }); + if (!ok) { + return; + } + setLoadingAction(`delete:${item.id}`); + try { + await deleteBanner(item.id); + await reload(); + showToast("BANNER 已删除", "success"); + } catch (err) { + showToast(err.message || "删除失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + return { + abilities, + activeAction, + closeDialog, + countryCode, + countryOptions, + data, + editingItem, + error, + form, + keyword, + loading, + loadingAction, + loadingCountries, + loadingRegions, + openCreate, + openEdit, + platform, + regionId, + regionOptions, + reload, + removeBanner, + setCountryCode, + setForm, + setKeyword, + setPlatform, + setRegionId, + setStatus, + status, + submitBanner + }; +} + +function normalizeForm(form) { + return { + ...form, + countryCode: form.countryCode || "", + regionId: form.regionId || 0, + sortOrder: form.sortOrder || 0 + }; +} + +function formFromBanner(item) { + return { + bannerType: item.bannerType || "h5", + countryCode: item.countryCode || "", + coverUrl: item.coverUrl || "", + description: item.description || "", + param: item.param || "", + platform: item.platform || "android", + regionId: item.regionId ? String(item.regionId) : "", + sortOrder: String(item.sortOrder || 0), + status: item.status || "active" + }; +} diff --git a/src/features/app-config/pages/BannerConfigPage.jsx b/src/features/app-config/pages/BannerConfigPage.jsx new file mode 100644 index 0000000..4dce46a --- /dev/null +++ b/src/features/app-config/pages/BannerConfigPage.jsx @@ -0,0 +1,308 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import ImageOutlined from "@mui/icons-material/ImageOutlined"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useMemo } from "react"; +import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.js"; +import styles from "@/features/app-config/app-config.module.css"; +import { Button } from "@/shared/ui/Button.jsx"; +import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx"; +import { + AdminActionIconButton, + AdminFilterSelect, + AdminListBody, + AdminListPage, + AdminListToolbar, + AdminRowActions, + AdminSearchBox, + adminListClasses +} from "@/shared/ui/AdminListLayout.jsx"; +import { DataState } from "@/shared/ui/DataState.jsx"; +import { DataTable } from "@/shared/ui/DataTable.jsx"; +import { createRegionColumnFilter, RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; +import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; +import { UploadField } from "@/shared/ui/UploadField.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; + +const statusOptions = [ + ["", "全部状态"], + ["active", "启用"], + ["disabled", "关闭"] +]; + +const platformOptions = [ + ["", "全部平台"], + ["android", "安卓"], + ["ios", "iOS"] +]; + +export function BannerConfigPage() { + const page = useBannerConfigPage(); + const items = page.data.items || []; + const columns = useMemo(() => bannerColumns(page), [page]); + + return ( + + } variant="primary" onClick={page.openCreate}> + 新增BANNER + + ) : null + } + filters={ + <> + + + + + + + } + /> + + + item.id} /> +
+ {items.length} 条 +
+
+
+ + + page.setForm({ coverUrl })} + /> + page.setForm({ bannerType: event.target.value })}> + H5 + APP + + page.setForm({ param: event.target.value })} + /> + page.setForm({ status: event.target.value })}> + 启用 + 关闭 + + page.setForm({ platform: event.target.value })}> + 安卓 + iOS + + page.setForm({ sortOrder: event.target.value })} + /> + page.setForm({ regionId })} + /> + page.setForm({ countryCode })} + /> + page.setForm({ description: event.target.value })} + /> + +
+ ); +} + +function bannerColumns(page) { + const columns = [ + { + key: "cover", + label: "封面图", + render: (item) => , + width: "minmax(112px, 0.55fr)" + }, + { + key: "target", + label: "类型 / 参数", + render: (item) => , + width: "minmax(260px, 1.3fr)" + }, + { + key: "state", + label: "平台 / 状态", + render: (item) => } />, + width: "minmax(140px, 0.75fr)" + }, + { + key: "scope", + label: "区域 / 国家", + filter: createRegionColumnFilter({ + loading: page.loadingRegions, + options: page.regionOptions, + value: page.regionId, + onChange: page.setRegionId + }), + render: (item) => , + width: "minmax(180px, 0.95fr)" + }, + { + key: "sortOrder", + label: "排序", + width: "minmax(80px, 0.4fr)" + }, + { + key: "description", + label: "描述", + render: (item) => item.description || "-", + width: "minmax(180px, 0.9fr)" + }, + { + key: "updatedAtMs", + label: "更新时间", + render: (item) => formatMillis(item.updatedAtMs), + width: "minmax(160px, 0.8fr)" + } + ]; + + if (!page.abilities.canUpdate) { + return columns; + } + + return [ + ...columns, + { + key: "actions", + label: "操作", + render: (item) => , + width: "minmax(96px, 0.45fr)" + } + ]; +} + +function BannerCover({ src }) { + if (!src) { + return ( + + + + ); + } + return ; +} + +function BannerActions({ item, page }) { + const deleting = page.loadingAction === `delete:${item.id}`; + return ( + + page.openEdit(item)}> + + + page.removeBanner(item)}> + + + + ); +} + +function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) { + return ( + onChange(event.target.value)}> + {emptyLabel} + {options.map((option) => ( + + {option.label} + + ))} + + ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +function StatusBadge({ status }) { + const tone = status === "active" ? "succeeded" : "stopped"; + return ( + + + {status === "active" ? "启用" : "关闭"} + + ); +} + +function typeLabel(value) { + return value === "app" ? "APP" : "H5"; +} + +function platformLabel(value) { + if (value === "android") { + return "安卓"; + } + if (value === "ios") { + return "iOS"; + } + return value || "-"; +} + +function regionLabel(options, value) { + if (!value) { + return "全部区域"; + } + return options.find((option) => option.value === String(value))?.label || `区域 ${value}`; +} + +function countryLabel(options, value) { + if (!value) { + return "全部国家"; + } + return options.find((option) => option.value === value)?.label || value; +} diff --git a/src/features/app-config/permissions.js b/src/features/app-config/permissions.js index 7f31115..c630e1c 100644 --- a/src/features/app-config/permissions.js +++ b/src/features/app-config/permissions.js @@ -6,6 +6,7 @@ export function useAppConfigAbilities() { return { canUpdate: can(PERMISSIONS.appConfigUpdate), + canUpload: can(PERMISSIONS.uploadCreate), canView: can(PERMISSIONS.appConfigView) }; } diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index 7afc396..9fbbc2a 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -8,5 +8,13 @@ export const appConfigRoutes = [ pageKey: "app-config-h5", path: "/app-config/h5", permission: PERMISSIONS.appConfigView + }, + { + label: "BANNER配置", + loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage), + menuCode: MENU_CODES.appConfigBanners, + pageKey: "app-config-banners", + path: "/app-config/banners", + permission: PERMISSIONS.appConfigView } ]; diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index bad434f..7d1fed2 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -13,3 +13,28 @@ export const h5LinkUpdateSchema = z.object({ }); export type H5LinkUpdateForm = z.infer; + +export const appBannerSchema = z.object({ + bannerType: z.enum(["h5", "app"]), + countryCode: z.string().trim().transform((value) => value.toUpperCase()) + .refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"), + coverUrl: z.string().trim().min(1, "请上传封面图").max(1024, "封面图地址不能超过 1024 个字符"), + description: z.string().trim().max(255, "描述不能超过 255 个字符"), + param: z.string().trim().max(2048, "参数不能超过 2048 个字符"), + platform: z.enum(["android", "ios"]), + regionId: z.coerce.number().int().min(0, "区域不正确").default(0), + sortOrder: z.coerce.number().int("排序必须是整数").default(0), + status: z.enum(["active", "disabled"]) +}).superRefine((value, ctx) => { + if (value.bannerType !== "h5") { + return; + } + if (!value.param) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] }); + } + if (/\s/.test(value.param)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] }); + } +}); + +export type AppBannerForm = z.infer; diff --git a/src/features/host-org/components/HostOrgFilters.jsx b/src/features/host-org/components/HostOrgFilters.jsx index 190e78b..1961191 100644 --- a/src/features/host-org/components/HostOrgFilters.jsx +++ b/src/features/host-org/components/HostOrgFilters.jsx @@ -1,5 +1,5 @@ import TextField from "@mui/material/TextField"; -import { RegionSelect } from "@/shared/ui/RegionSelect.jsx"; +import { RegionFilterControl } from "@/shared/ui/RegionFilterControl.jsx"; import { SearchBox } from "@/shared/ui/SearchBox.jsx"; import { StatusSelect } from "@/shared/ui/StatusSelect.jsx"; import styles from "@/features/host-org/host-org.module.css"; @@ -27,7 +27,7 @@ export function HostOrgFilters({ onChange={onStatusChange} />
- ; +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) => ( - - {resource.name || resource.resourceCode || resource.resourceId} - - ))} - - 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]) => ( + {label} + ))} + + page.setForm({ ...form, resourceId: event.target.value })} + > + {page.resourceOptions.map((resource) => ( + + {resource.name || resource.resourceCode || resource.resourceId} + + ))} + + page.setForm({ ...form, chargeAssetType: event.target.value })} + > + {chargeAssetOptions.map(([value, label]) => ( + {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) => ( + {twoDigits(hour)} + ))} + + changeTime("minute", event.target.value)} + > + {Array.from({ length: 60 }, (_, minute) => ( + {twoDigits(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) => ( - - {labels[String(regionId)] || 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) => ( + changeFilter(option.value)} + > + {option.label} + + )) + ) : ( +
{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") } } },