diff --git a/messages/en.json b/messages/en.json index 7c42d08..32fa0e0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -212,7 +212,7 @@ "tooltip": "Query through UserAgent", "confirm": "Confirm", "error": "Please confirm that you have entered the correct content", - "dashboardTitle": "MirrorChyan x {rid} {date} Sales Dashboard", + "dashboard": "MirrorChyan x {rid} Sales Dashboard", "export": "Export CSV file", "application": "Applications", "userAgent": "Checkin user agents", @@ -229,6 +229,7 @@ "count": "Deals", "monthlyCount": "Monthly Sales", "monthlyAmount": "Monthly Revenue", + "refreshSuccess": "Refresh was successful", "lineChart": { "toggleCount": "Click to switch to sales volume", "toggleAmount": "Click to switch to sales revenue", diff --git a/messages/zh.json b/messages/zh.json index 46caf30..228f72c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -206,7 +206,7 @@ "tooltip": "通过请求源查询", "confirm": "确认", "error": "~(>_<) 怎么不对呢!是不是手指抖了一下呀?", - "dashboardTitle": "Mirror酱 x {rid} {date} 销售看板", + "dashboardTitle": "Mirror酱 x {rid} 销售看板", "export": "导出 CSV 文件", "application": "应用", "userAgent": "签到源", @@ -223,6 +223,7 @@ "count": "销量", "monthlyCount": "月销售总量", "monthlyAmount": "月销售总额", + "refreshSuccess": "(☆▽☆) 数据更新成功", "lineChart": { "toggleCount": "点击切换为销量", "toggleAmount": "点击切换为销售额", diff --git a/src/app/[locale]/dashboard/LoginForm.tsx b/src/app/[locale]/dashboard/LoginForm.tsx index a38c238..c71d581 100644 --- a/src/app/[locale]/dashboard/LoginForm.tsx +++ b/src/app/[locale]/dashboard/LoginForm.tsx @@ -10,7 +10,13 @@ import YearMonthPicker from "@/components/YearMonthPicker"; import { RevenueResponse, RevenueType } from "@/app/[locale]/dashboard/page"; type LoginFormProps = { - onLoginSuccess: (data: RevenueType[], rid: string, date: string) => void; + onLoginSuccess: ( + data: RevenueType[], + rid: string, + date: string, + token?: string, + isUa?: boolean + ) => void; }; export default function LoginForm({ onLoginSuccess }: LoginFormProps) { @@ -54,7 +60,7 @@ export default function LoginForm({ onLoginSuccess }: LoginFormProps) { return; } - onLoginSuccess(response.data, rid, month); + onLoginSuccess(response.data, rid, month, token, isUa); } catch (error) { console.error("Error:", error); closeAll(); diff --git a/src/app/[locale]/dashboard/Revenue.tsx b/src/app/[locale]/dashboard/Revenue.tsx index db9de14..45c94b2 100644 --- a/src/app/[locale]/dashboard/Revenue.tsx +++ b/src/app/[locale]/dashboard/Revenue.tsx @@ -2,19 +2,22 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { Button, Card, Skeleton } from "@heroui/react"; +import { Button, Card, Skeleton, Popover, PopoverTrigger, PopoverContent } from "@heroui/react"; import { debounce } from "lodash"; import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts"; import { Props } from "recharts/types/component/DefaultLegendContent"; import { RevenueType } from "@/app/[locale]/dashboard/page"; import SalesList from "@/app/[locale]/dashboard/SalesList"; import SalesLineChart from "@/app/[locale]/dashboard/SalesLineChart"; +import YearMonthPicker from "@/components/YearMonthPicker"; type RevenueProps = { revenueData: RevenueType[]; onLogOut: () => void; date: string; rid: string; + month: string; + onMonthChange: (month: string) => void; }; type ChartDataItem = { @@ -29,7 +32,14 @@ type SalesPieChartProps = { title: string; }; -export default function Revenue({ revenueData, onLogOut, rid, date }: RevenueProps) { +export default function Revenue({ + revenueData, + onLogOut, + rid, + date, + month, + onMonthChange, +}: RevenueProps) { const t = useTranslations("Dashboard"); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -39,7 +49,7 @@ export default function Revenue({ revenueData, onLogOut, rid, date }: RevenuePro setTimeout(() => { setIsLoading(false); }, 1000); - }, []); + }, [revenueData, onLogOut]); // Prepare chart data const applicationData = useMemo(() => { @@ -255,21 +265,38 @@ export default function Revenue({ revenueData, onLogOut, rid, date }: RevenuePro return (
-
+
{/* 标题区 */}
-

- {t("dashboardTitle", { rid, date })} +

+ {t("dashboardTitle", { rid })}

- {/* 导出按钮移动到标题右侧 */} - + +
+

+ + + {date} + + + + + +

+ + +
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index 3573dcc..cc01f09 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -1,6 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { addToast, closeAll } from "@heroui/toast"; +import { CLIENT_BACKEND } from "@/app/requests/misc"; import Revenue from "@/app/[locale]/dashboard/Revenue"; import LoginForm from "@/app/[locale]/dashboard/LoginForm"; @@ -21,35 +24,123 @@ export type RevenueResponse = { }; export default function Dashboard() { + const t = useTranslations("Dashboard"); + + // State const [isLogin, setIsLogin] = useState(false); + + // Revenue data const [revenueData, setRevenueData] = useState([]); const [currentRid, setCurrentRid] = useState(""); const [currentDate, setCurrentDate] = useState(""); + const [currentToken, setCurrentToken] = useState(""); + const [currentIsUa, setCurrentIsUa] = useState(false); + const [month, setMonth] = useState(""); + const [pendingMonth, setPendingMonth] = useState(""); + + // Debounce timer for query form + const debounceTimer = useRef(null); - const handleLoginSuccess = (data: RevenueType[], rid: string, date: string) => { + // Handle login success from LoginForm + const handleLoginSuccess = ( + data: RevenueType[], + rid: string, + date: string, + token?: string, + isUa?: boolean + ) => { setRevenueData(data); setCurrentRid(rid); setCurrentDate(date); + if (token) setCurrentToken(token); + if (isUa !== undefined) setCurrentIsUa(isUa); + setMonth(date); + setPendingMonth(""); setIsLogin(true); }; + // Query effect for when month changes + useEffect(() => { + if (!pendingMonth || !currentRid || !currentToken) return; + + if (debounceTimer.current) clearTimeout(debounceTimer.current); + + debounceTimer.current = setTimeout(async () => { + if (month === pendingMonth) return; + + try { + const response: RevenueResponse = await fetch( + `${CLIENT_BACKEND}/api/billing/revenue?rid=${currentRid}&date=${pendingMonth}&is_ua=${+currentIsUa}`, + { + headers: { Authorization: currentToken }, + } + ).then(res => res.json()); + + if (response.ec === 200) { + closeAll(); + addToast({ + description: t("refreshSuccess"), + color: "success", + }); + setRevenueData(response.data); + setMonth(pendingMonth); + setCurrentDate(pendingMonth); + } else { + closeAll(); + addToast({ + description: t("error"), + color: "warning", + }); + } + } catch (error) { + console.error("Error:", error); + closeAll(); + addToast({ + description: t("error"), + color: "warning", + }); + } + }, 500); + + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + }; + }, [pendingMonth, currentRid, currentToken, currentIsUa, t]); + const handleLogOut = () => { setIsLogin(false); setRevenueData([]); setCurrentRid(""); setCurrentDate(""); + setCurrentToken(""); + setCurrentIsUa(false); + setMonth(""); + setPendingMonth(""); }; - if (isLogin) { + // If not logged in, show the standalone login form + if (!isLogin) { return ( - +
+ +
); } - return ; + // If logged in, show dashboard view with embedded query form on the top + return ( +
+
+ {/* Revenue charts and data display */} + +
+
+ ); } diff --git a/src/components/YearMonthPicker.tsx b/src/components/YearMonthPicker.tsx index 822d1d2..a8a08e5 100644 --- a/src/components/YearMonthPicker.tsx +++ b/src/components/YearMonthPicker.tsx @@ -1,77 +1,197 @@ "use client"; import { Select, SelectItem } from "@heroui/react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { SharedSelection } from "@heroui/system"; +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; + +const START_YEAR = 2025; type PropsType = { onChange?: (value: string) => void; + initialYearMonth?: string; // YYYYMM + showArrow?: boolean; // 是否显示左右箭头 }; -export default function YearMonthPicker({ onChange }: PropsType) { +export default function YearMonthPicker({ onChange, initialYearMonth, showArrow }: PropsType) { + const StrYear = (i: number | string) => String(i); + const StrMonth = (i: number | string) => String(i).padStart(2, "0"); + const currentDate = new Date(); const currentYear = currentDate.getFullYear(); - const currentMonth = formatMonth(currentDate.getMonth() + 1); // 月份从0开始 + const currentMonth = currentDate.getMonth() + 1; // 月份从0开始 - function formatMonth(month: number) { - return month.toString().padStart(2, "0"); - } + const allYears = Array(currentYear - START_YEAR + 1) + .fill(0) + .map((_, i) => StrYear(START_YEAR + i)); + const allMonths = Array(12) + .fill(0) + .map((_, i) => StrMonth(i + 1)); - const startYear = 2025; - const years = Array.from({ length: currentYear - startYear + 1 }, (_, i) => startYear + i); - const months = Array.from({ length: 12 }, (_, i) => formatMonth(i + 1)); + // 解析初始值 + const getInitialYearMonth = () => { + if (initialYearMonth && initialYearMonth.length === 6) { + const year = initialYearMonth.substring(0, 4); + const month = initialYearMonth.substring(4, 6); + if (allYears.includes(year) && allMonths.includes(month)) { + return { year, month }; + } + } + return { year: StrYear(currentYear), month: StrMonth(currentMonth) }; + }; + const { year: initialYearValue, month: initialMonthValue } = getInitialYearMonth(); - // 设置初始值为当前年月 - const [selectedYear, setSelectedYear] = useState(currentYear.toString()); - const [selectedMonth, setSelectedMonth] = useState(currentMonth); + const [selectedYear, setSelectedYear] = useState(initialYearValue); + const [selectedMonth, setSelectedMonth] = useState(initialMonthValue); + // 缓存 onChange 回调 + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + // 监听输入变化 const handleYearChange = (set: SharedSelection) => { - const year = String([...set][0]); + const year = StrYear([...set][0]); setSelectedYear(year); - if (selectedMonth) onChange?.(year + selectedMonth); + if (selectedMonth) onChangeRef.current?.(year + selectedMonth); }; const handleMonthChange = (set: SharedSelection) => { - const month = String([...set][0]); + const month = StrMonth([...set][0]); setSelectedMonth(month); - if (selectedYear) onChange?.(selectedYear + month); + if (selectedYear) onChangeRef.current?.(selectedYear + month); }; - // 初始化时触发一次回调 + // 在 selectedYear 和 selectedMonth 改变时触发回调 + useEffect(() => { + if (selectedYear && selectedMonth) { + onChangeRef.current?.(selectedYear + selectedMonth); + } + }, [selectedYear, selectedMonth]); + + // 当 initialYearMonth 改变时,同步更新选中值 useEffect(() => { - onChange?.(currentYear + currentMonth); - }, []); // 空依赖数组确保只执行一次 + if (initialYearMonth && initialYearMonth.length === 6) { + const year = initialYearMonth.substring(0, 4); + const month = initialYearMonth.substring(4, 6); + if (allYears.includes(year) && allMonths.includes(month)) { + setSelectedYear(year); + setSelectedMonth(month); + } + } + }, [initialYearMonth, allYears, allMonths]); + + // 截断未来的月份列表 + const getMonthsForYear = (year: string) => { + if (parseInt(year) === currentYear) { + return allMonths.slice(0, currentDate.getMonth() + 1); + } + return allMonths; + }; + + // 切换到上一个月 + const goPrevMonth = () => { + let year = parseInt(selectedYear); + let month = parseInt(selectedMonth); + if (month === 1) { + if (year > START_YEAR) { + year -= 1; + month = 12; + } else { + return; // 已到最小月 + } + } else { + month -= 1; + } + const newYear = StrYear(year); + const newMonth = StrMonth(month); + setSelectedYear(newYear); + setSelectedMonth(newMonth); + onChange?.(newYear + newMonth); + }; + + // 切换到下一个月 + const goNextMonth = () => { + let year = parseInt(selectedYear); + let month = parseInt(selectedMonth); + const isCurrentYear = year === currentYear; + const maxMonth = isCurrentYear ? currentDate.getMonth() + 1 : 12; + if (month === maxMonth) { + if (!isCurrentYear && year < currentYear) { + year += 1; + month = 1; + } else { + return; // 已到最大月 + } + } else { + month += 1; + } + if (year > currentYear || (year === currentYear && month > currentDate.getMonth() + 1)) { + return; // 不能超过当前年月 + } + const newYear = StrYear(year); + const newMonth = StrMonth(month); + setSelectedYear(newYear); + setSelectedMonth(newMonth); + onChange?.(newYear + newMonth); + }; + + // 判断是否可切换 + const isMinMonth = selectedYear === StrYear(START_YEAR) && selectedMonth === "01"; + const isMaxMonth = + selectedYear === StrYear(currentYear) && selectedMonth === StrMonth(currentMonth); return ( -
- - +
+ {showArrow && ( + + )} +
+ + +
+ {showArrow && ( + + )}
); }