增加每日任务模块

This commit is contained in:
zhx 2026-05-09 21:47:48 +08:00
parent 0fd67199b6
commit dd8cd5d952
21 changed files with 6168 additions and 4170 deletions

File diff suppressed because it is too large Load Diff

View File

@ -84,8 +84,9 @@ function NavGroup({
const Icon = item.icon;
const children = item.children || [];
const hasChildren = children.length > 0;
const isActive = Boolean(item.path && activePath.startsWith(item.path));
const isChildActive = children.some((child) => child.path && activePath.startsWith(child.path));
const activeChildPath = findBestActiveChildPath(activePath, children);
const isActive = isActiveRoute(activePath, item.path);
const isChildActive = Boolean(activeChildPath);
const isOpen = isCollapsed ? isFlyoutOpen : isExpanded;
const handleParentClick = () => {
@ -138,7 +139,7 @@ function NavGroup({
return (
<button
className={`nav-item nav-item--child ${child.path && activePath.startsWith(child.path) ? "nav-item--active" : ""}`}
className={`nav-item nav-item--child ${child.path === activeChildPath ? "nav-item--active" : ""}`}
key={child.id}
type="button"
onClick={() => onNavigate(child.path)}
@ -160,7 +161,7 @@ function NavGroup({
return (
<button
className={`nav-flyout__item ${child.path && activePath.startsWith(child.path) ? "nav-flyout__item--active" : ""}`}
className={`nav-flyout__item ${child.path === activeChildPath ? "nav-flyout__item--active" : ""}`}
key={child.id}
role="menuitem"
type="button"
@ -179,10 +180,27 @@ function NavGroup({
function findParentGroupId(activePath, items) {
for (const item of items) {
if (item.children?.some((child) => child.path && activePath.startsWith(child.path))) {
if (findBestActiveChildPath(activePath, item.children || [])) {
return item.id;
}
}
return null;
}
function findBestActiveChildPath(activePath, children) {
return children
.map((child) => child.path)
.filter((path) => isActiveRoute(activePath, path))
.sort((left, right) => right.length - left.length)[0];
}
function isActiveRoute(activePath, itemPath) {
if (!itemPath) {
return false;
}
if (activePath === itemPath) {
return true;
}
return activePath.startsWith(`${itemPath}/`);
}

View File

@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { Sidebar } from "./Sidebar.jsx";
function TestIcon() {
return <span aria-hidden="true" />;
}
test("only marks the longest matching child route active", () => {
render(
<Sidebar
activePath="/app/users/login-logs"
isCollapsed={false}
items={[
{
children: [
{ icon: TestIcon, id: "app-user-list", label: "用户列表", path: "/app/users" },
{ icon: TestIcon, id: "app-user-login-logs", label: "登录日志", path: "/app/users/login-logs" }
],
icon: TestIcon,
id: "app-users",
label: "用户管理"
}
]}
onNavigate={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: "登录日志" })).toHaveClass("nav-item--active");
expect(screen.getByRole("button", { name: "用户列表" })).not.toHaveClass("nav-item--active");
});

View File

@ -1,5 +1,6 @@
import ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
@ -24,217 +25,236 @@ import { getRouteByMenuCode } from "@/app/router/routeConfig";
const deprecatedMenuCodes = new Set(["services"]);
const menuLabelOverrides = {
system: "后台设置",
"system-users": "后台用户"
system: "后台设置",
"system-users": "后台用户",
};
const iconMap = {
apartment: ApartmentOutlined,
category: CategoryOutlined,
dashboard: DashboardOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
network: HubOutlined,
team: GroupsOutlined,
history: HistoryOutlined,
image: ImageOutlined,
login: LoginOutlined,
map: MapOutlined,
menu: MenuOpenOutlined,
notifications: NotificationsNoneOutlined,
public: PublicOutlined,
receipt: ReceiptLongOutlined,
room: BedroomParentOutlined,
send: SendOutlined,
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined
apartment: ApartmentOutlined,
campaign: CampaignOutlined,
category: CategoryOutlined,
dashboard: DashboardOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
network: HubOutlined,
team: GroupsOutlined,
history: HistoryOutlined,
image: ImageOutlined,
login: LoginOutlined,
map: MapOutlined,
menu: MenuOpenOutlined,
notifications: NotificationsNoneOutlined,
public: PublicOutlined,
receipt: ReceiptLongOutlined,
room: BedroomParentOutlined,
send: SendOutlined,
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
};
export const fallbackNavigation = [
routeNavItem("overview", { icon: DashboardOutlined }),
{
code: "system",
icon: SettingsApplicationsOutlined,
id: "system",
label: "后台设置",
children: [
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }),
routeNavItem("system-menus", { icon: MenuOpenOutlined })
]
},
{
code: "app-users",
icon: ManageAccountsOutlined,
id: "app-users",
label: "用户管理",
children: [
routeNavItem("app-user-list", { icon: ManageAccountsOutlined })
]
},
{
code: "rooms",
icon: BedroomParentOutlined,
id: "rooms",
label: "房间管理",
children: [
routeNavItem("room-list", { icon: BedroomParentOutlined })
]
},
{
code: "app-config",
icon: SettingsApplicationsOutlined,
id: "app-config",
label: "APP配置",
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined })
]
},
{
code: "resources",
icon: Inventory2Outlined,
id: "resources",
label: "资源管理",
children: [
routeNavItem("resource-list", { icon: Inventory2Outlined }),
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
routeNavItem("resource-grant-list", { icon: SendOutlined })
]
},
{
code: "payment",
icon: WalletOutlined,
id: "payment",
label: "支付管理",
children: [
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })
]
},
{
code: "logs",
icon: HistoryOutlined,
id: "logs",
label: "日志审计",
children: [
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined })
]
},
{
code: "geo",
icon: MapOutlined,
id: "geo",
label: "地区管理",
children: [
routeNavItem("host-org-countries", { icon: PublicOutlined }),
routeNavItem("host-org-regions", { icon: MapOutlined })
]
},
{
code: "host-org",
icon: HubOutlined,
id: "host-org",
label: "团队管理",
children: [
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined })
]
},
routeNavItem("notifications", { icon: NotificationsNoneOutlined })
routeNavItem("overview", { icon: DashboardOutlined }),
{
code: "system",
icon: SettingsApplicationsOutlined,
id: "system",
label: "后台设置",
children: [
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }),
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
],
},
{
code: "app-users",
icon: ManageAccountsOutlined,
id: "app-users",
label: "用户管理",
children: [
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
],
},
{
code: "rooms",
icon: BedroomParentOutlined,
id: "rooms",
label: "房间管理",
children: [routeNavItem("room-list", { icon: BedroomParentOutlined })],
},
{
code: "app-config",
icon: SettingsApplicationsOutlined,
id: "app-config",
label: "APP配置",
children: [
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
routeNavItem("app-config-banners", { icon: ImageOutlined }),
],
},
{
code: "resources",
icon: Inventory2Outlined,
id: "resources",
label: "资源管理",
children: [
routeNavItem("resource-list", { icon: Inventory2Outlined }),
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
routeNavItem("resource-grant-list", { icon: SendOutlined }),
],
},
{
code: "activities",
icon: CampaignOutlined,
id: "activities",
label: "活动管理",
children: [routeNavItem("daily-task-list", { icon: TaskAltOutlined })],
},
{
code: "payment",
icon: WalletOutlined,
id: "payment",
label: "支付管理",
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
},
{
code: "geo",
icon: MapOutlined,
id: "geo",
label: "地区管理",
children: [
routeNavItem("host-org-countries", { icon: PublicOutlined }),
routeNavItem("host-org-regions", { icon: MapOutlined }),
],
},
{
code: "host-org",
icon: HubOutlined,
id: "host-org",
label: "团队管理",
children: [
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
],
},
routeNavItem("notifications", { icon: NotificationsNoneOutlined }),
];
export function mapBackendMenus(menus = []) {
return menus
.map((item) => {
if (deprecatedMenuCodes.has(item.code)) {
return null;
}
return relocateLogMenusToSystem(menus)
.map((item) => {
if (deprecatedMenuCodes.has(item.code)) {
return null;
}
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
return {
children: children?.length ? children : undefined,
code: item.code,
icon: iconMap[item.icon] || MenuOpenOutlined,
id: item.code || String(item.id),
label: menuLabelOverrides[item.code] || route?.label || item.label,
path: route?.path || item.path,
permissionCode: route?.permission || item.permissionCode
};
})
.filter(Boolean);
return {
children: children?.length ? children : undefined,
code: item.code,
icon: iconMap[item.icon] || MenuOpenOutlined,
id: item.code || String(item.id),
label: menuLabelOverrides[item.code] || route?.label || item.label,
path: route?.path || item.path,
permissionCode: route?.permission || item.permissionCode,
};
})
.filter(Boolean);
}
function relocateLogMenusToSystem(menus = []) {
const systemMenu = menus.find((item) => item.code === "system");
const logsMenu = menus.find((item) => item.code === "logs");
if (!systemMenu || !logsMenu?.children?.length) {
return menus.filter((item) => item.code !== "logs");
}
const systemChildren = systemMenu.children || [];
const existingCodes = new Set(systemChildren.map((item) => item.code));
const movedLogChildren = logsMenu.children.filter((item) => !existingCodes.has(item.code));
return menus
.filter((item) => item.code !== "logs")
.map((item) =>
item.code === "system"
? {
...item,
children: [...systemChildren, ...movedLogChildren],
}
: item,
);
}
export function filterNavigationByPermission(items = [], can) {
return items
.map((item) => {
const children = filterNavigationByPermission(item.children || [], can);
const canViewItem = !item.permissionCode || can(item.permissionCode);
return items
.map((item) => {
const children = filterNavigationByPermission(item.children || [], can);
const canViewItem = !item.permissionCode || can(item.permissionCode);
if (children.length || (item.path && canViewItem)) {
return { ...item, children: children.length ? children : undefined };
}
if (children.length || (item.path && canViewItem)) {
return { ...item, children: children.length ? children : undefined };
}
return null;
})
.filter(Boolean);
return null;
})
.filter(Boolean);
}
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
const merged = primaryItems.map((item) => ({ ...item }));
const itemByCode = new Map(merged.map((item) => [item.code, item]));
const merged = primaryItems.map((item) => ({ ...item }));
const itemByCode = new Map(merged.map((item) => [item.code, item]));
fallbackItems.forEach((fallbackItem) => {
const existing = itemByCode.get(fallbackItem.code);
if (!existing) {
merged.push(fallbackItem);
return;
}
fallbackItems.forEach((fallbackItem) => {
const existing = itemByCode.get(fallbackItem.code);
if (!existing) {
merged.push(fallbackItem);
return;
}
if (fallbackItem.children?.length) {
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
}
});
if (fallbackItem.children?.length) {
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
}
});
return merged;
return merged;
}
export function findNavItemByPath(pathname, items = fallbackNavigation) {
let fallback = null;
let fallback = null;
for (const item of items) {
if (item.path && pathname.startsWith(item.path)) {
fallback = item;
for (const item of items) {
if (item.path && pathname.startsWith(item.path)) {
fallback = item;
}
if (item.children?.length) {
const child = findNavItemByPath(pathname, item.children);
if (child) {
return child;
}
}
}
if (item.children?.length) {
const child = findNavItemByPath(pathname, item.children);
if (child) {
return child;
}
}
}
return fallback;
return fallback;
}
function routeNavItem(menuCode, options = {}) {
const route = getRouteByMenuCode(menuCode);
return {
code: menuCode,
icon: options.icon || MenuOpenOutlined,
id: menuCode,
label: route?.label || menuCode,
path: route?.path,
permissionCode: route?.permission
};
const route = getRouteByMenuCode(menuCode);
return {
code: menuCode,
icon: options.icon || MenuOpenOutlined,
id: menuCode,
label: route?.label || menuCode,
path: route?.path,
permissionCode: route?.permission,
};
}

View File

@ -1,47 +1,59 @@
import { describe, expect, test } from "vitest";
import {
fallbackNavigation,
filterNavigationByPermission,
mergeNavigationItems
} from "./menu.js";
import { fallbackNavigation, filterNavigationByPermission, mergeNavigationItems } from "./menu.js";
describe("navigation menu helpers", () => {
test("keeps only fallback menu items allowed by permissions", () => {
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view" || code === "host:view");
test("keeps only fallback menu items allowed by permissions", () => {
const items = filterNavigationByPermission(
fallbackNavigation,
(code) => code === "agency:view" || code === "host:view",
);
expect(flattenCodes(items)).not.toContain("geo");
expect(flattenCodes(items)).toContain("host-org");
expect(flattenCodes(items)).toContain("host-org-agencies");
expect(flattenCodes(items)).toContain("host-org-hosts");
expect(flattenCodes(items)).not.toContain("host-org-bds");
});
expect(flattenCodes(items)).not.toContain("geo");
expect(flattenCodes(items)).toContain("host-org");
expect(flattenCodes(items)).toContain("host-org-agencies");
expect(flattenCodes(items)).toContain("host-org-hosts");
expect(flattenCodes(items)).not.toContain("host-org-bds");
});
test("places country and region pages under geo menu", () => {
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "country:view" || code === "region:view");
const geo = items.find((item) => item.code === "geo");
test("places country and region pages under geo menu", () => {
const items = filterNavigationByPermission(
fallbackNavigation,
(code) => code === "country:view" || code === "region:view",
);
const geo = items.find((item) => item.code === "geo");
expect(geo).toBeTruthy();
expect(geo.children.map((item) => item.code)).toEqual(["host-org-countries", "host-org-regions"]);
expect(flattenCodes(items)).not.toContain("host-org");
});
expect(geo).toBeTruthy();
expect(geo.children.map((item) => item.code)).toEqual(["host-org-countries", "host-org-regions"]);
expect(flattenCodes(items)).not.toContain("host-org");
});
test("adds missing permitted fallback children to backend menu groups", () => {
const backendMenus = [
{
children: [],
code: "host-org",
icon: fallbackNavigation.find((item) => item.code === "host-org").icon,
id: "host-org",
label: "团队管理"
}
];
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view");
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
test("keeps the app user login log entry routable", () => {
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-user:view");
const appUsers = items.find((item) => item.code === "app-users");
expect(flattenCodes(merged)).toContain("host-org-agencies");
});
expect(appUsers.children.map((item) => item.code)).toContain("app-user-login-logs");
expect(appUsers.children.find((item) => item.code === "app-user-login-logs").path).toBe(
"/app/users/login-logs",
);
});
test("adds missing permitted fallback children to backend menu groups", () => {
const backendMenus = [
{
children: [],
code: "host-org",
icon: fallbackNavigation.find((item) => item.code === "host-org").icon,
id: "host-org",
label: "团队管理",
},
];
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view");
const merged = mergeNavigationItems(backendMenus, fallbackMenus);
expect(flattenCodes(merged)).toContain("host-org-agencies");
});
});
function flattenCodes(items) {
return items.flatMap((item) => [item.code, ...flattenCodes(item.children || [])]);
return items.flatMap((item) => [item.code, ...flattenCodes(item.children || [])]);
}

View File

@ -71,6 +71,10 @@ export const PERMISSIONS = {
giftCreate: "gift:create",
giftUpdate: "gift:update",
giftStatus: "gift:status",
dailyTaskView: "daily-task:view",
dailyTaskCreate: "daily-task:create",
dailyTaskUpdate: "daily-task:update",
dailyTaskStatus: "daily-task:status",
uploadCreate: "upload:create",
jobView: "job:view",
jobCancel: "job:cancel",
@ -93,6 +97,7 @@ export const MENU_CODES = {
notifications: "notifications",
appUsers: "app-users",
appUserList: "app-user-list",
appUserLoginLogs: "app-user-login-logs",
rooms: "rooms",
roomList: "room-list",
appConfig: "app-config",
@ -103,6 +108,8 @@ export const MENU_CODES = {
resourceGroupList: "resource-group-list",
resourceGrantList: "resource-grant-list",
giftList: "gift-list",
activities: "activities",
dailyTaskList: "daily-task-list",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",

View File

@ -2,6 +2,7 @@ import { authRoutes } from "@/features/auth/routes.js";
import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
import { hostOrgRoutes } from "@/features/host-org/routes.js";
import { logsRoutes } from "@/features/logs/routes.js";
import { menusRoutes } from "@/features/menus/routes.js";
@ -17,18 +18,19 @@ import type { AdminRoute, PublicRoute } from "./types";
export const publicRoutes: PublicRoute[] = authRoutes;
export const adminRoutes: AdminRoute[] = [
...dashboardRoutes,
...appUserRoutes,
...roomRoutes,
...appConfigRoutes,
...resourceRoutes,
...paymentRoutes,
...usersRoutes,
...hostOrgRoutes,
...rolesRoutes,
...menusRoutes,
...logsRoutes,
...notificationsRoutes
...dashboardRoutes,
...appUserRoutes,
...roomRoutes,
...appConfigRoutes,
...dailyTaskRoutes,
...resourceRoutes,
...paymentRoutes,
...usersRoutes,
...hostOrgRoutes,
...rolesRoutes,
...menusRoutes,
...logsRoutes,
...notificationsRoutes,
];
export const defaultAdminPath = "/overview";
@ -36,5 +38,5 @@ export const defaultAdminPath = "/overview";
const routeByMenuCode = new Map<string, AdminRoute>(adminRoutes.map((route) => [route.menuCode, route]));
export function getRouteByMenuCode(menuCode: MenuCode | string) {
return routeByMenuCode.get(menuCode);
return routeByMenuCode.get(menuCode);
}

View File

@ -2,18 +2,27 @@ import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type {
ApiPage,
AppUserLoginLogDto,
AppUserDto,
AppUserPasswordPayload,
AppUserUpdatePayload,
EntityId,
PageQuery
PageQuery,
} from "@/shared/api/types";
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
const endpoint = API_ENDPOINTS.appListUsers;
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
method: endpoint.method,
query
query,
});
}
export function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
const endpoint = API_ENDPOINTS.appListLoginLogs;
return apiRequest<ApiPage<AppUserLoginLogDto>>(apiEndpointPath(API_OPERATIONS.appListLoginLogs), {
method: endpoint.method,
query,
});
}
@ -21,31 +30,34 @@ export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload):
const endpoint = API_ENDPOINTS.appUpdateUser;
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
body: payload,
method: endpoint.method
method: endpoint.method,
});
}
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method
method: endpoint.method,
});
}
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
method: endpoint.method
method: endpoint.method,
});
}
export function setAppUserPassword(userId: EntityId, payload: AppUserPasswordPayload): Promise<{ passwordSet: boolean }> {
export function setAppUserPassword(
userId: EntityId,
payload: AppUserPasswordPayload,
): Promise<{ passwordSet: boolean }> {
const endpoint = API_ENDPOINTS.appSetPassword;
return apiRequest<{ passwordSet: boolean }, AppUserPasswordPayload>(
apiEndpointPath(API_OPERATIONS.appSetPassword, { id: userId }),
{
body: payload,
method: endpoint.method
}
method: endpoint.method,
},
);
}

View File

@ -0,0 +1,305 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toPageQuery } from "@/shared/api/query";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminFilterResetButton } from "@/shared/ui/AdminListLayout.jsx";
import { FilterChip } from "@/shared/ui/FilterChip.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { listAppUserLoginLogs } from "@/features/app-users/api";
import styles from "@/features/app-users/app-users.module.css";
const pageSize = 20;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const resultOptions = [
{ label: "成功", value: "success" },
{ label: "失败", value: "failed" },
{ label: "已阻断", value: "blocked" },
];
export function AppUserLoginLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState("");
const [result, setResult] = useState("");
const [regionId, setRegionId] = useState("");
const [page, setPage] = useState(1);
const { loadingRegions, regionOptions } = useRegionOptions();
const userFilter = searchParams.get("user_id") || "";
const regionFilterOptions = useMemo(() => [{ label: "GLOBAL · 0", value: "0" }, ...regionOptions], [regionOptions]);
const requestQuery = useMemo(
() =>
toPageQuery({
keyword: query,
page,
pageSize,
region_id: regionId,
user_id: userFilter,
...(result === "blocked" ? { blocked: "blocked" } : { result }),
}),
[page, query, regionId, result, userFilter],
);
const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]);
const { data, error, loading, reload } = useAdminQuery(loadLogs, {
errorMessage: "加载用户登录日志失败",
initialData: emptyData,
keepPreviousData: true,
queryKey: ["app-users", "login-logs", requestQuery],
});
const changeQuery = useCallback((value) => {
setQuery(value);
setPage(1);
}, []);
const changeResult = useCallback((value) => {
setResult(value);
setPage(1);
}, []);
const changeRegionId = useCallback((value) => {
setRegionId(value);
setPage(1);
}, []);
const clearUserFilter = useCallback(() => {
setSearchParams(new URLSearchParams(), { replace: true });
setPage(1);
}, [setSearchParams]);
const showUserHistory = useCallback(
(log) => {
if (!log.userId) {
return;
}
const nextSearchParams = new URLSearchParams(searchParams);
nextSearchParams.set("user_id", log.userId);
setSearchParams(nextSearchParams, { replace: true });
setQuery("");
setResult("");
setRegionId("");
setPage(1);
},
[searchParams, setSearchParams],
);
const resetFilters = () => {
setQuery("");
setResult("");
setRegionId("");
setSearchParams(new URLSearchParams(), { replace: true });
setPage(1);
};
const columns = useMemo(
() => [
{
key: "identity",
label: "用户",
width: "minmax(240px, 1.4fr)",
filter: createTextColumnFilter({
placeholder: "搜索昵称、短 ID、用户 ID、IP",
value: query,
onChange: changeQuery,
}),
render: (log) => <UserIdentity log={log} />,
},
{
key: "ip",
label: "IP",
width: "minmax(160px, 1fr)",
render: (log) => (
<div className={styles.stack}>
<span>{log.loginIp || "-"}</span>
<span className={styles.meta}>{log.ipCountryCode || "-"}</span>
</div>
),
},
{
key: "region",
label: "区域",
width: "minmax(150px, 0.9fr)",
filter: createRegionColumnFilter({
loading: loadingRegions,
options: regionFilterOptions,
value: regionId,
onChange: changeRegionId,
}),
render: (log) => (
<div className={styles.stack}>
<span>{log.country || "-"}</span>
<span className={styles.meta}>{formatRegion(log)}</span>
</div>
),
},
{
key: "client",
label: "客户端",
width: "minmax(160px, 1fr)",
render: (log) => (
<div className={styles.stack}>
<span>{log.platform || "-"}</span>
<span className={styles.meta}>{log.channel || "-"}</span>
</div>
),
},
{
key: "method",
label: "方式",
width: "minmax(150px, 0.9fr)",
render: (log) => (
<div className={styles.stack}>
<span>{log.loginType || "-"}</span>
<span className={styles.meta}>{log.provider || "-"}</span>
</div>
),
},
{
key: "result",
label: "状态",
width: "minmax(130px, 0.7fr)",
filter: createOptionsColumnFilter({
emptyLabel: "全部状态",
options: resultOptions,
placeholder: "搜索状态",
value: result,
onChange: changeResult,
}),
render: (log) => <ResultBadge log={log} />,
},
{
key: "detail",
label: "详情",
width: "minmax(220px, 1.2fr)",
render: (log) => (
<span className="muted">{log.blockReason || log.failureCode || log.requestId || "-"}</span>
),
},
{
key: "createdAtMs",
label: "时间",
width: "180px",
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
},
],
[changeQuery, changeRegionId, changeResult, loadingRegions, query, regionFilterOptions, regionId, result],
);
const selectedUserLabel = useMemo(() => {
if (!userFilter) {
return "";
}
const matchedLog = (data.items || []).find((log) => String(log.userId || "") === String(userFilter));
return matchedLog?.displayUserId || matchedLog?.username || userFilter;
}, [data.items, userFilter]);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<div className={styles.toolbar}>
<section className={styles.filters}>
{userFilter ? (
<FilterChip active label={`用户 ${selectedUserLabel || userFilter}`} onClick={clearUserFilter} />
) : null}
<AdminFilterResetButton
disabled={!query && !result && !regionId && !userFilter}
onClick={resetFilters}
/>
</section>
</div>
<DataState error={error} loading={loading} onRetry={reload}>
<div className={styles.listBlock}>
<DataTable
columns={columns}
emptyLabel="暂无登录日志"
items={data.items || []}
minWidth="1380px"
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
rowKey={(log) => log.id}
title="登录日志"
total={data.total}
/>
{data.total > 0 ? (
<PaginationBar
page={page}
pageSize={data.pageSize || pageSize}
total={data.total}
onPageChange={setPage}
/>
) : null}
</div>
</DataState>
</div>
</section>
);
}
function loginLogRowProps(log, onOpenHistory) {
if (!log.userId) {
return {};
}
return {
"aria-label": `查看用户 ${log.displayUserId || log.userId} 的登录日志`,
role: "button",
tabIndex: 0,
onClick: () => onOpenHistory(log),
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenHistory(log);
}
},
};
}
function UserIdentity({ log }) {
const name = log.username || "-";
const shortId = log.displayUserId || log.userId || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{log.avatar ? <img src={log.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function ResultBadge({ log }) {
const result = String(log.result || "").toLowerCase();
const blocked = log.blocked || result === "blocked";
const tone = result === "success" || result === "succeeded" ? "running" : blocked ? "danger" : "warning";
const labels = {
blocked: "已阻断",
failed: "失败",
success: "成功",
succeeded: "成功",
};
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{labels[result] || (blocked ? "已阻断" : result || "-")}
</span>
);
}
function formatRegion(log) {
if (log.regionName) {
return log.regionName;
}
if (log.regionId) {
return `区域 ${log.regionId}`;
}
return "-";
}

View File

@ -1,12 +1,20 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const appUserRoutes = [
{
label: "用户列表",
loader: () => import("./pages/AppUserListPage.jsx").then((module) => module.AppUserListPage),
menuCode: MENU_CODES.appUserList,
pageKey: "app-user-list",
path: "/app/users",
permission: PERMISSIONS.appUserView
}
{
label: "用户列表",
loader: () => import("./pages/AppUserListPage.jsx").then((module) => module.AppUserListPage),
menuCode: MENU_CODES.appUserList,
pageKey: "app-user-list",
path: "/app/users",
permission: PERMISSIONS.appUserView,
},
{
label: "登录日志",
loader: () => import("./pages/AppUserLoginLogsPage.jsx").then((module) => module.AppUserLoginLogsPage),
menuCode: MENU_CODES.appUserLoginLogs,
pageKey: "app-user-login-logs",
path: "/app/users/login-logs",
permission: PERMISSIONS.appUserView,
},
];

View File

@ -0,0 +1,125 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, EntityId, PageQuery } from "@/shared/api/types";
export interface TaskDefinitionDto {
taskId: string;
taskType: string;
category: string;
metricType: string;
title: string;
description?: string;
targetValue: number;
targetUnit: string;
rewardCoinAmount: number;
status: string;
sortOrder?: number;
version?: number;
effectiveFromMs?: number;
effectiveToMs?: number;
createdByAdminId?: number;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface TaskDefinitionPayload {
task_type: string;
category: string;
metric_type: string;
title: string;
description: string;
target_value: number;
target_unit: string;
reward_coin_amount: number;
status: string;
sort_order: number;
effective_from_ms: number;
effective_to_ms: number;
}
export interface TaskStatusPayload {
status: string;
}
export function listTaskDefinitions(query: PageQuery = {}): Promise<ApiPage<TaskDefinitionDto>> {
const endpoint = API_ENDPOINTS.listTaskDefinitions;
return apiRequest<ApiPage<RawTaskDefinitionDto>>(apiEndpointPath(API_OPERATIONS.listTaskDefinitions), {
method: endpoint.method,
query,
}).then(normalizeTaskPage);
}
export function createTaskDefinition(payload: TaskDefinitionPayload): Promise<TaskDefinitionDto> {
const endpoint = API_ENDPOINTS.createTaskDefinition;
return apiRequest<RawTaskDefinitionDto, TaskDefinitionPayload>(
apiEndpointPath(API_OPERATIONS.createTaskDefinition),
{
body: payload,
method: endpoint.method,
},
).then(normalizeTaskDefinition);
}
export function updateTaskDefinition(taskId: EntityId, payload: TaskDefinitionPayload): Promise<TaskDefinitionDto> {
const endpoint = API_ENDPOINTS.updateTaskDefinition;
return apiRequest<RawTaskDefinitionDto, TaskDefinitionPayload>(
apiEndpointPath(API_OPERATIONS.updateTaskDefinition, { task_id: taskId }),
{
body: payload,
method: endpoint.method,
},
).then(normalizeTaskDefinition);
}
export function setTaskDefinitionStatus(taskId: EntityId, status: string): Promise<TaskDefinitionDto> {
const endpoint = API_ENDPOINTS.setTaskDefinitionStatus;
return apiRequest<RawTaskDefinitionDto, TaskStatusPayload>(
apiEndpointPath(API_OPERATIONS.setTaskDefinitionStatus, { task_id: taskId }),
{
body: { status },
method: endpoint.method,
},
).then(normalizeTaskDefinition);
}
type RawTaskDefinitionDto = TaskDefinitionDto & Record<string, unknown>;
function normalizeTaskPage(page: ApiPage<RawTaskDefinitionDto>): ApiPage<TaskDefinitionDto> {
return {
...page,
items: (page.items || []).map(normalizeTaskDefinition),
};
}
function normalizeTaskDefinition(task: RawTaskDefinitionDto): TaskDefinitionDto {
return {
taskId: stringValue(task.taskId ?? task.task_id),
taskType: stringValue(task.taskType ?? task.task_type),
category: stringValue(task.category),
metricType: stringValue(task.metricType ?? task.metric_type),
title: stringValue(task.title),
description: stringValue(task.description),
targetValue: numberValue(task.targetValue ?? task.target_value),
targetUnit: stringValue(task.targetUnit ?? task.target_unit),
rewardCoinAmount: numberValue(task.rewardCoinAmount ?? task.reward_coin_amount),
status: stringValue(task.status),
sortOrder: numberValue(task.sortOrder ?? task.sort_order),
version: numberValue(task.version),
effectiveFromMs: numberValue(task.effectiveFromMs ?? task.effective_from_ms),
effectiveToMs: numberValue(task.effectiveToMs ?? task.effective_to_ms),
createdByAdminId: numberValue(task.createdByAdminId ?? task.created_by_admin_id),
updatedByAdminId: numberValue(task.updatedByAdminId ?? task.updated_by_admin_id),
createdAtMs: numberValue(task.createdAtMs ?? task.created_at_ms),
updatedAtMs: numberValue(task.updatedAtMs ?? task.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,31 @@
export const taskTypeOptions = [
["daily", "每日任务"],
["exclusive", "专属任务"],
];
export const taskStatusOptions = [
["", "全部状态"],
["draft", "草稿"],
["active", "启用"],
["paused", "暂停"],
["archived", "归档"],
];
export const taskStatusLabels = {
active: "启用",
archived: "归档",
draft: "草稿",
paused: "暂停",
};
export const taskUnitOptions = [
["count", "次数"],
["minute", "分钟"],
["coin", "金币"],
];
export const taskUnitLabels = {
coin: "金币",
count: "次数",
minute: "分钟",
};

View File

@ -0,0 +1,51 @@
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.identityText,
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeBadge {
display: inline-flex;
min-width: 64px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--primary-border);
border-radius: var(--radius-sm);
background: var(--primary-surface);
color: var(--primary);
font-size: var(--admin-font-size);
font-weight: 700;
line-height: 1;
}
.statusCell {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-1);
}

View File

@ -0,0 +1,217 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
createTaskDefinition,
listTaskDefinitions,
setTaskDefinitionStatus,
updateTaskDefinition,
} from "@/features/daily-tasks/api";
import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js";
import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
const pageSize = 10;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => ({
category: task.category || "",
description: task.description || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
status: task.status || "draft",
targetUnit: task.targetUnit || "count",
targetValue: task.targetValue === 0 || task.targetValue ? String(task.targetValue) : "",
taskType: task.taskType || "daily",
title: task.title || "",
});
export function useDailyTasksPage() {
const abilities = useDailyTaskAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [taskType, setTaskType] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [selectedTask, setSelectedTask] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
category,
keyword: query,
status,
task_type: taskType,
}),
[category, query, status, taskType],
);
const {
data = emptyData,
error,
loading,
reload,
} = usePaginatedQuery({
errorMessage: "加载每日任务失败",
fetcher: listTaskDefinitions,
filters,
page,
pageSize,
queryKey: ["daily-tasks", filters, page],
});
const changeQuery = resetSetter(setQuery, setPage);
const changeStatus = resetSetter(setStatus, setPage);
const changeTaskType = resetSetter(setTaskType, setPage);
const changeCategory = resetSetter(setCategory, setPage);
const resetFilters = () => {
setQuery("");
setStatus("");
setTaskType("");
setCategory("");
setPage(1);
};
const openCreate = () => {
setSelectedTask(null);
setForm(emptyForm());
setActiveAction("create");
};
const openEdit = (task) => {
if (!task?.taskId) {
return;
}
setSelectedTask(task);
setForm(emptyForm(task));
setActiveAction("edit");
};
const closeAction = () => {
setActiveAction("");
setSelectedTask(null);
};
const submitTask = async (event) => {
event.preventDefault();
const editing = activeAction === "edit";
if (editing && !selectedTask?.taskId) {
return;
}
await runAction(editing ? "edit" : "create", editing ? "任务已更新" : "任务已创建", async () => {
const payload = buildTaskPayload(parseForm(dailyTaskFormSchema, form));
if (editing) {
await updateTaskDefinition(selectedTask.taskId, payload);
} else {
await createTaskDefinition(payload);
}
closeAction();
setForm(emptyForm());
await reload();
});
};
const toggleTask = async (task, nextEnabled = task.status !== "active") => {
if (!abilities.canStatus || !task?.taskId || task.status === "archived") {
return;
}
const nextStatus = nextEnabled ? "active" : "paused";
if (task.status === nextStatus) {
return;
}
await runAction(`status-${task.taskId}`, nextEnabled ? "任务已启用" : "任务已暂停", async () => {
await setTaskDefinitionStatus(task.taskId, nextStatus);
await reload();
});
};
const runAction = async (action, successMessage, submitter) => {
setLoadingAction(action);
try {
await submitter();
showToast(successMessage, "success");
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
activeAction,
category,
changeCategory,
changeQuery,
changeStatus,
changeTaskType,
closeAction,
data,
error,
form,
loading,
loadingAction,
openCreate,
openEdit,
page,
query,
reload,
resetFilters,
selectedTask,
setForm,
setPage,
status,
submitTask,
taskType,
toggleTask,
};
}
function buildTaskPayload(form) {
return {
category: form.category.trim(),
description: form.description.trim(),
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
metric_type: form.metricType.trim(),
reward_coin_amount: Number(form.rewardCoinAmount),
sort_order: Number(form.sortOrder || 0),
status: form.status,
target_unit: form.targetUnit,
target_value: Number(form.targetValue),
task_type: form.taskType,
title: form.title.trim(),
};
}
function resetSetter(setter, setPage) {
return (value) => {
setter(value);
setPage(1);
};
}
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;
}

View File

@ -0,0 +1,390 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Switch from "@mui/material/Switch";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
taskStatusLabels,
taskStatusOptions,
taskTypeOptions,
taskUnitLabels,
taskUnitOptions,
} from "@/features/daily-tasks/constants.js";
import { useDailyTasksPage } from "@/features/daily-tasks/hooks/useDailyTasksPage.js";
import styles from "@/features/daily-tasks/daily-tasks.module.css";
const baseColumns = [
{
key: "task",
label: "任务",
width: "minmax(260px, 1.4fr)",
render: (task) => <TaskIdentity task={task} />,
},
{
key: "metric",
label: "指标",
width: "minmax(220px, 1.1fr)",
render: (task) => (
<div className={styles.stack}>
<span>{task.metricType}</span>
<span className={styles.meta}>{task.category}</span>
</div>
),
},
{
key: "target",
label: "目标 / 奖励",
width: "minmax(150px, 0.75fr)",
render: (task) => (
<div className={styles.stack}>
<span>
{formatNumber(task.targetValue)} {taskUnitLabel(task.targetUnit)}
</span>
<span className={styles.meta}>{formatNumber(task.rewardCoinAmount)} 金币</span>
</div>
),
},
{
key: "effective",
label: "有效期",
width: "minmax(220px, 1.1fr)",
render: (task) => (
<div className={styles.stack}>
<span>{task.effectiveFromMs ? formatMillis(task.effectiveFromMs) : "立即生效"}</span>
<span className={styles.meta}>
{task.effectiveToMs ? formatMillis(task.effectiveToMs) : "长期有效"}
</span>
</div>
),
},
{
key: "version",
label: "版本 / 排序",
width: "minmax(120px, 0.65fr)",
render: (task) => (
<div className={styles.stack}>
<span>v{task.version || 1}</span>
<span className={styles.meta}>{task.sortOrder || 0}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(116px, 0.6fr)",
},
{
key: "updatedAt",
label: "更新时间",
width: "minmax(170px, 0.85fr)",
render: (task) => formatMillis(task.updatedAtMs || task.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.4fr)",
},
];
export function DailyTaskListPage() {
const page = useDailyTasksPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const isEditing = page.activeAction === "edit";
const columns = baseColumns.map((column) => {
if (column.key === "task") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索任务名称、任务 ID、指标",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "metric") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索分类",
value: page.category,
onChange: page.changeCategory,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: taskStatusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (task) => <TaskStatusSwitch page={page} task={task} />,
};
}
if (column.key === "actions") {
return {
...column,
render: (task) => <TaskRowActions page={page} task={task} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<AdminFilterSelect
label="任务类型"
options={[["", "全部类型"], ...taskTypeOptions]}
value={page.taskType}
onChange={page.changeTaskType}
/>
<AdminFilterResetButton
disabled={!page.query && !page.status && !page.taskType && !page.category}
onClick={page.resetFilters}
/>
</>
}
actions={
page.abilities.canCreate ? (
<AdminActionIconButton label="添加任务" primary onClick={page.openCreate}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1340px" rowKey={(task) => task.taskId} />
{total > 0 ? (
<PaginationBar
page={page.page}
pageSize={page.data.pageSize || 10}
total={total}
onPageChange={page.setPage}
/>
) : null}
</AdminListBody>
</DataState>
<TaskFormDialog
disabled={isEditing ? !page.abilities.canUpdate : !page.abilities.canCreate}
form={page.form}
loading={isEditing ? page.loadingAction === "edit" : page.loadingAction === "create"}
mode={isEditing ? "edit" : "create"}
open={page.activeAction === "create" || page.activeAction === "edit"}
page={page}
onClose={page.closeAction}
onSubmit={page.submitTask}
/>
</AdminListPage>
);
}
function TaskIdentity({ task }) {
return (
<div className={styles.identity}>
<span className={styles.typeBadge}>{taskTypeLabel(task.taskType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{task.title || task.taskId}</span>
<span className={styles.meta}>{task.taskId}</span>
</div>
</div>
);
}
function TaskStatusSwitch({ page, task }) {
const checked = task.status === "active";
const disabled =
!page.abilities.canStatus || task.status === "archived" || page.loadingAction === `status-${task.taskId}`;
return (
<div className={styles.statusCell}>
<Switch
checked={checked}
disabled={disabled}
inputProps={{ "aria-label": checked ? "暂停任务" : "启用任务" }}
onChange={(event) => page.toggleTask(task, event.target.checked)}
/>
<span className={styles.meta}>{taskStatusLabel(task.status)}</span>
</div>
);
}
function TaskRowActions({ page, task }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdate}
label="编辑任务"
onClick={() => page.openEdit(task)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page }) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="large"
submitDisabled={disabled || loading}
title={mode === "edit" ? "编辑任务" : "创建任务"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="任务类型"
required
select
value={form.taskType}
onChange={(event) => page.setForm({ ...form, taskType: event.target.value })}
>
{taskTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="状态"
required
select
value={form.status}
onChange={(event) => page.setForm({ ...form, status: event.target.value })}
>
{taskStatusOptions
.filter(([value]) => value)
.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="任务名称"
required
value={form.title}
onChange={(event) => page.setForm({ ...form, title: event.target.value })}
/>
<TextField
disabled={disabled}
label="任务分类"
required
value={form.category}
onChange={(event) => page.setForm({ ...form, category: event.target.value })}
/>
<TextField
disabled={disabled}
label="指标类型"
required
value={form.metricType}
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
/>
<TextField
disabled={disabled}
label="目标单位"
required
select
value={form.targetUnit}
onChange={(event) => page.setForm({ ...form, targetUnit: event.target.value })}
>
{taskUnitOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="目标值"
required
type="number"
value={form.targetValue}
onChange={(event) => page.setForm({ ...form, targetValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="奖励金币"
required
type="number"
value={form.rewardCoinAmount}
onChange={(event) => page.setForm({ ...form, rewardCoinAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效开始时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveFrom}
onChange={(event) => page.setForm({ ...form, effectiveFrom: event.target.value })}
/>
<TextField
disabled={disabled}
label="有效结束时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={form.effectiveTo}
onChange={(event) => page.setForm({ ...form, effectiveTo: event.target.value })}
/>
<TextField
disabled={disabled}
label="任务描述"
multiline
minRows={2}
value={form.description}
onChange={(event) => page.setForm({ ...form, description: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormDialog>
);
}
function taskTypeLabel(value) {
return taskTypeOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
}
function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-";
}
function taskUnitLabel(value) {
return taskUnitLabels[value] || value || "";
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "0";
}

View File

@ -0,0 +1,13 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useDailyTaskAbilities() {
const { can } = useAuth();
return {
canCreate: can(PERMISSIONS.dailyTaskCreate),
canStatus: can(PERMISSIONS.dailyTaskStatus),
canUpdate: can(PERMISSIONS.dailyTaskUpdate),
canView: can(PERMISSIONS.dailyTaskView),
};
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const dailyTaskRoutes = [
{
label: "每日任务",
loader: () => import("./pages/DailyTaskListPage.jsx").then((module) => module.DailyTaskListPage),
menuCode: MENU_CODES.dailyTaskList,
pageKey: "daily-task-list",
path: "/activities/daily-tasks",
permission: PERMISSIONS.dailyTaskView,
},
];

View File

@ -0,0 +1,54 @@
import { z } from "zod";
export const dailyTaskFormSchema = z
.object({
category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"),
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
rewardCoinAmount: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
status: z.enum(["draft", "active", "paused", "archived"]),
targetUnit: z.enum(["count", "minute", "coin"]),
targetValue: z.union([z.string(), z.number()]),
taskType: z.enum(["daily", "exclusive"]),
title: z.string().trim().min(1, "请输入任务名称").max(160, "任务名称不能超过 160 个字符"),
})
.superRefine((value, context) => {
const targetValue = Number(value.targetValue);
const rewardCoinAmount = Number(value.rewardCoinAmount);
const sortOrder = Number(value.sortOrder || 0);
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
if (!Number.isInteger(targetValue) || targetValue <= 0) {
context.addIssue({ code: "custom", message: "请输入正整数目标值", path: ["targetValue"] });
}
if (!Number.isInteger(rewardCoinAmount) || rewardCoinAmount <= 0) {
context.addIssue({ code: "custom", message: "请输入正整数奖励金币", path: ["rewardCoinAmount"] });
}
if (!Number.isInteger(sortOrder)) {
context.addIssue({ code: "custom", message: "排序必须是整数", path: ["sortOrder"] });
}
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"] });
}
});
export type DailyTaskForm = z.infer<typeof dailyTaskFormSchema>;
function datetimeLocalToMs(value: unknown) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return 0;
}
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : -1;
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,54 @@
*/
export interface paths {
"/admin/activity/daily-tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listTaskDefinitions"];
put?: never;
post: operations["createTaskDefinition"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/daily-tasks/{task_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateTaskDefinition"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/daily-tasks/{task_id}/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["setTaskDefinitionStatus"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/agencies": {
parameters: {
query?: never;
@ -132,6 +180,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/bd-leaders/{user_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteBDLeader"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/bd-leaders/{user_id}/status": {
parameters: {
query?: never;
@ -756,6 +820,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/app/users/{id}/login-logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["appListUserLoginLogs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/app/users/{id}/password": {
parameters: {
query?: never;
@ -788,6 +868,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/app/users/login-logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["appListLoginLogs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/change-password": {
parameters: {
query?: never;
@ -1845,6 +1941,58 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
listTaskDefinitions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createTaskDefinition: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateTaskDefinition: {
parameters: {
query?: never;
header?: never;
path: {
task_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
setTaskDefinitionStatus: {
parameters: {
query?: never;
header?: never;
path: {
task_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listAgencies: {
parameters: {
query?: {
@ -2022,6 +2170,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
deleteBDLeader: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
setBDLeaderStatus: {
parameters: {
query?: never;
@ -2746,6 +2908,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
appListUserLoginLogs: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["Id"];
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
appSetPassword: {
parameters: {
query?: never;
@ -2774,6 +2950,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
appListLoginLogs: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
changePassword: {
parameters: {
query?: never;

View File

@ -331,6 +331,30 @@ export interface AppUserDto {
username?: string;
}
export interface AppUserLoginLogDto {
avatar?: string;
blockReason?: string;
blocked?: boolean;
channel?: string;
country?: string;
createdAtMs?: number;
displayUserId?: string;
failureCode?: string;
id: number;
ipCountryCode?: string;
loginIp?: string;
loginType?: string;
platform?: string;
provider?: string;
regionId?: number;
regionName?: string;
requestId?: string;
result?: string;
riskHighlighted?: boolean;
userId?: string;
username?: string;
}
export interface AppUserUpdatePayload {
avatar?: string;
country?: string;