增加每日任务模块
This commit is contained in:
parent
0fd67199b6
commit
dd8cd5d952
File diff suppressed because it is too large
Load Diff
@ -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}/`);
|
||||
}
|
||||
|
||||
31
src/app/layout/Sidebar.test.jsx
Normal file
31
src/app/layout/Sidebar.test.jsx
Normal 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");
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 || [])]);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
305
src/features/app-users/pages/AppUserLoginLogsPage.jsx
Normal file
305
src/features/app-users/pages/AppUserLoginLogsPage.jsx
Normal 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 "-";
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
125
src/features/daily-tasks/api.ts
Normal file
125
src/features/daily-tasks/api.ts
Normal 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;
|
||||
}
|
||||
31
src/features/daily-tasks/constants.js
Normal file
31
src/features/daily-tasks/constants.js
Normal 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: "分钟",
|
||||
};
|
||||
51
src/features/daily-tasks/daily-tasks.module.css
Normal file
51
src/features/daily-tasks/daily-tasks.module.css
Normal 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);
|
||||
}
|
||||
217
src/features/daily-tasks/hooks/useDailyTasksPage.js
Normal file
217
src/features/daily-tasks/hooks/useDailyTasksPage.js
Normal 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;
|
||||
}
|
||||
390
src/features/daily-tasks/pages/DailyTaskListPage.jsx
Normal file
390
src/features/daily-tasks/pages/DailyTaskListPage.jsx
Normal 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";
|
||||
}
|
||||
13
src/features/daily-tasks/permissions.js
Normal file
13
src/features/daily-tasks/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
12
src/features/daily-tasks/routes.js
Normal file
12
src/features/daily-tasks/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
54
src/features/daily-tasks/schema.ts
Normal file
54
src/features/daily-tasks/schema.ts
Normal 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
188
src/shared/api/generated/schema.d.ts
vendored
188
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user