mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
feat: add group name search
Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
@@ -270,12 +270,21 @@ api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
|
|||||||
|
|
||||||
// Get list of known groups (for selection)
|
// Get list of known groups (for selection)
|
||||||
api.get("/groups", requireAuth, async (c) => {
|
api.get("/groups", requireAuth, async (c) => {
|
||||||
|
const query = c.req.query("q")?.trim();
|
||||||
|
const limit = Math.min(Number(c.req.query("limit") || 100), 200);
|
||||||
|
|
||||||
|
let whereClause = undefined;
|
||||||
|
if (query) {
|
||||||
|
whereClause = sql`${knownGroupChats.name} ilike ${`%${query}%`}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Return recent active groups
|
// Return recent active groups
|
||||||
const groups = await db
|
const groups = await db
|
||||||
.select()
|
.select()
|
||||||
.from(knownGroupChats)
|
.from(knownGroupChats)
|
||||||
|
.where(whereClause)
|
||||||
.orderBy(desc(knownGroupChats.lastActiveAt))
|
.orderBy(desc(knownGroupChats.lastActiveAt))
|
||||||
.limit(50);
|
.limit(limit);
|
||||||
return c.json(groups);
|
return c.json(groups);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MessageCircle, Plus, Trash2 } from "lucide-react";
|
import { MessageCircle, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { client } from "../lib/client";
|
import { client } from "../lib/client";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
|
|
||||||
@@ -29,16 +29,21 @@ export default function GroupBindingsModal({
|
|||||||
topicId,
|
topicId,
|
||||||
topicName,
|
topicName,
|
||||||
}: GroupBindingsModalProps) {
|
}: GroupBindingsModalProps) {
|
||||||
// const { user } = useAuth(); // Unused
|
|
||||||
const [bindings, setBindings] = useState<GroupBinding[]>([]);
|
const [bindings, setBindings] = useState<GroupBinding[]>([]);
|
||||||
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
|
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedChatId, setSelectedChatId] = useState("");
|
const [selectedChatId, setSelectedChatId] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [status, setStatus] = useState<{
|
const [status, setStatus] = useState<{
|
||||||
type: "success" | "error";
|
type: "success" | "error";
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchBindings = useCallback(async () => {
|
const fetchBindings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.api.topics[":id"].groups.$get(
|
const res = await client.api.topics[":id"].groups.$get(
|
||||||
@@ -58,17 +63,25 @@ export default function GroupBindingsModal({
|
|||||||
}
|
}
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
|
|
||||||
const fetchKnownGroups = useCallback(async () => {
|
const fetchKnownGroups = useCallback(async (q?: string) => {
|
||||||
|
setIsSearching(true);
|
||||||
try {
|
try {
|
||||||
const res = await client.api.groups.$get(undefined, {
|
const res = await client.api.groups.$get(
|
||||||
init: { credentials: "include" },
|
{
|
||||||
});
|
query: q ? { q } : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
init: { credentials: "include" },
|
||||||
|
},
|
||||||
|
);
|
||||||
const data = (await res.json()) as KnownGroup[];
|
const data = (await res.json()) as KnownGroup[];
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setKnownGroups(data);
|
setKnownGroups(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -78,24 +91,60 @@ export default function GroupBindingsModal({
|
|||||||
fetchKnownGroups();
|
fetchKnownGroups();
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setSelectedChatId("");
|
setSelectedChatId("");
|
||||||
|
setSearchQuery("");
|
||||||
|
setShowDropdown(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, topicId, fetchBindings, fetchKnownGroups]);
|
}, [isOpen, topicId, fetchBindings, fetchKnownGroups]);
|
||||||
|
|
||||||
|
// Handle click outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSearchQuery(value);
|
||||||
|
setSelectedChatId("");
|
||||||
|
setShowDropdown(true);
|
||||||
|
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
fetchKnownGroups(value);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectGroup = (group: KnownGroup) => {
|
||||||
|
setSelectedChatId(group.chatId);
|
||||||
|
setSearchQuery(group.name);
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBind = async () => {
|
const handleBind = async () => {
|
||||||
if (!selectedChatId) return;
|
if (!selectedChatId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
|
||||||
const group = knownGroups.find((g) => g.chatId === selectedChatId);
|
const groupName = searchQuery;
|
||||||
if (!group) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await client.api.topics[":id"].groups.$post(
|
const res = await client.api.topics[":id"].groups.$post(
|
||||||
{
|
{
|
||||||
param: { id: topicId },
|
param: { id: topicId },
|
||||||
json: {
|
json: {
|
||||||
chatId: group.chatId,
|
chatId: selectedChatId,
|
||||||
name: group.name,
|
name: groupName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,12 +163,12 @@ export default function GroupBindingsModal({
|
|||||||
});
|
});
|
||||||
fetchBindings();
|
fetchBindings();
|
||||||
setSelectedChatId("");
|
setSelectedChatId("");
|
||||||
|
setSearchQuery("");
|
||||||
} else {
|
} else {
|
||||||
await res.json(); // Consume body
|
await res.json();
|
||||||
setStatus({ type: "error", message: "Failed to bind group" });
|
setStatus({ type: "error", message: "Failed to bind group" });
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore error
|
|
||||||
setStatus({ type: "error", message: "An error occurred" });
|
setStatus({ type: "error", message: "An error occurred" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -147,7 +196,6 @@ export default function GroupBindingsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter out groups that are already bound
|
|
||||||
const availableGroups = knownGroups.filter(
|
const availableGroups = knownGroups.filter(
|
||||||
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
|
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
|
||||||
);
|
);
|
||||||
@@ -160,31 +208,41 @@ export default function GroupBindingsModal({
|
|||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<MessageCircle className="w-4 h-4 mr-2 text-indigo-500" />
|
||||||
Bound Groups
|
Bound Groups
|
||||||
</h4>
|
</h4>
|
||||||
{bindings.length === 0 ? (
|
{bindings.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic">
|
<div className="text-center py-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
|
||||||
No groups bound to this topic yet.
|
<p className="text-sm text-gray-400">
|
||||||
</p>
|
No groups bound to this topic yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-gray-200 border rounded-md">
|
<ul className="divide-y divide-gray-100 border rounded-lg overflow-hidden bg-white shadow-sm">
|
||||||
{bindings.map((binding) => (
|
{bindings.map((binding) => (
|
||||||
<li
|
<li
|
||||||
key={binding.id}
|
key={binding.id}
|
||||||
className="flex justify-between items-center p-3"
|
className="flex justify-between items-center p-3 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
<div className="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center mr-3 flex-shrink-0">
|
||||||
<span className="text-sm text-gray-700 mr-2">
|
<MessageCircle className="w-4 h-4 text-indigo-600" />
|
||||||
{binding.name}
|
</div>
|
||||||
</span>
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{binding.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono truncate">
|
||||||
|
{binding.chatId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium ${binding.status === "approved"
|
className={`ml-3 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase ${binding.status === "approved"
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-700"
|
||||||
: binding.status === "rejected"
|
: binding.status === "rejected"
|
||||||
? "bg-red-100 text-red-800"
|
? "bg-red-100 text-red-700"
|
||||||
: "bg-yellow-100 text-yellow-800"
|
: "bg-amber-100 text-amber-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{binding.status}
|
{binding.status}
|
||||||
@@ -193,10 +251,10 @@ export default function GroupBindingsModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleUnbind(binding.id)}
|
onClick={() => handleUnbind(binding.id)}
|
||||||
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
|
className="ml-2 text-gray-400 hover:text-red-500 p-1.5 rounded-md hover:bg-red-50 transition-colors"
|
||||||
title="Remove binding"
|
title="Remove binding"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4.5 h-4.5" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -204,45 +262,115 @@ export default function GroupBindingsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
|
<div className="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 shadow-sm">
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center">
|
||||||
|
<Plus className="w-4 h-4 mr-2 text-indigo-500" />
|
||||||
Add Group Binding
|
Add Group Binding
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
||||||
Select a group where the Feishu Bot has been added. If your group is
|
Search and select a group where the <strong>Alert Messenger</strong> bot
|
||||||
not listed, try removing and re-adding the bot to the group.
|
has been added.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
<select
|
<div className="relative" ref={dropdownRef}>
|
||||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
|
<div className="relative">
|
||||||
value={selectedChatId}
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
onChange={(e) => setSelectedChatId(e.target.value)}
|
{isSearching ? (
|
||||||
disabled={loading}
|
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||||
>
|
) : (
|
||||||
<option value="">Select a group...</option>
|
<Search className="h-4 w-4 text-gray-400" />
|
||||||
{availableGroups.map((group) => (
|
)}
|
||||||
<option key={group.chatId} value={group.chatId}>
|
</div>
|
||||||
{group.name}
|
<input
|
||||||
</option>
|
type="text"
|
||||||
))}
|
className="block w-full pl-10 pr-10 py-2.5 bg-white border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 placeholder-gray-400 transition-all"
|
||||||
</select>
|
placeholder="Search for a group name..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onFocus={() => knownGroups.length > 0 && setShowDropdown(true)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedChatId("");
|
||||||
|
fetchKnownGroups();
|
||||||
|
}}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full bg-white shadow-xl max-h-60 rounded-lg py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm border border-gray-100">
|
||||||
|
{availableGroups.length > 0 ? (
|
||||||
|
availableGroups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.chatId}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-4 py-2.5 hover:bg-indigo-50 cursor-pointer flex items-center group transition-colors"
|
||||||
|
onClick={() => handleSelectGroup(group)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-gray-900 truncate group-hover:text-indigo-700">
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 font-mono truncate">
|
||||||
|
{group.chatId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedChatId === group.chatId && (
|
||||||
|
<Plus className="w-4 h-4 text-indigo-600 ml-2" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-6 text-center text-gray-500">
|
||||||
|
<p className="text-sm font-medium">No results found</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Try a different search term or check if the bot is in
|
||||||
|
the group.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBind}
|
onClick={handleBind}
|
||||||
disabled={!selectedChatId || loading}
|
disabled={!selectedChatId || loading}
|
||||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
{loading ? (
|
||||||
Add
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Bind This Group
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<p
|
<div
|
||||||
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
|
className={`mt-4 p-3 rounded-lg flex items-start gap-2 ${status.type === "success"
|
||||||
|
? "bg-green-50 text-green-700 border border-green-100"
|
||||||
|
: "bg-red-50 text-red-700 border border-red-100"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{status.message}
|
<div className="text-sm font-medium">{status.message}</div>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user