增加每日任务模块
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 Icon = item.icon;
|
||||||
const children = item.children || [];
|
const children = item.children || [];
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = children.length > 0;
|
||||||
const isActive = Boolean(item.path && activePath.startsWith(item.path));
|
const activeChildPath = findBestActiveChildPath(activePath, children);
|
||||||
const isChildActive = children.some((child) => child.path && activePath.startsWith(child.path));
|
const isActive = isActiveRoute(activePath, item.path);
|
||||||
|
const isChildActive = Boolean(activeChildPath);
|
||||||
const isOpen = isCollapsed ? isFlyoutOpen : isExpanded;
|
const isOpen = isCollapsed ? isFlyoutOpen : isExpanded;
|
||||||
|
|
||||||
const handleParentClick = () => {
|
const handleParentClick = () => {
|
||||||
@ -138,7 +139,7 @@ function NavGroup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
key={child.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNavigate(child.path)}
|
onClick={() => onNavigate(child.path)}
|
||||||
@ -160,7 +161,7 @@ function NavGroup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
key={child.id}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
type="button"
|
type="button"
|
||||||
@ -179,10 +180,27 @@ function NavGroup({
|
|||||||
|
|
||||||
function findParentGroupId(activePath, items) {
|
function findParentGroupId(activePath, items) {
|
||||||
for (const item of 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 item.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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 ApartmentOutlined from "@mui/icons-material/ApartmentOutlined";
|
||||||
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
|
||||||
|
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
|
||||||
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
|
||||||
@ -24,217 +25,236 @@ import { getRouteByMenuCode } from "@/app/router/routeConfig";
|
|||||||
|
|
||||||
const deprecatedMenuCodes = new Set(["services"]);
|
const deprecatedMenuCodes = new Set(["services"]);
|
||||||
const menuLabelOverrides = {
|
const menuLabelOverrides = {
|
||||||
system: "后台设置",
|
system: "后台设置",
|
||||||
"system-users": "后台用户"
|
"system-users": "后台用户",
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
apartment: ApartmentOutlined,
|
apartment: ApartmentOutlined,
|
||||||
category: CategoryOutlined,
|
campaign: CampaignOutlined,
|
||||||
dashboard: DashboardOutlined,
|
category: CategoryOutlined,
|
||||||
gift: CardGiftcardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
inventory: Inventory2Outlined,
|
gift: CardGiftcardOutlined,
|
||||||
network: HubOutlined,
|
inventory: Inventory2Outlined,
|
||||||
team: GroupsOutlined,
|
network: HubOutlined,
|
||||||
history: HistoryOutlined,
|
team: GroupsOutlined,
|
||||||
image: ImageOutlined,
|
history: HistoryOutlined,
|
||||||
login: LoginOutlined,
|
image: ImageOutlined,
|
||||||
map: MapOutlined,
|
login: LoginOutlined,
|
||||||
menu: MenuOpenOutlined,
|
map: MapOutlined,
|
||||||
notifications: NotificationsNoneOutlined,
|
menu: MenuOpenOutlined,
|
||||||
public: PublicOutlined,
|
notifications: NotificationsNoneOutlined,
|
||||||
receipt: ReceiptLongOutlined,
|
public: PublicOutlined,
|
||||||
room: BedroomParentOutlined,
|
receipt: ReceiptLongOutlined,
|
||||||
send: SendOutlined,
|
room: BedroomParentOutlined,
|
||||||
settings: SettingsApplicationsOutlined,
|
send: SendOutlined,
|
||||||
shield: ShieldOutlined,
|
settings: SettingsApplicationsOutlined,
|
||||||
task: TaskAltOutlined,
|
shield: ShieldOutlined,
|
||||||
users: ManageAccountsOutlined,
|
task: TaskAltOutlined,
|
||||||
wallet: WalletOutlined
|
users: ManageAccountsOutlined,
|
||||||
|
wallet: WalletOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fallbackNavigation = [
|
export const fallbackNavigation = [
|
||||||
routeNavItem("overview", { icon: DashboardOutlined }),
|
routeNavItem("overview", { icon: DashboardOutlined }),
|
||||||
{
|
{
|
||||||
code: "system",
|
code: "system",
|
||||||
icon: SettingsApplicationsOutlined,
|
icon: SettingsApplicationsOutlined,
|
||||||
id: "system",
|
id: "system",
|
||||||
label: "后台设置",
|
label: "后台设置",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
routeNavItem("system-roles", { icon: ShieldOutlined }),
|
||||||
routeNavItem("system-menus", { icon: MenuOpenOutlined })
|
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
|
||||||
]
|
routeNavItem("logs-login", { icon: LoginOutlined }),
|
||||||
},
|
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
|
||||||
{
|
],
|
||||||
code: "app-users",
|
},
|
||||||
icon: ManageAccountsOutlined,
|
{
|
||||||
id: "app-users",
|
code: "app-users",
|
||||||
label: "用户管理",
|
icon: ManageAccountsOutlined,
|
||||||
children: [
|
id: "app-users",
|
||||||
routeNavItem("app-user-list", { icon: ManageAccountsOutlined })
|
label: "用户管理",
|
||||||
]
|
children: [
|
||||||
},
|
routeNavItem("app-user-list", { icon: ManageAccountsOutlined }),
|
||||||
{
|
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||||
code: "rooms",
|
],
|
||||||
icon: BedroomParentOutlined,
|
},
|
||||||
id: "rooms",
|
{
|
||||||
label: "房间管理",
|
code: "rooms",
|
||||||
children: [
|
icon: BedroomParentOutlined,
|
||||||
routeNavItem("room-list", { icon: BedroomParentOutlined })
|
id: "rooms",
|
||||||
]
|
label: "房间管理",
|
||||||
},
|
children: [routeNavItem("room-list", { icon: BedroomParentOutlined })],
|
||||||
{
|
},
|
||||||
code: "app-config",
|
{
|
||||||
icon: SettingsApplicationsOutlined,
|
code: "app-config",
|
||||||
id: "app-config",
|
icon: SettingsApplicationsOutlined,
|
||||||
label: "APP配置",
|
id: "app-config",
|
||||||
children: [
|
label: "APP配置",
|
||||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
children: [
|
||||||
routeNavItem("app-config-banners", { icon: ImageOutlined })
|
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||||
]
|
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
code: "resources",
|
{
|
||||||
icon: Inventory2Outlined,
|
code: "resources",
|
||||||
id: "resources",
|
icon: Inventory2Outlined,
|
||||||
label: "资源管理",
|
id: "resources",
|
||||||
children: [
|
label: "资源管理",
|
||||||
routeNavItem("resource-list", { icon: Inventory2Outlined }),
|
children: [
|
||||||
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
|
routeNavItem("resource-list", { icon: Inventory2Outlined }),
|
||||||
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
|
||||||
routeNavItem("resource-grant-list", { icon: SendOutlined })
|
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
||||||
]
|
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
code: "payment",
|
{
|
||||||
icon: WalletOutlined,
|
code: "activities",
|
||||||
id: "payment",
|
icon: CampaignOutlined,
|
||||||
label: "支付管理",
|
id: "activities",
|
||||||
children: [
|
label: "活动管理",
|
||||||
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })
|
children: [routeNavItem("daily-task-list", { icon: TaskAltOutlined })],
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
code: "payment",
|
||||||
code: "logs",
|
icon: WalletOutlined,
|
||||||
icon: HistoryOutlined,
|
id: "payment",
|
||||||
id: "logs",
|
label: "支付管理",
|
||||||
label: "日志审计",
|
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
|
||||||
children: [
|
},
|
||||||
routeNavItem("logs-login", { icon: LoginOutlined }),
|
{
|
||||||
routeNavItem("logs-operations", { icon: ReceiptLongOutlined })
|
code: "geo",
|
||||||
]
|
icon: MapOutlined,
|
||||||
},
|
id: "geo",
|
||||||
{
|
label: "地区管理",
|
||||||
code: "geo",
|
children: [
|
||||||
icon: MapOutlined,
|
routeNavItem("host-org-countries", { icon: PublicOutlined }),
|
||||||
id: "geo",
|
routeNavItem("host-org-regions", { icon: MapOutlined }),
|
||||||
label: "地区管理",
|
],
|
||||||
children: [
|
},
|
||||||
routeNavItem("host-org-countries", { icon: PublicOutlined }),
|
{
|
||||||
routeNavItem("host-org-regions", { icon: MapOutlined })
|
code: "host-org",
|
||||||
]
|
icon: HubOutlined,
|
||||||
},
|
id: "host-org",
|
||||||
{
|
label: "团队管理",
|
||||||
code: "host-org",
|
children: [
|
||||||
icon: HubOutlined,
|
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
||||||
id: "host-org",
|
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
||||||
label: "团队管理",
|
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
||||||
children: [
|
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
|
||||||
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
|
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
|
||||||
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
|
],
|
||||||
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
|
},
|
||||||
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
|
routeNavItem("notifications", { icon: NotificationsNoneOutlined }),
|
||||||
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined })
|
|
||||||
]
|
|
||||||
},
|
|
||||||
routeNavItem("notifications", { icon: NotificationsNoneOutlined })
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function mapBackendMenus(menus = []) {
|
export function mapBackendMenus(menus = []) {
|
||||||
return menus
|
return relocateLogMenusToSystem(menus)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (deprecatedMenuCodes.has(item.code)) {
|
if (deprecatedMenuCodes.has(item.code)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
|
const route = item.code ? getRouteByMenuCode(item.code) : undefined;
|
||||||
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
|
const children = item.children?.length ? mapBackendMenus(item.children) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
children: children?.length ? children : undefined,
|
children: children?.length ? children : undefined,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
icon: iconMap[item.icon] || MenuOpenOutlined,
|
icon: iconMap[item.icon] || MenuOpenOutlined,
|
||||||
id: item.code || String(item.id),
|
id: item.code || String(item.id),
|
||||||
label: menuLabelOverrides[item.code] || route?.label || item.label,
|
label: menuLabelOverrides[item.code] || route?.label || item.label,
|
||||||
path: route?.path || item.path,
|
path: route?.path || item.path,
|
||||||
permissionCode: route?.permission || item.permissionCode
|
permissionCode: route?.permission || item.permissionCode,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.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) {
|
export function filterNavigationByPermission(items = [], can) {
|
||||||
return items
|
return items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const children = filterNavigationByPermission(item.children || [], can);
|
const children = filterNavigationByPermission(item.children || [], can);
|
||||||
const canViewItem = !item.permissionCode || can(item.permissionCode);
|
const canViewItem = !item.permissionCode || can(item.permissionCode);
|
||||||
|
|
||||||
if (children.length || (item.path && canViewItem)) {
|
if (children.length || (item.path && canViewItem)) {
|
||||||
return { ...item, children: children.length ? children : undefined };
|
return { ...item, children: children.length ? children : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
||||||
const merged = primaryItems.map((item) => ({ ...item }));
|
const merged = primaryItems.map((item) => ({ ...item }));
|
||||||
const itemByCode = new Map(merged.map((item) => [item.code, item]));
|
const itemByCode = new Map(merged.map((item) => [item.code, item]));
|
||||||
|
|
||||||
fallbackItems.forEach((fallbackItem) => {
|
fallbackItems.forEach((fallbackItem) => {
|
||||||
const existing = itemByCode.get(fallbackItem.code);
|
const existing = itemByCode.get(fallbackItem.code);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
merged.push(fallbackItem);
|
merged.push(fallbackItem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackItem.children?.length) {
|
if (fallbackItem.children?.length) {
|
||||||
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
|
existing.children = mergeNavigationItems(existing.children || [], fallbackItem.children);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findNavItemByPath(pathname, items = fallbackNavigation) {
|
export function findNavItemByPath(pathname, items = fallbackNavigation) {
|
||||||
let fallback = null;
|
let fallback = null;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.path && pathname.startsWith(item.path)) {
|
if (item.path && pathname.startsWith(item.path)) {
|
||||||
fallback = item;
|
fallback = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children?.length) {
|
||||||
|
const child = findNavItemByPath(pathname, item.children);
|
||||||
|
if (child) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.children?.length) {
|
return fallback;
|
||||||
const child = findNavItemByPath(pathname, item.children);
|
|
||||||
if (child) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeNavItem(menuCode, options = {}) {
|
function routeNavItem(menuCode, options = {}) {
|
||||||
const route = getRouteByMenuCode(menuCode);
|
const route = getRouteByMenuCode(menuCode);
|
||||||
return {
|
return {
|
||||||
code: menuCode,
|
code: menuCode,
|
||||||
icon: options.icon || MenuOpenOutlined,
|
icon: options.icon || MenuOpenOutlined,
|
||||||
id: menuCode,
|
id: menuCode,
|
||||||
label: route?.label || menuCode,
|
label: route?.label || menuCode,
|
||||||
path: route?.path,
|
path: route?.path,
|
||||||
permissionCode: route?.permission
|
permissionCode: route?.permission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,59 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import { fallbackNavigation, filterNavigationByPermission, mergeNavigationItems } from "./menu.js";
|
||||||
fallbackNavigation,
|
|
||||||
filterNavigationByPermission,
|
|
||||||
mergeNavigationItems
|
|
||||||
} from "./menu.js";
|
|
||||||
|
|
||||||
describe("navigation menu helpers", () => {
|
describe("navigation menu helpers", () => {
|
||||||
test("keeps only fallback menu items allowed by permissions", () => {
|
test("keeps only fallback menu items allowed by permissions", () => {
|
||||||
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "agency:view" || code === "host:view");
|
const items = filterNavigationByPermission(
|
||||||
|
fallbackNavigation,
|
||||||
|
(code) => code === "agency:view" || code === "host:view",
|
||||||
|
);
|
||||||
|
|
||||||
expect(flattenCodes(items)).not.toContain("geo");
|
expect(flattenCodes(items)).not.toContain("geo");
|
||||||
expect(flattenCodes(items)).toContain("host-org");
|
expect(flattenCodes(items)).toContain("host-org");
|
||||||
expect(flattenCodes(items)).toContain("host-org-agencies");
|
expect(flattenCodes(items)).toContain("host-org-agencies");
|
||||||
expect(flattenCodes(items)).toContain("host-org-hosts");
|
expect(flattenCodes(items)).toContain("host-org-hosts");
|
||||||
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
expect(flattenCodes(items)).not.toContain("host-org-bds");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("places country and region pages under geo menu", () => {
|
test("places country and region pages under geo menu", () => {
|
||||||
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "country:view" || code === "region:view");
|
const items = filterNavigationByPermission(
|
||||||
const geo = items.find((item) => item.code === "geo");
|
fallbackNavigation,
|
||||||
|
(code) => code === "country:view" || code === "region:view",
|
||||||
|
);
|
||||||
|
const geo = items.find((item) => item.code === "geo");
|
||||||
|
|
||||||
expect(geo).toBeTruthy();
|
expect(geo).toBeTruthy();
|
||||||
expect(geo.children.map((item) => item.code)).toEqual(["host-org-countries", "host-org-regions"]);
|
expect(geo.children.map((item) => item.code)).toEqual(["host-org-countries", "host-org-regions"]);
|
||||||
expect(flattenCodes(items)).not.toContain("host-org");
|
expect(flattenCodes(items)).not.toContain("host-org");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adds missing permitted fallback children to backend menu groups", () => {
|
test("keeps the app user login log entry routable", () => {
|
||||||
const backendMenus = [
|
const items = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-user:view");
|
||||||
{
|
const appUsers = items.find((item) => item.code === "app-users");
|
||||||
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");
|
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) {
|
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",
|
giftCreate: "gift:create",
|
||||||
giftUpdate: "gift:update",
|
giftUpdate: "gift:update",
|
||||||
giftStatus: "gift:status",
|
giftStatus: "gift:status",
|
||||||
|
dailyTaskView: "daily-task:view",
|
||||||
|
dailyTaskCreate: "daily-task:create",
|
||||||
|
dailyTaskUpdate: "daily-task:update",
|
||||||
|
dailyTaskStatus: "daily-task:status",
|
||||||
uploadCreate: "upload:create",
|
uploadCreate: "upload:create",
|
||||||
jobView: "job:view",
|
jobView: "job:view",
|
||||||
jobCancel: "job:cancel",
|
jobCancel: "job:cancel",
|
||||||
@ -93,6 +97,7 @@ export const MENU_CODES = {
|
|||||||
notifications: "notifications",
|
notifications: "notifications",
|
||||||
appUsers: "app-users",
|
appUsers: "app-users",
|
||||||
appUserList: "app-user-list",
|
appUserList: "app-user-list",
|
||||||
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
roomList: "room-list",
|
roomList: "room-list",
|
||||||
appConfig: "app-config",
|
appConfig: "app-config",
|
||||||
@ -103,6 +108,8 @@ export const MENU_CODES = {
|
|||||||
resourceGroupList: "resource-group-list",
|
resourceGroupList: "resource-group-list",
|
||||||
resourceGrantList: "resource-grant-list",
|
resourceGrantList: "resource-grant-list",
|
||||||
giftList: "gift-list",
|
giftList: "gift-list",
|
||||||
|
activities: "activities",
|
||||||
|
dailyTaskList: "daily-task-list",
|
||||||
geo: "geo",
|
geo: "geo",
|
||||||
hostOrg: "host-org",
|
hostOrg: "host-org",
|
||||||
hostOrgCountries: "host-org-countries",
|
hostOrgCountries: "host-org-countries",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { authRoutes } from "@/features/auth/routes.js";
|
|||||||
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
import { appConfigRoutes } from "@/features/app-config/routes.js";
|
||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/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 { hostOrgRoutes } from "@/features/host-org/routes.js";
|
||||||
import { logsRoutes } from "@/features/logs/routes.js";
|
import { logsRoutes } from "@/features/logs/routes.js";
|
||||||
import { menusRoutes } from "@/features/menus/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 publicRoutes: PublicRoute[] = authRoutes;
|
||||||
|
|
||||||
export const adminRoutes: AdminRoute[] = [
|
export const adminRoutes: AdminRoute[] = [
|
||||||
...dashboardRoutes,
|
...dashboardRoutes,
|
||||||
...appUserRoutes,
|
...appUserRoutes,
|
||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
...resourceRoutes,
|
...dailyTaskRoutes,
|
||||||
...paymentRoutes,
|
...resourceRoutes,
|
||||||
...usersRoutes,
|
...paymentRoutes,
|
||||||
...hostOrgRoutes,
|
...usersRoutes,
|
||||||
...rolesRoutes,
|
...hostOrgRoutes,
|
||||||
...menusRoutes,
|
...rolesRoutes,
|
||||||
...logsRoutes,
|
...menusRoutes,
|
||||||
...notificationsRoutes
|
...logsRoutes,
|
||||||
|
...notificationsRoutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const defaultAdminPath = "/overview";
|
export const defaultAdminPath = "/overview";
|
||||||
@ -36,5 +38,5 @@ export const defaultAdminPath = "/overview";
|
|||||||
const routeByMenuCode = new Map<string, AdminRoute>(adminRoutes.map((route) => [route.menuCode, route]));
|
const routeByMenuCode = new Map<string, AdminRoute>(adminRoutes.map((route) => [route.menuCode, route]));
|
||||||
|
|
||||||
export function getRouteByMenuCode(menuCode: MenuCode | string) {
|
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 { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type {
|
||||||
ApiPage,
|
ApiPage,
|
||||||
|
AppUserLoginLogDto,
|
||||||
AppUserDto,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
AppUserUpdatePayload,
|
AppUserUpdatePayload,
|
||||||
EntityId,
|
EntityId,
|
||||||
PageQuery
|
PageQuery,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
|
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
|
||||||
const endpoint = API_ENDPOINTS.appListUsers;
|
const endpoint = API_ENDPOINTS.appListUsers;
|
||||||
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
|
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
|
||||||
method: endpoint.method,
|
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;
|
const endpoint = API_ENDPOINTS.appUpdateUser;
|
||||||
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
|
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
|
||||||
const endpoint = API_ENDPOINTS.appBanUser;
|
const endpoint = API_ENDPOINTS.appBanUser;
|
||||||
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
|
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
|
||||||
method: endpoint.method
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
|
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
|
||||||
const endpoint = API_ENDPOINTS.appUnbanUser;
|
const endpoint = API_ENDPOINTS.appUnbanUser;
|
||||||
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
|
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;
|
const endpoint = API_ENDPOINTS.appSetPassword;
|
||||||
return apiRequest<{ passwordSet: boolean }, AppUserPasswordPayload>(
|
return apiRequest<{ passwordSet: boolean }, AppUserPasswordPayload>(
|
||||||
apiEndpointPath(API_OPERATIONS.appSetPassword, { id: userId }),
|
apiEndpointPath(API_OPERATIONS.appSetPassword, { id: userId }),
|
||||||
{
|
{
|
||||||
body: payload,
|
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";
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
export const appUserRoutes = [
|
export const appUserRoutes = [
|
||||||
{
|
{
|
||||||
label: "用户列表",
|
label: "用户列表",
|
||||||
loader: () => import("./pages/AppUserListPage.jsx").then((module) => module.AppUserListPage),
|
loader: () => import("./pages/AppUserListPage.jsx").then((module) => module.AppUserListPage),
|
||||||
menuCode: MENU_CODES.appUserList,
|
menuCode: MENU_CODES.appUserList,
|
||||||
pageKey: "app-user-list",
|
pageKey: "app-user-list",
|
||||||
path: "/app/users",
|
path: "/app/users",
|
||||||
permission: PERMISSIONS.appUserView
|
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 {
|
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": {
|
"/admin/agencies": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -132,6 +180,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/bd-leaders/{user_id}/status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -756,6 +820,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/app/users/{id}/password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -788,6 +868,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/auth/change-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1845,6 +1941,58 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
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: {
|
listAgencies: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@ -2022,6 +2170,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteBDLeader: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
setBDLeaderStatus: {
|
setBDLeaderStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2746,6 +2908,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
appSetPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2774,6 +2950,18 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
appListLoginLogs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
changePassword: {
|
changePassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -331,6 +331,30 @@ export interface AppUserDto {
|
|||||||
username?: string;
|
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 {
|
export interface AppUserUpdatePayload {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user