koishi-plugin-chatluna-affinity 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1123 @@
1
+ import { send } from "@koishijs/client";
2
+ import { Tooltip } from "@heroui/react/tooltip";
3
+ import {
4
+ IconAlertCircle,
5
+ IconChevronDown,
6
+ IconChevronUp,
7
+ IconLoader2,
8
+ IconMinus,
9
+ IconRefresh,
10
+ IconTrendingDown,
11
+ IconTrendingUp,
12
+ } from "@tabler/icons-react";
13
+ import React, {
14
+ useCallback,
15
+ useEffect,
16
+ useLayoutEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ } from "react";
21
+ import {
22
+ CartesianGrid,
23
+ Line,
24
+ LineChart,
25
+ XAxis,
26
+ YAxis,
27
+ } from "recharts";
28
+ import { toast } from "sonner";
29
+ import {
30
+ Alert,
31
+ AlertDescription,
32
+ AlertTitle,
33
+ } from "../components/ui/alert";
34
+ import {
35
+ Avatar,
36
+ AvatarFallback,
37
+ AvatarImage,
38
+ } from "../components/ui/avatar";
39
+ import { Badge } from "../components/ui/badge";
40
+ import { Button } from "../components/ui/button";
41
+ import {
42
+ Card,
43
+ CardContent,
44
+ CardDescription,
45
+ CardHeader,
46
+ CardTitle,
47
+ } from "../components/ui/card";
48
+ import {
49
+ ChartContainer,
50
+ ChartTooltip,
51
+ ChartTooltipContent,
52
+ type ChartConfig,
53
+ } from "../components/ui/chart";
54
+ import {
55
+ Table,
56
+ TableBody,
57
+ TableCell,
58
+ TableHead,
59
+ TableHeader,
60
+ TableRow,
61
+ } from "../components/ui/table";
62
+ import {
63
+ Tabs,
64
+ TabsContent,
65
+ TabsList,
66
+ TabsTrigger,
67
+ } from "../components/ui/tabs";
68
+ import { Toaster } from "../components/ui/sonner";
69
+ import type {
70
+ DashboardBlacklistItem,
71
+ DashboardData,
72
+ DashboardMetricChange,
73
+ DashboardRelationStat,
74
+ DashboardTopUser,
75
+ } from "./types";
76
+
77
+ const DASHBOARD_EVENT = "chatluna-affinity/dashboard";
78
+ const TOP_USER_PAGE_SIZE = 10;
79
+
80
+ type SortColumn =
81
+ | "affinity"
82
+ | "chatCount"
83
+ | "lastInteractionAt"
84
+ | "relation"
85
+ | "user"
86
+ | "userId";
87
+ type SortDirection = "ascending" | "descending";
88
+ type TrendRange = "week" | "month" | "all";
89
+
90
+ interface TopUserSortDescriptor {
91
+ column: SortColumn;
92
+ direction: SortDirection;
93
+ }
94
+
95
+ const trendChartConfig = {
96
+ users: {
97
+ label: "用户记录",
98
+ color: "var(--chart-1)",
99
+ },
100
+ averageAffinity: {
101
+ label: "平均好感",
102
+ color: "var(--chart-2)",
103
+ },
104
+ chatCount: {
105
+ label: "互动次数",
106
+ color: "var(--chart-3)",
107
+ },
108
+ blacklisted: {
109
+ label: "黑名单",
110
+ color: "var(--chart-4)",
111
+ },
112
+ } satisfies ChartConfig;
113
+
114
+ const userHistoryChartConfig = {
115
+ affinity: {
116
+ label: "综合好感",
117
+ color: "var(--chart-2)",
118
+ },
119
+ longTermAffinity: {
120
+ label: "长期好感",
121
+ color: "var(--chart-3)",
122
+ },
123
+ chatCount: {
124
+ label: "对话次数",
125
+ color: "var(--chart-4)",
126
+ },
127
+ } satisfies ChartConfig;
128
+
129
+ const USER_HISTORY_DAY_MS = 24 * 60 * 60 * 1000;
130
+
131
+ function formatNumber(value: number): string {
132
+ return new Intl.NumberFormat("zh-CN").format(value);
133
+ }
134
+
135
+ function formatAverage(value: number): string {
136
+ return new Intl.NumberFormat("zh-CN", {
137
+ maximumFractionDigits: 2,
138
+ }).format(value);
139
+ }
140
+
141
+ function startOfLocalDay(value: Date): Date {
142
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate());
143
+ }
144
+
145
+ function getHistoryWindowDays(range: TrendRange): number {
146
+ if (range === "month") return 30;
147
+ if (range === "all") return Number.POSITIVE_INFINITY;
148
+ return 7;
149
+ }
150
+
151
+ function getUserHistoryData(
152
+ points: DashboardTopUser["historyPoints"],
153
+ range: TrendRange,
154
+ ): DashboardTopUser["historyPoints"] {
155
+ if (!points.length) return points;
156
+ if (range === "all") return points;
157
+
158
+ const latest = points.at(-1);
159
+ const latestDate = latest?.timestamp ? new Date(latest.timestamp) : null;
160
+ if (!latestDate || Number.isNaN(latestDate.getTime())) return points;
161
+
162
+ const windowDays = getHistoryWindowDays(range);
163
+ const anchor = startOfLocalDay(latestDate);
164
+ const start = anchor.getTime() - (windowDays - 1) * USER_HISTORY_DAY_MS;
165
+ const filtered = points.filter((point) => {
166
+ if (!point.timestamp) return false;
167
+ const date = new Date(point.timestamp);
168
+ const time = date.getTime();
169
+ return !Number.isNaN(time) && time >= start;
170
+ });
171
+
172
+ if (!filtered.length) return [latest];
173
+
174
+ const firstVisibleIndex = points.findIndex((point) => {
175
+ if (!point.timestamp) return false;
176
+ const date = new Date(point.timestamp);
177
+ const time = date.getTime();
178
+ return !Number.isNaN(time) && time >= start;
179
+ });
180
+ if (firstVisibleIndex > 0) {
181
+ const anchorPoint = points[firstVisibleIndex - 1];
182
+ if (anchorPoint?.timestamp) {
183
+ const anchorDatePoint = new Date(anchorPoint.timestamp);
184
+ if (!Number.isNaN(anchorDatePoint.getTime())) {
185
+ const result = [anchorPoint, ...filtered];
186
+ return result.filter(
187
+ (point, index, list) =>
188
+ index === 0 ||
189
+ point.timestamp !== list[index - 1]?.timestamp,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ return filtered;
196
+ }
197
+
198
+ function formatTime(value: string | null): string {
199
+ if (!value) return "暂无";
200
+ const date = new Date(value);
201
+ if (Number.isNaN(date.getTime())) return "暂无";
202
+ return new Intl.DateTimeFormat("zh-CN", {
203
+ dateStyle: "medium",
204
+ timeStyle: "short",
205
+ }).format(date);
206
+ }
207
+
208
+ function formatChange(change: DashboardMetricChange): string {
209
+ if (change.percent === null) return "上周无基准";
210
+ if (change.percent > 0) return `较上周 +${formatAverage(change.percent)}%`;
211
+ if (change.percent < 0) return `较上周 ${formatAverage(change.percent)}%`;
212
+ return "较上周持平";
213
+ }
214
+
215
+ function ChangeIcon({ change }: { change: DashboardMetricChange }) {
216
+ if (change.percent === null || change.percent === 0) {
217
+ return <IconMinus aria-hidden="true" />;
218
+ }
219
+ if (change.percent > 0) {
220
+ return <IconTrendingUp aria-hidden="true" />;
221
+ }
222
+ return <IconTrendingDown aria-hidden="true" />;
223
+ }
224
+
225
+ function getUserSortName(user: DashboardTopUser): string {
226
+ return user.name;
227
+ }
228
+
229
+ function getInteractionTimestamp(user: DashboardTopUser): number {
230
+ if (!user.lastInteractionAt) return Number.NEGATIVE_INFINITY;
231
+ const value = new Date(user.lastInteractionAt).getTime();
232
+ return Number.isNaN(value) ? Number.NEGATIVE_INFINITY : value;
233
+ }
234
+
235
+ function compareText(left: string, right: string): number {
236
+ return left.localeCompare(right, "zh-CN", {
237
+ numeric: true,
238
+ sensitivity: "base",
239
+ });
240
+ }
241
+
242
+ function compareTopUsers(
243
+ left: DashboardTopUser,
244
+ right: DashboardTopUser,
245
+ sortDescriptor: TopUserSortDescriptor,
246
+ ): number {
247
+ let result = 0;
248
+
249
+ switch (sortDescriptor.column) {
250
+ case "user":
251
+ result = compareText(getUserSortName(left), getUserSortName(right));
252
+ break;
253
+ case "userId":
254
+ result = compareText(left.userId, right.userId);
255
+ break;
256
+ case "relation":
257
+ result = compareText(left.relation, right.relation);
258
+ break;
259
+ case "affinity":
260
+ result = left.affinity - right.affinity;
261
+ break;
262
+ case "chatCount":
263
+ result = left.chatCount - right.chatCount;
264
+ break;
265
+ case "lastInteractionAt":
266
+ result = getInteractionTimestamp(left) - getInteractionTimestamp(right);
267
+ break;
268
+ }
269
+
270
+ if (result === 0) {
271
+ result = compareText(left.userId, right.userId);
272
+ }
273
+
274
+ return sortDescriptor.direction === "ascending" ? result : -result;
275
+ }
276
+
277
+ function getRelationBadgeClassName(
278
+ tone: DashboardTopUser["relationTone"],
279
+ ): string {
280
+ switch (tone) {
281
+ case "low":
282
+ return "border-slate-200 bg-slate-100 text-slate-900 hover:bg-slate-100";
283
+ case "medium":
284
+ return "border-sky-200 bg-sky-100 text-sky-900 hover:bg-sky-100";
285
+ case "high":
286
+ return "border-emerald-200 bg-emerald-100 text-emerald-900 hover:bg-emerald-100";
287
+ case "custom":
288
+ return "border-amber-200 bg-amber-100 text-amber-900 hover:bg-amber-100";
289
+ case "unknown":
290
+ return "border-muted bg-muted text-muted-foreground hover:bg-muted";
291
+ }
292
+ }
293
+
294
+ function OverflowTooltip({
295
+ children,
296
+ content,
297
+ className,
298
+ }: {
299
+ children: React.ReactNode;
300
+ content: string;
301
+ className?: string;
302
+ }) {
303
+ const triggerRef = useRef<HTMLDivElement | null>(null);
304
+ const [isOverflowing, setIsOverflowing] = useState(false);
305
+
306
+ useLayoutEffect(() => {
307
+ const trigger = triggerRef.current;
308
+ if (!trigger) return;
309
+
310
+ const checkOverflow = () => {
311
+ const target =
312
+ trigger.firstElementChild instanceof HTMLElement
313
+ ? trigger.firstElementChild
314
+ : trigger;
315
+
316
+ setIsOverflowing(
317
+ target.scrollWidth > target.clientWidth ||
318
+ trigger.scrollWidth > trigger.clientWidth,
319
+ );
320
+ };
321
+
322
+ checkOverflow();
323
+ const resizeObserver = new ResizeObserver(checkOverflow);
324
+ resizeObserver.observe(trigger);
325
+
326
+ return () => resizeObserver.disconnect();
327
+ }, [content]);
328
+
329
+ return (
330
+ <Tooltip closeDelay={0} delay={0} isDisabled={!isOverflowing}>
331
+ <Tooltip.Trigger
332
+ ref={triggerRef}
333
+ className={
334
+ className
335
+ ? `block w-full min-w-0 ${className}`
336
+ : "block w-full min-w-0"
337
+ }
338
+ >
339
+ {children}
340
+ </Tooltip.Trigger>
341
+ <Tooltip.Content showArrow>{content}</Tooltip.Content>
342
+ </Tooltip>
343
+ );
344
+ }
345
+
346
+ function SortHeader({
347
+ children,
348
+ column,
349
+ sortDescriptor,
350
+ onSortChange,
351
+ }: {
352
+ children: React.ReactNode;
353
+ column: SortColumn;
354
+ sortDescriptor: TopUserSortDescriptor;
355
+ onSortChange: (next: TopUserSortDescriptor) => void;
356
+ }) {
357
+ const active = sortDescriptor.column === column;
358
+ const nextDirection: SortDirection =
359
+ active && sortDescriptor.direction === "descending"
360
+ ? "ascending"
361
+ : "descending";
362
+
363
+ return (
364
+ <Button
365
+ className="h-auto w-full justify-start px-0 py-0 font-medium text-muted-foreground hover:bg-transparent"
366
+ type="button"
367
+ variant="ghost"
368
+ onClick={() =>
369
+ onSortChange({
370
+ column,
371
+ direction: nextDirection,
372
+ })
373
+ }
374
+ >
375
+ <span>{children}</span>
376
+ {active ? (
377
+ sortDescriptor.direction === "ascending" ? (
378
+ <IconChevronUp aria-hidden="true" />
379
+ ) : (
380
+ <IconChevronDown aria-hidden="true" />
381
+ )
382
+ ) : null}
383
+ </Button>
384
+ );
385
+ }
386
+
387
+ function StatCard({
388
+ label,
389
+ value,
390
+ detail,
391
+ change,
392
+ }: {
393
+ label: string;
394
+ value: string;
395
+ detail: string;
396
+ change: DashboardMetricChange;
397
+ }) {
398
+ return (
399
+ <Card>
400
+ <CardHeader className="gap-2">
401
+ <div className="flex items-start justify-between gap-2">
402
+ <CardDescription>{label}</CardDescription>
403
+ <Badge className="gap-1" variant="outline">
404
+ <ChangeIcon change={change} />
405
+ {formatChange(change)}
406
+ </Badge>
407
+ </div>
408
+ <CardTitle className="text-2xl">{value}</CardTitle>
409
+ <div className="text-sm text-muted-foreground">{detail}</div>
410
+ </CardHeader>
411
+ </Card>
412
+ );
413
+ }
414
+
415
+ function OverviewTrendChart({
416
+ trends,
417
+ }: {
418
+ trends: DashboardData["trends"];
419
+ }) {
420
+ const [range, setRange] = useState<TrendRange>("week");
421
+ const chartData = trends[range];
422
+
423
+ return (
424
+ <Card>
425
+ <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
426
+ <div className="grid min-w-0 gap-1">
427
+ <CardTitle>趋势概览</CardTitle>
428
+ <CardDescription>用户记录、平均好感、互动次数与黑名单</CardDescription>
429
+ </div>
430
+ <Tabs value={range} onValueChange={(value) => setRange(value as TrendRange)}>
431
+ <TabsList>
432
+ <TabsTrigger value="week">周</TabsTrigger>
433
+ <TabsTrigger value="month">月</TabsTrigger>
434
+ <TabsTrigger value="all">总</TabsTrigger>
435
+ </TabsList>
436
+ </Tabs>
437
+ </CardHeader>
438
+ <CardContent>
439
+ {chartData.length ? (
440
+ <ChartContainer className="h-72 w-full" config={trendChartConfig}>
441
+ <LineChart
442
+ accessibilityLayer
443
+ data={chartData}
444
+ margin={{ left: 8, right: 8 }}
445
+ >
446
+ <CartesianGrid vertical={false} />
447
+ <XAxis
448
+ axisLine={false}
449
+ dataKey="label"
450
+ tickLine={false}
451
+ tickMargin={8}
452
+ />
453
+ <YAxis axisLine={false} tickLine={false} width={36} />
454
+ <ChartTooltip content={<ChartTooltipContent />} />
455
+ <Line
456
+ dataKey="users"
457
+ dot={false}
458
+ stroke="var(--color-users)"
459
+ strokeWidth={2}
460
+ type="monotone"
461
+ />
462
+ <Line
463
+ dataKey="averageAffinity"
464
+ dot={false}
465
+ stroke="var(--color-averageAffinity)"
466
+ strokeWidth={2}
467
+ type="monotone"
468
+ />
469
+ <Line
470
+ dataKey="chatCount"
471
+ dot={false}
472
+ stroke="var(--color-chatCount)"
473
+ strokeWidth={2}
474
+ type="monotone"
475
+ />
476
+ <Line
477
+ dataKey="blacklisted"
478
+ dot={false}
479
+ stroke="var(--color-blacklisted)"
480
+ strokeWidth={2}
481
+ type="monotone"
482
+ />
483
+ </LineChart>
484
+ </ChartContainer>
485
+ ) : (
486
+ <p className="affinity-dashboard__empty">当前 scopeId 暂无趋势数据。</p>
487
+ )}
488
+ </CardContent>
489
+ </Card>
490
+ );
491
+ }
492
+
493
+ function RelationList({
494
+ items,
495
+ total,
496
+ }: {
497
+ items: DashboardRelationStat[];
498
+ total: number;
499
+ }) {
500
+ if (!items.length) {
501
+ return <p className="affinity-dashboard__empty">当前 scopeId 暂无关系数据。</p>;
502
+ }
503
+
504
+ return (
505
+ <div className="grid gap-3">
506
+ {items.slice(0, 8).map((item) => {
507
+ const percent = total > 0 ? Math.round((item.count / total) * 100) : 0;
508
+
509
+ return (
510
+ <div className="grid gap-2" key={`${item.kind}:${item.relation}`}>
511
+ <div className="flex items-center justify-between gap-3 text-sm">
512
+ <span className="truncate font-medium">{item.relation}</span>
513
+ <span className="text-muted-foreground">
514
+ {formatNumber(item.count)} 人
515
+ </span>
516
+ </div>
517
+ <div className="h-2 rounded-full bg-muted">
518
+ <div
519
+ className="h-2 rounded-full bg-primary"
520
+ style={{ width: `${percent}%` }}
521
+ />
522
+ </div>
523
+ </div>
524
+ );
525
+ })}
526
+ </div>
527
+ );
528
+ }
529
+
530
+ function RelationshipDistribution({
531
+ items,
532
+ total,
533
+ }: {
534
+ items: DashboardRelationStat[];
535
+ total: number;
536
+ }) {
537
+ const presetItems = items.filter((item) => item.kind === "preset");
538
+ const customItems = items.filter((item) => item.kind === "custom");
539
+
540
+ return (
541
+ <Card>
542
+ <CardHeader>
543
+ <CardTitle>关系分布</CardTitle>
544
+ <CardDescription>按用户当前展示关系统计</CardDescription>
545
+ </CardHeader>
546
+ <CardContent>
547
+ <Tabs defaultValue="preset">
548
+ <TabsList className="self-start">
549
+ <TabsTrigger value="preset">预设关系</TabsTrigger>
550
+ <TabsTrigger value="custom">自定义关系</TabsTrigger>
551
+ </TabsList>
552
+ <TabsContent value="preset">
553
+ <RelationList items={presetItems} total={total} />
554
+ </TabsContent>
555
+ <TabsContent value="custom">
556
+ <RelationList items={customItems} total={total} />
557
+ </TabsContent>
558
+ </Tabs>
559
+ </CardContent>
560
+ </Card>
561
+ );
562
+ }
563
+
564
+ function TopUserTable({
565
+ users,
566
+ selectedUserId,
567
+ onSelectUser,
568
+ }: {
569
+ users: DashboardTopUser[];
570
+ selectedUserId: string | null;
571
+ onSelectUser: (user: DashboardTopUser) => void;
572
+ }) {
573
+ const [page, setPage] = useState(1);
574
+ const [sortDescriptor, setSortDescriptor] =
575
+ useState<TopUserSortDescriptor>({
576
+ column: "affinity",
577
+ direction: "descending",
578
+ });
579
+
580
+ const sortedUsers = useMemo(
581
+ () =>
582
+ [...users].sort((left, right) =>
583
+ compareTopUsers(left, right, sortDescriptor),
584
+ ),
585
+ [sortDescriptor, users],
586
+ );
587
+ const pageCount = Math.max(1, Math.ceil(sortedUsers.length / TOP_USER_PAGE_SIZE));
588
+ const pageUsers = useMemo(() => {
589
+ const start = (page - 1) * TOP_USER_PAGE_SIZE;
590
+ return sortedUsers.slice(start, start + TOP_USER_PAGE_SIZE);
591
+ }, [page, sortedUsers]);
592
+ const startRank = (page - 1) * TOP_USER_PAGE_SIZE + 1;
593
+ const endRank = Math.min(page * TOP_USER_PAGE_SIZE, sortedUsers.length);
594
+
595
+ useEffect(() => {
596
+ setPage(1);
597
+ }, [users]);
598
+
599
+ useEffect(() => {
600
+ setPage((currentPage) => Math.min(currentPage, pageCount));
601
+ }, [pageCount]);
602
+
603
+ if (!users.length) {
604
+ return <p className="affinity-dashboard__empty">当前 scopeId 暂无好感度记录。</p>;
605
+ }
606
+
607
+ return (
608
+ <div className="grid gap-3">
609
+ <Table className="min-w-[960px] table-fixed">
610
+ <colgroup>
611
+ <col className="w-[26%]" />
612
+ <col className="w-[17%]" />
613
+ <col className="w-[13%]" />
614
+ <col className="w-[11%]" />
615
+ <col className="w-[11%]" />
616
+ <col className="w-[22%]" />
617
+ </colgroup>
618
+ <TableHeader>
619
+ <TableRow>
620
+ <TableHead>
621
+ <SortHeader
622
+ column="user"
623
+ sortDescriptor={sortDescriptor}
624
+ onSortChange={setSortDescriptor}
625
+ >
626
+ 用户
627
+ </SortHeader>
628
+ </TableHead>
629
+ <TableHead>
630
+ <SortHeader
631
+ column="userId"
632
+ sortDescriptor={sortDescriptor}
633
+ onSortChange={setSortDescriptor}
634
+ >
635
+ QQ号
636
+ </SortHeader>
637
+ </TableHead>
638
+ <TableHead>
639
+ <SortHeader
640
+ column="relation"
641
+ sortDescriptor={sortDescriptor}
642
+ onSortChange={setSortDescriptor}
643
+ >
644
+ 关系
645
+ </SortHeader>
646
+ </TableHead>
647
+ <TableHead>
648
+ <SortHeader
649
+ column="affinity"
650
+ sortDescriptor={sortDescriptor}
651
+ onSortChange={setSortDescriptor}
652
+ >
653
+ 好感度
654
+ </SortHeader>
655
+ </TableHead>
656
+ <TableHead>
657
+ <SortHeader
658
+ column="chatCount"
659
+ sortDescriptor={sortDescriptor}
660
+ onSortChange={setSortDescriptor}
661
+ >
662
+ 互动
663
+ </SortHeader>
664
+ </TableHead>
665
+ <TableHead>
666
+ <SortHeader
667
+ column="lastInteractionAt"
668
+ sortDescriptor={sortDescriptor}
669
+ onSortChange={setSortDescriptor}
670
+ >
671
+ 最后互动
672
+ </SortHeader>
673
+ </TableHead>
674
+ </TableRow>
675
+ </TableHeader>
676
+ <TableBody>
677
+ {pageUsers.map((user, index) => {
678
+ const selected = selectedUserId === user.userId;
679
+ const affinity = formatNumber(user.affinity);
680
+ const chatCount = formatNumber(user.chatCount);
681
+ const lastInteractionAt = formatTime(user.lastInteractionAt);
682
+ const rowClassName = selected
683
+ ? "cursor-pointer border-0 bg-muted/70 hover:bg-muted/70"
684
+ : index % 2 === 0
685
+ ? "cursor-pointer border-0 bg-muted/50 hover:bg-muted/60"
686
+ : "cursor-pointer border-0 bg-background hover:bg-muted/60";
687
+
688
+ return (
689
+ <React.Fragment key={user.userId}>
690
+ <TableRow
691
+ className={rowClassName}
692
+ onClick={() => onSelectUser(user)}
693
+ >
694
+ <TableCell className="text-left">
695
+ <div className="flex min-w-0 items-center gap-2">
696
+ <Avatar>
697
+ {user.avatarUrl ? (
698
+ <AvatarImage
699
+ alt={`${user.name} 的头像`}
700
+ loading="lazy"
701
+ src={user.avatarUrl}
702
+ />
703
+ ) : null}
704
+ <AvatarFallback>
705
+ {user.name.trim().slice(0, 1) || "?"}
706
+ </AvatarFallback>
707
+ </Avatar>
708
+ <div className="grid min-w-0 gap-0.5">
709
+ <OverflowTooltip content={user.name}>
710
+ <span className="block truncate font-medium">
711
+ {user.name}
712
+ </span>
713
+ </OverflowTooltip>
714
+ </div>
715
+ </div>
716
+ </TableCell>
717
+ <TableCell className="text-left">
718
+ <OverflowTooltip content={user.userId}>
719
+ <span className="block truncate text-muted-foreground">
720
+ {user.userId}
721
+ </span>
722
+ </OverflowTooltip>
723
+ </TableCell>
724
+ <TableCell className="text-left">
725
+ <OverflowTooltip content={user.relation}>
726
+ <Badge
727
+ className={`max-w-full truncate ${getRelationBadgeClassName(user.relationTone)}`}
728
+ variant="outline"
729
+ >
730
+ {user.relation}
731
+ </Badge>
732
+ </OverflowTooltip>
733
+ </TableCell>
734
+ <TableCell className="text-left">
735
+ <OverflowTooltip content={affinity}>{affinity}</OverflowTooltip>
736
+ </TableCell>
737
+ <TableCell className="text-left">
738
+ <OverflowTooltip content={chatCount}>{chatCount}</OverflowTooltip>
739
+ </TableCell>
740
+ <TableCell className="whitespace-nowrap text-left">
741
+ <OverflowTooltip content={lastInteractionAt}>
742
+ {lastInteractionAt}
743
+ </OverflowTooltip>
744
+ </TableCell>
745
+ </TableRow>
746
+ {selected ? (
747
+ <TableRow className="border-0 bg-muted/70 hover:bg-muted/70">
748
+ <TableCell colSpan={6}>
749
+ <UserHistoryChart user={user} />
750
+ </TableCell>
751
+ </TableRow>
752
+ ) : null}
753
+ </React.Fragment>
754
+ );
755
+ })}
756
+ </TableBody>
757
+ </Table>
758
+ {pageCount > 1 ? (
759
+ <div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
760
+ <span>
761
+ 第 {formatNumber(page)} / {formatNumber(pageCount)} 页,显示{" "}
762
+ {formatNumber(startRank)}-{formatNumber(endRank)} /{" "}
763
+ {formatNumber(sortedUsers.length)}
764
+ </span>
765
+ <div className="flex items-center gap-2">
766
+ <Button
767
+ disabled={page === 1}
768
+ size="sm"
769
+ type="button"
770
+ variant="outline"
771
+ onClick={() => setPage((currentPage) => currentPage - 1)}
772
+ >
773
+ 上一页
774
+ </Button>
775
+ <Button
776
+ disabled={page === pageCount}
777
+ size="sm"
778
+ type="button"
779
+ variant="outline"
780
+ onClick={() => setPage((currentPage) => currentPage + 1)}
781
+ >
782
+ 下一页
783
+ </Button>
784
+ </div>
785
+ </div>
786
+ ) : null}
787
+ </div>
788
+ );
789
+ }
790
+
791
+ function BlacklistTable({ items }: { items: DashboardBlacklistItem[] }) {
792
+ if (!items.length) {
793
+ return <p className="affinity-dashboard__empty">当前 scopeId 暂无黑名单记录。</p>;
794
+ }
795
+
796
+ return (
797
+ <Table className="table-fixed">
798
+ <colgroup>
799
+ <col className="w-[18%]" />
800
+ <col className="w-[13%]" />
801
+ <col className="w-[18%]" />
802
+ <col className="w-[8%]" />
803
+ <col className="w-[8%]" />
804
+ <col className="w-[8%]" />
805
+ <col className="w-[13.5%]" />
806
+ <col className="w-[13.5%]" />
807
+ </colgroup>
808
+ <TableHeader>
809
+ <TableRow>
810
+ <TableHead>用户</TableHead>
811
+ <TableHead>QQ号</TableHead>
812
+ <TableHead>理由</TableHead>
813
+ <TableHead>模式</TableHead>
814
+ <TableHead>平台</TableHead>
815
+ <TableHead>好感度</TableHead>
816
+ <TableHead>加入时间</TableHead>
817
+ <TableHead>到期时间</TableHead>
818
+ </TableRow>
819
+ </TableHeader>
820
+ <TableBody>
821
+ {items.map((item, index) => (
822
+ <TableRow
823
+ className={
824
+ index % 2 === 0
825
+ ? "border-0 bg-muted/50 hover:bg-muted/60"
826
+ : "border-0 bg-background hover:bg-muted/60"
827
+ }
828
+ key={`${item.mode}:${item.platform}:${item.userId}`}
829
+ >
830
+ <TableCell className="text-left">
831
+ <div className="flex min-w-0 items-center gap-2">
832
+ <Avatar>
833
+ {item.avatarUrl ? (
834
+ <AvatarImage
835
+ alt={`${item.name} 的头像`}
836
+ loading="lazy"
837
+ src={item.avatarUrl}
838
+ />
839
+ ) : null}
840
+ <AvatarFallback>
841
+ {item.name.trim().slice(0, 1) || "?"}
842
+ </AvatarFallback>
843
+ </Avatar>
844
+ <div className="grid min-w-0 gap-0.5">
845
+ <OverflowTooltip content={item.name}>
846
+ <span className="block truncate font-medium">
847
+ {item.name}
848
+ </span>
849
+ </OverflowTooltip>
850
+ </div>
851
+ </div>
852
+ </TableCell>
853
+ <TableCell className="text-left">
854
+ <OverflowTooltip content={item.userId}>
855
+ <span className="block truncate text-muted-foreground">
856
+ {item.userId}
857
+ </span>
858
+ </OverflowTooltip>
859
+ </TableCell>
860
+ <TableCell className="text-left">
861
+ <OverflowTooltip content={item.note || "暂无"}>
862
+ <span className="block truncate text-muted-foreground">
863
+ {item.note || "暂无"}
864
+ </span>
865
+ </OverflowTooltip>
866
+ </TableCell>
867
+ <TableCell className="text-left">
868
+ <Badge variant={item.mode === "permanent" ? "destructive" : "secondary"}>
869
+ {item.mode === "permanent" ? "永久" : "临时"}
870
+ </Badge>
871
+ </TableCell>
872
+ <TableCell className="text-left">
873
+ <OverflowTooltip content={item.platform}>
874
+ <span className="block truncate">{item.platform}</span>
875
+ </OverflowTooltip>
876
+ </TableCell>
877
+ <TableCell className="text-left">
878
+ <OverflowTooltip
879
+ content={item.affinity === null ? "暂无" : formatNumber(item.affinity)}
880
+ >
881
+ <span className="block truncate">
882
+ {item.affinity === null ? "暂无" : formatNumber(item.affinity)}
883
+ </span>
884
+ </OverflowTooltip>
885
+ </TableCell>
886
+ <TableCell className="whitespace-nowrap text-left">
887
+ <OverflowTooltip content={formatTime(item.blockedAt)}>
888
+ <span className="block truncate">{formatTime(item.blockedAt)}</span>
889
+ </OverflowTooltip>
890
+ </TableCell>
891
+ <TableCell className="whitespace-nowrap text-left">
892
+ <OverflowTooltip content={formatTime(item.expiresAt)}>
893
+ <span className="block truncate">{formatTime(item.expiresAt)}</span>
894
+ </OverflowTooltip>
895
+ </TableCell>
896
+ </TableRow>
897
+ ))}
898
+ </TableBody>
899
+ </Table>
900
+ );
901
+ }
902
+
903
+ function UserHistoryChart({ user }: { user: DashboardTopUser }) {
904
+ const [range, setRange] = useState<TrendRange>("week");
905
+ const historyPoints = useMemo(
906
+ () => getUserHistoryData(user.historyPoints, range),
907
+ [range, user.historyPoints],
908
+ );
909
+
910
+ return (
911
+ <div className="grid gap-3 py-2">
912
+ <div className="flex flex-wrap items-center justify-between gap-2">
913
+ <h3 className="text-sm font-medium">{user.name} 的好感度历史</h3>
914
+ <Tabs
915
+ value={range}
916
+ onValueChange={(value) => setRange(value as TrendRange)}
917
+ >
918
+ <TabsList>
919
+ <TabsTrigger value="week">周</TabsTrigger>
920
+ <TabsTrigger value="month">月</TabsTrigger>
921
+ <TabsTrigger value="all">总</TabsTrigger>
922
+ </TabsList>
923
+ </Tabs>
924
+ </div>
925
+ <ChartContainer className="h-56 w-full" config={userHistoryChartConfig}>
926
+ <LineChart
927
+ accessibilityLayer
928
+ data={historyPoints}
929
+ margin={{ left: 8, right: 8 }}
930
+ >
931
+ <CartesianGrid vertical={false} />
932
+ <XAxis axisLine={false} dataKey="label" tickLine={false} tickMargin={8} />
933
+ <YAxis axisLine={false} tickLine={false} width={36} />
934
+ <ChartTooltip content={<ChartTooltipContent />} />
935
+ <Line
936
+ dataKey="affinity"
937
+ dot={{ fill: "var(--color-affinity)" }}
938
+ stroke="var(--color-affinity)"
939
+ strokeWidth={2}
940
+ type="monotone"
941
+ />
942
+ <Line
943
+ dataKey="longTermAffinity"
944
+ dot={{ fill: "var(--color-longTermAffinity)" }}
945
+ stroke="var(--color-longTermAffinity)"
946
+ strokeWidth={2}
947
+ type="monotone"
948
+ />
949
+ <Line
950
+ dataKey="chatCount"
951
+ dot={{ fill: "var(--color-chatCount)" }}
952
+ stroke="var(--color-chatCount)"
953
+ strokeWidth={2}
954
+ type="monotone"
955
+ />
956
+ </LineChart>
957
+ </ChartContainer>
958
+ </div>
959
+ );
960
+ }
961
+
962
+ function RankingPanel({
963
+ data,
964
+ }: {
965
+ data: DashboardData;
966
+ }) {
967
+ const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
968
+
969
+ useEffect(() => {
970
+ setSelectedUserId((current) =>
971
+ current && data.topUsers.some((user) => user.userId === current) ? current : null,
972
+ );
973
+ }, [data.topUsers]);
974
+
975
+ return (
976
+ <Card>
977
+ <CardHeader>
978
+ <CardTitle>好感度排行</CardTitle>
979
+ <CardDescription>默认按好感度从高到低排序,每页 10 名</CardDescription>
980
+ </CardHeader>
981
+ <CardContent>
982
+ <Tabs defaultValue="ranking">
983
+ <TabsList className="self-start">
984
+ <TabsTrigger value="ranking">好感度</TabsTrigger>
985
+ <TabsTrigger value="blacklist">黑名单</TabsTrigger>
986
+ </TabsList>
987
+ <TabsContent value="ranking">
988
+ <div className="grid gap-4">
989
+ <TopUserTable
990
+ selectedUserId={selectedUserId}
991
+ users={data.topUsers}
992
+ onSelectUser={(user) =>
993
+ setSelectedUserId((current) =>
994
+ current === user.userId ? null : user.userId,
995
+ )
996
+ }
997
+ />
998
+ </div>
999
+ </TabsContent>
1000
+ <TabsContent value="blacklist">
1001
+ <BlacklistTable items={data.blacklistItems} />
1002
+ </TabsContent>
1003
+ </Tabs>
1004
+ </CardContent>
1005
+ </Card>
1006
+ );
1007
+ }
1008
+
1009
+ function DashboardContent({ data }: { data: DashboardData }) {
1010
+ return (
1011
+ <>
1012
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
1013
+ <StatCard
1014
+ change={data.weeklyChanges.users}
1015
+ detail={`黑名单 ${formatNumber(data.totals.blacklisted)} 人`}
1016
+ label="用户记录"
1017
+ value={formatNumber(data.totals.users)}
1018
+ />
1019
+ <StatCard
1020
+ change={data.weeklyChanges.averageAffinity}
1021
+ detail={`长期均值 ${formatAverage(data.averages.longTermAffinity)}`}
1022
+ label="平均好感度"
1023
+ value={formatAverage(data.averages.affinity)}
1024
+ />
1025
+ <StatCard
1026
+ change={data.weeklyChanges.chatCount}
1027
+ detail={`短期均值 ${formatAverage(data.averages.shortTermAffinity)}`}
1028
+ label="互动次数"
1029
+ value={formatNumber(data.totals.chatCount)}
1030
+ />
1031
+ <StatCard
1032
+ change={data.weeklyChanges.aliases}
1033
+ detail={`最近互动 ${formatTime(data.latestInteractionAt)}`}
1034
+ label="昵称记录"
1035
+ value={formatNumber(data.totals.aliases)}
1036
+ />
1037
+ </div>
1038
+
1039
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
1040
+ <OverviewTrendChart trends={data.trends} />
1041
+ <RelationshipDistribution
1042
+ items={data.relationStats}
1043
+ total={data.totals.users}
1044
+ />
1045
+ </div>
1046
+
1047
+ <RankingPanel data={data} />
1048
+ </>
1049
+ );
1050
+ }
1051
+
1052
+ export function AffinityDashboard() {
1053
+ const [data, setData] = useState<DashboardData | null>(null);
1054
+ const [loading, setLoading] = useState(true);
1055
+ const [error, setError] = useState<string | null>(null);
1056
+
1057
+ const load = useCallback(async (showToast = false) => {
1058
+ setLoading(true);
1059
+ setError(null);
1060
+ try {
1061
+ const nextData = await send(DASHBOARD_EVENT);
1062
+ setData(nextData as DashboardData);
1063
+ if (showToast) {
1064
+ toast.success("仪表盘已刷新");
1065
+ }
1066
+ } catch (reason) {
1067
+ const message = reason instanceof Error ? reason.message : String(reason);
1068
+ setError(message);
1069
+ if (showToast) {
1070
+ toast.error("刷新失败", {
1071
+ description: message,
1072
+ });
1073
+ }
1074
+ } finally {
1075
+ setLoading(false);
1076
+ }
1077
+ }, []);
1078
+
1079
+ useEffect(() => {
1080
+ void load();
1081
+ }, [load]);
1082
+
1083
+ return (
1084
+ <section className="affinity-dashboard">
1085
+ <Toaster position="bottom-center" richColors />
1086
+ <div className="flex flex-wrap items-start justify-between gap-4">
1087
+ <div className="grid min-w-0 gap-1">
1088
+ <h2 className="text-xl font-semibold leading-tight">好感度仪表盘</h2>
1089
+ <p className="text-sm text-muted-foreground">
1090
+ 当前 scopeId 下的真实统计数据
1091
+ </p>
1092
+ </div>
1093
+ <Button disabled={loading} size="sm" type="button" onClick={() => void load(true)}>
1094
+ {loading ? (
1095
+ <IconLoader2 aria-hidden="true" className="animate-spin" />
1096
+ ) : (
1097
+ <IconRefresh aria-hidden="true" />
1098
+ )}
1099
+ 刷新
1100
+ </Button>
1101
+ </div>
1102
+
1103
+ {loading && !data ? (
1104
+ <Card>
1105
+ <CardContent className="flex items-center gap-2 pt-4 text-sm text-muted-foreground">
1106
+ <IconLoader2 aria-hidden="true" className="animate-spin" />
1107
+ <span>正在读取仪表盘数据</span>
1108
+ </CardContent>
1109
+ </Card>
1110
+ ) : null}
1111
+
1112
+ {error ? (
1113
+ <Alert variant="destructive">
1114
+ <IconAlertCircle aria-hidden="true" />
1115
+ <AlertTitle>仪表盘数据读取失败</AlertTitle>
1116
+ <AlertDescription>{error}</AlertDescription>
1117
+ </Alert>
1118
+ ) : null}
1119
+
1120
+ {data ? <DashboardContent data={data} /> : null}
1121
+ </section>
1122
+ );
1123
+ }