Merge branch 'yongpxu-dev' into yongpxu-dev4
This commit is contained in:
26
Cunkebao/dist/.vite/manifest.json
vendored
26
Cunkebao/dist/.vite/manifest.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
Cunkebao/dist/index.html
vendored
8
Cunkebao/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
30
Cunkebao/pnpm-lock.yaml
generated
30
Cunkebao/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
232
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal file
232
Cunkebao/src/components/ContentSelection/selectionPopup.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
48
Cunkebao/src/components/Layout/LayoutFiexd.tsx
Normal file
48
Cunkebao/src/components/Layout/LayoutFiexd.tsx
Normal 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;
|
||||
34
Cunkebao/src/components/PoolSelection/api.ts
Normal file
34
Cunkebao/src/components/PoolSelection/api.ts
Normal 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");
|
||||
}
|
||||
61
Cunkebao/src/components/PoolSelection/data.ts
Normal file
61
Cunkebao/src/components/PoolSelection/data.ts
Normal 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;
|
||||
}
|
||||
206
Cunkebao/src/components/PoolSelection/index.module.scss
Normal file
206
Cunkebao/src/components/PoolSelection/index.module.scss
Normal 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;
|
||||
}
|
||||
127
Cunkebao/src/components/PoolSelection/index.tsx
Normal file
127
Cunkebao/src/components/PoolSelection/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
222
Cunkebao/src/components/PoolSelection/selectionPopup.tsx
Normal file
222
Cunkebao/src/components/PoolSelection/selectionPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
// 第三方登录处理
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 || []);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 筛选条件变化时刷新列表
|
||||
|
||||
@@ -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);
|
||||
// 重新获取列表
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,4 +54,6 @@ export const defFormData: FormData = {
|
||||
deveiceGroupsOptions: [],
|
||||
wechatGroups: [],
|
||||
wechatGroupsOptions: [],
|
||||
contentGroups: [],
|
||||
contentGroupsOptions: [],
|
||||
};
|
||||
|
||||
@@ -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 ?? [],
|
||||
}));
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface MessageContentItem {
|
||||
groupIds?: string[]; // 改为数组以支持GroupSelection组件
|
||||
groupOptions?: any[]; // 添加群选项数组
|
||||
linkUrl?: string;
|
||||
coverImage?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MessageContentGroup {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? "更新任务" : "创建任务"}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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[];
|
||||
// 京东联盟相关字段
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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` - 发送消息
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" }}>
|
||||
{/* 左侧素材分类 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: string; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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; /* 默认隐藏底部,如果需要显示可以移除此行 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
184
Cunkebao/src/pages/pc/ckbox/components/SidebarMenu/index.tsx
Normal file
184
Cunkebao/src/pages/pc/ckbox/components/SidebarMenu/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
Cunkebao/src/pages/pc/ckbox/components/Skeleton/index.tsx
Normal file
119
Cunkebao/src/pages/pc/ckbox/components/Skeleton/index.tsx
Normal 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;
|
||||
@@ -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; // 灰色表示离线
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
325
Cunkebao/src/pages/pc/ckbox/main.ts
Normal file
325
Cunkebao/src/pages/pc/ckbox/main.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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": "联系人导入",
|
||||
};
|
||||
|
||||
// 获取路由标题
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -80,6 +80,7 @@ export const useUserStore = createPersistStore<UserState>(
|
||||
},
|
||||
login2: token2 => {
|
||||
localStorage.setItem("token2", token2);
|
||||
|
||||
set({ token2, isLoggedIn: true });
|
||||
},
|
||||
logout: () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 弹窗内容
|
||||
|
||||
508
Cunkebao/src/utils/db-examples.ts
Normal file
508
Cunkebao/src/utils/db-examples.ts
Normal 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
446
Cunkebao/src/utils/db.ts
Normal 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;
|
||||
22
Server/application/ai/config/route.php
Normal file
22
Server/application/ai/config/route.php
Normal 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']);
|
||||
53
Server/application/ai/controller/DouBaoAI.php
Normal file
53
Server/application/ai/controller/DouBaoAI.php
Normal 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);
|
||||
}
|
||||
}
|
||||
141
Server/application/ai/controller/OpenAi.php
Normal file
141
Server/application/ai/controller/OpenAi.php
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/************************ 设备分组相关接口 ************************/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user