feat: 本次提交更新内容如下

新建内容库构建完成
This commit is contained in:
笔记本里的永平
2025-07-14 15:58:41 +08:00
parent 1d640abc6e
commit 53ae9d18c6
4 changed files with 412 additions and 48 deletions

View File

@@ -34,29 +34,35 @@ interface FriendsResponse {
}
// 获取好友列表API函数
const fetchFriendsList = async (page: number = 1, limit: number = 20, deviceIds: string[]): Promise<FriendsResponse> => {
if (deviceIds.length === 0) {
const fetchFriendsList = async (params: {
page: number;
limit: number;
deviceIds?: string[];
}): Promise<FriendsResponse> => {
if (params.deviceIds && params.deviceIds.length === 0) {
return {
code: 200,
msg: 'success',
data: {
list: [],
total: 0,
page,
limit
page: params.page,
limit: params.limit
}
};
}
const deviceIdsParam = deviceIds.join(',');
return get<FriendsResponse>(`/v1/friend?page=${page}&limit=${limit}&deviceIds=${deviceIdsParam}`);
const deviceIdsParam = params?.deviceIds?.join(',') || ''
return get<FriendsResponse>(`/v1/friend?page=${ params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}`);
};
// 组件属性接口
interface FriendSelectionProps {
selectedFriends: string[];
onSelect: (friends: string[]) => void;
deviceIds: string[];
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
deviceIds?: string[];
enableDeviceFilter?: boolean; // 新增开关默认true
placeholder?: string;
className?: string;
}
@@ -64,7 +70,9 @@ interface FriendSelectionProps {
export default function FriendSelection({
selectedFriends,
onSelect,
deviceIds,
onSelectDetail,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = ""
}: FriendSelectionProps) {
@@ -76,27 +84,38 @@ export default function FriendSelection({
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 当弹窗打开时获取好友列表
// 打开弹窗并请求第一页好友
const openDialog = () => {
setCurrentPage(1);
setDialogOpen(true);
fetchFriends(1);
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && deviceIds.length > 0) {
if (dialogOpen && currentPage !== 1) {
fetchFriends(currentPage);
}
}, [dialogOpen, currentPage, deviceIds]);
// 当设备ID变化时重置页码
useEffect(() => {
if (deviceIds.length > 0) {
setCurrentPage(1);
}
}, [deviceIds]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 获取好友列表API
const fetchFriends = async (page: number) => {
if (deviceIds.length === 0) return;
setLoading(true);
try {
const res = await fetchFriendsList(page, 20, deviceIds);
let res;
if (enableDeviceFilter) {
if (deviceIds.length === 0) {
setFriends([]);
setTotalFriends(0);
setTotalPages(1);
setLoading(false);
return;
}
res = await fetchFriendsList({ page, limit: 20, deviceIds: deviceIds });
} else {
res = await fetchFriendsList({ page, limit: 20 });
}
if (res && res.code === 200 && res.data) {
setFriends(res.data.list.map((friend) => ({
@@ -106,7 +125,6 @@ export default function FriendSelection({
avatar: friend.avatar || '',
customer: friend.customer || '',
})));
setTotalFriends(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
@@ -125,10 +143,16 @@ export default function FriendSelection({
// 处理好友选择
const handleFriendToggle = (friendId: string) => {
let newIds: string[];
if (selectedFriends.includes(friendId)) {
onSelect(selectedFriends.filter(id => id !== friendId));
newIds = selectedFriends.filter(id => id !== friendId);
} else {
onSelect([...selectedFriends, friendId]);
newIds = [...selectedFriends, friendId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = friends.filter(f => newIds.includes(f.id));
onSelectDetail(selectedObjs);
}
};
@@ -155,7 +179,7 @@ export default function FriendSelection({
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={() => setDialogOpen(true)}
onClick={openDialog}
value={getDisplayText()}
/>
</div>

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { get } from '@/api/request';
// 群组接口类型
interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
interface GroupsResponse {
code: number;
msg: string;
data: {
list: Array<{
id: number;
chatroomId: string;
name: string;
avatar?: string;
ownerWechatId?: string;
ownerNickname?: string;
ownerAvatar?: string;
}>;
total: number;
page: number;
limit: number;
};
}
const fetchGroupsList = async (params: { page: number; limit: number; }): Promise<GroupsResponse> => {
return get<GroupsResponse>(`/v1/chatroom?page=${params.page}&limit=${params.limit}`);
};
interface GroupSelectionProps {
selectedGroups: string[];
onSelect: (groups: string[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void; // 新增
placeholder?: string;
className?: string;
}
export default function GroupSelection({
selectedGroups,
onSelect,
onSelectDetail,
placeholder = '选择群聊',
className = ''
}: GroupSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
// 打开弹窗并请求第一页群组
const openDialog = () => {
setCurrentPage(1);
setDialogOpen(true);
fetchGroups(1);
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && currentPage !== 1) {
fetchGroups(currentPage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 获取群组列表API
const fetchGroups = async (page: number) => {
setLoading(true);
try {
const res = await fetchGroupsList({ page, limit: 20 });
if (res && res.code === 200 && res.data) {
setGroups(res.data.list.map((group) => ({
id: group.id?.toString() || '',
chatroomId: group.chatroomId || '',
name: group.name || '',
avatar: group.avatar || '',
ownerWechatId: group.ownerWechatId || '',
ownerNickname: group.ownerNickname || '',
ownerAvatar: group.ownerAvatar || '',
})));
setTotalGroups(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error('获取群组列表失败:', error);
} finally {
setLoading(false);
}
};
// 过滤群组
const filteredGroups = groups.filter(group =>
group.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
group.chatroomId.toLowerCase().includes(searchQuery.toLowerCase())
);
// 处理群组选择
const handleGroupToggle = (groupId: string) => {
let newIds: string[];
if (selectedGroups.includes(groupId)) {
newIds = selectedGroups.filter(id => id !== groupId);
} else {
newIds = [...selectedGroups, groupId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = groups.filter(g => newIds.includes(g.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return '';
return `已选择 ${selectedGroups.length} 个群聊`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</span>
<Input
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={openDialog}
value={getDisplayText()}
/>
</div>
{/* 群组选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<div className="p-6">
<DialogTitle className="text-center text-xl font-medium mb-6"></DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索群聊"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 py-2 rounded-full border-gray-200"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={() => setSearchQuery('')}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollArea className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : filteredGroups.length > 0 ? (
<div className="divide-y">
{filteredGroups.map((group) => (
<label
key={group.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleGroupToggle(group.id)}
>
<div className="mr-3 flex items-center justify-center">
<div className={`w-5 h-5 rounded-full border ${selectedGroups.includes(group.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
{selectedGroups.includes(group.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
</div>
</div>
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
{group.avatar ? (
<img src={group.avatar} alt={group.name} className="w-full h-full object-cover" />
) : (
group.name.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">ID: {group.chatroomId}</div>
{group.ownerNickname && (
<div className="text-sm text-gray-400">: {group.ownerNickname}</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500"></div>
</div>
)}
</ScrollArea>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalGroups}
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className="px-2 py-0 h-8 min-w-0"
>
&lt;
</Button>
<span className="text-sm">{currentPage} / {totalPages}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
&gt;
</Button>
</div>
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
</Button>
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
({selectedGroups.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,15 +2,16 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '@/components/Layout';
import UnifiedHeader from '@/components/UnifiedHeader';
import BottomNav from '@/components/BottomNav';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { Collapse, CollapsePanel } from 'tdesign-mobile-react';
import { Collapse, CollapsePanel ,Button} from 'tdesign-mobile-react';
import { toast } from '@/components/ui/toast';
import FriendSelection from '@/components/FriendSelection';
import GroupSelection from '@/components/GroupSelection';
import { post } from '@/api/request';
// TODO: 引入微信好友/群组选择器、日期选择器等组件
interface WechatFriend { id: string; nickname: string; avatar: string; }
@@ -45,6 +46,8 @@ export default function NewContentLibraryPage() {
aiPrompt: '',
enabled: true,
});
const [selectedFriendObjs, setSelectedFriendObjs] = useState<WechatFriend[]>([]);
const [selectedGroupObjs, setSelectedGroupObjs] = useState<WechatGroup[]>([]);
const [isFriendSelectorOpen, setIsFriendSelectorOpen] = useState(false);
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -54,7 +57,22 @@ export default function NewContentLibraryPage() {
const handleSave = async () => {
setIsSubmitting(true);
try {
await new Promise((resolve) => setTimeout(resolve, 500));
// 组装提交参数
const payload = {
name: form.name,
sourceType: form.sourceType === 'friends' ? 1 : 2,
friends: form.selectedFriends.map(f => Number(f.id)),
groups: form.selectedGroups.map(g => Number(g.id)),
groupMembers: {},
keywordInclude: form.keywordsInclude ? form.keywordsInclude.split(',').map(s => s.trim()).filter(Boolean) : [],
keywordExclude: form.keywordsExclude ? form.keywordsExclude.split(',').map(s => s.trim()).filter(Boolean) : [],
aiPrompt: form.aiPrompt,
timeEnabled: form.startDate || form.endDate ? 1 : 0,
startTime: form.startDate || '',
endTime: form.endDate || '',
status: form.enabled ? 1 : 0
};
await post('/v1/content/library/create', payload);
toast({ title: '创建成功', description: '内容库已保存' });
navigate('/content');
} catch (error) {
@@ -67,7 +85,13 @@ export default function NewContentLibraryPage() {
return (
<Layout
header={<UnifiedHeader title="新建内容库" showBack onBack={() => navigate(-1)} />}
footer={<BottomNav />}
footer={
<div className="p-4">
<Button theme="primary" block onClick={handleSave} disabled={isSubmitting || !form.name} >
{isSubmitting ? '创建中...' : '创建内容库'}
</Button>
</div>
}
>
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
<div className="p-4 space-y-4 max-w-lg mx-auto">
@@ -90,28 +114,84 @@ export default function NewContentLibraryPage() {
<TabsTrigger value="groups"></TabsTrigger>
</TabsList>
<TabsContent value="friends">
<Button variant="outline" className="w-full" onClick={() => setIsFriendSelectorOpen(true)}>
</Button>
{form.selectedFriends.length > 0 && (
<FriendSelection
selectedFriends={form.selectedFriends.map(f => f.id)}
onSelect={ids => setForm(f => ({
...f,
selectedFriends: ids.map(id => ({ id, nickname: id, avatar: '' }))
}))}
onSelectDetail={setSelectedFriendObjs}
enableDeviceFilter={false}
placeholder="选择微信好友"
/>
{selectedFriendObjs.length > 0 && (
<div className="mt-2 space-y-2">
{form.selectedFriends.map(friend => (
{selectedFriendObjs.map(friend => (
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<span>{friend.nickname}</span>
<div className="flex items-center gap-2">
{friend.avatar ? (
<img src={friend.avatar} alt={friend.nickname} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">{friend.nickname?.charAt(0) || '友'}</div>
)}
<span>{friend.nickname}</span>
</div>
<button
className="text-gray-400 hover:text-red-500 ml-2"
onClick={() => {
setForm(f => ({
...f,
selectedFriends: f.selectedFriends.filter(frd => frd.id !== friend.id)
}));
setSelectedFriendObjs(objs => objs.filter(frd => frd.id !== friend.id));
}}
title="移除"
>
×
</button>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="groups">
<Button variant="outline" className="w-full" onClick={() => setIsGroupSelectorOpen(true)}>
</Button>
{form.selectedGroups.length > 0 && (
<GroupSelection
selectedGroups={form.selectedGroups.map(g => g.id)}
onSelect={ids => setForm(f => ({
...f,
selectedGroups: ids.map(id => {
const old = f.selectedGroups.find(g => g.id === id);
return old || { id, name: id, avatar: '' };
})
}))}
onSelectDetail={setSelectedGroupObjs}
placeholder="选择群聊"
/>
{selectedGroupObjs.length > 0 && (
<div className="mt-2 space-y-2">
{form.selectedGroups.map(group => (
{selectedGroupObjs.map(group => (
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
<span>{group.name}</span>
<div className="flex items-center gap-2">
{group.avatar ? (
<img src={group.avatar} alt={group.name} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">{group.name?.charAt(0) || '群'}</div>
)}
<span>{group.name}</span>
</div>
<button
className="text-gray-400 hover:text-red-500 ml-2"
onClick={() => {
setForm(f => ({
...f,
selectedGroups: f.selectedGroups.filter(grp => grp.id !== group.id)
}));
setSelectedGroupObjs(objs => objs.filter(grp => grp.id !== group.id));
}}
title="移除"
>
×
</button>
</div>
))}
</div>
@@ -187,11 +267,7 @@ export default function NewContentLibraryPage() {
<label className="block font-medium mb-1"></label>
<Switch checked={form.enabled} onCheckedChange={checked => setForm(f => ({ ...f, enabled: checked }))} />
</div>
<div className="flex justify-end mt-4">
<Button onClick={handleSave} disabled={isSubmitting || !form.name}>
{isSubmitting ? '创建中...' : '创建内容库'}
</Button>
</div>
</div>
</Card>
</div>

View File

@@ -52,6 +52,7 @@ function formatDate(dateString: string) {
export default function TrafficPoolDetail() {
const { id } = useParams();
const [activeTab, setActiveTab] = useState<'base' | 'journey' | 'tags'>('base');
const user = getUserById(id as string);
if (!user) {
return <div className="p-8 text-center text-gray-400"></div>;
@@ -61,7 +62,6 @@ export default function TrafficPoolDetail() {
const device = getDevice(user.deviceId);
// RFM分段
const rfmSegment = Object.values(RFM_SEGMENTS).find((seg: any) => seg.name === user.rfmScore.segment) as { name: string; color: string } | undefined;
const [activeTab, setActiveTab] = useState<'base' | 'journey' | 'tags'>('base');
return (
<Layout