koishi-plugin-chatluna-affinity 0.3.3 → 0.3.4

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,108 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import * as React from "react";
3
+ import { cn } from "../../lib/utils";
4
+ import { Button } from "./button";
5
+ import { Input } from "./input";
6
+
7
+ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
8
+ return (
9
+ <div
10
+ className={cn(
11
+ "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-xl border border-input bg-background outline-none transition-[color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/50",
12
+ className,
13
+ )}
14
+ data-slot="input-group"
15
+ role="group"
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+
21
+ const inputGroupAddonVariants = cva(
22
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0",
23
+ {
24
+ variants: {
25
+ align: {
26
+ "inline-start": "order-first pl-3",
27
+ "inline-end": "order-last pr-2",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ align: "inline-start",
32
+ },
33
+ },
34
+ );
35
+
36
+ function InputGroupAddon({
37
+ className,
38
+ align = "inline-start",
39
+ ...props
40
+ }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
41
+ return (
42
+ <div
43
+ className={cn(inputGroupAddonVariants({ align }), className)}
44
+ data-align={align}
45
+ data-slot="input-group-addon"
46
+ onClick={(event) => {
47
+ if ((event.target as HTMLElement).closest("button")) return;
48
+ event.currentTarget.parentElement?.querySelector("input")?.focus();
49
+ }}
50
+ role="group"
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ const inputGroupButtonVariants = cva("flex items-center gap-2 shadow-none", {
57
+ variants: {
58
+ size: {
59
+ xs: "h-6 gap-1 rounded-lg px-2 text-xs [&>svg]:size-3.5",
60
+ "icon-xs": "size-6 rounded-lg p-0 [&>svg]:size-3.5",
61
+ },
62
+ },
63
+ defaultVariants: {
64
+ size: "xs",
65
+ },
66
+ });
67
+
68
+ function InputGroupButton({
69
+ className,
70
+ type = "button",
71
+ variant = "ghost",
72
+ size = "xs",
73
+ ...props
74
+ }: Omit<React.ComponentProps<typeof Button>, "size"> &
75
+ VariantProps<typeof inputGroupButtonVariants>) {
76
+ return (
77
+ <Button
78
+ className={cn(inputGroupButtonVariants({ size }), className)}
79
+ data-size={size}
80
+ type={type}
81
+ variant={variant}
82
+ {...props}
83
+ />
84
+ );
85
+ }
86
+
87
+ function InputGroupInput({
88
+ className,
89
+ ...props
90
+ }: React.ComponentProps<"input">) {
91
+ return (
92
+ <Input
93
+ className={cn(
94
+ "flex-1 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:border-transparent focus-visible:ring-0",
95
+ className,
96
+ )}
97
+ data-slot="input-group-control"
98
+ {...props}
99
+ />
100
+ );
101
+ }
102
+
103
+ export {
104
+ InputGroup,
105
+ InputGroupAddon,
106
+ InputGroupButton,
107
+ InputGroupInput,
108
+ };
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
5
+ return (
6
+ <input
7
+ className={cn(
8
+ "h-9 w-full min-w-0 rounded-xl border border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
9
+ className,
10
+ )}
11
+ data-slot="input"
12
+ type={type}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ export { Input };
@@ -7,8 +7,10 @@ import {
7
7
  IconLoader2,
8
8
  IconMinus,
9
9
  IconRefresh,
10
+ IconSearch,
10
11
  IconTrendingDown,
11
12
  IconTrendingUp,
13
+ IconX,
12
14
  } from "@tabler/icons-react";
13
15
  import React, {
14
16
  useCallback,
@@ -45,6 +47,12 @@ import {
45
47
  CardHeader,
46
48
  CardTitle,
47
49
  } from "../components/ui/card";
50
+ import {
51
+ InputGroup,
52
+ InputGroupAddon,
53
+ InputGroupButton,
54
+ InputGroupInput,
55
+ } from "../components/ui/input-group";
48
56
  import {
49
57
  ChartContainer,
50
58
  ChartTooltip,
@@ -85,6 +93,7 @@ type SortColumn =
85
93
  | "user"
86
94
  | "userId";
87
95
  type SortDirection = "ascending" | "descending";
96
+ type RankingTab = "ranking" | "blacklist";
88
97
  type TrendRange = "week" | "month" | "all";
89
98
 
90
99
  interface TopUserSortDescriptor {
@@ -239,6 +248,20 @@ function compareText(left: string, right: string): number {
239
248
  });
240
249
  }
241
250
 
251
+ function normalizeSearchText(value: string): string {
252
+ return value.trim().toLocaleLowerCase("zh-CN");
253
+ }
254
+
255
+ function matchesSearchQuery(
256
+ query: string,
257
+ values: Array<string | null | undefined>,
258
+ ): boolean {
259
+ if (!query) return true;
260
+ return values.some((value) =>
261
+ value ? normalizeSearchText(value).includes(query) : false,
262
+ );
263
+ }
264
+
242
265
  function compareTopUsers(
243
266
  left: DashboardTopUser,
244
267
  right: DashboardTopUser,
@@ -384,6 +407,40 @@ function SortHeader({
384
407
  );
385
408
  }
386
409
 
410
+ function RankingSearch({
411
+ value,
412
+ onValueChange,
413
+ }: {
414
+ value: string;
415
+ onValueChange: (value: string) => void;
416
+ }) {
417
+ return (
418
+ <InputGroup className="ml-auto w-64 max-w-full sm:w-80">
419
+ <InputGroupAddon>
420
+ <IconSearch aria-hidden="true" />
421
+ </InputGroupAddon>
422
+ <InputGroupInput
423
+ aria-label="搜索用户"
424
+ placeholder="搜索 QQ 号或昵称"
425
+ type="search"
426
+ value={value}
427
+ onChange={(event) => onValueChange(event.target.value)}
428
+ />
429
+ {value ? (
430
+ <InputGroupAddon align="inline-end">
431
+ <InputGroupButton
432
+ aria-label="清空搜索"
433
+ size="icon-xs"
434
+ onClick={() => onValueChange("")}
435
+ >
436
+ <IconX aria-hidden="true" />
437
+ </InputGroupButton>
438
+ </InputGroupAddon>
439
+ ) : null}
440
+ </InputGroup>
441
+ );
442
+ }
443
+
387
444
  function StatCard({
388
445
  label,
389
446
  value,
@@ -562,10 +619,12 @@ function RelationshipDistribution({
562
619
  }
563
620
 
564
621
  function TopUserTable({
622
+ emptyMessage,
565
623
  users,
566
624
  selectedUserId,
567
625
  onSelectUser,
568
626
  }: {
627
+ emptyMessage: string;
569
628
  users: DashboardTopUser[];
570
629
  selectedUserId: string | null;
571
630
  onSelectUser: (user: DashboardTopUser) => void;
@@ -601,7 +660,7 @@ function TopUserTable({
601
660
  }, [pageCount]);
602
661
 
603
662
  if (!users.length) {
604
- return <p className="affinity-dashboard__empty">当前 scopeId 暂无好感度记录。</p>;
663
+ return <p className="affinity-dashboard__empty">{emptyMessage}</p>;
605
664
  }
606
665
 
607
666
  return (
@@ -788,9 +847,15 @@ function TopUserTable({
788
847
  );
789
848
  }
790
849
 
791
- function BlacklistTable({ items }: { items: DashboardBlacklistItem[] }) {
850
+ function BlacklistTable({
851
+ emptyMessage,
852
+ items,
853
+ }: {
854
+ emptyMessage: string;
855
+ items: DashboardBlacklistItem[];
856
+ }) {
792
857
  if (!items.length) {
793
- return <p className="affinity-dashboard__empty">当前 scopeId 暂无黑名单记录。</p>;
858
+ return <p className="affinity-dashboard__empty">{emptyMessage}</p>;
794
859
  }
795
860
 
796
861
  return (
@@ -964,13 +1029,36 @@ function RankingPanel({
964
1029
  }: {
965
1030
  data: DashboardData;
966
1031
  }) {
1032
+ const [activeTab, setActiveTab] = useState<RankingTab>("ranking");
1033
+ const [searchQuery, setSearchQuery] = useState("");
967
1034
  const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
1035
+ const normalizedSearchQuery = useMemo(
1036
+ () => normalizeSearchText(searchQuery),
1037
+ [searchQuery],
1038
+ );
1039
+ const filteredTopUsers = useMemo(
1040
+ () =>
1041
+ data.topUsers.filter((user) =>
1042
+ matchesSearchQuery(normalizedSearchQuery, [user.userId, user.name]),
1043
+ ),
1044
+ [data.topUsers, normalizedSearchQuery],
1045
+ );
1046
+ const filteredBlacklistItems = useMemo(
1047
+ () =>
1048
+ data.blacklistItems.filter((item) =>
1049
+ matchesSearchQuery(normalizedSearchQuery, [item.userId, item.name]),
1050
+ ),
1051
+ [data.blacklistItems, normalizedSearchQuery],
1052
+ );
1053
+ const hasSearchQuery = normalizedSearchQuery.length > 0;
968
1054
 
969
1055
  useEffect(() => {
970
1056
  setSelectedUserId((current) =>
971
- current && data.topUsers.some((user) => user.userId === current) ? current : null,
1057
+ current && filteredTopUsers.some((user) => user.userId === current)
1058
+ ? current
1059
+ : null,
972
1060
  );
973
- }, [data.topUsers]);
1061
+ }, [filteredTopUsers]);
974
1062
 
975
1063
  return (
976
1064
  <Card>
@@ -979,16 +1067,30 @@ function RankingPanel({
979
1067
  <CardDescription>用户好感与黑名单记录</CardDescription>
980
1068
  </CardHeader>
981
1069
  <CardContent>
982
- <Tabs defaultValue="ranking">
983
- <TabsList className="self-start">
984
- <TabsTrigger value="ranking">好感度</TabsTrigger>
985
- <TabsTrigger value="blacklist">黑名单</TabsTrigger>
986
- </TabsList>
1070
+ <Tabs
1071
+ value={activeTab}
1072
+ onValueChange={(value) => setActiveTab(value as RankingTab)}
1073
+ >
1074
+ <div className="flex flex-wrap items-center justify-between gap-3">
1075
+ <TabsList className="self-start">
1076
+ <TabsTrigger value="ranking">好感度</TabsTrigger>
1077
+ <TabsTrigger value="blacklist">黑名单</TabsTrigger>
1078
+ </TabsList>
1079
+ <RankingSearch
1080
+ value={searchQuery}
1081
+ onValueChange={setSearchQuery}
1082
+ />
1083
+ </div>
987
1084
  <TabsContent value="ranking">
988
1085
  <div className="grid gap-4">
989
1086
  <TopUserTable
1087
+ emptyMessage={
1088
+ hasSearchQuery
1089
+ ? "没有找到匹配的好感度用户。"
1090
+ : "当前 scopeId 暂无好感度记录。"
1091
+ }
990
1092
  selectedUserId={selectedUserId}
991
- users={data.topUsers}
1093
+ users={filteredTopUsers}
992
1094
  onSelectUser={(user) =>
993
1095
  setSelectedUserId((current) =>
994
1096
  current === user.userId ? null : user.userId,
@@ -998,7 +1100,14 @@ function RankingPanel({
998
1100
  </div>
999
1101
  </TabsContent>
1000
1102
  <TabsContent value="blacklist">
1001
- <BlacklistTable items={data.blacklistItems} />
1103
+ <BlacklistTable
1104
+ emptyMessage={
1105
+ hasSearchQuery
1106
+ ? "没有找到匹配的黑名单用户。"
1107
+ : "当前 scopeId 暂无黑名单记录。"
1108
+ }
1109
+ items={filteredBlacklistItems}
1110
+ />
1002
1111
  </TabsContent>
1003
1112
  </Tabs>
1004
1113
  </CardContent>