同步代码

This commit is contained in:
2025-08-12 09:27:50 +08:00
parent dd4446fd8c
commit 9c3bc5200a
421 changed files with 54063 additions and 53044 deletions

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取好友列表
export function getAccountList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/workbench/account-list", params, "GET");
}

View File

@@ -0,0 +1,34 @@
// 账号对象类型
export interface AccountItem {
id: number;
userName: string;
realName: string;
departmentName: string;
avatar?: string;
[key: string]: any;
}
//弹窗的
export interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}
// 组件属性接口
export interface AccountSelectionProps {
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}

View File

@@ -0,0 +1,231 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -0,0 +1,139 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { AccountItem, AccountSelectionProps } from "./data";
export default function AccountSelection({
selectedOptions,
onSelect,
accounts: propAccounts = [],
placeholder = "选择账号",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: AccountSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个账号`;
};
// 删除已选账号
const handleRemoveAccount = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(d => d.id !== id));
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选账号列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(acc => (
<div
key={acc.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{acc.realName} {acc.userName}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveAccount(acc.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Popup } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import style from "./index.module.scss";
import { getAccountList } from "./api";
import { AccountItem, SelectionPopupProps } from "./data";
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [loading, setLoading] = useState(false);
// 累积已加载过的账号,确保确认时能返回更完整的对象
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
const pageSize = 20;
const fetchAccounts = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = { page, limit: pageSize };
if (keyword.trim()) params.keyword = keyword.trim();
const response = await getAccountList(params);
if (response && response.list) {
setAccounts(response.list);
const total: number = response.total || response.list.length || 0;
setTotalAccounts(total);
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
// 累积到映射表
response.list.forEach((acc: AccountItem) => {
loadedAccountMapRef.current.set(acc.id, acc);
});
} else {
setAccounts([]);
setTotalAccounts(0);
setTotalPages(1);
}
} catch (error) {
console.error("获取账号列表失败:", error);
} finally {
setLoading(false);
}
};
const handleAccountToggle = (account: AccountItem) => {
if (readonly || !onSelect) return;
const isSelected = selectedOptions.some(opt => opt.id === account.id);
const next = isSelected
? selectedOptions.filter(opt => opt.id !== account.id)
: selectedOptions.concat(account);
onSelect(next);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
loadedAccountMapRef.current.clear();
fetchAccounts(1, "");
}
}, [visible]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
if (searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchAccounts(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchAccounts(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
const selectedIdSet = useMemo(
() => new Set(selectedOptions.map(opt => opt.id)),
[selectedOptions],
);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择账号"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索账号"
loading={loading}
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalAccounts}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : accounts.length > 0 ? (
<div className={style.friendListInner}>
{accounts.map(acc => (
<label
key={acc.id}
className={style.friendItem}
onClick={() => !readonly && handleAccountToggle(acc)}
>
<div className={style.radioWrapper}>
<div
className={
selectedIdSet.has(acc.id)
? style.radioSelected
: style.radioUnselected
}
>
{selectedIdSet.has(acc.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{acc.avatar ? (
<img
src={acc.avatar}
alt={acc.userName}
className={style.avatarImg}
/>
) : (
(acc.userName?.charAt(0) ?? "?")
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{acc.userName}</div>
<div className={style.friendId}>
: {acc.realName}
</div>
<div className={style.friendId}>
: {acc.departmentName}
</div>
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的账号`
: "没有找到账号"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -1,92 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, ArrowLeft } from 'lucide-react';
interface BackButtonProps {
/** 返回按钮的样式变体 */
variant?: 'icon' | 'button' | 'text';
/** 自定义返回逻辑如果不提供则使用navigate(-1) */
onBack?: () => void;
/** 按钮文本仅在button和text变体时使用 */
text?: string;
/** 自定义CSS类名 */
className?: string;
/** 图标大小 */
iconSize?: number;
/** 是否显示图标 */
showIcon?: boolean;
/** 自定义图标 */
icon?: React.ReactNode;
}
/**
* 通用返回上一页按钮组件
* 使用React Router的navigate方法实现返回功能
*/
export const BackButton: React.FC<BackButtonProps> = ({
variant = 'icon',
onBack,
text = '返回',
className = '',
iconSize = 6,
showIcon = true,
icon
}) => {
const navigate = useNavigate();
const handleBack = () => {
if (onBack) {
onBack();
} else {
navigate(-1);
}
};
const defaultIcon = variant === 'icon' ? (
<ChevronLeft className={`h-${iconSize} w-${iconSize}`} />
) : (
<ArrowLeft className={`h-${iconSize} w-${iconSize}`} />
);
const buttonIcon = icon || (showIcon ? defaultIcon : null);
switch (variant) {
case 'icon':
return (
<button
onClick={handleBack}
className={`p-2 hover:bg-gray-100 rounded-lg transition-colors ${className}`}
title="返回上一页"
>
{buttonIcon}
</button>
);
case 'button':
return (
<button
onClick={handleBack}
className={`flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors ${className}`}
>
{buttonIcon}
{text}
</button>
);
case 'text':
return (
<button
onClick={handleBack}
className={`flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors ${className}`}
>
{buttonIcon}
{text}
</button>
);
default:
return null;
}
};
export default BackButton;

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Home, Users, LayoutGrid, User } from 'lucide-react';
const navItems = [
{
id: "home",
name: "首页",
href: "/",
icon: Home,
active: (pathname: string) => pathname === "/",
},
{
id: "scenarios",
name: "场景获客",
href: "/scenarios",
icon: Users,
active: (pathname: string) => pathname.startsWith("/scenarios"),
},
{
id: "workspace",
name: "工作台",
href: "/workspace",
icon: LayoutGrid,
active: (pathname: string) => pathname.startsWith("/workspace"),
},
{
id: "profile",
name: "我的",
href: "/profile",
icon: User,
active: (pathname: string) => pathname.startsWith("/profile"),
},
];
interface BottomNavProps {
activeTab?: string;
}
export default function BottomNav({ activeTab }: BottomNavProps) {
const location = useLocation();
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
{navItems.map((item) => {
const IconComponent = item.icon;
const isActive = activeTab ? activeTab === item.id : item.active(location.pathname);
return (
<Link
key={item.href}
to={item.href}
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
isActive ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
}`}
>
<IconComponent className="w-5 h-5" />
<span className="text-xs mt-1">{item.name}</span>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -1,210 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchContentLibraryList } from "@/api/content";
import { ContentLibrary } from "@/api/content";
import { useToast } from "@/components/ui/toast";
interface ContentLibrarySelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedLibraries: string[];
onSelect: (libraries: string[]) => void;
}
export function ContentLibrarySelectionDialog({
open,
onOpenChange,
selectedLibraries,
onSelect,
}: ContentLibrarySelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [tempSelected, setTempSelected] = useState<string[]>([]);
// 获取内容库列表
const fetchLibraries = useCallback(async () => {
setLoading(true);
try {
const response = await fetchContentLibraryList(1, 100, searchQuery);
if (response.code === 200 && response.data) {
setLibraries(response.data.list);
} else {
toast({
title: "获取内容库列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error("获取内容库列表失败:", error);
toast({
title: "获取内容库列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
}, [searchQuery, toast]);
useEffect(() => {
if (open) {
fetchLibraries();
setTempSelected(selectedLibraries);
}
}, [open, selectedLibraries, fetchLibraries]);
const handleRefresh = () => {
fetchLibraries();
};
const handleSelectAll = () => {
if (tempSelected.length === libraries.length) {
setTempSelected([]);
} else {
setTempSelected(libraries.map((lib) => lib.id));
}
};
const handleLibraryToggle = (libraryId: string) => {
setTempSelected((prev) =>
prev.includes(libraryId)
? prev.filter((id) => id !== libraryId)
: [...prev, libraryId]
);
};
const handleDialogOpenChange = (open: boolean) => {
if (!open) {
setTempSelected(selectedLibraries);
}
onOpenChange(open);
};
const handleConfirm = () => {
onSelect(tempSelected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-2 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索内容库"
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-gray-500">
{tempSelected.length}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={loading || libraries.length === 0}
>
{tempSelected.length === libraries.length ? "取消全选" : "全选"}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-500">
...
</div>
) : libraries.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
</div>
) : (
libraries.map((library) => (
<label
key={library.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
>
<input
type="checkbox"
checked={tempSelected.includes(library.id)}
onChange={() => handleLibraryToggle(library.id)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{library.name}</span>
<Badge variant="outline">
{library.sourceType === 1
? "文本"
: library.sourceType === 2
? "图片"
: "视频"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>: {library.creatorName || "-"}</div>
<div>
:{" "}
{new Date(library.updateTime).toLocaleString()}
</div>
</div>
</div>
</label>
))
)}
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
{tempSelected.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}>
{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,5 @@
import request from "@/api/request";
export function getContentLibraryList(params: any) {
return request("/v1/content/library/list", params, "GET");
}

View File

@@ -0,0 +1,21 @@
// 内容库接口类型
export interface ContentItem {
id: number;
name: string;
[key: string]: any;
}
// 组件属性接口
export interface ContentSelectionProps {
selectedOptions: ContentItem[];
onSelect: (selectedItems: ContentItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedItems: ContentItem[]) => void;
}

View File

@@ -0,0 +1,117 @@
.inputWrapper {
position: relative;
}
.selectedListWindow {
margin-top: 8px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fff;
}
.selectedListRow {
display: flex;
align-items: center;
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.libraryList {
flex: 1;
overflow-y: auto;
}
.libraryListInner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.libraryItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.checkboxWrapper {
margin-top: 4px;
}
.checkboxSelected {
width: 20px;
height: 20px;
border-radius: 4px;
background: #1677ff;
display: flex;
align-items: center;
justify-content: center;
}
.checkboxUnselected {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #e5e6eb;
background: #fff;
}
.checkboxDot {
width: 12px;
height: 12px;
border-radius: 2px;
background: #fff;
}
.libraryInfo {
flex: 1;
}
.libraryHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.libraryName {
font-weight: 500;
font-size: 16px;
color: #222;
}
.typeTag {
font-size: 12px;
color: #1677ff;
border: 1px solid #1677ff;
border-radius: 12px;
padding: 2px 10px;
margin-left: 8px;
background: #f4f8ff;
font-weight: 500;
}
.libraryMeta {
font-size: 12px;
color: #888;
}
.libraryDesc {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
}
.emptyText {
color: #888;
font-size: 15px;
}

View File

@@ -0,0 +1,302 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Popup, Checkbox } from "antd-mobile";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { getContentLibraryList } from "./api";
import { ContentItem, ContentSelectionProps } from "./data";
// 类型标签文本
const getTypeText = (type?: number) => {
if (type === 1) return "文本";
if (type === 2) return "图片";
if (type === 3) return "视频";
return "未知";
};
// 时间格式化
const formatDate = (dateStr?: string) => {
if (!dateStr) return "-";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "-";
return `${d.getFullYear()}/${(d.getMonth() + 1)
.toString()
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
.getHours()
.toString()
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
.getSeconds()
.toString()
.padStart(2, "0")}`;
};
export default function ContentSelection({
selectedOptions,
onSelect,
placeholder = "选择内容库",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: ContentSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [libraries, setLibraries] = useState<ContentItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalLibraries, setTotalLibraries] = useState(0);
const [loading, setLoading] = useState(false);
// 删除已选内容库
const handleRemoveLibrary = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(c => c.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
fetchLibraries(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (realVisible && currentPage !== 1) {
fetchLibraries(currentPage, searchQuery);
}
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchLibraries(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible]);
// 获取内容库列表API
const fetchLibraries = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getContentLibraryList(params);
if (response && response.list) {
setLibraries(response.list);
setTotalLibraries(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取内容库列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理内容库选择
const handleLibraryToggle = (library: ContentItem) => {
if (readonly) return;
const newSelected = selectedOptions.some(c => c.id === library.id)
? selectedOptions.filter(c => c.id !== library.id)
: [...selectedOptions, library];
onSelect(newSelected);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个内容库`;
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
setRealVisible(false);
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选内容库列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(item => (
<div
key={item.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.name || item.id}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveLibrary(item.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择内容库"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索内容库"
loading={loading}
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalLibraries}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.libraryList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : libraries.length > 0 ? (
<div className={style.libraryListInner}>
{libraries.map(item => (
<label key={item.id} className={style.libraryItem}>
<Checkbox
checked={selectedOptions.map(c => c.id).includes(item.id)}
onChange={() => !readonly && handleLibraryToggle(item)}
disabled={readonly}
className={style.checkboxWrapper}
/>
<div className={style.libraryInfo}>
<div className={style.libraryHeader}>
<span className={style.libraryName}>{item.name}</span>
<span className={style.typeTag}>
{getTypeText(item.sourceType)}
</span>
</div>
<div className={style.libraryMeta}>
<div>: {item.creatorName || "-"}</div>
<div>: {formatDate(item.updateTime)}</div>
</div>
{item.description && (
<div className={style.libraryDesc}>
{item.description}
</div>
)}
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的内容库`
: "没有找到内容库"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
</>
);
}

View File

@@ -1,211 +0,0 @@
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchDeviceList } from "@/api/devices";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
}
export default function DeviceSelection({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = "",
}: DeviceSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
// 获取设备列表支持keyword
const fetchDevices = async (keyword: string = "") => {
setLoading(true);
try {
const res = await fetchDeviceList(1, 100, keyword.trim() || undefined);
if (res && res.data && Array.isArray(res.data.list)) {
setDevices(
res.data.list.map((d) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
}))
);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
};
// 打开弹窗时获取设备列表
const openDialog = () => {
setSearchQuery("");
setDialogOpen(true);
fetchDevices("");
};
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<Search className="absolute left-3 top-4 h-5 w-5 text-gray-400" />
<Input
placeholder={placeholder}
className="pl-10 h-14 rounded-xl border-gray-200 text-base"
readOnly
onClick={openDialog}
value={getDisplayText()}
/>
</div>
{/* 设备选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent
className="w-full h-full max-w-none max-h-none flex flex-col bg-white"
aria-describedby="device-selection-description"
>
<div id="device-selection-description" className="sr-only">
</div>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-4 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : (
<div className="space-y-2">
{filteredDevices.map((device) => (
<label
key={device.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer"
>
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={() => handleDeviceToggle(device.id)}
className="mt-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<div
className={`w-16 h-6 flex items-center justify-center text-xs ${
device.status === "online"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-gray-500">
{selectedDevices.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取设备列表
export function getDeviceList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/devices", params, "GET");
}

View File

@@ -0,0 +1,26 @@
// 设备选择项接口
export interface DeviceSelectionItem {
id: number;
memo: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
export interface DeviceSelectionProps {
selectedOptions: DeviceSelectionItem[];
onSelect: (devices: DeviceSelectionItem[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}

View File

@@ -0,0 +1,182 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.input {
padding-left: 38px !important;
height: 56px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.popupSearchInput {
padding-left: 36px !important;
border-radius: 12px !important;
height: 44px;
font-size: 15px;
border: 1px solid #e5e6eb !important;
background: #f8f9fa;
}
.statusSelect {
width: 120px;
height: 40px;
border-radius: 8px;
border: 1px solid #e5e6eb;
font-size: 15px;
padding: 0 10px;
background: #fff;
}
.deviceList {
flex: 1;
overflow-y: auto;
}
.deviceListInner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.deviceItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.deviceCheckbox {
margin-top: 4px;
}
.deviceInfo {
flex: 1;
}
.deviceInfoRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.deviceName {
font-weight: 500;
font-size: 16px;
color: #222;
}
.statusOnline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #52c41a;
color: #fff;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.statusOffline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #e5e6eb;
color: #888;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.deviceInfoDetail {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
border-radius: 16px;
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
}

View File

@@ -0,0 +1,139 @@
import React, { useState } from "react";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { DeviceSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
import style from "./index.module.scss";
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedOptions,
onSelect,
placeholder = "选择设备",
className = "",
mode = "input",
open,
onOpenChange,
selectedListMaxHeight = 300, // 默认300
showInput = true,
showSelectedList = true,
readonly = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(false);
const isDialog = mode === "dialog";
const realVisible = isDialog ? !!open : popupVisible;
const setRealVisible = (v: boolean) => {
if (isDialog && onOpenChange) onOpenChange(v);
if (!isDialog) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个设备`;
};
// 删除已选设备
const handleRemoveDevice = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(v => v.id !== id));
};
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
{mode === "input" && showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选设备列表窗口 */}
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(device => (
<div
key={device.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{device.memo} - {device.wechatId}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveDevice(device.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible && !readonly}
onClose={() => setRealVisible(false)}
selectedOptions={selectedOptions}
onSelect={onSelect}
/>
</>
);
};
export default DeviceSelection;

View File

@@ -0,0 +1,198 @@
import React, { useState, useEffect, useCallback } from "react";
import { Checkbox, Popup } from "antd-mobile";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { DeviceSelectionItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onClose: () => void;
selectedOptions: DeviceSelectionItem[];
onSelect: (devices: DeviceSelectionItem[]) => void;
}
const PAGE_SIZE = 20;
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedOptions,
onSelect,
}) => {
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
memo: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
})),
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[],
);
// 打开弹窗时获取第一页
useEffect(() => {
if (visible) {
setSearchQuery("");
setCurrentPage(1);
fetchDevices("", 1);
}
}, [visible, fetchDevices]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!visible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter(device => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (device: DeviceSelectionItem) => {
if (selectedOptions.some(v => v.id === device.id)) {
onSelect(selectedOptions.filter(v => v.id !== device.id));
} else {
const newSelectedOptions = [...selectedOptions, device];
onSelect(newSelectedOptions);
}
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false}
>
<Layout
header={
<PopupHeader
title="选择设备"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索设备IMEI/备注/微信号"
loading={loading}
onRefresh={() => fetchDevices(searchQuery, currentPage)}
showTabs={true}
tabsConfig={{
activeKey: statusFilter,
onChange: setStatusFilter,
tabs: [
{ title: "全部", key: "all" },
{ title: "在线", key: "online" },
{ title: "离线", key: "offline" },
],
}}
/>
}
footer={
<PopupFooter
total={total}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={onClose}
onConfirm={onClose}
/>
}
>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map(device => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedOptions.some(v => v.id === device.id)}
onChange={() => handleDeviceToggle(device)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.memo}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -1,234 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchDeviceList } from "@/api/devices";
import { ServerDevice } from "@/types/device";
import { useToast } from "@/components/ui/toast";
interface Device {
id: string;
name: string;
imei: string;
wxid: string;
status: "online" | "offline";
usedInPlans: number;
nickname: string;
}
interface DeviceSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDevices: string[];
onSelect: (devices: string[]) => void;
}
export function DeviceSelectionDialog({
open,
onOpenChange,
selectedDevices,
onSelect,
}: DeviceSelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState<Device[]>([]);
// 获取设备列表支持keyword
const fetchDevices = useCallback(
async (keyword: string = "") => {
setLoading(true);
try {
const response = await fetchDeviceList(
1,
100,
keyword.trim() || undefined
);
if (response.code === 200 && response.data) {
// 转换服务端数据格式为组件需要的格式
const convertedDevices: Device[] = response.data.list.map(
(serverDevice: ServerDevice) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0, // 这个字段需要从其他API获取
nickname: serverDevice.nickname || "",
})
);
setDevices(convertedDevices);
} else {
toast({
title: "获取设备列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error("获取设备列表失败:", error);
toast({
title: "获取设备列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
},
[toast]
);
// 打开弹窗时获取设备列表
useEffect(() => {
if (open) {
fetchDevices("");
}
}, [open, fetchDevices]);
// 搜索防抖
useEffect(() => {
if (!open) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, open, fetchDevices]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const handleDeviceSelect = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="w-full h-full max-w-none max-h-none flex flex-col bg-white"
aria-describedby="device-selection-dialog-description"
>
<div id="device-selection-dialog-description" className="sr-only">
</div>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex items-center space-x-4 my-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
variant="outline"
size="icon"
onClick={() => fetchDevices(searchQuery)}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-500">
...
</div>
) : filteredDevices.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
</div>
) : (
filteredDevices.map((device) => (
<label
key={device.id}
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceSelect(device.id)}
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<Badge
variant={
device.status === "online" ? "default" : "secondary"
}
>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>IMEI: {device.imei}</div>
<div>: {device.wxid || "-"}</div>
<div>: {device.nickname || "-"}</div>
</div>
{device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-1">
{device.usedInPlans}
</div>
)}
</div>
</label>
))
)}
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
{selectedDevices.length}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={() => onOpenChange(false)}>
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,381 +0,0 @@
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 WechatFriend {
id: string;
nickname: string;
wechatId: string;
avatar: string;
customer: string;
}
// 好友列表API响应类型
interface FriendsResponse {
code: number;
msg: string;
data: {
list: Array<{
id: number;
nickname: string;
wechatId: string;
avatar?: string;
customer?: string;
}>;
total: number;
page: number;
limit: number;
};
}
// 获取好友列表API函数 - 添加 keyword 参数
const fetchFriendsList = async (params: {
page: number;
limit: number;
deviceIds?: string[];
keyword?: string;
}): Promise<FriendsResponse> => {
if (params.deviceIds && params.deviceIds.length === 0) {
return {
code: 200,
msg: "success",
data: {
list: [],
total: 0,
page: params.page,
limit: params.limit,
},
};
}
const deviceIdsParam = params?.deviceIds?.join(",") || "";
const keywordParam = params?.keyword
? `&keyword=${encodeURIComponent(params.keyword)}`
: "";
return get<FriendsResponse>(
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}${keywordParam}`
);
};
// 组件属性接口
interface FriendSelectionProps {
selectedFriends: string[];
onSelect: (friends: string[]) => void;
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
deviceIds?: string[];
enableDeviceFilter?: boolean; // 新增开关默认true
placeholder?: string;
className?: string;
}
export default function FriendSelection({
selectedFriends,
onSelect,
onSelectDetail,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
}: FriendSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 打开弹窗并请求第一页好友
const openDialog = () => {
setCurrentPage(1);
setSearchQuery(""); // 重置搜索关键词
setDialogOpen(true);
fetchFriends(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && currentPage !== 1) {
fetchFriends(currentPage, searchQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
setCurrentPage(1); // 重置到第一页
fetchFriends(1, searchQuery);
}, 500); // 500 防抖
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 获取好友列表API - 添加 keyword 参数
const fetchFriends = async (page: number, keyword: string = "") => {
setLoading(true);
try {
let res;
if (enableDeviceFilter) {
if (deviceIds.length === 0) {
setFriends([]);
setTotalFriends(0);
setTotalPages(1);
setLoading(false);
return;
}
res = await fetchFriendsList({
page,
limit: 20,
deviceIds: deviceIds,
keyword: keyword.trim() || undefined,
});
} else {
res = await fetchFriendsList({
page,
limit: 20,
keyword: keyword.trim() || undefined,
});
}
if (res && res.code === 200 && res.data) {
setFriends(
res.data.list.map((friend) => ({
id: friend.id?.toString() || "",
nickname: friend.nickname || "",
wechatId: friend.wechatId || "",
avatar: friend.avatar || "",
customer: friend.customer || "",
}))
);
setTotalFriends(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理好友选择
const handleFriendToggle = (friendId: string) => {
let newIds: string[];
if (selectedFriends.includes(friendId)) {
newIds = selectedFriends.filter((id) => id !== friendId);
} else {
newIds = [...selectedFriends, friendId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedFriends.length === 0) return "";
return `已选择 ${selectedFriends.length} 个好友`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchFriends(1, "");
};
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="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
aria-describedby="friend-selection-description"
>
<div id="friend-selection-description" className="sr-only">
</div>
<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" />
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={handleClearSearch}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : friends.length > 0 ? (
<div className="divide-y">
{friends.map((friend) => (
<label
key={friend.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handleFriendToggle(friend.id)}
>
<div className="mr-3 flex items-center justify-center">
<div
className={`w-5 h-5 rounded-full border ${
selectedFriends.includes(friend.id)
? "border-blue-600"
: "border-gray-300"
} flex items-center justify-center`}
>
{selectedFriends.includes(friend.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">
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className="w-full h-full object-cover"
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{friend.nickname}</div>
<div className="text-sm text-gray-500">
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className="text-sm text-gray-400">
: {friend.customer}
</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalFriends}
</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"
>
({selectedFriends.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 获取好友列表
export function getFriendList(params: {
page: number;
limit: number;
deviceIds?: string; // 逗号分隔
keyword?: string;
}) {
return request("/v1/friend", params, "GET");
}

View File

@@ -0,0 +1,27 @@
export interface FriendSelectionItem {
id: number;
wechatId: string;
nickname: string;
avatar: string;
[key: string]: any;
}
// 组件属性接口
export interface FriendSelectionProps {
selectedOptions?: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void; // 新增
}

View File

@@ -0,0 +1,246 @@
.inputWrapper {
position: relative;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -0,0 +1,140 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import { FriendSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
export default function FriendSelection({
selectedOptions = [],
onSelect,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 内部弹窗交给 selectionPopup 处理
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (!selectedOptions || selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个好友`;
};
// 删除已选好友
const handleRemoveFriend = (id: number) => {
if (readonly) return;
onSelect((selectedOptions || []).filter(v => v.id !== id));
};
// 弹窗确认回调
const handleConfirm = (
selectedIds: number[],
selectedItems: typeof selectedOptions,
) => {
onSelect(selectedItems);
if (onConfirm) onConfirm(selectedIds, selectedItems);
setRealVisible(false);
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选好友列表窗口 */}
{showSelectedList && (selectedOptions || []).length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{(selectedOptions || []).map(friend => (
<div key={friend.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={friend.avatar} />
<div className={style.selectedListRowContentText}>
<div>{friend.nickname}</div>
<div>{friend.wechatId}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveFriend(friend.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible && !readonly}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions || []}
onSelect={onSelect}
deviceIds={deviceIds}
enableDeviceFilter={enableDeviceFilter}
readonly={readonly}
onConfirm={handleConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useCallback, useEffect, useState } from "react";
import { Popup, Checkbox } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { getFriendList } from "./api";
import style from "./index.module.scss";
import type { FriendSelectionItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
enableDeviceFilter?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: number[],
selectedItems: FriendSelectionItem[],
) => void;
}
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onVisibleChange,
selectedOptions,
onSelect,
deviceIds = [],
enableDeviceFilter = true,
readonly = false,
onConfirm,
}) => {
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 获取好友列表API
const fetchFriends = useCallback(
async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
if (enableDeviceFilter && deviceIds.length > 0) {
params.deviceIds = deviceIds.join(",");
}
const response = await getFriendList(params);
if (response && response.list) {
setFriends(response.list);
setTotalFriends(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
},
[deviceIds, enableDeviceFilter],
);
// 处理好友选择
const handleFriendToggle = (friend: FriendSelectionItem) => {
if (readonly) return;
const newSelectedFriends = selectedOptions.some(f => f.id === friend.id)
? selectedOptions.filter(f => f.id !== friend.id)
: selectedOptions.concat(friend);
onSelect(newSelectedFriends);
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(v => v.id),
selectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
fetchFriends(1, "");
}
}, [visible]); // 只在弹窗开启时请求
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
const timer = setTimeout(() => {
setCurrentPage(1);
fetchFriends(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return; // 弹窗关闭或第一页时不请求
fetchFriends(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择微信好友"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索好友"
loading={loading}
onRefresh={() => fetchFriends(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalFriends}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : friends.length > 0 ? (
<div className={style.friendListInner}>
{friends.map(friend => (
<div key={friend.id} className={style.friendItem}>
<Checkbox
checked={selectedOptions.some(f => f.id === friend.id)}
onChange={() => !readonly && handleFriendToggle(friend)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className={style.avatarImg}
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{friend.nickname}</div>
<div className={style.friendId}>
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className={style.friendCustomer}>
: {friend.customer}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -1,343 +0,0 @@
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;
};
}
// 修改支持keyword参数
const fetchGroupsList = async (params: {
page: number;
limit: number;
keyword?: string;
}): Promise<GroupsResponse> => {
const keywordParam = params.keyword
? `&keyword=${encodeURIComponent(params.keyword)}`
: "";
return get<GroupsResponse>(
`/v1/chatroom?page=${params.page}&limit=${params.limit}${keywordParam}`
);
};
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);
setSearchQuery(""); // 重置搜索关键词
setDialogOpen(true);
fetchGroups(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (dialogOpen && currentPage !== 1) {
fetchGroups(currentPage, searchQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 搜索防抖
useEffect(() => {
if (!dialogOpen) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, dialogOpen]);
// 获取群组列表API - 支持keyword
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const res = await fetchGroupsList({
page,
limit: 20,
keyword: keyword.trim() || undefined,
});
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 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);
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchGroups(1, "");
};
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="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
aria-describedby="group-selection-description"
>
<div id="group-selection-description" className="sr-only">
</div>
<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" />
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={handleClearSearch}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">...</div>
</div>
) : groups.length > 0 ? (
<div className="divide-y">
{groups.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">
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalGroups}
{searchQuery && ` (搜索: "${searchQuery}")`}
</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

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/chatroom", params, "GET");
}

View File

@@ -0,0 +1,43 @@
// 群组接口类型
export interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
export interface GroupSelectionItem {
id: string;
avatar: string;
chatroomId?: string;
createTime?: number;
identifier?: string;
name: string;
ownerAlias?: string;
ownerAvatar?: string;
ownerNickname?: string;
ownerWechatId?: string;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void; // 新增
}

View File

@@ -0,0 +1,206 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function GroupSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择群聊",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选群聊
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(g => g.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个群聊`;
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选群聊列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(group => (
<div key={group.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={group.avatar} />
<div className={style.selectedListRowContentText}>
<div>{group.name}</div>
<div>{group.chatroomId}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { GroupSelectionItem } from "./data";
// 群组接口类型
interface WechatGroup {
id: string;
name: string;
avatar: string;
chatroomId?: string;
ownerWechatId?: string;
ownerNickname?: string;
ownerAvatar?: string;
}
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
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);
// 获取群聊列表API
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getGroupList(params);
if (response && response.list) {
setGroups(response.list);
setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取群聊列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理群聊选择
const handleGroupToggle = (group: GroupSelectionItem) => {
if (readonly) return;
const newSelectedGroups = selectedOptions.some(g => g.id === group.id)
? selectedOptions.filter(g => g.id !== group.id)
: selectedOptions.concat(group);
onSelect(newSelectedGroups);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
newSelectedGroups.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(g => g.id),
selectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
fetchGroups(1, "");
}
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchGroups(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择群聊"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索群聊"
loading={loading}
onRefresh={() => fetchGroups(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalGroups}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
<Checkbox
checked={selectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className={style.avatarImg}
/>
) : (
group.name.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className={style.groupOwner}>
: {group.ownerNickname}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -0,0 +1,87 @@
.listContainer {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.listItem {
flex-shrink: 0;
width: 100%;
}
.loadMoreButtonContainer {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
flex-shrink: 0;
}
.noMoreText {
text-align: center;
color: #999;
font-size: 14px;
padding: 16px;
flex-shrink: 0;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #999;
flex: 1;
min-height: 200px;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.emptyText {
font-size: 14px;
color: #999;
}
.pullToRefresh {
height: 100%;
overflow: auto;
}
// 自定义滚动条样式
.listContainer::-webkit-scrollbar {
width: 4px;
}
.listContainer::-webkit-scrollbar-track {
background: transparent;
}
.listContainer::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.listContainer::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
// 响应式设计
@media (max-width: 768px) {
.listContainer {
padding: 0 8px;
}
.loadMoreButtonContainer {
padding: 12px;
}
.noMoreText {
padding: 12px;
}
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
PullToRefresh,
InfiniteScroll,
Button,
SpinLoading,
} from "antd-mobile";
import styles from "./InfiniteList.module.scss";
interface InfiniteListProps<T> {
// 数据相关
data: T[];
loading?: boolean;
hasMore?: boolean;
loadingText?: string;
noMoreText?: string;
// 渲染相关
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number;
// 事件回调
onLoadMore?: () => Promise<void> | void;
onRefresh?: () => Promise<void> | void;
// 样式相关
className?: string;
itemClassName?: string;
containerStyle?: React.CSSProperties;
// 功能开关
enablePullToRefresh?: boolean;
enableInfiniteScroll?: boolean;
enableLoadMoreButton?: boolean;
// 自定义高度
height?: string | number;
minHeight?: string | number;
}
const InfiniteList = <T extends any>({
data,
loading = false,
hasMore = true,
loadingText = "加载中...",
noMoreText = "没有更多了",
renderItem,
keyExtractor = (_, index) => index,
onLoadMore,
onRefresh,
className = "",
itemClassName = "",
containerStyle = {},
enablePullToRefresh = true,
enableInfiniteScroll = true,
enableLoadMoreButton = false,
height = "100%",
minHeight = "200px",
}: InfiniteListProps<T>) => {
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 处理下拉刷新
const handleRefresh = useCallback(async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
await onRefresh();
} catch (error) {
console.error("Refresh failed:", error);
} finally {
setRefreshing(false);
}
}, [onRefresh]);
// 处理加载更多
const handleLoadMore = useCallback(async () => {
if (!onLoadMore || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
await onLoadMore();
} catch (error) {
console.error("Load more failed:", error);
} finally {
setLoadingMore(false);
}
}, [onLoadMore, loadingMore, hasMore]);
// 点击加载更多按钮
const handleLoadMoreClick = useCallback(() => {
handleLoadMore();
}, [handleLoadMore]);
// 容器样式
const containerStyles: React.CSSProperties = {
height,
minHeight,
...containerStyle,
};
// 渲染列表项
const renderListItems = () => {
return data.map((item, index) => (
<div
key={keyExtractor(item, index)}
className={`${styles.listItem} ${itemClassName}`}
>
{renderItem(item, index)}
</div>
));
};
// 渲染加载更多按钮
const renderLoadMoreButton = () => {
if (!enableLoadMoreButton || !hasMore) return null;
return (
<div className={styles.loadMoreButtonContainer}>
<Button
size="small"
loading={loadingMore}
onClick={handleLoadMoreClick}
disabled={loading || !hasMore}
>
{loadingMore ? loadingText : "点击加载更多"}
</Button>
</div>
);
};
// 渲染无更多数据提示
const renderNoMoreText = () => {
if (hasMore || data.length === 0) return null;
return <div className={styles.noMoreText}>{noMoreText}</div>;
};
// 渲染空状态
const renderEmptyState = () => {
if (data.length > 0 || loading) return null;
return (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📝</div>
<div className={styles.emptyText}></div>
</div>
);
};
const content = (
<div
className={`${styles.listContainer} ${className}`}
style={containerStyles}
>
{renderListItems()}
{renderLoadMoreButton()}
{renderNoMoreText()}
{renderEmptyState()}
{/* 无限滚动组件 */}
{enableInfiniteScroll && (
<InfiniteScroll
loadMore={handleLoadMore}
hasMore={hasMore}
threshold={100}
/>
)}
</div>
);
// 如果启用下拉刷新包装PullToRefresh
if (enablePullToRefresh && onRefresh) {
return (
<PullToRefresh
onRefresh={handleRefresh}
refreshing={refreshing}
className={styles.pullToRefresh}
>
{content}
</PullToRefresh>
);
}
return content;
};
export default InfiniteList;

View File

@@ -1,10 +0,0 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
}
.container main {
flex: 1;
overflow: auto;
}

View File

@@ -1,25 +0,0 @@
import React from "react";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
loading,
children,
header,
footer,
}) => {
return (
<div className="container">
{header && <header>{header}</header>}
<main className="bg-gray-50">{children}</main>
{footer && <footer>{footer}</footer>}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,52 @@
import React, { useEffect } from "react";
import { SpinLoading } from "antd-mobile";
import styles from "./layout.module.scss";
interface LayoutProps {
loading?: boolean;
children?: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
children,
header,
footer,
loading = false,
}) => {
// 移动端100vh兼容
useEffect(() => {
const setRealHeight = () => {
document.documentElement.style.setProperty(
"--real-vh",
`${window.innerHeight * 0.01}px`,
);
};
setRealHeight();
window.addEventListener("resize", setRealHeight);
return () => window.removeEventListener("resize", setRealHeight);
}, []);
return (
<div
className={styles.container}
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
>
{header && <header>{header}</header>}
<main>
{loading ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={styles.loadingText}>...</div>
</div>
) : (
children
)}
</main>
{footer && <footer>{footer}</footer>}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,28 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.container main {
flex: 1;
overflow: auto;
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
background: rgba(255, 255, 255, 0.8);
}
.loadingText {
margin-top: 16px;
color: #666;
font-size: 14px;
text-align: center;
}

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import BottomNav from './BottomNav';
// 配置需要底部导航的页面路径(白名单)
const BOTTOM_NAV_CONFIG = [
'/', // 首页
'/scenarios', // 场景获客
'/workspace', // 工作台
'/profile', // 我的
];
interface LayoutWrapperProps {
children: React.ReactNode;
}
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
const location = useLocation();
// 检查当前路径是否需要底部导航
const shouldShowBottomNav = BOTTOM_NAV_CONFIG.some(path => {
// 特殊处理首页路由 '/'
if (path === '/') {
return location.pathname === '/';
}
return location.pathname === path;
});
// 如果是登录页面,直接渲染内容(不显示底部导航)
if (location.pathname === '/login') {
return <>{children}</>;
}
// 只有在配置列表中的页面才显示底部导航
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto">
{children}
</div>
{shouldShowBottomNav && <BottomNav />}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import React from "react";
import ReactECharts from "echarts-for-react";
interface LineChartProps {
title?: string;
xData: string[];
yData: number[];
height?: number | string;
}
const LineChart: React.FC<LineChartProps> = ({
title = "",
xData,
yData,
height = 200,
}) => {
const option = {
title: {
text: title,
left: "center",
textStyle: { fontSize: 16 },
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: xData,
boundaryGap: false,
},
yAxis: {
type: "value",
boundaryGap: ["10%", "10%"], // 上下留白
min: (value: any) => value.min - 10, // 下方多留一点空间
max: (value: any) => value.max + 10, // 上方多留一点空间
minInterval: 1,
axisLabel: { margin: 12 },
},
series: [
{
data: yData,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: { color: "#1677ff" },
itemStyle: { color: "#1677ff" },
},
],
grid: { left: 40, right: 24, top: 40, bottom: 32 },
};
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
};
export default LineChart;

View File

@@ -0,0 +1,57 @@
import React from "react";
import { TabBar } from "antd-mobile";
import { PieOutline, UserOutline } from "antd-mobile-icons";
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
const tabs = [
{
key: "home",
title: "首页",
icon: <HomeOutlined />,
path: "/",
},
{
key: "scenarios",
title: "场景获客",
icon: <TeamOutlined />,
path: "/scenarios",
},
{
key: "workspace",
title: "工作台",
icon: <PieOutline />,
path: "/workspace",
},
{
key: "mine",
title: "我的",
icon: <UserOutline />,
path: "/mine",
},
];
interface MeauMobileProps {
activeKey: string;
}
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
const navigate = useNavigate();
return (
<TabBar
style={{ background: "#fff" }}
activeKey={activeKey}
onChange={key => {
const tab = tabs.find(t => t.key === key);
if (tab && tab.path) navigate(tab.path);
}}
>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
);
};
export default MeauMobile;

View File

@@ -0,0 +1,62 @@
import React, { useEffect, useState } from "react";
import { NavBar } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { getSafeAreaHeight } from "@/utils/common";
interface NavCommonProps {
title: string;
backFn?: () => void;
right?: React.ReactNode;
left?: React.ReactNode;
}
const NavCommon: React.FC<NavCommonProps> = ({
title,
backFn,
right,
left,
}) => {
const navigate = useNavigate();
const [paddingTop, setPaddingTop] = useState("0px");
useEffect(() => {
setPaddingTop(getSafeAreaHeight() + "px");
}, []);
return (
<div
style={{
paddingTop: paddingTop,
background: "#fff",
}}
>
<NavBar
back={null}
left={
left ? (
left
) : (
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => {
if (backFn) {
backFn();
} else {
navigate(-1);
}
}}
/>
</div>
)
}
right={right}
>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</span>
</NavBar>
</div>
);
};
export default NavCommon;

View File

@@ -1,83 +0,0 @@
import React from 'react';
import BackButton from './BackButton';
import { useSimpleBack } from '@/hooks/useBackNavigation';
interface PageHeaderProps {
/** 页面标题 */
title: string;
/** 返回按钮文本 */
backText?: string;
/** 自定义返回逻辑 */
onBack?: () => void;
/** 默认返回路径 */
defaultBackPath?: string;
/** 是否显示返回按钮 */
showBack?: boolean;
/** 右侧扩展内容 */
rightContent?: React.ReactNode;
/** 自定义CSS类名 */
className?: string;
/** 标题样式类名 */
titleClassName?: string;
/** 返回按钮样式变体 */
backButtonVariant?: 'icon' | 'button' | 'text';
/** 返回按钮自定义样式类名 */
backButtonClassName?: string;
/** 是否显示底部边框 */
showBorder?: boolean;
}
/**
* 通用页面Header组件
* 支持返回按钮、标题和右侧扩展插槽
*/
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
backText = '返回',
onBack,
defaultBackPath = '/',
showBack = true,
rightContent,
className = '',
titleClassName = '',
backButtonVariant = 'icon',
backButtonClassName = '',
showBorder = true
}) => {
const { goBack } = useSimpleBack(defaultBackPath);
const handleBack = onBack || goBack;
const baseClasses = `bg-white ${showBorder ? 'border-b border-gray-200' : ''}`;
const headerClasses = `${baseClasses} ${className}`;
// 默认小号按钮样式
const defaultBackBtnClass = 'text-sm px-2 py-1 h-8 min-h-0';
return (
<header className={headerClasses}>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center ">
{showBack && (
<BackButton
variant={backButtonVariant}
text={backText}
onBack={handleBack}
className={`${defaultBackBtnClass} ${backButtonClassName}`.trim()}
/>
)}
<h1 className={`text-lg font-semibold ${titleClassName}`}>
{title}
</h1>
</div>
{rightContent && (
<div className="flex items-center gap-2">
{rightContent}
</div>
)}
</div>
</header>
);
};
export default PageHeader;

View File

@@ -0,0 +1,56 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { PlusOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
interface PlaceholderPageProps {
title: string;
showBack?: boolean;
showAddButton?: boolean;
addButtonText?: string;
showFooter?: boolean;
}
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
title,
showBack = true,
showAddButton = false,
addButtonText = "新建",
showFooter = true,
}) => {
return (
<Layout
header={
<NavBar
backArrow={showBack}
style={{ background: "#fff" }}
onBack={showBack ? () => window.history.back() : undefined}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</div>
}
right={
showAddButton ? (
<Button size="small" color="primary">
<PlusOutlined />
<span style={{ marginLeft: 4, fontSize: 12 }}>
{addButtonText}
</span>
</Button>
) : undefined
}
/>
}
footer={showFooter ? <MeauMobile /> : undefined}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3>{title}</h3>
<p>...</p>
</div>
</Layout>
);
};
export default PlaceholderPage;

View File

@@ -0,0 +1,71 @@
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
border-radius: 16px;
border: 1px solid #d9d9d9;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
&:disabled {
background: #f5f5f5;
color: #ccc;
cursor: not-allowed;
}
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
min-width: 60px;
text-align: center;
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button } from "antd";
import style from "./footer.module.scss";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface PopupFooterProps {
total: number;
currentPage: number;
totalPages: number;
loading: boolean;
selectedCount: number;
onPageChange: (page: number) => void;
onCancel: () => void;
onConfirm: () => void;
}
const PopupFooter: React.FC<PopupFooterProps> = ({
total,
currentPage,
totalPages,
loading,
selectedCount,
onPageChange,
onCancel,
onConfirm,
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
<ArrowLeftOutlined />
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
<ArrowRightOutlined />
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}> {selectedCount} </div>
<div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}>
</Button>
<Button type="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</>
);
};
export default PopupFooter;

View File

@@ -0,0 +1,51 @@
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 5px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,86 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { Tabs } from "antd-mobile";
import style from "./header.module.scss";
interface PopupHeaderProps {
title: string;
searchQuery: string;
setSearchQuery: (value: string) => void;
searchPlaceholder?: string;
loading?: boolean;
onRefresh?: () => void;
showRefresh?: boolean;
showSearch?: boolean;
showTabs?: boolean;
tabsConfig?: {
activeKey: string;
onChange: (key: string) => void;
tabs: Array<{ title: string; key: string }>;
};
}
const PopupHeader: React.FC<PopupHeaderProps> = ({
title,
searchQuery,
setSearchQuery,
searchPlaceholder = "搜索...",
loading = false,
onRefresh,
showRefresh = true,
showSearch = true,
showTabs = false,
tabsConfig,
}) => {
return (
<>
<div className={style.popupHeader}>
<div className={style.popupTitle}>{title}</div>
</div>
{showSearch && (
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
size="large"
/>
</div>
{showRefresh && onRefresh && (
<Button
type="text"
onClick={onRefresh}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
)}
</div>
)}
{showTabs && tabsConfig && (
<Tabs
activeKey={tabsConfig.activeKey}
onChange={tabsConfig.onChange}
style={{ marginTop: 8 }}
>
{tabsConfig.tabs.map(tab => (
<Tabs.Tab key={tab.key} title={tab.title} />
))}
</Tabs>
)}
</>
);
};
export default PopupHeader;

View File

@@ -1,71 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
// 不需要登录的公共页面路径
const PUBLIC_PATHS = [
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/404',
'/500'
];
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 检查当前路径是否是公共页面
const isPublicPath = PUBLIC_PATHS.some(path =>
location.pathname.startsWith(path)
);
useEffect(() => {
// 如果正在加载,不进行任何跳转
if (isLoading) {
return;
}
// 如果未登录且不是公共页面,重定向到登录页面
if (!isAuthenticated && !isPublicPath) {
// 保存当前URL登录后可以重定向回来
const returnUrl = encodeURIComponent(window.location.href);
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
return;
}
// 如果已登录且在登录页面,重定向到首页
if (isAuthenticated && location.pathname === '/login') {
navigate('/', { replace: true });
return;
}
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
// 如果正在加载,显示加载状态
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
);
}
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
if (!isAuthenticated && !isPublicPath) {
return null;
}
// 如果已登录且在登录页面,不渲染内容(等待重定向)
if (isAuthenticated && location.pathname === '/login') {
return null;
}
// 其他情况正常渲染
return <>{children}</>;
}

View File

@@ -1,206 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from 'lucide-react';
interface Task {
id: string;
name: string;
status: "running" | "paused" | "completed";
stats: {
devices: number;
acquired: number;
added: number;
};
lastUpdated: string;
executionTime: string;
nextExecutionTime: string;
trend: { date: string; customers: number }[];
reqConf?: {
device?: string[];
selectedDevices?: string[];
};
acquiredCount?: number;
addedCount?: number;
passRate?: number;
}
interface ScenarioAcquisitionCardProps {
task: Task;
channel: string;
onEdit: (taskId: string) => void;
onCopy: (taskId: string) => void;
onDelete: (taskId: string) => void;
onOpenSettings?: (taskId: string) => void;
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void;
}
export function ScenarioAcquisitionCard({
task,
channel,
onEdit,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
// 兼容后端真实数据结构
const deviceCount = Array.isArray(task.reqConf?.device)
? task.reqConf!.device.length
: Array.isArray(task.reqConf?.selectedDevices)
? task.reqConf!.selectedDevices.length
: 0;
// 获客数和已添加数可根据 msgConf 或其它字段自定义
const acquiredCount = task.acquiredCount ?? 0;
const addedCount = task.addedCount ?? 0;
const passRate = task.passRate ?? 0;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const isActive = task.status === "running";
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation();
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running");
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onEdit(task.id);
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onCopy(task.id);
};
const handleOpenSettings = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
if (onOpenSettings) {
onOpenSettings(task.id);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpen(false);
onDelete(task.id);
};
const toggleMenu = (e?: React.MouseEvent) => {
if (e) e.stopPropagation();
setMenuOpen(!menuOpen);
};
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={isActive ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={handleStatusChange}
>
{isActive ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
<MoreHorizontal className="h-4 w-4" />
</Button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleEdit}
>
<Pencil className="w-4 h-4 mr-2" />
</button>
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleCopy}
>
<Copy className="w-4 h-4 mr-2" />
</button>
{onOpenSettings && (
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleOpenSettings}
>
<Link className="w-4 h-4 mr-2" />
</button>
)}
<button
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
onClick={handleDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</div>
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</div>
<div className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</div>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { Steps } from "antd-mobile";
interface StepIndicatorProps {
currentStep: number;
steps: { id: number; title: string; subtitle: string }[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({
currentStep,
steps,
}) => {
return (
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
<Steps current={currentStep - 1}>
{steps.map((step, idx) => (
<Steps.Step
key={step.id}
title={step.subtitle}
icon={
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{step.id}
</div>
}
/>
))}
</Steps>
</div>
);
};
export default StepIndicator;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { cn } from '@/utils';
interface TestComponentProps {
title: string;
className?: string;
}
const TestComponent: React.FC<TestComponentProps> = ({ title, className }) => {
return (
<div className={cn('p-4 border rounded-lg', className)}>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-gray-600">
@/
</p>
<div className="mt-2 text-sm text-blue-600">
使 @/utils
</div>
</div>
);
};
export default TestComponent;

View File

@@ -1,291 +0,0 @@
import React from 'react';
import {
useThrottledRequestWithLoading,
useThrottledRequestWithError,
useRequestWithRetry,
useCancellableRequest
} from '../hooks/useThrottledRequest';
interface ThrottledButtonProps {
onClick: () => Promise<any>;
children: React.ReactNode;
delay?: number;
disabled?: boolean;
className?: string;
variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable';
maxRetries?: number;
retryDelay?: number;
showLoadingText?: boolean;
loadingText?: string;
errorText?: string;
onSuccess?: (result: any) => void;
onError?: (error: any) => void;
}
export const ThrottledButton: React.FC<ThrottledButtonProps> = ({
onClick,
children,
delay = 1000,
disabled = false,
className = '',
variant = 'throttle',
maxRetries = 3,
retryDelay = 1000,
showLoadingText = true,
loadingText = '处理中...',
errorText,
onSuccess,
onError
}) => {
// 处理请求结果
const handleRequest = async () => {
try {
const result = await onClick();
onSuccess?.(result);
} catch (error) {
onError?.(error);
}
};
// 根据variant渲染不同的按钮
const renderButton = () => {
switch (variant) {
case 'retry':
return <RetryButtonContent
onClick={handleRequest}
maxRetries={maxRetries}
retryDelay={retryDelay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</RetryButtonContent>;
case 'cancellable':
return <CancellableButtonContent
onClick={handleRequest}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</CancellableButtonContent>;
case 'debounce':
return <DebounceButtonContent
onClick={handleRequest}
delay={delay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
errorText={errorText}
>
{children}
</DebounceButtonContent>;
default:
return <ThrottleButtonContent
onClick={handleRequest}
delay={delay}
loadingText={loadingText}
showLoadingText={showLoadingText}
disabled={disabled}
className={className}
>
{children}
</ThrottleButtonContent>;
}
};
return renderButton();
};
// 节流按钮内容组件
const ThrottleButtonContent: React.FC<{
onClick: () => Promise<any>;
delay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => {
const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<button
onClick={throttledRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
);
};
// 防抖按钮内容组件
const DebounceButtonContent: React.FC<{
onClick: () => Promise<any>;
delay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
errorText?: string;
children: React.ReactNode;
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => {
const { throttledRequest, loading, error } = useThrottledRequestWithError(onClick, delay);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
let variantClasses = '';
if (loading) {
variantClasses = 'bg-gray-400 text-white cursor-not-allowed';
} else if (error) {
variantClasses = 'bg-red-500 text-white hover:bg-red-600';
} else {
variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
}
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<div className="flex items-center gap-2">
<button
onClick={throttledRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
{error && errorText && (
<span className="text-red-500 text-sm">{errorText}</span>
)}
</div>
);
};
// 重试按钮内容组件
const RetryButtonContent: React.FC<{
onClick: () => Promise<any>;
maxRetries: number;
retryDelay: number;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => {
const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay);
const getButtonText = () => {
if (loading) {
if (retryCount > 0) {
return `${loadingText} (重试 ${retryCount}/${maxRetries})`;
}
return showLoadingText ? loadingText : children;
}
return children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<button
onClick={requestWithRetry}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
);
};
// 可取消按钮内容组件
const CancellableButtonContent: React.FC<{
onClick: () => Promise<any>;
loadingText: string;
showLoadingText: boolean;
disabled: boolean;
className: string;
children: React.ReactNode;
}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => {
const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick);
const getButtonText = () => {
return loading && showLoadingText ? loadingText : children;
};
const getButtonClassName = () => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
const variantClasses = loading
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
return `${baseClasses} ${variantClasses} ${className}`;
};
return (
<div className="flex items-center gap-2">
<button
onClick={cancellableRequest}
disabled={disabled || loading}
className={getButtonClassName()}
>
{getButtonText()}
</button>
{loading && cancelRequest && (
<button
onClick={cancelRequest}
className="px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600 text-sm"
>
</button>
)}
</div>
);
};
// 导出其他类型的按钮组件
export const DebouncedButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { delay?: number }> = (props) => (
<ThrottledButton {...props} variant="debounce" delay={props.delay || 300} />
);
export const RetryButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { maxRetries?: number; retryDelay?: number }> = (props) => (
<ThrottledButton {...props} variant="retry" maxRetries={props.maxRetries || 3} retryDelay={props.retryDelay || 1000} />
);
export const CancellableButton: React.FC<Omit<ThrottledButtonProps, 'variant'>> = (props) => (
<ThrottledButton {...props} variant="cancellable" />
);

View File

@@ -1,297 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, Database } 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 { useToast } from '@/components/ui/toast';
import { fetchDeviceLabels, type TrafficPool } from '@/api/trafficDistribution';
// 组件属性接口
interface TrafficPoolSelectionProps {
selectedPools: string[];
onSelect: (pools: string[]) => void;
deviceIds: string[];
placeholder?: string;
className?: string;
}
export default function TrafficPoolSelection({
selectedPools,
onSelect,
deviceIds,
placeholder = "选择流量池",
className = ""
}: TrafficPoolSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [pools, setPools] = useState<TrafficPool[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalPools, setTotalPools] = useState(0);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
// 获取流量池列表API
const fetchPools = useCallback(async (page: number, keyword: string = '') => {
if (deviceIds.length === 0) return;
setLoading(true);
try {
const res = await fetchDeviceLabels({
deviceIds,
page,
pageSize: 10,
keyword
});
if (res && res.code === 200 && res.data) {
setPools(res.data.list || []);
setTotalPools(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 10));
} else {
toast({
title: "获取流量池列表失败",
description: res?.msg || "请稍后重试",
variant: "destructive"
});
// 使用模拟数据作为降级处理
const mockData: TrafficPool[] = [
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
{ id: "6", name: "VIP客户池", count: 156, description: "VIP等级客户", deviceIds },
{ id: "7", name: "潜在客户池", count: 3200, description: "有潜在购买可能的客户", deviceIds },
{ id: "8", name: "游戏玩家池", count: 890, description: "游戏类产品感兴趣客户", deviceIds },
];
// 根据关键词过滤模拟数据
const filteredData = keyword
? mockData.filter(pool =>
pool.name.toLowerCase().includes(keyword.toLowerCase()) ||
(pool.description && pool.description.toLowerCase().includes(keyword.toLowerCase()))
)
: mockData;
// 分页处理模拟数据
const startIndex = (page - 1) * 10;
const endIndex = startIndex + 10;
const paginatedData = filteredData.slice(startIndex, endIndex);
setPools(paginatedData);
setTotalPools(filteredData.length);
setTotalPages(Math.ceil(filteredData.length / 10));
}
} catch (error) {
console.error('获取流量池列表失败:', error);
toast({
title: "网络错误",
description: "请检查网络连接后重试",
variant: "destructive"
});
// 网络错误时使用模拟数据
const mockData: TrafficPool[] = [
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
];
setPools(mockData);
setTotalPools(mockData.length);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [deviceIds, toast]);
// 当弹窗打开时获取流量池列表
useEffect(() => {
if (dialogOpen && deviceIds.length > 0) {
// 弹窗打开时重置搜索和页码,然后立即请求第一页数据
setSearchQuery('');
setCurrentPage(1);
fetchPools(1, '');
}
}, [dialogOpen, deviceIds, fetchPools]);
// 监听页码变化,重新请求数据
useEffect(() => {
if (dialogOpen && deviceIds.length > 0 && currentPage > 1) {
fetchPools(currentPage, searchQuery);
}
}, [currentPage, dialogOpen, deviceIds.length, fetchPools, searchQuery]);
// 当设备ID变化时清空已选择的流量池如果需要的话
useEffect(() => {
if (deviceIds.length === 0) {
setPools([]);
setTotalPools(0);
setTotalPages(1);
}
}, [deviceIds]);
// 处理搜索
const handleSearch = (keyword: string) => {
setSearchQuery(keyword);
setCurrentPage(1);
// 立即搜索,不管弹窗是否打开(因为这个函数只在弹窗内调用)
if (deviceIds.length > 0) {
fetchPools(1, keyword);
}
};
// 处理流量池选择
const handlePoolToggle = (poolId: string) => {
if (selectedPools.includes(poolId)) {
onSelect(selectedPools.filter(id => id !== poolId));
} else {
onSelect([...selectedPools, poolId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedPools.length === 0) return '';
return `已选择 ${selectedPools.length} 个流量池`;
};
const handleConfirm = () => {
setDialogOpen(false);
};
// 处理输入框点击
const handleInputClick = () => {
if (deviceIds.length === 0) {
toast({
title: "请先选择设备",
description: "需要先选择设备才能选择流量池",
variant: "destructive"
});
return;
}
setDialogOpen(true);
};
return (
<>
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Database className="w-5 h-5" />
</span>
<Input
placeholder={placeholder}
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
readOnly
onClick={handleInputClick}
value={getDisplayText()}
/>
</div>
{/* 流量池选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-sm w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<div>
<DialogTitle className="text-center text-xl font-medium mb-6"></DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索流量池"
value={searchQuery}
onChange={(e) => handleSearch(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" />
</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>
) : pools.length > 0 ? (
<div className="divide-y">
{pools.map((pool) => (
<label
key={pool.id}
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
onClick={() => handlePoolToggle(pool.id)}
>
<div className="mr-3 flex items-center justify-center">
<div className={`w-5 h-5 rounded-full border ${selectedPools.includes(pool.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
{selectedPools.includes(pool.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-blue-100 flex items-center justify-center">
<Database className="h-5 w-5 text-blue-600" />
</div>
<div className="flex-1">
<div className="font-medium">{pool.name}</div>
{pool.description && (
<div className="text-sm text-gray-500">{pool.description}</div>
)}
<div className="text-sm text-gray-400">{pool.count} </div>
</div>
</div>
</label>
))}
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{deviceIds.length === 0 ? '请先选择设备' : '没有找到流量池'}
</div>
</div>
)}
</ScrollArea>
<div className="border-t p-4 flex items-center justify-between bg-white">
<div className="text-sm text-gray-500">
{totalPools}
</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">
({selectedPools.length})
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,296 +0,0 @@
import React from 'react';
import { ChevronLeft, Settings, Bell, Search, RefreshCw, Filter, Plus, MoreVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useNavigate, useLocation } from 'react-router-dom';
interface HeaderAction {
type: 'button' | 'icon' | 'search' | 'custom';
icon?: React.ComponentType<any>;
label?: string;
onClick?: () => void;
variant?: 'default' | 'ghost' | 'outline' | 'destructive' | 'secondary';
size?: 'default' | 'sm' | 'lg' | 'icon';
className?: string;
content?: React.ReactNode;
}
interface UnifiedHeaderProps {
/** 页面标题 */
title: string;
/** 是否显示返回按钮 */
showBack?: boolean;
/** 返回按钮文本 */
backText?: string;
/** 自定义返回逻辑 */
onBack?: () => void;
/** 默认返回路径 */
defaultBackPath?: string;
/** 右侧操作按钮 */
actions?: HeaderAction[];
/** 自定义右侧内容 */
rightContent?: React.ReactNode;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 搜索框占位符 */
searchPlaceholder?: string;
/** 搜索值 */
searchValue?: string;
/** 搜索回调 */
onSearchChange?: (value: string) => void;
/** 是否显示底部边框 */
showBorder?: boolean;
/** 背景样式 */
background?: 'white' | 'transparent' | 'blur';
/** 自定义CSS类名 */
className?: string;
/** 标题样式类名 */
titleClassName?: string;
/** 标题颜色 */
titleColor?: 'default' | 'blue' | 'gray';
/** 是否居中标题 */
centerTitle?: boolean;
/** 头部高度 */
height?: 'default' | 'compact' | 'tall';
}
const UnifiedHeader: React.FC<UnifiedHeaderProps> = ({
title,
showBack = true,
backText = '返回',
onBack,
defaultBackPath = '/',
actions = [],
rightContent,
showSearch = false,
searchPlaceholder = '搜索...',
searchValue = '',
onSearchChange,
showBorder = true,
background = 'white',
className = '',
titleClassName = '',
titleColor = 'default',
centerTitle = false,
height = 'default',
}) => {
const navigate = useNavigate();
const location = useLocation();
const handleBack = () => {
if (onBack) {
onBack();
} else if (defaultBackPath) {
navigate(defaultBackPath);
} else {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
};
// 背景样式
const backgroundClasses = {
white: 'bg-white',
transparent: 'bg-transparent',
blur: 'bg-white/80 backdrop-blur-sm',
};
// 高度样式
const heightClasses = {
default: 'h-14',
compact: 'h-12',
tall: 'h-16',
};
// 标题颜色样式
const titleColorClasses = {
default: 'text-gray-900',
blue: 'text-blue-600',
gray: 'text-gray-600',
};
const headerClasses = [
backgroundClasses[background],
heightClasses[height],
showBorder ? 'border-b border-gray-200' : '',
'sticky top-0 z-50',
className,
].filter(Boolean).join(' ');
const titleClasses = [
'text-lg font-semibold',
titleColorClasses[titleColor],
centerTitle ? 'text-center' : '',
titleClassName,
].filter(Boolean).join(' ');
// 渲染操作按钮
const renderAction = (action: HeaderAction, index: number) => {
if (action.type === 'custom' && action.content) {
return <div key={index}>{action.content}</div>;
}
if (action.type === 'search') {
return (
<div key={index} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange?.(e.target.value)}
className="pl-9 w-48"
/>
</div>
);
}
const IconComponent = action.icon || MoreVertical;
return (
<Button
key={index}
variant={action.variant || 'ghost'}
size={action.size || 'icon'}
onClick={action.onClick}
className={action.className}
>
<IconComponent className="h-5 w-5" />
{action.label && action.size !== 'icon' && (
<span className="ml-2">{action.label}</span>
)}
</Button>
);
};
return (
<header className={headerClasses}>
<div className="flex items-center justify-between px-4 h-full">
{/* 左侧:返回按钮和标题 */}
<div className="flex items-center space-x-3 flex-1">
{showBack && (
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8 hover:bg-gray-100"
>
<ChevronLeft className="h-5 w-5" />
</Button>
)}
{!centerTitle && (
<h1 className={titleClasses}>
{title}
</h1>
)}
</div>
{/* 中间:居中标题 */}
{centerTitle && (
<div className="flex-1 flex justify-center">
<h1 className={titleClasses}>
{title}
</h1>
</div>
)}
{/* 右侧:搜索框、操作按钮、自定义内容 */}
<div className="flex items-center space-x-2 flex-1 justify-end">
{showSearch && !actions.some(a => a.type === 'search') && (
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange?.(e.target.value)}
className="pl-9 w-48"
/>
</div>
)}
{actions.map((action, index) => renderAction(action, index))}
{rightContent && (
<div className="flex items-center space-x-2">
{rightContent}
</div>
)}
</div>
</div>
</header>
);
};
// 预设的常用Header配置
export const HeaderPresets = {
// 基础页面Header有返回按钮
basic: (title: string, onBack?: () => void): UnifiedHeaderProps => ({
title,
showBack: true,
onBack,
titleColor: 'blue',
}),
// 主页Header无返回按钮
main: (title: string, actions?: HeaderAction[]): UnifiedHeaderProps => ({
title,
showBack: false,
titleColor: 'blue',
actions: actions || [
{
type: 'icon',
icon: Bell,
onClick: () => console.log('Notifications'),
},
],
}),
// 搜索页面Header
search: (title: string, searchValue: string, onSearchChange: (value: string) => void): UnifiedHeaderProps => ({
title,
showBack: true,
showSearch: true,
searchValue,
onSearchChange,
titleColor: 'blue',
}),
// 列表页面Header带刷新和添加
list: (title: string, onRefresh?: () => void, onAdd?: () => void): UnifiedHeaderProps => ({
title,
showBack: true,
titleColor: 'blue',
actions: [
...(onRefresh ? [{
type: 'icon' as const,
icon: RefreshCw,
onClick: onRefresh,
}] : []),
...(onAdd ? [{
type: 'button' as const,
icon: Plus,
label: '新建',
size: 'sm' as const,
onClick: onAdd,
}] : []),
],
}),
// 设置页面Header
settings: (title: string): UnifiedHeaderProps => ({
title,
showBack: true,
titleColor: 'blue',
actions: [
{
type: 'icon',
icon: Settings,
onClick: () => console.log('Settings'),
},
],
}),
};
export default UnifiedHeader;

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from "react";
import { Button } from "antd-mobile";
import { updateChecker } from "@/utils/updateChecker";
import {
ReloadOutlined,
CloudDownloadOutlined,
RocketOutlined,
} from "@ant-design/icons";
interface UpdateNotificationProps {
position?: "top" | "bottom";
autoReload?: boolean;
showToast?: boolean;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
position = "top",
autoReload = false,
showToast = true,
}) => {
const [hasUpdate, setHasUpdate] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 注册更新检测回调
const handleUpdate = (info: { hasUpdate: boolean }) => {
if (info.hasUpdate) {
setHasUpdate(true);
setIsVisible(true);
if (autoReload) {
// 自动刷新
setTimeout(() => {
updateChecker.forceReload();
}, 3000);
}
}
};
updateChecker.onUpdate(handleUpdate);
// 启动更新检测
updateChecker.start();
return () => {
updateChecker.offUpdate(handleUpdate);
updateChecker.stop();
};
}, [autoReload, showToast]);
const handleReload = () => {
updateChecker.forceReload();
};
if (!isVisible || !hasUpdate) {
return null;
}
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99999,
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
color: "white",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px",
textAlign: "center",
}}
>
{/* 背景装饰 */}
<div
style={{
position: "absolute",
top: "10%",
left: "50%",
transform: "translateX(-50%)",
fontSize: "120px",
opacity: 0.1,
animation: "float 3s ease-in-out infinite",
}}
>
<RocketOutlined />
</div>
{/* 主要内容 */}
<div style={{ position: "relative", zIndex: 1 }}>
{/* 图标 */}
<div
style={{
fontSize: "80px",
marginBottom: "20px",
animation: "pulse 2s ease-in-out infinite",
}}
>
<CloudDownloadOutlined />
</div>
{/* 标题 */}
<div
style={{
fontSize: "28px",
fontWeight: "bold",
marginBottom: "12px",
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
</div>
{/* 描述 */}
<div
style={{
fontSize: "16px",
opacity: 0.9,
marginBottom: "40px",
lineHeight: "1.5",
maxWidth: "300px",
}}
>
</div>
{/* 更新按钮 */}
<Button
size="large"
style={{
background: "rgba(255,255,255,0.9)",
border: "2px solid rgba(255,255,255,0.5)",
color: "#1890ff",
fontSize: "18px",
fontWeight: "bold",
padding: "12px 40px",
borderRadius: "50px",
backdropFilter: "blur(10px)",
boxShadow: "0 8px 32px rgba(24,144,255,0.3)",
transition: "all 0.3s ease",
}}
onClick={handleReload}
>
<ReloadOutlined style={{ marginRight: "8px" }} />
</Button>
{/* 提示文字 */}
<div
style={{
fontSize: "12px",
opacity: 0.7,
marginTop: "20px",
}}
>
</div>
</div>
{/* 动画样式 */}
<style>
{`
@keyframes float {
0%, 100% { transform: translateX(-50%) translateY(0px); }
50% { transform: translateX(-50%) translateY(-20px); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
`}
</style>
</div>
);
};
export default UpdateNotification;

View File

@@ -0,0 +1,484 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from "react";
import { Toast, Dialog } from "antd-mobile";
import { UserOutlined, CameraOutlined } from "@ant-design/icons";
import style from "./index.module.scss";
interface AvatarUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
size?: number; // 头像尺寸
}
const AvatarUpload: React.FC<AvatarUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
size = 100,
}) => {
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState(value);
useEffect(() => {
setAvatarUrl(value);
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith("image/");
if (!isValidType) {
Toast.show("只能上传图片文件!");
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("图片大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("头像上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("头像上传失败,请重试");
throw error;
}
};
// 处理头像上传
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || disabled || uploading) return;
const validatedFile = beforeUpload(file);
if (!validatedFile) return;
setUploading(true);
try {
const result = await upload(validatedFile);
setAvatarUrl(result.url);
onChange?.(result.url);
} catch (error) {
console.error("头像上传失败:", error);
} finally {
setUploading(false);
}
};
// 删除头像
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除头像吗?",
onConfirm: () => {
setAvatarUrl("");
onChange?.("");
Toast.show("头像已删除");
},
});
};
return (
<div className={`${style.avatarUploadContainer} ${className || ""}`}>
<div
className={style.avatarWrapper}
style={{ width: size, height: size }}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt="头像"
className={style.avatarImage}
style={{ width: size, height: size }}
/>
) : (
<div
className={style.avatarPlaceholder}
style={{ width: size, height: size }}
>
<UserOutlined />
</div>
)}
{/* 上传覆盖层 */}
<div
className={style.avatarUploadOverlay}
onClick={() =>
!disabled && !uploading && fileInputRef.current?.click()
}
>
{uploading ? (
<div className={style.uploadLoading}>...</div>
) : (
<CameraOutlined />
)}
</div>
{/* 删除按钮 */}
{avatarUrl && !disabled && (
<div className={style.avatarDeleteBtn} onClick={handleDelete}>
×
</div>
)}
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleAvatarChange}
disabled={disabled || uploading}
/>
{/* 提示文字 */}
<div className={style.avatarTip}>
{uploading
? "正在上传头像..."
: "点击头像可更换支持JPG、PNG格式大小不超过5MB"}
</div>
</div>
);
};
// 创建 ref
const fileInputRef = React.createRef<HTMLInputElement>();
export default AvatarUpload;

View File

@@ -0,0 +1,254 @@
import React, { useState } from "react";
import { Input, Button, Card, Space, Typography, Divider } from "antd";
import { SendOutlined } from "@ant-design/icons";
import ChatFileUpload from "./index";
const { TextArea } = Input;
const { Text } = Typography;
interface ChatMessage {
id: string;
type: "text" | "file";
content: string;
timestamp: Date;
fileInfo?: {
url: string;
name: string;
type: string;
size: number;
};
}
const ChatFileUploadExample: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
// 处理文件上传
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "file",
content: `文件: ${fileInfo.name}`,
timestamp: new Date(),
fileInfo,
};
setMessages(prev => [...prev, newMessage]);
};
// 处理文本发送
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage: ChatMessage = {
id: Date.now().toString(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 获取文件类型图标
const getFileTypeIcon = (type: string, name: string) => {
const lowerType = type.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerType.startsWith("image/")) {
return "🖼️";
} else if (lowerType.startsWith("video/")) {
return "🎥";
} else if (lowerType.startsWith("audio/")) {
return "🎵";
} else if (lowerType === "application/pdf") {
return "📄";
} else if (lowerName.endsWith(".doc") || lowerName.endsWith(".docx")) {
return "📝";
} else if (lowerName.endsWith(".xls") || lowerName.endsWith(".xlsx")) {
return "📊";
} else if (lowerName.endsWith(".ppt") || lowerName.endsWith(".pptx")) {
return "📈";
} else {
return "📎";
}
};
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
<Card title="聊天文件上传示例" style={{ marginBottom: 20 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Text></Text>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</Space>
</Card>
{/* 聊天消息区域 */}
<Card
title="聊天记录"
style={{
height: 400,
marginBottom: 20,
overflowY: "auto",
}}
bodyStyle={{ height: 320, overflowY: "auto" }}
>
{messages.length === 0 ? (
<div style={{ textAlign: "center", color: "#999", marginTop: 100 }}>
</div>
) : (
<div>
{messages.map(message => (
<div key={message.id} style={{ marginBottom: 16 }}>
<div
style={{
background: "#f0f0f0",
padding: 12,
borderRadius: 8,
maxWidth: "80%",
wordBreak: "break-word",
}}
>
{message.type === "text" ? (
<div>{message.content}</div>
) : (
<div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<span>
{getFileTypeIcon(
message.fileInfo!.type,
message.fileInfo!.name,
)}
</span>
<Text strong>{message.fileInfo!.name}</Text>
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {formatFileSize(message.fileInfo!.size)}
</div>
<div style={{ fontSize: 12, color: "#666" }}>
: {message.fileInfo!.type}
</div>
<a
href={message.fileInfo!.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12, color: "#1890ff" }}
>
</a>
</div>
)}
<div
style={{
fontSize: 11,
color: "#999",
marginTop: 4,
textAlign: "right",
}}
>
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* 输入区域 */}
<Card title="发送消息">
<Space direction="vertical" style={{ width: "100%" }}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息内容..."
autoSize={{ minRows: 2, maxRows: 4 }}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
handleSendText();
}
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Space>
{/* 文件上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="文件"
buttonIcon={<span>📎</span>}
/>
{/* 图片上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10} // 最大10MB
accept="image/*" // 只接受图片
buttonText="图片"
buttonIcon={<span>🖼</span>}
/>
{/* 文档上传组件 */}
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20} // 最大20MB
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" // 只接受文档
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
</Space>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendText}
disabled={!inputValue.trim()}
>
</Button>
</div>
</Space>
</Card>
</div>
);
};
export default ChatFileUploadExample;

View File

@@ -0,0 +1,48 @@
.chatFileUpload {
display: inline-block;
.uploadButton {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
background: transparent;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1890ff;
background-color: rgba(24, 144, 255, 0.1);
}
&:disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
.anticon {
font-size: 16px;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.chatFileUpload {
.uploadButton {
padding: 6px 8px;
font-size: 12px;
.anticon {
font-size: 14px;
}
}
}
}

View File

@@ -0,0 +1,189 @@
import React, { useRef, useState } from "react";
import { Button, message } from "antd";
import {
PaperClipOutlined,
LoadingOutlined,
FileOutlined,
FileImageOutlined,
FileVideoOutlined,
FileAudioOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import { uploadFile } from "@/api/common";
import style from "./index.module.scss";
interface ChatFileUploadProps {
onFileUploaded?: (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
accept?: string; // 接受的文件类型
buttonText?: string;
buttonIcon?: React.ReactNode;
}
const ChatFileUpload: React.FC<ChatFileUploadProps> = ({
onFileUploaded,
disabled = false,
className,
maxSize = 50, // 默认50MB
accept = "*/*", // 默认接受所有文件类型
buttonText = "发送文件",
buttonIcon = <PaperClipOutlined />,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 获取文件图标
const getFileIcon = (file: File) => {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.startsWith("image/")) {
return <FileImageOutlined />;
} else if (type.startsWith("video/")) {
return <FileVideoOutlined />;
} else if (type.startsWith("audio/")) {
return <FileAudioOutlined />;
} else if (type === "application/pdf") {
return <FilePdfOutlined />;
} else if (name.endsWith(".doc") || name.endsWith(".docx")) {
return <FileWordOutlined />;
} else if (name.endsWith(".xls") || name.endsWith(".xlsx")) {
return <FileExcelOutlined />;
} else if (name.endsWith(".ppt") || name.endsWith(".pptx")) {
return <FilePptOutlined />;
} else {
return <FileOutlined />;
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 验证文件
const validateFile = (file: File): boolean => {
// 检查文件大小
if (file.size > maxSize * 1024 * 1024) {
message.error(`文件大小不能超过 ${maxSize}MB`);
return false;
}
// 检查文件类型如果指定了accept
if (accept !== "*/*") {
const acceptTypes = accept.split(",").map(type => type.trim());
const fileType = file.type;
const fileName = file.name.toLowerCase();
const isValidType = acceptTypes.some(type => {
if (type.startsWith(".")) {
// 扩展名匹配
return fileName.endsWith(type);
} else if (type.includes("*")) {
// MIME类型通配符匹配
const baseType = type.replace("*", "");
return fileType.startsWith(baseType);
} else {
// 精确MIME类型匹配
return fileType === type;
}
});
if (!isValidType) {
message.error(`不支持的文件类型: ${file.type}`);
return false;
}
}
return true;
};
// 处理文件选择
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
// 验证文件
if (!validateFile(file)) {
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setUploading(true);
try {
// 上传文件
const fileUrl = await uploadFile(file);
// 调用回调函数,传递文件信息
onFileUploaded?.({
url: fileUrl,
name: file.name,
type: file.type,
size: file.size,
});
message.success("文件上传成功");
} catch (error: any) {
message.error(error.message || "文件上传失败");
} finally {
setUploading(false);
// 清空input值允许重新选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// 触发文件选择
const handleClick = () => {
if (disabled || uploading) return;
fileInputRef.current?.click();
};
return (
<div className={`${style.chatFileUpload} ${className || ""}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<Button
type="text"
icon={uploading ? <LoadingOutlined /> : buttonIcon}
onClick={handleClick}
disabled={disabled || uploading}
className={style.uploadButton}
title={buttonText}
>
{buttonText}
</Button>
</div>
);
};
export default ChatFileUpload;

View File

@@ -0,0 +1,265 @@
.fileUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.fileUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.fileItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.fileItemContent {
display: flex;
align-items: center;
gap: 12px;
.fileIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
// Excel文件图标样式
:global(.anticon-file-excel) {
color: #217346;
background: rgba(33, 115, 70, 0.1);
}
// Word文件图标样式
:global(.anticon-file-word) {
color: #2b579a;
background: rgba(43, 87, 154, 0.1);
}
// PPT文件图标样式
:global(.anticon-file-ppt) {
color: #d24726;
background: rgba(210, 71, 38, 0.1);
}
// 默认文件图标样式
:global(.anticon-file) {
color: #666;
background: rgba(102, 102, 102, 0.1);
}
}
.fileInfo {
flex: 1;
min-width: 0;
.fileName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.fileActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.filePreview {
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
iframe {
border-radius: 8px;
}
}
}
// 禁用状态
.fileUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.fileUploadContainer.error {
.fileUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,459 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
FileOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
FileExcelOutlined,
FileWordOutlined,
FilePptOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface FileUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
acceptTypes?: string[]; // 接受的文件类型
}
const FileUpload: React.FC<FileUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 10,
showPreview = true,
maxCount = 1,
acceptTypes = ["excel", "word", "ppt"],
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
// 文件类型配置
const fileTypeConfig = {
excel: {
accept: ".xlsx,.xls",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
],
icon: FileExcelOutlined,
name: "Excel文件",
extensions: ["xlsx", "xls"],
},
word: {
accept: ".docx,.doc",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
],
icon: FileWordOutlined,
name: "Word文件",
extensions: ["docx", "doc"],
},
ppt: {
accept: ".pptx,.ppt",
mimeTypes: [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint",
],
icon: FilePptOutlined,
name: "PPT文件",
extensions: ["pptx", "ppt"],
},
};
// 生成accept字符串
const generateAcceptString = () => {
return acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.accept)
.filter(Boolean)
.join(",");
};
// 获取文件类型信息
const getFileTypeInfo = (file: File) => {
const extension = file.name.split(".").pop()?.toLowerCase();
for (const type of acceptTypes) {
const config = fileTypeConfig[type as keyof typeof fileTypeConfig];
if (config && config.extensions.includes(extension || "")) {
return config;
}
}
return null;
};
// 获取文件图标
const getFileIcon = (file: File) => {
const typeInfo = getFileTypeInfo(file);
return typeInfo ? typeInfo.icon : FileOutlined;
};
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `document-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const typeInfo = getFileTypeInfo(file);
if (!typeInfo) {
const allowedTypes = acceptTypes
.map(type => fileTypeConfig[type as keyof typeof fileTypeConfig]?.name)
.filter(Boolean)
.join("、");
message.error(`只能上传${allowedTypes}`);
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`文件大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("文件上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个文件模式
onChange?.(uploadedUrl);
} else {
// 多个文件模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个文件模式,移除对应的文件
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个文件模式,删除指定文件
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("文件已删除");
},
});
return true;
};
// 预览文件
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.fileUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传文档"
: `上传文档 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
{" "}
{acceptTypes
.map(
type =>
fileTypeConfig[type as keyof typeof fileTypeConfig]?.name,
)
.filter(Boolean)
.join("、")}
{maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个文件`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
const FileIcon = file.originFileObj
? getFileIcon(file.originFileObj)
: FileOutlined;
if (file.status === "uploading") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.fileActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.fileItem}>
<div className={style.fileItemContent}>
<div className={style.fileIcon}>
<FileIcon />
</div>
<div className={style.fileInfo}>
<div className={style.fileName}>{file.name}</div>
<div className={style.fileSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.fileActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.fileUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept={generateAcceptString()}
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 文件预览模态框 */}
<Modal
title="文件预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.filePreview}>
<iframe
src={previewUrl}
style={{ width: "100%", height: "500px", border: "none" }}
title="文件预览"
/>
</div>
</Modal>
</div>
);
};
export default FileUpload;

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect } from "react";
import { ImageUploader, Toast, Dialog } from "antd-mobile";
import type { ImageUploadItem } from "antd-mobile/es/components/image-uploader";
import style from "./index.module.scss";
interface UploadComponentProps {
value?: string[];
onChange?: (urls: string[]) => void;
count?: number; // 最大上传数量
accept?: string; // 文件类型
disabled?: boolean;
className?: string;
}
const UploadComponent: React.FC<UploadComponentProps> = ({
value = [],
onChange,
count = 9,
accept = "image/*",
disabled = false,
className,
}) => {
const [fileList, setFileList] = useState<ImageUploadItem[]>([]);
// 将value转换为fileList格式
useEffect(() => {
if (value && value.length > 0) {
const files = value.map((url, index) => ({
url: url || "",
uid: `file-${index}`,
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
// 检查文件类型
const isValidType = file.type.startsWith(accept.replace("*", ""));
if (!isValidType) {
Toast.show(`只能上传${accept}格式的文件!`);
return null;
}
// 检查文件大小 (5MB)
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
Toast.show("文件大小不能超过5MB");
return null;
}
return file;
};
// 上传函数
const upload = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/v1/attachment/upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: formData,
},
);
if (!response.ok) {
throw new Error("上传失败");
}
const result = await response.json();
if (result.code === 200) {
Toast.show("上传成功");
// 确保返回的是字符串URL
let url = "";
if (typeof result.data === "string") {
url = result.data;
} else if (result.data && typeof result.data === "object") {
url = result.data.url || "";
}
return { url };
} else {
throw new Error(result.msg || "上传失败");
}
} catch (error) {
Toast.show("上传失败,请重试");
throw error;
}
};
// 处理文件变化
const handleChange = (files: ImageUploadItem[]) => {
setFileList(files);
// 提取URL数组并传递给父组件
const urls = files
.map(file => file.url)
.filter(url => Boolean(url)) as string[];
onChange?.(urls);
};
// 删除确认
const handleDelete = () => {
return Dialog.confirm({
content: "确定要删除这张图片吗?",
});
};
// 数量超出限制
const handleCountExceed = (exceed: number) => {
Toast.show(`最多选择 ${count} 张图片,你多选了 ${exceed}`);
};
return (
<div className={`${style.uploadContainer} ${className || ""}`}>
<ImageUploader
value={fileList}
onChange={handleChange}
upload={upload}
beforeUpload={beforeUpload}
onDelete={handleDelete}
onCountExceed={handleCountExceed}
multiple={count > 1}
maxCount={count}
showUpload={fileList.length < count && !disabled}
accept={accept}
/>
</div>
);
};
export default UploadComponent;

View File

@@ -0,0 +1,484 @@
.uploadContainer {
width: 100%;
// 自定义上传组件样式
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1677ff;
background: #f0f8ff;
}
.adm-image-uploader-upload-button-icon {
font-size: 32px;
color: #999;
}
}
.adm-image-uploader-item {
width: 100px;
height: 100px;
border-radius: 8px;
overflow: hidden;
position: relative;
.adm-image-uploader-item-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.adm-image-uploader-item-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
cursor: pointer;
}
.adm-image-uploader-item-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
// 禁用状态
.uploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.uploadContainer.error {
:global {
.adm-image-uploader-upload-button {
border-color: #ff4d4f;
background: #fff2f0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.uploadContainer {
:global {
.adm-image-uploader {
.adm-image-uploader-upload-button,
.adm-image-uploader-item {
width: 80px;
height: 80px;
}
.adm-image-uploader-upload-button-icon {
font-size: 28px;
}
}
}
}
}
// 头像上传组件样式
.avatarUploadContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.avatarWrapper {
position: relative;
border-radius: 50%;
overflow: hidden;
background: #f0f0f0;
border: 2px solid #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(24, 142, 238, 0.3);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 40px;
}
.avatarUploadOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
opacity: 0;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
.uploadLoading {
font-size: 12px;
text-align: center;
line-height: 1.4;
}
}
.avatarDeleteBtn {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
&:hover {
background: #ff7875;
transform: scale(1.1);
}
}
&:hover .avatarUploadOverlay {
opacity: 1;
}
}
.avatarTip {
font-size: 12px;
color: #999;
text-align: center;
line-height: 1.4;
max-width: 200px;
}
}
// 视频上传组件样式
.videoUploadContainer {
width: 100%;
.videoUploadButton {
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: 32px;
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: 14px;
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: 48px;
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: 12px;
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.videoUploadContainer {
.videoUploadButton {
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
border-color: #434343;
&:hover {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-color: #40a9ff;
}
.uploadingContainer {
.uploadingText {
color: #ccc;
}
}
.uploadContent {
.uploadText {
.uploadTitle {
color: #fff;
}
.uploadSubtitle {
color: #ccc;
}
}
}
}
.videoItem {
background: #2a2a2a;
border-color: #434343;
&:hover {
border-color: #40a9ff;
}
.videoItemContent {
.videoInfo {
.videoName {
color: #fff;
}
.videoSize {
color: #ccc;
}
}
.videoActions {
.previewBtn,
.deleteBtn {
&:hover {
background: #434343;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,291 @@
.mainImgUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.mainImgUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.mainImgItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.mainImgItemContent {
display: flex;
align-items: center;
gap: 12px;
.mainImgIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.mainImgInfo {
flex: 1;
min-width: 0;
.mainImgName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mainImgSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.mainImgActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.mainImgPreview {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
.mainImgImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.mainImgOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 8px;
.mainImgActions {
display: flex;
gap: 8px;
.previewBtn,
.deleteBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.9);
color: #666;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: white;
color: #1890ff;
transform: scale(1.1);
}
.anticon {
font-size: 14px;
}
}
.deleteBtn:hover {
color: #ff4d4f;
}
}
}
&:hover .mainImgOverlay {
opacity: 1;
}
}
}
}
// 禁用状态
.mainImgUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.mainImgUploadContainer.error {
.mainImgUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect } from "react";
import { Upload, message, Button } from "antd";
import {
LoadingOutlined,
PictureOutlined,
DeleteOutlined,
EyeOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface MainImgUploadProps {
value?: string;
onChange?: (url: string) => void;
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
}
const MainImgUpload: React.FC<MainImgUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 5,
showPreview = true,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
useEffect(() => {
if (value) {
const files: UploadFile[] = [
{
uid: "main-img",
name: "main-image",
status: "done",
url: value,
},
];
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith("image/");
if (!isImage) {
message.error("只能上传图片文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`图片大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
} else if (info.file.status === "done") {
setLoading(false);
message.success("图片上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
onChange?.(uploadedUrl);
}
} else if (info.file.status === "error") {
setLoading(false);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
onChange?.("");
}
};
// 删除文件
const handleRemove = () => {
setFileList([]);
onChange?.("");
message.success("图片已删除");
return true;
};
// 预览图片
const handlePreview = (url: string) => {
const img = new Image();
img.src = url;
const newWindow = window.open();
if (newWindow) {
newWindow.document.write(img.outerHTML);
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.mainImgUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}></div>
<div className={style.uploadSubtitle}>
JPGPNGGIF {maxSize}MB
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.mainImgActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.mainImgItem}>
<div className={style.mainImgItemContent}>
<div className={style.mainImgIcon}>
<PictureOutlined />
</div>
<div className={style.mainImgInfo}>
<div className={style.mainImgName}>{file.name}</div>
<div className={style.mainImgSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
<div className={style.mainImgPreview}>
<img
src={file.url}
alt={file.name}
className={style.mainImgImage}
/>
<div className={style.mainImgOverlay}>
<div className={style.mainImgActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove()}
className={style.deleteBtn}
/>
</div>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.mainImgUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={false}
fileList={fileList}
accept="image/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={1}
itemRender={customItemRender}
>
{fileList.length >= 1 ? null : uploadButton}
</Upload>
</div>
);
};
export default MainImgUpload;

View File

@@ -0,0 +1,451 @@
# Upload 组件使用说明
## 组件概述
本项目提供了多个专门的上传组件,所有组件都支持编辑时的数据回显功能,确保在编辑模式下能够正确显示已上传的文件。
## 组件列表
### 1. MainImgUpload 主图封面上传组件
#### 功能特点
- 只支持上传一张图片作为主图封面
- 上传后右上角显示删除按钮
- 支持图片预览功能
- 响应式设计,适配移动端
- 16:9宽高比宽度高度自适应
- **支持数据回显**:编辑时自动显示已上传的图片
#### 使用方法
```tsx
import MainImgUpload from "@/components/Upload/MainImgUpload";
const MyComponent = () => {
const [mainImage, setMainImage] = useState<string>("");
return (
<MainImgUpload
value={mainImage}
onChange={setMainImage}
maxSize={5} // 最大5MB
showPreview={true} // 显示预览按钮
disabled={false}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL
const [mainImage, setMainImage] = useState<string>(
"https://example.com/image.jpg",
);
<MainImgUpload
value={mainImage} // 会自动显示已上传的图片
onChange={setMainImage}
/>;
```
### 2. ImageUpload 多图上传组件
#### 功能特点
- 支持多张图片上传
- 可设置最大上传数量
- 支持图片预览和删除
- **支持数据回显**:编辑时自动显示已上传的图片数组
#### 使用方法
```tsx
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
const MyComponent = () => {
const [images, setImages] = useState<string[]>([]);
return (
<ImageUpload
value={images}
onChange={setImages}
count={9} // 最大9张
accept="image/*"
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的图片URL数组
const [images, setImages] = useState<string[]>([
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
]);
<ImageUpload
value={images} // 会自动显示已上传的图片
onChange={setImages}
/>;
```
### 3. VideoUpload 视频上传组件
#### 功能特点
- 支持视频文件上传
- 支持单个或多个视频
- 视频预览功能
- 文件大小验证
- **支持数据回显**:编辑时自动显示已上传的视频
#### 使用方法
```tsx
import VideoUpload from "@/components/Upload/VideoUpload";
const MyComponent = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
return (
<VideoUpload
value={videoUrl}
onChange={setVideoUrl}
maxSize={50} // 最大50MB
showPreview={true}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的视频URL
const [videoUrl, setVideoUrl] = useState<string>(
"https://example.com/video.mp4",
);
<VideoUpload
value={videoUrl} // 会自动显示已上传的视频
onChange={setVideoUrl}
/>;
```
### 4. FileUpload 文件上传组件
#### 功能特点
- 支持Excel、Word、PPT等文档文件
- 可配置接受的文件类型
- 文件预览和下载
- **支持数据回显**:编辑时自动显示已上传的文件
#### 使用方法
```tsx
import FileUpload from "@/components/Upload/FileUpload";
const MyComponent = () => {
const [fileUrl, setFileUrl] = useState<string>("");
return (
<FileUpload
value={fileUrl}
onChange={setFileUrl}
maxSize={10} // 最大10MB
acceptTypes={["excel", "word", "ppt"]}
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的文件URL
const [fileUrl, setFileUrl] = useState<string>(
"https://example.com/document.xlsx",
);
<FileUpload
value={fileUrl} // 会自动显示已上传的文件
onChange={setFileUrl}
/>;
```
### 5. AvatarUpload 头像上传组件
#### 功能特点
- 专门的头像上传组件
- 圆形头像显示
- 支持删除和重新上传
- **支持数据回显**:编辑时自动显示已上传的头像
#### 使用方法
```tsx
import AvatarUpload from "@/components/Upload/AvatarUpload";
const MyComponent = () => {
const [avatarUrl, setAvatarUrl] = useState<string>("");
return (
<AvatarUpload
value={avatarUrl}
onChange={setAvatarUrl}
size={100} // 头像尺寸
/>
);
};
```
#### 编辑模式数据回显
```tsx
// 编辑模式下传入已有的头像URL
const [avatarUrl, setAvatarUrl] = useState<string>(
"https://example.com/avatar.jpg",
);
<AvatarUpload
value={avatarUrl} // 会自动显示已上传的头像
onChange={setAvatarUrl}
/>;
```
### 6. ChatFileUpload 聊天文件上传组件
#### 功能特点
- 专门为聊天场景设计的文件上传组件
- 点击按钮直接唤醒文件选择框
- 选择文件后自动上传
- 上传成功后自动发送到聊天框
- 支持各种文件类型和大小限制
- 显示文件图标和大小信息
- 支持自定义按钮文本和图标
#### 使用方法
```tsx
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatComponent = () => {
const handleFileUploaded = (fileInfo: {
url: string;
name: string;
type: string;
size: number;
}) => {
// 处理上传成功的文件
console.log("文件上传成功:", fileInfo);
// 发送到聊天框
sendMessage({
type: "file",
content: fileInfo,
});
};
return (
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50} // 最大50MB
accept="*/*" // 接受所有文件类型
buttonText="发送文件"
buttonIcon={<span>📎</span>}
/>
);
};
```
#### 不同文件类型的配置示例
```tsx
// 图片上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={10}
accept="image/*"
buttonText="图片"
buttonIcon={<span>🖼️</span>}
/>
// 文档上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={20}
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
buttonText="文档"
buttonIcon={<span>📄</span>}
/>
// 视频上传
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={100}
accept="video/*"
buttonText="视频"
buttonIcon={<span>🎥</span>}
/>
```
#### 在聊天界面中的完整使用示例
```tsx
import React, { useState } from "react";
import { Input, Button } from "antd";
import ChatFileUpload from "@/components/Upload/ChatFileUpload";
const ChatInterface = () => {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState("");
const handleFileUploaded = fileInfo => {
const newMessage = {
id: Date.now(),
type: "file",
content: fileInfo,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
};
const handleSendText = () => {
if (!inputValue.trim()) return;
const newMessage = {
id: Date.now(),
type: "text",
content: inputValue,
timestamp: new Date(),
};
setMessages(prev => [...prev, newMessage]);
setInputValue("");
};
return (
<div>
{/* 聊天消息区域 */}
<div className="chat-messages">
{messages.map(msg => (
<div key={msg.id} className="message">
{msg.type === "file" ? (
<div>
<div>📎 {msg.content.name}</div>
<div>大小: {formatFileSize(msg.content.size)}</div>
<a href={msg.content.url} target="_blank">
查看文件
</a>
</div>
) : (
<div>{msg.content}</div>
)}
</div>
))}
</div>
{/* 输入区域 */}
<div className="chat-input">
<Input.TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="输入消息..."
/>
<div className="input-actions">
<ChatFileUpload
onFileUploaded={handleFileUploaded}
maxSize={50}
accept="*/*"
buttonText="文件"
/>
<Button onClick={handleSendText}>发送</Button>
</div>
</div>
</div>
);
};
```
## 数据回显机制
### 工作原理
所有Upload组件都通过以下机制实现数据回显
1. **useEffect监听value变化**当传入的value发生变化时自动更新内部状态
2. **文件列表同步**将URL转换为文件列表格式显示已上传的文件
3. **状态管理**:维护上传状态、文件列表等内部状态
4. **UI更新**:根据文件列表自动更新界面显示
### 使用场景
- **新增模式**value为空或未定义显示上传按钮
- **编辑模式**value包含已上传文件的URL自动显示文件
- **混合模式**:支持部分文件已上传,部分文件待上传
### 注意事项
1. **URL格式**确保传入的URL是有效的文件访问地址
2. **权限验证**确保文件URL在编辑时仍然可访问
3. **状态同步**value和onChange需要正确配合使用
4. **错误处理**组件会自动处理无效URL的显示
## 技术实现
### 核心特性
- 基于 antd Upload 组件
- 使用 antd-mobile 的 Toast 提示
- 支持 FormData 上传
- 自动处理文件验证和错误提示
- 集成项目统一的API请求封装
- **完整的数据回显支持**
### 文件结构
```
src/components/Upload/
├── MainImgUpload/ # 主图上传组件
├── ImageUpload/ # 多图上传组件
├── VideoUpload/ # 视频上传组件
├── FileUpload/ # 文件上传组件
├── AvatarUpload/ # 头像上传组件
├── ChatFileUpload/ # 聊天文件上传组件
│ ├── index.tsx # 主组件文件
│ ├── index.module.scss # 样式文件
│ └── example.tsx # 使用示例
└── README.md # 使用说明文档
```
### 统一的数据回显模式
所有组件都遵循相同的数据回显模式:
```tsx
// 1. 接收value属性
interface Props {
value?: string | string[];
onChange?: (url: string | string[]) => void;
}
// 2. 使用useEffect监听value变化
useEffect(() => {
if (value) {
// 将URL转换为文件列表格式
const files = convertUrlToFileList(value);
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 3. 在UI中显示文件列表
// 4. 支持编辑、删除、预览等操作
```

View File

@@ -0,0 +1,243 @@
.videoUploadContainer {
width: 100%;
// 覆盖 antd Upload 组件的默认样式
:global {
.ant-upload {
width: 100%;
}
.ant-upload-list {
width: 100%;
}
.ant-upload-list-text {
width: 100%;
}
.ant-upload-list-text .ant-upload-list-item {
width: 100%;
}
}
.videoUploadButton {
width: 100%;
aspect-ratio: 16 / 9;
min-height: clamp(90px, 20vw, 180px);
border: 2px dashed #d9d9d9;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
border-color: #1890ff;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.uploadingContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
padding: 20px;
.uploadingIcon {
font-size: clamp(24px, 4vw, 32px);
color: #1890ff;
animation: pulse 2s infinite;
}
.uploadingText {
font-size: clamp(11px, 2vw, 14px);
color: #666;
font-weight: 500;
}
.uploadProgress {
width: 100%;
max-width: 200px;
}
}
.uploadContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
text-align: center;
.uploadIcon {
font-size: clamp(50px, 6vw, 48px);
color: #1890ff;
transition: all 0.3s ease;
}
.uploadText {
.uploadTitle {
font-size: clamp(14px, 2.5vw, 16px);
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.uploadSubtitle {
font-size: clamp(10px, 1.5vw, 14px);
color: #666;
line-height: 1.4;
}
}
&:hover .uploadIcon {
transform: scale(1.1);
color: #40a9ff;
}
}
}
.videoItem {
width: 100%;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
}
.videoItemContent {
display: flex;
align-items: center;
gap: 12px;
.videoIcon {
width: clamp(28px, 5vw, 40px);
height: clamp(28px, 5vw, 40px);
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
}
.videoInfo {
flex: 1;
min-width: 0;
.videoName {
font-size: clamp(11px, 2vw, 14px);
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoSize {
font-size: clamp(10px, 1.5vw, 12px);
color: #666;
}
}
.videoActions {
display: flex;
gap: 4px;
flex-shrink: 0;
.previewBtn,
.deleteBtn {
padding: 4px 8px;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background: #f5f5f5;
}
}
.previewBtn {
color: #1890ff;
&:hover {
color: #40a9ff;
background: #e6f7ff;
}
}
.deleteBtn {
color: #ff4d4f;
&:hover {
color: #ff7875;
background: #fff2f0;
}
}
}
}
.itemProgress {
margin-top: 8px;
}
}
.videoPreview {
display: flex;
justify-content: center;
align-items: center;
background: #000;
border-radius: 8px;
overflow: hidden;
video {
border-radius: 8px;
}
}
}
// 禁用状态
.videoUploadContainer.disabled {
opacity: 0.6;
pointer-events: none;
}
// 错误状态
.videoUploadContainer.error {
.videoUploadButton {
border-color: #ff4d4f;
background: #fff2f0;
}
}
// 动画效果
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,381 @@
import React, { useState } from "react";
import { Upload, message, Progress, Button, Modal } from "antd";
import {
LoadingOutlined,
PlayCircleOutlined,
DeleteOutlined,
EyeOutlined,
FileOutlined,
CloudUploadOutlined,
} from "@ant-design/icons";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import style from "./index.module.scss";
interface VideoUploadProps {
value?: string | string[]; // 支持单个字符串或字符串数组
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
disabled?: boolean;
className?: string;
maxSize?: number; // 最大文件大小(MB)
showPreview?: boolean; // 是否显示预览
maxCount?: number; // 最大上传数量默认为1
}
const VideoUpload: React.FC<VideoUploadProps> = ({
value = "",
onChange,
disabled = false,
className,
maxSize = 50,
showPreview = true,
maxCount = 1,
}) => {
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
React.useEffect(() => {
if (value) {
// 处理单个字符串或字符串数组
const urls = Array.isArray(value) ? value : [value];
const files: UploadFile[] = urls.map((url, index) => ({
uid: `file-${index}`,
name: `video-${index + 1}`,
status: "done",
url: url || "",
}));
setFileList(files);
} else {
setFileList([]);
}
}, [value]);
// 文件验证
const beforeUpload = (file: File) => {
const isVideo = file.type.startsWith("video/");
if (!isVideo) {
message.error("只能上传视频文件!");
return false;
}
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
if (!isLtMaxSize) {
message.error(`视频大小不能超过${maxSize}MB`);
return false;
}
return true;
};
// 处理文件变化
const handleChange: UploadProps["onChange"] = info => {
// 更新 fileList确保所有 URL 都是字符串
const updatedFileList = info.fileList.map(file => {
let url = "";
if (file.url) {
url = file.url;
} else if (file.response) {
// 处理响应对象
if (typeof file.response === "string") {
url = file.response;
} else if (file.response.data) {
url =
typeof file.response.data === "string"
? file.response.data
: file.response.data.url || "";
} else if (file.response.url) {
url = file.response.url;
}
}
return {
...file,
url: url,
};
});
setFileList(updatedFileList);
// 处理上传状态
if (info.file.status === "uploading") {
setLoading(true);
// 模拟上传进度
const progress = Math.min(99, Math.random() * 100);
setUploadProgress(progress);
} else if (info.file.status === "done") {
setLoading(false);
setUploadProgress(100);
message.success("视频上传成功!");
// 从响应中获取上传后的URL
let uploadedUrl = "";
if (info.file.response) {
if (typeof info.file.response === "string") {
uploadedUrl = info.file.response;
} else if (info.file.response.data) {
uploadedUrl =
typeof info.file.response.data === "string"
? info.file.response.data
: info.file.response.data.url || "";
} else if (info.file.response.url) {
uploadedUrl = info.file.response.url;
}
}
if (uploadedUrl) {
if (maxCount === 1) {
// 单个视频模式
onChange?.(uploadedUrl);
} else {
// 多个视频模式
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const newUrls = [...currentUrls, uploadedUrl];
onChange?.(newUrls);
}
}
} else if (info.file.status === "error") {
setLoading(false);
setUploadProgress(0);
message.error("上传失败,请重试");
} else if (info.file.status === "removed") {
if (maxCount === 1) {
onChange?.("");
} else {
// 多个视频模式,移除对应的视频
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
const removedIndex = info.fileList.findIndex(
f => f.uid === info.file.uid,
);
if (removedIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== removedIndex,
);
onChange?.(newUrls);
}
}
}
};
// 删除文件
const handleRemove = (file?: UploadFile) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个视频文件吗?",
okText: "确定",
cancelText: "取消",
onOk: () => {
if (maxCount === 1) {
setFileList([]);
onChange?.("");
} else if (file) {
// 多个视频模式,删除指定视频
const currentUrls = Array.isArray(value)
? value
: value
? [value]
: [];
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
if (fileIndex !== -1) {
const newUrls = currentUrls.filter(
(_, index) => index !== fileIndex,
);
onChange?.(newUrls);
}
}
message.success("视频已删除");
},
});
return true;
};
// 预览视频
const handlePreview = (url: string) => {
setPreviewUrl(url);
setPreviewVisible(true);
};
// 获取文件大小显示
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 自定义上传按钮
const uploadButton = (
<div className={style.videoUploadButton}>
{loading ? (
<div className={style.uploadingContainer}>
<div className={style.uploadingIcon}>
<LoadingOutlined spin />
</div>
<div className={style.uploadingText}>...</div>
<Progress
percent={uploadProgress}
size="small"
showInfo={false}
strokeColor="#1890ff"
className={style.uploadProgress}
/>
</div>
) : (
<div className={style.uploadContent}>
<div className={style.uploadIcon}>
<CloudUploadOutlined />
</div>
<div className={style.uploadText}>
<div className={style.uploadTitle}>
{maxCount === 1
? "上传视频"
: `上传视频 (${fileList.length}/${maxCount})`}
</div>
<div className={style.uploadSubtitle}>
MP4AVIMOV {maxSize}MB
{maxCount > 1 && `,最多上传 ${maxCount} 个视频`}
</div>
</div>
</div>
)}
</div>
);
// 自定义文件列表项
const customItemRender = (
originNode: React.ReactElement,
file: UploadFile,
) => {
if (file.status === "uploading") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<FileOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "计算中..."}
</div>
</div>
<div className={style.videoActions}>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
<Progress
percent={uploadProgress}
size="small"
strokeColor="#1890ff"
className={style.itemProgress}
/>
</div>
);
}
if (file.status === "done") {
return (
<div className={style.videoItem}>
<div className={style.videoItemContent}>
<div className={style.videoIcon}>
<PlayCircleOutlined />
</div>
<div className={style.videoInfo}>
<div className={style.videoName}>{file.name}</div>
<div className={style.videoSize}>
{file.size ? formatFileSize(file.size) : "未知大小"}
</div>
</div>
<div className={style.videoActions}>
{showPreview && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handlePreview(file.url || "")}
className={style.previewBtn}
/>
)}
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemove(file)}
className={style.deleteBtn}
/>
</div>
</div>
</div>
);
}
return originNode;
};
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
return (
<div className={`${style.videoUploadContainer} ${className || ""}`}>
<Upload
name="file"
headers={{
Authorization: `Bearer ${localStorage.getItem("token")}`,
}}
action={action}
multiple={maxCount > 1}
fileList={fileList}
accept="video/*"
listType="text"
showUploadList={{
showPreviewIcon: false,
showRemoveIcon: false,
showDownloadIcon: false,
}}
disabled={disabled || loading}
beforeUpload={beforeUpload}
onChange={handleChange}
onRemove={handleRemove}
maxCount={maxCount}
itemRender={customItemRender}
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 视频预览模态框 */}
<Modal
title="视频预览"
open={previewVisible}
onCancel={() => setPreviewVisible(false)}
footer={null}
width={800}
centered
>
<div className={style.videoPreview}>
<video
controls
style={{ width: "100%", maxHeight: "400px" }}
src={previewUrl}
>
</video>
</div>
</Modal>
</div>
);
};
export default VideoUpload;

View File

@@ -1,54 +0,0 @@
import React from 'react';
import { Upload } from 'tdesign-mobile-react';
import type { UploadFile as TDesignUploadFile } from 'tdesign-mobile-react/es/upload/type';
import { uploadImage } from '@/api/upload';
interface UploadImageProps {
value?: string[];
onChange?: (urls: string[]) => void;
max?: number;
accept?: string;
disabled?: boolean;
}
const UploadImage: React.FC<UploadImageProps> = ({ value = [], onChange, ...props }) => {
// 处理上传
const requestMethod = async (file: TDesignUploadFile) => {
try {
const url = await uploadImage(file.raw as File);
return {
status: 'success' as const,
response: {
url
},
url,
};
} catch (e: any) {
return {
status: 'fail' as const,
error: e.message || '上传失败',
response: {},
};
}
};
// 处理文件变更
const handleChange = (newFiles: TDesignUploadFile[]) => {
const urls = newFiles.map(f => f.url).filter((url): url is string => Boolean(url));
onChange?.(urls);
};
return (
<Upload
files={value.map(url => ({ url }))}
requestMethod={requestMethod}
onChange={handleChange}
multiple
accept={props.accept}
max={props.max}
disabled={props.disabled}
/>
);
};
export default UploadImage;

View File

@@ -1,94 +0,0 @@
import React, { useRef } from 'react';
import { Button } from 'tdesign-mobile-react';
import { X } from 'lucide-react';
import { uploadImage } from '@/api/upload';
interface UploadVideoProps {
value?: string;
onChange?: (url: string) => void;
accept?: string;
disabled?: boolean;
}
const VIDEO_BOX_CLASS =
'relative flex items-center justify-center w-full aspect-[16/9] rounded-2xl border-2 border-dashed border-blue-300 bg-gray-50 overflow-hidden';
const UploadVideo: React.FC<UploadVideoProps> = ({
value,
onChange,
accept = 'video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-ms-wmv,video/x-flv,video/x-matroska',
disabled,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
// 选择文件并上传
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const url = await uploadImage(file);
onChange?.(url);
} catch (err: any) {
alert(err?.message || '上传失败');
} finally {
if (inputRef.current) inputRef.current.value = '';
}
};
// 触发文件选择
const handleClick = () => {
if (!disabled) inputRef.current?.click();
};
// 删除视频
const handleDelete = () => {
onChange?.('');
};
return (
<div className="flex flex-col items-center w-full">
{!value ? (
<div className={VIDEO_BOX_CLASS}>
<input
ref={inputRef}
type="file"
accept={accept}
style={{ display: 'none' }}
onChange={handleFileChange}
disabled={disabled}
/>
<button
type="button"
className="flex flex-col items-center justify-center w-full h-full bg-transparent border-none outline-none cursor-pointer"
onClick={handleClick}
disabled={disabled}
>
<span className="text-3xl mb-2">🎬</span>
<span className="text-base text-gray-500 font-medium"></span>
<span className="text-xs text-gray-400 mt-1">MP4WebMMOV等格式</span>
</button>
</div>
) : (
<div className={VIDEO_BOX_CLASS}>
<video
src={value}
controls
className="w-full h-full object-cover rounded-2xl bg-black"
style={{ background: '#000' }}
/>
<button
type="button"
className="absolute top-2 right-2 z-10 bg-white/80 hover:bg-white rounded-full p-1 shadow"
onClick={handleDelete}
disabled={disabled}
aria-label="删除视频"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
);
};
export default UploadVideo;

View File

@@ -1,11 +0,0 @@
import React from "react";
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
</svg>
);
}
export default AppleIcon;

View File

@@ -1,22 +0,0 @@
import React from "react";
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
height="24"
width="24"
{...props}
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default EyeIcon;

View File

@@ -1,12 +0,0 @@
import React from "react";
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
</svg>
);
}
export default WeChatIcon;

View File

@@ -1,62 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, children, ...props }, ref) => (
children ? (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
>
{children}
</h5>
) : null
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,77 +0,0 @@
import React from 'react';
import { UserCircle } from 'lucide-react';
interface AvatarProps {
children: React.ReactNode;
className?: string;
}
export function Avatar({ children, className = '' }: AvatarProps) {
return (
<div className={`relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full ${className}`}>
{children}
</div>
);
}
interface AvatarImageProps {
src?: string;
alt?: string;
className?: string;
}
export function AvatarImage({ src, alt, className = '' }: AvatarImageProps) {
if (!src) return null;
return (
<img
src={src}
alt={alt || '头像'}
className={`aspect-square h-full w-full object-cover ${className}`}
onError={(e) => {
// 图片加载失败时隐藏图片显示fallback
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
);
}
interface AvatarFallbackProps {
children?: React.ReactNode;
className?: string;
variant?: 'default' | 'gradient' | 'solid' | 'outline';
showUserIcon?: boolean;
}
export function AvatarFallback({
children,
className = '',
variant = 'default',
showUserIcon = true
}: AvatarFallbackProps) {
const getVariantClasses = () => {
switch (variant) {
case 'gradient':
return 'bg-gradient-to-br from-blue-500 to-purple-600 text-white shadow-lg';
case 'solid':
return 'bg-blue-500 text-white';
case 'outline':
return 'bg-white border-2 border-blue-500 text-blue-500';
default:
return 'bg-gradient-to-br from-blue-100 to-blue-200 text-blue-600';
}
};
return (
<div className={`flex h-full w-full items-center justify-center rounded-full ${getVariantClasses()} ${className}`}>
{children ? (
<span className="text-sm font-medium">{children}</span>
) : showUserIcon ? (
<UserCircle className="h-1/2 w-1/2" />
) : (
<span className="text-sm font-medium"></span>
)}
</div>
);
}

View File

@@ -1,45 +0,0 @@
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'secondary' | 'success' | 'destructive' | 'outline';
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function Badge({
children,
variant = 'default',
className = '',
onClick
}: BadgeProps) {
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
const variantClasses = {
default: 'bg-blue-100 text-blue-800',
secondary: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
destructive: 'bg-red-100 text-red-800',
outline: 'border border-gray-300 bg-white text-gray-700'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
if (onClick) {
return (
<button
className={classes}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
return (
<span className={classes}>
{children}
</span>
);
}

View File

@@ -1,60 +0,0 @@
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
className?: string;
onClick?: (e?: React.MouseEvent) => void;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
loading?: boolean;
}
export function Button({
children,
variant = 'default',
size = 'default',
className = '',
onClick,
disabled = false,
type = 'button',
loading = false
}: ButtonProps) {
const baseClasses = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
const variantClasses = {
default: 'bg-blue-600 text-white hover:bg-blue-700',
destructive: 'bg-red-600 text-white hover:bg-red-700',
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'hover:bg-gray-100 text-gray-700',
link: 'text-blue-600 underline-offset-4 hover:underline'
};
const sizeClasses = {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button
className={classes}
onClick={onClick}
disabled={disabled || loading}
type={type}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</button>
);
}

View File

@@ -1,66 +0,0 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
{children}
</div>
);
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
export function CardHeader({ children, className = '' }: CardHeaderProps) {
return (
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
{children}
</div>
);
}
interface CardTitleProps {
children: React.ReactNode;
className?: string;
}
export function CardTitle({ children, className = '' }: CardTitleProps) {
return (
<h3 className={`text-lg font-semibold text-gray-900 ${className}`}>
{children}
</h3>
);
}
interface CardContentProps {
children: React.ReactNode;
className?: string;
}
export function CardContent({ children, className = '' }: CardContentProps) {
return (
<div className={`px-6 py-4 ${className}`}>
{children}
</div>
);
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
export function CardFooter({ children, className = '' }: CardFooterProps) {
return (
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
{children}
</div>
);
}

View File

@@ -1,39 +0,0 @@
import React from 'react';
interface CheckboxProps {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
onChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
id?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function Checkbox({
checked = false,
onCheckedChange,
onChange,
disabled = false,
className = '',
id,
onClick
}: CheckboxProps) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = e.target.checked;
onCheckedChange?.(newChecked);
onChange?.(newChecked);
};
return (
<input
type="checkbox"
id={id}
checked={checked}
onChange={handleChange}
onClick={onClick}
disabled={disabled}
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${className}`}
/>
);
}

View File

@@ -1,122 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-white fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,109 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface DropdownMenuProps {
children: React.ReactNode;
}
export function DropdownMenu({ children }: DropdownMenuProps) {
return <>{children}</>;
}
interface DropdownMenuTriggerProps {
children: React.ReactNode;
asChild?: boolean;
}
export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) {
return <>{children}</>;
}
interface DropdownMenuContentProps {
children: React.ReactNode;
align?: 'start' | 'center' | 'end';
}
export function DropdownMenuContent({ children, align = 'end' }: DropdownMenuContentProps) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const handleClick = () => setIsOpen(!isOpen);
trigger.addEventListener('click', handleClick);
return () => {
trigger.removeEventListener('click', handleClick);
};
}, [isOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative">
<div ref={triggerRef}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
...child.props,
children: (
<>
{child.props.children}
{isOpen && (
<div
ref={contentRef}
className={`absolute z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-md ${
align === 'start' ? 'left-0' : align === 'center' ? 'left-1/2 transform -translate-x-1/2' : 'right-0'
}`}
>
{children}
</div>
)}
</>
)
});
}
return child;
})}
</div>
</div>
);
}
interface DropdownMenuItemProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export function DropdownMenuItem({ children, onClick, disabled = false }: DropdownMenuItemProps) {
return (
<button
className={`relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100 disabled:pointer-events-none disabled:opacity-50 ${
disabled ? 'cursor-not-allowed opacity-50' : ''
}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
interface InputProps {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
placeholder?: string;
className?: string;
readOnly?: boolean;
readonly?: boolean;
id?: string;
type?: string;
min?: number;
max?: number;
name?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: string;
step?: number;
}
export function Input({
value,
onChange,
onKeyDown,
onClick,
placeholder,
className = '',
readOnly = false,
readonly = false,
id,
type = 'text',
min,
max,
name,
required = false,
disabled = false,
autoComplete,
step
}: InputProps) {
const isReadOnly = readOnly || readonly;
return (
<input
id={id}
type={type}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onClick={onClick}
placeholder={placeholder}
readOnly={isReadOnly}
min={min}
max={max}
name={name}
required={required}
disabled={disabled}
autoComplete={autoComplete}
step={step}
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
/>
);
}

View File

@@ -1,18 +0,0 @@
import React from 'react';
interface LabelProps {
children: React.ReactNode;
htmlFor?: string;
className?: string;
}
export function Label({ children, htmlFor, className = '' }: LabelProps) {
return (
<label
htmlFor={htmlFor}
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
>
{children}
</label>
);
}

View File

@@ -1,17 +0,0 @@
import React from 'react';
interface ProgressProps {
value: number;
className?: string;
}
export function Progress({ value, className = '' }: ProgressProps) {
return (
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}

View File

@@ -1,44 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -1,184 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface SelectProps {
value?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
children: React.ReactNode;
}
interface SelectTriggerProps {
children: React.ReactNode;
className?: string;
}
interface SelectContentProps {
children: React.ReactNode;
className?: string;
}
interface SelectItemProps {
value: string;
children: React.ReactNode;
className?: string;
}
interface SelectValueProps {
placeholder?: string;
className?: string;
}
export function Select({
value,
onValueChange,
disabled = false,
className = '',
placeholder,
children
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(value || '');
const [selectedLabel, setSelectedLabel] = useState('');
const selectRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedValue(value || '');
}, [value]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (value: string, label: string) => {
setSelectedValue(value);
setSelectedLabel(label);
onValueChange?.(value);
setIsOpen(false);
};
return (
<div ref={selectRef} className={`relative ${className}`}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
if (child.type === SelectTrigger) {
return React.cloneElement(child as any, {
onClick: () => !disabled && setIsOpen(!isOpen),
disabled,
selectedValue: selectedValue,
selectedLabel: selectedLabel,
placeholder,
isOpen
});
}
if (child.type === SelectContent && isOpen) {
return React.cloneElement(child as any, {
onSelect: handleSelect
});
}
}
return child;
})}
</div>
);
}
export function SelectTrigger({
children,
className = '',
onClick,
disabled,
selectedValue,
selectedLabel,
placeholder,
isOpen
}: SelectTriggerProps & {
onClick?: () => void;
disabled?: boolean;
selectedValue?: string;
selectedLabel?: string;
placeholder?: string;
isOpen?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full px-3 py-2 text-left border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
>
<span className="flex items-center justify-between">
<span className={selectedValue ? 'text-gray-900' : 'text-gray-500'}>
{selectedLabel || placeholder || '请选择...'}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
);
}
export function SelectContent({
children,
className = '',
onSelect
}: SelectContentProps & {
onSelect?: (value: string, label: string) => void;
}) {
return (
<div className={`absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto ${className}`}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === SelectItem) {
return React.cloneElement(child as any, {
onSelect
});
}
return child;
})}
</div>
);
}
export function SelectItem({
value,
children,
className = '',
onSelect
}: SelectItemProps & {
onSelect?: (value: string, label: string) => void;
}) {
return (
<button
type="button"
onClick={() => onSelect?.(value, children as string)}
className={`w-full px-3 py-2 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${className}`}
>
{children}
</button>
);
}
export function SelectValue({
placeholder,
className = ''
}: SelectValueProps) {
return (
<span className={className}>
{placeholder}
</span>
);
}

View File

@@ -1,28 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "../../utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,15 +0,0 @@
import { cn } from "../../utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,34 +0,0 @@
import React from 'react';
interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
id?: string;
}
export function Switch({ checked, onCheckedChange, disabled = false, className = '', id }: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
id={id}
onClick={() => !disabled && onCheckedChange(!checked)}
className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
${checked ? 'bg-blue-600' : 'bg-gray-200'}
${className}
`}
>
<span
className={`
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
</button>
);
}

View File

@@ -1,69 +0,0 @@
import * as React from "react"
import { cn } from "@/utils"
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
)
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
)
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
)
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
{...props}
/>
),
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
),
)
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
{...props}
/>
),
)
TableCell.displayName = "TableCell"
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell }

View File

@@ -1,52 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,33 +0,0 @@
import React from 'react';
interface TextareaProps {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
rows?: number;
}
export function Textarea({
value,
onChange,
onKeyDown,
placeholder,
className = '',
disabled = false,
rows = 3
}: TextareaProps) {
return (
<textarea
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={rows}
className={`w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
/>
);
}

View File

@@ -1,223 +0,0 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/utils"
export type { ToastActionElement, ToastProps };
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
// === 以下为 use-toast.ts 的内容迁移 ===
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) return;
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: "REMOVE_TOAST", toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) };
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return { ...state, toasts: [] };
}
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId) };
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => listener(memoryState));
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
return { id, dismiss, update };
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@@ -1,76 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
interface TooltipProviderProps {
children: React.ReactNode;
}
export function TooltipProvider({ children }: TooltipProviderProps) {
return <>{children}</>;
}
interface TooltipProps {
children: React.ReactNode;
}
export function Tooltip({ children }: TooltipProps) {
return <>{children}</>;
}
interface TooltipTriggerProps {
children: React.ReactNode;
asChild?: boolean;
}
export function TooltipTrigger({ children, asChild }: TooltipTriggerProps) {
return <>{children}</>;
}
interface TooltipContentProps {
children: React.ReactNode;
className?: string;
}
export function TooltipContent({ children, className = '' }: TooltipContentProps) {
const [isVisible, setIsVisible] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const showTooltip = () => setIsVisible(true);
const hideTooltip = () => setIsVisible(false);
trigger.addEventListener('mouseenter', showTooltip);
trigger.addEventListener('mouseleave', hideTooltip);
return () => {
trigger.removeEventListener('mouseenter', showTooltip);
trigger.removeEventListener('mouseleave', hideTooltip);
};
}, []);
return (
<div ref={triggerRef} className="relative inline-block">
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
...child.props,
children: (
<>
{child.props.children}
{isVisible && (
<div className={`absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap ${className}`} style={{ top: '-30px', left: '50%', transform: 'translateX(-50%)' }}>
{children}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
</div>
)}
</>
)
});
}
return child;
})}
</div>
);
}

View File

@@ -1,188 +0,0 @@
"use client"
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }