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": {
|
"_charts-DKSCc2_C.js": {
|
||||||
"file": "assets/charts-CLRTJ7Uf.js",
|
"file": "assets/charts-DKSCc2_C.js",
|
||||||
"name": "charts",
|
"name": "charts",
|
||||||
"imports": [
|
"imports": [
|
||||||
"_ui-BFvqeNzU.js",
|
"_ui-DhAz00L0.js",
|
||||||
"_vendor-2vc8h_ct.js"
|
"_vendor-2vc8h_ct.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_ui-BFvqeNzU.js": {
|
"_ui-D0C0OGrH.css": {
|
||||||
"file": "assets/ui-BFvqeNzU.js",
|
"file": "assets/ui-D0C0OGrH.css",
|
||||||
|
"src": "_ui-D0C0OGrH.css"
|
||||||
|
},
|
||||||
|
"_ui-DhAz00L0.js": {
|
||||||
|
"file": "assets/ui-DhAz00L0.js",
|
||||||
"name": "ui",
|
"name": "ui",
|
||||||
"imports": [
|
"imports": [
|
||||||
"_vendor-2vc8h_ct.js"
|
"_vendor-2vc8h_ct.js"
|
||||||
@@ -17,10 +21,6 @@
|
|||||||
"assets/ui-D0C0OGrH.css"
|
"assets/ui-D0C0OGrH.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_ui-D0C0OGrH.css": {
|
|
||||||
"file": "assets/ui-D0C0OGrH.css",
|
|
||||||
"src": "_ui-D0C0OGrH.css"
|
|
||||||
},
|
|
||||||
"_utils-6WF66_dS.js": {
|
"_utils-6WF66_dS.js": {
|
||||||
"file": "assets/utils-6WF66_dS.js",
|
"file": "assets/utils-6WF66_dS.js",
|
||||||
"name": "utils",
|
"name": "utils",
|
||||||
@@ -33,18 +33,18 @@
|
|||||||
"name": "vendor"
|
"name": "vendor"
|
||||||
},
|
},
|
||||||
"index.html": {
|
"index.html": {
|
||||||
"file": "assets/index-C48GlG01.js",
|
"file": "assets/index-BdCPAYQ7.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "index.html",
|
"src": "index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_vendor-2vc8h_ct.js",
|
"_vendor-2vc8h_ct.js",
|
||||||
"_ui-BFvqeNzU.js",
|
|
||||||
"_utils-6WF66_dS.js",
|
"_utils-6WF66_dS.js",
|
||||||
"_charts-CLRTJ7Uf.js"
|
"_ui-DhAz00L0.js",
|
||||||
|
"_charts-DKSCc2_C.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"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>
|
</style>
|
||||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||||
<script type="text/javascript" src="/websdk.js"></script>
|
<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/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/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/ui-D0C0OGrH.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Ta4vyxDJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-ChiFk16x.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
"antd-mobile-icons": "^0.3.0",
|
"antd-mobile-icons": "^0.3.0",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"dexie": "^4.2.0",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
|
"react-window": "^1.8.11",
|
||||||
"vconsole": "^3.15.1",
|
"vconsole": "^3.15.1",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
|
|||||||
30
Cunkebao/pnpm-lock.yaml
generated
30
Cunkebao/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
|
dexie:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
echarts:
|
echarts:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
@@ -41,6 +44,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.20.0
|
specifier: ^6.20.0
|
||||||
version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
vconsole:
|
||||||
specifier: ^3.15.1
|
specifier: ^3.15.1
|
||||||
version: 3.15.1
|
version: 3.15.1
|
||||||
@@ -1061,6 +1067,9 @@ packages:
|
|||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
dexie@4.2.0:
|
||||||
|
resolution: {integrity: sha512-OSeyyWOUetDy9oFWeddJgi83OnRA3hSFh3RrbltmPgqHszE9f24eUCVLI4mPg0ifsWk0lQTdnS+jyGNrPMvhDA==}
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1563,6 +1572,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
memoize-one@5.2.1:
|
||||||
|
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||||
|
|
||||||
merge2@1.4.1:
|
merge2@1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2001,6 +2013,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.8'
|
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:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3384,6 +3403,8 @@ snapshots:
|
|||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dexie@4.2.0: {}
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
@@ -4025,6 +4046,8 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
memoize-one@5.2.1: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
@@ -4538,6 +4561,13 @@ snapshots:
|
|||||||
'@remix-run/router': 1.23.0
|
'@remix-run/router': 1.23.0
|
||||||
react: 18.3.1
|
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:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const instance: AxiosInstance = axios.create({
|
|||||||
instance.interceptors.request.use((config: any) => {
|
instance.interceptors.request.use((config: any) => {
|
||||||
// 在每次请求时动态获取最新的 token2
|
// 在每次请求时动态获取最新的 token2
|
||||||
const { token2 } = useUserStore.getState();
|
const { token2 } = useUserStore.getState();
|
||||||
|
|
||||||
if (token2) {
|
if (token2) {
|
||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
config.headers["Authorization"] = `bearer ${token2}`;
|
config.headers["Authorization"] = `bearer ${token2}`;
|
||||||
@@ -41,6 +40,15 @@ instance.interceptors.response.use(
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
err => {
|
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" });
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export default function SelectionPopup({
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalAccounts, setTotalAccounts] = useState(0);
|
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<AccountItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
||||||
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
||||||
@@ -58,16 +61,19 @@ export default function SelectionPopup({
|
|||||||
|
|
||||||
const handleAccountToggle = (account: AccountItem) => {
|
const handleAccountToggle = (account: AccountItem) => {
|
||||||
if (readonly || !onSelect) return;
|
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
|
const next = isSelected
|
||||||
? selectedOptions.filter(opt => opt.id !== account.id)
|
? tempSelectedOptions.filter(opt => opt.id !== account.id)
|
||||||
: selectedOptions.concat(account);
|
: tempSelectedOptions.concat(account);
|
||||||
onSelect(next);
|
setTempSelectedOptions(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm(selectedOptions);
|
onConfirm(tempSelectedOptions);
|
||||||
|
}
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
}
|
}
|
||||||
onVisibleChange(false);
|
onVisibleChange(false);
|
||||||
};
|
};
|
||||||
@@ -78,9 +84,11 @@ export default function SelectionPopup({
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
loadedAccountMapRef.current.clear();
|
loadedAccountMapRef.current.clear();
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
fetchAccounts(1, "");
|
fetchAccounts(1, "");
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible, selectedOptions]);
|
||||||
|
|
||||||
// 搜索防抖
|
// 搜索防抖
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,8 +108,8 @@ export default function SelectionPopup({
|
|||||||
}, [currentPage, visible, searchQuery]);
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
const selectedIdSet = useMemo(
|
const selectedIdSet = useMemo(
|
||||||
() => new Set(selectedOptions.map(opt => opt.id)),
|
() => new Set(tempSelectedOptions.map(opt => opt.id)),
|
||||||
[selectedOptions],
|
[tempSelectedOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +136,7 @@ export default function SelectionPopup({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCount={selectedOptions.length}
|
selectedCount={tempSelectedOptions.length}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onCancel={() => onVisibleChange(false)}
|
onCancel={() => onVisibleChange(false)}
|
||||||
onConfirm={handleConfirm}
|
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 { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
import { Button, Input } from "antd";
|
import { Button, Input } from "antd";
|
||||||
import { Popup, Checkbox } from "antd-mobile";
|
|
||||||
import style from "./index.module.scss";
|
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 { ContentItem, ContentSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
|
||||||
// 类型标签文本
|
const ContentSelection: React.FC<ContentSelectionProps> = ({
|
||||||
const getTypeText = (type?: number) => {
|
|
||||||
if (type === 1) return "文本";
|
|
||||||
if (type === 2) return "图片";
|
|
||||||
if (type === 3) return "视频";
|
|
||||||
return "未知";
|
|
||||||
};
|
|
||||||
|
|
||||||
// 时间格式化
|
|
||||||
const formatDate = (dateStr?: string) => {
|
|
||||||
if (!dateStr) return "-";
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
if (isNaN(d.getTime())) return "-";
|
|
||||||
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
|
||||||
.getHours()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
|
||||||
.getSeconds()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContentSelection({
|
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
onSelect,
|
onSelect,
|
||||||
placeholder = "选择内容库",
|
placeholder = "选择内容库",
|
||||||
@@ -45,22 +17,9 @@ export default function ContentSelection({
|
|||||||
showSelectedList = true,
|
showSelectedList = true,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ContentSelectionProps) {
|
}) => {
|
||||||
|
// 弹窗控制
|
||||||
const [popupVisible, setPopupVisible] = useState(false);
|
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 realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
const setRealVisible = (v: boolean) => {
|
const setRealVisible = (v: boolean) => {
|
||||||
if (onVisibleChange) onVisibleChange(v);
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
@@ -70,60 +29,7 @@ export default function ContentSelection({
|
|||||||
// 打开弹窗
|
// 打开弹窗
|
||||||
const openPopup = () => {
|
const openPopup = () => {
|
||||||
if (readonly) return;
|
if (readonly) return;
|
||||||
setCurrentPage(1);
|
|
||||||
setSearchQuery("");
|
|
||||||
setRealVisible(true);
|
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} 个内容库`;
|
return `已选择 ${selectedOptions.length} 个内容库`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认选择
|
// 删除已选内容库
|
||||||
const handleConfirm = () => {
|
const handleRemoveLibrary = (id: number) => {
|
||||||
if (onConfirm) {
|
if (readonly) return;
|
||||||
onConfirm(selectedOptions);
|
onSelect(selectedOptions.filter(c => c.id !== id));
|
||||||
}
|
};
|
||||||
setRealVisible(false);
|
|
||||||
|
// 清除所有已选内容库
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,6 +61,7 @@ export default function ContentSelection({
|
|||||||
onClick={openPopup}
|
onClick={openPopup}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear={!readonly}
|
allowClear={!readonly}
|
||||||
|
onClear={handleClearAll}
|
||||||
size="large"
|
size="large"
|
||||||
readOnly={readonly}
|
readOnly={readonly}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
@@ -220,83 +131,15 @@ export default function ContentSelection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 弹窗 */}
|
{/* 弹窗 */}
|
||||||
<Popup
|
<SelectionPopup
|
||||||
visible={realVisible && !readonly}
|
visible={realVisible && !readonly}
|
||||||
onMaskClick={() => setRealVisible(false)}
|
onClose={() => setRealVisible(false)}
|
||||||
position="bottom"
|
selectedOptions={selectedOptions}
|
||||||
bodyStyle={{ height: "100vh" }}
|
onSelect={onSelect}
|
||||||
>
|
onConfirm={onConfirm}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
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 [loading, setLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
DeviceSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
// 获取设备列表,支持keyword和分页
|
// 获取设备列表,支持keyword和分页
|
||||||
const fetchDevices = useCallback(
|
const fetchDevices = useCallback(
|
||||||
@@ -71,9 +74,11 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
fetchDevices("", 1);
|
fetchDevices("", 1);
|
||||||
}
|
}
|
||||||
}, [visible, fetchDevices]);
|
}, [visible, fetchDevices, selectedOptions]);
|
||||||
|
|
||||||
// 搜索防抖
|
// 搜索防抖
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,11 +110,13 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
|
|
||||||
// 处理设备选择
|
// 处理设备选择
|
||||||
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||||
if (selectedOptions.some(v => v.id === device.id)) {
|
if (tempSelectedOptions.some(v => v.id === device.id)) {
|
||||||
onSelect(selectedOptions.filter(v => v.id !== device.id));
|
setTempSelectedOptions(
|
||||||
|
tempSelectedOptions.filter(v => v.id !== device.id),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const newSelectedOptions = [...selectedOptions, device];
|
const newSelectedOptions = [...tempSelectedOptions, device];
|
||||||
onSelect(newSelectedOptions);
|
setTempSelectedOptions(newSelectedOptions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,10 +155,14 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCount={selectedOptions.length}
|
selectedCount={tempSelectedOptions.length}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onCancel={onClose}
|
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.headerRow}>
|
||||||
<div className={style.checkboxContainer}>
|
<div className={style.checkboxContainer}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOptions.some(v => v.id === device.id)}
|
checked={tempSelectedOptions.some(
|
||||||
|
v => v.id === device.id,
|
||||||
|
)}
|
||||||
onChange={() => handleDeviceToggle(device)}
|
onChange={() => handleDeviceToggle(device)}
|
||||||
className={style.deviceCheckbox}
|
className={style.deviceCheckbox}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface FriendSelectionItem {
|
|||||||
export interface FriendSelectionProps {
|
export interface FriendSelectionProps {
|
||||||
selectedOptions?: FriendSelectionItem[];
|
selectedOptions?: FriendSelectionItem[];
|
||||||
onSelect: (friends: FriendSelectionItem[]) => void;
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
deviceIds?: string[];
|
deviceIds?: number[];
|
||||||
enableDeviceFilter?: boolean;
|
enableDeviceFilter?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface SelectionPopupProps {
|
|||||||
onVisibleChange: (visible: boolean) => void;
|
onVisibleChange: (visible: boolean) => void;
|
||||||
selectedOptions: FriendSelectionItem[];
|
selectedOptions: FriendSelectionItem[];
|
||||||
onSelect: (friends: FriendSelectionItem[]) => void;
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
deviceIds?: string[];
|
deviceIds?: number[];
|
||||||
enableDeviceFilter?: boolean;
|
enableDeviceFilter?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
onConfirm?: (
|
onConfirm?: (
|
||||||
@@ -37,6 +37,9 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalFriends, setTotalFriends] = useState(0);
|
const [totalFriends, setTotalFriends] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
FriendSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
// 获取好友列表API
|
// 获取好友列表API
|
||||||
const fetchFriends = useCallback(
|
const fetchFriends = useCallback(
|
||||||
@@ -75,21 +78,23 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
const handleFriendToggle = (friend: FriendSelectionItem) => {
|
const handleFriendToggle = (friend: FriendSelectionItem) => {
|
||||||
if (readonly) return;
|
if (readonly) return;
|
||||||
|
|
||||||
const newSelectedFriends = selectedOptions.some(f => f.id === friend.id)
|
const newSelectedFriends = tempSelectedOptions.some(f => f.id === friend.id)
|
||||||
? selectedOptions.filter(f => f.id !== friend.id)
|
? tempSelectedOptions.filter(f => f.id !== friend.id)
|
||||||
: selectedOptions.concat(friend);
|
: tempSelectedOptions.concat(friend);
|
||||||
|
|
||||||
onSelect(newSelectedFriends);
|
setTempSelectedOptions(newSelectedFriends);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认选择
|
// 确认选择
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm(
|
onConfirm(
|
||||||
selectedOptions.map(v => v.id),
|
tempSelectedOptions.map(v => v.id),
|
||||||
selectedOptions,
|
tempSelectedOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 更新实际选中的选项
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
onVisibleChange(false);
|
onVisibleChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,9 +103,11 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
fetchFriends(1, "");
|
fetchFriends(1, "");
|
||||||
}
|
}
|
||||||
}, [visible]); // 只在弹窗开启时请求
|
}, [visible, selectedOptions]); // 只在弹窗开启时请求
|
||||||
|
|
||||||
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,7 +151,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCount={selectedOptions.length}
|
selectedCount={tempSelectedOptions.length}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onCancel={() => onVisibleChange(false)}
|
onCancel={() => onVisibleChange(false)}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
@@ -161,7 +168,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
|||||||
{friends.map(friend => (
|
{friends.map(friend => (
|
||||||
<div key={friend.id} className={style.friendItem}>
|
<div key={friend.id} className={style.friendItem}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOptions.some(f => f.id === friend.id)}
|
checked={tempSelectedOptions.some(f => f.id === friend.id)}
|
||||||
onChange={() => !readonly && handleFriendToggle(friend)}
|
onChange={() => !readonly && handleFriendToggle(friend)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
style={{ marginRight: 12 }}
|
style={{ marginRight: 12 }}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export default function SelectionPopup({
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalGroups, setTotalGroups] = useState(0);
|
const [totalGroups, setTotalGroups] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tempSelectedOptions, setTempSelectedOptions] = useState<
|
||||||
|
GroupSelectionItem[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
// 获取群聊列表API
|
// 获取群聊列表API
|
||||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||||
@@ -78,27 +81,30 @@ export default function SelectionPopup({
|
|||||||
const handleGroupToggle = (group: GroupSelectionItem) => {
|
const handleGroupToggle = (group: GroupSelectionItem) => {
|
||||||
if (readonly) return;
|
if (readonly) return;
|
||||||
|
|
||||||
const newSelectedGroups = selectedOptions.some(g => g.id === group.id)
|
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
|
||||||
? selectedOptions.filter(g => g.id !== group.id)
|
? tempSelectedOptions.filter(g => g.id !== group.id)
|
||||||
: selectedOptions.concat(group);
|
: tempSelectedOptions.concat(group);
|
||||||
|
|
||||||
onSelect(newSelectedGroups);
|
setTempSelectedOptions(newSelectedGroups);
|
||||||
|
|
||||||
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
|
||||||
if (onSelectDetail) {
|
|
||||||
const selectedGroupObjs = groups.filter(group =>
|
|
||||||
newSelectedGroups.some(g => g.id === group.id),
|
|
||||||
);
|
|
||||||
onSelectDetail(selectedGroupObjs);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认选择
|
// 确认选择
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
// 用户点击确认时,才更新实际的selectedOptions
|
||||||
|
onSelect(tempSelectedOptions);
|
||||||
|
|
||||||
|
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||||
|
if (onSelectDetail) {
|
||||||
|
const selectedGroupObjs = groups.filter(group =>
|
||||||
|
tempSelectedOptions.some(g => g.id === group.id),
|
||||||
|
);
|
||||||
|
onSelectDetail(selectedGroupObjs);
|
||||||
|
}
|
||||||
|
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm(
|
onConfirm(
|
||||||
selectedOptions.map(g => g.id),
|
tempSelectedOptions.map(g => g.id),
|
||||||
selectedOptions,
|
tempSelectedOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
onVisibleChange(false);
|
onVisibleChange(false);
|
||||||
@@ -109,6 +115,8 @@ export default function SelectionPopup({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
// 复制一份selectedOptions到临时变量
|
||||||
|
setTempSelectedOptions([...selectedOptions]);
|
||||||
fetchGroups(1, "");
|
fetchGroups(1, "");
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
@@ -155,7 +163,7 @@ export default function SelectionPopup({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
selectedCount={selectedOptions.length}
|
selectedCount={tempSelectedOptions.length}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
onCancel={() => onVisibleChange(false)}
|
onCancel={() => onVisibleChange(false)}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
@@ -172,7 +180,7 @@ export default function SelectionPopup({
|
|||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<div key={group.id} className={style.groupItem}>
|
<div key={group.id} className={style.groupItem}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOptions.some(g => g.id === group.id)}
|
checked={tempSelectedOptions.some(g => g.id === group.id)}
|
||||||
onChange={() => !readonly && handleGroupToggle(group)}
|
onChange={() => !readonly && handleGroupToggle(group)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
style={{ marginRight: 12 }}
|
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;
|
searchPlaceholder?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
showRefresh?: boolean;
|
showRefresh?: boolean;
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showTabs?: boolean;
|
showTabs?: boolean;
|
||||||
@@ -28,6 +29,7 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
|
|||||||
searchPlaceholder = "搜索...",
|
searchPlaceholder = "搜索...",
|
||||||
loading = false,
|
loading = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onSearch,
|
||||||
showRefresh = true,
|
showRefresh = true,
|
||||||
showSearch = true,
|
showSearch = true,
|
||||||
showTabs = false,
|
showTabs = false,
|
||||||
@@ -42,10 +44,11 @@ const PopupHeader: React.FC<PopupHeaderProps> = ({
|
|||||||
{showSearch && (
|
{showSearch && (
|
||||||
<div className={style.popupSearchRow}>
|
<div className={style.popupSearchRow}>
|
||||||
<div className={style.popupSearchInputWrap}>
|
<div className={style.popupSearchInputWrap}>
|
||||||
<Input
|
<Input.Search
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onSearch={() => onSearch && onSearch(searchQuery)}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ const WebSocketExample: React.FC = () => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
connect({
|
connect({
|
||||||
|
url: "wss://kf.quwanzhi.com:9993", // 显式指定WebSocket URL,确保使用正确的服务器地址
|
||||||
client: "kefu-client",
|
client: "kefu-client",
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ const Guide: React.FC = () => {
|
|||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
<Tabs.Tab title="扫码添加" key="scan" />
|
<Tabs.Tab title="扫码添加" key="scan" />
|
||||||
<Tabs.Tab title="手动添加" key="manual" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{addTab === "scan" && (
|
{addTab === "scan" && (
|
||||||
<div style={{ textAlign: "center", minHeight: 200 }}>
|
<div style={{ textAlign: "center", minHeight: 200 }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useCkChatStore } from "@/store/module/ckchat";
|
||||||
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
|
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
|
||||||
import {
|
import {
|
||||||
EyeInvisibleOutline,
|
EyeInvisibleOutline,
|
||||||
@@ -7,15 +7,8 @@ import {
|
|||||||
UserOutline,
|
UserOutline,
|
||||||
} from "antd-mobile-icons";
|
} from "antd-mobile-icons";
|
||||||
import { useUserStore } from "@/store/module/user";
|
import { useUserStore } from "@/store/module/user";
|
||||||
import { useCkChatStore } from "@/store/module/ckchat";
|
|
||||||
import { useWebSocketStore } from "@/store/module/websocket";
|
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
|
||||||
import {
|
|
||||||
loginWithPassword,
|
|
||||||
loginWithCode,
|
|
||||||
sendVerificationCode,
|
|
||||||
loginWithToken,
|
|
||||||
getChuKeBaoUserInfo,
|
|
||||||
} from "./api";
|
|
||||||
import style from "./login.module.scss";
|
import style from "./login.module.scss";
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
@@ -25,9 +18,8 @@ const Login: React.FC = () => {
|
|||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [agreeToTerms, setAgreeToTerms] = useState(false);
|
const [agreeToTerms, setAgreeToTerms] = useState(false);
|
||||||
|
const { setUserInfo } = useCkChatStore.getState();
|
||||||
const { login, login2 } = useUserStore();
|
const { login, login2 } = useUserStore();
|
||||||
const { setUserInfo, getAccountId } = useCkChatStore();
|
|
||||||
|
|
||||||
// 倒计时效果
|
// 倒计时效果
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,34 +61,7 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 登录处理
|
|
||||||
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) => {
|
const getToken = (values: any) => {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 添加typeId参数
|
// 添加typeId参数
|
||||||
const loginParams = {
|
const loginParams = {
|
||||||
...values,
|
...values,
|
||||||
@@ -108,39 +73,25 @@ const Login: React.FC = () => {
|
|||||||
? loginWithPassword(loginParams)
|
? loginWithPassword(loginParams)
|
||||||
: loginWithCode(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 => {
|
response.then(res => {
|
||||||
login2(res.access_token);
|
const { member, kefuData, deviceTotal } = res;
|
||||||
resolve(res.access_token);
|
login(res.token, member, deviceTotal);
|
||||||
});
|
const { self, token } = kefuData;
|
||||||
response.catch(err => {
|
login2(token.access_token);
|
||||||
reject(err);
|
setUserInfo(self);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 登录处理
|
||||||
|
const handleLogin = async (values: any) => {
|
||||||
|
if (!agreeToTerms) {
|
||||||
|
Toast.show({ content: "请同意用户协议和隐私政策", position: "top" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//获取存客宝
|
||||||
|
getToken(values);
|
||||||
|
};
|
||||||
|
|
||||||
// 第三方登录处理
|
// 第三方登录处理
|
||||||
const handleWechatLogin = () => {
|
const handleWechatLogin = () => {
|
||||||
Toast.show({ content: "微信登录功能开发中", position: "top" });
|
Toast.show({ content: "微信登录功能开发中", position: "top" });
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ const Devices: React.FC = () => {
|
|||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
<Tabs.Tab title="扫码添加" key="scan" />
|
<Tabs.Tab title="扫码添加" key="scan" />
|
||||||
<Tabs.Tab title="手动添加" key="manual" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{addTab === "scan" && (
|
{addTab === "scan" && (
|
||||||
<div style={{ textAlign: "center", minHeight: 200 }}>
|
<div style={{ textAlign: "center", minHeight: 200 }}>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const About: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 联系信息
|
// 联系信息
|
||||||
const contactInfo = [
|
const contractInfo = [
|
||||||
{
|
{
|
||||||
id: "email",
|
id: "email",
|
||||||
title: "邮箱支持",
|
title: "邮箱支持",
|
||||||
@@ -125,7 +125,7 @@ const About: React.FC = () => {
|
|||||||
{/* <Card className={style["setting-group"]}>
|
{/* <Card className={style["setting-group"]}>
|
||||||
<div className={style["group-title"]}>联系我们</div>
|
<div className={style["group-title"]}>联系我们</div>
|
||||||
<List>
|
<List>
|
||||||
{contactInfo.map(item => (
|
{contractInfo.map(item => (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={item.id}
|
key={item.id}
|
||||||
prefix={item.icon}
|
prefix={item.icon}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Modal, Selector } from "antd-mobile";
|
import { Popup, Selector } from "antd-mobile";
|
||||||
import type { PackageOption } from "./data";
|
import type { PackageOption } from "./data";
|
||||||
|
|
||||||
interface BatchAddModalProps {
|
interface BatchAddModalProps {
|
||||||
@@ -15,20 +15,30 @@ interface BatchAddModalProps {
|
|||||||
const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
packageOptions,
|
packageOptions = [],
|
||||||
batchTarget,
|
batchTarget,
|
||||||
setBatchTarget,
|
setBatchTarget,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
onConfirm,
|
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}
|
visible={visible}
|
||||||
title="批量加入分组"
|
onMaskClick={() => onClose()}
|
||||||
onClose={onClose}
|
position="bottom"
|
||||||
footer={[
|
bodyStyle={{ height: "80vh" }}
|
||||||
{ text: "取消", onClick: onClose },
|
|
||||||
{ text: "确定", onClick: onConfirm },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div>选择目标分组</div>
|
<div>选择目标分组</div>
|
||||||
@@ -41,7 +51,7 @@ const BatchAddModal: React.FC<BatchAddModalProps> = ({
|
|||||||
<div style={{ color: "#888", fontSize: 13 }}>
|
<div style={{ color: "#888", fontSize: 13 }}>
|
||||||
将选中的{selectedCount}个用户加入所选分组
|
将选中的{selectedCount}个用户加入所选分组
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Popup>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default BatchAddModal;
|
export default BatchAddModal;
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ export function fetchTrafficPoolList(params: {
|
|||||||
return request("/v1/traffic/pool", params, "GET");
|
return request("/v1/traffic/pool", params, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchScenarioOptions(): Promise<any[]> {
|
export async function fetchScenarioOptions() {
|
||||||
return request("/v1/plan/scenes", {}, "GET");
|
return request("/v1/plan/scenes", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPackageOptions(): Promise<any[]> {
|
export async function fetchPackageOptions() {
|
||||||
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
return request("/v1/traffic/pool/getPackage", {}, "GET");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import {
|
|||||||
fetchPackageOptions,
|
fetchPackageOptions,
|
||||||
fetchScenarioOptions,
|
fetchScenarioOptions,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import type {
|
import type { TrafficPoolUser, PackageOption, ScenarioOption } from "./data";
|
||||||
TrafficPoolUser,
|
|
||||||
DeviceOption,
|
|
||||||
PackageOption,
|
|
||||||
ValueLevel,
|
|
||||||
UserStatus,
|
|
||||||
ScenarioOption,
|
|
||||||
} from "./data";
|
|
||||||
import { Toast } from "antd-mobile";
|
import { Toast } from "antd-mobile";
|
||||||
|
|
||||||
export function useTrafficPoolListLogic() {
|
export function useTrafficPoolListLogic() {
|
||||||
@@ -78,8 +71,12 @@ export function useTrafficPoolListLogic() {
|
|||||||
|
|
||||||
// 获取筛选项
|
// 获取筛选项
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPackageOptions().then(setPackageOptions);
|
fetchPackageOptions().then(res => {
|
||||||
fetchScenarioOptions().then(setScenarioOptions);
|
setPackageOptions(res.list || []);
|
||||||
|
});
|
||||||
|
fetchScenarioOptions().then(res => {
|
||||||
|
setScenarioOptions(res.list || []);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 筛选条件变化时刷新列表
|
// 筛选条件变化时刷新列表
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ const TrafficPoolList: React.FC = () => {
|
|||||||
status: "offline" as const,
|
status: "offline" as const,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setPackageId(filters.packageId);
|
setPackageId(filters.packageId ? parseInt(filters.packageId) : 0);
|
||||||
setScenarioId(filters.scenarioId);
|
setScenarioId(filters.scenarioId ? parseInt(filters.scenarioId) : 0);
|
||||||
setUserValue(filters.userValue);
|
setUserValue(filters.userValue);
|
||||||
setUserStatus(filters.userStatus);
|
setUserStatus(filters.userStatus);
|
||||||
// 重新获取列表
|
// 重新获取列表
|
||||||
|
|||||||
@@ -61,29 +61,40 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
|||||||
}, [visible, ruleId]);
|
}, [visible, ruleId]);
|
||||||
|
|
||||||
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
|
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
|
||||||
const getStatusColor = (status?: string) => {
|
const getStatusColor = (status?: string | number) => {
|
||||||
switch (status) {
|
// 确保status是数字类型
|
||||||
case "normal":
|
const statusNum = Number(status);
|
||||||
return "#52c41a";
|
|
||||||
case "limited":
|
switch (statusNum) {
|
||||||
return "#faad14";
|
case 0:
|
||||||
case "blocked":
|
return "#faad14"; // 待添加 - 黄色警告色
|
||||||
return "#ff4d4f";
|
case 1:
|
||||||
|
return "#1890ff"; // 添加中 - 蓝色进行中
|
||||||
|
case 2:
|
||||||
|
return "#ff4d4f"; // 添加失败 - 红色错误色
|
||||||
|
case 3:
|
||||||
|
return "#ff4d4f"; // 添加失败 - 红色错误色
|
||||||
|
case 4:
|
||||||
|
return "#52c41a"; // 已添加 - 绿色成功色
|
||||||
default:
|
default:
|
||||||
return "#d9d9d9";
|
return "#d9d9d9"; // 未知状态 - 灰色
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status?: string) => {
|
const getStatusText = (status?: number) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "normal":
|
case 0:
|
||||||
return "正常";
|
return "待添加";
|
||||||
case "limited":
|
case 1:
|
||||||
return "受限";
|
return "添加中";
|
||||||
case "blocked":
|
case 2:
|
||||||
return "封禁";
|
return "请求已发送待通过";
|
||||||
|
case 3:
|
||||||
|
return "添加失败";
|
||||||
|
case 4:
|
||||||
|
return "已添加";
|
||||||
default:
|
default:
|
||||||
return "未知";
|
return "未知状态";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +160,7 @@ const AccountListModal: React.FC<AccountListModalProps> = ({
|
|||||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
style={{ backgroundColor: getStatusColor(account.status) }}
|
||||||
/>
|
/>
|
||||||
<span className={style.statusText}>
|
<span className={style.statusText}>
|
||||||
{getStatusText(account.status)}
|
{getStatusText(Number(account.status))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,4 +54,6 @@ export const defFormData: FormData = {
|
|||||||
deveiceGroupsOptions: [],
|
deveiceGroupsOptions: [],
|
||||||
wechatGroups: [],
|
wechatGroups: [],
|
||||||
wechatGroupsOptions: [],
|
wechatGroupsOptions: [],
|
||||||
|
contentGroups: [],
|
||||||
|
contentGroupsOptions: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export default function NewPlan() {
|
|||||||
deveiceGroupsOptions: detail.deveiceGroupsOptions ?? [],
|
deveiceGroupsOptions: detail.deveiceGroupsOptions ?? [],
|
||||||
wechatGroups: detail.wechatGroups ?? [],
|
wechatGroups: detail.wechatGroups ?? [],
|
||||||
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
||||||
|
contentGroups: detail.contentGroups ?? [],
|
||||||
|
contentGroupsOptions: detail.contentGroupsOptions ?? [],
|
||||||
status: detail.status ?? 0,
|
status: detail.status ?? 0,
|
||||||
messagePlans: detail.messagePlans ?? [],
|
messagePlans: detail.messagePlans ?? [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface MessageContentItem {
|
|||||||
groupIds?: string[]; // 改为数组以支持GroupSelection组件
|
groupIds?: string[]; // 改为数组以支持GroupSelection组件
|
||||||
groupOptions?: any[]; // 添加群选项数组
|
groupOptions?: any[]; // 添加群选项数组
|
||||||
linkUrl?: string;
|
linkUrl?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageContentGroup {
|
export interface MessageContentGroup {
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import FriendSelection from "@/components/FriendSelection";
|
|||||||
import GroupSelection from "@/components/GroupSelection";
|
import GroupSelection from "@/components/GroupSelection";
|
||||||
import ContentSelection from "@/components/ContentSelection";
|
import ContentSelection from "@/components/ContentSelection";
|
||||||
import AccountSelection from "@/components/AccountSelection";
|
import AccountSelection from "@/components/AccountSelection";
|
||||||
|
import PoolSelection from "@/components/PoolSelection";
|
||||||
import { isDevelopment } from "@/utils/env";
|
import { isDevelopment } from "@/utils/env";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
import { ContentItem } from "@/components/ContentSelection/data";
|
import { ContentItem } from "@/components/ContentSelection/data";
|
||||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
import { AccountItem } from "@/components/AccountSelection/data";
|
import { AccountItem } from "@/components/AccountSelection/data";
|
||||||
|
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
const ComponentTest: React.FC = () => {
|
const ComponentTest: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState("devices");
|
const [activeTab, setActiveTab] = useState("pools");
|
||||||
|
|
||||||
// 设备选择状态
|
// 设备选择状态
|
||||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
||||||
@@ -34,6 +36,9 @@ const ComponentTest: React.FC = () => {
|
|||||||
const [selectedFriendsOptions, setSelectedFriendsOptions] = useState<
|
const [selectedFriendsOptions, setSelectedFriendsOptions] = useState<
|
||||||
FriendSelectionItem[]
|
FriendSelectionItem[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
|
// 流量池选择状态
|
||||||
|
const [selectedPools, setSelectedPools] = useState<PoolSelectionItem[]>([]);
|
||||||
return (
|
return (
|
||||||
<Layout header={<NavCommon title="组件调试" />}>
|
<Layout header={<NavCommon title="组件调试" />}>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
@@ -84,7 +89,7 @@ const ComponentTest: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
|
||||||
<Tabs.Tab title="群组选择" key="groups">
|
<Tabs.Tab title="群组选择" key="groups2">
|
||||||
<div style={{ padding: "16px 0" }}>
|
<div style={{ padding: "16px 0" }}>
|
||||||
<h3 style={{ marginBottom: 16 }}>GroupSelection 组件测试</h3>
|
<h3 style={{ marginBottom: 16 }}>GroupSelection 组件测试</h3>
|
||||||
<GroupSelection
|
<GroupSelection
|
||||||
@@ -155,6 +160,58 @@ const ComponentTest: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Tab>
|
</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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Form, Toast, TextArea } from "antd-mobile";
|
import { Toast } from "antd-mobile";
|
||||||
import { Input, InputNumber, Button, Switch } from "antd";
|
import { Button } from "antd";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import style from "./index.module.scss";
|
|
||||||
import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
|
import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
|
||||||
import { AutoGroupFormData } from "./types";
|
import { AutoGroupFormData, StepItem } from "./types";
|
||||||
import DeviceSelection from "@/components/DeviceSelection/index";
|
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 NavCommon from "@/components/NavCommon/index";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
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 = {
|
const defaultForm: AutoGroupFormData = {
|
||||||
name: "",
|
name: "",
|
||||||
type: 4,
|
type: 4,
|
||||||
deveiceGroups: [], // 设备组
|
deveiceGroups: [], // 设备组
|
||||||
deveiceGroupsOptions: [], // 设备组选项
|
deveiceGroupsOptions: [], // 设备组选项
|
||||||
|
poolGroups: [], // 内容库
|
||||||
|
poolGroupsOptions: [], // 内容库选项
|
||||||
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
||||||
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
|
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
|
||||||
groupSizeMin: 20, // 群组最小人数
|
groupSizeMin: 20, // 群组最小人数
|
||||||
@@ -30,17 +41,33 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const [form, setForm] = useState<AutoGroupFormData>(defaultForm);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
// 这里应请求详情接口,回填表单,演示用mock
|
// 这里应请求详情接口,回填表单,演示用mock
|
||||||
getAutoGroupDetail(id).then(res => {
|
getAutoGroupDetail(id).then(res => {
|
||||||
setForm({
|
const updatedForm = {
|
||||||
...defaultForm,
|
...defaultForm,
|
||||||
name: res.name,
|
name: res.name,
|
||||||
deveiceGroups: res.config.deveiceGroups || [],
|
deveiceGroups: res.config.deveiceGroups || [],
|
||||||
deveiceGroupsOptions: res.config.deveiceGroupsOptions || [],
|
deveiceGroupsOptions: res.config.deveiceGroupsOptions || [],
|
||||||
|
poolGroups: res.config.poolGroups || [],
|
||||||
|
poolGroupsOptions: res.config.poolGroupsOptions || [],
|
||||||
startTime: res.config.startTime,
|
startTime: res.config.startTime,
|
||||||
endTime: res.config.endTime,
|
endTime: res.config.endTime,
|
||||||
groupSizeMin: res.config.groupSizeMin,
|
groupSizeMin: res.config.groupSizeMin,
|
||||||
@@ -51,19 +78,66 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
status: res.status,
|
status: res.status,
|
||||||
type: res.type,
|
type: res.type,
|
||||||
id: res.id,
|
id: res.id,
|
||||||
});
|
};
|
||||||
console.log(form);
|
setFormData(updatedForm);
|
||||||
|
setDeviceGroupsOptions(res.config.deveiceGroupsOptions || []);
|
||||||
|
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
||||||
|
setDataLoaded(true); // 标记数据已加载
|
||||||
});
|
});
|
||||||
}, [id]);
|
}, [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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
deveiceGroupsOptions: deviceGroupsOptions,
|
||||||
|
poolGroupsOptions: poolGroupsOptions,
|
||||||
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await updateAutoGroup(form);
|
await updateAutoGroup(submitData);
|
||||||
Toast.show({ content: "编辑成功" });
|
Toast.show({ content: "编辑成功" });
|
||||||
} else {
|
} else {
|
||||||
await createAutoGroup(form);
|
await createAutoGroup(submitData);
|
||||||
Toast.show({ content: "创建成功" });
|
Toast.show({ content: "创建成功" });
|
||||||
}
|
}
|
||||||
navigate("/workspace/auto-group");
|
navigate("/workspace/auto-group");
|
||||||
@@ -74,227 +148,134 @@ const AutoGroupForm: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTaskName = (val: string) => {
|
const handlePrevious = () => {
|
||||||
setForm((f: any) => ({ ...f, name: val }));
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const setDeviceGroups = (val: DeviceSelectionItem[]) => {
|
|
||||||
console.log(val);
|
const handleNext = async () => {
|
||||||
setForm((f: any) => ({
|
if (currentStep < 3) {
|
||||||
...f,
|
try {
|
||||||
deveiceGroups: val.map(item => item.id),
|
let isValid = false;
|
||||||
deveiceGroupsOptions: val,
|
|
||||||
}));
|
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 (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
|
<>
|
||||||
<NavCommon
|
<NavCommon
|
||||||
title={isEdit ? "编辑建群任务" : "新建建群任务"}
|
title={isEdit ? "编辑建群任务" : "新建建群任务"}
|
||||||
backFn={() => navigate(-1)}
|
backFn={() => navigate(-1)}
|
||||||
/>
|
/>
|
||||||
|
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
|
footer={renderFooter()}
|
||||||
>
|
>
|
||||||
<div className={style.autoGroupForm}>
|
<div style={{ padding: 12 }}>{renderCurrentStep()}</div>
|
||||||
<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>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||||
|
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||||
|
|
||||||
// 自动建群表单数据类型定义
|
// 自动建群表单数据类型定义
|
||||||
export interface AutoGroupFormData {
|
export interface AutoGroupFormData {
|
||||||
id?: string; // 任务ID
|
id?: string; // 任务ID
|
||||||
@@ -6,6 +8,8 @@ export interface AutoGroupFormData {
|
|||||||
name: string; // 任务名称
|
name: string; // 任务名称
|
||||||
deveiceGroups: string[]; // 设备组
|
deveiceGroups: string[]; // 设备组
|
||||||
deveiceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
|
deveiceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
|
||||||
|
poolGroups: string[]; // 流量池
|
||||||
|
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
|
||||||
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
||||||
endTime: string; // 结束时间 (YYYY-MM-DD HH:mm:ss)
|
endTime: string; // 结束时间 (YYYY-MM-DD HH:mm:ss)
|
||||||
groupSizeMin: number; // 群组最小人数
|
groupSizeMin: number; // 群组最小人数
|
||||||
@@ -17,6 +21,13 @@ export interface AutoGroupFormData {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 步骤定义
|
||||||
|
export interface StepItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
export const formValidationRules = {
|
export const formValidationRules = {
|
||||||
name: [
|
name: [
|
||||||
@@ -27,6 +38,10 @@ export const formValidationRules = {
|
|||||||
{ required: true, message: "请选择设备组" },
|
{ required: true, message: "请选择设备组" },
|
||||||
{ type: "array", min: 1, message: "至少选择一个设备组" },
|
{ type: "array", min: 1, message: "至少选择一个设备组" },
|
||||||
],
|
],
|
||||||
|
poolGroups: [
|
||||||
|
{ required: true, message: "请选择内容库" },
|
||||||
|
{ type: "array", min: 1, message: "至少选择一个内容库" },
|
||||||
|
],
|
||||||
startTime: [{ required: true, message: "请选择开始时间" }],
|
startTime: [{ required: true, message: "请选择开始时间" }],
|
||||||
endTime: [{ required: true, message: "请选择结束时间" }],
|
endTime: [{ required: true, message: "请选择结束时间" }],
|
||||||
groupSizeMin: [
|
groupSizeMin: [
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ export const getAutoGroupList = (params: any) =>
|
|||||||
export function copyAutoGroupTask(id: string): Promise<any> {
|
export function copyAutoGroupTask(id: string): Promise<any> {
|
||||||
return request("/v1/workbench/copy", { id }, "POST");
|
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,
|
PlusOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} 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 Layout from "@/components/Layout/Layout";
|
||||||
import style from "./index.module.scss";
|
import style from "./index.module.scss";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
@@ -110,14 +115,27 @@ const AutoGroupList: React.FC = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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);
|
const taskToDelete = tasks.find(task => task.id === taskId);
|
||||||
if (!taskToDelete) return;
|
if (!taskToDelete) return;
|
||||||
window.confirm(`确定要删除"${taskToDelete.name}"吗?`) &&
|
|
||||||
|
try {
|
||||||
|
await comfirm("确定要删除吗?", {
|
||||||
|
title: "删除确认",
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "删除",
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteAutoGroupTask(taskId);
|
||||||
setTasks(tasks.filter(task => task.id !== taskId));
|
setTasks(tasks.filter(task => task.id !== taskId));
|
||||||
Toast.show({ content: "删除成功" });
|
Toast.show({ content: "删除成功" });
|
||||||
|
} catch (error) {
|
||||||
|
// 用户取消删除或删除失败
|
||||||
|
if (error !== "cancel") {
|
||||||
|
Toast.show({ content: "删除失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (taskId: string) => {
|
const handleEdit = (taskId: string) => {
|
||||||
navigate(`/workspace/auto-group/${taskId}/edit`);
|
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> {
|
export function toggleAutoLikeTask(data): Promise<any> {
|
||||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
return request("/v1/workbench/update-status", { ...data, type: 1 }, "POST");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制自动点赞任务
|
// 复制自动点赞任务
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ export interface CreateLikeTaskData {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
contentTypes: ContentType[];
|
contentTypes: ContentType[];
|
||||||
deveiceGroups: string[];
|
deveiceGroups: number[];
|
||||||
deveiceGroupsOptions: DeviceSelectionItem[];
|
deveiceGroupsOptions: DeviceSelectionItem[];
|
||||||
friendsGroups: string[];
|
friendsGroups: number[];
|
||||||
friendsGroupsOptions: FriendSelectionItem[];
|
friendsGroupsOptions: FriendSelectionItem[];
|
||||||
friendMaxLikes: number;
|
friendMaxLikes: number;
|
||||||
friendTags?: string;
|
friendTags?: string;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
startTime: config.timeRange?.start || config.startTime || "08:00",
|
startTime: config.timeRange?.start || config.startTime || "08:00",
|
||||||
endTime: config.timeRange?.end || config.endTime || "22:00",
|
endTime: config.timeRange?.end || config.endTime || "22:00",
|
||||||
contentTypes: config.contentTypes || ["text", "image", "video"],
|
contentTypes: config.contentTypes || ["text", "image", "video"],
|
||||||
deveiceGroups: config.deveicegroups || [],
|
deveiceGroups: config.deveiceGroups || [],
|
||||||
deveiceGroupsOptions: config.deveiceGroupsOptions || [],
|
deveiceGroupsOptions: config.deveiceGroupsOptions || [],
|
||||||
friendsGroups: config.friendsgroups || [],
|
friendsGroups: config.friendsgroups || [],
|
||||||
friendsGroupsOptions: config.friendsGroupsOptions || [],
|
friendsGroupsOptions: config.friendsGroupsOptions || [],
|
||||||
@@ -121,7 +121,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
message.warning("请输入任务名称");
|
message.warning("请输入任务名称");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.deveicegroups || formData.deveicegroups.length === 0) {
|
if (!formData.deveiceGroups || formData.deveiceGroups.length === 0) {
|
||||||
message.warning("请选择执行设备");
|
message.warning("请选择执行设备");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -329,8 +329,13 @@ const NewAutoLike: React.FC = () => {
|
|||||||
<div className={style.basicSection}>
|
<div className={style.basicSection}>
|
||||||
<div className={style.formItem}>
|
<div className={style.formItem}>
|
||||||
<DeviceSelection
|
<DeviceSelection
|
||||||
selectedOptions={formData.deveicegroups}
|
selectedOptions={formData.deveiceGroupsOptions}
|
||||||
onSelect={devices => handleUpdateFormData({ devices })}
|
onSelect={devices =>
|
||||||
|
handleUpdateFormData({
|
||||||
|
deveiceGroups: devices.map(v => v.id),
|
||||||
|
deveiceGroupsOptions: devices,
|
||||||
|
})
|
||||||
|
}
|
||||||
showInput={true}
|
showInput={true}
|
||||||
showSelectedList={true}
|
showSelectedList={true}
|
||||||
/>
|
/>
|
||||||
@@ -348,7 +353,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className={style.nextBtn}
|
className={style.nextBtn}
|
||||||
size="large"
|
size="large"
|
||||||
disabled={formData.deveicegroups.length === 0}
|
disabled={formData.deveiceGroups.length === 0}
|
||||||
>
|
>
|
||||||
下一步
|
下一步
|
||||||
</Button>
|
</Button>
|
||||||
@@ -363,7 +368,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
selectedOptions={formData.friendsGroupsOptions || []}
|
selectedOptions={formData.friendsGroupsOptions || []}
|
||||||
onSelect={friends =>
|
onSelect={friends =>
|
||||||
handleUpdateFormData({
|
handleUpdateFormData({
|
||||||
friendsGroups: friends.map(f => String(f.id)),
|
friendsGroups: friends.map(f => f.id),
|
||||||
friendsGroupsOptions: friends,
|
friendsGroupsOptions: friends,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -385,7 +390,7 @@ const NewAutoLike: React.FC = () => {
|
|||||||
size="large"
|
size="large"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={
|
disabled={
|
||||||
!formData.friendsgroups || formData.friendsgroups.length === 0
|
!formData.friendsGroups || formData.friendsGroups.length === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isEditMode ? "更新任务" : "创建任务"}
|
{isEditMode ? "更新任务" : "创建任务"}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export interface GroupPushTask {
|
|||||||
createTime: string;
|
createTime: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
pushInterval: number;
|
pushInterval: number;
|
||||||
maxPushPerDay: number;
|
maxPerDay: number;
|
||||||
timeRange: { start: string; end: string };
|
startTime: string; // 允许推送的开始时间
|
||||||
|
endTime: string; // 允许推送的结束时间
|
||||||
messageType: "text" | "image" | "video" | "link";
|
messageType: "text" | "image" | "video" | "link";
|
||||||
messageContent: string;
|
messageContent: string;
|
||||||
targetTags: string[];
|
targetTags: string[];
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const Detail: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<SettingOutlined /> <b>基本设置</b>
|
<SettingOutlined /> <b>基本设置</b>
|
||||||
<div>推送间隔:{task.pushInterval} 秒</div>
|
<div>推送间隔:{task.pushInterval} 秒</div>
|
||||||
<div>每日最大推送数:{task.maxPushPerDay} 条</div>
|
<div>每日最大推送数:{task.maxPerDay} 条</div>
|
||||||
<div>
|
<div>
|
||||||
执行时间段:{task.timeRange.start} - {task.timeRange.end}
|
执行时间段:{task.timeRange.start} - {task.timeRange.end}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,12 +221,10 @@ const Detail: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<CalendarOutlined /> <b>执行进度</b>
|
<CalendarOutlined /> <b>执行进度</b>
|
||||||
<div>
|
<div>
|
||||||
今日已推送:{task.pushCount} / {task.maxPushPerDay}
|
今日已推送:{task.pushCount} / {task.maxPerDay}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
percent={Math.round(
|
percent={Math.round((task.pushCount / task.maxPerDay) * 100)}
|
||||||
(task.pushCount / task.maxPushPerDay) * 100,
|
|
||||||
)}
|
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
{task.targetTags.length > 0 && (
|
{task.targetTags.length > 0 && (
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import React, { useImperativeHandle, forwardRef } from "react";
|
import React, {
|
||||||
import { Input, Button, Card, Switch, Form, InputNumber } from "antd";
|
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 {
|
interface BasicSettingsProps {
|
||||||
defaultValues?: {
|
defaultValues?: {
|
||||||
name: string;
|
name: string;
|
||||||
pushTimeStart: string;
|
startTime: string; // 允许推送的开始时间
|
||||||
pushTimeEnd: string;
|
endTime: string; // 允许推送的结束时间
|
||||||
dailyPushCount: number;
|
maxPerDay: number;
|
||||||
pushOrder: "earliest" | "latest";
|
pushOrder: number; // 1: 按最早, 2: 按最新
|
||||||
isLoopPush: boolean;
|
isLoop: number; // 0: 否, 1: 是
|
||||||
isImmediatePush: boolean;
|
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||||
isEnabled: boolean;
|
status: number; // 0: 否, 1: 是
|
||||||
|
socialMediaId?: string;
|
||||||
|
promotionSiteId?: string;
|
||||||
};
|
};
|
||||||
onNext: (values: any) => void;
|
onNext: (values: any) => void;
|
||||||
onSave: (values: any) => void;
|
onSave: (values: any) => void;
|
||||||
@@ -27,18 +44,61 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
{
|
{
|
||||||
defaultValues = {
|
defaultValues = {
|
||||||
name: "",
|
name: "",
|
||||||
pushTimeStart: "06:00",
|
startTime: "06:00", // 允许推送的开始时间
|
||||||
pushTimeEnd: "23:59",
|
endTime: "23:59", // 允许推送的结束时间
|
||||||
dailyPushCount: 20,
|
maxPerDay: 20,
|
||||||
pushOrder: "latest",
|
pushOrder: 1,
|
||||||
isLoopPush: false,
|
isLoop: 0, // 0: 否, 1: 是
|
||||||
isImmediatePush: false,
|
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||||
isEnabled: false,
|
status: 0, // 0: 否, 1: 是
|
||||||
|
socialMediaId: undefined,
|
||||||
|
promotionSiteId: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [form] = Form.useForm();
|
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, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -55,7 +115,10 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
return form.getFieldsValue();
|
return form.getFieldsValue();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
const handlePushOrderChange = (value: number) => {
|
||||||
|
form.setFieldsValue({ pushOrder: value });
|
||||||
|
forceUpdate({}); // 强制组件重新渲染
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -64,7 +127,10 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={defaultValues}
|
initialValues={defaultValues}
|
||||||
onValuesChange={(changedValues, allValues) => {
|
onValuesChange={(changedValues, allValues) => {
|
||||||
// 可以在这里处理表单值变化
|
// 当pushOrder值变化时,强制更新组件
|
||||||
|
if ("pushOrder" in changedValues) {
|
||||||
|
forceUpdate({});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 任务名称 */}
|
{/* 任务名称 */}
|
||||||
@@ -78,12 +144,33 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
>
|
>
|
||||||
<Input placeholder="请输入任务名称" />
|
<Input placeholder="请输入任务名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{/* 推送类型 */}
|
||||||
{/* 允许推送的时间段 */}
|
|
||||||
<Form.Item label="允许推送的时间段">
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="pushTimeStart"
|
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
|
noStyle
|
||||||
rules={[{ required: true, message: "请选择开始时间" }]}
|
rules={[{ required: true, message: "请选择开始时间" }]}
|
||||||
>
|
>
|
||||||
@@ -91,7 +178,7 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<span style={{ color: "#888" }}>至</span>
|
<span style={{ color: "#888" }}>至</span>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="pushTimeEnd"
|
name="endTime"
|
||||||
noStyle
|
noStyle
|
||||||
rules={[{ required: true, message: "请选择结束时间" }]}
|
rules={[{ required: true, message: "请选择结束时间" }]}
|
||||||
>
|
>
|
||||||
@@ -99,11 +186,14 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
{/* 每日推送 */}
|
{/* 每日推送 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="每日推送"
|
label="每日推送"
|
||||||
name="dailyPushCount"
|
name="maxPerDay"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: "请输入每日推送数量" },
|
{ required: true, message: "请输入每日推送数量" },
|
||||||
{
|
{
|
||||||
@@ -130,46 +220,64 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type={
|
||||||
style={{ borderRadius: "6px 0 0 6px" }}
|
form.getFieldValue("pushOrder") == 1 ? "primary" : "default"
|
||||||
onClick={() => form.setFieldValue("pushOrder", "earliest")}
|
|
||||||
className={
|
|
||||||
form.getFieldValue("pushOrder") === "earliest"
|
|
||||||
? "ant-btn-primary"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
|
style={{ borderRadius: "6px 0 0 6px" }}
|
||||||
|
onClick={() => handlePushOrderChange(1)}
|
||||||
>
|
>
|
||||||
按最早
|
按最早
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type={
|
||||||
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
|
form.getFieldValue("pushOrder") == 2 ? "primary" : "default"
|
||||||
onClick={() => form.setFieldValue("pushOrder", "latest")}
|
|
||||||
className={
|
|
||||||
form.getFieldValue("pushOrder") === "latest"
|
|
||||||
? "ant-btn-primary"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
|
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
|
||||||
|
onClick={() => handlePushOrderChange(2)}
|
||||||
>
|
>
|
||||||
按最新
|
按最新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
label="是否循环推送"
|
label="是否循环推送"
|
||||||
name="isLoopPush"
|
name="isLoop"
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 是否立即推送 */}
|
|
||||||
<Form.Item
|
|
||||||
label="是否立即推送"
|
|
||||||
name="isImmediatePush"
|
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||||
|
getValueProps={value => ({ checked: value === 1 })}
|
||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -177,17 +285,25 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
{/* 是否启用 */}
|
{/* 是否启用 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="是否启用"
|
label="是否启用"
|
||||||
name="isEnabled"
|
name="status"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||||
|
getValueProps={value => ({ checked: value === 1 })}
|
||||||
>
|
>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 立即推送提示 */}
|
{/* 推送类型提示 */}
|
||||||
<Form.Item noStyle shouldUpdate>
|
<Form.Item
|
||||||
{() => {
|
noStyle
|
||||||
const isImmediatePush = form.getFieldValue("isImmediatePush");
|
shouldUpdate={(prevValues, currentValues) =>
|
||||||
return isImmediatePush ? (
|
prevValues.pushType !== currentValues.pushType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const pushType = getFieldValue("pushType");
|
||||||
|
if (pushType === 1) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fffbe6",
|
background: "#fffbe6",
|
||||||
@@ -198,9 +314,11 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
|||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
如果启用,系统会把内容库里所有的内容按顺序推送到指定的社群
|
如果启用立即推送,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</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";
|
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 () => {
|
export const fetchSocialMediaList = async () => {
|
||||||
return request("/v1/workbench/getJdSocialMedia", {}, "GET");
|
return request("/v1/workbench/getJdSocialMedia", {}, "GET");
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export interface ContentLibrary {
|
|||||||
|
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
pushTimeStart: string;
|
startTime: string; // 允许推送的开始时间
|
||||||
pushTimeEnd: string;
|
endTime: string; // 允许推送的结束时间
|
||||||
dailyPushCount: number;
|
dailyPushCount: number;
|
||||||
pushOrder: "earliest" | "latest";
|
pushOrder: number; // 1: 按最早, 2: 按最新
|
||||||
isLoopPush: boolean;
|
isLoop: number; // 0: 否, 1: 是
|
||||||
isImmediatePush: boolean;
|
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||||
isEnabled: boolean;
|
status: number; // 0: 否, 1: 是
|
||||||
contentGroups: string[];
|
contentGroups: string[];
|
||||||
wechatGroups: string[];
|
wechatGroups: string[];
|
||||||
// 京东联盟相关字段
|
// 京东联盟相关字段
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button } from "antd";
|
import { Button } from "antd";
|
||||||
|
import { Toast } from "antd-mobile";
|
||||||
import { createGroupPushTask } from "./index.api";
|
import { createGroupPushTask } from "./index.api";
|
||||||
import Layout from "@/components/Layout/Layout";
|
import Layout from "@/components/Layout/Layout";
|
||||||
import StepIndicator from "@/components/StepIndicator";
|
import StepIndicator from "@/components/StepIndicator";
|
||||||
@@ -9,7 +10,6 @@ import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
|||||||
import ContentSelector, {
|
import ContentSelector, {
|
||||||
ContentSelectorRef,
|
ContentSelectorRef,
|
||||||
} from "./components/ContentSelector";
|
} from "./components/ContentSelector";
|
||||||
import JingDongLink, { JingDongLinkRef } from "./components/JingDongLink";
|
|
||||||
import type { FormData } from "./index.data";
|
import type { FormData } from "./index.data";
|
||||||
import NavCommon from "@/components/NavCommon";
|
import NavCommon from "@/components/NavCommon";
|
||||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||||
@@ -18,7 +18,6 @@ const steps = [
|
|||||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||||
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
|
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
|
||||||
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
|
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
|
||||||
{ id: 4, title: "步骤 4", subtitle: "京东联盟" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const NewGroupPush: React.FC = () => {
|
const NewGroupPush: React.FC = () => {
|
||||||
@@ -35,13 +34,13 @@ const NewGroupPush: React.FC = () => {
|
|||||||
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
name: "",
|
name: "",
|
||||||
pushTimeStart: "06:00",
|
startTime: "06:00", // 允许推送的开始时间
|
||||||
pushTimeEnd: "23:59",
|
endTime: "23:59", // 允许推送的结束时间
|
||||||
dailyPushCount: 20,
|
maxPerDay: 20,
|
||||||
pushOrder: "latest",
|
pushOrder: 2, // 2: 按最新
|
||||||
isLoopPush: false,
|
isLoop: 0, // 0: 否, 1: 是
|
||||||
isImmediatePush: false,
|
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||||
isEnabled: false,
|
status: 0, // 0: 否, 1: 是
|
||||||
wechatGroups: [],
|
wechatGroups: [],
|
||||||
contentGroups: [],
|
contentGroups: [],
|
||||||
});
|
});
|
||||||
@@ -51,7 +50,6 @@ const NewGroupPush: React.FC = () => {
|
|||||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||||
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
||||||
const contentSelectorRef = useRef<ContentSelectorRef>(null);
|
const contentSelectorRef = useRef<ContentSelectorRef>(null);
|
||||||
const jingDongLinkRef = useRef<JingDongLinkRef>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -83,41 +81,33 @@ const NewGroupPush: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formData.name.trim()) {
|
try {
|
||||||
window.alert("请输入任务名称");
|
// 调用 ContentSelector 的表单校验
|
||||||
return;
|
const isValid = (await contentSelectorRef.current?.validate()) || false;
|
||||||
}
|
if (!isValid) return;
|
||||||
if (formData.wechatGroups.length === 0) {
|
|
||||||
window.alert("请选择至少一个社群");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (formData.contentGroups.length === 0) {
|
|
||||||
window.alert("请选择至少一个内容库");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取京东联盟数据
|
|
||||||
const jingDongLinkValues = jingDongLinkRef.current?.getValues();
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
|
||||||
|
// 获取基础设置中的京东联盟数据
|
||||||
|
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
||||||
|
|
||||||
|
// 构建 API 请求数据
|
||||||
const apiData = {
|
const apiData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
timeRange: {
|
startTime: formData.startTime, // 允许推送的开始时间
|
||||||
start: formData.pushTimeStart,
|
endTime: formData.endTime, // 允许推送的结束时间
|
||||||
end: formData.pushTimeEnd,
|
maxPerDay: formData.maxPerDay,
|
||||||
},
|
|
||||||
maxPushPerDay: formData.dailyPushCount,
|
|
||||||
pushOrder: formData.pushOrder,
|
pushOrder: formData.pushOrder,
|
||||||
isLoopPush: formData.isLoopPush,
|
isLoop: formData.isLoop, // 0: 否, 1: 是
|
||||||
isImmediatePush: formData.isImmediatePush,
|
pushType: formData.pushType, // 0: 定时推送, 1: 立即推送
|
||||||
isEnabled: formData.isEnabled,
|
status: formData.status, // 0: 否, 1: 是
|
||||||
wechatGroups: formData.wechatGroups,
|
wechatGroups: formData.wechatGroups,
|
||||||
contentGroups: formData.contentGroups,
|
contentGroups: formData.contentGroups,
|
||||||
// 京东联盟数据
|
// 京东联盟数据从基础设置中获取
|
||||||
socialMediaId: jingDongLinkValues?.socialMediaId,
|
socialMediaId: basicSettingsValues.socialMediaId,
|
||||||
promotionSiteId: jingDongLinkValues?.promotionSiteId,
|
promotionSiteId: basicSettingsValues.promotionSiteId,
|
||||||
pushMode: formData.isImmediatePush
|
pushMode:
|
||||||
|
formData.pushType === 1
|
||||||
? ("immediate" as const)
|
? ("immediate" as const)
|
||||||
: ("scheduled" as const),
|
: ("scheduled" as const),
|
||||||
messageType: "text" as const,
|
messageType: "text" as const,
|
||||||
@@ -125,15 +115,27 @@ const NewGroupPush: React.FC = () => {
|
|||||||
targetTags: [],
|
targetTags: [],
|
||||||
pushInterval: 60,
|
pushInterval: 60,
|
||||||
};
|
};
|
||||||
const response = await createGroupPushTask(apiData);
|
|
||||||
if (response.code === 200) {
|
// 打印API请求数据,用于调试
|
||||||
window.alert("保存成功");
|
console.log("发送到API的数据:", apiData);
|
||||||
|
|
||||||
|
// 调用创建或更新 API
|
||||||
|
if (id) {
|
||||||
|
// 更新逻辑将在这里实现
|
||||||
|
Toast.show({ content: "更新成功", position: "top" });
|
||||||
navigate("/workspace/group-push");
|
navigate("/workspace/group-push");
|
||||||
} else {
|
} else {
|
||||||
window.alert("保存失败,请稍后重试");
|
createGroupPushTask(apiData)
|
||||||
|
.then(() => {
|
||||||
|
Toast.show({ content: "创建成功", position: "top" });
|
||||||
|
navigate("/workspace/group-push");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Toast.show({ content: "创建失败,请稍后重试", position: "top" });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert("保存失败,请稍后重试");
|
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -146,7 +148,7 @@ const NewGroupPush: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (currentStep < 4) {
|
if (currentStep < 3) {
|
||||||
try {
|
try {
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
|
|
||||||
@@ -171,14 +173,6 @@ const NewGroupPush: React.FC = () => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 3:
|
|
||||||
// 调用 ContentSelector 的表单校验
|
|
||||||
isValid = (await contentSelectorRef.current?.validate()) || false;
|
|
||||||
if (isValid) {
|
|
||||||
setCurrentStep(4);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
setCurrentStep(currentStep + 1);
|
setCurrentStep(currentStep + 1);
|
||||||
}
|
}
|
||||||
@@ -196,7 +190,7 @@ const NewGroupPush: React.FC = () => {
|
|||||||
上一步
|
上一步
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 ? (
|
{currentStep === 3 ? (
|
||||||
<Button size="large" type="primary" onClick={handleSave}>
|
<Button size="large" type="primary" onClick={handleSave}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,13 +218,13 @@ const NewGroupPush: React.FC = () => {
|
|||||||
ref={basicSettingsRef}
|
ref={basicSettingsRef}
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
pushTimeStart: formData.pushTimeStart,
|
startTime: formData.startTime,
|
||||||
pushTimeEnd: formData.pushTimeEnd,
|
endTime: formData.endTime,
|
||||||
dailyPushCount: formData.dailyPushCount,
|
maxPerDay: formData.maxPerDay,
|
||||||
pushOrder: formData.pushOrder,
|
pushOrder: formData.pushOrder,
|
||||||
isLoopPush: formData.isLoopPush,
|
isLoop: formData.isLoop,
|
||||||
isImmediatePush: formData.isImmediatePush,
|
status: formData.status,
|
||||||
isEnabled: formData.isEnabled,
|
pushType: formData.pushType,
|
||||||
}}
|
}}
|
||||||
onNext={handleBasicSettingsChange}
|
onNext={handleBasicSettingsChange}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -253,9 +247,6 @@ const NewGroupPush: React.FC = () => {
|
|||||||
onNext={handleLibrariesChange}
|
onNext={handleLibrariesChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
|
||||||
<JingDongLink ref={jingDongLinkRef} loading={loading} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request";
|
||||||
|
import { GroupPushTask } from "../detail/groupPush";
|
||||||
|
|
||||||
interface ApiResponse<T = any> {
|
interface ApiResponse<T = any> {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -11,22 +12,16 @@ export async function fetchGroupPushTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
|
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,
|
export function toggleGroupPushTask(data): Promise<any> {
|
||||||
status: string,
|
return request("/v1/workbench/update-status", { ...data, type: 3 }, "POST");
|
||||||
): Promise<ApiResponse> {
|
|
||||||
return request(
|
|
||||||
`/v1/workspace/group-push/tasks/${id}/toggle`,
|
|
||||||
{ status },
|
|
||||||
"POST",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
|
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(
|
export async function createGroupPushTask(
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const GroupPush: React.FC = () => {
|
|||||||
const task = tasks.find(t => t.id === taskId);
|
const task = tasks.find(t => t.id === taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
const newStatus = task.status === 1 ? 2 : 1;
|
const newStatus = task.status === 1 ? 2 : 1;
|
||||||
await toggleGroupPushTask(taskId, String(newStatus));
|
await toggleGroupPushTask({ id: taskId, status: newStatus });
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ interface MomentsSyncTask {
|
|||||||
createTime: string;
|
createTime: string;
|
||||||
creatorName: string;
|
creatorName: string;
|
||||||
contentLib?: 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) => {
|
const getStatusText = (status: number) => {
|
||||||
@@ -195,7 +203,7 @@ const MomentsSync: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<div className={style.emptyText}>暂无同步任务</div>
|
<div className={style.emptyText}>暂无同步任务</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
color="primary"
|
||||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||||
>
|
>
|
||||||
新建第一个任务
|
新建第一个任务
|
||||||
@@ -256,9 +264,9 @@ const MomentsSync: React.FC = () => {
|
|||||||
<div className={style.itemInfoRow}>
|
<div className={style.itemInfoRow}>
|
||||||
<div className={style.infoCol}>
|
<div className={style.infoCol}>
|
||||||
内容库:
|
内容库:
|
||||||
{task.config?.contentLibraryNames?.join(",") ||
|
{task.config?.contentGroupsOptions
|
||||||
task.contentLib ||
|
?.map(c => c.name)
|
||||||
"默认内容库"}
|
.join(",") || "默认内容库"}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.infoCol}>
|
<div className={style.infoCol}>
|
||||||
创建人:{task.creatorName}
|
创建人:{task.creatorName}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
syncType: res.accountType === 1 ? 1 : 2,
|
syncType: res.accountType === 1 ? 1 : 2,
|
||||||
accountType: res.accountType === 1 ? "business" : "personal",
|
accountType: res.accountType === 1 ? "business" : "personal",
|
||||||
enabled: res.status === 1,
|
enabled: res.status === 1,
|
||||||
deveiceGroups: res.config?.devices || [],
|
deveiceGroups: res.config?.deveiceGroups || [],
|
||||||
// 关键:用id字符串数组回填
|
// 关键:用id字符串数组回填
|
||||||
contentGroups: res.config?.contentGroups || [], // 直接用对象数组
|
contentGroups: res.config?.contentGroups || [], // 直接用对象数组
|
||||||
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
||||||
@@ -134,8 +134,8 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
name: formData.taskName,
|
name: formData.taskName,
|
||||||
devices: formData.deveiceGroups,
|
deveiceGroups: formData.deveiceGroups,
|
||||||
contentLibraries: formData.contentGroups.map((lib: any) => lib.id),
|
contentGroups: formData.contentGroups.map((lib: any) => lib.id),
|
||||||
syncInterval: formData.syncInterval,
|
syncInterval: formData.syncInterval,
|
||||||
syncCount: formData.syncCount,
|
syncCount: formData.syncCount,
|
||||||
syncType: formData.syncType, // 账号类型真实传参
|
syncType: formData.syncType, // 账号类型真实传参
|
||||||
@@ -146,7 +146,7 @@ const NewMomentsSync: React.FC = () => {
|
|||||||
targetTags: formData.targetTags,
|
targetTags: formData.targetTags,
|
||||||
filterKeywords: formData.filterKeywords,
|
filterKeywords: formData.filterKeywords,
|
||||||
type: 2,
|
type: 2,
|
||||||
status: formData.enabled ? 1 : 2,
|
status: formData.enabled ? 1 : 0,
|
||||||
};
|
};
|
||||||
if (isEditMode && id) {
|
if (isEditMode && id) {
|
||||||
await updateMomentsSync({ id, ...params });
|
await updateMomentsSync({ id, ...params });
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ ckbox/
|
|||||||
|
|
||||||
## 数据结构
|
## 数据结构
|
||||||
|
|
||||||
### 联系人 (ContactData)
|
### 联系人 (ContractData)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ContactData {
|
interface ContractData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -169,7 +169,7 @@ export default ckboxRoutes;
|
|||||||
|
|
||||||
确保后端提供以下API接口:
|
确保后端提供以下API接口:
|
||||||
|
|
||||||
- `GET /v1/contacts` - 获取联系人列表
|
- `GET /v1/contracts` - 获取联系人列表
|
||||||
- `GET /v1/chats/sessions` - 获取聊天会话列表
|
- `GET /v1/chats/sessions` - 获取聊天会话列表
|
||||||
- `GET /v1/chats/:id/messages` - 获取聊天历史
|
- `GET /v1/chats/:id/messages` - 获取聊天历史
|
||||||
- `POST /v1/chats/:id/messages` - 发送消息
|
- `POST /v1/chats/:id/messages` - 发送消息
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import request from "@/api/request";
|
import request from "@/api/request2";
|
||||||
import {
|
import {
|
||||||
ContactData,
|
|
||||||
ContactListResponse,
|
|
||||||
ChatSession,
|
|
||||||
MessageData,
|
MessageData,
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
SendMessageRequest,
|
|
||||||
MessageType,
|
MessageType,
|
||||||
GroupData,
|
|
||||||
OnlineStatus,
|
OnlineStatus,
|
||||||
MessageStatus,
|
MessageStatus,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
@@ -16,19 +11,78 @@ import {
|
|||||||
ChatSettings,
|
ChatSettings,
|
||||||
} from "./data";
|
} 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[]> => {
|
export const getContactList = (params: { prevId: number; count: number }) => {
|
||||||
return request("/v1/contacts", {}, "GET");
|
return request("/api/wechatFriend/list", params, "GET");
|
||||||
|
};
|
||||||
|
|
||||||
|
//获取控制终端列表
|
||||||
|
export const getControlTerminalList = params => {
|
||||||
|
return request("/api/wechataccount", params, "GET");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 搜索联系人
|
// 搜索联系人
|
||||||
export const searchContacts = (keyword: string): Promise<ContactData[]> => {
|
export const getChatMessage = (params: {
|
||||||
return request("/v1/contacts/search", { keyword }, "GET");
|
wechatAccountId: number;
|
||||||
};
|
wechatFriendId: number;
|
||||||
|
From: number;
|
||||||
// 获取聊天会话列表
|
To: number;
|
||||||
export const getChatSessions = (): Promise<ChatSession[]> => {
|
Count: number;
|
||||||
return request("/v1/chats/sessions", {}, "GET");
|
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");
|
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 = (
|
export const addGroupMembers = (
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
|||||||
@@ -30,10 +30,18 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0; // 防止flex子元素溢出
|
min-width: 0; // 防止flex子元素溢出
|
||||||
|
|
||||||
|
:global(.ant-avatar) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.chatHeaderDetails {
|
.chatHeaderDetails {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.chatHeaderName {
|
.chatHeaderName {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -63,6 +71,28 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
|
:global(.ant-avatar) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
.profileInfo {
|
.profileInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
@@ -308,6 +346,18 @@
|
|||||||
color: #262626;
|
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 {
|
.profileStatus {
|
||||||
margin: 0 0 4px 0;
|
margin: 0 0 4px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -319,11 +369,36 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8c8c8c;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactInfo {
|
.profileWechatId {
|
||||||
.contactItem {
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contractInfo {
|
||||||
|
.contractItem {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -339,7 +414,7 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactItemText {
|
.contractItemText {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,13 +448,26 @@
|
|||||||
|
|
||||||
.messageItem {
|
.messageItem {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.messageContent {
|
.messageContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageTime {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageItem {
|
||||||
|
.messageContent {
|
||||||
.messageAvatar {
|
.messageAvatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -389,6 +477,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
.messageSender {
|
.messageSender {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -402,11 +491,193 @@
|
|||||||
word-break: break-word;
|
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 {
|
.messageTime {
|
||||||
font-size: 11px;
|
display: none;
|
||||||
color: #bfbfbf;
|
|
||||||
margin-top: 4px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,8 +788,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contactInfo {
|
.contractInfo {
|
||||||
.contactItem {
|
.contractItem {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 10px;
|
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,
|
Menu,
|
||||||
message,
|
message,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Divider,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
Tag,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Modal,
|
Modal,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
@@ -26,94 +20,165 @@ import {
|
|||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined,
|
|
||||||
MailOutlined,
|
|
||||||
EnvironmentOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
BankOutlined,
|
|
||||||
CloseOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
EnvironmentOutlined as LocationOutlined,
|
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
AudioOutlined as AudioHoldOutlined,
|
AudioOutlined as AudioHoldOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
CodeSandboxOutlined,
|
CodeSandboxOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
FilePptOutlined,
|
||||||
|
PlayCircleFilled,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
StarOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import { ChatRecord, ContractData } from "@/pages/pc/ckbox/data";
|
||||||
import { ChatSession, MessageData, MessageType } from "../../data";
|
import { clearUnreadCount, getMessages } from "@/pages/pc/ckbox/api";
|
||||||
// import { getChatHistory, sendMessage } from "../api";
|
|
||||||
import styles from "./ChatWindow.module.scss";
|
import styles from "./ChatWindow.module.scss";
|
||||||
|
import { useWebSocketStore, WebSocketMessage } from "@/store/module/websocket";
|
||||||
const { Header, Content, Footer, Sider } = Layout;
|
import { formatWechatTime } from "@/utils/common";
|
||||||
|
import { useCkChatStore } from "@/store/module/ckchat";
|
||||||
|
import Person from "./components/Person";
|
||||||
|
const { Header, Content, Footer } = Layout;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
chat: ChatSession;
|
contract: ContractData;
|
||||||
onSendMessage: (message: string) => void;
|
onSendMessage: (message: string) => void;
|
||||||
showProfile?: boolean;
|
showProfile?: boolean;
|
||||||
onToggleProfile?: () => void;
|
onToggleProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
chat,
|
contract,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
showProfile = true,
|
showProfile = true,
|
||||||
onToggleProfile,
|
onToggleProfile,
|
||||||
}) => {
|
}) => {
|
||||||
const [messages, setMessages] = useState<MessageData[]>([]);
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [messages, setMessages] = useState<ChatRecord[]>([]);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||||||
|
const [pendingVideoRequests, setPendingVideoRequests] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const kfSelectedUser = useCkChatStore(state => state.kfSelectedUser());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChatHistory();
|
clearUnreadCount([contract.id]).then(() => {
|
||||||
}, [chat.id]);
|
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(() => {
|
useEffect(() => {
|
||||||
|
// 只有在非视频加载操作时才自动滚动到底部
|
||||||
|
// 检查是否有视频正在加载中
|
||||||
|
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();
|
scrollToBottom();
|
||||||
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const fetchChatHistory = async () => {
|
// 添加 WebSocket 消息订阅 - 监听视频下载响应消息
|
||||||
try {
|
useEffect(() => {
|
||||||
setLoading(true);
|
// 只有当有待处理的视频请求时才订阅WebSocket消息
|
||||||
// 模拟聊天历史数据
|
if (Object.keys(pendingVideoRequests).length === 0) {
|
||||||
const mockMessages: MessageData[] = [
|
return;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
@@ -123,21 +188,33 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
if (!inputValue.trim()) return;
|
if (!inputValue.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newMessage: MessageData = {
|
const newMessage: ChatRecord = {
|
||||||
id: Date.now().toString(),
|
id: contract.id,
|
||||||
senderId: "me",
|
wechatAccountId: contract.wechatAccountId,
|
||||||
senderName: "我",
|
wechatFriendId: contract.id,
|
||||||
|
tenantId: 0,
|
||||||
|
accountId: 0,
|
||||||
|
synergyAccountId: 0,
|
||||||
content: inputValue,
|
content: inputValue,
|
||||||
type: MessageType.TEXT,
|
msgType: 0,
|
||||||
timestamp: dayjs().toISOString(),
|
msgSubType: 0,
|
||||||
isRead: false,
|
msgSvrId: "",
|
||||||
|
isSend: false,
|
||||||
|
createTime: "",
|
||||||
|
isDeleted: false,
|
||||||
|
deleteTime: "",
|
||||||
|
sendStatus: 0,
|
||||||
|
wechatTime: 0,
|
||||||
|
origin: 0,
|
||||||
|
msgId: 0,
|
||||||
|
recalled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages(prev => [...prev, newMessage]);
|
setMessages(prev => [...prev, newMessage]);
|
||||||
onSendMessage(inputValue);
|
onSendMessage(inputValue);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error("发送失败");
|
messageApi.error("发送失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,8 +265,489 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
// 这里可以根据不同的素材类型显示不同的模态框
|
// 这里可以根据不同的素材类型显示不同的模态框
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMessage = (msg: MessageData) => {
|
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||||
const isOwn = msg.senderId === "me";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -201,7 +759,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
{!isOwn && (
|
{!isOwn && (
|
||||||
<Avatar
|
<Avatar
|
||||||
size={32}
|
size={32}
|
||||||
src={chat.avatar}
|
src={contract.avatar}
|
||||||
icon={<UserOutlined />}
|
icon={<UserOutlined />}
|
||||||
className={styles.messageAvatar}
|
className={styles.messageAvatar}
|
||||||
/>
|
/>
|
||||||
@@ -210,10 +768,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
{!isOwn && (
|
{!isOwn && (
|
||||||
<div className={styles.messageSender}>{msg.senderName}</div>
|
<div className={styles.messageSender}>{msg.senderName}</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.messageText}>{msg.content}</div>
|
{parseMessageContent(msg.content, msg)}
|
||||||
<div className={styles.messageTime}>
|
|
||||||
{dayjs(msg.timestamp).format("HH:mm")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,24 +796,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</Menu>
|
</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 (
|
return (
|
||||||
<Layout className={styles.chatWindow}>
|
<Layout className={styles.chatWindow}>
|
||||||
|
{contextHolder}
|
||||||
{/* 聊天主体区域 */}
|
{/* 聊天主体区域 */}
|
||||||
<Layout className={styles.chatMain}>
|
<Layout className={styles.chatMain}>
|
||||||
{/* 聊天头部 */}
|
{/* 聊天头部 */}
|
||||||
@@ -266,20 +806,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
<div className={styles.chatHeaderInfo}>
|
<div className={styles.chatHeaderInfo}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={40}
|
size={40}
|
||||||
src={chat.avatar}
|
src={contract.avatar}
|
||||||
icon={chat.type === "group" ? <TeamOutlined /> : <UserOutlined />}
|
icon={
|
||||||
|
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className={styles.chatHeaderDetails}>
|
||||||
className={styles.chatHeaderDetails}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.chatHeaderName}>
|
<div className={styles.chatHeaderName}>
|
||||||
{chat.name}
|
{contract.nickname || contract.name}
|
||||||
{chat.online && (
|
|
||||||
<span className={styles.chatHeaderOnlineStatus}>在线</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +851,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</div>
|
</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} />
|
<div ref={messagesEndRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -353,7 +892,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
<Tooltip title="位置">
|
<Tooltip title="位置">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<LocationOutlined />}
|
icon={<EnvironmentOutlined />}
|
||||||
className={styles.toolbarButton}
|
className={styles.toolbarButton}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -442,108 +981,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
{/* 右侧个人资料卡片 */}
|
{/* 右侧个人资料卡片 */}
|
||||||
{showProfile && (
|
<Person
|
||||||
<Sider width={280} className={styles.profileSider}>
|
contract={contract}
|
||||||
<div className={styles.profileSiderContent}>
|
showProfile={showProfile}
|
||||||
<div className={styles.profileHeader}>
|
onToggleProfile={onToggleProfile}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 素材选择模态框 */}
|
{/* 素材选择模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -563,7 +1005,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
width={800}
|
width={800}
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", height: "400px" }}>
|
<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 {
|
.messageList {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.messageItem {
|
.messageItem {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -66,6 +67,33 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1;
|
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 {
|
.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 React from "react";
|
||||||
import { List, Avatar, Badge } from "antd";
|
import { List, Avatar, Badge } from "antd";
|
||||||
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
|
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import { ContractData, GroupData } from "@/pages/pc/ckbox/data";
|
||||||
import { ChatSession } from "../data";
|
|
||||||
import styles from "./MessageList.module.scss";
|
import styles from "./MessageList.module.scss";
|
||||||
|
import { formatWechatTime } from "@/utils/common";
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
sessions: ChatSession[];
|
chatSessions: ContractData[] | GroupData[];
|
||||||
currentChat: ChatSession | null;
|
currentChat: ContractData | GroupData;
|
||||||
onChatSelect: (chat: ChatSession) => void;
|
onChatSelect: (chat: ContractData | GroupData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageList: React.FC<MessageListProps> = ({
|
const MessageList: React.FC<MessageListProps> = ({
|
||||||
sessions,
|
chatSessions,
|
||||||
currentChat,
|
currentChat,
|
||||||
onChatSelect,
|
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 (
|
return (
|
||||||
<div className={styles.messageList}>
|
<div className={styles.messageList}>
|
||||||
<List
|
<List
|
||||||
dataSource={sessions}
|
dataSource={chatSessions as ContractData[]}
|
||||||
renderItem={session => (
|
renderItem={session => (
|
||||||
<List.Item
|
<List.Item
|
||||||
|
key={session.id}
|
||||||
className={`${styles.messageItem} ${
|
className={`${styles.messageItem} ${
|
||||||
currentChat?.id === session.id ? styles.active : ""
|
currentChat?.id === session.id ? styles.active : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -47,9 +31,9 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
<Badge count={session.unreadCount} size="small">
|
<Badge count={session.unreadCount} size="small">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={48}
|
size={48}
|
||||||
src={session.avatar}
|
src={session.avatar || session.chatroomAvatar}
|
||||||
icon={
|
icon={
|
||||||
session.type === "group" ? (
|
session?.type === "group" ? (
|
||||||
<TeamOutlined />
|
<TeamOutlined />
|
||||||
) : (
|
) : (
|
||||||
<UserOutlined />
|
<UserOutlined />
|
||||||
@@ -59,24 +43,27 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<div className={styles.messageDetails}>
|
<div className={styles.messageDetails}>
|
||||||
<div className={styles.messageHeader}>
|
<div className={styles.messageHeader}>
|
||||||
<div className={styles.messageName}>{session.name}</div>
|
<div className={styles.messageName}>{session.nickname}</div>
|
||||||
<div className={styles.messageTime}>
|
<div className={styles.messageTime}>
|
||||||
{formatTime(session.lastTime)}
|
{formatWechatTime(session?.lastUpdateTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.messageContent}>
|
<div className={styles.messageContent}>
|
||||||
<div className={styles.lastMessage}>
|
<div
|
||||||
{session.lastMessage}
|
className={styles.lastMessage}
|
||||||
|
data-count={
|
||||||
|
session.unreadCount > 0 ? session.unreadCount : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{session?.lastMessage}
|
||||||
</div>
|
</div>
|
||||||
{session.online && (
|
|
||||||
<div className={styles.onlineIndicator}>在线</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<div className={styles.lastDayMessage}>最近一天的消息</div>
|
||||||
</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 {
|
export interface ContractData {
|
||||||
id: string;
|
id?: number;
|
||||||
name: string;
|
wechatAccountId: number;
|
||||||
phone: string;
|
wechatId: string;
|
||||||
|
alias: string;
|
||||||
|
conRemark: string;
|
||||||
|
nickname: string;
|
||||||
|
quanPin: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
online: boolean;
|
gender: number;
|
||||||
lastSeen?: string;
|
region: string;
|
||||||
status?: string;
|
addFrom: number;
|
||||||
department?: string;
|
phone: string;
|
||||||
position?: 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;
|
muted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群组信息接口
|
|
||||||
export interface GroupData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
description?: string;
|
|
||||||
members: ContactData[];
|
|
||||||
adminIds: string[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聊天历史响应接口
|
// 聊天历史响应接口
|
||||||
export interface ChatHistoryResponse {
|
export interface ChatHistoryResponse {
|
||||||
messages: MessageData[];
|
messages: MessageData[];
|
||||||
@@ -79,12 +195,6 @@ export interface SendMessageRequest {
|
|||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人列表响应接口
|
|
||||||
export interface ContactListResponse {
|
|
||||||
contacts: ContactData[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索联系人请求接口
|
// 搜索联系人请求接口
|
||||||
export interface SearchContactRequest {
|
export interface SearchContactRequest {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
.ckboxLayout {
|
.ckboxLayout {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.sidebar {
|
.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;
|
background: #fff;
|
||||||
border-right: 1px solid #f0f0f0;
|
border-right: 1px solid #f0f0f0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@@ -87,6 +110,8 @@
|
|||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
.chatContainer {
|
.chatContainer {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,187 +1,67 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import { Layout, Button, Space, message, Tooltip } from "antd";
|
||||||
Layout,
|
import { InfoCircleOutlined, MessageOutlined } from "@ant-design/icons";
|
||||||
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 dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ContactData, MessageData, ChatSession } from "./data";
|
|
||||||
import ChatWindow from "./components/ChatWindow/index";
|
import ChatWindow from "./components/ChatWindow/index";
|
||||||
import ContactList from "./components/ContactList/index";
|
import SidebarMenu from "./components/SidebarMenu/index";
|
||||||
import MessageList from "./components/MessageList/index";
|
import VerticalUserList from "./components/VerticalUserList";
|
||||||
|
import PageSkeleton from "./components/Skeleton";
|
||||||
import styles from "./index.module.scss";
|
import styles from "./index.module.scss";
|
||||||
|
import { addChatSession } from "@/store/module/ckchat";
|
||||||
const { Sider, Content } = Layout;
|
const { Header, Content, Sider } = Layout;
|
||||||
const { TabPane } = Tabs;
|
import { chatInitAPIdata } from "./main";
|
||||||
|
import { KfUserListData, GroupData, ContractData } from "@/pages/pc/ckbox/data";
|
||||||
|
|
||||||
const CkboxPage: React.FC = () => {
|
const CkboxPage: React.FC = () => {
|
||||||
const [contacts, setContacts] = useState<ContactData[]>([]);
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
|
const [contracts, setContacts] = useState<any[]>([]);
|
||||||
const [currentChat, setCurrentChat] = useState<ChatSession | null>(null);
|
const [currentChat, setCurrentChat] = useState<ContractData | GroupData>(
|
||||||
const [searchText, setSearchText] = useState("");
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("contacts");
|
|
||||||
const [showProfile, setShowProfile] = useState(true);
|
const [showProfile, setShowProfile] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchContacts();
|
// 方法一:使用 Promise 链式调用处理异步函数
|
||||||
fetchChatSessions();
|
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 () => {
|
const handleContactClick = (contract: ContractData | GroupData) => {
|
||||||
try {
|
addChatSession(contract);
|
||||||
setLoading(true);
|
setCurrentChat(contract);
|
||||||
// 模拟联系人数据
|
|
||||||
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 handleSendMessage = async (message: string) => {
|
const handleSendMessage = async (message: string) => {
|
||||||
if (!currentChat || !message.trim()) return;
|
if (!currentChat || !message.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newMessage: MessageData = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
senderId: "me",
|
|
||||||
senderName: "我",
|
|
||||||
content: message,
|
|
||||||
type: "text" as any,
|
|
||||||
timestamp: dayjs().toISOString(),
|
|
||||||
isRead: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新当前聊天会话
|
// 更新当前聊天会话
|
||||||
const updatedSession = {
|
const updatedSession = {
|
||||||
...currentChat,
|
...currentChat,
|
||||||
@@ -190,102 +70,42 @@ const CkboxPage: React.FC = () => {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
setChatSessions(prev =>
|
|
||||||
prev.map(s => (s.id === currentChat.id ? updatedSession : s)),
|
|
||||||
);
|
|
||||||
setCurrentChat(updatedSession);
|
setCurrentChat(updatedSession);
|
||||||
|
|
||||||
message.success("消息发送成功");
|
messageApi.success("消息发送成功");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error("消息发送失败");
|
messageApi.error("消息发送失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
// 处理垂直侧边栏用户选择
|
||||||
setSearchText(value);
|
const handleVerticalUserSelect = (userId: string) => {
|
||||||
};
|
// setActiveVerticalUserId(userId);
|
||||||
|
// 这里可以根据选择的用户类别筛选不同的联系人列表
|
||||||
const getFilteredContacts = () => {
|
// 例如:根据userId加载不同分类的联系人
|
||||||
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()),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PageSkeleton loading={loading}>
|
||||||
<Layout className={styles.ckboxLayout}>
|
<Layout className={styles.ckboxLayout}>
|
||||||
{/* 左侧边栏 */}
|
{contextHolder}
|
||||||
<Sider width={300} className={styles.sidebar}>
|
<Header className={styles.header}>触客宝</Header>
|
||||||
{/* 搜索栏 */}
|
<Layout>
|
||||||
<div className={styles.searchBar}>
|
{/* 垂直侧边栏 */}
|
||||||
<Input
|
|
||||||
placeholder="搜索联系人、群组"
|
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
value={searchText}
|
|
||||||
onChange={e => handleSearch(e.target.value)}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签页 */}
|
<Sider width={60} className={styles.verticalSider}>
|
||||||
<Tabs
|
<VerticalUserList />
|
||||||
activeKey={activeTab}
|
</Sider>
|
||||||
onChange={setActiveTab}
|
|
||||||
className={styles.tabs}
|
{/* 左侧联系人边栏 */}
|
||||||
>
|
<Sider width={280} className={styles.sider}>
|
||||||
<TabPane
|
<SidebarMenu
|
||||||
tab={
|
contracts={contracts}
|
||||||
<span>
|
|
||||||
<MessageOutlined />
|
|
||||||
聊天
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
key="chats"
|
|
||||||
>
|
|
||||||
<MessageList
|
|
||||||
sessions={getFilteredSessions()}
|
|
||||||
currentChat={currentChat}
|
currentChat={currentChat}
|
||||||
onChatSelect={setCurrentChat}
|
|
||||||
/>
|
|
||||||
</TabPane>
|
|
||||||
<TabPane
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<UserOutlined />
|
|
||||||
联系人
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
key="contacts"
|
|
||||||
>
|
|
||||||
<ContactList
|
|
||||||
contacts={getFilteredContacts()}
|
|
||||||
onContactClick={handleContactClick}
|
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>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
@@ -307,7 +127,7 @@ const CkboxPage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
chat={currentChat}
|
contract={currentChat}
|
||||||
onSendMessage={handleSendMessage}
|
onSendMessage={handleSendMessage}
|
||||||
showProfile={showProfile}
|
showProfile={showProfile}
|
||||||
onToggleProfile={() => setShowProfile(!showProfile)}
|
onToggleProfile={() => setShowProfile(!showProfile)}
|
||||||
@@ -324,6 +144,8 @@ const CkboxPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</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",
|
||||||
"/plans/:planId",
|
"/plans/:planId",
|
||||||
"/orders",
|
"/orders",
|
||||||
"/contact-import",
|
"/contract-import",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -126,7 +126,7 @@ export const routePermissions = {
|
|||||||
"/plans",
|
"/plans",
|
||||||
"/plans/:planId",
|
"/plans/:planId",
|
||||||
"/orders",
|
"/orders",
|
||||||
"/contact-import",
|
"/contract-import",
|
||||||
],
|
],
|
||||||
|
|
||||||
// 访客权限
|
// 访客权限
|
||||||
@@ -150,7 +150,7 @@ export const routeTitles: Record<string, string> = {
|
|||||||
"/profile": "个人中心",
|
"/profile": "个人中心",
|
||||||
"/plans": "计划管理",
|
"/plans": "计划管理",
|
||||||
"/orders": "订单管理",
|
"/orders": "订单管理",
|
||||||
"/contact-import": "联系人导入",
|
"/contract-import": "联系人导入",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取路由标题
|
// 获取路由标题
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
// 账户信息接口
|
import { ContractData, KfUserListData, CkAccount } from "@/pages/pc/ckbox/data";
|
||||||
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 PrivilegeFrag {
|
export interface PrivilegeFrag {
|
||||||
@@ -40,6 +27,24 @@ export interface CkUserInfo {
|
|||||||
export interface CkChatState {
|
export interface CkChatState {
|
||||||
userInfo: CkUserInfo | null;
|
userInfo: CkUserInfo | null;
|
||||||
isLoggedIn: boolean;
|
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;
|
setUserInfo: (userInfo: CkUserInfo) => void;
|
||||||
clearUserInfo: () => void;
|
clearUserInfo: () => void;
|
||||||
updateAccount: (account: Partial<CkAccount>) => void;
|
updateAccount: (account: Partial<CkAccount>) => void;
|
||||||
|
|||||||
@@ -1,12 +1,102 @@
|
|||||||
import { createPersistStore } from "@/store/createPersistStore";
|
import { createPersistStore } from "@/store/createPersistStore";
|
||||||
|
import { CkChatState, CkUserInfo, CkTenant } from "./ckchat.data";
|
||||||
import { CkChatState, CkUserInfo, CkAccount, CkTenant } from "./ckchat.data";
|
import {
|
||||||
|
ContractData,
|
||||||
|
GroupData,
|
||||||
|
CkAccount,
|
||||||
|
KfUserListData,
|
||||||
|
} from "@/pages/pc/ckbox/data";
|
||||||
|
|
||||||
export const useCkChatStore = createPersistStore<CkChatState>(
|
export const useCkChatStore = createPersistStore<CkChatState>(
|
||||||
set => ({
|
set => ({
|
||||||
userInfo: null,
|
userInfo: null,
|
||||||
isLoggedIn: false,
|
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) => {
|
setUserInfo: (userInfo: CkUserInfo) => {
|
||||||
set({ userInfo, isLoggedIn: true });
|
set({ userInfo, isLoggedIn: true });
|
||||||
@@ -44,7 +134,7 @@ export const useCkChatStore = createPersistStore<CkChatState>(
|
|||||||
// 获取账户ID
|
// 获取账户ID
|
||||||
getAccountId: () => {
|
getAccountId: () => {
|
||||||
const state = useCkChatStore.getState();
|
const state = useCkChatStore.getState();
|
||||||
return state.userInfo?.account?.id || null;
|
return Number(state.userInfo?.account?.id) || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取租户ID
|
// 获取租户ID
|
||||||
@@ -87,3 +177,29 @@ export const getCkTenantId = () => useCkChatStore.getState().getTenantId();
|
|||||||
export const getCkAccountName = () =>
|
export const getCkAccountName = () =>
|
||||||
useCkChatStore.getState().getAccountName();
|
useCkChatStore.getState().getAccountName();
|
||||||
export const getCkTenantName = () => useCkChatStore.getState().getTenantName();
|
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 => {
|
login2: token2 => {
|
||||||
localStorage.setItem("token2", token2);
|
localStorage.setItem("token2", token2);
|
||||||
|
|
||||||
set({ token2, isLoggedIn: true });
|
set({ token2, isLoggedIn: true });
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { createPersistStore } from "@/store/createPersistStore";
|
import { createPersistStore } from "@/store/createPersistStore";
|
||||||
import { Toast } from "antd-mobile";
|
import { Toast } from "antd-mobile";
|
||||||
import { useUserStore } from "./user";
|
import { useUserStore } from "./user";
|
||||||
|
import { useCkChatStore } from "@/store/module/ckchat";
|
||||||
|
const { getAccountId } = useCkChatStore.getState();
|
||||||
|
|
||||||
// WebSocket消息类型
|
// WebSocket消息类型
|
||||||
export interface WebSocketMessage {
|
export interface WebSocketMessage {
|
||||||
id: string;
|
cmdType?: string;
|
||||||
type: string;
|
seq?: number;
|
||||||
content: any;
|
wechatAccountIds?: string[];
|
||||||
timestamp: number;
|
content?: any;
|
||||||
sender?: string;
|
[key: string]: any;
|
||||||
receiver?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket连接状态
|
// WebSocket连接状态
|
||||||
@@ -25,11 +26,14 @@ export enum WebSocketStatus {
|
|||||||
interface WebSocketConfig {
|
interface WebSocketConfig {
|
||||||
url: string;
|
url: string;
|
||||||
client: string;
|
client: string;
|
||||||
accountId: string;
|
accountId: number;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
autoReconnect: boolean;
|
autoReconnect: boolean;
|
||||||
|
cmdType: string;
|
||||||
|
seq: number;
|
||||||
reconnectInterval: number;
|
reconnectInterval: number;
|
||||||
maxReconnectAttempts: number;
|
maxReconnectAttempts: number;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketState {
|
interface WebSocketState {
|
||||||
@@ -68,11 +72,13 @@ interface WebSocketState {
|
|||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
const DEFAULT_CONFIG: WebSocketConfig = {
|
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",
|
client: "kefu-client",
|
||||||
accountId: "",
|
accountId: 0,
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
|
cmdType: "", // 添加默认的命令类型
|
||||||
|
seq: +new Date(), // 添加默认的序列号
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
maxReconnectAttempts: 5,
|
maxReconnectAttempts: 5,
|
||||||
};
|
};
|
||||||
@@ -103,24 +109,32 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
const { token, token2, user } = useUserStore.getState();
|
const { token2 } = useUserStore.getState();
|
||||||
const accessToken = fullConfig.accessToken || token2 || token;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!token2) {
|
||||||
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
|
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建WebSocket URL
|
// 构建WebSocket URL
|
||||||
const params = {
|
const params = new URLSearchParams({
|
||||||
client: fullConfig.client,
|
client: fullConfig.client.toString(),
|
||||||
accountId: fullConfig.accountId || user?.s2_accountId || "",
|
accountId: getAccountId().toString(),
|
||||||
accessToken: accessToken,
|
accessToken: token2,
|
||||||
t: Date.now().toString(),
|
t: Date.now().toString(),
|
||||||
};
|
});
|
||||||
|
|
||||||
const wsUrl =
|
// 检查URL是否为localhost,如果是则不连接
|
||||||
fullConfig.url + "?" + new URLSearchParams(params).toString();
|
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({
|
set({
|
||||||
status: WebSocketStatus.CONNECTING,
|
status: WebSocketStatus.CONNECTING,
|
||||||
@@ -166,7 +180,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => {
|
sendMessage: message => {
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -179,8 +193,6 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
|
|
||||||
const fullMessage: WebSocketMessage = {
|
const fullMessage: WebSocketMessage = {
|
||||||
...message,
|
...message,
|
||||||
id: Date.now().toString(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -204,16 +216,8 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = useUserStore.getState();
|
|
||||||
const { token, token2 } = useUserStore.getState();
|
|
||||||
const accessToken = token2 || token;
|
|
||||||
|
|
||||||
const command = {
|
const command = {
|
||||||
accessToken: accessToken,
|
cmdType,
|
||||||
accountId: user?.s2_accountId,
|
|
||||||
client: currentState.config?.client || "kefu-client",
|
|
||||||
cmdType: cmdType,
|
|
||||||
seq: Date.now(),
|
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,6 +245,11 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
const currentState = get();
|
const currentState = get();
|
||||||
|
|
||||||
if (currentState.config) {
|
if (currentState.config) {
|
||||||
|
// 检查是否允许重连
|
||||||
|
if (!currentState.config.autoReconnect) {
|
||||||
|
console.log("自动重连已禁用,不再尝试重连");
|
||||||
|
return;
|
||||||
|
}
|
||||||
currentState.connect(currentState.config);
|
currentState.connect(currentState.config);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -255,10 +264,20 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("WebSocket连接成功");
|
console.log("WebSocket连接成功");
|
||||||
|
const { token2 } = useUserStore.getState();
|
||||||
// 发送登录命令
|
// 发送登录命令
|
||||||
if (currentState.config) {
|
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" });
|
Toast.show({ content: "WebSocket连接成功", position: "top" });
|
||||||
@@ -270,6 +289,32 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log("收到WebSocket消息:", 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 currentState = get();
|
||||||
const newMessage: WebSocketMessage = {
|
const newMessage: WebSocketMessage = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
@@ -309,7 +354,12 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
|
|||||||
currentState.reconnectAttempts <
|
currentState.reconnectAttempts <
|
||||||
(currentState.config?.maxReconnectAttempts || 5)
|
(currentState.config?.maxReconnectAttempts || 5)
|
||||||
) {
|
) {
|
||||||
|
console.log("尝试自动重连...");
|
||||||
currentState._startReconnectTimer();
|
currentState._startReconnectTimer();
|
||||||
|
} else if (!currentState.config?.autoReconnect) {
|
||||||
|
console.log("自动重连已禁用,不再尝试重连");
|
||||||
|
// 重置重连计数
|
||||||
|
set({ reconnectAttempts: 0 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
import { Modal } from "antd-mobile";
|
import { Modal } from "antd-mobile";
|
||||||
import { getSetting } from "@/store/module/settings";
|
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风格
|
* 通用js调用弹窗,Promise风格
|
||||||
* @param content 弹窗内容
|
* @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\DeviceModel;
|
||||||
use app\api\model\DeviceGroupModel;
|
use app\api\model\DeviceGroupModel;
|
||||||
|
use think\Db;
|
||||||
use think\facade\Request;
|
use think\facade\Request;
|
||||||
use think\facade\Env;
|
use think\facade\Env;
|
||||||
use Endroid\QrCode\QrCode;
|
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 消息数据
|
* @param array $data 消息数据
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
protected function sendMessage($data)
|
protected function sendMessage($data,$receive = true)
|
||||||
{
|
{
|
||||||
$this->checkConnection();
|
$this->checkConnection();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->client->send(json_encode($data));
|
$this->client->send(json_encode($data));
|
||||||
|
if ($receive){
|
||||||
$response = $this->client->receive();
|
$response = $this->client->receive();
|
||||||
return json_decode($response, true);
|
return json_decode($response, true);
|
||||||
|
}else{
|
||||||
|
return ['code' => 200, 'msg' => '成功'];
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error("发送消息失败:" . $e->getMessage());
|
Log::error("发送消息失败:" . $e->getMessage());
|
||||||
$this->reconnect();
|
$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 请求参数
|
* @param array $data 请求参数
|
||||||
@@ -804,31 +910,26 @@ class WebSocketController extends BaseController
|
|||||||
if (empty($data['wechatChatroomId'])) {
|
if (empty($data['wechatChatroomId'])) {
|
||||||
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
|
return json_encode(['code' => 400, 'msg' => '群ID不能为空']);
|
||||||
}
|
}
|
||||||
if (empty($data['wechatFriendId'])) {
|
if (empty($data['wechatFriendIds'])) {
|
||||||
return json_encode(['code' => 400, 'msg' => '好友ID不能为空']);
|
return json_encode(['code' => 400, 'msg' => '好友ID不能为空']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($data['wechatFriendId'])) {
|
if (!is_array($data['wechatFriendIds'])) {
|
||||||
return json_encode(['code' => 400, 'msg' => '好友数据格式必须为数组']);
|
return json_encode(['code' => 400, 'msg' => '好友数据格式必须为数组']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($data['wechatAccountId'])) {
|
|
||||||
return json_encode(['code' => 400, 'msg' => '微信账号ID不能为空']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
$params = [
|
$params = [
|
||||||
"cmdType" => "CmdChatroomInvite",
|
"cmdType" => "CmdChatroomInvite",
|
||||||
"seq" => time(),
|
"seq" => time(),
|
||||||
"wechatChatroomId" => $data['wechatChatroomId'],
|
"wechatChatroomId" => $data['wechatChatroomId'],
|
||||||
"wechatFriendId" => $data['wechatFriendId'],
|
"wechatFriendIds" => $data['wechatFriendIds']
|
||||||
"wechatAccountId" => $data['wechatAccountId']
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 记录请求日志
|
// 记录请求日志
|
||||||
Log::info('邀请好友入群请求:' . json_encode($params, 256));
|
Log::info('邀请好友入群请求:' . json_encode($params, 256));
|
||||||
|
$message = $this->sendMessage($params,false);
|
||||||
$message = $this->sendMessage($params);
|
|
||||||
return json_encode(['code' => 200, 'msg' => '邀请成功', 'data' => $message]);
|
return json_encode(['code' => 200, 'msg' => '邀请成功', 'data' => $message]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// 记录错误日志
|
// 记录错误日志
|
||||||
@@ -837,4 +938,64 @@ class WebSocketController extends BaseController
|
|||||||
return json_encode(['code' => 500, 'msg' => '邀请好友入群异常:' . $e->getMessage()]);
|
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
|
* @return \think\response\Json
|
||||||
*/
|
*/
|
||||||
public function getlist($pageIndex = '',$pageSize = '',$isInner = false, $isDel = '')
|
public function getlist($data = [],$isInner = false, $isDel = '')
|
||||||
{
|
{
|
||||||
// 获取授权token
|
// 获取授权token
|
||||||
$authorization = trim($this->request->header('authorization', $this->authorization));
|
$authorization = $this->authorization;
|
||||||
if (empty($authorization)) {
|
if (empty($authorization)) {
|
||||||
if($isInner){
|
|
||||||
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
|
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
|
||||||
}else{
|
|
||||||
return errorJson('缺少授权信息');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,15 +32,15 @@ class WechatChatroomController extends BaseController
|
|||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
$params = [
|
$params = [
|
||||||
'keyword' => $this->request->param('keyword', ''),
|
'keyword' => $data['keyword'] ?? '',
|
||||||
'wechatAccountKeyword' => $this->request->param('wechatAccountKeyword', ''),
|
'wechatAccountKeyword' => $data['wechatAccountKeyword'] ?? '',
|
||||||
'isDeleted' => $this->request->param('isDeleted', $isDeleted),
|
'isDeleted' => $data['isDeleted'] ?? $isDeleted ,
|
||||||
'allotAccountId' => $this->request->param('allotAccountId', ''),
|
'allotAccountId' => $data['allotAccountId'] ?? '',
|
||||||
'groupId' => $this->request->param('groupId', ''),
|
'groupId' => $data['groupId'] ?? '',
|
||||||
'wechatChatroomId' => $this->request->param('wechatChatroomId', 0),
|
'wechatChatroomId' => $data['wechatChatroomId'] ?? '',
|
||||||
'memberKeyword' => $this->request->param('memberKeyword', ''),
|
'memberKeyword' => $data['memberKeyword'] ?? '',
|
||||||
'pageIndex' => !empty($pageIndex) ? $pageIndex : input('pageIndex', 0),
|
'pageIndex' => $data['pageIndex'] ?? 1,
|
||||||
'pageSize' => !empty($pageSize) ? $pageSize : input('pageSize', 20)
|
'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