page.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import {
  4. fetchAnalyticsSummary,
  5. type AnalyticsDailyRow,
  6. type AnalyticsSummaryResponse,
  7. } from "@/features/agent/services/analyticsApi";
  8. import { Button } from "@/components/ui/button";
  9. import { toast } from "@/hooks/useToast";
  10. import type { I18nKey } from "@/lib/i18n/dict";
  11. import { useI18n } from "@/lib/i18n/provider";
  12. function formatPercent(n: number) {
  13. if (Number.isNaN(n)) return "—";
  14. return `${n.toFixed(2)}%`;
  15. }
  16. function StatCard({
  17. title,
  18. value,
  19. sub,
  20. }: {
  21. title: string;
  22. value: string | number;
  23. sub?: string;
  24. }) {
  25. return (
  26. <div className="rounded-xl border border-border/60 bg-card p-3 shadow-sm sm:p-4">
  27. <div className="text-xs font-medium text-muted-foreground">{title}</div>
  28. <div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
  29. {sub ? <div className="mt-1 text-xs text-muted-foreground">{sub}</div> : null}
  30. </div>
  31. );
  32. }
  33. function DailyBars({
  34. daily,
  35. field,
  36. label,
  37. color,
  38. emptyLabel,
  39. }: {
  40. daily: AnalyticsDailyRow[];
  41. field: keyof Pick<
  42. AnalyticsDailyRow,
  43. "widget_opens" | "sessions" | "messages" | "ai_replies"
  44. >;
  45. label: string;
  46. color: string;
  47. emptyLabel: string;
  48. }) {
  49. const max = useMemo(() => {
  50. let m = 1;
  51. for (const row of daily) {
  52. const v = Number(row[field]) || 0;
  53. if (v > m) m = v;
  54. }
  55. return m;
  56. }, [daily, field]);
  57. if (daily.length === 0) {
  58. return <p className="text-sm text-muted-foreground">{emptyLabel}</p>;
  59. }
  60. return (
  61. <div className="min-w-0">
  62. <div className="mb-2 text-sm font-medium text-foreground">{label}</div>
  63. <div className="-mx-1 overflow-x-auto px-1 pb-1">
  64. <div className="flex h-36 min-w-max items-end gap-1 border-b border-border/40 pb-1">
  65. {daily.map((row) => {
  66. const v = Number(row[field]) || 0;
  67. const h = Math.round((v / max) * 100);
  68. return (
  69. <div
  70. key={row.date}
  71. className="flex min-w-0 flex-1 flex-col items-center justify-end gap-1"
  72. title={`${row.date}: ${v}`}
  73. >
  74. <div
  75. className="w-full max-w-[28px] rounded-t transition-all"
  76. style={{
  77. height: `${Math.max(h, v > 0 ? 8 : 0)}%`,
  78. backgroundColor: color,
  79. minHeight: v > 0 ? 4 : 0,
  80. }}
  81. />
  82. <span className="truncate text-[10px] text-muted-foreground">
  83. {row.date.slice(5)}
  84. </span>
  85. </div>
  86. );
  87. })}
  88. </div>
  89. </div>
  90. </div>
  91. );
  92. }
  93. export default function AnalyticsPage(_props: { embedded?: boolean }) {
  94. const { t } = useI18n();
  95. const tr = (key: I18nKey, vars?: Record<string, string>) => {
  96. let s = t(key);
  97. if (!vars) return s;
  98. for (const k of Object.keys(vars)) {
  99. s = s.replaceAll(`{{${k}}}`, vars[k] ?? "");
  100. }
  101. return s;
  102. };
  103. const [from, setFrom] = useState(() => {
  104. const d = new Date();
  105. d.setDate(d.getDate() - 6);
  106. return d.toISOString().slice(0, 10);
  107. });
  108. const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10));
  109. const [data, setData] = useState<AnalyticsSummaryResponse | null>(null);
  110. const [loading, setLoading] = useState(true);
  111. const load = useCallback(async () => {
  112. setLoading(true);
  113. try {
  114. const res = await fetchAnalyticsSummary(from, to);
  115. setData(res);
  116. } catch (e) {
  117. toast.error((e as Error).message);
  118. setData(null);
  119. } finally {
  120. setLoading(false);
  121. }
  122. }, [from, to]);
  123. useEffect(() => {
  124. void load();
  125. }, [load]);
  126. const totals = data?.totals;
  127. return (
  128. <div className="mx-auto flex min-h-0 w-full max-w-6xl flex-col overflow-auto p-3 sm:p-4 md:p-6">
  129. <div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
  130. <div className="min-w-0">
  131. <h1 className="text-xl font-semibold tracking-tight">{t("agent.analytics.title")}</h1>
  132. <p className="mt-1 text-sm text-muted-foreground">{t("agent.analytics.subtitle")}</p>
  133. </div>
  134. <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center lg:ml-auto">
  135. <label className="flex items-center gap-2 text-xs text-muted-foreground sm:gap-1">
  136. <span className="shrink-0">{t("agent.analytics.from")}</span>
  137. <input
  138. type="date"
  139. value={from}
  140. onChange={(e) => setFrom(e.target.value)}
  141. className="min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm sm:flex-initial"
  142. />
  143. </label>
  144. <label className="flex items-center gap-2 text-xs text-muted-foreground sm:gap-1">
  145. <span className="shrink-0">{t("agent.analytics.to")}</span>
  146. <input
  147. type="date"
  148. value={to}
  149. onChange={(e) => setTo(e.target.value)}
  150. className="min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm sm:flex-initial"
  151. />
  152. </label>
  153. <Button className="w-full sm:w-auto" size="sm" onClick={() => void load()} disabled={loading}>
  154. {loading ? t("agent.analytics.loading") : t("agent.analytics.query")}
  155. </Button>
  156. </div>
  157. </div>
  158. {data && (
  159. <p className="text-xs text-muted-foreground mb-4">{data.note}</p>
  160. )}
  161. {totals && (
  162. <>
  163. <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-6">
  164. <StatCard
  165. title={t("agent.analytics.stat.widgetOpens")}
  166. value={totals.widget_opens}
  167. sub={t("agent.analytics.stat.widgetOpensSub")}
  168. />
  169. <StatCard title={t("agent.analytics.stat.sessions")} value={totals.sessions} />
  170. <StatCard title={t("agent.analytics.stat.messages")} value={totals.messages} />
  171. <StatCard title={t("agent.analytics.stat.aiReplies")} value={totals.ai_replies} />
  172. <StatCard title={t("agent.analytics.stat.aiFailed")} value={totals.ai_failed} />
  173. <StatCard
  174. title={t("agent.analytics.stat.aiFailureRate")}
  175. value={formatPercent(totals.ai_failure_rate_percent)}
  176. sub={t("agent.analytics.stat.aiFailureRateSub")}
  177. />
  178. <StatCard title={t("agent.analytics.stat.kbHits")} value={totals.kb_hits} />
  179. <StatCard
  180. title={t("agent.analytics.stat.kbHitRate")}
  181. value={formatPercent(totals.kb_hit_rate_percent)}
  182. sub={t("agent.analytics.stat.kbHitRateSub")}
  183. />
  184. <StatCard
  185. title={t("agent.analytics.stat.maxAiRounds")}
  186. value={totals.max_ai_rounds}
  187. sub={t("agent.analytics.stat.maxAiRoundsSub")}
  188. />
  189. <StatCard
  190. title={t("agent.analytics.stat.sessionsWithAi")}
  191. value={totals.sessions_with_ai}
  192. sub={tr("agent.analytics.stat.sessionsWithAiSub", {
  193. pct: formatPercent(totals.ai_participation_rate_percent),
  194. })}
  195. />
  196. <StatCard
  197. title={t("agent.analytics.stat.aiToHuman")}
  198. value={totals.ai_to_human_sessions}
  199. sub={tr("agent.analytics.stat.aiToHumanSub", {
  200. pct: formatPercent(totals.ai_to_human_rate_percent),
  201. })}
  202. />
  203. <StatCard
  204. title={t("agent.analytics.stat.humanToAi")}
  205. value={totals.human_to_ai_sessions}
  206. sub={tr("agent.analytics.stat.humanToAiSub", {
  207. pct: formatPercent(totals.human_to_ai_rate_percent),
  208. })}
  209. />
  210. </div>
  211. <div className="grid grid-cols-1 gap-6 rounded-xl border border-border/60 bg-card p-3 sm:p-4 md:gap-8 lg:grid-cols-2">
  212. <DailyBars
  213. daily={data!.daily}
  214. field="widget_opens"
  215. label={t("agent.analytics.chart.widgetOpens")}
  216. color="rgb(34 197 94)"
  217. emptyLabel={t("agent.analytics.empty")}
  218. />
  219. <DailyBars
  220. daily={data!.daily}
  221. field="sessions"
  222. label={t("agent.analytics.chart.sessions")}
  223. color="rgb(59 130 246)"
  224. emptyLabel={t("agent.analytics.empty")}
  225. />
  226. <DailyBars
  227. daily={data!.daily}
  228. field="messages"
  229. label={t("agent.analytics.chart.messages")}
  230. color="rgb(168 85 247)"
  231. emptyLabel={t("agent.analytics.empty")}
  232. />
  233. <DailyBars
  234. daily={data!.daily}
  235. field="ai_replies"
  236. label={t("agent.analytics.chart.aiReplies")}
  237. color="rgb(249 115 22)"
  238. emptyLabel={t("agent.analytics.empty")}
  239. />
  240. </div>
  241. </>
  242. )}
  243. {!loading && !totals && (
  244. <p className="text-sm text-muted-foreground">{t("agent.analytics.emptyOrFailed")}</p>
  245. )}
  246. </div>
  247. );
  248. }