Merge branch 'yongpxu-dev' into yongpxu-dev4

This commit is contained in:
超级老白兔
2025-08-29 15:57:54 +08:00
148 changed files with 9546 additions and 3944 deletions

View File

@@ -1,14 +1,18 @@
{
"_charts-CLRTJ7Uf.js": {
"file": "assets/charts-CLRTJ7Uf.js",
"_charts-DKSCc2_C.js": {
"file": "assets/charts-DKSCc2_C.js",
"name": "charts",
"imports": [
"_ui-BFvqeNzU.js",
"_ui-DhAz00L0.js",
"_vendor-2vc8h_ct.js"
]
},
"_ui-BFvqeNzU.js": {
"file": "assets/ui-BFvqeNzU.js",
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_ui-DhAz00L0.js": {
"file": "assets/ui-DhAz00L0.js",
"name": "ui",
"imports": [
"_vendor-2vc8h_ct.js"
@@ -17,10 +21,6 @@
"assets/ui-D0C0OGrH.css"
]
},
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_utils-6WF66_dS.js": {
"file": "assets/utils-6WF66_dS.js",
"name": "utils",
@@ -33,18 +33,18 @@
"name": "vendor"
},
"index.html": {
"file": "assets/index-C48GlG01.js",
"file": "assets/index-BdCPAYQ7.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"imports": [
"_vendor-2vc8h_ct.js",
"_ui-BFvqeNzU.js",
"_utils-6WF66_dS.js",
"_charts-CLRTJ7Uf.js"
"_ui-DhAz00L0.js",
"_charts-DKSCc2_C.js"
],
"css": [
"assets/index-Ta4vyxDJ.css"
"assets/index-ChiFk16x.css"
]
}
}

View File

@@ -11,13 +11,13 @@
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="/websdk.js"></script>
<script type="module" crossorigin src="/assets/index-C48GlG01.js"></script>
<script type="module" crossorigin src="/assets/index-BdCPAYQ7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-2vc8h_ct.js">
<link rel="modulepreload" crossorigin href="/assets/ui-BFvqeNzU.js">
<link rel="modulepreload" crossorigin href="/assets/utils-6WF66_dS.js">
<link rel="modulepreload" crossorigin href="/assets/charts-CLRTJ7Uf.js">
<link rel="modulepreload" crossorigin href="/assets/ui-DhAz00L0.js">
<link rel="modulepreload" crossorigin href="/assets/charts-DKSCc2_C.js">
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
<link rel="stylesheet" crossorigin href="/assets/index-Ta4vyxDJ.css">
<link rel="stylesheet" crossorigin href="/assets/index-ChiFk16x.css">
</head>
<body>
<div id="root"></div>

View File

@@ -10,11 +10,13 @@
"antd-mobile-icons": "^0.3.0",
"axios": "^1.6.7",
"dayjs": "^1.11.13",
"dexie": "^4.2.0",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-window": "^1.8.11",
"vconsole": "^3.15.1",
"zustand": "^5.0.6"
},

View File

@@ -26,6 +26,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.13
dexie:
specifier: ^4.2.0
version: 4.2.0
echarts:
specifier: ^5.6.0
version: 5.6.0
@@ -41,6 +44,9 @@ importers:
react-router-dom:
specifier: ^6.20.0
version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-window:
specifier: ^1.8.11
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
vconsole:
specifier: ^3.15.1
version: 3.15.1
@@ -1061,6 +1067,9 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
dexie@4.2.0:
resolution: {integrity: sha512-OSeyyWOUetDy9oFWeddJgi83OnRA3hSFh3RrbltmPgqHszE9f24eUCVLI4mPg0ifsWk0lQTdnS+jyGNrPMvhDA==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -1563,6 +1572,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -2001,6 +2013,13 @@ packages:
peerDependencies:
react: '>=16.8'
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -3384,6 +3403,8 @@ snapshots:
detect-libc@1.0.3:
optional: true
dexie@4.2.0: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -4025,6 +4046,8 @@ snapshots:
math-intrinsics@1.1.0: {}
memoize-one@5.2.1: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -4538,6 +4561,13 @@ snapshots:
'@remix-run/router': 1.23.0
react: 18.3.1
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.28.2
memoize-one: 5.2.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0

View File

@@ -28,7 +28,6 @@ const instance: AxiosInstance = axios.create({
instance.interceptors.request.use((config: any) => {
// 在每次请求时动态获取最新的 token2
const { token2 } = useUserStore.getState();
if (token2) {
config.headers = config.headers || {};
config.headers["Authorization"] = `bearer ${token2}`;
@@ -41,6 +40,15 @@ instance.interceptors.response.use(
return res.data;
},
err => {
// 处理401错误跳转到登录页面
if (err.response && err.response.status === 401) {
Toast.show({ content: "登录已过期,请重新登录", position: "top" });
// 获取当前路径,用于登录后跳回
const currentPath = window.location.pathname + window.location.search;
window.location.href = `/login?returnUrl=${encodeURIComponent(currentPath)}`;
return Promise.reject(err);
}
Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err);
},

View File

@@ -21,6 +21,9 @@ export default function SelectionPopup({
const [totalPages, setTotalPages] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<AccountItem[]>(
[],
);
// 累积已加载过的账号,确保确认时能返回更完整的对象
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
@@ -58,16 +61,19 @@ export default function SelectionPopup({
const handleAccountToggle = (account: AccountItem) => {
if (readonly || !onSelect) return;
const isSelected = selectedOptions.some(opt => opt.id === account.id);
const isSelected = tempSelectedOptions.some(opt => opt.id === account.id);
const next = isSelected
? selectedOptions.filter(opt => opt.id !== account.id)
: selectedOptions.concat(account);
onSelect(next);
? tempSelectedOptions.filter(opt => opt.id !== account.id)
: tempSelectedOptions.concat(account);
setTempSelectedOptions(next);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
onConfirm(tempSelectedOptions);
}
if (onSelect) {
onSelect(tempSelectedOptions);
}
onVisibleChange(false);
};
@@ -78,9 +84,11 @@ export default function SelectionPopup({
setCurrentPage(1);
setSearchQuery("");
loadedAccountMapRef.current.clear();
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchAccounts(1, "");
}
}, [visible]);
}, [visible, selectedOptions]);
// 搜索防抖
useEffect(() => {
@@ -100,8 +108,8 @@ export default function SelectionPopup({
}, [currentPage, visible, searchQuery]);
const selectedIdSet = useMemo(
() => new Set(selectedOptions.map(opt => opt.id)),
[selectedOptions],
() => new Set(tempSelectedOptions.map(opt => opt.id)),
[tempSelectedOptions],
);
return (
@@ -128,7 +136,7 @@ export default function SelectionPopup({
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}

View File

@@ -1,39 +1,11 @@
import React, { useState, useEffect } from "react";
import React, { useState } 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";
import SelectionPopup from "./selectionPopup";
// 类型标签文本
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({
const ContentSelection: React.FC<ContentSelectionProps> = ({
selectedOptions,
onSelect,
placeholder = "选择内容库",
@@ -45,22 +17,9 @@ export default function ContentSelection({
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);
@@ -70,60 +29,7 @@ export default function ContentSelection({
// 打开弹窗
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);
};
// 获取显示文本
@@ -132,12 +38,16 @@ export default function ContentSelection({
return `已选择 ${selectedOptions.length} 个内容库`;
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
setRealVisible(false);
// 删除已选内容库
const handleRemoveLibrary = (id: number) => {
if (readonly) return;
onSelect(selectedOptions.filter(c => c.id !== id));
};
// 清除所有已选内容库
const handleClearAll = () => {
if (readonly) return;
onSelect([]);
};
return (
@@ -151,6 +61,7 @@ export default function ContentSelection({
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
onClear={handleClearAll}
size="large"
readOnly={readonly}
disabled={readonly}
@@ -220,83 +131,15 @@ export default function ContentSelection({
</div>
)}
{/* 弹窗 */}
<Popup
<SelectionPopup
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>
onClose={() => setRealVisible(false)}
selectedOptions={selectedOptions}
onSelect={onSelect}
onConfirm={onConfirm}
/>
</>
);
}
};
export default ContentSelection;

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from "react";
import { Checkbox, Popup } from "antd-mobile";
import { getContentLibraryList } 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 { ContentItem } from "./data";
interface SelectionPopupProps {
visible: boolean;
onClose: () => void;
selectedOptions: ContentItem[];
onSelect: (libraries: ContentItem[]) => void;
onConfirm?: (libraries: ContentItem[]) => void;
}
const PAGE_SIZE = 10;
// 类型标签文本
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")}`;
};
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedOptions,
onSelect,
onConfirm,
}) => {
// 内容库数据
const [libraries, setLibraries] = useState<ContentItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true); // 默认设置为加载中状态
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalLibraries, setTotalLibraries] = useState(0);
const [tempSelectedOptions, setTempSelectedOptions] = useState<ContentItem[]>(
[],
);
// 获取内容库列表支持keyword和分页
const fetchLibraries = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: PAGE_SIZE,
};
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) / PAGE_SIZE));
} else {
// 如果没有返回列表数据,设置为空数组
setLibraries([]);
setTotalLibraries(0);
setTotalPages(1);
}
} catch (error) {
console.error("获取内容库列表失败:", error);
// 请求失败时,设置为空数组
setLibraries([]);
setTotalLibraries(0);
setTotalPages(1);
} finally {
setTimeout(() => {
setLoading(false);
});
}
};
// 打开弹窗时获取第一页
useEffect(() => {
if (visible) {
setSearchQuery("");
setCurrentPage(1);
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
// 设置loading状态避免显示空内容
setLoading(true);
fetchLibraries(1, "");
} else {
// 关闭弹窗时重置加载状态,确保下次打开时显示加载中
setLoading(true);
}
}, [visible, selectedOptions]);
// 搜索处理函数
const handleSearch = (query: string) => {
if (!visible) return;
setCurrentPage(1);
fetchLibraries(1, query);
};
// 搜索输入变化时的处理
const handleSearchChange = (query: string) => {
setSearchQuery(query);
};
// 翻页处理函数
const handlePageChange = (page: number) => {
if (!visible || page === currentPage) return;
setCurrentPage(page);
fetchLibraries(page, searchQuery);
};
// 处理内容库选择
const handleLibraryToggle = (library: ContentItem) => {
const newSelected = tempSelectedOptions.some(c => c.id === library.id)
? tempSelectedOptions.filter(c => c.id !== library.id)
: [...tempSelectedOptions, library];
setTempSelectedOptions(newSelected);
};
// 确认选择
const handleConfirm = () => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
if (onConfirm) {
onConfirm(tempSelectedOptions);
}
onClose();
};
// 渲染内容库列表或空状态提示
const OptionsList = () => {
return libraries.length > 0 ? (
<div className={style.libraryListInner}>
{libraries.map(item => (
<label key={item.id} className={style.libraryItem}>
<Checkbox
checked={tempSelectedOptions.map(c => c.id).includes(item.id)}
onChange={() => handleLibraryToggle(item)}
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}></div>
</div>
);
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="bottom"
bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false}
>
<Layout
header={
<PopupHeader
title="选择内容库"
searchQuery={searchQuery}
setSearchQuery={handleSearchChange}
searchPlaceholder="搜索内容库"
loading={loading}
onSearch={handleSearch}
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalLibraries}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={tempSelectedOptions.length}
onPageChange={handlePageChange}
onCancel={onClose}
onConfirm={handleConfirm}
/>
}
>
<div className={style.libraryList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
OptionsList()
)}
</div>
</Layout>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -29,6 +29,9 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
DeviceSelectionItem[]
>([]);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
@@ -71,9 +74,11 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
if (visible) {
setSearchQuery("");
setCurrentPage(1);
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchDevices("", 1);
}
}, [visible, fetchDevices]);
}, [visible, fetchDevices, selectedOptions]);
// 搜索防抖
useEffect(() => {
@@ -105,11 +110,13 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
// 处理设备选择
const handleDeviceToggle = (device: DeviceSelectionItem) => {
if (selectedOptions.some(v => v.id === device.id)) {
onSelect(selectedOptions.filter(v => v.id !== device.id));
if (tempSelectedOptions.some(v => v.id === device.id)) {
setTempSelectedOptions(
tempSelectedOptions.filter(v => v.id !== device.id),
);
} else {
const newSelectedOptions = [...selectedOptions, device];
onSelect(newSelectedOptions);
const newSelectedOptions = [...tempSelectedOptions, device];
setTempSelectedOptions(newSelectedOptions);
}
};
@@ -148,10 +155,14 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={onClose}
onConfirm={onClose}
onConfirm={() => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
onClose();
}}
/>
}
>
@@ -168,7 +179,9 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
<div className={style.headerRow}>
<div className={style.checkboxContainer}>
<Checkbox
checked={selectedOptions.some(v => v.id === device.id)}
checked={tempSelectedOptions.some(
v => v.id === device.id,
)}
onChange={() => handleDeviceToggle(device)}
className={style.deviceCheckbox}
/>

View File

@@ -10,7 +10,7 @@ export interface FriendSelectionItem {
export interface FriendSelectionProps {
selectedOptions?: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
deviceIds?: number[];
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;

View File

@@ -12,7 +12,7 @@ interface SelectionPopupProps {
onVisibleChange: (visible: boolean) => void;
selectedOptions: FriendSelectionItem[];
onSelect: (friends: FriendSelectionItem[]) => void;
deviceIds?: string[];
deviceIds?: number[];
enableDeviceFilter?: boolean;
readonly?: boolean;
onConfirm?: (
@@ -37,6 +37,9 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
FriendSelectionItem[]
>([]);
// 获取好友列表API
const fetchFriends = useCallback(
@@ -75,21 +78,23 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
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);
const newSelectedFriends = tempSelectedOptions.some(f => f.id === friend.id)
? tempSelectedOptions.filter(f => f.id !== friend.id)
: tempSelectedOptions.concat(friend);
onSelect(newSelectedFriends);
setTempSelectedOptions(newSelectedFriends);
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(v => v.id),
selectedOptions,
tempSelectedOptions.map(v => v.id),
tempSelectedOptions,
);
}
// 更新实际选中的选项
onSelect(tempSelectedOptions);
onVisibleChange(false);
};
@@ -98,9 +103,11 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchFriends(1, "");
}
}, [visible]); // 只在弹窗开启时请求
}, [visible, selectedOptions]); // 只在弹窗开启时请求
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
@@ -144,7 +151,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
@@ -161,7 +168,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
{friends.map(friend => (
<div key={friend.id} className={style.friendItem}>
<Checkbox
checked={selectedOptions.some(f => f.id === friend.id)}
checked={tempSelectedOptions.some(f => f.id === friend.id)}
onChange={() => !readonly && handleFriendToggle(friend)}
disabled={readonly}
style={{ marginRight: 12 }}

View File

@@ -47,6 +47,9 @@ export default function SelectionPopup({
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
GroupSelectionItem[]
>([]);
// 获取群聊列表API
const fetchGroups = async (page: number, keyword: string = "") => {
@@ -78,27 +81,30 @@ export default function SelectionPopup({
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);
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
? tempSelectedOptions.filter(g => g.id !== group.id)
: tempSelectedOptions.concat(group);
onSelect(newSelectedGroups);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
newSelectedGroups.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
setTempSelectedOptions(newSelectedGroups);
};
// 确认选择
const handleConfirm = () => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
tempSelectedOptions.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
if (onConfirm) {
onConfirm(
selectedOptions.map(g => g.id),
selectedOptions,
tempSelectedOptions.map(g => g.id),
tempSelectedOptions,
);
}
onVisibleChange(false);
@@ -109,6 +115,8 @@ export default function SelectionPopup({
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchGroups(1, "");
}
}, [visible]);
@@ -155,7 +163,7 @@ export default function SelectionPopup({
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
@@ -172,7 +180,7 @@ export default function SelectionPopup({
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
<Checkbox
checked={selectedOptions.some(g => g.id === group.id)}
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}

View File

@@ -0,0 +1,48 @@
import React 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 LayoutFiexd: React.FC<LayoutProps> = ({
header,
children,
footer,
loading = false,
}) => {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div className="header">{header}</div>
<div
className="content"
style={{
flex: 1,
overflow: "auto",
}}
>
{loading ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={styles.loadingText}>...</div>
</div>
) : (
children
)}
</div>
<div className="footer">{footer}</div>
</div>
);
};
export default LayoutFiexd;

View File

@@ -0,0 +1,34 @@
import request from "@/api/request";
// 请求参数接口
export interface Request {
keyword: string;
/**
* 条数
*/
limit: string;
/**
* 分页
*/
page: string;
[property: string]: any;
}
// 获取流量池包列表
export function getPoolPackages(params: Request) {
return request("/v1/traffic/pool/getPackage", params, "GET");
}
// 保留原接口以兼容现有代码
export function getPoolList(params: {
page?: string;
pageSize?: string;
keyword?: string;
addStatus?: string;
deviceId?: string;
packageId?: string;
userValue?: string;
[property: string]: any;
}) {
return request("/v1/traffic/pool", params, "GET");
}

View File

@@ -0,0 +1,61 @@
// 流量池包接口类型
export interface PoolPackageItem {
id: number;
name: string;
description: string;
createTime: string;
num: number;
}
// 原流量池接口类型(保留以兼容现有代码)
export interface PoolItem {
id: number;
identifier: string;
mobile: string;
wechatId: string;
fromd: string;
status: number;
createTime: string;
companyId: number;
sourceId: string;
type: number;
nickname: string;
avatar: string;
gender: number;
phone: string;
alias: string;
packages: any[];
tags: any[];
}
export interface PoolSelectionItem {
id: string;
avatar?: string;
name: string;
wechatId?: string;
mobile?: string;
nickname?: string;
createTime?: string;
description?: string;
num?: number;
[key: string]: any;
}
// 组件属性接口
export interface PoolSelectionProps {
selectedOptions: PoolSelectionItem[];
onSelect: (Pools: PoolSelectionItem[]) => void;
onSelectDetail?: (Pools: PoolPackageItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: PoolSelectionItem[],
) => 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: 8px;
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,127 @@
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 { PoolSelectionProps } from "./data";
export default function PoolSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择流量池",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: PoolSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选流量池项
const handleRemoveItem = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(item => item.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(item => (
<div key={item.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<div className={style.groupAvatar}>
{(item.nickname || item.name || "").charAt(0)}
</div>
<div className={style.selectedListRowContentText}>
<div>{item.nickname || item.name}</div>
<div>{item.wechatId || item.mobile}</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={() => handleRemoveItem(item.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getPoolPackages, Request } 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 { PoolSelectionItem, PoolPackageItem } from "./data";
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: PoolSelectionItem[];
onSelect: (items: PoolSelectionItem[]) => void;
onSelectDetail?: (items: PoolPackageItem[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: PoolSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [poolPackages, setPoolPackages] = useState<PoolPackageItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
PoolSelectionItem[]
>([]);
// 获取流量池包列表API
const fetchPoolPackages = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: Request = {
page: String(page),
limit: "20",
keyword: keyword.trim(),
};
const response = await getPoolPackages(params);
if (response && response.list) {
setPoolPackages(response.list);
setTotalItems(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取流量池包列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理流量池包选择
const handlePackageToggle = (item: PoolPackageItem) => {
if (readonly) return;
// 将PoolPackageItem转换为GroupSelectionItem格式
const selectionItem: PoolSelectionItem = {
id: String(item.id),
name: item.name,
description: item.description,
createTime: item.createTime,
num: item.num,
// 保留原始数据
originalData: item,
};
const newSelectedItems = tempSelectedOptions.some(
g => g.id === String(item.id),
)
? tempSelectedOptions.filter(g => g.id !== String(item.id))
: tempSelectedOptions.concat(selectionItem);
setTempSelectedOptions(newSelectedItems);
// 如果有 onSelectDetail 回调,传递完整的流量池包对象
if (onSelectDetail) {
const selectedItemObjs = poolPackages.filter(packageItem =>
newSelectedItems.some(g => g.id === String(packageItem.id)),
);
onSelectDetail(selectedItemObjs);
}
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(item => item.id),
tempSelectedOptions,
);
}
// 更新实际选中的选项
onSelect(tempSelectedOptions);
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchPoolPackages(1, "");
}
}, [visible, selectedOptions]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchPoolPackages(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchPoolPackages(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择流量池包"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索流量池包"
loading={loading}
onRefresh={() => fetchPoolPackages(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalItems}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : poolPackages.length > 0 ? (
<div className={style.groupListInner}>
{poolPackages.map(item => (
<div key={item.id} className={style.groupItem}>
<Checkbox
checked={tempSelectedOptions.some(
g => g.id === String(item.id),
)}
onChange={() => !readonly && handlePackageToggle(item)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{item.name ? item.name.charAt(0) : "?"}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{item.name}</div>
<div className={style.groupId}>
: {item.description || "无描述"}
</div>
<div className={style.groupOwner}>
: {item.createTime}
</div>
<div className={style.groupOwner}>
: {item.num}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的流量池包`
: "没有找到流量池包"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -11,6 +11,7 @@ interface PopupHeaderProps {
searchPlaceholder?: string;
loading?: boolean;
onRefresh?: () => void;
onSearch?: (query: string) => void;
showRefresh?: boolean;
showSearch?: boolean;
showTabs?: boolean;
@@ -28,6 +29,7 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
searchPlaceholder = "搜索...",
loading = false,
onRefresh,
onSearch,
showRefresh = true,
showSearch = true,
showTabs = false,
@@ -42,10 +44,11 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
{showSearch && (
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<Input
<Input.Search
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onSearch={() => onSearch && onSearch(searchQuery)}
prefix={<SearchOutlined />}
size="large"
/>

View File

@@ -116,6 +116,7 @@ const WebSocketExample: React.FC = () => {
color="primary"
onClick={() =>
connect({
url: "wss://kf.quwanzhi.com:9993", // 显式指定WebSocket URL确保使用正确的服务器地址
client: "kefu-client",
autoReconnect: true,
})

View File

@@ -282,7 +282,6 @@ const Guide: React.FC = () => {
style={{ marginBottom: 16 }}
>
<Tabs.Tab title="扫码添加" key="scan" />
<Tabs.Tab title="手动添加" key="manual" />
</Tabs>
{addTab === "scan" && (
<div style={{ textAlign: "center", minHeight: 200 }}>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { useCkChatStore } from "@/store/module/ckchat";
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
import {
EyeInvisibleOutline,
@@ -7,15 +7,8 @@ import {
UserOutline,
} from "antd-mobile-icons";
import { useUserStore } from "@/store/module/user";
import { useCkChatStore } from "@/store/module/ckchat";
import { useWebSocketStore } from "@/store/module/websocket";
import {
loginWithPassword,
loginWithCode,
sendVerificationCode,
loginWithToken,
getChuKeBaoUserInfo,
} from "./api";
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
import style from "./login.module.scss";
const Login: React.FC = () => {
@@ -25,9 +18,8 @@ const Login: React.FC = () => {
const [countdown, setCountdown] = useState(0);
const [showPassword, setShowPassword] = useState(false);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const { setUserInfo } = useCkChatStore.getState();
const { login, login2 } = useUserStore();
const { setUserInfo, getAccountId } = useCkChatStore();
// 倒计时效果
useEffect(() => {
@@ -69,76 +61,35 @@ const Login: React.FC = () => {
}
};
const getToken = (values: any) => {
// 添加typeId参数
const loginParams = {
...values,
typeId: activeTab as number,
};
const response =
activeTab === 1
? loginWithPassword(loginParams)
: loginWithCode(loginParams);
response.then(res => {
const { member, kefuData, deviceTotal } = res;
login(res.token, member, deviceTotal);
const { self, token } = kefuData;
login2(token.access_token);
setUserInfo(self);
});
};
// 登录处理
const handleLogin = async (values: any) => {
if (!agreeToTerms) {
Toast.show({ content: "请同意用户协议和隐私政策", position: "top" });
return;
}
getToken(values).then(() => {
getChuKeBaoUserInfo().then(res => {
setUserInfo(res);
getToken2().then(Token => {
// // 使用WebSocket store连接
// const { connect } = useWebSocketStore.getState();
// connect({
// accessToken: Token,
// accountId: getAccountId()?.toString() || "",
// client: "kefu-client",
// autoReconnect: true,
// reconnectInterval: 3000,
// maxReconnectAttempts: 5,
// });
});
});
setLoading(false);
});
};
const getToken = (values: any) => {
return new Promise((resolve, reject) => {
// 添加typeId参数
const loginParams = {
...values,
typeId: activeTab as number,
};
const response =
activeTab === 1
? loginWithPassword(loginParams)
: loginWithCode(loginParams);
response
.then(res => {
// 获取设备总数
const deviceTotal = res.deviceTotal || 0;
// 更新状态管理token会自动存储到localStorage用户信息存储在状态管理中
login(res.token, res.member, deviceTotal);
resolve(res);
})
.catch(err => {
reject(err);
});
});
};
const getToken2 = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
};
const response = loginWithToken(params);
response.then(res => {
login2(res.access_token);
resolve(res.access_token);
});
response.catch(err => {
reject(err);
});
});
//获取存客宝
getToken(values);
};
// 第三方登录处理

View File

@@ -379,7 +379,6 @@ const Devices: React.FC = () => {
style={{ marginBottom: 16 }}
>
<Tabs.Tab title="扫码添加" key="scan" />
<Tabs.Tab title="手动添加" key="manual" />
</Tabs>
{addTab === "scan" && (
<div style={{ textAlign: "center", minHeight: 200 }}>

View File

@@ -46,7 +46,7 @@ const About: React.FC = () => {
];
// 联系信息
const contactInfo = [
const contractInfo = [
{
id: "email",
title: "邮箱支持",
@@ -125,7 +125,7 @@ const About: React.FC = () => {
{/* <Card className={style["setting-group"]}>
<div className={style["group-title"]}>联系我们</div>
<List>
{contactInfo.map(item => (
{contractInfo.map(item => (
<List.Item
key={item.id}
prefix={item.icon}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Modal, Selector } from "antd-mobile";
import { Popup, Selector } from "antd-mobile";
import type { PackageOption } from "./data";
interface BatchAddModalProps {
@@ -15,20 +15,30 @@ interface BatchAddModalProps {
const BatchAddModal: React.FC<BatchAddModalProps> = ({
visible,
onClose,
packageOptions,
packageOptions = [],
batchTarget,
setBatchTarget,
selectedCount,
onConfirm,
}) => (
<Modal
// <Modal visible={visible} title="批量加入分组" onConfirm={onConfirm}>
// <div style={{ marginBottom: 12 }}>
// <div>选择目标分组</div>
// <Selector
// options={packageOptions.map(p => ({ label: p.name, value: p.id }))}
// value={[batchTarget]}
// onChange={v => setBatchTarget(v[0])}
// />
// </div>
// <div style={{ color: "#888", fontSize: 13 }}>
// 将选中的{selectedCount}个用户加入所选分组
// </div>
// </Modal>
<Popup
visible={visible}
title="批量加入分组"
onClose={onClose}
footer={[
{ text: "取消", onClick: onClose },
{ text: "确定", onClick: onConfirm },
]}
onMaskClick={() => onClose()}
position="bottom"
bodyStyle={{ height: "80vh" }}
>
<div style={{ marginBottom: 12 }}>
<div></div>
@@ -41,7 +51,7 @@ const BatchAddModal: React.FC<BatchAddModalProps> = ({
<div style={{ color: "#888", fontSize: 13 }}>
{selectedCount}
</div>
</Modal>
</Popup>
);
export default BatchAddModal;

View File

@@ -9,10 +9,10 @@ export function fetchTrafficPoolList(params: {
return request("/v1/traffic/pool", params, "GET");
}
export async function fetchScenarioOptions(): Promise<any[]> {
export async function fetchScenarioOptions() {
return request("/v1/plan/scenes", {}, "GET");
}
export async function fetchPackageOptions(): Promise<any[]> {
export async function fetchPackageOptions() {
return request("/v1/traffic/pool/getPackage", {}, "GET");
}

View File

@@ -4,14 +4,7 @@ import {
fetchPackageOptions,
fetchScenarioOptions,
} from "./api";
import type {
TrafficPoolUser,
DeviceOption,
PackageOption,
ValueLevel,
UserStatus,
ScenarioOption,
} from "./data";
import type { TrafficPoolUser, PackageOption, ScenarioOption } from "./data";
import { Toast } from "antd-mobile";
export function useTrafficPoolListLogic() {
@@ -78,8 +71,12 @@ export function useTrafficPoolListLogic() {
// 获取筛选项
useEffect(() => {
fetchPackageOptions().then(setPackageOptions);
fetchScenarioOptions().then(setScenarioOptions);
fetchPackageOptions().then(res => {
setPackageOptions(res.list || []);
});
fetchScenarioOptions().then(res => {
setScenarioOptions(res.list || []);
});
}, []);
// 筛选条件变化时刷新列表

View File

@@ -173,8 +173,8 @@ const TrafficPoolList: React.FC = () => {
status: "offline" as const,
})),
);
setPackageId(filters.packageId);
setScenarioId(filters.scenarioId);
setPackageId(filters.packageId ? parseInt(filters.packageId) : 0);
setScenarioId(filters.scenarioId ? parseInt(filters.scenarioId) : 0);
setUserValue(filters.userValue);
setUserStatus(filters.userStatus);
// 重新获取列表

View File

@@ -61,29 +61,40 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
}, [visible, ruleId]);
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
const getStatusColor = (status?: string) => {
switch (status) {
case "normal":
return "#52c41a";
case "limited":
return "#faad14";
case "blocked":
return "#ff4d4f";
const getStatusColor = (status?: string | number) => {
// 确保status是数字类型
const statusNum = Number(status);
switch (statusNum) {
case 0:
return "#faad14"; // 待添加 - 黄色警告色
case 1:
return "#1890ff"; // 添加中 - 蓝色进行中
case 2:
return "#ff4d4f"; // 添加失败 - 红色错误色
case 3:
return "#ff4d4f"; // 添加失败 - 红色错误色
case 4:
return "#52c41a"; // 已添加 - 绿色成功色
default:
return "#d9d9d9";
return "#d9d9d9"; // 未知状态 - 灰色
}
};
const getStatusText = (status?: string) => {
const getStatusText = (status?: number) => {
switch (status) {
case "normal":
return "正常";
case "limited":
return "受限";
case "blocked":
return "封禁";
case 0:
return "待添加";
case 1:
return "添加中";
case 2:
return "请求已发送待通过";
case 3:
return "添加失败";
case 4:
return "已添加";
default:
return "未知";
return "未知状态";
}
};
@@ -149,7 +160,7 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
style={{ backgroundColor: getStatusColor(account.status) }}
/>
<span className={style.statusText}>
{getStatusText(account.status)}
{getStatusText(Number(account.status))}
</span>
</div>
</div>

View File

@@ -54,4 +54,6 @@ export const defFormData: FormData = {
deveiceGroupsOptions: [],
wechatGroups: [],
wechatGroupsOptions: [],
contentGroups: [],
contentGroupsOptions: [],
};

View File

@@ -71,6 +71,8 @@ export default function NewPlan() {
deveiceGroupsOptions: detail.deveiceGroupsOptions ?? [],
wechatGroups: detail.wechatGroups ?? [],
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
contentGroups: detail.contentGroups ?? [],
contentGroupsOptions: detail.contentGroupsOptions ?? [],
status: detail.status ?? 0,
messagePlans: detail.messagePlans ?? [],
}));

View File

@@ -58,6 +58,8 @@ export interface MessageContentItem {
groupIds?: string[]; // 改为数组以支持GroupSelection组件
groupOptions?: any[]; // 添加群选项数组
linkUrl?: string;
coverImage?: string;
[key: string]: any;
}
export interface MessageContentGroup {

View File

@@ -7,14 +7,16 @@ import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import ContentSelection from "@/components/ContentSelection";
import AccountSelection from "@/components/AccountSelection";
import PoolSelection from "@/components/PoolSelection";
import { isDevelopment } from "@/utils/env";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { ContentItem } from "@/components/ContentSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { AccountItem } from "@/components/AccountSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
const ComponentTest: React.FC = () => {
const [activeTab, setActiveTab] = useState("devices");
const [activeTab, setActiveTab] = useState("pools");
// 设备选择状态
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
@@ -34,6 +36,9 @@ const ComponentTest: React.FC = () => {
const [selectedFriendsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
>([]);
// 流量池选择状态
const [selectedPools, setSelectedPools] = useState<PoolSelectionItem[]>([]);
return (
<Layout header={<NavCommon title="组件调试" />}>
<div style={{ padding: 16 }}>
@@ -84,7 +89,7 @@ const ComponentTest: React.FC = () => {
</div>
</Tabs.Tab>
<Tabs.Tab title="群组选择" key="groups">
<Tabs.Tab title="群组选择" key="groups2">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>GroupSelection </h3>
<GroupSelection
@@ -155,6 +160,58 @@ const ComponentTest: React.FC = () => {
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="群组选择" key="groups">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>GroupSelection </h3>
<GroupSelection
selectedOptions={selectedGroups}
onSelect={setSelectedGroups}
placeholder="请选择微信群组"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedGroups.length}
<br />
<strong>ID:</strong>{" "}
{selectedGroups.map(g => g.id).join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="流量池选择" key="pools">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>PoolSelection </h3>
<PoolSelection
selectedOptions={selectedPools}
onSelect={setSelectedPools}
placeholder="请选择流量池"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedPools.length}
<br />
<strong>ID:</strong>{" "}
{selectedPools.map(p => p.id).join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
</Tabs>
</div>
</Layout>

View File

@@ -0,0 +1,336 @@
import React, { useImperativeHandle, forwardRef, useEffect } from "react";
import { Button, Card, Switch, Form, InputNumber } from "antd";
import { Input } from "antd";
const { TextArea } = Input;
interface BasicSettingsProps {
initialValues?: {
name: string;
startTime: string;
endTime: string;
groupSizeMin: number;
groupSizeMax: number;
maxGroupsPerDay: number;
groupNameTemplate: string;
groupDescription: string;
status: number;
};
}
export interface BasicSettingsRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
(
{
initialValues = {
name: "",
startTime: "06:00",
endTime: "23:59",
groupSizeMin: 20,
groupSizeMax: 50,
maxGroupsPerDay: 10,
groupNameTemplate: "",
groupDescription: "",
status: 1,
},
},
ref,
) => {
const [form] = Form.useForm();
// 当initialValues变化时重新设置表单值
useEffect(() => {
form.setFieldsValue(initialValues);
}, [form, initialValues]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
try {
await form.validateFields();
return true;
} catch (error) {
console.log("BasicSettings 表单验证失败:", error);
return false;
}
},
getValues: () => {
return form.getFieldsValue();
},
}));
return (
<div style={{ marginBottom: 24 }}>
<Card>
<Form
form={form}
layout="vertical"
key={JSON.stringify(initialValues)}
onValuesChange={(changedValues, allValues) => {
// 可以在这里处理表单值变化
}}
>
{/* 任务名称 */}
<Form.Item
label="任务名称"
name="name"
rules={[
{ required: true, message: "请输入任务名称" },
{ min: 2, max: 50, message: "任务名称长度在2-50个字符之间" },
]}
>
<Input placeholder="请输入任务名称" />
</Form.Item>
{/* 允许建群的时间段 */}
<Form.Item label="允许建群的时间段">
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Form.Item
name="startTime"
noStyle
rules={[{ required: true, message: "请选择开始时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
<span style={{ color: "#888" }}></span>
<Form.Item
name="endTime"
noStyle
rules={[{ required: true, message: "请选择结束时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
</div>
</Form.Item>
{/* 每日最大建群数 */}
<Form.Item
label="每日最大建群数"
name="maxGroupsPerDay"
rules={[
{ required: true, message: "请输入每日最大建群数" },
{
validator: (_, value) => {
const numValue = Number(value);
if (value && (numValue < 1 || numValue > 100)) {
return Promise.reject(
new Error("每日最大建群数在1-100之间"),
);
}
return Promise.resolve();
},
},
]}
>
<InputNumber
min={1}
max={100}
placeholder="请输入最大建群数"
step={1}
style={{ width: "100%" }}
value={form.getFieldValue("maxGroupsPerDay")}
onChange={value => form.setFieldValue("maxGroupsPerDay", value)}
addonBefore={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("maxGroupsPerDay") || 1;
const newValue = Math.max(1, currentValue - 1);
form.setFieldValue("maxGroupsPerDay", newValue);
}}
>
-
</Button>
}
addonAfter={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("maxGroupsPerDay") || 1;
const newValue = Math.min(100, currentValue + 1);
form.setFieldValue("maxGroupsPerDay", newValue);
}}
>
+
</Button>
}
/>
</Form.Item>
{/* 群组最小人数 */}
<Form.Item
label="群组最小人数"
name="groupSizeMin"
rules={[
{ required: true, message: "请输入群组最小人数" },
{
validator: (_, value) => {
const numValue = Number(value);
if (value && (numValue < 1 || numValue > 500)) {
return Promise.reject(
new Error("群组最小人数在1-500之间"),
);
}
return Promise.resolve();
},
},
]}
>
<InputNumber
min={1}
max={500}
placeholder="请输入最小人数"
step={1}
style={{ width: "100%" }}
value={form.getFieldValue("groupSizeMin")}
onChange={value => form.setFieldValue("groupSizeMin", value)}
addonBefore={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("groupSizeMin") || 1;
const newValue = Math.max(1, currentValue - 1);
form.setFieldValue("groupSizeMin", newValue);
}}
>
-
</Button>
}
addonAfter={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("groupSizeMin") || 1;
const newValue = Math.min(500, currentValue + 1);
form.setFieldValue("groupSizeMin", newValue);
}}
>
+
</Button>
}
/>
</Form.Item>
{/* 群组最大人数 */}
<Form.Item
label="群组最大人数"
name="groupSizeMax"
rules={[
{ required: true, message: "请输入群组最大人数" },
{
validator: (_, value) => {
const numValue = Number(value);
if (value && (numValue < 1 || numValue > 500)) {
return Promise.reject(
new Error("群组最大人数在1-500之间"),
);
}
return Promise.resolve();
},
},
]}
>
<InputNumber
min={1}
max={500}
placeholder="请输入最大人数"
step={1}
style={{ width: "100%" }}
value={form.getFieldValue("groupSizeMax")}
onChange={value => form.setFieldValue("groupSizeMax", value)}
addonBefore={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("groupSizeMax") || 1;
const newValue = Math.max(1, currentValue - 1);
form.setFieldValue("groupSizeMax", newValue);
}}
>
-
</Button>
}
addonAfter={
<Button
type="text"
onClick={() => {
const currentValue =
form.getFieldValue("groupSizeMax") || 1;
const newValue = Math.min(500, currentValue + 1);
form.setFieldValue("groupSizeMax", newValue);
}}
>
+
</Button>
}
/>
</Form.Item>
{/* 群名称模板 */}
<Form.Item
label="群名称模板"
name="groupNameTemplate"
rules={[
{ required: true, message: "请输入群名称模板" },
{
min: 2,
max: 100,
message: "群名称模板长度在2-100个字符之间",
},
]}
>
<Input placeholder="请输入群名称模板" />
</Form.Item>
{/* 群描述 */}
<Form.Item
label="群描述"
name="groupDescription"
rules={[{ max: 200, message: "群描述不能超过200个字符" }]}
>
<TextArea
placeholder="请输入群描述"
rows={3}
maxLength={200}
showCount
/>
</Form.Item>
{/* 是否启用 */}
<Form.Item
label="是否启用"
name="status"
valuePropName="checked"
getValueFromEvent={checked => (checked ? 1 : 0)}
getValueProps={value => ({ checked: value === 1 })}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch />
</div>
</Form.Item>
</Form>
</Card>
</div>
);
},
);
BasicSettings.displayName = "BasicSettings";
export default BasicSettings;

View File

@@ -0,0 +1,95 @@
import React, { useImperativeHandle, forwardRef } from "react";
import { Form, Card } from "antd";
import DeviceSelection from "@/components/DeviceSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
interface DeviceSelectorProps {
selectedDevices: DeviceSelectionItem[];
onNext: (data: {
deveiceGroups: string[];
deveiceGroupsOptions: DeviceSelectionItem[];
}) => void;
}
export interface DeviceSelectorRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const DeviceSelector = forwardRef<DeviceSelectorRef, DeviceSelectorProps>(
({ selectedDevices, onNext }, ref) => {
const [form] = Form.useForm();
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
try {
await form.validateFields();
return true;
} catch (error) {
console.log("DeviceSelector 表单验证失败:", error);
return false;
}
},
getValues: () => {
return form.getFieldsValue();
},
}));
// 设备选择
const handleDeviceSelect = (
deveiceGroupsOptions: DeviceSelectionItem[],
) => {
const deveiceGroups = deveiceGroupsOptions.map(item => item.id);
form.setFieldValue("deveiceGroups", deveiceGroups);
// 通知父组件数据变化
onNext({
deveiceGroups: deveiceGroups.map(id => String(id)),
deveiceGroupsOptions,
});
};
return (
<Card>
<Form
form={form}
layout="vertical"
initialValues={{
deveiceGroups: selectedDevices.map(item => item.id),
}}
>
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
</h2>
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
</p>
</div>
<Form.Item
name="deveiceGroups"
rules={[
{
required: true,
type: "array",
min: 1,
message: "请选择至少一个设备组",
},
{ type: "array", max: 20, message: "最多只能选择20个设备组" },
]}
>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={handleDeviceSelect}
/>
</Form.Item>
</Form>
</Card>
);
},
);
DeviceSelector.displayName = "DeviceSelector";
export default DeviceSelector;

View File

@@ -0,0 +1,98 @@
import React, { useImperativeHandle, forwardRef } from "react";
import { Form, Card } from "antd";
import PoolSelection from "@/components/PoolSelection";
import {
PoolSelectionItem,
PoolPackageItem,
} from "@/components/PoolSelection/data";
interface PoolSelectorProps {
selectedPools: PoolSelectionItem[];
onNext: (data: {
poolGroups: string[];
poolGroupsOptions: PoolSelectionItem[];
}) => void;
}
export interface PoolSelectorRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const PoolSelector = forwardRef<PoolSelectorRef, PoolSelectorProps>(
({ selectedPools, onNext }, ref) => {
const [form] = Form.useForm();
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
try {
await form.validateFields();
return true;
} catch (error) {
console.log("PoolSelector 表单验证失败:", error);
return false;
}
},
getValues: () => {
return form.getFieldsValue();
},
}));
// 处理选择变化
const handlePoolChange = (poolGroupsOptions: PoolSelectionItem[]) => {
const poolGroups = poolGroupsOptions.map(c => c.id.toString());
form.setFieldValue("poolGroups", poolGroups);
onNext({
poolGroups,
poolGroupsOptions,
});
};
// 处理详细选择数据
const handleSelectDetail = (poolPackages: PoolPackageItem[]) => {
// 如果需要处理原始流量池包数据,可以在这里添加逻辑
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<Form
form={form}
layout="vertical"
initialValues={{
poolGroups: selectedPools.map(c => c.id.toString()),
}}
>
<div style={{ marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
</h2>
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
</p>
</div>
<Form.Item
name="poolGroups"
rules={[
{ required: true, message: "请选择至少一个流量池包" },
{ type: "array", min: 1, message: "请选择至少一个流量池包" },
{ type: "array", max: 20, message: "最多只能选择20个流量池包" },
]}
>
<PoolSelection
selectedOptions={selectedPools}
onSelect={handlePoolChange}
/>
</Form.Item>
</Form>
</Card>
</div>
);
},
);
PoolSelector.displayName = "PoolSelector";
export default PoolSelector;

View File

@@ -1,21 +1,32 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Form, Toast, TextArea } from "antd-mobile";
import { Input, InputNumber, Button, Switch } from "antd";
import { Toast } from "antd-mobile";
import { Button } from "antd";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
import { AutoGroupFormData } from "./types";
import DeviceSelection from "@/components/DeviceSelection/index";
import { AutoGroupFormData, StepItem } from "./types";
import StepIndicator from "@/components/StepIndicator";
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
import DeviceSelector, { DeviceSelectorRef } from "./components/DeviceSelector";
import PoolSelector, { PoolSelectorRef } from "./components/PoolSelector";
import NavCommon from "@/components/NavCommon/index";
import dayjs from "dayjs";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
const steps: StepItem[] = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
];
const defaultForm: AutoGroupFormData = {
name: "",
type: 4,
deveiceGroups: [], // 设备组
deveiceGroupsOptions: [], // 设备组选项
poolGroups: [], // 内容库
poolGroupsOptions: [], // 内容库选项
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
groupSizeMin: 20, // 群组最小人数
@@ -30,17 +41,33 @@ const AutoGroupForm: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const [form, setForm] = useState<AutoGroupFormData>(defaultForm);
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
DeviceSelectionItem[]
>([]);
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
PoolSelectionItem[]
>([]);
// 创建子组件的ref
const basicSettingsRef = useRef<BasicSettingsRef>(null);
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
const poolSelectorRef = useRef<PoolSelectorRef>(null);
useEffect(() => {
if (!id) return;
// 这里应请求详情接口回填表单演示用mock
getAutoGroupDetail(id).then(res => {
setForm({
const updatedForm = {
...defaultForm,
name: res.name,
deveiceGroups: res.config.deveiceGroups || [],
deveiceGroupsOptions: res.config.deveiceGroupsOptions || [],
poolGroups: res.config.poolGroups || [],
poolGroupsOptions: res.config.poolGroupsOptions || [],
startTime: res.config.startTime,
endTime: res.config.endTime,
groupSizeMin: res.config.groupSizeMin,
@@ -51,19 +78,66 @@ const AutoGroupForm: React.FC = () => {
status: res.status,
type: res.type,
id: res.id,
});
console.log(form);
};
setFormData(updatedForm);
setDeviceGroupsOptions(res.config.deveiceGroupsOptions || []);
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
setDataLoaded(true); // 标记数据已加载
});
}, [id]);
const handleSubmit = async () => {
const handleBasicSettingsChange = (values: Partial<AutoGroupFormData>) => {
setFormData(prev => ({ ...prev, ...values }));
};
// 设备组选择
const handleDevicesChange = (data: {
deveiceGroups: string[];
deveiceGroupsOptions: DeviceSelectionItem[];
}) => {
setFormData(prev => ({
...prev,
deveiceGroups: data.deveiceGroups,
}));
setDeviceGroupsOptions(data.deveiceGroupsOptions);
};
// 流量池包选择
const handlePoolsChange = (data: {
poolGroups: string[];
poolGroupsOptions: PoolSelectionItem[];
}) => {
setFormData(prev => ({ ...prev, poolGroups: data.poolGroups }));
setpoolGroupsOptions(data.poolGroupsOptions);
};
const handleSave = async () => {
if (!formData.name.trim()) {
Toast.show({ content: "请输入任务名称" });
return;
}
if (formData.deveiceGroups.length === 0) {
Toast.show({ content: "请选择至少一个设备组" });
return;
}
if (formData.poolGroups.length === 0) {
Toast.show({ content: "请选择至少一个流量池包" });
return;
}
setLoading(true);
try {
const submitData = {
...formData,
deveiceGroupsOptions: deviceGroupsOptions,
poolGroupsOptions: poolGroupsOptions,
};
if (isEdit) {
await updateAutoGroup(form);
await updateAutoGroup(submitData);
Toast.show({ content: "编辑成功" });
} else {
await createAutoGroup(form);
await createAutoGroup(submitData);
Toast.show({ content: "创建成功" });
}
navigate("/workspace/auto-group");
@@ -74,227 +148,134 @@ const AutoGroupForm: React.FC = () => {
}
};
const setTaskName = (val: string) => {
setForm((f: any) => ({ ...f, name: val }));
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const setDeviceGroups = (val: DeviceSelectionItem[]) => {
console.log(val);
setForm((f: any) => ({
...f,
deveiceGroups: val.map(item => item.id),
deveiceGroupsOptions: val,
}));
const handleNext = async () => {
if (currentStep < 3) {
try {
let isValid = false;
switch (currentStep) {
case 1:
// 调用 BasicSettings 的表单校验
isValid = (await basicSettingsRef.current?.validate()) || false;
if (isValid) {
const values = basicSettingsRef.current?.getValues();
if (values) {
handleBasicSettingsChange(values);
}
setCurrentStep(2);
}
break;
case 2:
// 调用 DeviceSelector 的表单校验
isValid = (await deviceSelectorRef.current?.validate()) || false;
if (isValid) {
setCurrentStep(3);
}
break;
default:
setCurrentStep(currentStep + 1);
}
} catch (error) {
console.log("表单验证失败:", error);
}
}
};
const renderCurrentStep = () => {
// 编辑模式下,等待数据加载完成后再渲染
if (isEdit && !dataLoaded) {
return (
<div style={{ textAlign: "center", padding: "50px" }}>...</div>
);
}
switch (currentStep) {
case 1:
return (
<BasicSettings
ref={basicSettingsRef}
initialValues={{
name: formData.name,
startTime: formData.startTime,
endTime: formData.endTime,
groupSizeMin: formData.groupSizeMin,
groupSizeMax: formData.groupSizeMax,
maxGroupsPerDay: formData.maxGroupsPerDay,
groupNameTemplate: formData.groupNameTemplate,
groupDescription: formData.groupDescription,
status: formData.status,
}}
/>
);
case 2:
return (
<DeviceSelector
ref={deviceSelectorRef}
selectedDevices={deviceGroupsOptions}
onNext={handleDevicesChange}
/>
);
case 3:
return (
<PoolSelector
ref={poolSelectorRef}
selectedPools={poolGroupsOptions}
onNext={handlePoolsChange}
/>
);
default:
return null;
}
};
const renderFooter = () => {
return (
<div className="footer-btn-group">
{currentStep > 1 && (
<Button size="large" onClick={handlePrevious}>
</Button>
)}
{currentStep === 3 ? (
<Button
size="large"
type="primary"
loading={loading}
onClick={handleSave}
>
{isEdit ? "保存修改" : "创建任务"}
</Button>
) : (
<Button size="large" type="primary" onClick={handleNext}>
</Button>
)}
</div>
);
};
return (
<Layout
header={
<NavCommon
title={isEdit ? "编辑建群任务" : "新建建群任务"}
backFn={() => navigate(-1)}
/>
<>
<NavCommon
title={isEdit ? "编辑建群任务" : "新建建群任务"}
backFn={() => navigate(-1)}
/>
<StepIndicator currentStep={currentStep} steps={steps} />
</>
}
footer={renderFooter()}
>
<div className={style.autoGroupForm}>
<Form
layout="vertical"
footer={
<Button
block
type="primary"
loading={loading}
onClick={handleSubmit}
>
{isEdit ? "保存修改" : "创建任务"}
</Button>
}
>
<Form.Item label="任务名称" required>
<Input
value={form.name}
onChange={val => setTaskName(val.target.value)}
placeholder="请输入任务名称"
/>
</Form.Item>
<Form.Item label="设备组" required>
<DeviceSelection
selectedOptions={form.deveiceGroupsOptions}
onSelect={setDeviceGroups}
/>
</Form.Item>
<Form.Item label="每日最大建群数" name="maxGroupsPerDay" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.maxGroupsPerDay || 1) - 1);
setForm((f: any) => ({ ...f, maxGroupsPerDay: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={100}
value={form.maxGroupsPerDay}
onChange={val =>
setForm((f: any) => ({ ...f, maxGroupsPerDay: val || 1 }))
}
placeholder="请输入最大建群数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(
100,
(form.maxGroupsPerDay || 1) + 1,
);
setForm((f: any) => ({ ...f, maxGroupsPerDay: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="开始时间" required>
<Input
type="time"
style={{ width: 120 }}
value={form.startTime || ""}
onChange={e => {
setForm((f: any) => ({ ...f, startTime: e.target.value }));
}}
/>
</Form.Item>
<Form.Item label="结束时间" required>
<Input
type="time"
style={{ width: 120 }}
value={form.endTime || ""}
onChange={e => {
setForm((f: any) => ({ ...f, endTime: e.target.value }));
}}
/>
</Form.Item>
<Form.Item label="群组最小人数" name="groupSizeMin" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.groupSizeMin || 1) - 1);
setForm((f: any) => ({ ...f, groupSizeMin: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={500}
value={form.groupSizeMin}
onChange={val => {
const newValue = val || 1;
setForm((f: any) => ({
...f,
groupSizeMin: Math.min(newValue, f.groupSizeMax),
}));
}}
placeholder="请输入最小人数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(500, (form.groupSizeMin || 1) + 1);
setForm((f: any) => ({ ...f, groupSizeMin: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="群组最大人数" name="groupSizeMax" required>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.max(1, (form.groupSizeMax || 1) - 1);
setForm((f: any) => ({ ...f, groupSizeMax: newValue }));
}}
>
-
</Button>
<InputNumber
min={1}
max={500}
value={form.groupSizeMax}
onChange={val => {
const newValue = val || 1;
setForm((f: any) => ({
...f,
groupSizeMax: Math.max(newValue, f.groupSizeMin),
}));
}}
placeholder="请输入最大人数"
step={1}
style={{ flex: 1 }}
/>
<Button
htmlType="button"
onClick={() => {
const newValue = Math.min(500, (form.groupSizeMax || 1) + 1);
setForm((f: any) => ({ ...f, groupSizeMax: newValue }));
}}
>
+
</Button>
</div>
</Form.Item>
<Form.Item label="群名称模板" required>
<Input
value={form.groupNameTemplate}
onChange={val =>
setForm((f: any) => ({
...f,
groupNameTemplate: val.target.value,
}))
}
placeholder="请输入群名称模板"
/>
</Form.Item>
<Form.Item label="群描述" name="groupDescription">
<TextArea
value={form.groupDescription}
onChange={val =>
setForm((f: any) => ({ ...f, groupDescription: val }))
}
placeholder="请输入群描述"
rows={3}
maxLength={100}
showCount
/>
</Form.Item>
<Form.Item label="是否开启" name="status">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
checked={form.status === 1}
onChange={checked =>
setForm((f: any) => ({ ...f, status: checked ? 1 : 0 }))
}
/>
</div>
</Form.Item>
</Form>
</div>
<div style={{ padding: 12 }}>{renderCurrentStep()}</div>
</Layout>
);
};

View File

@@ -1,4 +1,6 @@
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
// 自动建群表单数据类型定义
export interface AutoGroupFormData {
id?: string; // 任务ID
@@ -6,6 +8,8 @@ export interface AutoGroupFormData {
name: string; // 任务名称
deveiceGroups: string[]; // 设备组
deveiceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
poolGroups: string[]; // 流量池
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
endTime: string; // 结束时间 (YYYY-MM-DD HH:mm:ss)
groupSizeMin: number; // 群组最小人数
@@ -17,6 +21,13 @@ export interface AutoGroupFormData {
[key: string]: any;
}
// 步骤定义
export interface StepItem {
id: number;
title: string;
subtitle: string;
}
// 表单验证规则
export const formValidationRules = {
name: [
@@ -27,6 +38,10 @@ export const formValidationRules = {
{ required: true, message: "请选择设备组" },
{ type: "array", min: 1, message: "至少选择一个设备组" },
],
poolGroups: [
{ required: true, message: "请选择内容库" },
{ type: "array", min: 1, message: "至少选择一个内容库" },
],
startTime: [{ required: true, message: "请选择开始时间" }],
endTime: [{ required: true, message: "请选择结束时间" }],
groupSizeMin: [

View File

@@ -9,3 +9,8 @@ export const getAutoGroupList = (params: any) =>
export function copyAutoGroupTask(id: string): Promise<any> {
return request("/v1/workbench/copy", { id }, "POST");
}
// 删除自动建群任务
export function deleteAutoGroupTask(id: string): Promise<any> {
return request("/v1/workbench/delete", { id }, "DELETE");
}

View File

@@ -14,7 +14,12 @@ import {
PlusOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { getAutoGroupList, copyAutoGroupTask } from "./api";
import {
getAutoGroupList,
copyAutoGroupTask,
deleteAutoGroupTask,
} from "./api";
import { comfirm } from "@/utils/common";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
@@ -110,14 +115,27 @@ const AutoGroupList: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDelete = (taskId: string) => {
const handleDelete = async (taskId: string) => {
const taskToDelete = tasks.find(task => task.id === taskId);
if (!taskToDelete) return;
window.confirm(`确定要删除"${taskToDelete.name}"吗?`) &&
setTasks(tasks.filter(task => task.id !== taskId));
Toast.show({ content: "删除成功" });
};
try {
await comfirm("确定要删除吗?", {
title: "删除确认",
cancelText: "取消",
confirmText: "删除",
});
await deleteAutoGroupTask(taskId);
setTasks(tasks.filter(task => task.id !== taskId));
Toast.show({ content: "删除成功" });
} catch (error) {
// 用户取消删除或删除失败
if (error !== "cancel") {
Toast.show({ content: "删除失败" });
}
}
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/auto-group/${taskId}/edit`);
};

View File

@@ -35,8 +35,8 @@ export function deleteAutoLikeTask(id: string): Promise<any> {
}
// 切换任务状态
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
return request("/v1/workbench/update-status", { id, status }, "POST");
export function toggleAutoLikeTask(data): Promise<any> {
return request("/v1/workbench/update-status", { ...data, type: 1 }, "POST");
}
// 复制自动点赞任务

View File

@@ -79,9 +79,9 @@ export interface CreateLikeTaskData {
startTime: string;
endTime: string;
contentTypes: ContentType[];
deveiceGroups: string[];
deveiceGroups: number[];
deveiceGroupsOptions: DeviceSelectionItem[];
friendsGroups: string[];
friendsGroups: number[];
friendsGroupsOptions: FriendSelectionItem[];
friendMaxLikes: number;
friendTags?: string;

View File

@@ -72,7 +72,7 @@ const NewAutoLike: React.FC = () => {
startTime: config.timeRange?.start || config.startTime || "08:00",
endTime: config.timeRange?.end || config.endTime || "22:00",
contentTypes: config.contentTypes || ["text", "image", "video"],
deveiceGroups: config.deveicegroups || [],
deveiceGroups: config.deveiceGroups || [],
deveiceGroupsOptions: config.deveiceGroupsOptions || [],
friendsGroups: config.friendsgroups || [],
friendsGroupsOptions: config.friendsGroupsOptions || [],
@@ -121,7 +121,7 @@ const NewAutoLike: React.FC = () => {
message.warning("请输入任务名称");
return;
}
if (!formData.deveicegroups || formData.deveicegroups.length === 0) {
if (!formData.deveiceGroups || formData.deveiceGroups.length === 0) {
message.warning("请选择执行设备");
return;
}
@@ -329,8 +329,13 @@ const NewAutoLike: React.FC = () => {
<div className={style.basicSection}>
<div className={style.formItem}>
<DeviceSelection
selectedOptions={formData.deveicegroups}
onSelect={devices => handleUpdateFormData({ devices })}
selectedOptions={formData.deveiceGroupsOptions}
onSelect={devices =>
handleUpdateFormData({
deveiceGroups: devices.map(v => v.id),
deveiceGroupsOptions: devices,
})
}
showInput={true}
showSelectedList={true}
/>
@@ -348,7 +353,7 @@ const NewAutoLike: React.FC = () => {
onClick={handleNext}
className={style.nextBtn}
size="large"
disabled={formData.deveicegroups.length === 0}
disabled={formData.deveiceGroups.length === 0}
>
</Button>
@@ -363,7 +368,7 @@ const NewAutoLike: React.FC = () => {
selectedOptions={formData.friendsGroupsOptions || []}
onSelect={friends =>
handleUpdateFormData({
friendsGroups: friends.map(f => String(f.id)),
friendsGroups: friends.map(f => f.id),
friendsGroupsOptions: friends,
})
}
@@ -385,7 +390,7 @@ const NewAutoLike: React.FC = () => {
size="large"
loading={isSubmitting}
disabled={
!formData.friendsgroups || formData.friendsgroups.length === 0
!formData.friendsGroups || formData.friendsGroups.length === 0
}
>
{isEditMode ? "更新任务" : "创建任务"}

View File

@@ -12,8 +12,9 @@ export interface GroupPushTask {
createTime: string;
creator: string;
pushInterval: number;
maxPushPerDay: number;
timeRange: { start: string; end: string };
maxPerDay: number;
startTime: string; // 允许推送的开始时间
endTime: string; // 允许推送的结束时间
messageType: "text" | "image" | "video" | "link";
messageContent: string;
targetTags: string[];

View File

@@ -179,7 +179,7 @@ const Detail: React.FC = () => {
<div>
<SettingOutlined /> <b></b>
<div>{task.pushInterval} </div>
<div>{task.maxPushPerDay} </div>
<div>{task.maxPerDay} </div>
<div>
{task.timeRange.start} - {task.timeRange.end}
</div>
@@ -221,12 +221,10 @@ const Detail: React.FC = () => {
<div>
<CalendarOutlined /> <b></b>
<div>
{task.pushCount} / {task.maxPushPerDay}
{task.pushCount} / {task.maxPerDay}
</div>
<Progress
percent={Math.round(
(task.pushCount / task.maxPushPerDay) * 100,
)}
percent={Math.round((task.pushCount / task.maxPerDay) * 100)}
size="small"
/>
{task.targetTags.length > 0 && (

View File

@@ -1,16 +1,33 @@
import React, { useImperativeHandle, forwardRef } from "react";
import { Input, Button, Card, Switch, Form, InputNumber } from "antd";
import React, {
useImperativeHandle,
forwardRef,
useState,
useEffect,
} from "react";
import {
Input,
Button,
Card,
Switch,
Form,
InputNumber,
Select,
Radio,
} from "antd";
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
interface BasicSettingsProps {
defaultValues?: {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
startTime: string; // 允许推送的开始时间
endTime: string; // 允许推送的结束时间
maxPerDay: number;
pushOrder: number; // 1: 按最早, 2: 按最新
isLoop: number; // 0: 否, 1: 是
pushType: number; // 0: 定时推送, 1: 立即推送
status: number; // 0: 否, 1: 是
socialMediaId?: string;
promotionSiteId?: string;
};
onNext: (values: any) => void;
onSave: (values: any) => void;
@@ -27,18 +44,61 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
{
defaultValues = {
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
startTime: "06:00", // 允许推送的开始时间
endTime: "23:59", // 允许推送的结束时间
maxPerDay: 20,
pushOrder: 1,
isLoop: 0, // 0: 否, 1: 是
pushType: 0, // 0: 定时推送, 1: 立即推送
status: 0, // 0: 否, 1: 是
socialMediaId: undefined,
promotionSiteId: undefined,
},
},
ref,
) => {
const [form] = Form.useForm();
const [, forceUpdate] = useState({});
const [socialMediaList, setSocialMediaList] = useState([]);
const [promotionSiteList, setPromotionSiteList] = useState([]);
const [loadingSocialMedia, setLoadingSocialMedia] = useState(false);
const [loadingPromotionSite, setLoadingPromotionSite] = useState(false);
// 确保组件初始化时能正确显示按钮状态
useEffect(() => {
forceUpdate({});
}, []);
// 组件挂载时获取社交媒体列表
useEffect(() => {
setLoadingSocialMedia(true);
fetchSocialMediaList()
.then(res => {
setSocialMediaList(res);
})
.finally(() => {
setLoadingSocialMedia(false);
});
}, []);
// 监听社交媒体选择变化
const handleSocialMediaChange = value => {
form.setFieldsValue({ socialMediaId: value });
// 清空推广站点选择
form.setFieldsValue({ promotionSiteId: undefined });
setPromotionSiteList([]);
if (value) {
setLoadingPromotionSite(true);
fetchPromotionSiteList(value)
.then(res => {
setPromotionSiteList(res);
})
.finally(() => {
setLoadingPromotionSite(false);
});
}
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
@@ -55,7 +115,10 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
return form.getFieldsValue();
},
}));
const handlePushOrderChange = (value: number) => {
form.setFieldsValue({ pushOrder: value });
forceUpdate({}); // 强制组件重新渲染
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
@@ -64,7 +127,10 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
layout="vertical"
initialValues={defaultValues}
onValuesChange={(changedValues, allValues) => {
// 可以在这里处理表单值变化
// 当pushOrder值变化时强制更新组件
if ("pushOrder" in changedValues) {
forceUpdate({});
}
}}
>
{/* 任务名称 */}
@@ -78,32 +144,56 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
>
<Input placeholder="请输入任务名称" />
</Form.Item>
{/* 允许推送的时间段 */}
<Form.Item label="允许推送的时间段">
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Form.Item
name="pushTimeStart"
noStyle
rules={[{ required: true, message: "请选择开始时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
<span style={{ color: "#888" }}></span>
<Form.Item
name="pushTimeEnd"
noStyle
rules={[{ required: true, message: "请选择结束时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
</div>
{/* 推送类型 */}
<Form.Item
label="推送类型"
name="pushType"
rules={[{ required: true, message: "请选择推送类型" }]}
>
<Radio.Group>
<Radio value={0}></Radio>
<Radio value={1}></Radio>
</Radio.Group>
</Form.Item>
{/* 允许推送的时间段 - 只在定时推送时显示 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.pushType !== currentValues.pushType
}
>
{({ getFieldValue }) => {
// 只在pushType为0定时推送时显示时间段设置
return getFieldValue("pushType") === 0 ? (
<Form.Item label="允许推送的时间段">
<div
style={{ display: "flex", gap: 8, alignItems: "center" }}
>
<Form.Item
name="startTime"
noStyle
rules={[{ required: true, message: "请选择开始时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
<span style={{ color: "#888" }}></span>
<Form.Item
name="endTime"
noStyle
rules={[{ required: true, message: "请选择结束时间" }]}
>
<Input type="time" style={{ width: 120 }} />
</Form.Item>
</div>
</Form.Item>
) : null;
}}
</Form.Item>
{/* 每日推送 */}
<Form.Item
label="每日推送"
name="dailyPushCount"
name="maxPerDay"
rules={[
{ required: true, message: "请输入每日推送数量" },
{
@@ -130,46 +220,64 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
>
<div style={{ display: "flex" }}>
<Button
type="default"
style={{ borderRadius: "6px 0 0 6px" }}
onClick={() => form.setFieldValue("pushOrder", "earliest")}
className={
form.getFieldValue("pushOrder") === "earliest"
? "ant-btn-primary"
: ""
type={
form.getFieldValue("pushOrder") == 1 ? "primary" : "default"
}
style={{ borderRadius: "6px 0 0 6px" }}
onClick={() => handlePushOrderChange(1)}
>
</Button>
<Button
type="default"
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
onClick={() => form.setFieldValue("pushOrder", "latest")}
className={
form.getFieldValue("pushOrder") === "latest"
? "ant-btn-primary"
: ""
type={
form.getFieldValue("pushOrder") == 2 ? "primary" : "default"
}
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
onClick={() => handlePushOrderChange(2)}
>
</Button>
</div>
</Form.Item>
{/* 京东联盟 */}
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
<Form.Item name="socialMediaId" noStyle>
<Select
placeholder="请选择社交媒体"
style={{ width: 200 }}
loading={loadingSocialMedia}
onChange={handleSocialMediaChange}
options={socialMediaList.map(item => ({
label: item.name,
value: item.id,
}))}
/>
</Form.Item>
<Form.Item name="promotionSiteId" noStyle>
<Select
placeholder="请选择推广站点"
style={{ width: 200 }}
loading={loadingPromotionSite}
disabled={!form.getFieldValue("socialMediaId")}
options={promotionSiteList.map(item => ({
label: item.name,
value: item.id,
}))}
/>
</Form.Item>
</div>
</Form.Item>
{/* 是否循环推送 */}
<Form.Item
label="是否循环推送"
name="isLoopPush"
valuePropName="checked"
>
<Switch />
</Form.Item>
{/* 是否立即推送 */}
<Form.Item
label="是否立即推送"
name="isImmediatePush"
name="isLoop"
valuePropName="checked"
getValueFromEvent={checked => (checked ? 1 : 0)}
getValueProps={value => ({ checked: value === 1 })}
>
<Switch />
</Form.Item>
@@ -177,30 +285,40 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
{/* 是否启用 */}
<Form.Item
label="是否启用"
name="isEnabled"
name="status"
valuePropName="checked"
getValueFromEvent={checked => (checked ? 1 : 0)}
getValueProps={value => ({ checked: value === 1 })}
>
<Switch />
</Form.Item>
{/* 立即推送提示 */}
<Form.Item noStyle shouldUpdate>
{() => {
const isImmediatePush = form.getFieldValue("isImmediatePush");
return isImmediatePush ? (
<div
style={{
background: "#fffbe6",
border: "1px solid #ffe58f",
borderRadius: 4,
padding: 8,
color: "#ad8b00",
marginBottom: 16,
}}
>
</div>
) : null;
{/* 推送类型提示 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.pushType !== currentValues.pushType
}
>
{({ getFieldValue }) => {
const pushType = getFieldValue("pushType");
if (pushType === 1) {
return (
<div
style={{
background: "#fffbe6",
border: "1px solid #ffe58f",
borderRadius: 4,
padding: 8,
color: "#ad8b00",
marginBottom: 16,
}}
>
</div>
);
}
return null;
}}
</Form.Item>
</Form>

View File

@@ -1,148 +0,0 @@
import React, {
useState,
useEffect,
useImperativeHandle,
forwardRef,
} from "react";
import { Form, Select, Card } from "antd";
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
// 京东社交媒体接口
interface JdSocialMedia {
id: string;
name: string;
[key: string]: any;
}
// 京东推广站点接口
interface JdPromotionSite {
id: string;
name: string;
[key: string]: any;
}
interface JingDongLinkProps {
defaultValues?: {
socialMediaId?: string;
promotionSiteId?: string;
};
onNext?: (values: any) => void;
onSave?: (values: any) => void;
loading?: boolean;
}
export interface JingDongLinkRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const JingDongLink = forwardRef<JingDongLinkRef, JingDongLinkProps>(
(
{
defaultValues = {
socialMediaId: undefined,
promotionSiteId: undefined,
},
onNext,
onSave,
loading = false,
},
ref,
) => {
const [form] = Form.useForm();
const [socialMediaList, setSocialMediaList] = useState<JdSocialMedia[]>([]);
const [promotionSiteList, setPromotionSiteList] = useState<
JdPromotionSite[]
>([]);
const [loadingSocialMedia, setLoadingSocialMedia] = useState(false);
const [loadingPromotionSite, setLoadingPromotionSite] = useState(false);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
try {
await form.validateFields();
return true;
} catch (error) {
console.log("JingDongLink 表单验证失败:", error);
return false;
}
},
getValues: () => {
return form.getFieldsValue();
},
}));
// 组件挂载时获取社交媒体列表
useEffect(() => {
fetchSocialMediaList().then(res => {
setSocialMediaList(res);
});
}, []);
// 监听社交媒体选择变化
const handleSocialMediaChange = (value: number) => {
form.setFieldsValue({ socialMediaId: value });
// 清空推广站点选择
form.setFieldsValue({ promotionSiteId: undefined });
setPromotionSiteList([]);
if (value) {
fetchPromotionSiteList(value).then(res => {
setPromotionSiteList(res);
});
}
};
return (
<div style={{ marginBottom: 24 }}>
<Card title="京东联盟">
<Form form={form} layout="vertical" initialValues={defaultValues}>
{/* 京东社交媒体选择 */}
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
<Form.Item
name="socialMediaId"
noStyle
rules={[{ required: true, message: "请选择社交媒体" }]}
>
<Select
placeholder="请选择社交媒体"
style={{ width: 200 }}
loading={loadingSocialMedia}
onChange={handleSocialMediaChange}
options={socialMediaList.map(item => ({
label: item.name,
value: item.id,
}))}
/>
</Form.Item>
<Form.Item
name="promotionSiteId"
noStyle
rules={[{ required: true, message: "请选择推广站点" }]}
>
<Select
placeholder="请选择推广站点"
style={{ width: 200 }}
loading={loadingPromotionSite}
disabled={!form.getFieldValue("socialMediaId")}
options={promotionSiteList.map(item => ({
label: item.name,
value: item.id,
}))}
/>
</Form.Item>
</div>
</Form.Item>
</Form>
</Card>
</div>
);
},
);
JingDongLink.displayName = "JingDongLink";
export default JingDongLink;

View File

@@ -1,8 +1,12 @@
import request from "@/api/request";
export function createGroupPushTask(taskData) {
return request("/v1/workspace/group-push/tasks", taskData, "POST");
export function createGroupPushTask(data) {
return request("/v1/workbench/create", { ...data, type: 3 }, "POST");
}
export function updateGroupPushTask(data) {
return request("/v1/workbench/update", { ...data, type: 3 }, "POST");
}
// 获取京东社交媒体列表
export const fetchSocialMediaList = async () => {
return request("/v1/workbench/getJdSocialMedia", {}, "GET");

View File

@@ -21,13 +21,13 @@ export interface ContentLibrary {
export interface FormData {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
startTime: string; // 允许推送的开始时间
endTime: string; // 允许推送的结束时间
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
pushOrder: number; // 1: 按最早, 2: 按最新
isLoop: number; // 0: 否, 1: 是
pushType: number; // 0: 定时推送, 1: 立即推送
status: number; // 0: 否, 1: 是
contentGroups: string[];
wechatGroups: string[];
// 京东联盟相关字段

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "antd";
import { Toast } from "antd-mobile";
import { createGroupPushTask } from "./index.api";
import Layout from "@/components/Layout/Layout";
import StepIndicator from "@/components/StepIndicator";
@@ -9,7 +10,6 @@ import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
import ContentSelector, {
ContentSelectorRef,
} from "./components/ContentSelector";
import JingDongLink, { JingDongLinkRef } from "./components/JingDongLink";
import type { FormData } from "./index.data";
import NavCommon from "@/components/NavCommon";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
@@ -18,7 +18,6 @@ const steps = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
{ id: 4, title: "步骤 4", subtitle: "京东联盟" },
];
const NewGroupPush: React.FC = () => {
@@ -35,13 +34,13 @@ const NewGroupPush: React.FC = () => {
const [formData, setFormData] = useState<FormData>({
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
startTime: "06:00", // 允许推送的开始时间
endTime: "23:59", // 允许推送的结束时间
maxPerDay: 20,
pushOrder: 2, // 2: 按最新
isLoop: 0, // 0: 否, 1: 是
pushType: 0, // 0: 定时推送, 1: 立即推送
status: 0, // 0: 否, 1: 是
wechatGroups: [],
contentGroups: [],
});
@@ -51,7 +50,6 @@ const NewGroupPush: React.FC = () => {
const basicSettingsRef = useRef<BasicSettingsRef>(null);
const groupSelectorRef = useRef<GroupSelectorRef>(null);
const contentSelectorRef = useRef<ContentSelectorRef>(null);
const jingDongLinkRef = useRef<JingDongLinkRef>(null);
useEffect(() => {
if (!id) return;
@@ -83,57 +81,61 @@ const NewGroupPush: React.FC = () => {
};
const handleSave = async () => {
if (!formData.name.trim()) {
window.alert("请输入任务名称");
return;
}
if (formData.wechatGroups.length === 0) {
window.alert("请选择至少一个社群");
return;
}
if (formData.contentGroups.length === 0) {
window.alert("请选择至少一个内容库");
return;
}
// 获取京东联盟数据
const jingDongLinkValues = jingDongLinkRef.current?.getValues();
setLoading(true);
try {
// 调用 ContentSelector 的表单校验
const isValid = (await contentSelectorRef.current?.validate()) || false;
if (!isValid) return;
setLoading(true);
// 获取基础设置中的京东联盟数据
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
// 构建 API 请求数据
const apiData = {
name: formData.name,
timeRange: {
start: formData.pushTimeStart,
end: formData.pushTimeEnd,
},
maxPushPerDay: formData.dailyPushCount,
startTime: formData.startTime, // 允许推送的开始时间
endTime: formData.endTime, // 允许推送的结束时间
maxPerDay: formData.maxPerDay,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
isLoop: formData.isLoop, // 0: 否, 1: 是
pushType: formData.pushType, // 0: 定时推送, 1: 立即推送
status: formData.status, // 0: 否, 1: 是
wechatGroups: formData.wechatGroups,
contentGroups: formData.contentGroups,
// 京东联盟数据
socialMediaId: jingDongLinkValues?.socialMediaId,
promotionSiteId: jingDongLinkValues?.promotionSiteId,
pushMode: formData.isImmediatePush
? ("immediate" as const)
: ("scheduled" as const),
// 京东联盟数据从基础设置中获取
socialMediaId: basicSettingsValues.socialMediaId,
promotionSiteId: basicSettingsValues.promotionSiteId,
pushMode:
formData.pushType === 1
? ("immediate" as const)
: ("scheduled" as const),
messageType: "text" as const,
messageContent: "",
targetTags: [],
pushInterval: 60,
};
const response = await createGroupPushTask(apiData);
if (response.code === 200) {
window.alert("保存成功");
// 打印API请求数据用于调试
console.log("发送到API的数据:", apiData);
// 调用创建或更新 API
if (id) {
// 更新逻辑将在这里实现
Toast.show({ content: "更新成功", position: "top" });
navigate("/workspace/group-push");
} else {
window.alert("保存失败,请稍后重试");
createGroupPushTask(apiData)
.then(() => {
Toast.show({ content: "创建成功", position: "top" });
navigate("/workspace/group-push");
})
.catch(() => {
Toast.show({ content: "创建失败,请稍后重试", position: "top" });
});
}
} catch (error) {
window.alert("保存失败,请稍后重试");
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
} finally {
setLoading(false);
}
@@ -146,7 +148,7 @@ const NewGroupPush: React.FC = () => {
};
const handleNext = async () => {
if (currentStep < 4) {
if (currentStep < 3) {
try {
let isValid = false;
@@ -171,14 +173,6 @@ const NewGroupPush: React.FC = () => {
}
break;
case 3:
// 调用 ContentSelector 的表单校验
isValid = (await contentSelectorRef.current?.validate()) || false;
if (isValid) {
setCurrentStep(4);
}
break;
default:
setCurrentStep(currentStep + 1);
}
@@ -196,7 +190,7 @@ const NewGroupPush: React.FC = () => {
</Button>
)}
{currentStep === 4 ? (
{currentStep === 3 ? (
<Button size="large" type="primary" onClick={handleSave}>
</Button>
@@ -224,13 +218,13 @@ const NewGroupPush: React.FC = () => {
ref={basicSettingsRef}
defaultValues={{
name: formData.name,
pushTimeStart: formData.pushTimeStart,
pushTimeEnd: formData.pushTimeEnd,
dailyPushCount: formData.dailyPushCount,
startTime: formData.startTime,
endTime: formData.endTime,
maxPerDay: formData.maxPerDay,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
isLoop: formData.isLoop,
status: formData.status,
pushType: formData.pushType,
}}
onNext={handleBasicSettingsChange}
onSave={handleSave}
@@ -253,9 +247,6 @@ const NewGroupPush: React.FC = () => {
onNext={handleLibrariesChange}
/>
)}
{currentStep === 4 && (
<JingDongLink ref={jingDongLinkRef} loading={loading} />
)}
</div>
</div>
</Layout>

View File

@@ -1,4 +1,5 @@
import request from "@/api/request";
import { GroupPushTask } from "../detail/groupPush";
interface ApiResponse<T = any> {
code: number;
@@ -11,22 +12,16 @@ export async function fetchGroupPushTasks() {
}
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}`, {}, "DELETE");
return request("/v1/workbench/delete", { id }, "DELETE");
}
export async function toggleGroupPushTask(
id: string,
status: string,
): Promise<ApiResponse> {
return request(
`/v1/workspace/group-push/tasks/${id}/toggle`,
{ status },
"POST",
);
// 切换任务状态
export function toggleGroupPushTask(data): Promise<any> {
return request("/v1/workbench/update-status", { ...data, type: 3 }, "POST");
}
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}/copy`, {}, "POST");
return request("/v1/workbench/copy", { id }, "POST");
}
export async function createGroupPushTask(

View File

@@ -133,7 +133,7 @@ const GroupPush: React.FC = () => {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
const newStatus = task.status === 1 ? 2 : 1;
await toggleGroupPushTask(taskId, String(newStatus));
await toggleGroupPushTask({ id: taskId, status: newStatus });
fetchTasks();
};

View File

@@ -28,7 +28,15 @@ interface MomentsSyncTask {
createTime: string;
creatorName: string;
contentLib?: string;
config?: { devices?: string[]; contentLibraryNames?: string[] };
config?: {
devices?: string[];
contentGroups: number[];
contentGroupsOptions?: {
id: number;
name: string;
[key: string]: any;
}[];
};
}
const getStatusText = (status: number) => {
@@ -195,7 +203,7 @@ const MomentsSync: React.FC = () => {
</span>
<div className={style.emptyText}></div>
<Button
type="primary"
color="primary"
onClick={() => navigate("/workspace/moments-sync/new")}
>
@@ -256,9 +264,9 @@ const MomentsSync: React.FC = () => {
<div className={style.itemInfoRow}>
<div className={style.infoCol}>
{task.config?.contentLibraryNames?.join(",") ||
task.contentLib ||
"默认内容库"}
{task.config?.contentGroupsOptions
?.map(c => c.name)
.join(",") || "默认内容库"}
</div>
<div className={style.infoCol}>
{task.creatorName}

View File

@@ -70,7 +70,7 @@ const NewMomentsSync: React.FC = () => {
syncType: res.accountType === 1 ? 1 : 2,
accountType: res.accountType === 1 ? "business" : "personal",
enabled: res.status === 1,
deveiceGroups: res.config?.devices || [],
deveiceGroups: res.config?.deveiceGroups || [],
// 关键用id字符串数组回填
contentGroups: res.config?.contentGroups || [], // 直接用对象数组
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
@@ -134,8 +134,8 @@ const NewMomentsSync: React.FC = () => {
try {
const params = {
name: formData.taskName,
devices: formData.deveiceGroups,
contentLibraries: formData.contentGroups.map((lib: any) => lib.id),
deveiceGroups: formData.deveiceGroups,
contentGroups: formData.contentGroups.map((lib: any) => lib.id),
syncInterval: formData.syncInterval,
syncCount: formData.syncCount,
syncType: formData.syncType, // 账号类型真实传参
@@ -146,7 +146,7 @@ const NewMomentsSync: React.FC = () => {
targetTags: formData.targetTags,
filterKeywords: formData.filterKeywords,
type: 2,
status: formData.enabled ? 1 : 2,
status: formData.enabled ? 1 : 0,
};
if (isEditMode && id) {
await updateMomentsSync({ id, ...params });

View File

@@ -89,10 +89,10 @@ ckbox/
## 数据结构
### 联系人 (ContactData)
### 联系人 (ContractData)
```typescript
interface ContactData {
interface ContractData {
id: string;
name: string;
phone: string;
@@ -169,7 +169,7 @@ export default ckboxRoutes;
确保后端提供以下API接口
- `GET /v1/contacts` - 获取联系人列表
- `GET /v1/contracts` - 获取联系人列表
- `GET /v1/chats/sessions` - 获取聊天会话列表
- `GET /v1/chats/:id/messages` - 获取聊天历史
- `POST /v1/chats/:id/messages` - 发送消息

View File

@@ -1,13 +1,8 @@
import request from "@/api/request";
import request from "@/api/request2";
import {
ContactData,
ContactListResponse,
ChatSession,
MessageData,
ChatHistoryResponse,
SendMessageRequest,
MessageType,
GroupData,
OnlineStatus,
MessageStatus,
FileUploadResponse,
@@ -16,19 +11,78 @@ import {
ChatSettings,
} from "./data";
//读取聊天信息
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
export function WechatGroup(params) {
return request("/api/WechatGroup/list", params, "GET");
}
//获取聊天记录-1 清除未读
export function clearUnreadCount(params) {
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
}
//获取聊天记录-2 获取列表
export function getMessages(params: {
wechatAccountId: number;
wechatFriendId: number;
From: number;
To: number;
Count: number;
olderData: boolean;
}) {
return request("/api/FriendMessage/SearchMessage", params, "GET");
}
//获取群列表
export function getGroupList(params: { prevId: number; count: number }) {
return request(
"/api/wechatChatroom/listExcludeMembersByPage?",
params,
"GET",
);
}
//触客宝登陆
export function loginWithToken(params: any) {
return request(
"/token",
params,
"POST",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
1000,
);
}
// 获取触客宝用户信息
export function getChuKeBaoUserInfo() {
return request("/api/account/self", {}, "GET");
}
// 获取联系人列表
export const getContactList = (): Promise<ContactData[]> => {
return request("/v1/contacts", {}, "GET");
export const getContactList = (params: { prevId: number; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
//获取控制终端列表
export const getControlTerminalList = params => {
return request("/api/wechataccount", params, "GET");
};
// 搜索联系人
export const searchContacts = (keyword: string): Promise<ContactData[]> => {
return request("/v1/contacts/search", { keyword }, "GET");
};
// 获取聊天会话列表
export const getChatSessions = (): Promise<ChatSession[]> => {
return request("/v1/chats/sessions", {}, "GET");
export const getChatMessage = (params: {
wechatAccountId: number;
wechatFriendId: number;
From: number;
To: number;
Count: number;
olderData: boolean;
keyword: string;
}) => {
return request("/api/FriendMessage/SearchMessage", params, "GET");
};
// 获取聊天历史
@@ -71,33 +125,6 @@ export const markChatAsRead = (chatId: string): Promise<void> => {
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
};
// 获取群组列表
export const getGroupList = (): Promise<GroupData[]> => {
return request("/v1/groups", {}, "GET");
};
// 创建群组
export const createGroup = (data: {
name: string;
description?: string;
memberIds: string[];
}): Promise<GroupData> => {
return request("/v1/groups", data, "POST");
};
// 获取群组详情
export const getGroupDetail = (groupId: string): Promise<GroupData> => {
return request(`/v1/groups/${groupId}`, {}, "GET");
};
// 更新群组信息
export const updateGroup = (
groupId: string,
data: Partial<GroupData>,
): Promise<GroupData> => {
return request(`/v1/groups/${groupId}`, data, "PUT");
};
// 添加群组成员
export const addGroupMembers = (
groupId: string,

View File

@@ -30,10 +30,18 @@
flex: 1;
min-width: 0; // 防止flex子元素溢出
:global(.ant-avatar) {
flex-shrink: 0;
width: 40px;
height: 40px;
}
.chatHeaderDetails {
flex: 1;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
min-width: 0;
.chatHeaderName {
font-size: 16px;
@@ -63,6 +71,28 @@
overflow: hidden;
text-overflow: ellipsis;
}
.chatHeaderSubInfo {
display: flex;
gap: 12px;
margin-top: 2px;
font-size: 12px;
overflow: hidden;
.chatHeaderRemark {
color: #1890ff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chatHeaderWechatId {
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@@ -298,8 +328,16 @@
align-items: center;
gap: 16px;
:global(.ant-avatar) {
flex-shrink: 0;
width: 48px;
height: 48px;
}
.profileInfo {
flex: 1;
min-width: 0;
overflow: hidden;
h4 {
margin: 0 0 8px 0;
@@ -308,6 +346,18 @@
color: #262626;
}
.profileNickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
cursor: pointer;
}
.profileStatus {
margin: 0 0 4px 0;
font-size: 12px;
@@ -319,11 +369,36 @@
font-size: 12px;
color: #8c8c8c;
}
.profileRemark {
margin: 0 0 4px 0;
font-size: 12px;
color: #1890ff;
:global(.ant-input) {
font-size: 12px;
}
:global(.ant-btn) {
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
}
.profileWechatId {
margin: 0;
font-size: 12px;
color: #8c8c8c;
}
}
}
.contactInfo {
.contactItem {
.contractInfo {
.contractItem {
align-items: center;
margin-bottom: 12px;
font-size: 14px;
@@ -339,7 +414,7 @@
width: 16px;
}
.contactItemText {
.contractItemText {
padding-left: 10px;
}
}
@@ -373,13 +448,26 @@
.messageItem {
margin-bottom: 16px;
position: relative;
.messageContent {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 70%;
}
}
.messageTime {
text-align: center;
padding: 4px 0;
font-size: 12px;
color: #999;
margin: 20px 0;
}
.messageItem {
.messageContent {
.messageAvatar {
flex-shrink: 0;
}
@@ -389,6 +477,7 @@
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
max-width: 100%;
.messageSender {
font-size: 12px;
@@ -402,11 +491,193 @@
word-break: break-word;
}
.emojiMessage {
img {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
display: block;
cursor: pointer;
}
}
.imageMessage {
img {
max-width: 100%;
border-radius: 8px;
display: block;
cursor: pointer;
}
}
.videoMessage {
position: relative;
display: flex;
flex-direction: column;
video {
max-width: 100%;
border-radius: 8px;
display: block;
}
.videoContainer {
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 8px;
&:hover .videoPlayIcon {
transform: scale(1.1);
}
.videoThumbnail {
width: 100%;
display: block;
border-radius: 8px;
}
.videoPlayIcon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 60px;
height: 60px;
transition: transform 0.2s ease;
.loadingSpinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
.downloadButton {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
width: 32px;
height: 32px;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: #40a9ff;
}
}
}
.audioMessage {
position: relative;
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
audio {
flex: 1;
min-width: 200px;
}
.downloadButton {
display: flex;
align-items: center;
justify-content: center;
color: #1890ff;
font-size: 18px;
width: 32px;
height: 32px;
border-radius: 50%;
margin-left: 8px;
transition: all 0.2s;
&:hover {
color: #40a9ff;
}
}
}
.fileMessage {
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
display: flex;
align-items: center;
position: relative;
transition: background-color 0.2s;
width: 240px;
&:hover {
background: #e6f7ff;
}
.fileInfo {
flex: 1;
margin-right: 8px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.downloadButton {
display: flex;
align-items: center;
justify-content: center;
color: #1890ff;
font-size: 18px;
width: 32px;
height: 32px;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: #40a9ff;
}
}
}
.locationMessage {
background: #f5f5f5;
border-radius: 8px;
padding: 8px;
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #fff2e8;
}
}
.messageTime {
font-size: 11px;
color: #bfbfbf;
margin-top: 4px;
text-align: right;
display: none;
}
}
}
@@ -517,8 +788,8 @@
}
}
.contactInfo {
.contactItem {
.contractInfo {
.contractItem {
font-size: 13px;
margin-bottom: 10px;
}

View File

@@ -0,0 +1,204 @@
.profileSider {
background: #fff;
border-left: 1px solid #e8e8e8;
height: 100%;
overflow-y: auto;
.profileContainer {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.profileHeader {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
.closeButton {
color: #8c8c8c;
&:hover {
color: #262626;
background: #f5f5f5;
}
}
}
.profileBasic {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
.profileInfo {
margin-top: 16px;
text-align: center;
width: 100%;
.profileNickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #262626;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profileRemark {
margin-bottom: 12px;
.remarkText {
color: #8c8c8c;
font-size: 14px;
cursor: pointer;
&:hover {
color: #1890ff;
}
}
}
.profileStatus {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #52c41a;
font-size: 14px;
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52c41a;
}
}
}
}
.profileCard {
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
:global(.ant-card-head) {
padding: 0 16px;
min-height: 40px;
border-bottom: 1px solid #f0f0f0;
:global(.ant-card-head-title) {
font-size: 14px;
font-weight: 500;
color: #262626;
}
}
:global(.ant-card-body) {
padding: 16px;
}
.infoItem {
display: flex;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.infoIcon {
color: #8c8c8c;
margin-right: 8px;
width: 16px;
flex-shrink: 0;
}
.infoLabel {
color: #8c8c8c;
font-size: 14px;
width: 60px;
flex-shrink: 0;
}
.infoValue {
color: #262626;
font-size: 14px;
flex: 1;
word-break: break-all;
}
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
:global(.ant-tag) {
margin: 0;
border-radius: 4px;
}
}
.bioText {
margin: 0;
color: #595959;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
}
.profileActions {
margin-top: auto;
padding-top: 16px;
:global(.ant-btn) {
border-radius: 6px;
font-weight: 500;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.profileSider {
width: 280px !important;
.profileContainer {
padding: 12px;
}
.profileBasic {
.profileInfo {
.profileNickname {
font-size: 16px;
}
}
}
.profileCard {
:global(.ant-card-body) {
padding: 12px;
}
.infoItem {
margin-bottom: 10px;
.infoLabel {
width: 50px;
font-size: 13px;
}
.infoValue {
font-size: 13px;
}
}
}
}
}

View File

@@ -0,0 +1,272 @@
import React, { useState, useEffect } from "react";
import {
Layout,
Input,
Button,
Avatar,
Tooltip,
Card,
Tag,
message,
} from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
UserOutlined,
TeamOutlined,
MailOutlined,
EnvironmentOutlined,
CalendarOutlined,
BankOutlined,
CloseOutlined,
StarOutlined,
EditOutlined,
CheckOutlined,
} from "@ant-design/icons";
import { ContractData } from "@/pages/pc/ckbox/data";
import { useCkChatStore } from "@/store/module/ckchat";
import styles from "./Person.module.scss";
const { Sider } = Layout;
interface PersonProps {
contract: ContractData;
showProfile: boolean;
onToggleProfile?: () => void;
}
const Person: React.FC<PersonProps> = ({
contract,
showProfile,
onToggleProfile,
}) => {
const [messageApi, contextHolder] = message.useMessage();
const [isEditingRemark, setIsEditingRemark] = useState(false);
const [remarkValue, setRemarkValue] = useState(contract.conRemark || "");
const kfSelectedUser = useCkChatStore(state => state.kfSelectedUser());
// 当contract变化时更新备注值
useEffect(() => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
}, [contract.conRemark]);
// 处理备注保存
const handleSaveRemark = () => {
// 这里应该调用API保存备注到后端
// 暂时只更新本地状态
messageApi.success("备注保存成功");
setIsEditingRemark(false);
// 更新contract对象中的备注实际项目中应该通过props回调或状态管理
};
// 处理取消编辑
const handleCancelEdit = () => {
setRemarkValue(contract.conRemark || "");
setIsEditingRemark(false);
};
// 模拟联系人详细信息
const contractInfo = {
name: contract.name,
nickname: contract.nickname,
conRemark: remarkValue, // 使用当前编辑的备注值
alias: contract.alias,
wechatId: contract.wechatId,
avatar: contract.avatar,
phone: contract.phone || "-",
email: contract.email || "-",
department: contract.department || "-",
position: contract.position || "-",
company: contract.company || "-",
location: contract.location || "-",
joinDate: contract.joinDate || "-",
status: "在线",
tags: contract.labels,
bio: contract.bio || "-",
};
if (!showProfile) {
return null;
}
return (
<>
{contextHolder}
<Sider width={320} className={styles.profileSider}>
<div className={styles.profileContainer}>
{/* 关闭按钮 */}
<div className={styles.profileHeader}>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onToggleProfile}
className={styles.closeButton}
/>
</div>
{/* 头像和基本信息 */}
<div className={styles.profileBasic}>
<Avatar
size={80}
src={contractInfo.avatar}
icon={<UserOutlined />}
/>
<div className={styles.profileInfo}>
<Tooltip
title={contractInfo.nickname || contractInfo.name}
placement="top"
>
<h4 className={styles.profileNickname}>
{contractInfo.nickname || contractInfo.name}
</h4>
</Tooltip>
<div className={styles.profileRemark}>
{JSON.stringify(kfSelectedUser)}
{isEditingRemark ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<Input
value={remarkValue}
onChange={e => setRemarkValue(e.target.value)}
placeholder="请输入备注"
size="small"
style={{ flex: 1 }}
/>
<Button
type="text"
size="small"
icon={<CheckOutlined />}
onClick={handleSaveRemark}
style={{ color: "#52c41a" }}
/>
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={handleCancelEdit}
style={{ color: "#ff4d4f" }}
/>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span className={styles.remarkText}>
{contractInfo.conRemark || "点击添加备注"}
</span>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => setIsEditingRemark(true)}
/>
</div>
)}
</div>
<div className={styles.profileStatus}>
<span className={styles.statusDot}></span>
{contractInfo.status}
</div>
</div>
</div>
{/* 详细信息卡片 */}
<Card title="详细信息" className={styles.profileCard}>
<div className={styles.infoItem}>
<TeamOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.wechatId}</span>
</div>
<div className={styles.infoItem}>
<UserOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.alias}</span>
</div>
<div className={styles.infoItem}>
<PhoneOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.phone}</span>
</div>
<div className={styles.infoItem}>
<MailOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.email}</span>
</div>
<div className={styles.infoItem}>
<BankOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>
{contractInfo.department}
</span>
</div>
<div className={styles.infoItem}>
<StarOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.position}</span>
</div>
<div className={styles.infoItem}>
<BankOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.company}</span>
</div>
<div className={styles.infoItem}>
<EnvironmentOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.location}</span>
</div>
<div className={styles.infoItem}>
<CalendarOutlined className={styles.infoIcon} />
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>{contractInfo.joinDate}</span>
</div>
</Card>
{/* 标签 */}
<Card title="标签" className={styles.profileCard}>
<div className={styles.tagsContainer}>
{contractInfo.tags?.map((tag, index) => (
<Tag key={index} color="blue">
{tag}
</Tag>
))}
</div>
</Card>
{/* 个人简介 */}
<Card title="个人简介" className={styles.profileCard}>
<p className={styles.bioText}>{contractInfo.bio}</p>
</Card>
{/* 操作按钮 */}
<div className={styles.profileActions}>
<Button type="primary" icon={<PhoneOutlined />} block>
</Button>
<Button
icon={<VideoCameraOutlined />}
block
style={{ marginTop: 8 }}
>
</Button>
</div>
</div>
</Sider>
</>
);
};
export default Person;

View File

@@ -9,12 +9,6 @@ import {
Menu,
message,
Tooltip,
Divider,
Badge,
Card,
Tag,
Row,
Col,
Modal,
} from "antd";
import {
@@ -26,94 +20,165 @@ import {
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
TeamOutlined,
MailOutlined,
EnvironmentOutlined,
CalendarOutlined,
BankOutlined,
CloseOutlined,
StarOutlined,
EnvironmentOutlined as LocationOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
DownloadOutlined,
CodeSandboxOutlined,
MessageOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
EnvironmentOutlined,
TeamOutlined,
StarOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { ChatSession, MessageData, MessageType } from "../../data";
// import { getChatHistory, sendMessage } from "../api";
import { ChatRecord, ContractData } from "@/pages/pc/ckbox/data";
import { clearUnreadCount, getMessages } from "@/pages/pc/ckbox/api";
import styles from "./ChatWindow.module.scss";
const { Header, Content, Footer, Sider } = Layout;
import { useWebSocketStore, WebSocketMessage } from "@/store/module/websocket";
import { formatWechatTime } from "@/utils/common";
import { useCkChatStore } from "@/store/module/ckchat";
import Person from "./components/Person";
const { Header, Content, Footer } = Layout;
const { TextArea } = Input;
interface ChatWindowProps {
chat: ChatSession;
contract: ContractData;
onSendMessage: (message: string) => void;
showProfile?: boolean;
onToggleProfile?: () => void;
}
const ChatWindow: React.FC<ChatWindowProps> = ({
chat,
contract,
onSendMessage,
showProfile = true,
onToggleProfile,
}) => {
const [messages, setMessages] = useState<MessageData[]>([]);
const [messageApi, contextHolder] = message.useMessage();
const [messages, setMessages] = useState<ChatRecord[]>([]);
const [inputValue, setInputValue] = useState("");
const [loading, setLoading] = useState(false);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [pendingVideoRequests, setPendingVideoRequests] = useState<
Record<string, string>
>({});
const messagesEndRef = useRef<HTMLDivElement>(null);
const kfSelectedUser = useCkChatStore(state => state.kfSelectedUser());
useEffect(() => {
fetchChatHistory();
}, [chat.id]);
clearUnreadCount([contract.id]).then(() => {
setLoading(true);
getMessages({
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract.id,
From: 1,
To: +new Date() + 1000,
Count: 100,
olderData: true,
})
.then(msg => {
setMessages(msg);
})
.finally(() => {
setLoading(false);
});
});
}, [contract.id]);
useEffect(() => {
scrollToBottom();
// 只有在非视频加载操作时才自动滚动到底部
// 检查是否有视频正在加载中
const hasLoadingVideo = messages.some(msg => {
try {
const content =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
return content.isLoading === true;
} catch (e) {
return false;
}
});
if (!hasLoadingVideo) {
scrollToBottom();
}
}, [messages]);
const fetchChatHistory = async () => {
try {
setLoading(true);
// 模拟聊天历史数据
const mockMessages: MessageData[] = [
{
id: "1",
senderId: "other",
senderName: chat.name,
content: "你好,请问有什么可以帮助您的吗?",
type: MessageType.TEXT,
timestamp: dayjs().subtract(10, "minute").toISOString(),
isRead: true,
},
{
id: "2",
senderId: "me",
senderName: "我",
content: "我想了解一下你们的产品",
type: MessageType.TEXT,
timestamp: dayjs().subtract(8, "minute").toISOString(),
isRead: true,
},
{
id: "3",
senderId: "other",
senderName: chat.name,
content: "好的,我来为您详细介绍",
type: MessageType.TEXT,
timestamp: dayjs().subtract(5, "minute").toISOString(),
isRead: true,
},
];
setMessages(mockMessages);
} catch (error) {
message.error("获取聊天记录失败");
} finally {
setLoading(false);
// 添加 WebSocket 消息订阅 - 监听视频下载响应消息
useEffect(() => {
// 只有当有待处理的视频请求时才订阅WebSocket消息
if (Object.keys(pendingVideoRequests).length === 0) {
return;
}
};
console.log("开始监听视频下载响应,当前待处理请求:", pendingVideoRequests);
// 订阅 WebSocket 消息变化
const unsubscribe = useWebSocketStore.subscribe(state => {
// 只处理新增的消息
const messages = state.messages as WebSocketMessage[];
// 筛选出视频下载响应消息
messages.forEach(message => {
if (message?.content?.cmdType === "CmdDownloadVideoResult") {
console.log("收到视频下载响应:", message.content);
// 检查是否是我们正在等待的视频响应
const messageId = Object.keys(pendingVideoRequests).find(
id => pendingVideoRequests[id] === message.content.friendMessageId,
);
if (messageId) {
console.log("找到对应的消息ID:", messageId);
// 从待处理队列中移除
setPendingVideoRequests(prev => {
const newRequests = { ...prev };
delete newRequests[messageId];
return newRequests;
});
// 更新消息内容将视频URL添加到对应的消息中
setMessages(prevMessages => {
return prevMessages.map(msg => {
if (msg.id === Number(messageId)) {
try {
const msgContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
// 更新消息内容添加视频URL并移除加载状态
return {
...msg,
content: JSON.stringify({
...msgContent,
videoUrl: message.content.url,
isLoading: false,
}),
};
} catch (e) {
console.error("解析消息内容失败:", e);
}
}
return msg;
});
});
}
}
});
});
// 组件卸载时取消订阅
return () => {
unsubscribe();
};
}, [pendingVideoRequests]); // 依赖于pendingVideoRequests当队列变化时重新设置订阅
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -123,21 +188,33 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
if (!inputValue.trim()) return;
try {
const newMessage: MessageData = {
id: Date.now().toString(),
senderId: "me",
senderName: "我",
const newMessage: ChatRecord = {
id: contract.id,
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract.id,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: inputValue,
type: MessageType.TEXT,
timestamp: dayjs().toISOString(),
isRead: false,
msgType: 0,
msgSubType: 0,
msgSvrId: "",
isSend: false,
createTime: "",
isDeleted: false,
deleteTime: "",
sendStatus: 0,
wechatTime: 0,
origin: 0,
msgId: 0,
recalled: false,
};
setMessages(prev => [...prev, newMessage]);
onSendMessage(inputValue);
setInputValue("");
} catch (error) {
message.error("发送失败");
messageApi.error("发送失败");
}
};
@@ -188,8 +265,489 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
// 这里可以根据不同的素材类型显示不同的模态框
};
const renderMessage = (msg: MessageData) => {
const isOwn = msg.senderId === "me";
// 处理视频播放请求发送socket请求获取真实视频地址
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
// 生成请求ID (使用当前时间戳作为唯一标识)
const requestSeq = `${+new Date()}`;
console.log("发送视频下载请求:", { messageId, requestSeq });
// 构建socket请求数据
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
chatroomMessageId: contract.chatroomId ? messageId : 0,
friendMessageId: contract.chatroomId ? 0 : messageId,
seq: requestSeq, // 使用唯一的请求ID
tencentUrl: tencentUrl,
wechatAccountId: contract.wechatAccountId,
});
// 将消息ID和请求序列号添加到待处理队列
setPendingVideoRequests(prev => ({
...prev,
[messageId]: messageId,
}));
// 更新消息状态为加载中
setMessages(prevMessages => {
return prevMessages.map(msg => {
if (msg.id === messageId) {
// 保存原始内容添加loading状态
const originalContent = msg.content;
return {
...msg,
content: JSON.stringify({
...JSON.parse(originalContent),
isLoading: true,
}),
};
}
return msg;
});
});
};
// 解析消息内容,判断消息类型并返回对应的渲染内容
const parseMessageContent = (content: string, msg: ChatRecord) => {
// 检查是否为表情包
if (
typeof content === "string" &&
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
content.includes("#")
) {
return (
<div className={styles.emojiMessage}>
<img
src={content}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(content, "_blank")}
/>
</div>
);
}
// 检查是否为带预览图的视频消息
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const videoData = JSON.parse(content);
// 处理用户提供的JSON格式 {"previewImage":"https://...", "tencentUrl":"..."}
if (videoData.previewImage && videoData.tencentUrl) {
// 提取预览图URL去掉可能的引号
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
// 创建点击处理函数调用handleVideoPlayRequest发送socket请求获取真实视频地址
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 调用处理函数传入tencentUrl和消息ID
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
};
// 检查是否已下载视频URL
if (videoData.videoUrl) {
// 已获取到视频URL显示视频播放器
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
</div>
);
}
// 检查是否处于加载状态
if (videoData.isLoading) {
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: "0.7",
}}
/>
<div className={styles.videoPlayIcon}>
<div className={styles.loadingSpinner}></div>
</div>
</div>
</div>
);
}
// 默认显示预览图和播放按钮
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer} onClick={handlePlayClick}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<div className={styles.videoPlayIcon}>
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
</div>
</div>
</div>
);
}
// 保留原有的视频处理逻辑
else if (
videoData.type === "video" &&
videoData.url &&
videoData.thumb
) {
return (
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={() => window.open(videoData.url, "_blank")}
>
<img
src={videoData.thumb}
alt="视频预览"
className={styles.videoThumbnail}
/>
<div className={styles.videoPlayIcon}>
<VideoCameraOutlined
style={{ fontSize: "32px", color: "#fff" }}
/>
</div>
</div>
<a
href={videoData.url}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
}
} catch (e) {
// 解析JSON失败不是视频消息
console.log("解析视频消息失败:", e);
}
// 检查是否为图片链接
if (
typeof content === "string" &&
(content.match(/\.(jpg|jpeg|png|gif)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".jpg")))
) {
return (
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
onClick={() => window.open(content, "_blank")}
/>
</div>
);
}
// 检查是否为视频链接
if (
typeof content === "string" &&
(content.match(/\.(mp4|avi|mov|wmv|flv)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".mp4")))
) {
return (
<div className={styles.videoMessage}>
<video
controls
src={content}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为音频链接
if (
typeof content === "string" &&
(content.match(/\.(mp3|wav|ogg|m4a)$/i) ||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
content.includes(".mp3")))
) {
return (
<div className={styles.audioMessage}>
<audio controls src={content} style={{ maxWidth: "100%" }} />
<a
href={content}
download
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => e.stopPropagation()}
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为Office文件链接
if (
typeof content === "string" &&
content.match(/\.(doc|docx|xls|xlsx|ppt|pptx|pdf)$/i)
) {
const fileName = content.split("/").pop() || "文件";
const fileExt = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择不同的图标
let fileIcon = (
<FileOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
/>
);
if (fileExt === "pdf") {
fileIcon = (
<FilePdfOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
/>
);
} else if (fileExt === "doc" || fileExt === "docx") {
fileIcon = (
<FileWordOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#2f54eb" }}
/>
);
} else if (fileExt === "xls" || fileExt === "xlsx") {
fileIcon = (
<FileExcelOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#52c41a" }}
/>
);
} else if (fileExt === "ppt" || fileExt === "pptx") {
fileIcon = (
<FilePptOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#fa8c16" }}
/>
);
}
return (
<div className={styles.fileMessage}>
{fileIcon}
<div className={styles.fileInfo}>
<div
style={{
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileName}
</div>
</div>
<a
href={content}
download={fileExt !== "pdf" ? fileName : undefined}
target={fileExt === "pdf" ? "_blank" : undefined}
className={styles.downloadButton}
onClick={e => e.stopPropagation()}
style={{ display: "flex" }}
rel="noreferrer"
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
// 检查是否为文件消息JSON格式
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const fileData = JSON.parse(content);
if (fileData.type === "file" && fileData.title) {
// 检查是否为Office文件
const fileExt = fileData.title.split(".").pop()?.toLowerCase();
let fileIcon = (
<FolderOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
/>
);
if (fileExt === "pdf") {
fileIcon = (
<FilePdfOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#ff4d4f",
}}
/>
);
} else if (fileExt === "doc" || fileExt === "docx") {
fileIcon = (
<FileWordOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#2f54eb",
}}
/>
);
} else if (fileExt === "xls" || fileExt === "xlsx") {
fileIcon = (
<FileExcelOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#52c41a",
}}
/>
);
} else if (fileExt === "ppt" || fileExt === "pptx") {
fileIcon = (
<FilePptOutlined
style={{
fontSize: "24px",
marginRight: "8px",
color: "#fa8c16",
}}
/>
);
}
return (
<div className={styles.fileMessage}>
{fileIcon}
<div className={styles.fileInfo}>
<div
style={{
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileData.title}
</div>
{fileData.totalLen && (
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
{Math.round(fileData.totalLen / 1024)} KB
</div>
)}
</div>
<a
href={fileData.url || "#"}
download={fileExt !== "pdf" ? fileData.title : undefined}
target={fileExt === "pdf" ? "_blank" : undefined}
className={styles.downloadButton}
style={{ display: "flex" }}
onClick={e => {
e.stopPropagation();
if (!fileData.url) {
console.log("文件URL不存在");
}
}}
rel="noreferrer"
>
<DownloadOutlined style={{ fontSize: "18px" }} />
</a>
</div>
);
}
}
} catch (e) {
// 解析JSON失败不是文件消息
}
// 检查是否为位置信息
if (
typeof content === "string" &&
(content.includes("<location") || content.includes("<msg><location"))
) {
// 提取位置信息
const labelMatch = content.match(/label="([^"]*)"/i);
const poiNameMatch = content.match(/poiname="([^"]*)"/i);
const xMatch = content.match(/x="([^"]*)"/i);
const yMatch = content.match(/y="([^"]*)"/i);
const label = labelMatch
? labelMatch[1]
: poiNameMatch
? poiNameMatch[1]
: "位置信息";
const coordinates = xMatch && yMatch ? `${yMatch[1]}, ${xMatch[1]}` : "";
return (
<div className={styles.locationMessage}>
<EnvironmentOutlined
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
/>
<div>
<div style={{ fontWeight: "bold" }}>{label}</div>
{coordinates && (
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
{coordinates}
</div>
)}
</div>
</div>
);
}
// 默认为文本消息
return <div className={styles.messageText}>{content}</div>;
};
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[]) => {
const groups: { time: string; messages: ChatRecord[] }[] = [];
messages.forEach(msg => {
// 使用 formatWechatTime 函数格式化时间戳
const formattedTime = formatWechatTime(msg.wechatTime);
groups.push({ time: formattedTime, messages: [msg] });
});
return groups;
};
const renderMessage = (msg: ChatRecord) => {
const isOwn = msg.isSend;
return (
<div
key={msg.id}
@@ -201,7 +759,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{!isOwn && (
<Avatar
size={32}
src={chat.avatar}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
@@ -210,10 +768,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{!isOwn && (
<div className={styles.messageSender}>{msg.senderName}</div>
)}
<div className={styles.messageText}>{msg.content}</div>
<div className={styles.messageTime}>
{dayjs(msg.timestamp).format("HH:mm")}
</div>
{parseMessageContent(msg.content, msg)}
</div>
</div>
</div>
@@ -241,24 +796,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Menu>
);
// 模拟联系人详细信息
const contactInfo = {
name: chat.name,
avatar: chat.avatar,
phone: "13800138001",
email: "zhangsan@example.com",
department: "技术部",
position: "前端工程师",
company: "某某科技有限公司",
location: "北京市朝阳区",
joinDate: "2023-01-15",
status: "在线",
tags: ["技术专家", "前端", "React"],
bio: "专注于前端开发热爱新技术擅长React、Vue等框架。",
};
return (
<Layout className={styles.chatWindow}>
{contextHolder}
{/* 聊天主体区域 */}
<Layout className={styles.chatMain}>
{/* 聊天头部 */}
@@ -266,20 +806,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<div className={styles.chatHeaderInfo}>
<Avatar
size={40}
src={chat.avatar}
icon={chat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
src={contract.avatar}
icon={
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
}
/>
<div
className={styles.chatHeaderDetails}
style={{
display: "flex",
}}
>
<div className={styles.chatHeaderDetails}>
<div className={styles.chatHeaderName}>
{chat.name}
{chat.online && (
<span className={styles.chatHeaderOnlineStatus}>线</span>
)}
{contract.nickname || contract.name}
</div>
</div>
</div>
@@ -317,7 +851,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</div>
) : (
<>
{messages.map(renderMessage)}
{groupMessagesByTime(messages).map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
<div className={styles.messageTime}>{group.time}</div>
{group.messages.map(renderMessage)}
</React.Fragment>
))}
<div ref={messagesEndRef} />
</>
)}
@@ -353,7 +892,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
<Tooltip title="位置">
<Button
type="text"
icon={<LocationOutlined />}
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
@@ -442,108 +981,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Layout>
{/* 右侧个人资料卡片 */}
{showProfile && (
<Sider width={280} className={styles.profileSider}>
<div className={styles.profileSiderContent}>
<div className={styles.profileHeader}>
<h3></h3>
<Button
type="text"
icon={<CloseOutlined />}
onClick={onToggleProfile}
className={styles.closeButton}
/>
</div>
<div className={styles.profileContent}>
{/* 基本信息 */}
<Card className={styles.profileCard}>
<div className={styles.profileBasic}>
<Avatar
size={80}
src={contactInfo.avatar}
icon={<UserOutlined />}
/>
<div className={styles.profileInfo}>
<h4>{contactInfo.name}</h4>
<p className={styles.profileStatus}>
<Badge status="success" text={contactInfo.status} />
</p>
<p className={styles.profilePosition}>
{contactInfo.position} · {contactInfo.department}
</p>
</div>
</div>
</Card>
{/* 联系信息 */}
<Card title="联系信息" className={styles.profileCard}>
<div className={styles.contactInfo}>
<div className={styles.contactItem}>
<PhoneOutlined />
<span className={styles.contactItemText}>
{contactInfo.phone}
</span>
</div>
<div className={styles.contactItem}>
<MailOutlined />
<span className={styles.contactItemText}>
{contactInfo.email}
</span>
</div>
<div className={styles.contactItem}>
<EnvironmentOutlined />
<span className={styles.contactItemText}>
{contactInfo.location}
</span>
</div>
<div className={styles.contactItem}>
<BankOutlined />
<span className={styles.contactItemText}>
{contactInfo.company}
</span>
</div>
<div className={styles.contactItem}>
<CalendarOutlined />
<span className={styles.contactItemText}>
{contactInfo.joinDate}
</span>
</div>
</div>
</Card>
{/* 标签 */}
<Card title="标签" className={styles.profileCard}>
<div className={styles.tagsContainer}>
{contactInfo.tags.map((tag, index) => (
<Tag key={index} color="blue">
{tag}
</Tag>
))}
</div>
</Card>
{/* 个人简介 */}
<Card title="个人简介" className={styles.profileCard}>
<p className={styles.bioText}>{contactInfo.bio}</p>
</Card>
{/* 操作按钮 */}
<div className={styles.profileActions}>
<Button type="primary" icon={<PhoneOutlined />} block>
</Button>
<Button
icon={<VideoCameraOutlined />}
block
style={{ marginTop: 8 }}
>
</Button>
</div>
</div>
</div>
</Sider>
)}
<Person
contract={contract}
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
{/* 素材选择模态框 */}
<Modal
@@ -563,7 +1005,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
</Button>,
]}
width={800}
bodyStyle={{ padding: 0 }}
>
<div style={{ display: "flex", height: "400px" }}>
{/* 左侧素材分类 */}

View File

@@ -1,78 +0,0 @@
.contactList {
height: 100%;
overflow-y: auto;
.contactItem {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s;
&:hover {
background-color: #f5f5f5;
}
&:last-child {
border-bottom: none;
}
.contactInfo {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.contactDetails {
flex: 1;
min-width: 0;
.contactName {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contactPhone {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 2px;
}
.contactStatus {
font-size: 11px;
color: #bfbfbf;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.contactList {
.contactItem {
padding: 10px 12px;
.contactInfo {
gap: 10px;
.contactDetails {
.contactName {
font-size: 13px;
}
.contactPhone {
font-size: 11px;
}
}
}
}
}
}

View File

@@ -1,48 +0,0 @@
import React from "react";
import { List, Avatar, Badge } from "antd";
import { UserOutlined } from "@ant-design/icons";
import { ContactData } from "../../data";
import styles from "./ContactList.module.scss";
interface ContactListProps {
contacts: ContactData[];
onContactClick: (contact: ContactData) => void;
}
const ContactList: React.FC<ContactListProps> = ({
contacts,
onContactClick,
}) => {
return (
<div className={styles.contactList}>
<List
dataSource={contacts}
renderItem={contact => (
<List.Item
className={styles.contactItem}
onClick={() => onContactClick(contact)}
>
<div className={styles.contactInfo}>
<Badge dot={contact.online} color="#52c41a" offset={[-2, 2]}>
<Avatar
size={40}
src={contact.avatar}
icon={<UserOutlined />}
/>
</Badge>
<div className={styles.contactDetails}>
<div className={styles.contactName}>{contact.name}</div>
<div className={styles.contactPhone}>{contact.phone}</div>
{contact.status && (
<div className={styles.contactStatus}>{contact.status}</div>
)}
</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default ContactList;

View File

@@ -1,101 +0,0 @@
.customerDetailModal {
:global(.ant-modal-body) {
padding: 0;
max-height: 70vh;
overflow-y: auto;
}
}
.modalTitle {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
font-weight: 600;
}
.modalContent {
padding: 24px;
}
.infoCard {
margin-bottom: 16px;
border-radius: 6px;
&:last-child {
margin-bottom: 0;
}
:global(.ant-card-head) {
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
min-height: 40px;
.ant-card-head-title {
font-size: 14px;
font-weight: 600;
}
}
}
.tagsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.remarkText {
color: #666;
line-height: 1.6;
white-space: pre-wrap;
}
.followUpItem {
.followUpHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.followUpType {
font-weight: 500;
color: #1890ff;
background: #e6f7ff;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.followUpDate {
color: #8c8c8c;
font-size: 12px;
}
}
.followUpContent {
color: #262626;
line-height: 1.6;
margin-bottom: 4px;
}
.nextFollowUp {
color: #fa8c16;
font-size: 12px;
font-style: italic;
}
}
// 响应式设计
@media (max-width: 768px) {
.modalContent {
padding: 16px;
}
.followUpItem {
.followUpHeader {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
}

View File

@@ -1,370 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Descriptions,
Tag,
Avatar,
Button,
Space,
Divider,
List,
Card,
Timeline,
Empty,
Spin,
} from "antd";
import {
UserOutlined,
PhoneOutlined,
MailOutlined,
WechatOutlined,
EditOutlined,
CalendarOutlined,
EnvironmentOutlined,
BankOutlined,
TagOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { CustomerData, CustomerStatus, CustomerSource } from "../data";
import { getCustomerDetail, getCustomerFollowUps } from "../api";
import styles from "./CustomerDetailModal.module.scss";
interface CustomerDetailModalProps {
visible: boolean;
customer: CustomerData | null;
onCancel: () => void;
onEdit: () => void;
}
const CustomerDetailModal: React.FC<CustomerDetailModalProps> = ({
visible,
customer,
onCancel,
onEdit,
}) => {
const [loading, setLoading] = useState(false);
const [customerDetail, setCustomerDetail] = useState<CustomerData | null>(
null,
);
const [followUps, setFollowUps] = useState<any[]>([]);
useEffect(() => {
if (visible && customer) {
fetchCustomerDetail();
fetchFollowUps();
}
}, [visible, customer]);
const fetchCustomerDetail = async () => {
if (!customer) return;
try {
setLoading(true);
const detail = await getCustomerDetail(customer.id);
setCustomerDetail(detail);
} catch (error) {
console.error("获取客户详情失败:", error);
} finally {
setLoading(false);
}
};
const fetchFollowUps = async () => {
if (!customer) return;
try {
const data = await getCustomerFollowUps(customer.id);
setFollowUps(data);
} catch (error) {
console.error("获取跟进记录失败:", error);
}
};
const getStatusTag = (status: CustomerStatus) => {
const statusConfig = {
[CustomerStatus.ACTIVE]: { color: "green", text: "活跃" },
[CustomerStatus.INACTIVE]: { color: "red", text: "非活跃" },
[CustomerStatus.POTENTIAL]: { color: "blue", text: "潜在" },
[CustomerStatus.LOST]: { color: "gray", text: "流失" },
};
const config = statusConfig[status];
return <Tag color={config.color}>{config.text}</Tag>;
};
const getSourceTag = (source: CustomerSource) => {
const sourceConfig = {
[CustomerSource.WECHAT]: {
color: "green",
text: "微信",
icon: <WechatOutlined />,
},
[CustomerSource.WEBSITE]: {
color: "blue",
text: "官网",
icon: <UserOutlined />,
},
[CustomerSource.REFERRAL]: {
color: "orange",
text: "推荐",
icon: <UserOutlined />,
},
[CustomerSource.OTHER]: {
color: "gray",
text: "其他",
icon: <UserOutlined />,
},
};
const config = sourceConfig[source];
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
const getLevelTag = (level?: string) => {
const levelConfig = {
vip: { color: "gold", text: "VIP客户" },
normal: { color: "blue", text: "普通客户" },
potential: { color: "orange", text: "潜在客户" },
};
const config = levelConfig[level as keyof typeof levelConfig];
return config ? <Tag color={config.color}>{config.text}</Tag> : null;
};
const getGenderText = (gender?: string) => {
const genderMap = {
male: "男",
female: "女",
unknown: "未知",
};
return genderMap[gender as keyof typeof genderMap] || "未知";
};
if (!customerDetail) {
return (
<Modal
title="客户详情"
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
onClick={onEdit}
>
</Button>,
]}
width={800}
>
<div style={{ textAlign: "center", padding: "40px" }}>
<Spin size="large" />
</div>
</Modal>
);
}
return (
<Modal
title={
<div className={styles.modalTitle}>
<Avatar
size={40}
src={customerDetail.avatar}
icon={<UserOutlined />}
/>
<span>{customerDetail.name}</span>
</div>
}
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
onClick={onEdit}
>
</Button>,
]}
width={900}
className={styles.customerDetailModal}
>
<div className={styles.modalContent}>
{/* 基本信息 */}
<Card title="基本信息" size="small" className={styles.infoCard}>
<Descriptions column={2} size="small">
<Descriptions.Item label="姓名" span={1}>
{customerDetail.name}
</Descriptions.Item>
<Descriptions.Item label="手机号" span={1}>
<Space>
<PhoneOutlined />
{customerDetail.phone}
</Space>
</Descriptions.Item>
<Descriptions.Item label="邮箱" span={1}>
{customerDetail.email ? (
<Space>
<MailOutlined />
{customerDetail.email}
</Space>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="微信号" span={1}>
{customerDetail.wechat ? (
<Space>
<WechatOutlined />
{customerDetail.wechat}
</Space>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="性别" span={1}>
{getGenderText(customerDetail.gender)}
</Descriptions.Item>
<Descriptions.Item label="生日" span={1}>
{customerDetail.birthday ? (
<Space>
<CalendarOutlined />
{dayjs(customerDetail.birthday).format("YYYY-MM-DD")}
</Space>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="客户来源" span={1}>
{getSourceTag(customerDetail.source)}
</Descriptions.Item>
<Descriptions.Item label="客户状态" span={1}>
{getStatusTag(customerDetail.status)}
</Descriptions.Item>
<Descriptions.Item label="客户等级" span={1}>
{getLevelTag(customerDetail.level)}
</Descriptions.Item>
<Descriptions.Item label="最近联系" span={1}>
{customerDetail.lastContact ? (
<Space>
<CalendarOutlined />
{dayjs(customerDetail.lastContact).format("YYYY-MM-DD HH:mm")}
</Space>
) : (
"-"
)}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 公司信息 */}
{(customerDetail.company || customerDetail.position) && (
<Card title="公司信息" size="small" className={styles.infoCard}>
<Descriptions column={2} size="small">
<Descriptions.Item label="公司" span={1}>
{customerDetail.company ? (
<Space>
<BankOutlined />
{customerDetail.company}
</Space>
) : (
"-"
)}
</Descriptions.Item>
<Descriptions.Item label="职位" span={1}>
{customerDetail.position || "-"}
</Descriptions.Item>
<Descriptions.Item label="地址" span={2}>
{customerDetail.address ? (
<Space>
<EnvironmentOutlined />
{customerDetail.address}
</Space>
) : (
"-"
)}
</Descriptions.Item>
</Descriptions>
</Card>
)}
{/* 标签信息 */}
{customerDetail.tags && customerDetail.tags.length > 0 && (
<Card title="标签信息" size="small" className={styles.infoCard}>
<div className={styles.tagsContainer}>
{customerDetail.tags.map((tag, index) => (
<Tag key={index} icon={<TagOutlined />}>
{tag}
</Tag>
))}
</div>
</Card>
)}
{/* 备注信息 */}
{customerDetail.remark && (
<Card title="备注信息" size="small" className={styles.infoCard}>
<div className={styles.remarkText}>{customerDetail.remark}</div>
</Card>
)}
<Divider />
{/* 跟进记录 */}
<Card title="跟进记录" size="small" className={styles.infoCard}>
{followUps.length > 0 ? (
<Timeline>
{followUps.map((followUp, index) => (
<Timeline.Item key={index}>
<div className={styles.followUpItem}>
<div className={styles.followUpHeader}>
<span className={styles.followUpType}>
{followUp.type}
</span>
<span className={styles.followUpDate}>
{dayjs(followUp.createdAt).format("YYYY-MM-DD HH:mm")}
</span>
</div>
<div className={styles.followUpContent}>
{followUp.content}
</div>
{followUp.nextFollowUpDate && (
<div className={styles.nextFollowUp}>
:{" "}
{dayjs(followUp.nextFollowUpDate).format("YYYY-MM-DD")}
</div>
)}
</div>
</Timeline.Item>
))}
</Timeline>
) : (
<Empty description="暂无跟进记录" />
)}
</Card>
{/* 时间信息 */}
<Card title="时间信息" size="small" className={styles.infoCard}>
<Descriptions column={2} size="small">
<Descriptions.Item label="创建时间">
{dayjs(customerDetail.createdAt).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{dayjs(customerDetail.updatedAt).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
</Descriptions>
</Card>
</div>
</Modal>
);
};
export default CustomerDetailModal;

View File

@@ -1,36 +0,0 @@
.filterContent {
.activeFilters {
margin-bottom: 24px;
padding: 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #52c41a;
}
.filterTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.filterContent {
.activeFilters {
margin-bottom: 16px;
padding: 12px;
.filterTags {
gap: 6px;
}
}
}
}

View File

@@ -1,297 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Form,
Select,
DatePicker,
Button,
Row,
Col,
Space,
Tag,
Input,
} from "antd";
import { FilterOutlined, ClearOutlined, TagOutlined } from "@ant-design/icons";
import { CustomerStatus, CustomerSource, CustomerFilters } from "../data";
import { getAllTags } from "../api";
import styles from "./CustomerFilter.module.scss";
const { RangePicker } = DatePicker;
const { Option } = Select;
interface CustomerFilterProps {
visible: boolean;
filters: CustomerFilters;
onCancel: () => void;
onOk: (filters: CustomerFilters) => void;
}
const CustomerFilter: React.FC<CustomerFilterProps> = ({
visible,
filters,
onCancel,
onOk,
}) => {
const [form] = Form.useForm();
const [allTags, setAllTags] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (visible) {
fetchAllTags();
// 设置表单初始值
form.setFieldsValue({
status: filters.status,
source: filters.source,
dateRange: filters.dateRange
? [
filters.dateRange[0] ? new Date(filters.dateRange[0]) : null,
filters.dateRange[1] ? new Date(filters.dateRange[1]) : null,
]
: undefined,
tags: filters.tags,
level: filters.level,
gender: filters.gender,
});
}
}, [visible, filters, form]);
const fetchAllTags = async () => {
try {
const tags = await getAllTags();
setAllTags(tags);
} catch (error) {
console.error("获取标签失败:", error);
}
};
const handleOk = async () => {
try {
const values = await form.validateFields();
const newFilters: CustomerFilters = {
status: values.status,
source: values.source,
dateRange: values.dateRange
? [
values.dateRange[0]?.toISOString().split("T")[0] || "",
values.dateRange[1]?.toISOString().split("T")[0] || "",
]
: undefined,
tags: values.tags || [],
level: values.level,
gender: values.gender,
};
onOk(newFilters);
} catch (error) {
console.error("表单验证失败:", error);
}
};
const handleReset = () => {
form.resetFields();
};
const getActiveFilterCount = () => {
let count = 0;
if (filters.status) count++;
if (filters.source) count++;
if (filters.dateRange) count++;
if (filters.tags && filters.tags.length > 0) count++;
if (filters.level) count++;
if (filters.gender) count++;
return count;
};
const renderFilterSummary = () => {
const activeFilters = [];
if (filters.status) {
const statusText = {
[CustomerStatus.ACTIVE]: "活跃",
[CustomerStatus.INACTIVE]: "非活跃",
[CustomerStatus.POTENTIAL]: "潜在",
[CustomerStatus.LOST]: "流失",
}[filters.status];
activeFilters.push(
<Tag key="status" color="blue">
: {statusText}
</Tag>,
);
}
if (filters.source) {
const sourceText = {
[CustomerSource.WECHAT]: "微信",
[CustomerSource.WEBSITE]: "官网",
[CustomerSource.REFERRAL]: "推荐",
[CustomerSource.OTHER]: "其他",
}[filters.source];
activeFilters.push(
<Tag key="source" color="green">
: {sourceText}
</Tag>,
);
}
if (filters.dateRange) {
activeFilters.push(
<Tag key="dateRange" color="orange">
: {filters.dateRange[0]} ~ {filters.dateRange[1]}
</Tag>,
);
}
if (filters.tags && filters.tags.length > 0) {
activeFilters.push(
<Tag key="tags" color="purple">
: {filters.tags.join(", ")}
</Tag>,
);
}
if (filters.level) {
const levelText = {
vip: "VIP客户",
normal: "普通客户",
potential: "潜在客户",
}[filters.level];
activeFilters.push(
<Tag key="level" color="gold">
: {levelText}
</Tag>,
);
}
if (filters.gender) {
const genderText = {
male: "男",
female: "女",
unknown: "未知",
}[filters.gender];
activeFilters.push(
<Tag key="gender" color="cyan">
: {genderText}
</Tag>,
);
}
return activeFilters;
};
return (
<Modal
title={
<Space>
<FilterOutlined />
{getActiveFilterCount() > 0 && (
<Tag color="blue">{getActiveFilterCount()} </Tag>
)}
</Space>
}
open={visible}
onCancel={onCancel}
footer={[
<Button key="reset" icon={<ClearOutlined />} onClick={handleReset}>
</Button>,
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button key="ok" type="primary" onClick={handleOk}>
</Button>,
]}
width={600}
>
<div className={styles.filterContent}>
{/* 当前筛选条件 */}
{getActiveFilterCount() > 0 && (
<div className={styles.activeFilters}>
<h4>:</h4>
<div className={styles.filterTags}>{renderFilterSummary()}</div>
</div>
)}
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="status" label="客户状态">
<Select placeholder="请选择客户状态" allowClear>
<Option value={CustomerStatus.ACTIVE}></Option>
<Option value={CustomerStatus.INACTIVE}></Option>
<Option value={CustomerStatus.POTENTIAL}></Option>
<Option value={CustomerStatus.LOST}></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="source" label="客户来源">
<Select placeholder="请选择客户来源" allowClear>
<Option value={CustomerSource.WECHAT}></Option>
<Option value={CustomerSource.WEBSITE}></Option>
<Option value={CustomerSource.REFERRAL}></Option>
<Option value={CustomerSource.OTHER}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="level" label="客户等级">
<Select placeholder="请选择客户等级" allowClear>
<Option value="vip">VIP客户</Option>
<Option value="normal"></Option>
<Option value="potential"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gender" label="性别">
<Select placeholder="请选择性别" allowClear>
<Option value="male"></Option>
<Option value="female"></Option>
<Option value="unknown"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="dateRange" label="创建时间">
<RangePicker
style={{ width: "100%" }}
placeholder={["开始日期", "结束日期"]}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="tags" label="标签">
<Select
mode="multiple"
placeholder="请选择标签"
allowClear
showSearch
filterOption={(input, option) =>
(option?.children as unknown as string)
?.toLowerCase()
.includes(input.toLowerCase())
}
>
{allTags.map(tag => (
<Option key={tag.id} value={tag.name}>
<Space>
<TagOutlined style={{ color: tag.color }} />
{tag.name}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Form>
</div>
</Modal>
);
};
export default CustomerFilter;

View File

@@ -1,62 +0,0 @@
.customerForm {
.avatarSection {
text-align: center;
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.avatarUpload {
display: inline-block;
cursor: pointer;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
.avatarPlaceholder {
width: 80px;
height: 80px;
border: 2px dashed #d9d9d9;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 12px;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
.anticon {
font-size: 24px;
margin-bottom: 4px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.customerForm {
.avatarSection {
margin-bottom: 16px;
padding: 12px;
}
.avatarPlaceholder {
width: 60px;
height: 60px;
.anticon {
font-size: 20px;
}
}
}
}

View File

@@ -1,327 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Form,
Input,
Select,
DatePicker,
Button,
Row,
Col,
message,
Upload,
Avatar,
} from "antd";
import {
UserOutlined,
PhoneOutlined,
MailOutlined,
WechatOutlined,
UploadOutlined,
PlusOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import {
CustomerData,
CustomerFormData,
CustomerStatus,
CustomerSource,
} from "../data";
import { createCustomer, updateCustomer } from "../api";
import styles from "./CustomerFormModal.module.scss";
const { Option } = Select;
const { TextArea } = Input;
interface CustomerFormModalProps {
visible: boolean;
customer: CustomerData | null;
onCancel: () => void;
onOk: () => void;
}
const CustomerFormModal: React.FC<CustomerFormModalProps> = ({
visible,
customer,
onCancel,
onOk,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string>("");
const isEdit = !!customer;
useEffect(() => {
if (visible) {
if (customer) {
// 编辑模式,填充表单数据
form.setFieldsValue({
name: customer.name,
phone: customer.phone,
email: customer.email,
wechat: customer.wechat,
source: customer.source,
status: customer.status,
tags: customer.tags,
remark: customer.remark,
company: customer.company,
position: customer.position,
address: customer.address,
birthday: customer.birthday ? dayjs(customer.birthday) : undefined,
gender: customer.gender,
level: customer.level,
});
setAvatarUrl(customer.avatar || "");
} else {
// 新增模式,重置表单
form.resetFields();
setAvatarUrl("");
}
}
}, [visible, customer, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const formData: CustomerFormData = {
...values,
birthday: values.birthday
? values.birthday.format("YYYY-MM-DD")
: undefined,
avatar: avatarUrl,
};
if (isEdit) {
await updateCustomer(customer!.id, formData);
message.success("客户信息更新成功");
} else {
await createCustomer(formData);
message.success("客户创建成功");
}
onOk();
} catch (error) {
console.error("提交失败:", error);
message.error(isEdit ? "更新失败" : "创建失败");
} finally {
setLoading(false);
}
};
const handleAvatarChange = (info: any) => {
if (info.file.status === "done") {
setAvatarUrl(info.file.response.url);
message.success("头像上传成功");
} else if (info.file.status === "error") {
message.error("头像上传失败");
}
};
const uploadProps = {
name: "file",
action: "/api/upload",
headers: {
authorization: "authorization-text",
},
onChange: handleAvatarChange,
showUploadList: false,
};
return (
<Modal
title={isEdit ? "编辑客户" : "新增客户"}
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
{isEdit ? "更新" : "创建"}
</Button>,
]}
width={800}
destroyOnClose
>
<Form
form={form}
layout="vertical"
className={styles.customerForm}
initialValues={{
status: CustomerStatus.ACTIVE,
source: CustomerSource.OTHER,
gender: "unknown",
level: "normal",
tags: [],
}}
>
{/* 头像上传 */}
<div className={styles.avatarSection}>
<Upload {...uploadProps}>
<div className={styles.avatarUpload}>
{avatarUrl ? (
<Avatar size={80} src={avatarUrl} />
) : (
<div className={styles.avatarPlaceholder}>
<UploadOutlined />
<div></div>
</div>
)}
</div>
</Upload>
</div>
<Row gutter={16}>
{/* 基本信息 */}
<Col span={12}>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: "请输入客户姓名" }]}
>
<Input prefix={<UserOutlined />} placeholder="请输入客户姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号" },
]}
>
<Input prefix={<PhoneOutlined />} placeholder="请输入手机号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="email"
label="邮箱"
rules={[{ type: "email", message: "请输入正确的邮箱格式" }]}
>
<Input prefix={<MailOutlined />} placeholder="请输入邮箱" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="wechat" label="微信号">
<Input prefix={<WechatOutlined />} placeholder="请输入微信号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="gender" label="性别">
<Select placeholder="请选择性别">
<Option value="male"></Option>
<Option value="female"></Option>
<Option value="unknown"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="birthday" label="生日">
<DatePicker
style={{ width: "100%" }}
placeholder="请选择生日"
format="YYYY-MM-DD"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="source"
label="客户来源"
rules={[{ required: true, message: "请选择客户来源" }]}
>
<Select placeholder="请选择客户来源">
<Option value={CustomerSource.WECHAT}></Option>
<Option value={CustomerSource.WEBSITE}></Option>
<Option value={CustomerSource.REFERRAL}></Option>
<Option value={CustomerSource.OTHER}></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="status"
label="客户状态"
rules={[{ required: true, message: "请选择客户状态" }]}
>
<Select placeholder="请选择客户状态">
<Option value={CustomerStatus.ACTIVE}></Option>
<Option value={CustomerStatus.INACTIVE}></Option>
<Option value={CustomerStatus.POTENTIAL}></Option>
<Option value={CustomerStatus.LOST}></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="level" label="客户等级">
<Select placeholder="请选择客户等级">
<Option value="vip">VIP客户</Option>
<Option value="normal"></Option>
<Option value="potential"></Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="company" label="公司">
<Input placeholder="请输入公司名称" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="position" label="职位">
<Input placeholder="请输入职位" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="address" label="地址">
<Input placeholder="请输入地址" />
</Form.Item>
</Col>
</Row>
<Form.Item name="tags" label="标签">
<Select
mode="tags"
placeholder="请输入标签,按回车确认"
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<TextArea
rows={4}
placeholder="请输入备注信息"
maxLength={500}
showCount
/>
</Form.Item>
</Form>
</Modal>
);
};
export default CustomerFormModal;

View File

@@ -1,106 +0,0 @@
.tagModalContent {
.createTagSection {
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
.tagListSection {
margin-bottom: 24px;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
.tagGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.tagItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
background: #f6ffed;
}
&.selected {
border-color: #1890ff;
background: #e6f7ff;
}
.tagActions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.3s;
}
&:hover .tagActions {
opacity: 1;
}
}
}
.selectedTagsSection {
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
.selectedTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.tagModalContent {
.createTagSection {
margin-bottom: 16px;
padding: 12px;
}
.tagListSection {
margin-bottom: 16px;
.tagGrid {
grid-template-columns: 1fr;
gap: 8px;
}
.tagItem {
padding: 6px 8px;
.tagActions {
opacity: 1;
}
}
}
}
}

View File

@@ -1,325 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Tag,
Button,
Input,
Space,
List,
Empty,
message,
Popconfirm,
ColorPicker,
Form,
Row,
Col,
} from "antd";
import {
PlusOutlined,
DeleteOutlined,
TagOutlined,
EditOutlined,
} from "@ant-design/icons";
import { CustomerData, TagData } from "../data";
import {
getCustomerTags,
updateCustomerTags,
getAllTags,
createTag,
deleteTag,
} from "../api";
import styles from "./CustomerTagModal.module.scss";
interface CustomerTagModalProps {
visible: boolean;
customer: CustomerData | null;
onCancel: () => void;
onOk: () => void;
}
const CustomerTagModal: React.FC<CustomerTagModalProps> = ({
visible,
customer,
onCancel,
onOk,
}) => {
const [loading, setLoading] = useState(false);
const [customerTags, setCustomerTags] = useState<string[]>([]);
const [allTags, setAllTags] = useState<TagData[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [newTagName, setNewTagName] = useState("");
const [newTagColor, setNewTagColor] = useState("#1890ff");
const [editingTag, setEditingTag] = useState<TagData | null>(null);
useEffect(() => {
if (visible && customer) {
fetchCustomerTags();
fetchAllTags();
}
}, [visible, customer]);
useEffect(() => {
if (customerTags.length > 0) {
setSelectedTags(customerTags);
}
}, [customerTags]);
const fetchCustomerTags = async () => {
if (!customer) return;
try {
const tags = await getCustomerTags(customer.id);
setCustomerTags(tags.map(tag => tag.name));
} catch (error) {
console.error("获取客户标签失败:", error);
}
};
const fetchAllTags = async () => {
try {
const tags = await getAllTags();
setAllTags(tags);
} catch (error) {
console.error("获取所有标签失败:", error);
}
};
const handleTagToggle = (tagName: string) => {
setSelectedTags(prev => {
if (prev.includes(tagName)) {
return prev.filter(tag => tag !== tagName);
} else {
return [...prev, tagName];
}
});
};
const handleSave = async () => {
if (!customer) return;
try {
setLoading(true);
await updateCustomerTags(customer.id, selectedTags);
message.success("标签更新成功");
onOk();
} catch (error) {
message.error("标签更新失败");
} finally {
setLoading(false);
}
};
const handleCreateTag = async () => {
if (!newTagName.trim()) {
message.warning("请输入标签名称");
return;
}
try {
await createTag({
name: newTagName.trim(),
color: newTagColor,
});
message.success("标签创建成功");
setNewTagName("");
setNewTagColor("#1890ff");
fetchAllTags();
} catch (error) {
message.error("标签创建失败");
}
};
const handleDeleteTag = async (tagId: string) => {
try {
await deleteTag(tagId);
message.success("标签删除成功");
fetchAllTags();
// 如果删除的标签在客户标签中,也要移除
const tagToDelete = allTags.find(tag => tag.id === tagId);
if (tagToDelete && selectedTags.includes(tagToDelete.name)) {
setSelectedTags(prev => prev.filter(tag => tag !== tagToDelete.name));
}
} catch (error) {
message.error("标签删除失败");
}
};
const handleEditTag = (tag: TagData) => {
setEditingTag(tag);
setNewTagName(tag.name);
setNewTagColor(tag.color);
};
const handleUpdateTag = async () => {
if (!editingTag || !newTagName.trim()) {
message.warning("请输入标签名称");
return;
}
try {
// 这里需要后端提供更新标签的接口
// await updateTag(editingTag.id, { name: newTagName.trim(), color: newTagColor });
message.success("标签更新成功");
setEditingTag(null);
setNewTagName("");
setNewTagColor("#1890ff");
fetchAllTags();
} catch (error) {
message.error("标签更新失败");
}
};
const handleCancelEdit = () => {
setEditingTag(null);
setNewTagName("");
setNewTagColor("#1890ff");
};
return (
<Modal
title={`管理标签 - ${customer?.name}`}
open={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
</Button>,
<Button
key="save"
type="primary"
loading={loading}
onClick={handleSave}
>
</Button>,
]}
width={600}
>
<div className={styles.tagModalContent}>
{/* 创建新标签 */}
<div className={styles.createTagSection}>
<h4></h4>
<Row gutter={16} align="middle">
<Col span={8}>
<Input
placeholder="标签名称"
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
onPressEnter={editingTag ? handleUpdateTag : handleCreateTag}
/>
</Col>
<Col span={6}>
<ColorPicker
value={newTagColor}
onChange={color => setNewTagColor(color.toHexString())}
/>
</Col>
<Col span={10}>
<Space>
{editingTag ? (
<>
<Button
type="primary"
size="small"
onClick={handleUpdateTag}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</>
) : (
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleCreateTag}
>
</Button>
)}
</Space>
</Col>
</Row>
</div>
{/* 标签列表 */}
<div className={styles.tagListSection}>
<h4></h4>
{allTags.length > 0 ? (
<div className={styles.tagGrid}>
{allTags.map(tag => (
<div
key={tag.id}
className={`${styles.tagItem} ${
selectedTags.includes(tag.name) ? styles.selected : ""
}`}
onClick={() => handleTagToggle(tag.name)}
>
<Tag
color={tag.color}
icon={<TagOutlined />}
style={{ cursor: "pointer" }}
>
{tag.name}
</Tag>
<div className={styles.tagActions}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={e => {
e.stopPropagation();
handleEditTag(tag);
}}
/>
<Popconfirm
title="确定要删除这个标签吗?"
onConfirm={() => handleDeleteTag(tag.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
danger
onClick={e => e.stopPropagation()}
/>
</Popconfirm>
</div>
</div>
))}
</div>
) : (
<Empty description="暂无标签" />
)}
</div>
{/* 已选标签 */}
{selectedTags.length > 0 && (
<div className={styles.selectedTagsSection}>
<h4></h4>
<div className={styles.selectedTags}>
{selectedTags.map(tagName => {
const tag = allTags.find(t => t.name === tagName);
return (
<Tag
key={tagName}
color={tag?.color || "#1890ff"}
icon={<TagOutlined />}
closable
onClose={() => handleTagToggle(tagName)}
>
{tagName}
</Tag>
);
})}
</div>
</div>
)}
</div>
</Modal>
);
};
export default CustomerTagModal;

View File

@@ -1,6 +1,7 @@
.messageList {
height: 100%;
overflow-y: auto;
position: relative;
.messageItem {
padding: 12px 16px;
@@ -66,6 +67,33 @@
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
position: relative;
padding-right: 5px;
height: 18px; // 添加固定高度
line-height: 18px; // 设置行高与高度一致
&::before {
content: attr(data-count);
position: absolute;
right: -5px;
top: 0;
background-color: #ff4d4f;
color: white;
border-radius: 10px;
padding: 0 6px;
font-size: 10px;
line-height: 16px;
min-width: 16px;
height: 16px;
text-align: center;
display: none;
}
&[data-count]:not([data-count=""]):not([data-count="0"]) {
&::before {
display: inline-block;
}
}
}
.onlineIndicator {
@@ -78,6 +106,19 @@
}
}
}
.lastDayMessage {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #f9f9f9;
padding: 8px 16px;
font-size: 12px;
color: #8c8c8c;
text-align: center;
border-top: 1px solid #f0f0f0;
}
}
// 响应式设计

View File

@@ -0,0 +1,6 @@
import request from "@/api/request";
// 获取联系人列表
export const getContactList = (params: { prevId: string; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};

View File

@@ -0,0 +1,48 @@
// 联系人数据接口
export interface ContractData {
id?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields: null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config: null;
lastMessageTime: number;
unreadCount: number;
duplicate: boolean;
[key: string]: any;
}
//聊天会话类型
export type ChatType = "private" | "group";
// 聊天会话接口
export interface ChatSession {
id: number;
type: ChatType;
name: string;
avatar?: string;
lastMessage: string;
lastTime: string;
unreadCount: number;
online: boolean;
members?: string[];
pinned?: boolean;
muted?: boolean;
}

View File

@@ -1,43 +1,27 @@
import React from "react";
import { List, Avatar, Badge } from "antd";
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { ChatSession } from "../data";
import { ContractData, GroupData } from "@/pages/pc/ckbox/data";
import styles from "./MessageList.module.scss";
import { formatWechatTime } from "@/utils/common";
interface MessageListProps {
sessions: ChatSession[];
currentChat: ChatSession | null;
onChatSelect: (chat: ChatSession) => void;
chatSessions: ContractData[] | GroupData[];
currentChat: ContractData | GroupData;
onChatSelect: (chat: ContractData | GroupData) => void;
}
const MessageList: React.FC<MessageListProps> = ({
sessions,
chatSessions,
currentChat,
onChatSelect,
}) => {
const formatTime = (timestamp: string) => {
const now = dayjs();
const messageTime = dayjs(timestamp);
const diffDays = now.diff(messageTime, "day");
if (diffDays === 0) {
return messageTime.format("HH:mm");
} else if (diffDays === 1) {
return "昨天";
} else if (diffDays < 7) {
return messageTime.format("ddd");
} else {
return messageTime.format("MM-DD");
}
};
return (
<div className={styles.messageList}>
<List
dataSource={sessions}
dataSource={chatSessions as ContractData[]}
renderItem={session => (
<List.Item
key={session.id}
className={`${styles.messageItem} ${
currentChat?.id === session.id ? styles.active : ""
}`}
@@ -47,9 +31,9 @@ const MessageList: React.FC<MessageListProps> = ({
<Badge count={session.unreadCount} size="small">
<Avatar
size={48}
src={session.avatar}
src={session.avatar || session.chatroomAvatar}
icon={
session.type === "group" ? (
session?.type === "group" ? (
<TeamOutlined />
) : (
<UserOutlined />
@@ -59,24 +43,27 @@ const MessageList: React.FC<MessageListProps> = ({
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>{session.name}</div>
<div className={styles.messageName}>{session.nickname}</div>
<div className={styles.messageTime}>
{formatTime(session.lastTime)}
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
<div className={styles.lastMessage}>
{session.lastMessage}
<div
className={styles.lastMessage}
data-count={
session.unreadCount > 0 ? session.unreadCount : ""
}
>
{session?.lastMessage}
</div>
{session.online && (
<div className={styles.onlineIndicator}>线</div>
)}
</div>
</div>
</div>
</List.Item>
)}
/>
<div className={styles.lastDayMessage}></div>
</div>
);
};

View File

@@ -0,0 +1,111 @@
.sidebarMenu {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
.headerContainer {
padding: 16px 16px 0px 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.searchBar {
margin-bottom: 16px;
padding: 0;
background: #fff;
}
.tabsContainer {
display: flex;
justify-content: space-around;
border-bottom: 1px solid #f0f0f0;
.tabItem {
padding: 8px 0;
flex: 1;
text-align: center;
cursor: pointer;
color: #999;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
&.active {
color: #1890ff;
border-bottom: 2px solid #1890ff;
}
span {
margin-left: 4px;
}
}
}
}
}
// 骨架屏样式
.skeletonContainer {
height: 100%;
padding: 16px;
display: flex;
flex-direction: column;
.searchBarSkeleton {
margin-bottom: 16px;
}
.tabsContainerSkeleton {
display: flex;
justify-content: space-around;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
gap: 8px;
}
.contactListSkeleton {
flex: 1;
overflow-y: auto;
.contactItemSkeleton {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
.contactInfoSkeleton {
margin-left: 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
padding: 20px;
text-align: center;
}
.contentContainer {
flex: 1;
overflow-y: auto;
}
.footer {
padding: 10px;
text-align: center;
border-top: 1px solid #f0f0f0;
background: #fff;
display: none; /* 默认隐藏底部,如果需要显示可以移除此行 */
}

View File

@@ -0,0 +1,115 @@
.contractListSimple {
display: flex;
flex-direction: column;
height: 100%;
background-color: #fff;
color: #333;
.header {
padding: 10px 15px;
font-weight: bold;
border-bottom: 1px solid #f0f0f0;
}
.groupCollapse {
width: 100%;
background-color: transparent;
border: none;
:global(.ant-collapse-item) {
border-bottom: 1px solid #f0f0f0;
}
:global(.ant-collapse-header) {
padding: 10px 15px !important;
font-weight: bold;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
}
}
.groupHeader {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.contactCount {
font-size: 12px;
color: #999;
font-weight: normal;
}
.groupPanel {
background-color: transparent;
}
.loadMoreContainer {
display: flex;
justify-content: center;
padding: 10px 0;
}
.noMoreText {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px 0;
}
.list {
flex: 1;
overflow-y: auto;
:global(.ant-list-item) {
padding: 10px 15px;
border-bottom: none;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
}
}
.contractItem {
display: flex;
align-items: center;
padding: 8px 15px;
&.selected {
background-color: #f5f5f5;
}
}
.avatarContainer {
margin-right: 10px;
}
.avatar {
background-color: #1890ff;
}
.contractInfo {
flex: 1;
overflow: hidden;
}
.name {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 12px;
color: #aaa;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@@ -0,0 +1,186 @@
import React, { useState, useCallback, useEffect } from "react";
import { List, Avatar, Collapse, Button } from "antd";
import type { CollapseProps } from "antd";
import styles from "./WechatFriends.module.scss";
import { useCkChatStore } from "@/store/module/ckchat";
import { ContractData, GroupData } from "@/pages/pc/ckbox/data";
interface WechatFriendsProps {
contracts: ContractData[] | GroupData[];
onContactClick: (contract: ContractData | GroupData) => void;
selectedContactId?: ContractData | GroupData;
}
const ContactListSimple: React.FC<WechatFriendsProps> = ({
contracts,
onContactClick,
selectedContactId,
}) => {
const newContractList = useCkChatStore(state => state.newContractList);
const [activeKey, setActiveKey] = useState<string[]>([]); // 默认展开第一个分组
// 分页加载相关状态
const [visibleContacts, setVisibleContacts] = useState<{
[key: string]: ContractData[];
}>({});
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
const [hasMore, setHasMore] = useState<{ [key: string]: boolean }>({});
const [page, setPage] = useState<{ [key: string]: number }>({});
// 渲染联系人项
const renderContactItem = (contact: ContractData) => (
<List.Item
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Avatar
src={contact.avatar}
icon={!contact.avatar && <span>{contact.nickname.charAt(0)}</span>}
className={styles.avatar}
/>
</div>
<div className={styles.contractInfo}>
<div className={styles.name}>
{contact.conRemark || contact.nickname}
</div>
</div>
</List.Item>
);
// 初始化分页数据
useEffect(() => {
if (newContractList && newContractList.length > 0) {
const initialVisibleContacts: { [key: string]: ContractData[] } = {};
const initialLoading: { [key: string]: boolean } = {};
const initialHasMore: { [key: string]: boolean } = {};
const initialPage: { [key: string]: number } = {};
newContractList.forEach((group, index) => {
const groupKey = index.toString();
// 每个分组初始加载20条数据
const pageSize = 20;
initialVisibleContacts[groupKey] = group.contacts.slice(0, pageSize);
initialLoading[groupKey] = false;
initialHasMore[groupKey] = group.contacts.length > pageSize;
initialPage[groupKey] = 1;
});
setVisibleContacts(initialVisibleContacts);
setLoading(initialLoading);
setHasMore(initialHasMore);
setPage(initialPage);
}
}, [newContractList]);
// 加载更多联系人
const loadMoreContacts = useCallback(
(groupKey: string) => {
if (loading[groupKey] || !hasMore[groupKey] || !newContractList) return;
setLoading(prev => ({ ...prev, [groupKey]: true }));
// 模拟异步加载
setTimeout(() => {
const groupIndex = parseInt(groupKey);
const group = newContractList[groupIndex];
if (!group) return;
const pageSize = 20;
const currentPage = page[groupKey] || 1;
const nextPage = currentPage + 1;
const startIndex = currentPage * pageSize;
const endIndex = nextPage * pageSize;
const newContacts = group.contacts.slice(startIndex, endIndex);
setVisibleContacts(prev => ({
...prev,
[groupKey]: [...(prev[groupKey] || []), ...newContacts],
}));
setPage(prev => ({ ...prev, [groupKey]: nextPage }));
setHasMore(prev => ({
...prev,
[groupKey]: endIndex < group.contacts.length,
}));
setLoading(prev => ({ ...prev, [groupKey]: false }));
}, 300);
},
[loading, hasMore, page, newContractList],
);
// 渲染加载更多按钮
const renderLoadMoreButton = (groupKey: string) => {
if (!hasMore[groupKey])
return <div className={styles.noMoreText}></div>;
return (
<div className={styles.loadMoreContainer}>
<Button
size="small"
loading={loading[groupKey]}
onClick={() => loadMoreContacts(groupKey)}
>
{loading[groupKey] ? "加载中..." : "加载更多"}
</Button>
</div>
);
};
// 构建Collapse的items属性
const getCollapseItems = (): CollapseProps["items"] => {
if (!newContractList || newContractList.length === 0) return [];
return newContractList.map((group, index) => {
const groupKey = index.toString();
const isActive = activeKey.includes(groupKey);
return {
key: groupKey,
label: (
<div className={styles.groupHeader}>
<span>{group.groupName}</span>
<span className={styles.contactCount}>{group.contacts.length}</span>
</div>
),
className: styles.groupPanel,
children: isActive ? (
<>
<List
className={styles.list}
dataSource={visibleContacts[groupKey] || []}
renderItem={renderContactItem}
/>
{renderLoadMoreButton(groupKey)}
</>
) : null,
};
});
};
return (
<div className={styles.contractListSimple}>
{newContractList && newContractList.length > 0 ? (
<Collapse
className={styles.groupCollapse}
activeKey={activeKey}
onChange={keys => setActiveKey(keys as string[])}
items={getCollapseItems()}
/>
) : (
<>
<div className={styles.header}></div>
<List
className={styles.list}
dataSource={contracts as ContractData[]}
renderItem={renderContactItem}
/>
</>
)}
</div>
);
};
export default ContactListSimple;

View File

@@ -0,0 +1,184 @@
import React, { useState } from "react";
import { Input, Skeleton } from "antd";
import {
SearchOutlined,
UserOutlined,
ChromeOutlined,
MessageOutlined,
} from "@ant-design/icons";
import { ContractData, GroupData } from "@/pages/pc/ckbox/data";
import WechatFriends from "./WechatFriends";
import MessageList from "./MessageList/index";
import styles from "./SidebarMenu.module.scss";
import { getChatSessions } from "@/store/module/ckchat";
interface SidebarMenuProps {
contracts: ContractData[] | GroupData[];
currentChat: ContractData | GroupData;
onContactClick: (contract: ContractData | GroupData) => void;
onChatSelect: (chat: ContractData | GroupData) => void;
loading?: boolean;
}
const SidebarMenu: React.FC<SidebarMenuProps> = ({
contracts,
currentChat,
onContactClick,
onChatSelect,
loading = false,
}) => {
const chatSessions = getChatSessions();
const [searchText, setSearchText] = useState("");
const [activeTab, setActiveTab] = useState("chats");
const handleSearch = (value: string) => {
setSearchText(value);
};
const getFilteredContacts = () => {
if (!searchText) return contracts;
return contracts.filter(
contract =>
contract.nickname.toLowerCase().includes(searchText.toLowerCase()) ||
contract.phone.includes(searchText),
);
};
const getFilteredSessions = () => {
if (!searchText) return chatSessions;
return chatSessions.filter(session =>
session.nickname.toLowerCase().includes(searchText.toLowerCase()),
);
};
// 渲染骨架屏
const renderSkeleton = () => (
<div className={styles.skeletonContainer}>
<div className={styles.searchBarSkeleton}>
<Skeleton.Input active size="small" block />
</div>
<div className={styles.tabsContainerSkeleton}>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
</div>
<div className={styles.contactListSkeleton}>
{Array(8)
.fill(null)
.map((_, index) => (
<div
key={`contact-skeleton-${index}`}
className={styles.contactItemSkeleton}
>
<Skeleton.Avatar active size="large" shape="circle" />
<div className={styles.contactInfoSkeleton}>
<Skeleton.Input active size="small" style={{ width: "60%" }} />
<Skeleton.Input active size="small" style={{ width: "80%" }} />
</div>
</div>
))}
</div>
</div>
);
// 渲染Header部分包含搜索框和标签页切换
const renderHeader = () => (
<div className={styles.headerContainer}>
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索联系人、群组"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => handleSearch(e.target.value)}
allowClear
/>
</div>
{/* 标签页切换 */}
<div className={styles.tabsContainer}>
<div
className={`${styles.tabItem} ${activeTab === "chats" ? styles.active : ""}`}
onClick={() => setActiveTab("chats")}
>
<MessageOutlined />
<span></span>
</div>
<div
className={`${styles.tabItem} ${activeTab === "contracts" ? styles.active : ""}`}
onClick={() => setActiveTab("contracts")}
>
<UserOutlined />
<span></span>
</div>
<div
className={`${styles.tabItem} ${activeTab === "groups" ? styles.active : ""}`}
onClick={() => setActiveTab("groups")}
>
<ChromeOutlined />
<span></span>
</div>
</div>
</div>
);
// 渲染内容部分
const renderContent = () => {
switch (activeTab) {
case "chats":
return (
<MessageList
chatSessions={getFilteredSessions()}
onChatSelect={onChatSelect}
currentChat={currentChat}
/>
);
case "contracts":
return (
<WechatFriends
contracts={getFilteredContacts() as ContractData[]}
onContactClick={onContactClick}
selectedContactId={currentChat}
/>
);
case "groups":
return (
<div className={styles.emptyState}>
<ChromeOutlined style={{ fontSize: 48, color: "#ccc" }} />
<p></p>
</div>
);
default:
return null;
}
};
if (loading) {
return renderSkeleton();
}
return (
<div className={styles.sidebarMenu}>
{renderHeader()}
<div className={styles.contentContainer}>{renderContent()}</div>
</div>
);
};
export default SidebarMenu;

View File

@@ -0,0 +1,111 @@
.skeletonLayout {
height: 100vh;
display: flex;
flex-direction: column;
.skeletonHeader {
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #f0f0f0;
}
.skeletonVerticalSider {
background-color: #fff;
border-right: 1px solid #f0f0f0;
.verticalUserList {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
.verticalUserItem {
margin-bottom: 16px;
}
}
}
.skeletonSider {
background-color: #fff;
border-right: 1px solid #f0f0f0;
padding: 16px;
.searchSkeleton {
margin-bottom: 16px;
}
.tabsSkeleton {
display: flex;
justify-content: space-around;
margin-bottom: 16px;
}
.contactListSkeleton {
.contactItemSkeleton {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
.contactInfoSkeleton {
margin-left: 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
}
}
}
.skeletonMainContent {
background-color: #f5f5f5;
padding: 16px;
display: flex;
flex-direction: column;
.chatHeaderSkeleton {
background-color: #fff;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 8px 8px 0 0;
}
.chatContentSkeleton {
flex: 1;
background-color: #fff;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
.messageSkeleton {
display: flex;
align-items: flex-start;
gap: 8px;
&.leftMessage {
align-self: flex-start;
}
&.rightMessage {
align-self: flex-end;
flex-direction: row-reverse;
}
}
}
.inputAreaSkeleton {
background-color: #fff;
padding: 16px;
border-radius: 0 0 8px 8px;
border-top: 1px solid #f0f0f0;
}
}
}

View File

@@ -0,0 +1,119 @@
import React from "react";
import { Skeleton, Layout } from "antd";
import styles from "./index.module.scss";
import pageStyles from "../../index.module.scss";
const { Header, Content, Sider } = Layout;
interface PageSkeletonProps {
loading: boolean;
children: React.ReactNode;
}
/**
* 页面骨架屏组件
* 在数据加载完成前显示骨架屏
*/
const PageSkeleton: React.FC<PageSkeletonProps> = ({ loading, children }) => {
if (!loading) return <>{children}</>;
return (
<Layout className={pageStyles.ckboxLayout}>
<Header className={pageStyles.header}>
<Skeleton.Button active size="large" shape="square" block />
</Header>
<Layout>
{/* 垂直侧边栏骨架 */}
<Sider width={60} className={pageStyles.verticalSider}>
<div className={styles.verticalUserList}>
{Array(5)
.fill(null)
.map((_, index) => (
<div
key={`vertical-${index}`}
className={styles.verticalUserItem}
>
<Skeleton.Avatar active size="large" shape="circle" />
</div>
))}
</div>
</Sider>
{/* 左侧联系人边栏骨架 */}
<Sider width={280} className={pageStyles.sider}>
<div className={styles.searchSkeleton}>
<Skeleton.Input active size="small" block />
</div>
<div className={styles.tabsSkeleton}>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
<Skeleton.Button
active
size="small"
shape="square"
style={{ width: "30%" }}
/>
</div>
<div className={styles.contactListSkeleton}>
{Array(8)
.fill(null)
.map((_, index) => (
<div
key={`contact-${index}`}
className={styles.contactItemSkeleton}
>
<Skeleton.Avatar active size="large" shape="circle" />
<div className={styles.contactInfoSkeleton}>
<Skeleton.Input
active
size="small"
style={{ width: "60%" }}
/>
<Skeleton.Input
active
size="small"
style={{ width: "80%" }}
/>
</div>
</div>
))}
</div>
</Sider>
{/* 主内容区骨架 */}
<Content className={styles.skeletonMainContent}>
<div className={styles.chatHeaderSkeleton}>
<Skeleton.Avatar active size="large" shape="circle" />
<Skeleton.Input active size="small" style={{ width: "30%" }} />
</div>
<div className={styles.chatContentSkeleton}>
{Array(5)
.fill(null)
.map((_, index) => (
<div
key={`message-${index}`}
className={`${styles.messageSkeleton} ${index % 2 === 0 ? styles.leftMessage : styles.rightMessage}`}
>
<Skeleton.Avatar active size="small" shape="circle" />
<Skeleton.Input
active
size="small"
style={{ width: index % 2 === 0 ? "60%" : "40%" }}
/>
</div>
))}
</div>
<div className={styles.inputAreaSkeleton}>
<Skeleton.Input active size="large" block />
</div>
</Content>
</Layout>
</Layout>
);
};
export default PageSkeleton;

View File

@@ -0,0 +1,102 @@
.verticalUserList {
display: flex;
flex-direction: column;
height: 100%;
background-color: #2e2e2e;
color: #fff;
width: 60px;
.userListHeader {
padding: 10px 0;
text-align: center;
border-bottom: 1px solid #3a3a3a;
cursor: pointer;
.allFriends {
font-size: 12px;
color: #ccc;
}
}
.userList {
flex: 1;
overflow-y: auto;
padding: 10px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: #555;
border-radius: 2px;
}
}
.userItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 0;
position: relative;
cursor: pointer;
&:hover {
background-color: #3a3a3a;
}
&.active {
background-color: #3a3a3a;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background-color: #1890ff;
}
}
}
.userAvatar {
border: 2px solid transparent;
.active & {
border-color: #1890ff;
}
}
.messageBadge {
:global(.ant-badge-count) {
background-color: #ff4d4f;
box-shadow: none;
font-size: 10px;
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
border-radius: 8px;
}
}
.onlineIndicator {
position: absolute;
bottom: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid #2e2e2e;
&.online {
background-color: #52c41a; // 绿色表示在线
}
&.offline {
background-color: #8c8c8c; // 灰色表示离线
}
}
}

View File

@@ -0,0 +1,65 @@
import React, { useState } from "react";
import { Avatar, Badge, Tooltip } from "antd";
import styles from "./VerticalUserList.module.scss";
import { useCkChatStore, asyncKfSelected } from "@/store/module/ckchat";
import { TeamOutlined } from "@ant-design/icons";
const VerticalUserList: React.FC = () => {
// 格式化消息数量显示
const formatMessageCount = (count: number) => {
if (count > 99) return "99+";
return count.toString();
};
const handleUserSelect = (userId: number) => {
asyncKfSelected(userId);
};
const kfUserList = useCkChatStore(state => state.kfUserList);
const kfSelected = useCkChatStore(state => state.kfSelected);
return (
<div className={styles.verticalUserList}>
<div
className={styles.userListHeader}
onClick={() => handleUserSelect(0)}
>
<TeamOutlined style={{ fontSize: "26px" }} />
<div className={styles.allFriends}></div>
</div>
<div className={styles.userList}>
{kfUserList.map(user => (
<Tooltip key={user.id} title={user.name} placement="right">
<div
className={`${styles.userItem} ${kfSelected === user.id ? styles.active : ""}`}
onClick={() => handleUserSelect(user.id)}
>
<Badge
count={
user.messageCount ? formatMessageCount(user.messageCount) : 0
}
overflowCount={99}
className={styles.messageBadge}
>
<Avatar
src={user.avatar}
size={40}
className={styles.userAvatar}
style={{
backgroundColor: !user.avatar ? "#1890ff" : undefined,
}}
>
{!user.avatar && user.name.charAt(0)}
</Avatar>
</Badge>
<div
className={`${styles.onlineIndicator} ${user.isOnline ? styles.online : styles.offline}`}
/>
</div>
</Tooltip>
))}
</div>
</div>
);
};
export default VerticalUserList;

View File

@@ -1,14 +1,142 @@
//终端用户数据接口
export interface KfUserListData {
id: number;
tenantId: number;
wechatId: string;
nickname: string;
alias: string;
avatar: string;
gender: number;
region: string;
signature: string;
bindQQ: string;
bindEmail: string;
bindMobile: string;
createTime: string;
currentDeviceId: number;
isDeleted: boolean;
deleteTime: string;
groupId: number;
memo: string;
wechatVersion: string;
labels: string[];
lastUpdateTime: string;
isOnline?: boolean;
[key: string]: any;
}
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
//群聊数据接口
export interface GroupData {
id?: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
chatroomId: string;
chatroomOwner: string;
conRemark: string;
nickname: string;
chatroomAvatar: string;
groupId: number;
config?: {
chat: boolean;
};
unreadCount: number;
notice: string;
selfDisplyName: string;
[key: string]: any;
}
// 联系人数据接口
export interface ContactData {
id: string;
name: string;
phone: string;
export interface ContractData {
id?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
online: boolean;
lastSeen?: string;
status?: string;
department?: string;
position?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields: null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config?: {
chat: boolean;
};
lastMessageTime: number;
unreadCount: number;
duplicate: boolean;
[key: string]: any;
}
//聊天记录接口
export interface ChatRecord {
id: number;
wechatFriendId: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
synergyAccountId: number;
content: string;
msgType: number;
msgSubType: number;
msgSvrId: string;
isSend: boolean;
createTime: string;
isDeleted: boolean;
deleteTime: string;
sendStatus: number;
wechatTime: number;
origin: number;
msgId: number;
recalled: boolean;
[key: string]: any;
}
/**
* 微信好友基本信息接口
* 包含主要字段和兼容性字段
*/
export interface WechatFriend {
// 主要字段
id: number; // 好友ID
wechatAccountId: number; // 微信账号ID
wechatId: string; // 微信ID
nickname: string; // 昵称
conRemark: string; // 备注名
avatar: string; // 头像URL
gender: number; // 性别1-男2-女0-未知
region: string; // 地区
phone: string; // 电话
labels: string[]; // 标签列表
[key: string]: any;
}
// 消息类型枚举
@@ -52,18 +180,6 @@ export interface ChatSession {
muted?: boolean;
}
// 群组信息接口
export interface GroupData {
id: string;
name: string;
avatar?: string;
description?: string;
members: ContactData[];
adminIds: string[];
createdAt: string;
updatedAt: string;
}
// 聊天历史响应接口
export interface ChatHistoryResponse {
messages: MessageData[];
@@ -79,12 +195,6 @@ export interface SendMessageRequest {
replyTo?: string;
}
// 联系人列表响应接口
export interface ContactListResponse {
contacts: ContactData[];
total: number;
}
// 搜索联系人请求接口
export interface SearchContactRequest {
keyword: string;

View File

@@ -1,10 +1,33 @@
.ckboxLayout {
height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
.header {
background: #1890ff;
color: #fff;
height: 64px;
line-height: 64px;
padding: 0 24px;
font-size: 18px;
font-weight: bold;
}
.verticalSider {
background: #2e2e2e;
border-right: 1px solid #3a3a3a;
overflow: hidden;
}
.sider {
background: #fff;
border-right: 1px solid #f0f0f0;
overflow: auto;
}
.sidebar {
background: #fff;
border-right: 1px solid #f0f0f0;
height: 100%;
display: flex;
flex-direction: column;
@@ -87,6 +110,8 @@
background: #f5f5f5;
display: flex;
flex-direction: column;
padding: 16px;
overflow: auto;
.chatContainer {
height: 100%;

View File

@@ -1,187 +1,67 @@
import React, { useState, useEffect, useRef } from "react";
import {
Layout,
Input,
Button,
Avatar,
List,
Badge,
Tabs,
Space,
Dropdown,
Menu,
message,
Popover,
Tooltip,
Divider,
} from "antd";
import {
SearchOutlined,
PlusOutlined,
MoreOutlined,
SendOutlined,
SmileOutlined,
PaperClipOutlined,
PhoneOutlined,
VideoCameraOutlined,
UserOutlined,
TeamOutlined,
MessageOutlined,
SettingOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import React, { useState, useEffect } from "react";
import { Layout, Button, Space, message, Tooltip } from "antd";
import { InfoCircleOutlined, MessageOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { ContactData, MessageData, ChatSession } from "./data";
import ChatWindow from "./components/ChatWindow/index";
import ContactList from "./components/ContactList/index";
import MessageList from "./components/MessageList/index";
import SidebarMenu from "./components/SidebarMenu/index";
import VerticalUserList from "./components/VerticalUserList";
import PageSkeleton from "./components/Skeleton";
import styles from "./index.module.scss";
const { Sider, Content } = Layout;
const { TabPane } = Tabs;
import { addChatSession } from "@/store/module/ckchat";
const { Header, Content, Sider } = Layout;
import { chatInitAPIdata } from "./main";
import { KfUserListData, GroupData, ContractData } from "@/pages/pc/ckbox/data";
const CkboxPage: React.FC = () => {
const [contacts, setContacts] = useState<ContactData[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [currentChat, setCurrentChat] = useState<ChatSession | null>(null);
const [searchText, setSearchText] = useState("");
const [messageApi, contextHolder] = message.useMessage();
const [contracts, setContacts] = useState<any[]>([]);
const [currentChat, setCurrentChat] = useState<ContractData | GroupData>(
null,
);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState("contacts");
const [showProfile, setShowProfile] = useState(true);
useEffect(() => {
fetchContacts();
fetchChatSessions();
// 方法一:使用 Promise 链式调用处理异步函数
setLoading(true);
chatInitAPIdata()
.then(response => {
const data = response as {
contractList: any[];
groupList: any[];
kfUserList: KfUserListData[];
newContractList: { groupName: string; contacts: any[] }[];
};
const { contractList } = data;
//找出已经在聊天的
const isChatList = contractList.filter(
v => (v?.config && v.config?.chat) || false,
);
isChatList.forEach(v => {
addChatSession(v);
});
setContacts(isChatList);
})
.catch(error => {
console.error("获取联系人列表失败:", error);
})
.finally(() => {
setLoading(false);
});
}, []);
const fetchContacts = async () => {
try {
setLoading(true);
// 模拟联系人数据
const mockContacts: ContactData[] = [
{
id: "1",
name: "张三",
phone: "13800138001",
avatar: "",
online: true,
status: "在线",
department: "技术部",
position: "前端工程师",
},
{
id: "2",
name: "李四",
phone: "13800138002",
avatar: "",
online: false,
status: "忙碌中",
department: "产品部",
position: "产品经理",
},
{
id: "3",
name: "王五",
phone: "13800138003",
avatar: "",
online: true,
status: "在线",
department: "设计部",
position: "UI设计师",
},
{
id: "4",
name: "赵六",
phone: "13800138004",
avatar: "",
online: false,
status: "离线",
department: "运营部",
position: "运营专员",
},
];
setContacts(mockContacts);
} catch (error) {
message.error("获取联系人失败");
} finally {
setLoading(false);
}
};
const fetchChatSessions = async () => {
try {
// 模拟聊天会话数据
const sessions: ChatSession[] = [
{
id: "1",
type: "private",
name: "张三",
avatar: "",
lastMessage: "你好,请问有什么可以帮助您的吗?",
lastTime: dayjs().subtract(5, "minute").toISOString(),
unreadCount: 2,
online: true,
},
{
id: "2",
type: "group",
name: "技术支持群",
avatar: "",
lastMessage: "新版本已经发布,请大家及时更新",
lastTime: dayjs().subtract(1, "hour").toISOString(),
unreadCount: 0,
online: false,
},
{
id: "3",
type: "private",
name: "李四",
avatar: "",
lastMessage: "谢谢您的帮助!",
lastTime: dayjs().subtract(2, "hour").toISOString(),
unreadCount: 0,
online: false,
},
];
setChatSessions(sessions);
} catch (error) {
message.error("获取聊天记录失败");
}
};
const handleContactClick = (contact: ContactData) => {
// 查找或创建聊天会话
let session = chatSessions.find(s => s.id === contact.id);
if (!session) {
session = {
id: contact.id,
type: "private",
name: contact.name,
avatar: contact.avatar,
lastMessage: "",
lastTime: dayjs().toISOString(),
unreadCount: 0,
online: contact.online,
};
setChatSessions(prev => [session!, ...prev]);
}
setCurrentChat(session);
const handleContactClick = (contract: ContractData | GroupData) => {
addChatSession(contract);
setCurrentChat(contract);
};
const handleSendMessage = async (message: string) => {
if (!currentChat || !message.trim()) return;
try {
const newMessage: MessageData = {
id: Date.now().toString(),
senderId: "me",
senderName: "我",
content: message,
type: "text" as any,
timestamp: dayjs().toISOString(),
isRead: false,
};
// 更新当前聊天会话
const updatedSession = {
...currentChat,
@@ -190,140 +70,82 @@ const CkboxPage: React.FC = () => {
unreadCount: 0,
};
setChatSessions(prev =>
prev.map(s => (s.id === currentChat.id ? updatedSession : s)),
);
setCurrentChat(updatedSession);
message.success("消息发送成功");
messageApi.success("消息发送成功");
} catch (error) {
message.error("消息发送失败");
messageApi.error("消息发送失败");
}
};
const handleSearch = (value: string) => {
setSearchText(value);
};
const getFilteredContacts = () => {
if (!searchText) return contacts;
return contacts.filter(
contact =>
contact.name.toLowerCase().includes(searchText.toLowerCase()) ||
contact.phone.includes(searchText),
);
};
const getFilteredSessions = () => {
if (!searchText) return chatSessions;
return chatSessions.filter(session =>
session.name.toLowerCase().includes(searchText.toLowerCase()),
);
// 处理垂直侧边栏用户选择
const handleVerticalUserSelect = (userId: string) => {
// setActiveVerticalUserId(userId);
// 这里可以根据选择的用户类别筛选不同的联系人列表
// 例如根据userId加载不同分类的联系人
};
return (
<Layout className={styles.ckboxLayout}>
{/* 左侧边栏 */}
<Sider width={300} className={styles.sidebar}>
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索联系人、群组"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => handleSearch(e.target.value)}
allowClear
/>
</div>
<PageSkeleton loading={loading}>
<Layout className={styles.ckboxLayout}>
{contextHolder}
<Header className={styles.header}></Header>
<Layout>
{/* 垂直侧边栏 */}
{/* 标签页 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className={styles.tabs}
>
<TabPane
tab={
<span>
<MessageOutlined />
</span>
}
key="chats"
>
<MessageList
sessions={getFilteredSessions()}
<Sider width={60} className={styles.verticalSider}>
<VerticalUserList />
</Sider>
{/* 左侧联系人边栏 */}
<Sider width={280} className={styles.sider}>
<SidebarMenu
contracts={contracts}
currentChat={currentChat}
onChatSelect={setCurrentChat}
/>
</TabPane>
<TabPane
tab={
<span>
<UserOutlined />
</span>
}
key="contacts"
>
<ContactList
contacts={getFilteredContacts()}
onContactClick={handleContactClick}
onChatSelect={setCurrentChat}
loading={loading}
/>
</TabPane>
<TabPane
tab={
<span>
<TeamOutlined />
</span>
}
key="groups"
>
<div className={styles.emptyState}>
<TeamOutlined style={{ fontSize: 48, color: "#ccc" }} />
<p></p>
</div>
</TabPane>
</Tabs>
</Sider>
</Sider>
{/* 主内容区 */}
<Content className={styles.mainContent}>
{currentChat ? (
<div className={styles.chatContainer}>
<div className={styles.chatToolbar}>
<Space>
<Tooltip title={showProfile ? "隐藏资料" : "显示资料"}>
<Button
type={showProfile ? "primary" : "default"}
icon={<InfoCircleOutlined />}
onClick={() => setShowProfile(!showProfile)}
size="small"
>
{showProfile ? "隐藏资料" : "显示资料"}
</Button>
</Tooltip>
</Space>
</div>
<ChatWindow
chat={currentChat}
onSendMessage={handleSendMessage}
showProfile={showProfile}
onToggleProfile={() => setShowProfile(!showProfile)}
/>
</div>
) : (
<div className={styles.welcomeScreen}>
<div className={styles.welcomeContent}>
<MessageOutlined style={{ fontSize: 64, color: "#1890ff" }} />
<h2>使</h2>
<p></p>
</div>
</div>
)}
</Content>
</Layout>
{/* 主内容区 */}
<Content className={styles.mainContent}>
{currentChat ? (
<div className={styles.chatContainer}>
<div className={styles.chatToolbar}>
<Space>
<Tooltip title={showProfile ? "隐藏资料" : "显示资料"}>
<Button
type={showProfile ? "primary" : "default"}
icon={<InfoCircleOutlined />}
onClick={() => setShowProfile(!showProfile)}
size="small"
>
{showProfile ? "隐藏资料" : "显示资料"}
</Button>
</Tooltip>
</Space>
</div>
<ChatWindow
contract={currentChat}
onSendMessage={handleSendMessage}
showProfile={showProfile}
onToggleProfile={() => setShowProfile(!showProfile)}
/>
</div>
) : (
<div className={styles.welcomeScreen}>
<div className={styles.welcomeContent}>
<MessageOutlined style={{ fontSize: 64, color: "#1890ff" }} />
<h2>使</h2>
<p></p>
</div>
</div>
)}
</Content>
</Layout>
</Layout>
</PageSkeleton>
);
};

View File

@@ -0,0 +1,325 @@
import {
useCkChatStore,
asyncKfUserList,
asyncContractList,
asyncChatSessions,
asyncNewContractList,
} from "@/store/module/ckchat";
import { useWebSocketStore } from "@/store/module/websocket";
import {
loginWithToken,
getControlTerminalList,
getContactList,
getGroupList,
WechatGroup,
} from "./api";
const { sendCommand } = useWebSocketStore.getState();
import { useUserStore } from "@/store/module/user";
import { ContractData, GroupData, KfUserListData } from "@/pages/pc/ckbox/data";
const { login2 } = useUserStore.getState();
//获取触客宝基础信息
export const chatInitAPIdata = async () => {
try {
//获取联系人列表
const contractList = await getAllContactList();
//获取联系人列表
asyncContractList(contractList);
// 提取不重复的wechatAccountId组
const uniqueWechatAccountIds: number[] =
getUniqueWechatAccountIds(contractList);
//获取控制终端列表
const kfUserList: KfUserListData[] =
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
//获取用户列表
asyncKfUserList(kfUserList);
//获取群列表
const groupList = await getAllGroupList();
//构建联系人列表标签
const newContractList = await createContractList(contractList, groupList);
console.log("分组信息", newContractList);
// 会话列表分组
asyncNewContractList(newContractList);
//获取消息会话列表并按lastUpdateTime排序
const filterUserSessions = contractList?.filter(
v => v?.config && v.config?.chat,
);
const filterGroupSessions = groupList?.filter(
v => v?.config && v.config?.chat,
);
//排序功能
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
(a, b) => {
// 如果lastUpdateTime不存在则将其排在最后
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
// 首先按时间降序排列(最新的在前面)
const timeCompare =
new Date(b.lastUpdateTime).getTime() -
new Date(a.lastUpdateTime).getTime();
// 如果时间相同,则按未读消息数量降序排列
if (timeCompare === 0) {
// 如果unreadCount不存在则将其排在后面
const aUnread = a.unreadCount || 0;
const bUnread = b.unreadCount || 0;
return bUnread - aUnread; // 未读消息多的排在前面
}
return timeCompare;
},
);
//会话数据同步
asyncChatSessions(sortedSessions);
return {
contractList,
groupList,
kfUserList,
newContractList,
};
} catch (error) {
console.error("获取联系人列表失败:", error);
return [];
}
};
//构建联系人列表标签
export const createContractList = async (
contractList: ContractData[],
groupList: GroupData[],
) => {
const LablesRes = await Promise.all(
[1, 2].map(item =>
WechatGroup({
groupType: item,
}),
),
);
const [friend, group] = LablesRes;
const countLables = [...friend, ...group];
// 根据countLables中的groupName整理contractList数据
// 返回按标签分组的联系人数组,包括未分组标签(在数组最后)
return organizeContactsByLabels(countLables, contractList, groupList);
};
/**
* 根据标签组织联系人
* @param contractList 联系人列表
* @param countLables 标签列表
* @returns 按标签分组的联系人
*/
export const organizeContactsByLabels = (
countLables: any[],
contractList: ContractData[],
groupList: GroupData[],
) => {
// 创建结果对象,用于存储按标签分组的联系人
const result: { [key: string]: any[] } = {};
// 初始化结果对象,为每个标签创建一个空数组
countLables.forEach(label => {
if (label && label.groupName) {
result[label.groupName] = [];
}
});
// 创建未分组标签,用于存放没有匹配到任何标签的联系人
const ungroupedLabel = "未分组";
result[ungroupedLabel] = [];
// 遍历联系人列表
contractList.forEach(contact => {
// 确保联系人有labels字段且是数组
if (contact && Array.isArray(contact.labels)) {
// 标记联系人是否已被分配到某个组
let isAssigned = false;
// 遍历标签列表
countLables.forEach(label => {
if (label && label.groupName) {
// 检查联系人的labels是否包含当前标签的groupName
if (contact.labels.includes(label.groupName)) {
// 将联系人添加到对应标签的数组中
result[label.groupName].push(contact);
isAssigned = true;
}
}
});
// 如果联系人没有被分配到任何组,则添加到未分组
if (!isAssigned) {
result[ungroupedLabel].push(contact);
}
} else {
// 如果联系人没有labels字段或不是数组也添加到未分组
result[ungroupedLabel].push(contact);
}
});
// 将结果转换为数组格式,确保未分组在最后
const resultArray = Object.entries(result).map(([groupName, contacts]) => ({
groupName,
contacts,
}));
// 将未分组移到数组末尾
const ungroupedIndex = resultArray.findIndex(
item => item.groupName === ungroupedLabel,
);
if (ungroupedIndex !== -1) {
const ungrouped = resultArray.splice(ungroupedIndex, 1)[0];
resultArray.push(ungrouped);
}
return resultArray;
};
//获取控制终端列表
export const getControlTerminalListByWechatAccountIds = (
WechatAccountIds: number[],
) => {
return Promise.all(
WechatAccountIds.map(id => getControlTerminalList({ id: id })),
);
};
// 递归获取所有联系人列表
export const getAllContactList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getContactList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有联系人列表失败:", error);
return [];
}
};
// 提取不重复的wechatAccountId组
export const getUniqueWechatAccountIds = contacts => {
if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
return [];
}
// 使用Set来存储不重复的wechatAccountId
const uniqueAccountIdsSet = new Set<number>();
// 遍历联系人列表将每个wechatAccountId添加到Set中
contacts.forEach(contact => {
if (contact && contact.wechatAccountId) {
uniqueAccountIdsSet.add(contact.wechatAccountId);
}
});
// 将Set转换为数组并返回
return Array.from(uniqueAccountIdsSet);
};
// 递归获取所有群列表
export const getAllGroupList = async () => {
try {
let allContacts = [];
let prevId = 0;
const count = 1000;
let hasMore = true;
while (hasMore) {
const contractList = await getGroupList({
prevId,
count,
});
if (
!contractList ||
!Array.isArray(contractList) ||
contractList.length === 0
) {
hasMore = false;
break;
}
allContacts = [...allContacts, ...contractList];
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
if (contractList.length < count) {
hasMore = false;
} else {
// 获取最后一条数据的id作为下一次请求的prevId
const lastContact = contractList[contractList.length - 1];
prevId = lastContact.id;
}
}
return allContacts;
} catch (error) {
console.error("获取所有群列表失败:", error);
return [];
}
};
export const getChatInfo = () => {
//获取UserId
sendCommand("CmdRequestWechatAccountsAliveStatus", {
wechatAccountIds: ["300745", "4880930", "32686452"],
seq: +new Date(),
});
console.log("发送链接信息");
};
//获取token
const getToken = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
// username: "karuo",
// password: "zhiqun1984",
};
loginWithToken(params)
.then(res => {
login2(res.access_token);
resolve(res.access_token);
})
.catch(err => {
reject(err);
});
});
};

View File

@@ -95,7 +95,7 @@ export const routeGroups = {
"/plans",
"/plans/:planId",
"/orders",
"/contact-import",
"/contract-import",
],
},
};
@@ -126,7 +126,7 @@ export const routePermissions = {
"/plans",
"/plans/:planId",
"/orders",
"/contact-import",
"/contract-import",
],
// 访客权限
@@ -150,7 +150,7 @@ export const routeTitles: Record<string, string> = {
"/profile": "个人中心",
"/plans": "计划管理",
"/orders": "订单管理",
"/contact-import": "联系人导入",
"/contract-import": "联系人导入",
};
// 获取路由标题

View File

@@ -1,17 +1,4 @@
// 账户信息接口
export interface CkAccount {
id: number;
realName: string;
nickname: string | null;
memo: string | null;
avatar: string;
userName: string;
secret: string;
accountType: number;
departmentId: number;
useGoogleSecretKey: boolean;
hasVerifyGoogleSecret: boolean;
}
import { ContractData, KfUserListData, CkAccount } from "@/pages/pc/ckbox/data";
// 权限片段接口
export interface PrivilegeFrag {
@@ -40,6 +27,24 @@ export interface CkUserInfo {
export interface CkChatState {
userInfo: CkUserInfo | null;
isLoggedIn: boolean;
contractList: ContractData[];
chatSessions: any[];
kfUserList: KfUserListData[];
kfSelected: number;
kfSelectedUser: () => KfUserListData | undefined;
newContractList: { groupName: string; contacts: any[] }[];
asyncKfSelected: (data: number) => void;
getkfUserList: () => KfUserListData[];
asyncKfUserList: (data: KfUserListData[]) => void;
asyncContractList: (data: ContractData[]) => void;
asyncChatSessions: (data: any[]) => void;
deleteCtrlUser: (userId: number) => void;
updateCtrlUser: (user: KfUserListData) => void;
clearkfUserList: () => void;
getChatSessions: () => any[];
addChatSession: (session: any) => void;
updateChatSession: (session: any) => void;
deleteChatSession: (sessionId: string) => void;
setUserInfo: (userInfo: CkUserInfo) => void;
clearUserInfo: () => void;
updateAccount: (account: Partial<CkAccount>) => void;

View File

@@ -1,12 +1,102 @@
import { createPersistStore } from "@/store/createPersistStore";
import { CkChatState, CkUserInfo, CkAccount, CkTenant } from "./ckchat.data";
import { CkChatState, CkUserInfo, CkTenant } from "./ckchat.data";
import {
ContractData,
GroupData,
CkAccount,
KfUserListData,
} from "@/pages/pc/ckbox/data";
export const useCkChatStore = createPersistStore<CkChatState>(
set => ({
userInfo: null,
isLoggedIn: false,
contractList: [], //联系人列表
chatSessions: [], //聊天会话
kfUserList: [], //客服列表
kfSelected: 0,
newContractList: [], //联系人分组
kfSelectedUser: () => {
const state = useCkChatStore.getState();
return state.kfUserList.find(item => item.id === state.kfSelected);
},
asyncKfSelected: (data: number) => {
set({ kfSelected: data });
},
// 异步设置会话列表
asyncNewContractList: data => {
set({ newContractList: data });
},
getNewContractList: () => {
const state = useCkChatStore.getState();
return state.newContractList;
},
// 异步设置会话列表
asyncChatSessions: data => {
set({ chatSessions: data });
},
// 异步设置联系人列表
asyncContractList: data => {
set({ contractList: data });
},
// 控制终端用户列表
getkfUserList: () => {
const state = useCkChatStore.getState();
return state.kfUserList;
},
asyncKfUserList: data => {
set({ kfUserList: data });
},
// 删除控制终端用户
deleteCtrlUser: (userId: number) => {
set(state => ({
kfUserList: state.kfUserList.filter(item => item.id !== userId),
}));
},
// 更新控制终端用户
updateCtrlUser: (user: KfUserListData) => {
set(state => ({
kfUserList: state.kfUserList.map(item =>
item.id === user.id ? user : item,
),
}));
},
// 清空控制终端用户列表
clearkfUserList: () => {
set({ kfUserList: [] });
},
// 获取聊天会话
getChatSessions: () => {
const state = useCkChatStore.getState();
return state.chatSessions;
},
// 添加聊天会话
addChatSession: (session: ContractData | GroupData) => {
set(state => {
// 检查是否已存在相同id的会话
const exists = state.chatSessions.some(item => item.id === session.id);
// 如果已存在则不添加,否则添加到列表中
return {
chatSessions: exists
? state.chatSessions
: [...state.chatSessions, session as ContractData | GroupData],
};
});
},
// 更新聊天会话
updateChatSession: (session: ContractData | GroupData) => {
set(state => ({
chatSessions: state.chatSessions.map(item =>
item.id === session.id ? session : item,
),
}));
},
// 删除聊天会话
deleteChatSession: (sessionId: string) => {
set(state => ({
chatSessions: state.chatSessions.filter(item => item.id !== sessionId),
}));
},
// 设置用户信息
setUserInfo: (userInfo: CkUserInfo) => {
set({ userInfo, isLoggedIn: true });
@@ -44,7 +134,7 @@ export const useCkChatStore = createPersistStore<CkChatState>(
// 获取账户ID
getAccountId: () => {
const state = useCkChatStore.getState();
return state.userInfo?.account?.id || null;
return Number(state.userInfo?.account?.id) || null;
},
// 获取租户ID
@@ -87,3 +177,29 @@ export const getCkTenantId = () => useCkChatStore.getState().getTenantId();
export const getCkAccountName = () =>
useCkChatStore.getState().getAccountName();
export const getCkTenantName = () => useCkChatStore.getState().getTenantName();
export const getChatSessions = () =>
useCkChatStore.getState().getChatSessions();
export const addChatSession = (session: ContractData | GroupData) =>
useCkChatStore.getState().addChatSession(session);
export const updateChatSession = (session: ContractData | GroupData) =>
useCkChatStore.getState().updateChatSession(session);
export const deleteChatSession = (sessionId: string) =>
useCkChatStore.getState().deleteChatSession(sessionId);
export const getkfUserList = () => useCkChatStore.getState().kfUserList;
export const addCtrlUser = (user: KfUserListData) =>
useCkChatStore.getState().addCtrlUser(user);
export const deleteCtrlUser = (userId: number) =>
useCkChatStore.getState().deleteCtrlUser(userId);
export const updateCtrlUser = (user: KfUserListData) =>
useCkChatStore.getState().updateCtrlUser(user);
export const asyncKfUserList = (data: KfUserListData[]) =>
useCkChatStore.getState().asyncKfUserList(data);
export const asyncContractList = (data: ContractData[]) =>
useCkChatStore.getState().asyncContractList(data);
export const asyncChatSessions = (data: ContractData[]) =>
useCkChatStore.getState().asyncChatSessions(data);
export const asyncNewContractList = (
data: { groupName: string; contacts: any[] }[],
) => useCkChatStore.getState().asyncNewContractList(data);
export const asyncKfSelected = (data: number) =>
useCkChatStore.getState().asyncKfSelected(data);

View File

@@ -80,6 +80,7 @@ export const useUserStore = createPersistStore<UserState>(
},
login2: token2 => {
localStorage.setItem("token2", token2);
set({ token2, isLoggedIn: true });
},
logout: () => {

View File

@@ -1,15 +1,16 @@
import { createPersistStore } from "@/store/createPersistStore";
import { Toast } from "antd-mobile";
import { useUserStore } from "./user";
import { useCkChatStore } from "@/store/module/ckchat";
const { getAccountId } = useCkChatStore.getState();
// WebSocket消息类型
export interface WebSocketMessage {
id: string;
type: string;
content: any;
timestamp: number;
sender?: string;
receiver?: string;
cmdType?: string;
seq?: number;
wechatAccountIds?: string[];
content?: any;
[key: string]: any;
}
// WebSocket连接状态
@@ -25,11 +26,14 @@ export enum WebSocketStatus {
interface WebSocketConfig {
url: string;
client: string;
accountId: string;
accountId: number;
accessToken: string;
autoReconnect: boolean;
cmdType: string;
seq: number;
reconnectInterval: number;
maxReconnectAttempts: number;
[key: string]: any;
}
interface WebSocketState {
@@ -68,11 +72,13 @@ interface WebSocketState {
// 默认配置
const DEFAULT_CONFIG: WebSocketConfig = {
url: (import.meta as any).env?.VITE_API_WS_URL || "ws://localhost:8080",
url: (import.meta as any).env?.VITE_API_WS_URL,
client: "kefu-client",
accountId: "",
accountId: 0,
accessToken: "",
autoReconnect: true,
cmdType: "", // 添加默认的命令类型
seq: +new Date(), // 添加默认的序列号
reconnectInterval: 3000,
maxReconnectAttempts: 5,
};
@@ -103,24 +109,32 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
};
// 获取用户信息
const { token, token2, user } = useUserStore.getState();
const accessToken = fullConfig.accessToken || token2 || token;
const { token2 } = useUserStore.getState();
if (!accessToken) {
if (!token2) {
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
return;
}
// 构建WebSocket URL
const params = {
client: fullConfig.client,
accountId: fullConfig.accountId || user?.s2_accountId || "",
accessToken: accessToken,
const params = new URLSearchParams({
client: fullConfig.client.toString(),
accountId: getAccountId().toString(),
accessToken: token2,
t: Date.now().toString(),
};
});
const wsUrl =
fullConfig.url + "?" + new URLSearchParams(params).toString();
// 检查URL是否为localhost如果是则不连接
const wsUrl = fullConfig.url + "?" + params;
if (wsUrl.includes("localhost") || wsUrl.includes("127.0.0.1")) {
console.error("WebSocket连接被拦截不允许连接到本地地址", wsUrl);
Toast.show({
content: "WebSocket连接被拦截不允许连接到本地地址",
position: "top",
});
set({ status: WebSocketStatus.ERROR });
return;
}
set({
status: WebSocketStatus.CONNECTING,
@@ -166,7 +180,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
},
// 发送消息
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => {
sendMessage: message => {
const currentState = get();
if (
@@ -179,8 +193,6 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
const fullMessage: WebSocketMessage = {
...message,
id: Date.now().toString(),
timestamp: Date.now(),
};
try {
@@ -204,16 +216,8 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
return;
}
const { user } = useUserStore.getState();
const { token, token2 } = useUserStore.getState();
const accessToken = token2 || token;
const command = {
accessToken: accessToken,
accountId: user?.s2_accountId,
client: currentState.config?.client || "kefu-client",
cmdType: cmdType,
seq: Date.now(),
cmdType,
...data,
};
@@ -241,6 +245,11 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
const currentState = get();
if (currentState.config) {
// 检查是否允许重连
if (!currentState.config.autoReconnect) {
console.log("自动重连已禁用,不再尝试重连");
return;
}
currentState.connect(currentState.config);
}
},
@@ -255,10 +264,20 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
});
console.log("WebSocket连接成功");
const { token2 } = useUserStore.getState();
// 发送登录命令
if (currentState.config) {
currentState.sendCommand("CmdSignIn");
currentState.sendCommand("CmdSignIn", {
accessToken: token2,
accountId: Number(getAccountId()),
client: currentState.config?.client || "kefu-client",
seq: +new Date(),
});
//获取UserId
currentState.sendCommand("CmdRequestWechatAccountsAliveStatus", {
wechatAccountIds: ["300745", "4880930", "32686452"],
seq: +new Date(),
});
}
Toast.show({ content: "WebSocket连接成功", position: "top" });
@@ -270,6 +289,32 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
const data = JSON.parse(event.data);
console.log("收到WebSocket消息:", data);
// 处理特定的通知消息
if (data.cmdType === "CmdNotify") {
// 处理Auth failed通知
if (data.notify === "Auth failed" || data.notify === "Kicked out") {
console.error(`WebSocket ${data.notify},断开连接`);
Toast.show({
content: `WebSocket ${data.notify},断开连接`,
position: "top",
});
// 禁用自动重连
if (get().config) {
set({
config: {
...get().config!,
autoReconnect: false,
},
});
}
// 断开连接
get().disconnect();
return;
}
}
const currentState = get();
const newMessage: WebSocketMessage = {
id: Date.now().toString(),
@@ -309,7 +354,12 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
currentState.reconnectAttempts <
(currentState.config?.maxReconnectAttempts || 5)
) {
console.log("尝试自动重连...");
currentState._startReconnectTimer();
} else if (!currentState.config?.autoReconnect) {
console.log("自动重连已禁用,不再尝试重连");
// 重置重连计数
set({ reconnectAttempts: 0 });
}
},

View File

@@ -1,5 +1,56 @@
import { Modal } from "antd-mobile";
import { getSetting } from "@/store/module/settings";
export function formatWechatTime(timestamp) {
if (!timestamp) {
return "";
}
// 处理时间戳(兼容秒级/毫秒级)
const date = new Date(
timestamp.toString().length === 10 ? timestamp * 1000 : timestamp,
);
const now = new Date();
// 获取消息时间的年月日时分
const messageYear = date.getFullYear();
const messageMonth = date.getMonth();
const messageDate = date.getDate();
const messageHour = date.getHours().toString().padStart(2, "0");
const messageMinute = date.getMinutes().toString().padStart(2, "0");
// 获取当前时间的年月日
const nowYear = now.getFullYear();
const nowMonth = now.getMonth();
const nowDate = now.getDate();
// 创建当天0点的时间对象用于比较是否同一天
const today = new Date(nowYear, nowMonth, nowDate, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 6); // 7天前包括今天
// 消息日期(不含时间)
const messageDay = new Date(messageYear, messageMonth, messageDate, 0, 0, 0);
// 当天消息:只显示时分
if (messageDay.getTime() === today.getTime()) {
return `${messageHour}:${messageMinute}`;
}
// 昨天消息:显示"昨天 时分"
if (messageDay.getTime() === yesterday.getTime()) {
return `昨天 ${messageHour}:${messageMinute}`;
}
// 一周内消息:显示"星期X 时分"
if (messageDay.getTime() >= weekAgo.getTime()) {
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
return `${weekdays[date.getDay()]} ${messageHour}:${messageMinute}`;
}
// 超过一周:显示"年月日 时分"
return `${messageYear}${messageMonth + 1}${messageDate}${messageHour}:${messageMinute}`;
}
/**
* 通用js调用弹窗Promise风格
* @param content 弹窗内容

View File

@@ -0,0 +1,508 @@
// 数据库使用示例
import {
db,
userService,
messageService,
chatRoomService,
settingService,
userBusinessService,
messageBusinessService,
settingBusinessService,
DatabaseUtils,
type User,
type Message,
type ChatRoom,
type Setting,
} from "./db";
// ============= 基础 CRUD 操作示例 =============
// 1. 用户操作示例
export const userExamples = {
// 创建用户
async createUser() {
const userId = await userService.create({
name: "张三",
email: "zhangsan@example.com",
status: "active",
});
console.log("创建用户成功ID:", userId);
return userId;
},
// 批量创建用户
async createMultipleUsers() {
const userIds = await userService.createMany([
{ name: "李四", email: "lisi@example.com", status: "active" },
{ name: "王五", email: "wangwu@example.com", status: "inactive" },
{ name: "赵六", email: "zhaoliu@example.com", status: "active" },
]);
console.log("批量创建用户成功IDs:", userIds);
return userIds;
},
// 查询用户
async findUsers() {
// 查询所有用户
const allUsers = await userService.findAll();
console.log("所有用户:", allUsers);
// 根据ID查询
const user = await userService.findById(1);
console.log("ID为1的用户:", user);
// 条件查询
const activeUsers = await userService.findWhere("status", "active");
console.log("活跃用户:", activeUsers);
return { allUsers, user, activeUsers };
},
// 分页查询
async paginateUsers() {
const result = await userService.paginate(1, 5); // 第1页每页5条
console.log("分页结果:", result);
return result;
},
// 更新用户
async updateUser() {
const count = await userService.update(1, {
name: "张三(已更新)",
status: "inactive",
});
console.log("更新用户数量:", count);
return count;
},
// 删除用户
async deleteUser() {
await userService.delete(1);
console.log("删除用户成功");
},
};
// 2. 消息操作示例
export const messageExamples = {
// 创建消息
async createMessage() {
const messageId = await messageService.create({
userId: 1,
content: "这是一条测试消息",
type: "text",
isRead: false,
});
console.log("创建消息成功ID:", messageId);
return messageId;
},
// 查询未读消息
async findUnreadMessages() {
const unreadMessages = await messageService.findWhere("isRead", false);
console.log("未读消息:", unreadMessages);
return unreadMessages;
},
// 标记消息为已读
async markMessageAsRead() {
const count = await messageService.update(1, { isRead: true });
console.log("标记已读消息数量:", count);
return count;
},
// 获取最近消息
async getRecentMessages() {
const recentMessages = await messageService.findAllSorted(
"createdAt",
"desc",
);
console.log("最近消息:", recentMessages.slice(0, 10));
return recentMessages.slice(0, 10);
},
};
// 3. 设置操作示例
export const settingExamples = {
// 保存设置
async saveSetting() {
const settingId = await settingService.create({
key: "theme",
value: "dark",
category: "appearance",
});
console.log("保存设置成功ID:", settingId);
return settingId;
},
// 批量保存设置
async saveMultipleSettings() {
const settingIds = await settingService.createMany([
{ key: "language", value: "zh-CN", category: "general" },
{ key: "fontSize", value: 14, category: "appearance" },
{ key: "autoSave", value: true, category: "editor" },
]);
console.log("批量保存设置成功IDs:", settingIds);
return settingIds;
},
// 查询设置
async getSettings() {
// 查询所有设置
const allSettings = await settingService.findAll();
console.log("所有设置:", allSettings);
// 按分类查询
const appearanceSettings = await settingService.findWhere(
"category",
"appearance",
);
console.log("外观设置:", appearanceSettings);
return { allSettings, appearanceSettings };
},
};
// ============= 业务方法示例 =============
// 4. 用户业务操作示例
export const userBusinessExamples = {
// 根据邮箱查找用户
async findUserByEmail() {
const user = await userBusinessService.findByEmail("zhangsan@example.com");
console.log("根据邮箱查找的用户:", user);
return user;
},
// 查找活跃用户
async findActiveUsers() {
const activeUsers = await userBusinessService.findActiveUsers();
console.log("活跃用户列表:", activeUsers);
return activeUsers;
},
// 搜索用户
async searchUsers() {
const users = await userBusinessService.searchByName("张");
console.log("搜索结果:", users);
return users;
},
// 更新用户状态
async updateUserStatus() {
const count = await userBusinessService.updateStatus(1, "active");
console.log("更新用户状态数量:", count);
return count;
},
};
// 5. 消息业务操作示例
export const messageBusinessExamples = {
// 查找用户消息
async findUserMessages() {
const messages = await messageBusinessService.findByUserId(1);
console.log("用户消息:", messages);
return messages;
},
// 查找未读消息
async findUnreadMessages() {
const unreadMessages = await messageBusinessService.findUnreadMessages();
console.log("未读消息:", unreadMessages);
return unreadMessages;
},
// 标记消息为已读
async markAsRead() {
const count = await messageBusinessService.markAsRead(1);
console.log("标记已读数量:", count);
return count;
},
// 标记用户所有消息为已读
async markAllAsRead() {
const count = await messageBusinessService.markAllAsRead(1);
console.log("标记用户所有消息已读数量:", count);
return count;
},
// 获取最近消息
async getRecentMessages() {
const messages = await messageBusinessService.getRecentMessages(20);
console.log("最近20条消息:", messages);
return messages;
},
};
// 6. 设置业务操作示例
export const settingBusinessExamples = {
// 获取设置值
async getSetting() {
const theme = await settingBusinessService.getSetting("theme");
console.log("主题设置:", theme);
return theme;
},
// 设置值
async setSetting() {
const settingId = await settingBusinessService.setSetting(
"theme",
"light",
"appearance",
);
console.log("设置主题成功ID:", settingId);
return settingId;
},
// 获取分类设置
async getSettingsByCategory() {
const settings =
await settingBusinessService.getSettingsByCategory("appearance");
console.log("外观设置:", settings);
return settings;
},
// 删除设置
async deleteSetting() {
await settingBusinessService.deleteSetting("oldSetting");
console.log("删除设置成功");
},
};
// ============= 数据库工具示例 =============
// 7. 数据库工具操作示例
export const databaseUtilsExamples = {
// 获取数据库统计信息
async getStats() {
const stats = await DatabaseUtils.getStats();
console.log("数据库统计:", stats);
return stats;
},
// 健康检查
async healthCheck() {
const health = await DatabaseUtils.healthCheck();
console.log("数据库健康状态:", health);
return health;
},
// 导出数据
async exportData() {
const jsonData = await DatabaseUtils.exportData();
console.log("导出的数据长度:", jsonData.length);
return jsonData;
},
// 备份到文件
async backupToFile() {
await DatabaseUtils.backupToFile();
console.log("备份文件下载成功");
},
// 从文件恢复(需要文件输入)
async restoreFromFile(file: File) {
try {
await DatabaseUtils.restoreFromFile(file);
console.log("数据恢复成功");
} catch (error) {
console.error("数据恢复失败:", error);
}
},
// 清空所有数据
async clearAllData() {
const confirmed = confirm("确定要清空所有数据吗?此操作不可恢复!");
if (confirmed) {
await DatabaseUtils.clearAllData();
console.log("所有数据已清空");
}
},
};
// ============= 高级查询示例 =============
// 8. 高级查询示例
export const advancedQueryExamples = {
// 复杂条件查询
async complexQuery() {
// 查询活跃用户的未读消息
const activeUsers = await db.users
.where("status")
.equals("active")
.toArray();
const activeUserIds = activeUsers.map(user => user.id!);
const unreadMessages = await db.messages
.where("userId")
.anyOf(activeUserIds)
.and(msg => !msg.isRead)
.toArray();
console.log("活跃用户的未读消息:", unreadMessages);
return unreadMessages;
},
// 统计查询
async statisticsQuery() {
const stats = {
totalUsers: await db.users.count(),
activeUsers: await db.users.where("status").equals("active").count(),
totalMessages: await db.messages.count(),
unreadMessages: await db.messages.where("isRead").equals(false).count(),
messagesThisWeek: await db.messages
.where("createdAt")
.above(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.count(),
};
console.log("统计信息:", stats);
return stats;
},
// 事务操作
async transactionExample() {
try {
await db.transaction("rw", [db.users, db.messages], async () => {
// 创建用户
const userId = await db.users.add({
name: "事务用户",
email: "transaction@example.com",
status: "active",
});
// 为该用户创建欢迎消息
await db.messages.add({
userId,
content: "欢迎使用我们的应用!",
type: "text",
isRead: false,
});
console.log("事务执行成功");
});
} catch (error) {
console.error("事务执行失败:", error);
}
},
};
// ============= 完整使用流程示例 =============
// 9. 完整的应用场景示例
export const fullScenarioExample = {
// 模拟用户注册和使用流程
async simulateUserFlow() {
console.log("=== 开始模拟用户流程 ===");
try {
// 1. 用户注册
const userId = await userBusinessService.create({
name: "新用户",
email: "newuser@example.com",
status: "active",
});
console.log("1. 用户注册成功ID:", userId);
// 2. 设置用户偏好
await settingBusinessService.setSetting(
"theme",
"dark",
"user-" + userId,
);
await settingBusinessService.setSetting(
"language",
"zh-CN",
"user-" + userId,
);
console.log("2. 用户偏好设置完成");
// 3. 发送欢迎消息
const messageId = await messageBusinessService.create({
userId,
content: "欢迎加入我们的平台!",
type: "text",
isRead: false,
});
console.log("3. 欢迎消息发送成功ID:", messageId);
// 4. 用户查看消息
const userMessages = await messageBusinessService.findByUserId(userId);
console.log("4. 用户消息列表:", userMessages);
// 5. 标记消息为已读
await messageBusinessService.markAsRead(messageId);
console.log("5. 消息已标记为已读");
// 6. 获取用户统计信息
const userStats = {
totalMessages: await messageService.countWhere("userId", userId),
unreadMessages: await db.messages
.where("userId")
.equals(userId)
.and(msg => !msg.isRead)
.count(),
};
console.log("6. 用户统计信息:", userStats);
console.log("=== 用户流程模拟完成 ===");
return { userId, messageId, userStats };
} catch (error) {
console.error("用户流程模拟失败:", error);
throw error;
}
},
};
// 导出所有示例
export const allExamples = {
userExamples,
messageExamples,
settingExamples,
userBusinessExamples,
messageBusinessExamples,
settingBusinessExamples,
databaseUtilsExamples,
advancedQueryExamples,
fullScenarioExample,
};
// 快速测试函数
export async function quickTest() {
console.log("=== 开始快速测试 ===");
try {
// 健康检查
const health = await DatabaseUtils.healthCheck();
console.log("数据库健康状态:", health);
// 创建测试数据
const userId = await userBusinessService.create({
name: "测试用户",
email: "test@example.com",
status: "active",
});
const messageId = await messageBusinessService.create({
userId,
content: "测试消息",
type: "text",
isRead: false,
});
// 查询测试
const user = await userBusinessService.findById(userId);
const message = await messageBusinessService.findById(messageId);
console.log("创建的用户:", user);
console.log("创建的消息:", message);
// 统计信息
const stats = await DatabaseUtils.getStats();
console.log("数据库统计:", stats);
console.log("=== 快速测试完成 ===");
return { userId, messageId, stats };
} catch (error) {
console.error("快速测试失败:", error);
throw error;
}
}

446
Cunkebao/src/utils/db.ts Normal file
View File

@@ -0,0 +1,446 @@
import Dexie, { Table } from "dexie";
import { KfUserListData, GroupData, ContractData } from "@/pages/pc/ckbox/data";
// 定义数据库表结构接口
export interface BaseEntity {
id?: number;
createdAt?: Date;
updatedAt?: Date;
}
export interface User extends BaseEntity {
name: string;
email: string;
avatar?: string;
status: "active" | "inactive";
}
export interface Message extends BaseEntity {
userId: number;
content: string;
type: "text" | "image" | "file";
isRead: boolean;
}
export interface ChatRoom extends BaseEntity {
name: string;
description?: string;
memberIds: number[];
lastMessageAt?: Date;
}
export interface Setting extends BaseEntity {
key: string;
value: any;
category: string;
}
// 数据库类
class AppDatabase extends Dexie {
users!: Table<User>;
messages!: Table<Message>;
chatRooms!: Table<ChatRoom>;
settings!: Table<Setting>;
constructor() {
super("CunkebaoDatabase");
this.version(1).stores({
users: "++id, name, email, status, createdAt",
messages: "++id, userId, type, isRead, createdAt",
chatRooms: "++id, name, lastMessageAt, createdAt",
settings: "++id, key, category, createdAt",
});
// 自动添加时间戳
this.users.hook("creating", (primKey, obj, trans) => {
obj.createdAt = new Date();
obj.updatedAt = new Date();
});
this.users.hook("updating", (modifications, primKey, obj, trans) => {
modifications.updatedAt = new Date();
});
this.messages.hook("creating", (primKey, obj, trans) => {
obj.createdAt = new Date();
obj.updatedAt = new Date();
});
this.chatRooms.hook("creating", (primKey, obj, trans) => {
obj.createdAt = new Date();
obj.updatedAt = new Date();
});
this.settings.hook("creating", (primKey, obj, trans) => {
obj.createdAt = new Date();
obj.updatedAt = new Date();
});
}
}
// 创建数据库实例
export const db = new AppDatabase();
// 通用数据库操作类
export class DatabaseService<T extends BaseEntity> {
constructor(private table: Table<T>) {}
// 基础 CRUD 操作
async create(
data: Omit<T, "id" | "createdAt" | "updatedAt">,
): Promise<number> {
return await this.table.add(data as T);
}
async createMany(
dataList: Omit<T, "id" | "createdAt" | "updatedAt">[],
): Promise<number[]> {
return await this.table.bulkAdd(dataList as T[], { allKeys: true });
}
async findById(id: number): Promise<T | undefined> {
return await this.table.get(id);
}
async findAll(): Promise<T[]> {
return await this.table.toArray();
}
async findByIds(ids: number[]): Promise<T[]> {
return await this.table.where("id").anyOf(ids).toArray();
}
async update(
id: number,
data: Partial<Omit<T, "id" | "createdAt">>,
): Promise<number> {
return await this.table.update(id, data);
}
async updateMany(
updates: { id: number; data: Partial<Omit<T, "id" | "createdAt">> }[],
): Promise<number> {
return await this.table.bulkUpdate(
updates.map(u => ({ key: u.id, changes: u.data })),
);
}
async delete(id: number): Promise<void> {
await this.table.delete(id);
}
async deleteMany(ids: number[]): Promise<void> {
await this.table.bulkDelete(ids);
}
async deleteAll(): Promise<void> {
await this.table.clear();
}
// 分页查询
async paginate(
page: number = 1,
limit: number = 10,
): Promise<{ data: T[]; total: number; page: number; limit: number }> {
const offset = (page - 1) * limit;
const total = await this.table.count();
const data = await this.table.offset(offset).limit(limit).toArray();
return { data, total, page, limit };
}
// 条件查询
async findWhere(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.equals(value)
.toArray();
}
async findWhereIn(field: keyof T, values: any[]): Promise<T[]> {
return await this.table
.where(field as string)
.anyOf(values)
.toArray();
}
async findWhereNot(field: keyof T, value: any): Promise<T[]> {
return await this.table
.where(field as string)
.notEqual(value)
.toArray();
}
// 排序查询
async findAllSorted(
field: keyof T,
direction: "asc" | "desc" = "asc",
): Promise<T[]> {
const collection = this.table.orderBy(field as string);
return direction === "desc"
? await collection.reverse().toArray()
: await collection.toArray();
}
// 搜索功能
async search(field: keyof T, keyword: string): Promise<T[]> {
return await this.table
.where(field as string)
.startsWithIgnoreCase(keyword)
.toArray();
}
// 统计功能
async count(): Promise<number> {
return await this.table.count();
}
async countWhere(field: keyof T, value: any): Promise<number> {
return await this.table
.where(field as string)
.equals(value)
.count();
}
// 存在性检查
async exists(id: number): Promise<boolean> {
const item = await this.table.get(id);
return !!item;
}
async existsWhere(field: keyof T, value: any): Promise<boolean> {
const count = await this.table
.where(field as string)
.equals(value)
.count();
return count > 0;
}
}
// 创建各表的服务实例
export const userService = new DatabaseService(db.users);
export const messageService = new DatabaseService(db.messages);
export const chatRoomService = new DatabaseService(db.chatRooms);
export const settingService = new DatabaseService(db.settings);
// 专门的业务方法
export class UserService extends DatabaseService<User> {
constructor() {
super(db.users);
}
async findByEmail(email: string): Promise<User | undefined> {
return await db.users.where("email").equals(email).first();
}
async findActiveUsers(): Promise<User[]> {
return await db.users.where("status").equals("active").toArray();
}
async searchByName(name: string): Promise<User[]> {
return await db.users.where("name").startsWithIgnoreCase(name).toArray();
}
async updateStatus(
id: number,
status: "active" | "inactive",
): Promise<number> {
return await this.update(id, { status });
}
}
export class MessageService extends DatabaseService<Message> {
constructor() {
super(db.messages);
}
async findByUserId(userId: number): Promise<Message[]> {
return await db.messages.where("userId").equals(userId).toArray();
}
async findUnreadMessages(): Promise<Message[]> {
return await db.messages.where("isRead").equals(false).toArray();
}
async markAsRead(id: number): Promise<number> {
return await this.update(id, { isRead: true });
}
async markAllAsRead(userId: number): Promise<number> {
const messages = await db.messages
.where("userId")
.equals(userId)
.and(msg => !msg.isRead)
.toArray();
const updates = messages.map(msg => ({
id: msg.id!,
data: { isRead: true },
}));
return await this.updateMany(updates);
}
async getRecentMessages(limit: number = 50): Promise<Message[]> {
return await db.messages
.orderBy("createdAt")
.reverse()
.limit(limit)
.toArray();
}
}
export class SettingService extends DatabaseService<Setting> {
constructor() {
super(db.settings);
}
async getSetting(key: string): Promise<any> {
const setting = await db.settings.where("key").equals(key).first();
return setting?.value;
}
async setSetting(
key: string,
value: any,
category: string = "general",
): Promise<number> {
const existing = await db.settings.where("key").equals(key).first();
if (existing) {
return await this.update(existing.id!, { value });
} else {
return await this.create({ key, value, category });
}
}
async getSettingsByCategory(category: string): Promise<Setting[]> {
return await db.settings.where("category").equals(category).toArray();
}
async deleteSetting(key: string): Promise<void> {
await db.settings.where("key").equals(key).delete();
}
}
// 数据库工具类
export class DatabaseUtils {
// 数据导出
static async exportData(): Promise<string> {
const data = {
users: await db.users.toArray(),
messages: await db.messages.toArray(),
chatRooms: await db.chatRooms.toArray(),
settings: await db.settings.toArray(),
exportedAt: new Date().toISOString(),
};
return JSON.stringify(data, null, 2);
}
// 数据导入
static async importData(jsonData: string): Promise<void> {
try {
const data = JSON.parse(jsonData);
await db.transaction(
"rw",
[db.users, db.messages, db.chatRooms, db.settings],
async () => {
if (data.users) await db.users.bulkPut(data.users);
if (data.messages) await db.messages.bulkPut(data.messages);
if (data.chatRooms) await db.chatRooms.bulkPut(data.chatRooms);
if (data.settings) await db.settings.bulkPut(data.settings);
},
);
} catch (error) {
throw new Error("导入数据失败: " + error);
}
}
// 清空所有数据
static async clearAllData(): Promise<void> {
await db.transaction(
"rw",
[db.users, db.messages, db.chatRooms, db.settings],
async () => {
await db.users.clear();
await db.messages.clear();
await db.chatRooms.clear();
await db.settings.clear();
},
);
}
// 获取数据库统计信息
static async getStats(): Promise<{
users: number;
messages: number;
chatRooms: number;
settings: number;
totalSize: number;
}> {
const [users, messages, chatRooms, settings] = await Promise.all([
db.users.count(),
db.messages.count(),
db.chatRooms.count(),
db.settings.count(),
]);
// 估算数据库大小(简单估算)
const totalSize = users + messages + chatRooms + settings;
return { users, messages, chatRooms, settings, totalSize };
}
// 数据库健康检查
static async healthCheck(): Promise<{
status: "healthy" | "error";
message: string;
}> {
try {
await db.users.limit(1).toArray();
return { status: "healthy", message: "数据库连接正常" };
} catch (error) {
return { status: "error", message: "数据库连接异常: " + error };
}
}
// 数据备份到文件
static async backupToFile(): Promise<void> {
const data = await this.exportData();
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `cunkebao-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 从文件恢复数据
static async restoreFromFile(file: File): Promise<void> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async e => {
try {
const jsonData = e.target?.result as string;
await this.importData(jsonData);
resolve();
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error("文件读取失败"));
reader.readAsText(file);
});
}
}
// 创建业务服务实例
export const userBusinessService = new UserService();
export const messageBusinessService = new MessageService();
export const settingBusinessService = new SettingService();
// 默认导出数据库实例
export default db;

View File

@@ -0,0 +1,22 @@
<?php
use think\facade\Route;
// 定义RESTful风格的API路由
Route::group('v1/ai', function () {
//openai、chatGPT
Route::group('openai', function () {
Route::post('text', 'app\ai\controller\OpenAi@text');
});
//豆包ai
Route::group('doubao', function () {
Route::post('text', 'app\ai\controller\DouBaoAI@text');
});
})->middleware(['jwt']);

View File

@@ -0,0 +1,53 @@
<?php
namespace app\ai\controller;
use think\facade\Env;
class DouBaoAI
{
protected $apiUrl;
protected $apiKey;
protected $headers;
public function __init()
{
$this->apiUrl = Env::get('doubaoAi.api_url');
$this->apiKey = Env::get('doubaoAi.api_key');
// 设置请求头
$this->headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey
];
if (empty($this->apiKey) || empty($this->apiUrl)) {
return json_encode(['code' => 500, 'msg' => '参数缺失']);
}
}
public function text()
{
$this->__init();
// 发送请求
$params = [
'model' => 'doubao-1-5-pro-32k-250115',
'messages' => [
['role' => 'system', 'content' => '你是人工智能助手.'],
['role' => 'user', 'content' => '厦门天气'],
],
/*'extra_headers' => [
'x-is-encrypted' => true
],
'temperature' => 1,
'top_p' => 0.7,
'max_tokens' => 4096,
'frequency_penalty' => 0,*/
];
$result = requestCurl($this->apiUrl, $params, 'POST', $this->headers, 'json');
$result = json_decode($result, true);
return successJson($result);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace app\ai\controller;
use think\facade\Env;
class OpenAi
{
protected $apiUrl;
protected $apiKey;
protected $headers;
public function __init()
{
$this->apiUrl = Env::get('openAi.apiUrl');
$this->apiKey = Env::get('openAi.apiKey');
// 设置请求头
$this->headers = [
'Content-Type: application/json',
'Authorization: Bearer '.$this->apiKey
];
}
public function text()
{
$this->__init();
$params = [
'model' => 'gpt-3.5-turbo-0125',
'input' => 'DHA 从孕期到出生到老年都需要助力大脑发育🧠减缓脑压力有助记忆给大脑动力贝蒂喜藻油DHA 双标认证每粒 150毫克高含量、高性价比从小吃到老长期吃更健康 重写这条朋友圈 要求: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除'
];
$result = $this->httpRequest( $this->apiUrl, 'POST', $params,$this->headers);
exit_data($result);
}
/**
* 示例调用OpenAI API生成睡前故事
* 对应curl命令
* curl "https://api.ai.com/v1/responses" \
* -H "Content-Type: application/json" \
* -H "Authorization: Bearer $OPENAI_API_KEY" \
* -d '{
* "model": "gpt-5",
* "input": "Write a one-sentence bedtime story about a unicorn."
* }'
*/
public function bedtimeStory()
{
$this->__init();
// API请求参数
$params = [
'model' => 'gpt-5',
'input' => 'Write a one-sentence bedtime story about a unicorn.'
];
// 发送请求到OpenAI API
$url = 'https://api.openai.com/v1/responses';
$result = $this->httpRequest($url, 'POST', $params, $this->headers);
// 返回结果
exit_data($result);
}
/**
* CURL请求 - 专门用于JSON API请求
*
* @param $url 请求url地址
* @param $method 请求方法 get post
* @param null $postfields post数据数组
* @param array $headers 请求header信息
* @param int $timeout 超时时间
* @param bool|false $debug 调试开启 默认false
* @return mixed
*/
protected function httpRequest($url, $method = "GET", $postfields = null, $headers = array(), $timeout = 30, $debug = false)
{
$method = strtoupper($method);
$ci = curl_init();
/* Curl settings */
curl_setopt($ci, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 6.2; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0");
curl_setopt($ci, CURLOPT_CONNECTTIMEOUT, 60); /* 在发起连接前等待的时间如果设置为0则无限等待 */
curl_setopt($ci, CURLOPT_TIMEOUT, $timeout); /* 设置cURL允许执行的最长秒数 */
curl_setopt($ci, CURLOPT_RETURNTRANSFER, true);
switch ($method) {
case "POST":
curl_setopt($ci, CURLOPT_POST, true);
if (!empty($postfields)) {
// 对于JSON API直接将数组转换为JSON字符串
if (is_array($postfields)) {
$tmpdatastr = json_encode($postfields);
} else {
$tmpdatastr = $postfields;
}
curl_setopt($ci, CURLOPT_POSTFIELDS, $tmpdatastr);
}
break;
default:
curl_setopt($ci, CURLOPT_CUSTOMREQUEST, $method); /* //设置请求方式 */
break;
}
$ssl = preg_match('/^https:\/\//i', $url) ? TRUE : FALSE;
curl_setopt($ci, CURLOPT_URL, $url);
if ($ssl) {
curl_setopt($ci, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书和hosts
curl_setopt($ci, CURLOPT_SSL_VERIFYHOST, FALSE); // 不从证书中检查SSL加密算法是否存在
}
if (ini_get('open_basedir') == '' && ini_get('safe_mode' == 'Off')) {
curl_setopt($ci, CURLOPT_FOLLOWLOCATION, 1);
}
curl_setopt($ci, CURLOPT_MAXREDIRS, 2);/*指定最多的HTTP重定向的数量这个选项是和CURLOPT_FOLLOWLOCATION一起使用的*/
curl_setopt($ci, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ci, CURLINFO_HEADER_OUT, true);
$response = curl_exec($ci);
$requestinfo = curl_getinfo($ci);
$http_code = curl_getinfo($ci, CURLINFO_HTTP_CODE);
if ($debug) {
echo "=====post data======\r\n";
var_dump($postfields);
echo "=====info===== \r\n";
print_r($requestinfo);
echo "=====response=====\r\n";
print_r($response);
}
curl_close($ci);
return $response;
}
}

View File

@@ -4,6 +4,7 @@ namespace app\api\controller;
use app\api\model\DeviceModel;
use app\api\model\DeviceGroupModel;
use think\Db;
use think\facade\Request;
use think\facade\Env;
use Endroid\QrCode\QrCode;
@@ -274,6 +275,53 @@ class DeviceController extends BaseController
}
}
/**
* 删除设备
*
* @param $deviceId
* @return false|string
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\ModelNotFoundException
* @throws \think\exception\DbException
*/
public function delDevice($deviceId = '')
{
$authorization = $this->authorization;
if (empty($authorization)) {
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
}
if (empty($deviceId)) {
return json_encode(['code'=>500,'msg'=>'删除的设备不能为空']);
}
$device = Db::table('s2_device')->where('id', $deviceId)->find();
if (empty($device)) {
return json_encode(['code'=>500,'msg'=>'设备不存在']);
}
try {
// 设置请求头
$headerData = ['client:system'];
$header = setHeader($headerData, $authorization, 'json');
// 发送请求
$result = requestCurl($this->baseUrl . 'api/device/del/'.$deviceId, [], 'DELETE', $header,'json');
if (empty($result)) {
Db::table('s2_device')->where('id', $deviceId)->update([
'isDeleted' => 1,
'deleteTime' => time()
]);
return json_encode(['code'=>200,'msg'=>'删除成功']);
}else{
return json_encode(['code'=>200,'msg'=>'删除失败']);
}
} catch (\Exception $e) {
return json_encode(['code'=>500,'msg'=>'获取设备分组列表失败:' . $e->getMessage()]);
}
}
/************************ 设备分组相关接口 ************************/
/**

View File

@@ -200,14 +200,18 @@ class WebSocketController extends BaseController
* @param array $data 消息数据
* @return array
*/
protected function sendMessage($data)
protected function sendMessage($data,$receive = true)
{
$this->checkConnection();
try {
$this->client->send(json_encode($data));
$response = $this->client->receive();
return json_decode($response, true);
if ($receive){
$response = $this->client->receive();
return json_decode($response, true);
}else{
return ['code' => 200, 'msg' => '成功'];
}
} catch (\Exception $e) {
Log::error("发送消息失败:" . $e->getMessage());
$this->reconnect();
@@ -787,6 +791,108 @@ class WebSocketController extends BaseController
}
/**
* 添加群好友
* @param $data
* @return false|string
*/
public function CmdChatroomOperate($data = [])
{
try {
// 参数验证
if (empty($data)) {
return json_encode(['code' => 400, 'msg' => '参数缺失']);
}
// 验证必要参数
if (empty($data['wechatId'])) {
return json_encode(['code' => 400, 'msg' => 'wechatId不能为空']);
}
if (empty($data['sendWord'])) {
return json_encode(['code' => 400, 'msg' => '添加的招呼语不能为空']);
}
if (empty($data['wechatAccountId'])) {
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
}
if (empty($data['wechatChatroomId'])) {
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
}
// 构建请求参数
$params = [
"chatroomOperateType" => 1,
"cmdType" => "CmdChatroomOperate",
"extra" => [
'wechatId' => $data['wechatId'],
'sendWord' => $data['sendWord']
],
"seq" => time(),
"wechatAccountId" => $data['wechatAccountId'],
"wechatChatroomId" => $data['wechatChatroomId'],
];
$message = $this->sendMessage($params);
return json_encode(['code' => 200, 'msg' => '添加好友请求发送成功', 'data' => $message]);
} catch (\Exception $e) {
// 返回错误响应
return json_encode(['code' => 500, 'msg' => '添加群好友异常:' . $e->getMessage()]);
}
}
/**
* 创建群聊
* @param array $data 请求参数
* @return string JSON响应
*/
public function CmdChatroomCreate($data = [])
{
try {
// 参数验证
if (empty($data)) {
return json_encode(['code' => 400, 'msg' => '参数缺失']);
}
// 验证必要参数
if (empty($data['chatroomName'])) {
return json_encode(['code' => 400, 'msg' => '群名称不能为空']);
}
if (empty($data['wechatFriendIds']) || !is_array($data['wechatFriendIds'])) {
return json_encode(['code' => 400, 'msg' => '好友ID列表不能为空且必须为数组']);
}
if (count($data['wechatFriendIds']) < 2) {
return json_encode(['code' => 400, 'msg' => '创建群聊至少需要2个好友']);
}
if (empty($data['wechatAccountId'])) {
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
}
// 构建请求参数
$params = [
"cmdType" => "CmdChatroomCreate",
"seq" => time(),
"wechatAccountId" => $data['wechatAccountId'],
"chatroomName" => $data['chatroomName'],
"wechatFriendIds" => $data['wechatFriendIds']
];
// 记录请求日志
Log::info('创建群聊请求:' . json_encode($params, 256));
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '群聊创建成功', 'data' => $message]);
} catch (\Exception $e) {
// 记录错误日志
Log::error('创建群聊异常:' . $e->getMessage());
// 返回错误响应
return json_encode(['code' => 500, 'msg' => '创建群聊异常:' . $e->getMessage()]);
}
}
/**
* 邀请好友入群
* @param array $data 请求参数
@@ -804,31 +910,26 @@ class WebSocketController extends BaseController
if (empty($data['wechatChatroomId'])) {
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
}
if (empty($data['wechatFriendId'])) {
if (empty($data['wechatFriendIds'])) {
return json_encode(['code' => 400, 'msg' => '好友ID不能为空']);
}
if (!is_array($data['wechatFriendId'])) {
if (!is_array($data['wechatFriendIds'])) {
return json_encode(['code' => 400, 'msg' => '好友数据格式必须为数组']);
}
if (empty($data['wechatAccountId'])) {
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
}
// 构建请求参数
$params = [
"cmdType" => "CmdChatroomInvite",
"seq" => time(),
"wechatChatroomId" => $data['wechatChatroomId'],
"wechatFriendId" => $data['wechatFriendId'],
"wechatAccountId" => $data['wechatAccountId']
"wechatFriendIds" => $data['wechatFriendIds']
];
// 记录请求日志
Log::info('邀请好友入群请求:' . json_encode($params, 256));
$message = $this->sendMessage($params);
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '邀请成功', 'data' => $message]);
} catch (\Exception $e) {
// 记录错误日志
@@ -837,4 +938,64 @@ class WebSocketController extends BaseController
return json_encode(['code' => 500, 'msg' => '邀请好友入群异常:' . $e->getMessage()]);
}
}
/**
* 修改群信息(群昵称和群公告)
* @param array $data 请求参数
* @return string JSON响应
*/
public function CmdChatroomModifyInfo($data = [])
{
try {
// 参数验证
if (empty($data)) {
return json_encode(['code' => 400, 'msg' => '参数缺失']);
}
// 验证必要参数
if (empty($data['wechatChatroomId'])) {
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
}
if (empty($data['wechatAccountId'])) {
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
}
// 检查是否至少提供了一个修改项
if (empty($data['chatroomName']) && !isset($data['announce'])) {
return json_encode(['code' => 400, 'msg' => '请至少提供群昵称或群公告中的一个参数']);
}
if (!empty($data['chatroomName'])) {
$extra = [
"chatroomName" => $data['chatroomName']
];
} else {
$extra = [
"announce" => $data['announce']
];
}
$params = [
"chatroomOperateType" => !empty($data['chatroomName']) ? 6 : 5,
"cmdType" => "CmdChatroomOperate",
"extra" => json_encode($extra,256),
"seq" => time(),
"wechatAccountId" => $data['wechatAccountId'],
"wechatChatroomId" => $data['wechatChatroomId']
];
// 记录请求日志
Log::info('创建群聊请求:' . json_encode($params, 256));
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '群聊创建成功', 'data' => $message]);
} catch (\Exception $e) {
Log::error('修改群信息异常: ' . $e->getMessage());
return json_encode(['code' => 500, 'msg' => '修改群信息失败: ' . $e->getMessage()]);
}
}
}

View File

@@ -13,16 +13,12 @@ class WechatChatroomController extends BaseController
* 获取微信群聊列表
* @return \think\response\Json
*/
public function getlist($pageIndex = '',$pageSize = '',$isInner = false, $isDel = '')
public function getlist($data = [],$isInner = false, $isDel = '')
{
// 获取授权token
$authorization = trim($this->request->header('authorization', $this->authorization));
$authorization = $this->authorization;
if (empty($authorization)) {
if($isInner){
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
}else{
return errorJson('缺少授权信息');
}
}
try {
@@ -36,15 +32,15 @@ class WechatChatroomController extends BaseController
// 构建请求参数
$params = [
'keyword' => $this->request->param('keyword', ''),
'wechatAccountKeyword' => $this->request->param('wechatAccountKeyword', ''),
'isDeleted' => $this->request->param('isDeleted', $isDeleted),
'allotAccountId' => $this->request->param('allotAccountId', ''),
'groupId' => $this->request->param('groupId', ''),
'wechatChatroomId' => $this->request->param('wechatChatroomId', 0),
'memberKeyword' => $this->request->param('memberKeyword', ''),
'pageIndex' => !empty($pageIndex) ? $pageIndex : input('pageIndex', 0),
'pageSize' => !empty($pageSize) ? $pageSize : input('pageSize', 20)
'keyword' => $data['keyword'] ?? '',
'wechatAccountKeyword' => $data['wechatAccountKeyword'] ?? '',
'isDeleted' => $data['isDeleted'] ?? $isDeleted ,
'allotAccountId' => $data['allotAccountId'] ?? '',
'groupId' => $data['groupId'] ?? '',
'wechatChatroomId' => $data['wechatChatroomId'] ?? '',
'memberKeyword' => $data['memberKeyword'] ?? '',
'pageIndex' => $data['pageIndex'] ?? 1,
'pageSize' => $data['pageSize'] ?? 20
];
// 设置请求头

Some files were not shown because too many files have changed in this diff Show More