From cea4a01a4a3cf0d0e9d81c4054b93dacc260ba2f Mon Sep 17 00:00:00 2001 From: wong <1069948207@qq.com> Date: Mon, 14 Apr 2025 16:40:39 +0800 Subject: [PATCH 01/26] Initial Commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..54d76890 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +#cunkebao_v3 From d940d36123a8bafd957c9b52c89558515ab1ac4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= Date: Mon, 10 Nov 2025 15:55:14 +0800 Subject: [PATCH 02/26] Refactor styles and structure for account selection in CreatePushTask component. Update account list display to use a grid layout, enhance account card styling, and improve status indicators for online/offline status. Implement customer list fetching logic on component mount. --- .../create-push-task/index.module.scss | 116 +++++-------- .../create-push-task/index.tsx | 152 ++++++++---------- 2 files changed, 110 insertions(+), 158 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss index 04b7188a..55504547 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss @@ -367,6 +367,7 @@ // 右侧栏 .rightColumn { width: 400px; + flex: 1; display: flex; flex-direction: column; gap: 20px; @@ -697,23 +698,14 @@ } } - .searchBar { - margin-bottom: 24px; - - :global(.ant-input-affix-wrapper) { - height: 40px; - border-radius: 8px; - } - } - - // 未选择的账号列表 - .accountList { - margin-bottom: 30px; - max-height: 400px; - overflow-y: auto; + // 账号卡片列表 + .accountCards { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + max-height: 500px; + overflow-y: auto; + padding: 8px; &::-webkit-scrollbar { width: 6px; @@ -724,7 +716,8 @@ border-radius: 3px; } - .accountItem { + .accountCard { + position: relative; display: flex; align-items: center; gap: 12px; @@ -737,82 +730,61 @@ &:hover { border-color: #52c41a; - background: #fafafa; + box-shadow: 0 2px 8px rgba(82, 196, 26, 0.1); + } + + &.selected { + border: 2px solid #52c41a; + background: #fff; } .accountInfo { flex: 1; + min-width: 0; .accountName { font-size: 14px; font-weight: 500; color: #1a1a1a; margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .accountId { + .accountStatus { + display: flex; + align-items: center; + gap: 6px; font-size: 12px; - color: #999; - } - } - } - } - // 已选择区域 - .selectedSection { - .selectedHeader { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0; - margin-bottom: 12px; + .statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; - span { - font-size: 14px; - color: #666; - } + &.online { + background: #52c41a; + } - .clearButton { - padding: 0; - font-size: 14px; - } - } - - .selectedCards { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; - - .selectedCard { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - border: 2px solid #52c41a; - border-radius: 8px; - background: #f6ffed; - position: relative; - - .accountInfo { - flex: 1; - - .accountName { - font-size: 14px; - font-weight: 500; - color: #1a1a1a; - margin-bottom: 4px; + &.offline { + background: #999; + } } - .accountId { - font-size: 12px; - color: #999; + .statusText { + color: #666; } } + } - .checkIcon { - font-size: 20px; - color: #52c41a; - } + .checkmark { + position: absolute; + top: 8px; + right: 8px; + font-size: 18px; + color: #52c41a; } } } diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx index 30bb057b..2c3ee85a 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx @@ -20,7 +20,6 @@ import { UserOutlined, TeamOutlined, CheckCircleOutlined, - CheckOutlined, SendOutlined, CopyOutlined, DeleteOutlined, @@ -30,18 +29,18 @@ import Layout from "@/components/Layout/LayoutFiexd"; import styles from "./index.module.scss"; import { useCustomerStore, + updateCustomerList, } from "@/store/module/weChat/customer"; -import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; +import { + getContactList, + getGroupList, + getCustomerList, +} from "@/pages/pc/ckbox/weChat/api"; -export type PushType = "friend-message" | "group-message" | "group-announcement"; - -interface WeChatAccount { - id: number; - name: string; - avatar?: string; - isOnline?: boolean; - wechatId?: string; -} +export type PushType = + | "friend-message" + | "group-message" + | "group-announcement"; interface ContactItem { id: number; @@ -76,7 +75,8 @@ const CreatePushTask: React.FC = () => { const [selectedTag, setSelectedTag] = useState(""); const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); - const [selectedScriptGroup, setSelectedScriptGroup] = useState("group1"); + const [selectedScriptGroup, setSelectedScriptGroup] = + useState("group1"); const [scriptGroups] = useState([ { id: "group1", name: "话术组 1", messageCount: 1, content: "啊实打实" }, ]); @@ -91,6 +91,20 @@ const CreatePushTask: React.FC = () => { const customerList = useCustomerStore(state => state.customerList); + // 如果 customerList 为空,重新请求客服账户列表接口 + useEffect(() => { + if (customerList.length === 0) { + getCustomerList() + .then(res => { + updateCustomerList(res); + }) + .catch(error => { + console.error("获取客服列表失败:", error); + message.error("获取客服列表失败"); + }); + } + }, [customerList.length]); + // 获取标题和描述 const getTitle = () => { switch (validPushType) { @@ -149,17 +163,8 @@ const CreatePushTask: React.FC = () => { }); }; - // 步骤1:全选/取消全选 - const handleSelectAll = () => { - if (selectedAccounts.length === filteredAccounts.length) { - setSelectedAccounts([]); - } else { - setSelectedAccounts([...filteredAccounts]); - } - }; - - // 清空所有选择 - const handleClearAll = () => { + // 步骤1:清空选择 + const handleClearSelection = () => { setSelectedAccounts([]); }; @@ -201,7 +206,8 @@ const CreatePushTask: React.FC = () => { } // 处理响应数据 - const data = response.data?.list || response.data || response.list || []; + const data = + response.data?.list || response.data || response.list || []; const total = response.data?.total || response.total || 0; // 过滤出属于当前账号的数据(双重保险) @@ -317,7 +323,9 @@ const CreatePushTask: React.FC = () => { setCurrentStep(2); } else if (currentStep === 2) { if (selectedContacts.length === 0) { - message.warning(`请至少选择一个${validPushType === "friend-message" ? "好友" : "群"}`); + message.warning( + `请至少选择一个${validPushType === "friend-message" ? "好友" : "群"}`, + ); return; } setCurrentStep(3); @@ -355,10 +363,6 @@ const CreatePushTask: React.FC = () => { // 渲染步骤1:选择微信账号 const renderStep1 = () => { - const unselectedAccounts = filteredAccounts.filter( - a => !selectedAccounts.some(s => s.id === a.id) - ); - return (
@@ -376,78 +380,53 @@ const CreatePushTask: React.FC = () => { />
- {/* 未选择的账号列表 */} - {unselectedAccounts.length > 0 || filteredAccounts.length === 0 ? ( -
- {unselectedAccounts.length > 0 ? ( - unselectedAccounts.map(account => ( + {/* 账号列表 */} + {filteredAccounts.length > 0 ? ( +
+ {filteredAccounts.map(account => { + const isSelected = selectedAccounts.some( + s => s.id === account.id, + ); + return (
handleAccountToggle(account)} > - {!account.avatar && (account.nickname || account.name || "").charAt(0)} + {!account.avatar && + (account.nickname || account.name || "").charAt(0)}
{account.nickname || account.name || "未知"}
-
- {account.isOnline ? "在线" : "离线"} +
+ + + {account.isOnline ? "在线" : "离线"} +
+ {isSelected && ( + + )}
- )) - ) : ( - - )} -
- ) : null} - - {/* 已选择的账号 */} - {selectedAccounts.length > 0 && ( -
-
- 已选择 {selectedAccounts.length} 个微信账号 - -
-
- {selectedAccounts.map(account => ( -
- - {!account.avatar && (account.nickname || account.name || "").charAt(0)} - -
-
- {account.nickname || account.name || "未知"} -
-
- {account.isOnline ? "在线" : "离线"} -
-
- -
- ))} -
+ ); + })}
+ ) : ( + )}
); @@ -712,7 +691,8 @@ const CreatePushTask: React.FC = () => {
- 按住CTRL+ENTER换行,已配置{scriptGroups.length}个话术组,已选择0个进行推送 + 按住CTRL+ENTER换行,已配置{scriptGroups.length} + 个话术组,已选择0个进行推送
@@ -855,7 +835,7 @@ const CreatePushTask: React.FC = () => {
{currentStep === 1 && ( <> - + From 5a287a42ac4deca706504f982d52f768d30e7b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= Date: Mon, 10 Nov 2025 18:08:53 +0800 Subject: [PATCH 03/26] Refactor CreatePushTask component to streamline account and contact selection process. Update styles for layout consistency, enhance user experience with improved step indicators, and simplify state management for selected accounts and contacts. --- .../src/components/Layout/layout.module.scss | 1 - .../components/StepSelectAccount/index.tsx | 107 +++ .../components/StepSelectContacts/index.tsx | 339 ++++++++ .../components/StepSendMessage/index.tsx | 184 ++++ .../create-push-task/index.module.scss | 5 +- .../create-push-task/index.tsx | 805 +++--------------- .../create-push-task/types.ts | 15 + 7 files changed, 752 insertions(+), 704 deletions(-) create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts diff --git a/Touchkebao/src/components/Layout/layout.module.scss b/Touchkebao/src/components/Layout/layout.module.scss index 3818f44d..c43e8901 100644 --- a/Touchkebao/src/components/Layout/layout.module.scss +++ b/Touchkebao/src/components/Layout/layout.module.scss @@ -2,7 +2,6 @@ display: flex; height: 100vh; flex-direction: column; - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); } .container main { diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx new file mode 100644 index 00000000..278889cc --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { Avatar, Empty, Input } from "antd"; +import { CheckCircleOutlined, SearchOutlined } from "@ant-design/icons"; + +import styles from "../../index.module.scss"; + +interface StepSelectAccountProps { + customerList: any[]; + selectedAccounts: any[]; + onChange: (accounts: any[]) => void; +} + +const StepSelectAccount: React.FC = ({ + customerList, + selectedAccounts, + onChange, +}) => { + const [searchKeyword, setSearchKeyword] = useState(""); + + const filteredAccounts = useMemo(() => { + if (!searchKeyword.trim()) return customerList; + const keyword = searchKeyword.toLowerCase(); + return customerList.filter( + account => + (account.nickname || "").toLowerCase().includes(keyword) || + (account.wechatId || "").toLowerCase().includes(keyword), + ); + }, [customerList, searchKeyword]); + + const handleAccountToggle = (account: any) => { + const isSelected = selectedAccounts.some(a => a.id === account.id); + if (isSelected) { + onChange(selectedAccounts.filter(a => a.id !== account.id)); + return; + } + onChange([...selectedAccounts, account]); + }; + + return ( +
+
+

选择微信账号

+

可选择多个微信账号进行推送

+
+ +
+ } + value={searchKeyword} + onChange={e => setSearchKeyword(e.target.value)} + allowClear + /> +
+ + {filteredAccounts.length > 0 ? ( +
+ {filteredAccounts.map(account => { + const isSelected = selectedAccounts.some(s => s.id === account.id); + return ( +
handleAccountToggle(account)} + > + + {!account.avatar && + (account.nickname || account.name || "").charAt(0)} + +
+
+ {account.nickname || account.name || "未知"} +
+
+ + + {account.isOnline ? "在线" : "离线"} + +
+
+ {isSelected && ( + + )} +
+ ); + })} +
+ ) : ( + + )} +
+ ); +}; + +export default StepSelectAccount; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx new file mode 100644 index 00000000..b804cafc --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx @@ -0,0 +1,339 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Avatar, + Button, + Checkbox, + Empty, + Input, + Pagination, + Spin, + message, +} from "antd"; +import { + CloseOutlined, + SearchOutlined, + TeamOutlined, + UserOutlined, +} from "@ant-design/icons"; + +import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; + +import styles from "../../index.module.scss"; +import { ContactItem, PushType } from "../../types"; + +interface StepSelectContactsProps { + pushType: PushType; + selectedAccounts: any[]; + selectedContacts: ContactItem[]; + onChange: (contacts: ContactItem[]) => void; +} + +const StepSelectContacts: React.FC = ({ + pushType, + selectedAccounts, + selectedContacts, + onChange, +}) => { + const [contactsData, setContactsData] = useState([]); + const [loadingContacts, setLoadingContacts] = useState(false); + const [page, setPage] = useState(1); + const [searchValue, setSearchValue] = useState(""); + const [total, setTotal] = useState(0); + + const pageSize = 20; + + const stepTitle = useMemo(() => { + switch (pushType) { + case "friend-message": + return "好友"; + case "group-message": + case "group-announcement": + return "群"; + default: + return "选择"; + } + }, [pushType]); + + const loadContacts = useCallback(async () => { + if (selectedAccounts.length === 0) { + setContactsData([]); + setTotal(0); + return; + } + + setLoadingContacts(true); + try { + const accountIds = selectedAccounts.map(a => a.id); + const allData: ContactItem[] = []; + let totalCount = 0; + + for (const accountId of accountIds) { + const params: any = { + page, + limit: pageSize, + wechatAccountId: accountId, + }; + + if (searchValue.trim()) { + params.keyword = searchValue.trim(); + } + + const response = + pushType === "friend-message" + ? await getContactList(params) + : await getGroupList(params); + + const data = + response.data?.list || response.data || response.list || []; + const totalValue = response.data?.total || response.total || 0; + + const filteredData = data.filter((item: any) => { + const itemAccountId = item.wechatAccountId || item.accountId; + return itemAccountId === accountId; + }); + + filteredData.forEach((item: ContactItem) => { + if (!allData.some(d => d.id === item.id)) { + allData.push(item); + } + }); + + totalCount += totalValue; + } + + setContactsData(allData); + setTotal(totalCount > 0 ? totalCount : allData.length); + } catch (error) { + console.error("加载数据失败:", error); + message.error("加载数据失败"); + setContactsData([]); + setTotal(0); + } finally { + setLoadingContacts(false); + } + }, [page, pushType, searchValue, selectedAccounts]); + + useEffect(() => { + loadContacts(); + }, [loadContacts]); + + useEffect(() => { + if (!searchValue.trim()) { + return; + } + setPage(1); + }, [searchValue]); + + useEffect(() => { + setPage(1); + if (selectedAccounts.length === 0 && selectedContacts.length > 0) { + onChange([]); + } + }, [onChange, selectedAccounts, selectedContacts.length]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + if (!value.trim()) { + setPage(1); + } + }; + + const filteredContacts = useMemo(() => { + if (searchValue.trim()) { + return contactsData; + } + return contactsData; + }, [contactsData, searchValue]); + + const handleContactToggle = (contact: ContactItem) => { + const isSelected = selectedContacts.some(c => c.id === contact.id); + if (isSelected) { + onChange(selectedContacts.filter(c => c.id !== contact.id)); + return; + } + onChange([...selectedContacts, contact]); + }; + + const handleRemoveContact = (contactId: number) => { + onChange(selectedContacts.filter(c => c.id !== contactId)); + }; + + const handleSelectAllContacts = () => { + if (filteredContacts.length === 0) return; + const allSelected = filteredContacts.every(contact => + selectedContacts.some(c => c.id === contact.id), + ); + if (allSelected) { + const currentIds = filteredContacts.map(c => c.id); + onChange(selectedContacts.filter(c => !currentIds.includes(c.id))); + return; + } + const toAdd = filteredContacts.filter( + contact => !selectedContacts.some(c => c.id === contact.id), + ); + onChange([...selectedContacts, ...toAdd]); + }; + + const handlePageChange = (p: number) => { + setPage(p); + }; + + return ( +
+
+
+

选择{stepTitle}

+

从{stepTitle}列表中选择推送对象

+
+
+ } + value={searchValue} + onChange={e => handleSearchChange(e.target.value)} + allowClear + /> + +
+
+
+
+ + {stepTitle}列表(共{total}个) + +
+
+ {loadingContacts ? ( +
+ + 加载中... +
+ ) : filteredContacts.length > 0 ? ( + filteredContacts.map(contact => { + const isSelected = selectedContacts.some( + c => c.id === contact.id, + ); + return ( +
handleContactToggle(contact)} + > + + + ) : ( + + ) + } + /> +
+
+ {contact.nickname} +
+ {contact.conRemark && ( +
+ {contact.conRemark} +
+ )} +
+ {contact.type === "group" && ( + + )} +
+ ); + }) + ) : ( + + )} +
+ {total > 0 && ( +
+ +
+ )} +
+ +
+
+ + 已选{stepTitle}列表(共{selectedContacts.length}个) + + {selectedContacts.length > 0 && ( + + )} +
+
+ {selectedContacts.length > 0 ? ( + selectedContacts.map(contact => ( +
+
+ + ) : ( + + ) + } + /> +
+
{contact.nickname}
+ {contact.conRemark && ( +
+ {contact.conRemark} +
+ )} +
+ {contact.type === "group" && ( + + )} +
+ { + e.stopPropagation(); + handleRemoveContact(contact.id); + }} + /> +
+ )) + ) : ( + + )} +
+
+
+
+
+ ); +}; + +export default StepSelectContacts; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx new file mode 100644 index 00000000..86041eca --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useState } from "react"; +import { Button, Input, Select, Slider, Switch } from "antd"; + +import styles from "../../index.module.scss"; +import { ContactItem } from "../../types"; +import ContentSelection from "@/components/ContentSelection"; +import { ContentItem } from "@/components/ContentSelection/data"; + +interface StepSendMessageProps { + selectedAccounts: any[]; + selectedContacts: ContactItem[]; + targetLabel: string; + messageContent: string; + onMessageContentChange: (value: string) => void; + friendInterval: number; + onFriendIntervalChange: (value: number) => void; + messageInterval: number; + onMessageIntervalChange: (value: number) => void; + selectedTag: string; + onSelectedTagChange: (value: string) => void; + aiRewriteEnabled: boolean; + onAiRewriteToggle: (value: boolean) => void; + aiPrompt: string; + onAiPromptChange: (value: string) => void; +} + +const StepSendMessage: React.FC = ({ + selectedAccounts, + selectedContacts, + targetLabel, + messageContent, + onMessageContentChange, + friendInterval, + onFriendIntervalChange, + messageInterval, + onMessageIntervalChange, + selectedTag, + onSelectedTagChange, + aiRewriteEnabled, + onAiRewriteToggle, + aiPrompt, + onAiPromptChange, +}) => { + const [selectedContentLibraries, setSelectedContentLibraries] = useState< + ContentItem[] + >([]); + + return ( +
+
+
+
+
模拟推送内容
+
+
当前编辑话术
+
+ {messageContent || "开始添加消息内容..."} +
+
+
+ +
+
已保存话术组
+ +
+ +
+ onMessageContentChange(e.target.value)} + rows={4} + onKeyDown={e => { + if (e.ctrlKey && e.key === "Enter") { + e.preventDefault(); + onMessageContentChange(`${messageContent}\n`); + } + }} + /> +
+
+
+ + AI智能话术改写 + {aiRewriteEnabled && ( + onAiPromptChange(e.target.value)} + style={{ marginLeft: 12, width: 200 }} + /> + )} + +
+
+ 按住CTRL+ENTER换行,已选择{selectedContentLibraries.length} + 个话术组,已选择{selectedContacts.length} + 个进行推送 +
+
+
+ +
+
+
相关设置
+
+
好友间间隔
+
+ 间隔时间(秒) + onFriendIntervalChange(value as number)} + style={{ flex: 1, margin: "0 16px" }} + /> + {friendInterval} - 20 +
+
+
+
消息间间隔
+
+ 间隔时间(秒) + onMessageIntervalChange(value as number)} + style={{ flex: 1, margin: "0 16px" }} + /> + {messageInterval} - 12 +
+
+
+ +
+
完成打标签
+ +
+ +
+
推送预览
+
    +
  • 推送账号: {selectedAccounts.length}个
  • +
  • + 推送{targetLabel}: {selectedContacts.length}个 +
  • +
  • 话术组数: {selectedContentLibraries.length}个
  • +
  • 随机推送: 否
  • +
  • 预计耗时: ~1分钟
  • +
+
+
+
+
+ ); +}; + +export default StepSendMessage; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss index 55504547..c9c1ed8e 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss @@ -1,7 +1,5 @@ .container { - padding: 24px; - background: #f5f5f5; - min-height: calc(100vh - 64px); + padding: 15px; display: flex; flex-direction: column; } @@ -586,6 +584,7 @@ background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin: 20px; .footerLeft { font-size: 14px; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx index 2c3ee85a..c09a1d53 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx @@ -1,29 +1,7 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { - Input, - Button, - Avatar, - Checkbox, - Empty, - Spin, - message, - Pagination, - Slider, - Select, - Switch, - Radio, -} from "antd"; -import { - SearchOutlined, - CloseOutlined, - UserOutlined, - TeamOutlined, - CheckCircleOutlined, - SendOutlined, - CopyOutlined, - DeleteOutlined, -} from "@ant-design/icons"; +import { Button, message } from "antd"; +import { SendOutlined } from "@ant-design/icons"; import PowerNavigation from "@/components/PowerNavtion"; import Layout from "@/components/Layout/LayoutFiexd"; import styles from "./index.module.scss"; @@ -31,33 +9,18 @@ import { useCustomerStore, updateCustomerList, } from "@/store/module/weChat/customer"; -import { - getContactList, - getGroupList, - getCustomerList, -} from "@/pages/pc/ckbox/weChat/api"; +import { getCustomerList } from "@/pages/pc/ckbox/weChat/api"; -export type PushType = - | "friend-message" - | "group-message" - | "group-announcement"; - -interface ContactItem { - id: number; - nickname: string; - avatar?: string; - conRemark?: string; - wechatId?: string; - gender?: number; - region?: string; - type?: "friend" | "group"; -} +import StepSelectAccount from "./components/StepSelectAccount"; +import StepSelectContacts from "./components/StepSelectContacts"; +import StepSendMessage from "./components/StepSendMessage"; +import { ContactItem, PushType } from "./types"; +import StepIndicator from "@/components/StepIndicator"; const CreatePushTask: React.FC = () => { const navigate = useNavigate(); const { pushType } = useParams<{ pushType: PushType }>(); - // 验证推送类型 const validPushType: PushType = pushType === "friend-message" || pushType === "group-message" || @@ -66,7 +29,6 @@ const CreatePushTask: React.FC = () => { : "friend-message"; const [currentStep, setCurrentStep] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(""); const [selectedAccounts, setSelectedAccounts] = useState([]); const [selectedContacts, setSelectedContacts] = useState([]); const [messageContent, setMessageContent] = useState(""); @@ -75,23 +37,9 @@ const CreatePushTask: React.FC = () => { const [selectedTag, setSelectedTag] = useState(""); const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); - const [selectedScriptGroup, setSelectedScriptGroup] = - useState("group1"); - const [scriptGroups] = useState([ - { id: "group1", name: "话术组 1", messageCount: 1, content: "啊实打实" }, - ]); - - // 步骤2数据 - const [contactsData, setContactsData] = useState([]); - const [loadingContacts, setLoadingContacts] = useState(false); - const [step2Page, setStep2Page] = useState(1); - const [step2SearchValue, setStep2SearchValue] = useState(""); - const [step2Total, setStep2Total] = useState(0); - const step2PageSize = 20; const customerList = useCustomerStore(state => state.customerList); - // 如果 customerList 为空,重新请求客服账户列表接口 useEffect(() => { if (customerList.length === 0) { getCustomerList() @@ -105,8 +53,7 @@ const CreatePushTask: React.FC = () => { } }, [customerList.length]); - // 获取标题和描述 - const getTitle = () => { + const title = useMemo(() => { switch (validPushType) { case "friend-message": return "好友消息推送"; @@ -117,14 +64,11 @@ const CreatePushTask: React.FC = () => { default: return "消息推送"; } - }; + }, [validPushType]); - const getSubtitle = () => { - return "智能批量推送,AI智能话术改写"; - }; + const subtitle = "智能批量推送,AI智能话术改写"; - // 步骤2的标题 - const getStep2Title = () => { + const step2Title = useMemo(() => { switch (validPushType) { case "friend-message": return "好友"; @@ -132,188 +76,14 @@ const CreatePushTask: React.FC = () => { case "group-announcement": return "群"; default: - return "选择"; + return "对象"; } - }; + }, [validPushType]); - // 重置状态 const handleClose = () => { navigate("/pc/powerCenter/message-push-assistant"); }; - // 步骤1:过滤微信账号 - const filteredAccounts = useMemo(() => { - if (!searchKeyword.trim()) return customerList; - const keyword = searchKeyword.toLowerCase(); - return customerList.filter( - account => - (account.nickname || "").toLowerCase().includes(keyword) || - (account.wechatId || "").toLowerCase().includes(keyword), - ); - }, [customerList, searchKeyword]); - - // 步骤1:切换账号选择 - const handleAccountToggle = (account: any) => { - setSelectedAccounts(prev => { - const isSelected = prev.some(a => a.id === account.id); - if (isSelected) { - return prev.filter(a => a.id !== account.id); - } - return [...prev, account]; - }); - }; - - // 步骤1:清空选择 - const handleClearSelection = () => { - setSelectedAccounts([]); - }; - - // 步骤2:加载好友/群数据 - const loadStep2Data = async () => { - if (selectedAccounts.length === 0) { - setContactsData([]); - setStep2Total(0); - return; - } - - setLoadingContacts(true); - try { - const accountIds = selectedAccounts.map(a => a.id); - - // 如果有多个账号,分别请求每个账号的数据并合并 - const allData: ContactItem[] = []; - let totalCount = 0; - - // 为每个账号请求数据 - for (const accountId of accountIds) { - const params: any = { - page: step2Page, - limit: step2PageSize, - wechatAccountId: accountId, // 传递微信账号ID - }; - - if (step2SearchValue.trim()) { - params.keyword = step2SearchValue.trim(); - } - - let response; - if (validPushType === "friend-message") { - // 好友消息推送:获取好友列表 - response = await getContactList(params); - } else { - // 群消息推送/群公告推送:获取群列表 - response = await getGroupList(params); - } - - // 处理响应数据 - const data = - response.data?.list || response.data || response.list || []; - const total = response.data?.total || response.total || 0; - - // 过滤出属于当前账号的数据(双重保险) - const filteredData = data.filter((item: any) => { - const itemAccountId = item.wechatAccountId || item.accountId; - return itemAccountId === accountId; - }); - - // 合并数据(去重,根据id) - filteredData.forEach((item: ContactItem) => { - if (!allData.some(d => d.id === item.id)) { - allData.push(item); - } - }); - - totalCount += total; - } - - // 如果多个账号,需要重新排序和分页 - // 这里简化处理:显示所有合并后的数据,但总数使用第一个账号的总数 - // 实际应该根据业务需求调整 - setContactsData(allData); - setStep2Total(totalCount > 0 ? totalCount : allData.length); - } catch (error) { - console.error("加载数据失败:", error); - message.error("加载数据失败"); - setContactsData([]); - setStep2Total(0); - } finally { - setLoadingContacts(false); - } - }; - - // 步骤2:当进入步骤2或分页变化时加载数据 - useEffect(() => { - if (currentStep === 2 && selectedAccounts.length > 0) { - loadStep2Data(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentStep, selectedAccounts, step2Page, validPushType]); - - // 步骤2:搜索时重置分页并重新加载数据 - useEffect(() => { - if (currentStep === 2 && selectedAccounts.length > 0) { - // 搜索时重置到第一页 - if (step2SearchValue.trim() && step2Page !== 1) { - setStep2Page(1); - } else if (!step2SearchValue.trim() && step2Page === 1) { - // 清空搜索时,如果已经在第一页,直接加载 - loadStep2Data(); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [step2SearchValue]); - - // 步骤2:过滤联系人(前端过滤,如果后端已支持搜索则不需要) - const filteredContacts = useMemo(() => { - // 如果后端已支持搜索,直接返回数据 - if (step2SearchValue.trim()) { - // 后端已搜索,直接返回 - return contactsData; - } - return contactsData; - }, [contactsData, step2SearchValue]); - - // 步骤2:显示的数据(后端已分页,直接使用) - const paginatedContacts = filteredContacts; - - // 步骤2:切换联系人选择 - const handleContactToggle = (contact: ContactItem) => { - setSelectedContacts(prev => { - const isSelected = prev.some(c => c.id === contact.id); - if (isSelected) { - return prev.filter(c => c.id !== contact.id); - } - return [...prev, contact]; - }); - }; - - // 步骤2:移除已选联系人 - const handleRemoveContact = (contactId: number) => { - setSelectedContacts(prev => prev.filter(c => c.id !== contactId)); - }; - - // 步骤2:全选当前页 - const handleSelectAllContacts = () => { - if (paginatedContacts.length === 0) return; - const allSelected = paginatedContacts.every(contact => - selectedContacts.some(c => c.id === contact.id), - ); - if (allSelected) { - // 取消全选当前页 - const currentPageIds = paginatedContacts.map(c => c.id); - setSelectedContacts(prev => - prev.filter(c => !currentPageIds.includes(c.id)), - ); - } else { - // 全选当前页 - const toAdd = paginatedContacts.filter( - contact => !selectedContacts.some(c => c.id === contact.id), - ); - setSelectedContacts(prev => [...prev, ...toAdd]); - } - }; - - // 下一步 const handleNext = () => { if (currentStep === 1) { if (selectedAccounts.length === 0) { @@ -321,7 +91,10 @@ const CreatePushTask: React.FC = () => { return; } setCurrentStep(2); - } else if (currentStep === 2) { + return; + } + + if (currentStep === 2) { if (selectedContacts.length === 0) { message.warning( `请至少选择一个${validPushType === "friend-message" ? "好友" : "群"}`, @@ -332,14 +105,20 @@ const CreatePushTask: React.FC = () => { } }; - // 上一步 const handlePrev = () => { if (currentStep > 1) { setCurrentStep(currentStep - 1); } }; - // 发送 + const handleClearAccounts = () => { + if (selectedAccounts.length === 0) { + message.info("暂无已选微信账号"); + return; + } + setSelectedAccounts([]); + }; + const handleSend = () => { if (!messageContent.trim()) { message.warning("请输入消息内容"); @@ -361,460 +140,42 @@ const CreatePushTask: React.FC = () => { navigate("/pc/powerCenter/message-push-assistant"); }; - // 渲染步骤1:选择微信账号 - const renderStep1 = () => { - return ( -
-
-

选择微信账号

-

可选择多个微信账号进行推送

-
- -
- } - value={searchKeyword} - onChange={e => setSearchKeyword(e.target.value)} - allowClear - /> -
- - {/* 账号列表 */} - {filteredAccounts.length > 0 ? ( -
- {filteredAccounts.map(account => { - const isSelected = selectedAccounts.some( - s => s.id === account.id, - ); - return ( -
handleAccountToggle(account)} - > - - {!account.avatar && - (account.nickname || account.name || "").charAt(0)} - -
-
- {account.nickname || account.name || "未知"} -
-
- - - {account.isOnline ? "在线" : "离线"} - -
-
- {isSelected && ( - - )} -
- ); - })} -
- ) : ( - - )} -
- ); - }; - - // 渲染步骤2:选择好友/群 - const renderStep2 = () => ( -
-
-
-

选择{getStep2Title()}

-

从{getStep2Title()}列表中选择推送对象

-
-
- } - value={step2SearchValue} - onChange={e => setStep2SearchValue(e.target.value)} - allowClear - /> - -
-
- {/* 左侧:好友/群列表 */} -
-
- - {getStep2Title()}列表(共{step2Total}个) - -
-
- {loadingContacts ? ( -
- - 加载中... -
- ) : paginatedContacts.length > 0 ? ( - paginatedContacts.map(contact => { - const isSelected = selectedContacts.some( - c => c.id === contact.id, - ); - return ( -
handleContactToggle(contact)} - > - - - ) : ( - - ) - } - /> -
-
- {contact.nickname} -
- {contact.conRemark && ( -
- {contact.conRemark} -
- )} -
- {contact.type === "group" && ( - - )} -
- ); - }) - ) : ( - - )} -
- {step2Total > 0 && ( -
- setStep2Page(p)} - showSizeChanger={false} - /> -
- )} -
- - {/* 右侧:已选列表 */} -
-
- - 已选{getStep2Title()}列表(共{selectedContacts.length}个) - - {selectedContacts.length > 0 && ( - - )} -
-
- {selectedContacts.length > 0 ? ( - selectedContacts.map(contact => ( -
-
- - ) : ( - - ) - } - /> -
-
{contact.nickname}
- {contact.conRemark && ( -
- {contact.conRemark} -
- )} -
- {contact.type === "group" && ( - - )} -
- handleRemoveContact(contact.id)} - /> -
- )) - ) : ( - - )} -
-
-
-
-
- ); - - // 渲染步骤3:一键群发 - const renderStep3 = () => ( -
-
- {/* 左侧栏:内容编辑 */} -
- {/* 模拟推送内容 */} -
-
模拟推送内容
-
-
当前编辑话术
-
- {messageContent || "开始添加消息内容..."} -
-
-
- - {/* 已保存话术组 */} -
-
- 已保存话术组({scriptGroups.length}) -
- {scriptGroups.map(group => ( -
-
-
- setSelectedScriptGroup(group.id)} - /> - {group.name} - - {group.messageCount}条消息 - -
-
-
-
- {selectedScriptGroup === group.id && ( -
- {group.content} -
- )} -
- ))} -
- - {/* 消息输入区域 */} -
- setMessageContent(e.target.value)} - rows={4} - onKeyDown={e => { - if (e.ctrlKey && e.key === "Enter") { - e.preventDefault(); - setMessageContent(prev => prev + "\n"); - } - }} - /> -
-
-
- - AI智能话术改写 - {aiRewriteEnabled && ( - setAiPrompt(e.target.value)} - style={{ marginLeft: 12, width: 200 }} - /> - )} - -
-
- 按住CTRL+ENTER换行,已配置{scriptGroups.length} - 个话术组,已选择0个进行推送 -
-
-
- - {/* 右侧栏:设置和预览 */} -
- {/* 相关设置 */} -
-
相关设置
-
-
好友间间隔
-
- 间隔时间(秒) - - {friendInterval} - 20 -
-
-
-
消息间间隔
-
- 间隔时间(秒) - - {messageInterval} - 12 -
-
-
- - {/* 完成打标签 */} -
-
完成打标签
- -
- - {/* 推送预览 */} -
-
推送预览
-
    -
  • 推送账号: {selectedAccounts.length}个
  • -
  • - 推送{getStep2Title()}: {selectedContacts.length}个 -
  • -
  • 话术组数: 0个
  • -
  • 随机推送: 否
  • -
  • 预计耗时: ~1分钟
  • -
-
-
-
-
- ); - return ( - +
+ +
+ -
+ } - footer={null} - > -
- {/* 步骤指示器 */} -
-
= 1 ? styles.active : ""} ${currentStep > 1 ? styles.completed : ""}`} - > -
- {currentStep > 1 ? : "1"} -
- 选择微信 -
-
= 2 ? styles.active : ""} ${currentStep > 2 ? styles.completed : ""}`} - > -
- {currentStep > 2 ? : "2"} -
- 选择{getStep2Title()} -
-
= 3 ? styles.active : ""}`} - > -
3
- 一键群发 -
-
- - {/* 步骤内容 */} -
- {currentStep === 1 && renderStep1()} - {currentStep === 2 && renderStep2()} - {currentStep === 3 && renderStep3()} -
- - {/* 底部操作栏 */} + footer={
{currentStep === 1 && ( @@ -822,12 +183,12 @@ const CreatePushTask: React.FC = () => { )} {currentStep === 2 && ( - 已选择{selectedContacts.length}个{getStep2Title()} + 已选择{selectedContacts.length}个{step2Title} )} {currentStep === 3 && ( - 推送账号: {selectedAccounts.length}个, 推送{getStep2Title()}:{" "} + 推送账号: {selectedAccounts.length}个, 推送{step2Title}:{" "} {selectedContacts.length}个 )} @@ -835,7 +196,12 @@ const CreatePushTask: React.FC = () => {
{currentStep === 1 && ( <> - + @@ -863,6 +229,45 @@ const CreatePushTask: React.FC = () => { )}
+ } + > +
+
+ {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + {currentStep === 3 && ( + + )} +
); diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts new file mode 100644 index 00000000..c0505f80 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts @@ -0,0 +1,15 @@ +export type PushType = + | "friend-message" + | "group-message" + | "group-announcement"; + +export interface ContactItem { + id: number; + nickname: string; + avatar?: string; + conRemark?: string; + wechatId?: string; + gender?: number; + region?: string; + type?: "friend" | "group"; +} From 2f804c7d40913703911ca678df9ab98e3c71f129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= Date: Tue, 11 Nov 2025 10:26:51 +0800 Subject: [PATCH 04/26] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=BC=98=E5=8C=96=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SmallProgramMessage.module.scss | 8 +- .../components/SmallProgramMessage/index.tsx | 433 ++++++++++----- .../components/MessageRecord/index.tsx | 504 +++++++++--------- .../src/store/module/weChat/weChat.data.ts | 5 + Touchkebao/src/store/module/weChat/weChat.ts | 207 +++++++ .../src/store/module/websocket/msgManage.ts | 18 + 6 files changed, 791 insertions(+), 384 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss index 40124966..8aec8f11 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss @@ -273,6 +273,12 @@ text-decoration: underline; } } + + .fileActionDisabled { + color: #999; + cursor: not-allowed; + pointer-events: none; + } } // 响应式设计 @@ -312,4 +318,4 @@ } } } -} \ No newline at end of file +} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx index 51711685..11b6d335 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx @@ -1,14 +1,169 @@ import React from "react"; import { parseWeappMsgStr } from "@/utils/common"; +import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; +import { useWebSocketStore } from "@/store/module/websocket/websocket"; +import { useWeChatStore } from "@/store/module/weChat/weChat"; import styles from "./SmallProgramMessage.module.scss"; +const FILE_MESSAGE_TYPE = "file"; + +interface FileMessageData { + type: string; + title?: string; + fileName?: string; + filename?: string; + url?: string; + isDownloading?: boolean; + fileext?: string; + size?: number | string; + [key: string]: any; +} + +const isJsonLike = (value: string) => { + const trimmed = value.trim(); + return trimmed.startsWith("{") && trimmed.endsWith("}"); +}; + +const extractFileInfoFromXml = (source: string): FileMessageData | null => { + if (typeof source !== "string") { + return null; + } + + const trimmed = source.trim(); + if (!trimmed) { + return null; + } + + try { + if (typeof DOMParser !== "undefined") { + const parser = new DOMParser(); + const doc = parser.parseFromString(trimmed, "text/xml"); + if (doc.getElementsByTagName("parsererror").length === 0) { + const titleNode = doc.getElementsByTagName("title")[0]; + const fileExtNode = doc.getElementsByTagName("fileext")[0]; + const sizeNode = + doc.getElementsByTagName("totallen")[0] || + doc.getElementsByTagName("filesize")[0]; + + const result: FileMessageData = { type: FILE_MESSAGE_TYPE }; + const titleText = titleNode?.textContent?.trim(); + if (titleText) { + result.title = titleText; + } + + const fileExtText = fileExtNode?.textContent?.trim(); + if (fileExtText) { + result.fileext = fileExtText; + } + + const sizeText = sizeNode?.textContent?.trim(); + if (sizeText) { + const sizeNumber = Number(sizeText); + result.size = Number.isNaN(sizeNumber) ? sizeText : sizeNumber; + } + + return result; + } + } + } catch (error) { + console.warn("extractFileInfoFromXml parse failed:", error); + } + + const regexTitle = + trimmed.match(/<!\[CDATA\[(.*?)\]\]><\/title>/i) || + trimmed.match(/<title>([^<]+)<\/title>/i); + const regexExt = + trimmed.match(/<fileext><!\[CDATA\[(.*?)\]\]><\/fileext>/i) || + trimmed.match(/<fileext>([^<]+)<\/fileext>/i); + const regexSize = + trimmed.match(/<totallen>([^<]+)<\/totallen>/i) || + trimmed.match(/<filesize>([^<]+)<\/filesize>/i); + + if (!regexTitle && !regexExt && !regexSize) { + return null; + } + + const fallback: FileMessageData = { type: FILE_MESSAGE_TYPE }; + if (regexTitle?.[1]) { + fallback.title = regexTitle[1].trim(); + } + if (regexExt?.[1]) { + fallback.fileext = regexExt[1].trim(); + } + if (regexSize?.[1]) { + const sizeNumber = Number(regexSize[1]); + fallback.size = Number.isNaN(sizeNumber) ? regexSize[1].trim() : sizeNumber; + } + + return fallback; +}; + +const resolveFileMessageData = ( + messageData: any, + msg: ChatRecord, + rawContent: string, +): FileMessageData | null => { + const meta = + msg?.fileDownloadMeta && typeof msg.fileDownloadMeta === "object" + ? { ...(msg.fileDownloadMeta as Record<string, any>) } + : null; + + if (messageData && typeof messageData === "object") { + if (messageData.type === FILE_MESSAGE_TYPE) { + return { + type: FILE_MESSAGE_TYPE, + ...messageData, + ...(meta || {}), + }; + } + + if (typeof messageData.contentXml === "string") { + const xmlData = extractFileInfoFromXml(messageData.contentXml); + if (xmlData || meta) { + return { + ...(xmlData || {}), + ...(meta || {}), + type: FILE_MESSAGE_TYPE, + }; + } + } + } + + if (typeof rawContent === "string") { + const xmlData = extractFileInfoFromXml(rawContent); + if (xmlData || meta) { + return { + ...(xmlData || {}), + ...(meta || {}), + type: FILE_MESSAGE_TYPE, + }; + } + } + + if (meta) { + return { + type: FILE_MESSAGE_TYPE, + ...meta, + }; + } + + return null; +}; + interface SmallProgramMessageProps { content: string; + msg: ChatRecord; + contract: ContractData | weChatGroup; } const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ content, + msg, + contract, }) => { + const sendCommand = useWebSocketStore(state => state.sendCommand); + const setFileDownloading = useWeChatStore(state => state.setFileDownloading); + // 统一的错误消息渲染函数 const renderErrorMessage = (fallbackText: string) => ( <div className={styles.messageText}>{fallbackText}</div> @@ -20,12 +175,10 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ try { const trimmedContent = content.trim(); + const isJsonContent = isJsonLike(trimmedContent); + const messageData = isJsonContent ? JSON.parse(trimmedContent) : null; - // 尝试解析JSON格式的消息 - if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) { - const messageData = JSON.parse(trimmedContent); - - // 处理文章类型消息 + if (messageData && typeof messageData === "object") { if (messageData.type === "link") { const { title, desc, thumbPath, url } = messageData; @@ -37,10 +190,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ className={`${styles.miniProgramCard} ${styles.articleCard}`} onClick={() => window.open(url, "_blank")} > - {/* 标题在第一行 */} <div className={styles.articleTitle}>{title}</div> - - {/* 下方:文字在左,图片在右 */} <div className={styles.articleContent}> <div className={styles.articleTextArea}> {desc && ( @@ -67,7 +217,6 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ ); } - // 处理小程序消息 - 统一使用parseWeappMsgStr解析 if (messageData.type === "miniprogram") { try { const parsedData = parseWeappMsgStr(trimmedContent); @@ -77,16 +226,12 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ const title = appmsg.title || "小程序消息"; const appName = appmsg.sourcedisplayname || appmsg.appname || "小程序"; - - // 获取小程序类型 const miniProgramType = appmsg.weappinfo && appmsg.weappinfo.type ? parseInt(appmsg.weappinfo.type) : 1; - // 根据type类型渲染不同布局 if (miniProgramType === 2) { - // 类型2:图片区域布局 return ( <div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} @@ -113,146 +258,158 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ </div> </div> ); - } else { - // 默认类型:横向布局 - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} - > - <div className={styles.miniProgramCard}> - <img - src={parsedData.previewImage} - alt="小程序缩略图" - className={styles.miniProgramThumb} - onError={e => { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - <div className={styles.miniProgramInfo}> - <div className={styles.miniProgramTitle}>{title}</div> - </div> - </div> - <div className={styles.miniProgramApp}>{appName}</div> - </div> - ); } + + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} + > + <div className={styles.miniProgramCard}> + <img + src={parsedData.previewImage} + alt="小程序缩略图" + className={styles.miniProgramThumb} + onError={e => { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + <div className={styles.miniProgramInfo}> + <div className={styles.miniProgramTitle}>{title}</div> + </div> + </div> + <div className={styles.miniProgramApp}>{appName}</div> + </div> + ); } } catch (parseError) { console.error("parseWeappMsgStr解析失败:", parseError); return renderErrorMessage("[小程序消息 - 解析失败]"); } } + } - //处理文档类型消息 + const rawContentForResolve = + messageData && typeof messageData.contentXml === "string" + ? messageData.contentXml + : trimmedContent; + const fileMessageData = resolveFileMessageData( + messageData, + msg, + rawContentForResolve, + ); - if (messageData.type === "file") { - const { url, title } = messageData; - // 增强的文件消息处理 - const isFileUrl = - url.startsWith("http") || - url.startsWith("https") || - url.startsWith("file://") || - /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(url); + if (fileMessageData && fileMessageData.type === FILE_MESSAGE_TYPE) { + const { + url = "", + title, + fileName, + filename, + fileext, + isDownloading = false, + } = fileMessageData; + const resolvedFileName = + title || + fileName || + filename || + (typeof url === "string" && url + ? url.split("/").pop()?.split("?")[0] + : "") || + "文件"; + const resolvedExtension = ( + fileext || + resolvedFileName.split(".").pop() || + "" + ).toLowerCase(); - if (isFileUrl) { - // 尝试从URL中提取文件名 - const fileName = - title || url.split("/").pop()?.split("?")[0] || "文件"; - const fileExtension = fileName.split(".").pop()?.toLowerCase(); + const iconMap: Record<string, string> = { + pdf: "📕", + doc: "📘", + docx: "📘", + xls: "📗", + xlsx: "📗", + ppt: "📙", + pptx: "📙", + txt: "📝", + zip: "🗜️", + rar: "🗜️", + "7z": "🗜️", + jpg: "🖼️", + jpeg: "🖼️", + png: "🖼️", + gif: "🖼️", + mp4: "🎬", + avi: "🎬", + mov: "🎬", + mp3: "🎵", + wav: "🎵", + flac: "🎵", + }; + const fileIcon = iconMap[resolvedExtension] || "📄"; + const isUrlAvailable = typeof url === "string" && url.trim().length > 0; - // 根据文件类型选择图标 - let fileIcon = "📄"; - if (fileExtension) { - const iconMap: { [key: string]: string } = { - pdf: "📕", - doc: "📘", - docx: "📘", - xls: "📗", - xlsx: "📗", - ppt: "📙", - pptx: "📙", - txt: "📝", - zip: "🗜️", - rar: "🗜️", - "7z": "🗜️", - jpg: "🖼️", - jpeg: "🖼️", - png: "🖼️", - gif: "🖼️", - mp4: "🎬", - avi: "🎬", - mov: "🎬", - mp3: "🎵", - wav: "🎵", - flac: "🎵", - }; - fileIcon = iconMap[fileExtension] || "📄"; + const handleFileDownload = () => { + if (isDownloading || !contract || !msg?.id) return; + + setFileDownloading(msg.id, true); + sendCommand("CmdDownloadFile", { + wechatAccountId: contract.wechatAccountId, + friendMessageId: contract.chatroomId ? 0 : msg.id, + chatroomMessageId: contract.chatroomId ? msg.id : 0, + }); + }; + + const actionText = isUrlAvailable + ? "点击查看" + : isDownloading + ? "下载中..." + : "下载"; + const actionDisabled = !isUrlAvailable && isDownloading; + + const handleActionClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (isUrlAvailable) { + try { + window.open(url, "_blank"); + } catch (e) { + console.error("文件打开失败:", e); } + return; + } + handleFileDownload(); + }; - return ( - <div className={styles.fileMessage}> - <div className={styles.fileCard}> - <div className={styles.fileIcon}>{fileIcon}</div> - <div className={styles.fileInfo}> - <div className={styles.fileName}> - {fileName.length > 20 - ? fileName.substring(0, 20) + "..." - : fileName} - </div> - <div - className={styles.fileAction} - onClick={() => { - try { - window.open(messageData.url, "_blank"); - } catch (e) { - console.error("文件打开失败:", e); - } - }} - > - 点击查看 - </div> - </div> + return ( + <div className={styles.fileMessage}> + <div + className={styles.fileCard} + onClick={() => { + if (isUrlAvailable) { + window.open(url, "_blank"); + } else if (!isDownloading) { + handleFileDownload(); + } + }} + > + <div className={styles.fileIcon}>{fileIcon}</div> + <div className={styles.fileInfo}> + <div className={styles.fileName}> + {resolvedFileName.length > 20 + ? resolvedFileName.substring(0, 20) + "..." + : resolvedFileName} + </div> + <div + className={`${styles.fileAction} ${ + actionDisabled ? styles.fileActionDisabled : "" + }`} + onClick={handleActionClick} + > + {actionText} </div> </div> - ); - } - } - - // 验证传统JSON格式的小程序数据结构 - // if ( - // messageData && - // typeof messageData === "object" && - // (messageData.title || messageData.appName) - // ) { - // return ( - // <div className={styles.miniProgramMessage}> - // <div className={styles.miniProgramCard}> - // {messageData.thumb && ( - // <img - // src={messageData.thumb} - // alt="小程序缩略图" - // className={styles.miniProgramThumb} - // onError={e => { - // const target = e.target as HTMLImageElement; - // target.style.display = "none"; - // }} - // /> - // )} - // <div className={styles.miniProgramInfo}> - // <div className={styles.miniProgramTitle}> - // {messageData.title || "小程序消息"} - // </div> - // {messageData.appName && ( - // <div className={styles.miniProgramApp}> - // {messageData.appName} - // </div> - // )} - // </div> - // </div> - // </div> - // ); - // } + </div> + </div> + ); } return renderErrorMessage("[小程序/文件消息]"); diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx index 715c755b..1da7da52 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { CSSProperties, useEffect, useRef, useState } from "react"; import { Avatar, Checkbox } from "antd"; import { UserOutlined, LoadingOutlined } from "@ant-design/icons"; import AudioMessage from "./components/AudioMessage/AudioMessage"; @@ -17,6 +17,117 @@ import { useCustomerStore } from "@weChatStore/customer"; import { fetchReCallApi, fetchVoiceToTextApi } from "./api"; import TransmitModal from "./components/TransmitModal"; +const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i; +const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i; +const DEFAULT_IMAGE_STYLE: CSSProperties = { + maxWidth: "200px", + maxHeight: "200px", + borderRadius: "8px", +}; +const EMOJI_IMAGE_STYLE: CSSProperties = { + maxWidth: "120px", + maxHeight: "120px", +}; + +type ImageContentOptions = { + src: string; + alt: string; + fallbackText: string; + style?: CSSProperties; + wrapperClassName?: string; + withBubble?: boolean; + onClick?: () => void; +}; + +const openInNewTab = (url: string) => window.open(url, "_blank"); + +const handleImageError = ( + event: React.SyntheticEvent<HTMLImageElement>, + fallbackText: string, +) => { + const target = event.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `<div class="${styles.messageText}">${fallbackText}</div>`; + } +}; + +const renderImageContent = ({ + src, + alt, + fallbackText, + style = DEFAULT_IMAGE_STYLE, + wrapperClassName = styles.imageMessage, + withBubble = false, + onClick, +}: ImageContentOptions) => { + const imageNode = ( + <div className={wrapperClassName}> + <img + src={src} + alt={alt} + style={style} + onClick={onClick ?? (() => openInNewTab(src))} + onError={event => handleImageError(event, fallbackText)} + /> + </div> + ); + + if (withBubble) { + return <div className={styles.messageBubble}>{imageNode}</div>; + } + + return imageNode; +}; + +const renderEmojiContent = (src: string) => + renderImageContent({ + src, + alt: "表情包", + fallbackText: "[表情包加载失败]", + style: EMOJI_IMAGE_STYLE, + wrapperClassName: styles.emojiMessage, + }); + +const renderFileContent = (url: string) => { + const fileName = url.split("/").pop()?.split("?")[0] || "文件"; + const displayName = + fileName.length > 20 ? `${fileName.substring(0, 20)}...` : fileName; + + return ( + <div className={styles.fileMessage}> + <div className={styles.fileCard}> + <div className={styles.fileIcon}>📄</div> + <div className={styles.fileInfo}> + <div className={styles.fileName}>{displayName}</div> + <div className={styles.fileAction} onClick={() => openInNewTab(url)}> + 点击查看 + </div> + </div> + </div> + </div> + ); +}; + +const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); +const isHttpImageUrl = (value: string) => + isHttpUrl(value) && IMAGE_EXT_REGEX.test(value); +const isFileUrl = (value: string) => + isHttpUrl(value) && FILE_EXT_REGEX.test(value); + +const isLegacyEmojiContent = (content: string) => + IMAGE_EXT_REGEX.test(content) || + content.includes("emoji") || + content.includes("sticker"); + +const tryParseContentJson = (content: string): Record<string, any> | null => { + try { + return JSON.parse(content); + } catch (error) { + return null; + } +}; + interface MessageRecordProps { contract: ContractData | weChatGroup; } @@ -122,6 +233,115 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { return parts; }; + const renderUnknownContent = ( + rawContent: string, + trimmedContent: string, + msg?: ChatRecord, + contract?: ContractData | weChatGroup, + ) => { + if (isLegacyEmojiContent(trimmedContent)) { + return renderEmojiContent(rawContent); + } + + const jsonData = tryParseContentJson(trimmedContent); + + if (jsonData && typeof jsonData === "object") { + if (jsonData.type === "file" && msg && contract) { + return ( + <SmallProgramMessage + content={rawContent} + msg={msg} + contract={contract} + /> + ); + } + + if (jsonData.type === "link" && jsonData.title && jsonData.url) { + const { title, desc, thumbPath, url } = jsonData; + + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} + > + <div + className={`${styles.miniProgramCard} ${styles.linkCard}`} + onClick={() => openInNewTab(url)} + > + {thumbPath && ( + <img + src={thumbPath} + alt="链接缩略图" + className={styles.miniProgramThumb} + onError={event => { + const target = event.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + )} + <div className={styles.miniProgramInfo}> + <div className={styles.miniProgramTitle}>{title}</div> + {desc && <div className={styles.linkDescription}>{desc}</div>} + </div> + </div> + <div className={styles.miniProgramApp}>链接</div> + </div> + ); + } + + if (jsonData.previewImage && (jsonData.tencentUrl || jsonData.videoUrl)) { + const previewImageUrl = String(jsonData.previewImage).replace( + /[`"']/g, + "", + ); + return ( + <div className={styles.videoMessage}> + <div className={styles.videoContainer}> + <img + src={previewImageUrl} + alt="视频预览" + className={styles.videoPreview} + onClick={() => { + const videoUrl = jsonData.videoUrl || jsonData.tencentUrl; + if (videoUrl) { + openInNewTab(videoUrl); + } + }} + onError={event => { + const target = event.target as HTMLImageElement; + const parent = target.parentElement?.parentElement; + if (parent) { + parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`; + } + }} + /> + <div className={styles.playButton}> + <svg width="24" height="24" viewBox="0 0 24 24" fill="white"> + <path d="M8 5v14l11-7z" /> + </svg> + </div> + </div> + </div> + ); + } + } + + if (isHttpImageUrl(trimmedContent)) { + return renderImageContent({ + src: rawContent, + alt: "图片消息", + fallbackText: "[图片加载失败]", + }); + } + + if (isFileUrl(trimmedContent)) { + return renderFileContent(trimmedContent); + } + + return ( + <div className={styles.messageText}>{parseEmojiText(rawContent)}</div> + ); + }; + useEffect(() => { const prevMessages = prevMessagesRef.current; @@ -190,290 +410,84 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { <div className={styles.messageText}>{fallbackText}</div> ); - // 添加调试信息 - // console.log("MessageRecord - msgType:", msgType, "content:", content); + const isStringValue = typeof content === "string"; + const rawContent = isStringValue ? content : ""; + const trimmedContent = rawContent.trim(); - // 根据msgType进行消息类型判断 switch (msgType) { case 1: // 文本消息 return ( <div className={styles.messageBubble}> - <div className={styles.messageText}>{parseEmojiText(content)}</div> + <div className={styles.messageText}> + {parseEmojiText(rawContent)} + </div> </div> ); case 3: // 图片消息 - // 验证是否为有效的图片URL - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[图片消息 - 无效链接]"); } - return ( - <div className={styles.messageBubble}> - <div className={styles.imageMessage}> - <img - src={content} - alt="图片消息" - style={{ - maxWidth: "200px", - maxHeight: "200px", - borderRadius: "8px", - }} - onClick={() => window.open(content, "_blank")} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`; - } - }} - /> - </div> - </div> - ); + return renderImageContent({ + src: rawContent, + alt: "图片消息", + fallbackText: "[图片加载失败]", + withBubble: true, + }); case 34: // 语音消息 - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[语音消息 - 无效内容]"); } - // content直接是音频URL字符串 - return <AudioMessage audioUrl={content} msgId={String(msg.id)} />; + return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />; case 43: // 视频消息 return ( - <VideoMessage content={content || ""} msg={msg} contract={contract} /> + <VideoMessage + content={isStringValue ? rawContent : ""} + msg={msg} + contract={contract} + /> ); case 47: // 动图表情包(gif、其他表情包) - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[表情包 - 无效链接]"); } - // 使用工具函数判断表情包URL - if (isEmojiUrl(content)) { - return ( - <div className={styles.emojiMessage}> - <img - src={content} - alt="表情包" - style={{ maxWidth: "120px", maxHeight: "120px" }} - onClick={() => window.open(content, "_blank")} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`; - } - }} - /> - </div> - ); + if (isEmojiUrl(trimmedContent)) { + return renderEmojiContent(rawContent); } return renderErrorMessage("[表情包]"); case 48: // 定位消息 - return <LocationMessage content={content || ""} />; + return <LocationMessage content={isStringValue ? rawContent : ""} />; case 49: // 小程序/文章/其他:图文、文件 - return <SmallProgramMessage content={content || ""} />; + return ( + <SmallProgramMessage + content={isStringValue ? rawContent : ""} + msg={msg} + contract={contract} + /> + ); case 10002: // 系统推荐备注消息 - return <SystemRecommendRemarkMessage content={content || ""} />; + return ( + <SystemRecommendRemarkMessage + content={isStringValue ? rawContent : ""} + /> + ); default: { - // 兼容旧版本和未知消息类型的处理逻辑 - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage( `[未知消息类型${msgType ? ` - ${msgType}` : ""}]`, ); } - // 智能识别消息类型(兼容旧版本数据) - const contentStr = content.trim(); - - // 1. 检查是否为表情包(兼容旧逻辑) - const isLegacyEmoji = - contentStr.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") || - /\.(gif|webp|png|jpg|jpeg)$/i.test(contentStr) || - contentStr.includes("emoji") || - contentStr.includes("sticker"); - - if (isLegacyEmoji) { - return ( - <div className={styles.emojiMessage}> - <img - src={contentStr} - alt="表情包" - style={{ maxWidth: "120px", maxHeight: "120px" }} - onClick={() => window.open(contentStr, "_blank")} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`; - } - }} - /> - </div> - ); - } - - // 2. 检查是否为JSON格式消息(包括视频、链接等) - if (contentStr.startsWith("{") && contentStr.endsWith("}")) { - try { - const jsonData = JSON.parse(contentStr); - - // 检查是否为链接类型消息 - if (jsonData.type === "link" && jsonData.title && jsonData.url) { - const { title, desc, thumbPath, url } = jsonData; - - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} - > - <div - className={`${styles.miniProgramCard} ${styles.linkCard}`} - onClick={() => window.open(url, "_blank")} - > - {thumbPath && ( - <img - src={thumbPath} - alt="链接缩略图" - className={styles.miniProgramThumb} - onError={e => { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - )} - <div className={styles.miniProgramInfo}> - <div className={styles.miniProgramTitle}>{title}</div> - {desc && ( - <div className={styles.linkDescription}>{desc}</div> - )} - </div> - </div> - <div className={styles.miniProgramApp}>链接</div> - </div> - ); - } - - // 检查是否为视频消息(兼容旧逻辑) - if ( - jsonData && - typeof jsonData === "object" && - jsonData.previewImage && - (jsonData.tencentUrl || jsonData.videoUrl) - ) { - const previewImageUrl = String(jsonData.previewImage).replace( - /[`"']/g, - "", - ); - return ( - <div className={styles.videoMessage}> - <div className={styles.videoContainer}> - <img - src={previewImageUrl} - alt="视频预览" - className={styles.videoPreview} - onClick={() => { - const videoUrl = - jsonData.videoUrl || jsonData.tencentUrl; - if (videoUrl) { - window.open(videoUrl, "_blank"); - } - }} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement?.parentElement; - if (parent) { - parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`; - } - }} - /> - <div className={styles.playButton}> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="white" - > - <path d="M8 5v14l11-7z" /> - </svg> - </div> - </div> - </div> - ); - } - } catch (e) { - console.warn("兼容模式JSON解析失败:", e); - } - } - - // 3. 检查是否为图片链接 - const isImageUrl = - contentStr.startsWith("http") && - /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(contentStr); - - if (isImageUrl) { - return ( - <div className={styles.imageMessage}> - <img - src={contentStr} - alt="图片消息" - style={{ - maxWidth: "200px", - maxHeight: "200px", - borderRadius: "8px", - }} - onClick={() => window.open(contentStr, "_blank")} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`; - } - }} - /> - </div> - ); - } - - // 4. 检查是否为文件链接 - const isFileLink = - contentStr.startsWith("http") && - /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test( - contentStr, - ); - - if (isFileLink) { - const fileName = contentStr.split("/").pop()?.split("?")[0] || "文件"; - return ( - <div className={styles.fileMessage}> - <div className={styles.fileCard}> - <div className={styles.fileIcon}>📄</div> - <div className={styles.fileInfo}> - <div className={styles.fileName}> - {fileName.length > 20 - ? fileName.substring(0, 20) + "..." - : fileName} - </div> - <div - className={styles.fileAction} - onClick={() => window.open(contentStr, "_blank")} - > - 点击查看 - </div> - </div> - </div> - </div> - ); - } - - // 5. 默认按文本消息处理 - return ( - <div className={styles.messageText}>{parseEmojiText(content)}</div> - ); + return renderUnknownContent(rawContent, trimmedContent, msg, contract); } } }; diff --git a/Touchkebao/src/store/module/weChat/weChat.data.ts b/Touchkebao/src/store/module/weChat/weChat.data.ts index 00bc2c51..d23dfdf8 100644 --- a/Touchkebao/src/store/module/weChat/weChat.data.ts +++ b/Touchkebao/src/store/module/weChat/weChat.data.ts @@ -97,6 +97,11 @@ export interface WeChatState { setVideoLoading: (messageId: number, isLoading: boolean) => void; /** 设置视频消息URL */ setVideoUrl: (messageId: number, videoUrl: string) => void; + // ==================== 文件消息处理 ==================== + /** 设置文件消息下载状态 */ + setFileDownloading: (messageId: number, isDownloading: boolean) => void; + /** 设置文件消息URL */ + setFileDownloadUrl: (messageId: number, fileUrl: string) => void; // ==================== 消息接收处理 ==================== /** 接收新消息处理 */ diff --git a/Touchkebao/src/store/module/weChat/weChat.ts b/Touchkebao/src/store/module/weChat/weChat.ts index d5299f47..f161bac0 100644 --- a/Touchkebao/src/store/module/weChat/weChat.ts +++ b/Touchkebao/src/store/module/weChat/weChat.ts @@ -27,6 +27,169 @@ let aiRequestTimer: NodeJS.Timeout | null = null; let pendingMessages: ChatRecord[] = []; // 待处理的消息队列 let currentAiGenerationId: string | null = null; // 当前AI生成的唯一ID const AI_REQUEST_DELAY = 3000; // 3秒延迟 +const FILE_MESSAGE_TYPE = "file"; + +type FileMessagePayload = { + type?: string; + title?: string; + url?: string; + isDownloading?: boolean; + fileext?: string; + size?: number | string; + [key: string]: any; +}; + +const isJsonLike = (value: string) => { + const trimmed = value.trim(); + return trimmed.startsWith("{") && trimmed.endsWith("}"); +}; + +const parseFileJsonContent = ( + rawContent: unknown, +): FileMessagePayload | null => { + if (typeof rawContent !== "string") { + return null; + } + + const trimmed = rawContent.trim(); + if (!trimmed || !isJsonLike(trimmed)) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + if ( + parsed && + typeof parsed === "object" && + parsed.type === FILE_MESSAGE_TYPE + ) { + return parsed as FileMessagePayload; + } + } catch (error) { + console.warn("parseFileJsonContent failed:", error); + } + + return null; +}; + +const extractFileTitleFromContent = (rawContent: unknown): string => { + if (typeof rawContent !== "string") { + return ""; + } + + const trimmed = rawContent.trim(); + if (!trimmed) { + return ""; + } + + const cdataMatch = + trimmed.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/i) || + trimmed.match(/"title"\s*:\s*"([^"]+)"/i); + if (cdataMatch?.[1]) { + return cdataMatch[1].trim(); + } + + const simpleMatch = trimmed.match(/<title>([^<]+)<\/title>/i); + if (simpleMatch?.[1]) { + return simpleMatch[1].trim(); + } + + return ""; +}; + +const isFileLikeMessage = (msg: ChatRecord): boolean => { + if ((msg as any).fileDownloadMeta) { + return true; + } + + if (typeof msg.content === "string") { + const trimmed = msg.content.trim(); + if (!trimmed) { + return false; + } + + if ( + /"type"\s*:\s*"file"/i.test(trimmed) || + /<appattach/i.test(trimmed) || + /<fileext/i.test(trimmed) + ) { + return true; + } + } + + return false; +}; + +const normalizeFilePayload = ( + payload: FileMessagePayload | null | undefined, + msg: ChatRecord, +): FileMessagePayload => { + const fallbackTitle = + payload?.title || + ((msg as any).fileDownloadMeta && + typeof (msg as any).fileDownloadMeta === "object" + ? ((msg as any).fileDownloadMeta as FileMessagePayload).title + : undefined) || + extractFileTitleFromContent(msg.content) || + (msg as any).fileName || + (msg as any).title || + ""; + + return { + type: FILE_MESSAGE_TYPE, + ...payload, + title: payload?.title ?? fallbackTitle ?? "", + isDownloading: payload?.isDownloading ?? false, + }; +}; + +const updateFileMessageState = ( + msg: ChatRecord, + updater: (payload: FileMessagePayload) => FileMessagePayload, +): ChatRecord => { + const parsedPayload = parseFileJsonContent(msg.content); + + if (!parsedPayload && !isFileLikeMessage(msg)) { + return msg; + } + + const basePayload = parsedPayload + ? normalizeFilePayload(parsedPayload, msg) + : normalizeFilePayload( + (msg as any).fileDownloadMeta as FileMessagePayload | undefined, + msg, + ); + + const updatedPayload = updater(basePayload); + const sanitizedPayload: FileMessagePayload = { + ...basePayload, + ...updatedPayload, + type: FILE_MESSAGE_TYPE, + title: + updatedPayload.title ?? + basePayload.title ?? + extractFileTitleFromContent(msg.content) ?? + "", + isDownloading: + updatedPayload.isDownloading ?? basePayload.isDownloading ?? false, + }; + + if (parsedPayload) { + return { + ...msg, + content: JSON.stringify({ + ...parsedPayload, + ...sanitizedPayload, + }), + fileDownloadMeta: sanitizedPayload, + }; + } + + return { + ...msg, + fileDownloadMeta: sanitizedPayload, + }; +}; /** * 清除AI请求定时器和队列 @@ -618,6 +781,50 @@ export const useWeChatStore = create<WeChatState>()( })); }, + // ==================== 文件消息处理方法 ==================== + /** 更新文件消息下载状态 */ + setFileDownloading: (messageId: number, isDownloading: boolean) => { + set(state => ({ + currentMessages: state.currentMessages.map(msg => { + if (msg.id !== messageId) { + return msg; + } + + try { + return updateFileMessageState(msg, payload => ({ + ...payload, + isDownloading, + })); + } catch (error) { + console.error("更新文件下载状态失败:", error); + return msg; + } + }), + })); + }, + + /** 更新文件消息URL */ + setFileDownloadUrl: (messageId: number, fileUrl: string) => { + set(state => ({ + currentMessages: state.currentMessages.map(msg => { + if (msg.id !== messageId) { + return msg; + } + + try { + return updateFileMessageState(msg, payload => ({ + ...payload, + url: fileUrl, + isDownloading: false, + })); + } catch (error) { + console.error("更新文件URL失败:", error); + return msg; + } + }), + })); + }, + // ==================== 数据清理方法 ==================== /** 清空所有数据 */ clearAllData: () => { diff --git a/Touchkebao/src/store/module/websocket/msgManage.ts b/Touchkebao/src/store/module/websocket/msgManage.ts index 82058dc9..2fcaceb3 100644 --- a/Touchkebao/src/store/module/websocket/msgManage.ts +++ b/Touchkebao/src/store/module/websocket/msgManage.ts @@ -17,6 +17,8 @@ const updateMessage = useWeChatStore.getState().updateMessage; const updateMomentCommonLoading = useWeChatStore.getState().updateMomentCommonLoading; const addMomentCommon = useWeChatStore.getState().addMomentCommon; +const setFileDownloadUrl = useWeChatStore.getState().setFileDownloadUrl; +const setFileDownloading = useWeChatStore.getState().setFileDownloading; // 消息处理器映射 const messageHandlers: Record<string, MessageHandler> = { // 微信账号存活状态响应 @@ -104,6 +106,22 @@ const messageHandlers: Record<string, MessageHandler> = { console.log("视频下载结果:", message); // setVideoUrl(message.friendMessageId, message.url); }, + CmdDownloadFileResult: message => { + const messageId = message.friendMessageId || message.chatroomMessageId; + + if (!messageId) { + console.warn("文件下载结果缺少消息ID:", message); + return; + } + + if (!message.url) { + console.warn("文件下载结果缺少URL:", message); + setFileDownloading(messageId, false); + return; + } + + setFileDownloadUrl(messageId, message.url); + }, CmdFetchMomentResult: message => { addMomentCommon(message.result); From bb72038e2b7e5f3272768d47de1167644df83398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Tue, 11 Nov 2025 11:56:38 +0800 Subject: [PATCH 05/26] Implement contact filtering functionality in CreatePushTask component. Add modal for filtering contacts by tags, cities, and nickname/remark. Update styles for filter modal and enhance state management for filter values. --- .../components/StepSelectContacts/index.tsx | 357 +++++++++++++++++- .../create-push-task/index.module.scss | 83 ++++ .../create-push-task/types.ts | 6 + 3 files changed, 437 insertions(+), 9 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx index b804cafc..8de9fce1 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx @@ -10,6 +10,8 @@ import { Pagination, Spin, message, + Modal, + Select, } from "antd"; import { CloseOutlined, @@ -23,6 +25,37 @@ import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; import styles from "../../index.module.scss"; import { ContactItem, PushType } from "../../types"; +interface ContactFilterValues { + includeTags: string[]; + excludeTags: string[]; + includeCities: string[]; + excludeCities: string[]; + nicknameRemark: string; + groupIds: string[]; +} + +const createDefaultFilterValues = (): ContactFilterValues => ({ + includeTags: [], + excludeTags: [], + includeCities: [], + excludeCities: [], + nicknameRemark: "", + groupIds: [], +}); + +const cloneFilterValues = ( + values: ContactFilterValues, +): ContactFilterValues => ({ + includeTags: [...values.includeTags], + excludeTags: [...values.excludeTags], + includeCities: [...values.includeCities], + excludeCities: [...values.excludeCities], + nicknameRemark: values.nicknameRemark, + groupIds: [...values.groupIds], +}); + +const DISABLED_TAG_LABELS = new Set(["请选择标签"]); + interface StepSelectContactsProps { pushType: PushType; selectedAccounts: any[]; @@ -41,6 +74,12 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ const [page, setPage] = useState(1); const [searchValue, setSearchValue] = useState(""); const [total, setTotal] = useState(0); + const [filterModalVisible, setFilterModalVisible] = useState(false); + const [filterValues, setFilterValues] = useState<ContactFilterValues>( + createDefaultFilterValues, + ); + const [draftFilterValues, setDraftFilterValues] = + useState<ContactFilterValues>(createDefaultFilterValues); const pageSize = 20; @@ -140,12 +179,159 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ } }; - const filteredContacts = useMemo(() => { - if (searchValue.trim()) { - return contactsData; + const tagOptions = useMemo(() => { + const tagSet = new Set<string>(); + contactsData.forEach(contact => { + (contact.labels || []).forEach(tag => { + const normalizedTag = (tag || "").trim(); + if (normalizedTag && !DISABLED_TAG_LABELS.has(normalizedTag)) { + tagSet.add(normalizedTag); + } + }); + }); + return Array.from(tagSet).map(tag => ({ label: tag, value: tag })); + }, [contactsData]); + + const cityOptions = useMemo(() => { + const citySet = new Set<string>(); + contactsData.forEach(contact => { + const city = (contact.city || contact.region || "").trim(); + if (city) { + citySet.add(city); + } + }); + return Array.from(citySet).map(city => ({ label: city, value: city })); + }, [contactsData]); + + const groupOptions = useMemo(() => { + const groupMap = new Map<string, string>(); + contactsData.forEach(contact => { + const key = + contact.groupName || + contact.groupLabel || + (contact.groupId !== undefined ? contact.groupId.toString() : ""); + if (key) { + const display = + contact.groupName || contact.groupLabel || `分组 ${key}`; + groupMap.set(key, display); + } + }); + return Array.from(groupMap.entries()).map(([value, label]) => ({ + value, + label, + })); + }, [contactsData]); + + const hasActiveFilter = useMemo(() => { + const { + includeTags, + excludeTags, + includeCities, + excludeCities, + nicknameRemark, + groupIds, + } = filterValues; + + if ( + includeTags.length || + excludeTags.length || + includeCities.length || + excludeCities.length || + groupIds.length || + nicknameRemark.trim() + ) { + return true; } - return contactsData; - }, [contactsData, searchValue]); + return false; + }, [filterValues]); + + const filteredContacts = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + const nicknameKeyword = filterValues.nicknameRemark.trim().toLowerCase(); + + return contactsData.filter(contact => { + const labels = contact.labels || []; + const city = (contact.city || contact.region || "").toLowerCase(); + const groupValue = + contact.groupName || + contact.groupLabel || + (contact.groupId !== undefined ? contact.groupId.toString() : ""); + + if (keyword) { + const combined = `${contact.nickname || ""} ${ + contact.conRemark || "" + }`.toLowerCase(); + if (!combined.includes(keyword)) { + return false; + } + } + + if (filterValues.includeTags.length > 0) { + const hasAllIncludes = filterValues.includeTags.every(tag => + labels.includes(tag), + ); + if (!hasAllIncludes) { + return false; + } + } + + if (filterValues.excludeTags.length > 0) { + const hasExcluded = filterValues.excludeTags.some(tag => + labels.includes(tag), + ); + if (hasExcluded) { + return false; + } + } + + if (filterValues.includeCities.length > 0) { + const matchCity = filterValues.includeCities.some(value => + city.includes(value.toLowerCase()), + ); + if (!matchCity) { + return false; + } + } + + if (filterValues.excludeCities.length > 0) { + const matchExcludedCity = filterValues.excludeCities.some(value => + city.includes(value.toLowerCase()), + ); + if (matchExcludedCity) { + return false; + } + } + + if (nicknameKeyword) { + const combined = `${contact.nickname || ""} ${ + contact.conRemark || "" + }`.toLowerCase(); + if (!combined.includes(nicknameKeyword)) { + return false; + } + } + + if (filterValues.groupIds.length > 0) { + if (!groupValue) { + return false; + } + if ( + !filterValues.groupIds.some(value => value === groupValue.toString()) + ) { + return false; + } + } + + return true; + }); + }, [contactsData, filterValues, searchValue]); + + const displayTotal = useMemo(() => { + if (hasActiveFilter) { + return filteredContacts.length; + } + return total; + }, [filteredContacts, hasActiveFilter, total]); const handleContactToggle = (contact: ContactItem) => { const isSelected = selectedContacts.some(c => c.id === contact.id); @@ -176,6 +362,39 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ onChange([...selectedContacts, ...toAdd]); }; + const openFilterModal = () => { + setDraftFilterValues(cloneFilterValues(filterValues)); + setFilterModalVisible(true); + }; + + const closeFilterModal = () => { + setFilterModalVisible(false); + }; + + const handleFilterConfirm = () => { + setFilterValues(cloneFilterValues(draftFilterValues)); + setPage(1); + setFilterModalVisible(false); + }; + + const handleFilterReset = () => { + const nextValues = createDefaultFilterValues(); + setDraftFilterValues(nextValues); + setFilterValues(nextValues); + setPage(1); + setFilterModalVisible(false); + }; + + const updateDraftFilter = <K extends keyof ContactFilterValues>( + key: K, + value: ContactFilterValues[K], + ) => { + setDraftFilterValues(prev => ({ + ...prev, + [key]: value, + })); + }; + const handlePageChange = (p: number) => { setPage(p); }; @@ -195,14 +414,22 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ onChange={e => handleSearchChange(e.target.value)} allowClear /> - <Button onClick={handleSelectAllContacts}>全选</Button> </div> <div className={styles.contentBody}> <div className={styles.contactList}> <div className={styles.listHeader}> <span> - {stepTitle}列表(共{total}个) + {stepTitle}列表(共{displayTotal}个) </span> + <div style={{ display: "flex", gap: 10 }}> + <Button onClick={handleSelectAllContacts}>全选</Button> + <Button + type={hasActiveFilter ? "primary" : "default"} + onClick={openFilterModal} + > + 筛选 + </Button> + </div> </div> <div className={styles.listContent}> {loadingContacts ? ( @@ -260,13 +487,13 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ /> )} </div> - {total > 0 && ( + {displayTotal > 0 && ( <div className={styles.paginationContainer}> <Pagination size="small" current={page} pageSize={pageSize} - total={total} + total={displayTotal} onChange={handlePageChange} showSizeChanger={false} /> @@ -332,6 +559,118 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ </div> </div> </div> + + <Modal + title={`筛选${stepTitle}`} + open={filterModalVisible} + onCancel={closeFilterModal} + width={720} + className={styles.filterModal} + footer={[ + <Button key="reset" onClick={handleFilterReset}> + 重置 + </Button>, + <Button key="cancel" onClick={closeFilterModal}> + 取消 + </Button>, + <Button key="ok" type="primary" onClick={handleFilterConfirm}> + 确定 + </Button>, + ]} + > + <div className={styles.filterRow}> + <div className={styles.filterLabel}>标签</div> + <div className={styles.filterControls}> + <div className={styles.filterControl}> + <Button type="primary">包含</Button> + <Select + allowClear + mode="multiple" + placeholder="请选择" + options={tagOptions} + value={draftFilterValues.includeTags} + onChange={(value: string[]) => + updateDraftFilter("includeTags", value) + } + /> + </div> + <div className={styles.filterControl}> + <Button className={styles.excludeButton}>不包含</Button> + <Select + allowClear + mode="multiple" + placeholder="请选择" + options={tagOptions} + value={draftFilterValues.excludeTags} + onChange={(value: string[]) => + updateDraftFilter("excludeTags", value) + } + /> + </div> + </div> + </div> + + <div className={styles.filterRow}> + <div className={styles.filterLabel}>城市</div> + <div className={styles.filterControls}> + <div className={styles.filterControl}> + <Button type="primary">包含</Button> + <Select + allowClear + mode="multiple" + placeholder="请选择" + options={cityOptions} + value={draftFilterValues.includeCities} + onChange={(value: string[]) => + updateDraftFilter("includeCities", value) + } + /> + </div> + <div className={styles.filterControl}> + <Button className={styles.excludeButton}>不包含</Button> + <Select + allowClear + mode="multiple" + placeholder="请选择" + options={cityOptions} + value={draftFilterValues.excludeCities} + onChange={(value: string[]) => + updateDraftFilter("excludeCities", value) + } + /> + </div> + </div> + </div> + + <div className={styles.filterRow}> + <div className={styles.filterLabel}>昵称/备注</div> + <div className={styles.filterSingleControl}> + <Input + placeholder="请输入内容" + value={draftFilterValues.nicknameRemark} + onChange={e => + updateDraftFilter("nicknameRemark", e.target.value) + } + /> + </div> + </div> + + <div className={styles.filterRow}> + <div className={styles.filterLabel}>分组</div> + <div className={styles.filterSingleControl}> + <Select + allowClear + mode="multiple" + placeholder="请选择" + options={groupOptions} + value={draftFilterValues.groupIds} + onChange={(value: string[]) => + updateDraftFilter("groupIds", value) + } + /> + </div> + </div> + </Modal> </div> ); }; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss index c9c1ed8e..dc036ef8 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss @@ -576,6 +576,89 @@ } } +.filterModal { + :global(.ant-modal-body) { + padding-bottom: 12px; + } + + .filterRow { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; + } + + .filterLabel { + width: 64px; + text-align: right; + line-height: 32px; + font-weight: 500; + color: #1f1f1f; + } + + .filterControls { + flex: 1; + display: flex; + gap: 16px; + flex-wrap: wrap; + } + + .filterControl { + display: flex; + align-items: center; + gap: 12px; + + :global(.ant-select) { + min-width: 220px; + } + } + + .filterSingleControl { + flex: 1; + + :global(.ant-input), + :global(.ant-select) { + width: 100%; + } + } + + .extendFields { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; + } + + .extendFieldItem { + display: flex; + align-items: center; + gap: 12px; + } + + .extendFieldLabel { + width: 80px; + text-align: right; + color: #595959; + } + + .extendFieldItem :global(.ant-input) { + flex: 1; + } + + .excludeButton { + background-color: #faad14; + border-color: #faad14; + color: #fff; + + &:hover, + &:focus { + background-color: #d48806; + border-color: #d48806; + color: #fff; + } + } +} + .footer { display: flex; justify-content: space-between; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts index c0505f80..379bce7c 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts @@ -12,4 +12,10 @@ export interface ContactItem { gender?: number; region?: string; type?: "friend" | "group"; + labels?: string[]; + groupId?: number | string; + groupName?: string; + groupLabel?: string; + city?: string; + extendFields?: Record<string, any>; } From 87d1e1c44fd4832334d1726d2df2ddd66149b879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Tue, 11 Nov 2025 16:20:46 +0800 Subject: [PATCH 06/26] =?UTF-8?q?=E9=87=8D=E6=9E=84PushTask=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E7=94=A8=E4=BA=8E=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=AE=A1=E7=90=86=E7=9A=84=E8=84=9A=E6=9C=AC=E7=BB=84?= =?UTF-8?q?=E3=80=82=E5=BC=95=E5=85=A5=E6=96=B0=E7=9A=84=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=9D=A5=E5=A4=84=E7=90=86=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=92=8C=E7=BB=84=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E7=94=A8=E4=BA=8E=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=92=8C=E7=AE=A1=E7=90=86=E8=84=9A=E6=9C=AC=E7=BB=84=E7=9A=84?= =?UTF-8?q?UI=E3=80=82=E6=B8=85=E7=90=86=E6=A0=B7=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E5=96=84=E6=B6=88=E6=81=AF=E8=BE=93=E5=85=A5=E5=8C=BA?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputMessage/InputMessage.tsx | 0 .../InputMessage/index.module.scss | 147 +++++++ .../StepSendMessage/index.module.scss | 265 +++++++++++++ .../components/StepSendMessage/index.tsx | 6 + .../ContentLibrarySelector.tsx | 34 ++ .../InputMessage/InputMessage.tsx | 251 ++++++++++++ .../InputMessage/index.module.scss | 143 +++++++ .../StepSendMessage/index.module.scss | 375 ++++++++++++++++++ .../components/StepSendMessage/index.tsx | 312 ++++++++++++--- .../create-push-task/index.module.scss | 245 ------------ .../create-push-task/index.tsx | 44 +- .../create-push-task/types.ts | 6 + 12 files changed, 1526 insertions(+), 302 deletions(-) create mode 100644 Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx create mode 100644 Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss create mode 100644 Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss create mode 100644 Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/ContentLibrarySelector.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss new file mode 100644 index 00000000..b8085ce9 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss @@ -0,0 +1,147 @@ +.chatFooter { + background: #f7f7f7; + border-top: 1px solid #e1e1e1; + padding: 0; + height: auto; + border-radius: 8px; +} + +.inputContainer { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.inputToolbar { + display: flex; + align-items: center; + padding: 4px 0; +} + +.leftTool { + display: flex; + gap: 4px; + align-items: center; +} + +.toolbarButton { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: #666; + font-size: 16px; + transition: all 0.15s; + border: none; + background: transparent; + + &:hover { + background: #e6e6e6; + color: #333; + } + + &:active { + background: #d9d9d9; + } +} + +.inputArea { + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.inputWrapper { + border: 1px solid #d1d1d1; + border-radius: 4px; + background: #fff; + overflow: hidden; + + &:focus-within { + border-color: #07c160; + } +} + +.messageInput { + width: 100%; + border: none; + resize: none; + font-size: 13px; + line-height: 1.4; + padding: 8px 10px; + background: transparent; + + &:focus { + box-shadow: none; + outline: none; + } + + &::placeholder { + color: #b3b3b3; + } +} + +.sendButtonArea { + padding: 8px 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.sendButton { + height: 32px; + border-radius: 4px; + font-weight: normal; + min-width: 60px; + font-size: 13px; + background: #07c160; + border-color: #07c160; + + &:hover { + background: #06ad56; + border-color: #06ad56; + } + + &:active { + background: #059748; + border-color: #059748; + } + + &:disabled { + background: #b3b3b3; + border-color: #b3b3b3; + opacity: 1; + } +} + +.hintButton { + border: none; + background: transparent; + color: #666; + font-size: 12px; + + &:hover { + color: #333; + } +} + +.inputHint { + font-size: 11px; + color: #999; + text-align: right; + margin-top: 2px; +} + +@media (max-width: 768px) { + .inputToolbar { + flex-wrap: wrap; + gap: 8px; + } + + .sendButtonArea { + justify-content: space-between; + } +} diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss new file mode 100644 index 00000000..fee50c0a --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -0,0 +1,265 @@ +.stepContent { + .stepHeader { + margin-bottom: 20px; + + h3 { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 8px 0; + } + + p { + font-size: 14px; + color: #666; + margin: 0; + } + } +} + +.step3Content { + display: flex; + gap: 24px; + align-items: flex-start; + + .leftColumn { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .rightColumn { + width: 400px; + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .messagePreview { + border: 2px dashed #52c41a; + border-radius: 8px; + padding: 20px; + background: #f6ffed; + + .previewTitle { + font-size: 14px; + color: #52c41a; + font-weight: 500; + margin-bottom: 12px; + } + + .messageBubble { + min-height: 60px; + padding: 12px; + background: #fff; + border-radius: 6px; + color: #666; + font-size: 14px; + line-height: 1.6; + + .currentEditingLabel { + font-size: 12px; + color: #999; + margin-bottom: 8px; + } + + .messageText { + color: #333; + white-space: pre-wrap; + word-break: break-word; + } + } + } + + .savedScriptGroups { + .scriptGroupTitle { + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 12px; + } + + .scriptGroupItem { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + background: #fff; + + .scriptGroupHeader { + display: flex; + justify-content: space-between; + align-items: center; + + .scriptGroupLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + :global(.ant-radio) { + margin-right: 4px; + } + + .scriptGroupName { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .messageCount { + font-size: 12px; + color: #999; + margin-left: 8px; + } + } + + .scriptGroupActions { + display: flex; + gap: 4px; + + .actionButton { + padding: 4px; + color: #666; + + &:hover { + color: #1890ff; + } + } + } + } + + .scriptGroupContent { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f0f0f0; + font-size: 13px; + color: #666; + } + } + } + + .messageInputArea { + .messageInput { + margin-bottom: 12px; + } + + .attachmentButtons { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .aiRewriteSection { + display: flex; + align-items: center; + margin-bottom: 8px; + } + + .messageHint { + font-size: 12px; + color: #999; + } + } + + .settingsPanel { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #fafafa; + + .settingsTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 16px; + } + + .settingItem { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + .settingControl { + display: flex; + align-items: center; + gap: 8px; + + span { + font-size: 14px; + color: #666; + min-width: 80px; + } + } + } + } + + .tagSection { + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + } + + .pushPreview { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #f0f7ff; + + .previewTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: 14px; + color: #666; + line-height: 1.8; + } + } + } +} + +@media (max-width: 1200px) { + .step3Content { + .rightColumn { + width: 350px; + } + } +} + +@media (max-width: 768px) { + .step3Content { + flex-direction: column; + + .leftColumn { + width: 100%; + } + + .rightColumn { + width: 100%; + } + } +} + diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx new file mode 100644 index 00000000..082831d5 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -0,0 +1,6 @@ +import ContentSelection from "@/components/ContentSelection"; +import { ContentItem } from "@/components/ContentSelection/data"; +import InputMessage from "./InputMessage/InputMessage"; +import styles from "./index.module.scss"; + +interface StepSendMessageProps { diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/ContentLibrarySelector.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/ContentLibrarySelector.tsx new file mode 100644 index 00000000..574ec9dd --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/ContentLibrarySelector.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import ContentSelection from "@/components/ContentSelection"; +import type { ContentItem } from "@/components/ContentSelection/data"; +import styles from "./index.module.scss"; + +interface ContentLibrarySelectorProps { + selectedContentLibraries: ContentItem[]; + onSelectedContentLibrariesChange: (selectedItems: ContentItem[]) => void; +} + +const ContentLibrarySelector: React.FC<ContentLibrarySelectorProps> = ({ + selectedContentLibraries, + onSelectedContentLibrariesChange, +}) => { + return ( + <div className={styles.contentLibrarySelector}> + <div className={styles.contentLibraryHeader}> + <div className={styles.contentLibraryTitle}>内容库选择</div> + <div className={styles.contentLibraryHint}> + 选择内容库可快速引用现有话术 + </div> + </div> + <ContentSelection + selectedOptions={selectedContentLibraries} + onSelect={onSelectedContentLibrariesChange} + onConfirm={onSelectedContentLibrariesChange} + placeholder="请选择内容库" + selectedListMaxHeight={200} + /> + </div> + ); +}; + +export default ContentLibrarySelector; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx new file mode 100644 index 00000000..baadd6b4 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx @@ -0,0 +1,251 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Button, Input, message as antdMessage } from "antd"; +import { FolderOutlined, PictureOutlined } from "@ant-design/icons"; + +import { EmojiPicker } from "@/components/EmojiSeclection"; +import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji"; +import SimpleFileUpload from "@/components/Upload/SimpleFileUpload"; +import AudioRecorder from "@/components/Upload/AudioRecorder"; + +import styles from "./index.module.scss"; + +const { TextArea } = Input; + +type FileTypeValue = 1 | 2 | 3 | 4 | 5; + +interface InputMessageProps { + defaultValue?: string; + onContentChange?: (value: string) => void; + onSend?: (value: string) => void; + clearOnSend?: boolean; + placeholder?: string; + hint?: React.ReactNode; +} + +const FileType: Record<string, FileTypeValue> = { + TEXT: 1, + IMAGE: 2, + VIDEO: 3, + AUDIO: 4, + FILE: 5, +}; + +const getMsgTypeByFileFormat = (filePath: string): number => { + const extension = filePath.toLowerCase().split(".").pop() || ""; + const imageFormats = [ + "jpg", + "jpeg", + "png", + "gif", + "bmp", + "webp", + "svg", + "ico", + ]; + if (imageFormats.includes(extension)) { + return 3; + } + + const videoFormats = [ + "mp4", + "avi", + "mov", + "wmv", + "flv", + "mkv", + "webm", + "3gp", + "rmvb", + ]; + if (videoFormats.includes(extension)) { + return 43; + } + + return 49; +}; + +const InputMessage: React.FC<InputMessageProps> = ({ + defaultValue = "", + onContentChange, + onSend, + clearOnSend = false, + placeholder = "输入消息...", + hint, +}) => { + const [inputValue, setInputValue] = useState(defaultValue); + + useEffect(() => { + if (defaultValue !== inputValue) { + setInputValue(defaultValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValue]); + + useEffect(() => { + onContentChange?.(inputValue); + }, [inputValue, onContentChange]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setInputValue(e.target.value); + }, + [], + ); + + const handleSend = useCallback(() => { + const content = inputValue.trim(); + if (!content) { + return; + } + onSend?.(content); + if (clearOnSend) { + setInputValue(""); + } + antdMessage.success("已添加消息内容"); + }, [clearOnSend, inputValue, onSend]); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (e.key !== "Enter") { + return; + } + + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const target = e.currentTarget; + const { selectionStart, selectionEnd, value } = target; + const nextValue = + value.slice(0, selectionStart) + "\n" + value.slice(selectionEnd); + setInputValue(nextValue); + requestAnimationFrame(() => { + const cursorPosition = selectionStart + 1; + target.selectionStart = cursorPosition; + target.selectionEnd = cursorPosition; + }); + return; + } + + if (!e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleEmojiSelect = useCallback((emoji: EmojiInfo) => { + setInputValue(prev => prev + `[${emoji.name}]`); + }, []); + + const handleFileUploaded = useCallback( + ( + filePath: { url: string; name: string; durationMs?: number }, + fileType: FileTypeValue, + ) => { + let msgType = 1; + let content: string | Record<string, unknown> = filePath.url; + if ([FileType.TEXT].includes(fileType)) { + msgType = getMsgTypeByFileFormat(filePath.url); + } else if ([FileType.IMAGE].includes(fileType)) { + msgType = 3; + } else if ([FileType.AUDIO].includes(fileType)) { + msgType = 34; + content = JSON.stringify({ + url: filePath.url, + durationMs: filePath.durationMs, + }); + } else if ([FileType.FILE].includes(fileType)) { + msgType = getMsgTypeByFileFormat(filePath.url); + if (msgType === 49) { + content = JSON.stringify({ + type: "file", + title: filePath.name, + url: filePath.url, + }); + } + } + + console.log("模拟上传内容: ", { + msgType, + content, + }); + antdMessage.success("附件上传成功,可在推送时使用"); + }, + [], + ); + + const handleAudioUploaded = useCallback( + (audioData: { name: string; url: string; durationMs?: number }) => { + handleFileUploaded( + { + name: audioData.name, + url: audioData.url, + durationMs: audioData.durationMs, + }, + FileType.AUDIO, + ); + }, + [handleFileUploaded], + ); + + return ( + <div className={styles.chatFooter}> + <div className={styles.inputContainer}> + <div className={styles.inputToolbar}> + <div className={styles.leftTool}> + <EmojiPicker onEmojiSelect={handleEmojiSelect} /> + + <SimpleFileUpload + onFileUploaded={fileInfo => + handleFileUploaded(fileInfo, FileType.IMAGE) + } + maxSize={10} + type={1} + slot={ + <Button + className={styles.toolbarButton} + type="text" + icon={<PictureOutlined />} + /> + } + /> + <SimpleFileUpload + onFileUploaded={fileInfo => + handleFileUploaded(fileInfo, FileType.FILE) + } + maxSize={20} + type={4} + slot={ + <Button + className={styles.toolbarButton} + type="text" + icon={<FolderOutlined />} + /> + } + /> + <AudioRecorder + onAudioUploaded={handleAudioUploaded} + className={styles.toolbarButton} + /> + </div> + </div> + + <div className={styles.inputArea}> + <div className={styles.inputWrapper}> + <TextArea + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyPress} + placeholder={placeholder} + className={styles.messageInput} + autoSize={{ minRows: 3, maxRows: 6 }} + /> + </div> + {hint && <div className={styles.inputHint}>{hint}</div>} + </div> + </div> + </div> + ); +}; + +export default InputMessage; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss new file mode 100644 index 00000000..75fb1288 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss @@ -0,0 +1,143 @@ +.chatFooter { + height: auto; + border-radius: 8px; +} + +.inputContainer { + display: flex; + flex-direction: column; + gap: 6px; +} + +.inputToolbar { + display: flex; + align-items: center; + padding: 4px 0; +} + +.leftTool { + display: flex; + gap: 4px; + align-items: center; +} + +.toolbarButton { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: #666; + font-size: 16px; + transition: all 0.15s; + border: none; + background: transparent; + + &:hover { + background: #e6e6e6; + color: #333; + } + + &:active { + background: #d9d9d9; + } +} + +.inputArea { + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.inputWrapper { + border: 1px solid #d1d1d1; + border-radius: 4px; + background: #fff; + overflow: hidden; + + &:focus-within { + border-color: #07c160; + } +} + +.messageInput { + width: 100%; + border: none; + resize: none; + font-size: 13px; + line-height: 1.4; + padding: 8px 10px; + background: transparent; + + &:focus { + box-shadow: none; + outline: none; + } + + &::placeholder { + color: #b3b3b3; + } +} + +.sendButtonArea { + padding: 8px 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.sendButton { + height: 32px; + border-radius: 4px; + font-weight: normal; + min-width: 60px; + font-size: 13px; + background: #07c160; + border-color: #07c160; + + &:hover { + background: #06ad56; + border-color: #06ad56; + } + + &:active { + background: #059748; + border-color: #059748; + } + + &:disabled { + background: #b3b3b3; + border-color: #b3b3b3; + opacity: 1; + } +} + +.hintButton { + border: none; + background: transparent; + color: #666; + font-size: 12px; + + &:hover { + color: #333; + } +} + +.inputHint { + font-size: 11px; + color: #999; + text-align: right; + margin-top: 2px; +} + +@media (max-width: 768px) { + .inputToolbar { + flex-wrap: wrap; + gap: 8px; + } + + .sendButtonArea { + justify-content: space-between; + } +} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss new file mode 100644 index 00000000..191204f6 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -0,0 +1,375 @@ +.stepContent { + .stepHeader { + margin-bottom: 20px; + + h3 { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 8px 0; + } + + p { + font-size: 14px; + color: #666; + margin: 0; + } + } +} + +.step3Content { + display: flex; + gap: 24px; + align-items: flex-start; + + .leftColumn { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .rightColumn { + width: 400px; + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .previewHeader { + display: flex; + justify-content: space-between; + + .previewHeaderTitle { + font-size: 16px; + font-weight: 600; + color: #1a1a1a; + } + } + + .messagePreview { + border: 2px dashed #52c41a; + border-radius: 8px; + padding: 15px; + + .messageBubble { + min-height: 100px; + background: #fff; + border-radius: 6px; + color: #666; + font-size: 14px; + line-height: 1.6; + + .currentEditingLabel { + font-size: 14px; + color: #52c41a; + font-weight: bold; + margin-bottom: 12px; + } + + .messageText { + color: #1a1a1a; + white-space: pre-wrap; + word-break: break-word; + } + + .messagePlaceholder { + color: #999; + font-size: 14px; + } + + .messageList { + display: flex; + flex-direction: column; + gap: 0; + } + + .messageItem { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } + + .messageText { + flex: 1; + } + + .messageAction { + color: #ff4d4f; + padding: 0; + } + } + } + + .scriptNameInput { + margin-top: 12px; + } + } + + .savedScriptGroups { + .contentLibrarySelector { + margin-bottom: 20px; + padding: 16px; + background: #fff; + border: 1px solid #e8e8e8; + border-radius: 8px; + } + + .contentLibraryHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .contentLibraryTitle { + font-size: 14px; + font-weight: 600; + color: #1a1a1a; + } + + .contentLibraryHint { + font-size: 12px; + color: #999; + } + + .scriptGroupHeaderRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .scriptGroupTitle { + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 12px; + } + + .scriptGroupHint { + font-size: 12px; + color: #999; + } + + .scriptGroupList { + max-height: 260px; + overflow-y: auto; + } + + .emptyGroup { + padding: 24px; + text-align: center; + color: #999; + background: #fff; + border: 1px dashed #d9d9d9; + border-radius: 8px; + } + + .scriptGroupItem { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + background: #fff; + + .scriptGroupHeader { + display: flex; + justify-content: space-between; + align-items: center; + + .scriptGroupLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + :global(.ant-checkbox) { + margin-right: 4px; + } + + .scriptGroupInfo { + display: flex; + flex-direction: column; + } + + .scriptGroupName { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .messageCount { + font-size: 12px; + color: #999; + margin-left: 8px; + } + } + + .scriptGroupActions { + display: flex; + gap: 4px; + + .actionButton { + padding: 4px; + color: #666; + + &:hover { + color: #1890ff; + } + } + } + } + + .scriptGroupContent { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f0f0f0; + font-size: 13px; + color: #666; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .messageInputArea { + .messageInput { + margin-bottom: 12px; + } + + .attachmentButtons { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .aiRewriteSection { + display: flex; + align-items: center; + margin-bottom: 8px; + gap: 12px; + + .aiRewriteToggle { + display: flex; + align-items: center; + gap: 8px; + } + + .aiRewriteLabel { + font-size: 14px; + color: #1a1a1a; + } + + .aiRewriteInput { + max-width: 240px; + } + } + } + + .settingsPanel { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #fafafa; + + .settingsTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 16px; + } + + .settingItem { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + .settingControl { + display: flex; + align-items: center; + gap: 8px; + + span { + font-size: 14px; + color: #666; + min-width: 80px; + } + } + } + } + + .tagSection { + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + } + + .pushPreview { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #f0f7ff; + + .previewTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: 14px; + color: #666; + line-height: 1.8; + } + } + } +} + +@media (max-width: 1200px) { + .step3Content { + .rightColumn { + width: 350px; + } + } +} + +@media (max-width: 768px) { + .step3Content { + flex-direction: column; + + .leftColumn { + width: 100%; + } + + .rightColumn { + width: 100%; + } + } +} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx index 86041eca..3a400aec 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -1,12 +1,23 @@ "use client"; -import React, { useState } from "react"; -import { Button, Input, Select, Slider, Switch } from "antd"; +import React, { useCallback } from "react"; +import { + Button, + Checkbox, + Input, + Select, + Slider, + Switch, + message as antdMessage, +} from "antd"; +import { CopyOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import type { CheckboxChangeEvent } from "antd/es/checkbox"; -import styles from "../../index.module.scss"; -import { ContactItem } from "../../types"; -import ContentSelection from "@/components/ContentSelection"; -import { ContentItem } from "@/components/ContentSelection/data"; +import styles from "./index.module.scss"; +import { ContactItem, ScriptGroup } from "../../types"; +import InputMessage from "./InputMessage/InputMessage"; +import ContentLibrarySelector from "./ContentLibrarySelector"; +import type { ContentItem } from "@/components/ContentSelection/data"; interface StepSendMessageProps { selectedAccounts: any[]; @@ -24,6 +35,16 @@ interface StepSendMessageProps { onAiRewriteToggle: (value: boolean) => void; aiPrompt: string; onAiPromptChange: (value: string) => void; + currentScriptMessages: string[]; + onCurrentScriptMessagesChange: (messages: string[]) => void; + currentScriptName: string; + onCurrentScriptNameChange: (value: string) => void; + savedScriptGroups: ScriptGroup[]; + onSavedScriptGroupsChange: (groups: ScriptGroup[]) => void; + selectedScriptGroupIds: string[]; + onSelectedScriptGroupIdsChange: (ids: string[]) => void; + selectedContentLibraries: ContentItem[]; + onSelectedContentLibrariesChange: (items: ContentItem[]) => void; } const StepSendMessage: React.FC<StepSendMessageProps> = ({ @@ -42,77 +63,268 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ onAiRewriteToggle, aiPrompt, onAiPromptChange, + currentScriptMessages, + onCurrentScriptMessagesChange, + currentScriptName, + onCurrentScriptNameChange, + savedScriptGroups, + onSavedScriptGroupsChange, + selectedScriptGroupIds, + onSelectedScriptGroupIdsChange, + selectedContentLibraries, + onSelectedContentLibrariesChange, }) => { - const [selectedContentLibraries, setSelectedContentLibraries] = useState< - ContentItem[] - >([]); + const handleAddMessage = useCallback( + (content?: string, showSuccess?: boolean) => { + const finalContent = (content ?? messageContent).trim(); + if (!finalContent) { + antdMessage.warning("请输入消息内容"); + return; + } + onCurrentScriptMessagesChange([...currentScriptMessages, finalContent]); + onMessageContentChange(""); + if (showSuccess) { + antdMessage.success("已添加消息内容"); + } + }, + [ + currentScriptMessages, + messageContent, + onCurrentScriptMessagesChange, + onMessageContentChange, + ], + ); + + const handleRemoveMessage = useCallback( + (index: number) => { + const next = currentScriptMessages.filter((_, idx) => idx !== index); + onCurrentScriptMessagesChange(next); + }, + [currentScriptMessages, onCurrentScriptMessagesChange], + ); + + const handleSaveScriptGroup = useCallback(() => { + if (currentScriptMessages.length === 0) { + antdMessage.warning("请先添加消息内容"); + return; + } + const groupName = + currentScriptName.trim() || `话术组${savedScriptGroups.length + 1}`; + const newGroup: ScriptGroup = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: groupName, + messages: currentScriptMessages, + }; + onSavedScriptGroupsChange([...savedScriptGroups, newGroup]); + onCurrentScriptMessagesChange([]); + onCurrentScriptNameChange(""); + onMessageContentChange(""); + antdMessage.success("已保存为话术组"); + }, [ + currentScriptMessages, + currentScriptName, + onCurrentScriptMessagesChange, + onCurrentScriptNameChange, + onMessageContentChange, + onSavedScriptGroupsChange, + savedScriptGroups, + ]); + + const handleApplyGroup = useCallback( + (group: ScriptGroup) => { + onCurrentScriptMessagesChange(group.messages); + onCurrentScriptNameChange(group.name); + onMessageContentChange(""); + antdMessage.success("已加载话术组"); + }, + [ + onCurrentScriptMessagesChange, + onCurrentScriptNameChange, + onMessageContentChange, + ], + ); + + const handleDeleteGroup = useCallback( + (groupId: string) => { + const nextGroups = savedScriptGroups.filter( + group => group.id !== groupId, + ); + onSavedScriptGroupsChange(nextGroups); + if (selectedScriptGroupIds.includes(groupId)) { + const nextSelected = selectedScriptGroupIds.filter( + id => id !== groupId, + ); + onSelectedScriptGroupIdsChange(nextSelected); + } + antdMessage.success("已删除话术组"); + }, + [ + onSavedScriptGroupsChange, + savedScriptGroups, + onSelectedScriptGroupIdsChange, + selectedScriptGroupIds, + ], + ); + + const handleSelectChange = useCallback( + (groupId: string) => (event: CheckboxChangeEvent) => { + const checked = event.target.checked; + if (checked) { + if (!selectedScriptGroupIds.includes(groupId)) { + onSelectedScriptGroupIdsChange([...selectedScriptGroupIds, groupId]); + } + } else { + onSelectedScriptGroupIdsChange( + selectedScriptGroupIds.filter(id => id !== groupId), + ); + } + }, + [onSelectedScriptGroupIdsChange, selectedScriptGroupIds], + ); return ( <div className={styles.stepContent}> <div className={styles.step3Content}> <div className={styles.leftColumn}> + <div className={styles.previewHeader}> + <div className={styles.previewHeaderTitle}>模拟推送内容</div> + <Button + type="primary" + icon={<PlusOutlined />} + onClick={handleSaveScriptGroup} + disabled={currentScriptMessages.length === 0} + > + 保存为话术组 + </Button> + </div> <div className={styles.messagePreview}> - <div className={styles.previewTitle}>模拟推送内容</div> <div className={styles.messageBubble}> <div className={styles.currentEditingLabel}>当前编辑话术</div> - <div className={styles.messageText}> - {messageContent || "开始添加消息内容..."} - </div> + {currentScriptMessages.length === 0 ? ( + <div className={styles.messagePlaceholder}> + 开始添加消息内容... + </div> + ) : ( + <div className={styles.messageList}> + {currentScriptMessages.map((msg, index) => ( + <div className={styles.messageItem} key={index}> + <div className={styles.messageText}>{msg}</div> + <Button + type="text" + danger + icon={<DeleteOutlined />} + onClick={() => handleRemoveMessage(index)} + className={styles.messageAction} + /> + </div> + ))} + </div> + )} + </div> + <div className={styles.scriptNameInput}> + <Input + placeholder="话术组名称(可选)" + value={currentScriptName} + onChange={event => + onCurrentScriptNameChange(event.target.value) + } + /> </div> </div> <div className={styles.savedScriptGroups}> - <div className={styles.scriptGroupTitle}>已保存话术组</div> - <ContentSelection - selectedOptions={selectedContentLibraries} - onSelect={setSelectedContentLibraries} - placeholder="选择话术内容" - showSelectedList - selectedListMaxHeight={220} + {/* 内容库选择组件 */} + <ContentLibrarySelector + selectedContentLibraries={selectedContentLibraries} + onSelectedContentLibrariesChange={ + onSelectedContentLibrariesChange + } /> + <div className={styles.scriptGroupHeaderRow}> + <div className={styles.scriptGroupTitle}> + 已保存话术组 ({savedScriptGroups.length}) + </div> + <div className={styles.scriptGroupHint}>勾选后将随机均分推送</div> + </div> + <div className={styles.scriptGroupList}> + {savedScriptGroups.length === 0 ? ( + <div className={styles.emptyGroup}>暂无已保存话术组</div> + ) : ( + savedScriptGroups.map((group, index) => ( + <div className={styles.scriptGroupItem} key={group.id}> + <div className={styles.scriptGroupHeader}> + <div className={styles.scriptGroupLeft}> + <Checkbox + checked={selectedScriptGroupIds.includes(group.id)} + onChange={handleSelectChange(group.id)} + /> + <div className={styles.scriptGroupInfo}> + <div className={styles.scriptGroupName}> + {group.name || `话术组${index + 1}`} + </div> + <div className={styles.messageCount}> + {group.messages.length}条消息 + </div> + </div> + </div> + <div className={styles.scriptGroupActions}> + <Button + type="text" + icon={<CopyOutlined />} + className={styles.actionButton} + onClick={() => handleApplyGroup(group)} + /> + <Button + type="text" + icon={<DeleteOutlined />} + className={styles.actionButton} + onClick={() => handleDeleteGroup(group.id)} + /> + </div> + </div> + <div className={styles.scriptGroupContent}> + {group.messages[0]} + {group.messages.length > 1 && " ..."} + </div> + </div> + )) + )} + </div> </div> <div className={styles.messageInputArea}> - <Input.TextArea - className={styles.messageInput} + <InputMessage + defaultValue={messageContent} + onContentChange={onMessageContentChange} + onSend={value => handleAddMessage(value)} + clearOnSend placeholder="请输入内容" - value={messageContent} - onChange={e => onMessageContentChange(e.target.value)} - rows={4} - onKeyDown={e => { - if (e.ctrlKey && e.key === "Enter") { - e.preventDefault(); - onMessageContentChange(`${messageContent}\n`); - } - }} + hint={`按住CTRL+ENTER换行,已配置${savedScriptGroups.length}个话术组,已选择${selectedScriptGroupIds.length}个进行推送,已选${selectedContentLibraries.length}个内容库`} /> - <div className={styles.attachmentButtons}> - <Button type="text" icon="😊" /> - <Button type="text" icon="🖼️" /> - <Button type="text" icon="📎" /> - <Button type="text" icon="🔗" /> - <Button type="text" icon="⭐" /> - </div> <div className={styles.aiRewriteSection}> - <Switch checked={aiRewriteEnabled} onChange={onAiRewriteToggle} /> - <span style={{ marginLeft: 8 }}>AI智能话术改写</span> + <div className={styles.aiRewriteToggle}> + <Switch + checked={aiRewriteEnabled} + onChange={onAiRewriteToggle} + /> + <span className={styles.aiRewriteLabel}>AI智能话术改写</span> + </div> {aiRewriteEnabled && ( <Input placeholder="输入改写提示词" value={aiPrompt} - onChange={e => onAiPromptChange(e.target.value)} - style={{ marginLeft: 12, width: 200 }} + onChange={event => onAiPromptChange(event.target.value)} + className={styles.aiRewriteInput} /> )} - <Button type="primary" style={{ marginLeft: 12 }}> - + 添加 + <Button + type="primary" + icon={<PlusOutlined />} + onClick={() => handleAddMessage(undefined, true)} + > + 添加 </Button> </div> - <div className={styles.messageHint}> - 按住CTRL+ENTER换行,已选择{selectedContentLibraries.length} - 个话术组,已选择{selectedContacts.length} - 个进行推送 - </div> </div> </div> @@ -170,7 +382,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ <li> 推送{targetLabel}: {selectedContacts.length}个 </li> - <li>话术组数: {selectedContentLibraries.length}个</li> + <li>话术组数: {savedScriptGroups.length}个</li> <li>随机推送: 否</li> <li>预计耗时: ~1分钟</li> </ul> diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss index dc036ef8..474dbde2 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.module.scss @@ -349,233 +349,6 @@ } } -.step3Content { - display: flex; - gap: 24px; - align-items: flex-start; - - // 左侧栏 - .leftColumn { - flex: 1; - display: flex; - flex-direction: column; - gap: 20px; - } - - // 右侧栏 - .rightColumn { - width: 400px; - flex: 1; - display: flex; - flex-direction: column; - gap: 20px; - } - - .messagePreview { - border: 2px dashed #52c41a; - border-radius: 8px; - padding: 20px; - background: #f6ffed; - - .previewTitle { - font-size: 14px; - color: #52c41a; - font-weight: 500; - margin-bottom: 12px; - } - - .messageBubble { - min-height: 60px; - padding: 12px; - background: #fff; - border-radius: 6px; - color: #666; - font-size: 14px; - line-height: 1.6; - - .currentEditingLabel { - font-size: 12px; - color: #999; - margin-bottom: 8px; - } - - .messageText { - color: #333; - white-space: pre-wrap; - word-break: break-word; - } - } - } - - // 已保存话术组 - .savedScriptGroups { - .scriptGroupTitle { - font-size: 14px; - font-weight: 500; - color: #333; - margin-bottom: 12px; - } - - .scriptGroupItem { - border: 1px solid #e8e8e8; - border-radius: 8px; - padding: 12px; - margin-bottom: 12px; - background: #fff; - - .scriptGroupHeader { - display: flex; - justify-content: space-between; - align-items: center; - - .scriptGroupLeft { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - - :global(.ant-radio) { - margin-right: 4px; - } - - .scriptGroupName { - font-size: 14px; - font-weight: 500; - color: #333; - } - - .messageCount { - font-size: 12px; - color: #999; - margin-left: 8px; - } - } - - .scriptGroupActions { - display: flex; - gap: 4px; - - .actionButton { - padding: 4px; - color: #666; - - &:hover { - color: #1890ff; - } - } - } - } - - .scriptGroupContent { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid #f0f0f0; - font-size: 13px; - color: #666; - } - } - } - - .messageInputArea { - .messageInput { - margin-bottom: 12px; - } - - .attachmentButtons { - display: flex; - gap: 8px; - margin-bottom: 12px; - } - - .aiRewriteSection { - display: flex; - align-items: center; - margin-bottom: 8px; - } - - .messageHint { - font-size: 12px; - color: #999; - } - } - - .settingsPanel { - border: 1px solid #e8e8e8; - border-radius: 8px; - padding: 20px; - background: #fafafa; - - .settingsTitle { - font-size: 14px; - font-weight: 500; - color: #1a1a1a; - margin-bottom: 16px; - } - - .settingItem { - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } - - .settingLabel { - font-size: 14px; - font-weight: 500; - color: #1a1a1a; - margin-bottom: 12px; - } - - .settingControl { - display: flex; - align-items: center; - gap: 8px; - - span { - font-size: 14px; - color: #666; - min-width: 80px; - } - } - } - } - - .tagSection { - .settingLabel { - font-size: 14px; - font-weight: 500; - color: #1a1a1a; - margin-bottom: 12px; - } - } - - .pushPreview { - border: 1px solid #e8e8e8; - border-radius: 8px; - padding: 20px; - background: #f0f7ff; - - .previewTitle { - font-size: 14px; - font-weight: 500; - color: #1a1a1a; - margin-bottom: 12px; - } - - ul { - list-style: none; - padding: 0; - margin: 0; - - li { - font-size: 14px; - color: #666; - line-height: 1.8; - } - } - } -} - .filterModal { :global(.ant-modal-body) { padding-bottom: 12px; @@ -695,12 +468,6 @@ min-height: 200px; } } - - .step3Content { - .rightColumn { - width: 350px; - } - } } } @@ -735,18 +502,6 @@ } } - .step3Content { - flex-direction: column; - - .leftColumn { - width: 100%; - } - - .rightColumn { - width: 100%; - } - } - .footer { padding: 12px 16px; flex-direction: column; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx index c09a1d53..620e7523 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx @@ -14,8 +14,9 @@ import { getCustomerList } from "@/pages/pc/ckbox/weChat/api"; import StepSelectAccount from "./components/StepSelectAccount"; import StepSelectContacts from "./components/StepSelectContacts"; import StepSendMessage from "./components/StepSendMessage"; -import { ContactItem, PushType } from "./types"; +import { ContactItem, PushType, ScriptGroup } from "./types"; import StepIndicator from "@/components/StepIndicator"; +import type { ContentItem } from "@/components/ContentSelection/data"; const CreatePushTask: React.FC = () => { const navigate = useNavigate(); @@ -31,7 +32,18 @@ const CreatePushTask: React.FC = () => { const [currentStep, setCurrentStep] = useState(1); const [selectedAccounts, setSelectedAccounts] = useState<any[]>([]); const [selectedContacts, setSelectedContacts] = useState<ContactItem[]>([]); - const [messageContent, setMessageContent] = useState(""); + const [messageDraft, setMessageDraft] = useState(""); + const [currentScriptMessages, setCurrentScriptMessages] = useState<string[]>( + [], + ); + const [currentScriptName, setCurrentScriptName] = useState(""); + const [savedScriptGroups, setSavedScriptGroups] = useState<ScriptGroup[]>([]); + const [selectedScriptGroupIds, setSelectedScriptGroupIds] = useState< + string[] + >([]); + const [selectedContentLibraries, setSelectedContentLibraries] = useState< + ContentItem[] + >([]); const [friendInterval, setFriendInterval] = useState(10); const [messageInterval, setMessageInterval] = useState(1); const [selectedTag, setSelectedTag] = useState<string>(""); @@ -120,8 +132,11 @@ const CreatePushTask: React.FC = () => { }; const handleSend = () => { - if (!messageContent.trim()) { - message.warning("请输入消息内容"); + const selectedGroups = savedScriptGroups.filter(group => + selectedScriptGroupIds.includes(group.id), + ); + if (currentScriptMessages.length === 0 && selectedGroups.length === 0) { + message.warning("请先添加话术内容或选择话术组"); return; } // TODO: 实现发送逻辑 @@ -129,12 +144,17 @@ const CreatePushTask: React.FC = () => { pushType: validPushType, accounts: selectedAccounts, contacts: selectedContacts, - messageContent, + currentScript: { + name: currentScriptName, + messages: currentScriptMessages, + }, + selectedScriptGroups: selectedGroups, friendInterval, messageInterval, selectedTag, aiRewriteEnabled, aiPrompt, + selectedContentLibraries, }); message.success("推送任务已创建"); navigate("/pc/powerCenter/message-push-assistant"); @@ -253,8 +273,18 @@ const CreatePushTask: React.FC = () => { selectedAccounts={selectedAccounts} selectedContacts={selectedContacts} targetLabel={step2Title} - messageContent={messageContent} - onMessageContentChange={setMessageContent} + messageContent={messageDraft} + onMessageContentChange={setMessageDraft} + currentScriptMessages={currentScriptMessages} + onCurrentScriptMessagesChange={setCurrentScriptMessages} + currentScriptName={currentScriptName} + onCurrentScriptNameChange={setCurrentScriptName} + savedScriptGroups={savedScriptGroups} + onSavedScriptGroupsChange={setSavedScriptGroups} + selectedScriptGroupIds={selectedScriptGroupIds} + onSelectedScriptGroupIdsChange={setSelectedScriptGroupIds} + selectedContentLibraries={selectedContentLibraries} + onSelectedContentLibrariesChange={setSelectedContentLibraries} friendInterval={friendInterval} onFriendIntervalChange={setFriendInterval} messageInterval={messageInterval} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts index 379bce7c..bd5eb0f1 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/types.ts @@ -19,3 +19,9 @@ export interface ContactItem { city?: string; extendFields?: Record<string, any>; } + +export interface ScriptGroup { + id: string; + name: string; + messages: string[]; +} From b1b68f4397162d7d1bf58fc69b09f51cfea6208b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 12 Nov 2025 11:40:05 +0800 Subject: [PATCH 07/26] Add FloatingVideoHelp component to AppRouter for enhanced user assistance. --- .../FloatingVideoHelp/VideoPlayer.module.scss | 128 ++++++++++++++++++ .../FloatingVideoHelp/VideoPlayer.tsx | 101 ++++++++++++++ .../FloatingVideoHelp/index.module.scss | 56 ++++++++ .../components/FloatingVideoHelp/index.tsx | 68 ++++++++++ .../FloatingVideoHelp/videoConfig.ts | 110 +++++++++++++++ Cunkebao/src/router/index.tsx | 2 + 6 files changed, 465 insertions(+) create mode 100644 Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss create mode 100644 Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx create mode 100644 Cunkebao/src/components/FloatingVideoHelp/index.module.scss create mode 100644 Cunkebao/src/components/FloatingVideoHelp/index.tsx create mode 100644 Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss new file mode 100644 index 00000000..308c00d5 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss @@ -0,0 +1,128 @@ +.modalMask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.3s ease; +} + +.videoContainer { + width: 100%; + max-width: 90vw; + max-height: 90vh; + background: #000; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + .title { + font-size: 16px; + font-weight: 600; + color: #fff; + } + + .closeButton { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &:active { + transform: scale(0.95); + } + + svg { + font-size: 16px; + } + } +} + +.videoWrapper { + width: 100%; + position: relative; + padding-top: 56.25%; // 16:9 比例 + background: #000; +} + +.video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + outline: none; +} + +// 动画 +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +// 移动端适配 +@media (max-width: 768px) { + .modalMask { + padding: 0; + } + + .videoContainer { + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + } + + .header { + padding: 10px 12px; + + .title { + font-size: 14px; + } + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx new file mode 100644 index 00000000..a26e824a --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx @@ -0,0 +1,101 @@ +import React, { useRef, useEffect } from "react"; +import { CloseOutlined } from "@ant-design/icons"; +import styles from "./VideoPlayer.module.scss"; + +interface VideoPlayerProps { + /** 视频URL */ + videoUrl: string; + /** 是否显示播放器 */ + visible: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 视频标题 */ + title?: string; +} + +const VideoPlayer: React.FC<VideoPlayerProps> = ({ + videoUrl, + visible, + onClose, + title = "操作视频", +}) => { + const videoRef = useRef<HTMLVideoElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (visible && videoRef.current) { + // 播放器打开时播放视频 + videoRef.current.play().catch(err => { + console.error("视频播放失败:", err); + }); + // 阻止背景滚动 + document.body.style.overflow = "hidden"; + } else if (videoRef.current) { + // 播放器关闭时暂停视频 + videoRef.current.pause(); + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [visible]); + + // 点击遮罩层关闭 + const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => { + // 如果点击的是遮罩层本身(不是视频容器),则关闭 + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + const handleClose = () => { + if (videoRef.current) { + videoRef.current.pause(); + } + onClose(); + }; + + // 阻止事件冒泡 + const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => { + e.stopPropagation(); + }; + + if (!visible) { + return null; + } + + return ( + <div + ref={containerRef} + className={styles.modalMask} + onClick={handleMaskClick} + > + <div className={styles.videoContainer} onClick={handleContentClick}> + <div className={styles.header}> + <span className={styles.title}>{title}</span> + <button className={styles.closeButton} onClick={handleClose}> + <CloseOutlined /> + </button> + </div> + <div className={styles.videoWrapper}> + <video + ref={videoRef} + src={videoUrl} + controls + className={styles.video} + playsInline + webkit-playsinline="true" + x5-playsinline="true" + x5-video-player-type="h5" + x5-video-player-fullscreen="true" + > + 您的浏览器不支持视频播放 + </video> + </div> + </div> + </div> + ); +}; + +export default VideoPlayer; diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.module.scss b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss new file mode 100644 index 00000000..0b35e258 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss @@ -0,0 +1,56 @@ +.floatingButton { + position: fixed; + right: 20px; + bottom: 80px; + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 9998; + transition: all 0.3s ease; + animation: float 3s ease-in-out infinite; + + &:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5); + } + + &:active { + transform: scale(0.95); + } + + .icon { + font-size: 28px; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + } + + // 移动端适配 + @media (max-width: 768px) { + right: 16px; + bottom: 70px; + width: 50px; + height: 50px; + + .icon { + font-size: 24px; + } + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.tsx b/Cunkebao/src/components/FloatingVideoHelp/index.tsx new file mode 100644 index 00000000..a85ad480 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.tsx @@ -0,0 +1,68 @@ +import React, { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { PlayCircleOutlined } from "@ant-design/icons"; +import VideoPlayer from "./VideoPlayer"; +import { getVideoUrlByRoute } from "./videoConfig"; +import styles from "./index.module.scss"; + +interface FloatingVideoHelpProps { + /** 是否显示悬浮窗,默认为 true */ + visible?: boolean; + /** 自定义样式类名 */ + className?: string; +} + +const FloatingVideoHelp: React.FC<FloatingVideoHelpProps> = ({ + visible = true, + className, +}) => { + const location = useLocation(); + const [showPlayer, setShowPlayer] = useState(false); + const [currentVideoUrl, setCurrentVideoUrl] = useState<string | null>(null); + + // 根据当前路由获取视频URL + useEffect(() => { + const videoUrl = getVideoUrlByRoute(location.pathname); + setCurrentVideoUrl(videoUrl); + }, [location.pathname]); + + const handleClick = () => { + if (currentVideoUrl) { + setShowPlayer(true); + } else { + // 如果没有对应的视频,可以显示提示 + console.warn("当前路由没有对应的操作视频"); + } + }; + + const handleClose = () => { + setShowPlayer(false); + }; + + // 如果没有视频URL,不显示悬浮窗 + if (!visible || !currentVideoUrl) { + return null; + } + + return ( + <> + <div + className={`${styles.floatingButton} ${className || ""}`} + onClick={handleClick} + title="查看操作视频" + > + <PlayCircleOutlined className={styles.icon} /> + </div> + + {showPlayer && currentVideoUrl && ( + <VideoPlayer + videoUrl={currentVideoUrl} + visible={showPlayer} + onClose={handleClose} + /> + )} + </> + ); +}; + +export default FloatingVideoHelp; diff --git a/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts new file mode 100644 index 00000000..35798056 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts @@ -0,0 +1,110 @@ +/** + * 路由到视频URL的映射配置 + * key: 路由路径(支持正则表达式) + * value: 视频URL + */ +interface VideoConfig { + [route: string]: string; +} + +// 视频URL配置 +const videoConfig: VideoConfig = { + // 首页 + "/": "/videos/home.mp4", + "/mobile/home": "/videos/home.mp4", + + // 工作台 + "/workspace": "/videos/workspace.mp4", + "/workspace/auto-like": "/videos/auto-like-list.mp4", + "/workspace/auto-like/new": "/videos/auto-like-new.mp4", + "/workspace/auto-like/record": "/videos/auto-like-record.mp4", + "/workspace/auto-group": "/videos/auto-group-list.mp4", + "/workspace/auto-group/new": "/videos/auto-group-new.mp4", + "/workspace/group-push": "/videos/group-push-list.mp4", + "/workspace/group-push/new": "/videos/group-push-new.mp4", + "/workspace/moments-sync": "/videos/moments-sync-list.mp4", + "/workspace/moments-sync/new": "/videos/moments-sync-new.mp4", + "/workspace/ai-assistant": "/videos/ai-assistant.mp4", + "/workspace/ai-analyzer": "/videos/ai-analyzer.mp4", + "/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4", + "/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4", + "/workspace/contact-import": "/videos/contact-import-list.mp4", + "/workspace/contact-import/form": "/videos/contact-import-form.mp4", + "/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4", + "/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4", + + // 我的 + "/mobile/mine": "/videos/mine.mp4", + "/mobile/mine/devices": "/videos/devices.mp4", + "/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4", + "/mobile/mine/content": "/videos/content.mp4", + "/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4", + "/mobile/mine/recharge": "/videos/recharge.mp4", + "/mobile/mine/setting": "/videos/setting.mp4", + + // 场景 + "/mobile/scenarios": "/videos/scenarios.mp4", + "/mobile/scenarios/plan": "/videos/scenarios-plan.mp4", +}; + +/** + * 根据路由路径获取对应的视频URL + * @param routePath 当前路由路径 + * @returns 视频URL,如果没有匹配则返回 null + */ +export function getVideoUrlByRoute(routePath: string): string | null { + // 精确匹配 + if (videoConfig[routePath]) { + return videoConfig[routePath]; + } + + // 模糊匹配(支持动态路由参数) + // 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id + const routeKeys = Object.keys(videoConfig); + for (const key of routeKeys) { + // 将配置中的 :id 等参数转换为正则表达式 + const regexPattern = key.replace(/:\w+/g, "[^/]+"); + const regex = new RegExp(`^${regexPattern}$`); + if (regex.test(routePath)) { + return videoConfig[key]; + } + } + + // 前缀匹配(作为兜底方案) + // 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like + const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列 + for (const key of sortedKeys) { + if (routePath.startsWith(key)) { + return videoConfig[key]; + } + } + + return null; +} + +/** + * 添加或更新视频配置 + * @param route 路由路径 + * @param videoUrl 视频URL + */ +export function setVideoConfig(route: string, videoUrl: string): void { + videoConfig[route] = videoUrl; +} + +/** + * 批量添加视频配置 + * @param config 视频配置对象 + */ +export function setVideoConfigs(config: VideoConfig): void { + Object.assign(videoConfig, config); +} + +/** + * 获取所有视频配置 + * @returns 视频配置对象 + */ +export function getAllVideoConfigs(): VideoConfig { + return { ...videoConfig }; +} + +export default videoConfig; diff --git a/Cunkebao/src/router/index.tsx b/Cunkebao/src/router/index.tsx index 117681a7..d52ba041 100644 --- a/Cunkebao/src/router/index.tsx +++ b/Cunkebao/src/router/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom"; import PermissionRoute from "./permissionRoute"; +import FloatingVideoHelp from "@/components/FloatingVideoHelp"; // 动态导入所有 module 下的 ts/tsx 路由模块 const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true }); @@ -43,6 +44,7 @@ const AppRouter: React.FC = () => ( }} > <AppRoutes /> + <FloatingVideoHelp /> </BrowserRouter> ); From f28f9049d5bb9a2c7e810048492bce591b0baeb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 12 Nov 2025 12:21:04 +0800 Subject: [PATCH 08/26] =?UTF-8?q?=E9=87=8D=E6=9E=84PushTaskModal=E5=92=8CP?= =?UTF-8?q?ushTask=E7=BB=84=E4=BB=B6=E4=BB=A5=E5=AE=9E=E7=8E=B0=E5=A5=BD?= =?UTF-8?q?=E5=8F=8B=E5=92=8C=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E7=9A=84?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=97=B4=E9=9A=94=E3=80=82=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E9=97=B4=E9=9A=94=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E8=81=94=E7=B3=BB=E6=AD=A5=E9=AA=A4=E4=B8=AD?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=81=E9=87=8F=E6=B1=A0=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E3=80=82=E6=9B=B4=E6=96=B0=E6=A0=B7=E5=BC=8F=E4=BB=A5=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=B8=83=E5=B1=80=E5=92=8C=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PushTaskModal.tsx | 62 ++-- .../create-push-task/api.ts | 6 + .../components/StepSelectContacts/index.tsx | 13 + .../components/StepSendMessage/api.ts | 31 ++ .../StepSendMessage/index.module.scss | 1 + .../components/StepSendMessage/index.tsx | 168 ++++++++--- .../create-push-task/index.tsx | 274 ++++++++++++++++-- .../create-push-task/提示词.txt | 79 +++++ Touchkebao/src/utils/dbAction/message.ts | 39 ++- 9 files changed, 574 insertions(+), 99 deletions(-) create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/api.ts create mode 100644 Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/提示词.txt diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx index 18ef6ce4..74cec1e1 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx @@ -23,12 +23,16 @@ import { SendOutlined, } from "@ant-design/icons"; import styles from "./PushTaskModal.module.scss"; -import { - useCustomerStore, -} from "@/store/module/weChat/customer"; +import { useCustomerStore } from "@/store/module/weChat/customer"; import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; -export type PushType = "friend-message" | "group-message" | "group-announcement"; +const DEFAULT_FRIEND_INTERVAL: [number, number] = [3, 10]; +const DEFAULT_MESSAGE_INTERVAL: [number, number] = [1, 3]; + +export type PushType = + | "friend-message" + | "group-message" + | "group-announcement"; interface PushTaskModalProps { visible: boolean; @@ -67,8 +71,12 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ const [selectedAccounts, setSelectedAccounts] = useState<any[]>([]); const [selectedContacts, setSelectedContacts] = useState<ContactItem[]>([]); const [messageContent, setMessageContent] = useState(""); - const [friendInterval, setFriendInterval] = useState(10); - const [messageInterval, setMessageInterval] = useState(1); + const [friendInterval, setFriendInterval] = useState<[number, number]>([ + ...DEFAULT_FRIEND_INTERVAL, + ]); + const [messageInterval, setMessageInterval] = useState<[number, number]>([ + ...DEFAULT_MESSAGE_INTERVAL, + ]); const [selectedTag, setSelectedTag] = useState<string>(""); const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); @@ -120,8 +128,8 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ setSelectedAccounts([]); setSelectedContacts([]); setMessageContent(""); - setFriendInterval(10); - setMessageInterval(1); + setFriendInterval([...DEFAULT_FRIEND_INTERVAL]); + setMessageInterval([...DEFAULT_MESSAGE_INTERVAL]); setSelectedTag(""); setAiRewriteEnabled(false); setAiPrompt(""); @@ -270,7 +278,9 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ setCurrentStep(2); } else if (currentStep === 2) { if (selectedContacts.length === 0) { - message.warning(`请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`); + message.warning( + `请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`, + ); return; } setCurrentStep(3); @@ -343,7 +353,9 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ <div className={styles.accountCards}> {filteredAccounts.length > 0 ? ( filteredAccounts.map(account => { - const isSelected = selectedAccounts.some(a => a.id === account.id); + const isSelected = selectedAccounts.some( + a => a.id === account.id, + ); return ( <div key={account.id} @@ -355,7 +367,8 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ size={48} style={{ backgroundColor: "#1890ff" }} > - {!account.avatar && (account.nickname || account.name || "").charAt(0)} + {!account.avatar && + (account.nickname || account.name || "").charAt(0)} </Avatar> <div className={styles.cardName}> {account.nickname || account.name || "未知"} @@ -570,10 +583,7 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ <Button type="text" icon="⭐" /> </div> <div className={styles.aiRewriteSection}> - <Switch - checked={aiRewriteEnabled} - onChange={setAiRewriteEnabled} - /> + <Switch checked={aiRewriteEnabled} onChange={setAiRewriteEnabled} /> <span style={{ marginLeft: 8 }}>AI智能话术改写</span> {aiRewriteEnabled && ( <Input @@ -598,13 +608,16 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ <div className={styles.settingControl}> <span>间隔时间(秒)</span> <Slider - min={10} - max={20} + range + min={1} + max={60} value={friendInterval} - onChange={setFriendInterval} + onChange={value => setFriendInterval(value as [number, number])} style={{ flex: 1, margin: "0 16px" }} /> - <span>{friendInterval} - 20</span> + <span> + {friendInterval[0]} - {friendInterval[1]} + </span> </div> </div> <div className={styles.settingItem}> @@ -612,13 +625,18 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ <div className={styles.settingControl}> <span>间隔时间(秒)</span> <Slider + range min={1} - max={12} + max={60} value={messageInterval} - onChange={setMessageInterval} + onChange={value => + setMessageInterval(value as [number, number]) + } style={{ flex: 1, margin: "0 16px" }} /> - <span>{messageInterval} - 12</span> + <span> + {messageInterval[0]} - {messageInterval[1]} + </span> </div> </div> <div className={styles.settingItem}> diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts new file mode 100644 index 00000000..34fa4bb4 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts @@ -0,0 +1,6 @@ +import request from "@/api/request"; + +// 获取客服列表 +export function queryWorkbenchCreate(params) { + return request("/v1/workbench/create", params, "POST"); +} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx index 8de9fce1..93bc0d31 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx @@ -24,6 +24,8 @@ import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; import styles from "../../index.module.scss"; import { ContactItem, PushType } from "../../types"; +import PoolSelection from "@/components/PoolSelection"; +import type { PoolSelectionItem } from "@/components/PoolSelection/data"; interface ContactFilterValues { includeTags: string[]; @@ -61,6 +63,8 @@ interface StepSelectContactsProps { selectedAccounts: any[]; selectedContacts: ContactItem[]; onChange: (contacts: ContactItem[]) => void; + selectedTrafficPools: PoolSelectionItem[]; + onTrafficPoolsChange: (pools: PoolSelectionItem[]) => void; } const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ @@ -68,6 +72,8 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ selectedAccounts, selectedContacts, onChange, + selectedTrafficPools, + onTrafficPoolsChange, }) => { const [contactsData, setContactsData] = useState<ContactItem[]>([]); const [loadingContacts, setLoadingContacts] = useState(false); @@ -415,6 +421,13 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({ allowClear /> </div> + <PoolSelection + selectedOptions={selectedTrafficPools} + onSelect={onTrafficPoolsChange} + placeholder="选择流量池包" + showSelectedList + selectedListMaxHeight={200} + /> <div className={styles.contentBody}> <div className={styles.contactList}> <div className={styles.listHeader}> diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/api.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/api.ts new file mode 100644 index 00000000..f12513c0 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/api.ts @@ -0,0 +1,31 @@ +import request from "@/api/request"; +// 创建内容库参数 +export interface CreateContentLibraryParams { + name: string; + sourceType: number; + sourceFriends?: string[]; + sourceGroups?: string[]; + keywordInclude?: string[]; + keywordExclude?: string[]; + aiPrompt?: string; + timeEnabled?: number; + timeStart?: string; + timeEnd?: string; +} + +// 创建内容库 +export function createContentLibrary( + params: CreateContentLibraryParams, +): Promise<any> { + return request("/v1/content/library/create", params, "POST"); +} + +// 删除内容库 +export function deleteContentLibrary(params: { id: number }) { + return request(`/v1/content/library/update`, params, "DELETE"); +} + +// 智能话术改写 +export function aiEditContent(params: { aiPrompt: string; content: string }) { + return request(`/v1/content/library/aiEditContent`, params, "GET"); +} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss index 191204f6..12ee6679 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -254,6 +254,7 @@ .aiRewriteSection { display: flex; + justify-content: space-between; align-items: center; margin-bottom: 8px; gap: 12px; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx index 3a400aec..8a19ad74 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import { Button, Checkbox, @@ -18,6 +18,11 @@ import { ContactItem, ScriptGroup } from "../../types"; import InputMessage from "./InputMessage/InputMessage"; import ContentLibrarySelector from "./ContentLibrarySelector"; import type { ContentItem } from "@/components/ContentSelection/data"; +import { + createContentLibrary, + deleteContentLibrary, + type CreateContentLibraryParams, +} from "./api"; interface StepSendMessageProps { selectedAccounts: any[]; @@ -25,10 +30,10 @@ interface StepSendMessageProps { targetLabel: string; messageContent: string; onMessageContentChange: (value: string) => void; - friendInterval: number; - onFriendIntervalChange: (value: number) => void; - messageInterval: number; - onMessageIntervalChange: (value: number) => void; + friendInterval: [number, number]; + onFriendIntervalChange: (value: [number, number]) => void; + messageInterval: [number, number]; + onMessageIntervalChange: (value: [number, number]) => void; selectedTag: string; onSelectedTagChange: (value: string) => void; aiRewriteEnabled: boolean; @@ -74,6 +79,9 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ selectedContentLibraries, onSelectedContentLibrariesChange, }) => { + const [savingScriptGroup, setSavingScriptGroup] = useState(false); + const [deletingGroupIds, setDeletingGroupIds] = useState<string[]>([]); + const handleAddMessage = useCallback( (content?: string, showSuccess?: boolean) => { const finalContent = (content ?? messageContent).trim(); @@ -103,24 +111,57 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ [currentScriptMessages, onCurrentScriptMessagesChange], ); - const handleSaveScriptGroup = useCallback(() => { + const handleSaveScriptGroup = useCallback(async () => { + if (savingScriptGroup) { + return; + } if (currentScriptMessages.length === 0) { antdMessage.warning("请先添加消息内容"); return; } const groupName = currentScriptName.trim() || `话术组${savedScriptGroups.length + 1}`; - const newGroup: ScriptGroup = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + const messages = [...currentScriptMessages]; + const params: CreateContentLibraryParams = { name: groupName, - messages: currentScriptMessages, + sourceType: 1, + keywordInclude: messages, }; - onSavedScriptGroupsChange([...savedScriptGroups, newGroup]); - onCurrentScriptMessagesChange([]); - onCurrentScriptNameChange(""); - onMessageContentChange(""); - antdMessage.success("已保存为话术组"); + const trimmedPrompt = aiPrompt.trim(); + if (aiRewriteEnabled && trimmedPrompt) { + params.aiPrompt = trimmedPrompt; + } + let hideLoading: ReturnType<typeof antdMessage.loading> | undefined; + try { + setSavingScriptGroup(true); + hideLoading = antdMessage.loading("正在保存话术组...", 0); + const response = await createContentLibrary(params); + hideLoading?.(); + const responseId = + response?.id ?? response?.data?.id ?? response?.libraryId; + const newGroup: ScriptGroup = { + id: + responseId !== undefined + ? String(responseId) + : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: groupName, + messages, + }; + onSavedScriptGroupsChange([...savedScriptGroups, newGroup]); + onCurrentScriptMessagesChange([]); + onCurrentScriptNameChange(""); + onMessageContentChange(""); + antdMessage.success("已保存为话术组"); + } catch (error) { + hideLoading?.(); + console.error("保存话术组失败:", error); + antdMessage.error("保存失败,请稍后重试"); + } finally { + setSavingScriptGroup(false); + } }, [ + aiPrompt, + aiRewriteEnabled, currentScriptMessages, currentScriptName, onCurrentScriptMessagesChange, @@ -128,6 +169,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ onMessageContentChange, onSavedScriptGroupsChange, savedScriptGroups, + savingScriptGroup, ]); const handleApplyGroup = useCallback( @@ -145,23 +187,47 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ ); const handleDeleteGroup = useCallback( - (groupId: string) => { - const nextGroups = savedScriptGroups.filter( - group => group.id !== groupId, - ); - onSavedScriptGroupsChange(nextGroups); - if (selectedScriptGroupIds.includes(groupId)) { - const nextSelected = selectedScriptGroupIds.filter( - id => id !== groupId, - ); - onSelectedScriptGroupIdsChange(nextSelected); + async (groupId: string) => { + if (deletingGroupIds.includes(groupId)) { + return; + } + const numericGroupId = Number(groupId); + if (Number.isNaN(numericGroupId)) { + antdMessage.error("无法删除:缺少有效的内容库ID"); + return; + } + let hideLoading: ReturnType<typeof antdMessage.loading> | undefined; + try { + setDeletingGroupIds(prev => [...prev, groupId]); + hideLoading = antdMessage.loading("正在删除话术组...", 0); + await deleteContentLibrary({ id: numericGroupId }); + hideLoading?.(); + const nextGroups = savedScriptGroups.filter( + group => group.id !== groupId, + ); + onSavedScriptGroupsChange(nextGroups); + if (selectedScriptGroupIds.includes(groupId)) { + const nextSelected = selectedScriptGroupIds.filter( + id => id !== groupId, + ); + onSelectedScriptGroupIdsChange(nextSelected); + } + antdMessage.success("已删除话术组"); + } catch (error) { + hideLoading?.(); + console.error("删除话术组失败:", error); + antdMessage.error("删除失败,请稍后重试"); + } finally { + setDeletingGroupIds(prev => + prev.filter(deletingId => deletingId !== groupId), + ); } - antdMessage.success("已删除话术组"); }, [ + deletingGroupIds, onSavedScriptGroupsChange, - savedScriptGroups, onSelectedScriptGroupIdsChange, + savedScriptGroups, selectedScriptGroupIds, ], ); @@ -192,7 +258,8 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ type="primary" icon={<PlusOutlined />} onClick={handleSaveScriptGroup} - disabled={currentScriptMessages.length === 0} + disabled={currentScriptMessages.length === 0 || savingScriptGroup} + loading={savingScriptGroup} > 保存为话术组 </Button> @@ -279,6 +346,8 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ icon={<DeleteOutlined />} className={styles.actionButton} onClick={() => handleDeleteGroup(group.id)} + loading={deletingGroupIds.includes(group.id)} + disabled={deletingGroupIds.includes(group.id)} /> </div> </div> @@ -307,16 +376,19 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ checked={aiRewriteEnabled} onChange={onAiRewriteToggle} /> - <span className={styles.aiRewriteLabel}>AI智能话术改写</span> + <div className={styles.aiRewriteLabel}>AI智能话术改写</div> + <div> + {aiRewriteEnabled && ( + <Input + placeholder="输入改写提示词" + value={aiPrompt} + onChange={event => onAiPromptChange(event.target.value)} + className={styles.aiRewriteInput} + /> + )} + </div> </div> - {aiRewriteEnabled && ( - <Input - placeholder="输入改写提示词" - value={aiPrompt} - onChange={event => onAiPromptChange(event.target.value)} - className={styles.aiRewriteInput} - /> - )} + <Button type="primary" icon={<PlusOutlined />} @@ -336,13 +408,18 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ <div className={styles.settingControl}> <span>间隔时间(秒)</span> <Slider - min={10} - max={20} + range + min={1} + max={60} value={friendInterval} - onChange={value => onFriendIntervalChange(value as number)} + onChange={value => + onFriendIntervalChange(value as [number, number]) + } style={{ flex: 1, margin: "0 16px" }} /> - <span>{friendInterval} - 20</span> + <span> + {friendInterval[0]} - {friendInterval[1]} + </span> </div> </div> <div className={styles.settingItem}> @@ -350,13 +427,18 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ <div className={styles.settingControl}> <span>间隔时间(秒)</span> <Slider + range min={1} - max={12} + max={60} value={messageInterval} - onChange={value => onMessageIntervalChange(value as number)} + onChange={value => + onMessageIntervalChange(value as [number, number]) + } style={{ flex: 1, margin: "0 16px" }} /> - <span>{messageInterval} - 12</span> + <span> + {messageInterval[0]} - {messageInterval[1]} + </span> </div> </div> </div> diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx index 620e7523..d682b46c 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx @@ -17,6 +17,47 @@ import StepSendMessage from "./components/StepSendMessage"; import { ContactItem, PushType, ScriptGroup } from "./types"; import StepIndicator from "@/components/StepIndicator"; import type { ContentItem } from "@/components/ContentSelection/data"; +import type { PoolSelectionItem } from "@/components/PoolSelection/data"; +import { queryWorkbenchCreate } from "./api"; + +const DEFAULT_FRIEND_INTERVAL: [number, number] = [3, 10]; +const DEFAULT_MESSAGE_INTERVAL: [number, number] = [1, 3]; + +const DEFAULT_TIME_RANGE: Record< + PushType, + { startTime: string; endTime: string } +> = { + "friend-message": { startTime: "10:00", endTime: "22:00" }, + "group-message": { startTime: "09:00", endTime: "20:00" }, + "group-announcement": { startTime: "08:30", endTime: "18:30" }, +}; + +const DEFAULT_PUSH_ORDER: Record<PushType, 1 | 2> = { + "friend-message": 1, + "group-message": 1, + "group-announcement": 2, +}; + +const DEFAULT_MAX_PER_DAY: Record<PushType, number> = { + "friend-message": 150, + "group-message": 200, + "group-announcement": 80, +}; + +const DEFAULT_AUTO_START: Record<PushType, 0 | 1> = { + "friend-message": 1, + "group-message": 1, + "group-announcement": 0, +}; + +const DEFAULT_PUSH_TYPE: Record<PushType, 0 | 1> = { + "friend-message": 0, + "group-message": 0, + "group-announcement": 1, +}; + +const isValidNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); const CreatePushTask: React.FC = () => { const navigate = useNavigate(); @@ -44,11 +85,19 @@ const CreatePushTask: React.FC = () => { const [selectedContentLibraries, setSelectedContentLibraries] = useState< ContentItem[] >([]); - const [friendInterval, setFriendInterval] = useState(10); - const [messageInterval, setMessageInterval] = useState(1); + const [friendInterval, setFriendInterval] = useState<[number, number]>([ + ...DEFAULT_FRIEND_INTERVAL, + ]); + const [messageInterval, setMessageInterval] = useState<[number, number]>([ + ...DEFAULT_MESSAGE_INTERVAL, + ]); const [selectedTag, setSelectedTag] = useState<string>(""); const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); + const [creatingTask, setCreatingTask] = useState(false); + const [selectedTrafficPools, setSelectedTrafficPools] = useState< + PoolSelectionItem[] + >([]); const customerList = useCustomerStore(state => state.customerList); @@ -131,33 +180,206 @@ const CreatePushTask: React.FC = () => { setSelectedAccounts([]); }; - const handleSend = () => { + const handleSend = async () => { + if (creatingTask) { + return; + } const selectedGroups = savedScriptGroups.filter(group => selectedScriptGroupIds.includes(group.id), ); - if (currentScriptMessages.length === 0 && selectedGroups.length === 0) { - message.warning("请先添加话术内容或选择话术组"); + if ( + currentScriptMessages.length === 0 && + selectedGroups.length === 0 && + selectedContentLibraries.length === 0 + ) { + message.warning("请添加话术内容、选择话术组或内容库"); return; } - // TODO: 实现发送逻辑 - console.log("发送推送", { - pushType: validPushType, - accounts: selectedAccounts, - contacts: selectedContacts, - currentScript: { - name: currentScriptName, - messages: currentScriptMessages, - }, - selectedScriptGroups: selectedGroups, - friendInterval, - messageInterval, - selectedTag, - aiRewriteEnabled, - aiPrompt, - selectedContentLibraries, - }); - message.success("推送任务已创建"); - navigate("/pc/powerCenter/message-push-assistant"); + const manualMessages = currentScriptMessages + .map(item => item.trim()) + .filter(Boolean); + if (validPushType === "group-announcement" && manualMessages.length === 0) { + message.warning("请先填写公告内容"); + return; + } + const toNumberId = (value: unknown) => { + const numeric = Number(value); + return Number.isFinite(numeric) && !Number.isNaN(numeric) + ? numeric + : null; + }; + const contentGroupIds = Array.from( + new Set( + [ + ...selectedContentLibraries + .map(item => toNumberId(item?.id)) + .filter((id): id is number => id !== null), + ...selectedScriptGroupIds + .map(id => toNumberId(id)) + .filter((id): id is number => id !== null), + ].filter((id): id is number => id !== null), + ), + ); + if ( + manualMessages.length === 0 && + selectedGroups.length === 0 && + contentGroupIds.length === 0 + ) { + message.warning("缺少有效的话术内容,请重新检查"); + return; + } + const ownerWechatIds = Array.from( + new Set( + selectedAccounts + .map(account => toNumberId(account?.id)) + .filter((id): id is number => id !== null), + ), + ); + if (ownerWechatIds.length === 0) { + message.error("缺少有效的推送账号信息"); + return; + } + const selectedContactIds = Array.from( + new Set( + selectedContacts.map(contact => contact?.id).filter(isValidNumber), + ), + ); + if (selectedContactIds.length === 0) { + message.error("缺少有效的推送对象"); + return; + } + const friendIntervalMin = friendInterval[0]; + const friendIntervalMax = friendInterval[1]; + const messageIntervalMin = messageInterval[0]; + const messageIntervalMax = messageInterval[1]; + const trafficPoolIds = selectedTrafficPools + .map(pool => pool.id) + .filter( + id => id !== undefined && id !== null && String(id).trim() !== "", + ); + const { startTime, endTime } = DEFAULT_TIME_RANGE[validPushType]; + const maxPerDay = + selectedContacts.length > 0 + ? selectedContacts.length + : DEFAULT_MAX_PER_DAY[validPushType]; + const pushOrder = DEFAULT_PUSH_ORDER[validPushType]; + const normalizedPostPushTags = + selectedTag.trim().length > 0 + ? [ + toNumberId(selectedTag) !== null + ? (toNumberId(selectedTag) as number) + : selectedTag, + ] + : []; + const taskName = + currentScriptName.trim() || + selectedGroups[0]?.name || + (manualMessages[0] ? manualMessages[0].slice(0, 20) : "") || + `推送任务-${Date.now()}`; + const deviceGroupIds = Array.from( + new Set( + selectedAccounts + .map(account => toNumberId(account?.currentDeviceId)) + .filter((id): id is number => id !== null), + ), + ); + if (validPushType === "friend-message" && deviceGroupIds.length === 0) { + message.error("缺少有效的推送设备分组"); + return; + } + + const basePayload: Record<string, any> = { + name: taskName, + type: 3, + autoStart: DEFAULT_AUTO_START[validPushType], + status: 1, + pushType: DEFAULT_PUSH_TYPE[validPushType], + targetType: validPushType === "friend-message" ? 2 : 1, + groupPushSubType: validPushType === "group-announcement" ? 2 : 1, + startTime, + endTime, + maxPerDay, + pushOrder, + friendIntervalMin, + friendIntervalMax, + messageIntervalMin, + messageIntervalMax, + isRandomTemplate: selectedScriptGroupIds.length > 1 ? 1 : 0, + contentGroups: contentGroupIds, + postPushTags: normalizedPostPushTags, + ownerWechatIds, + enableAiRewrite: aiRewriteEnabled ? 1 : 0, + }; + if (trafficPoolIds.length > 0) { + basePayload.trafficPools = trafficPoolIds; + } + if (validPushType === "friend-message") { + basePayload.isLoop = 0; + basePayload.deviceGroups = deviceGroupIds; + } + if (manualMessages.length > 0) { + basePayload.manualMessages = manualMessages; + if (currentScriptName.trim()) { + basePayload.manualScriptName = currentScriptName.trim(); + } + } + if (selectedScriptGroupIds.length > 0) { + basePayload.selectedScriptGroupIds = selectedScriptGroupIds; + } + if (aiRewriteEnabled && aiPrompt.trim()) { + basePayload.aiRewritePrompt = aiPrompt.trim(); + } + if (selectedGroups.length > 0) { + basePayload.scriptGroups = selectedGroups.map(group => ({ + id: group.id, + name: group.name, + messages: group.messages, + })); + } + if (validPushType === "friend-message") { + basePayload.wechatFriends = Array.from( + new Set( + selectedContacts + .map(contact => toNumberId(contact?.id)) + .filter((id): id is number => id !== null), + ), + ); + basePayload.targetType = 2; + } else { + const groupIds = Array.from( + new Set( + selectedContacts + .map(contact => toNumberId(contact.groupId ?? contact.id)) + .filter((id): id is number => id !== null), + ), + ); + basePayload.wechatGroups = groupIds; + basePayload.groupPushSubType = + validPushType === "group-announcement" ? 2 : 1; + basePayload.targetType = 1; + if (validPushType === "group-announcement") { + basePayload.announcementContent = manualMessages.join("\n"); + } + } + let hideLoading: ReturnType<typeof message.loading> | undefined; + try { + setCreatingTask(true); + hideLoading = message.loading("正在创建推送任务...", 0); + await queryWorkbenchCreate(basePayload); + hideLoading?.(); + message.success("推送任务已创建"); + navigate("/pc/powerCenter/message-push-assistant"); + } catch (error) { + hideLoading?.(); + console.error("创建推送任务失败:", error); + const errorMessage = + (error as any)?.message || + (error as any)?.response?.data?.message || + "创建推送任务失败,请稍后重试"; + message.error(errorMessage); + } finally { + setCreatingTask(false); + } }; return ( @@ -242,6 +464,8 @@ const CreatePushTask: React.FC = () => { type="primary" icon={<SendOutlined />} onClick={handleSend} + loading={creatingTask} + disabled={creatingTask} > 一键发送 </Button> @@ -266,6 +490,8 @@ const CreatePushTask: React.FC = () => { selectedAccounts={selectedAccounts} selectedContacts={selectedContacts} onChange={setSelectedContacts} + selectedTrafficPools={selectedTrafficPools} + onTrafficPoolsChange={setSelectedTrafficPools} /> )} {currentStep === 3 && ( diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/提示词.txt b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/提示词.txt new file mode 100644 index 00000000..df1db7a1 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/提示词.txt @@ -0,0 +1,79 @@ +帮我对接数据,以下是传参实例,三种模式都是同一界面的。 + +群发助手传参实例 +{ + "name": "群群发-新品宣传", // 任务名称 + "type": 3, // 工作台类型:3=群消息推送 + "autoStart": 1, // 保存后自动启动 + "status": 1, // 是否启用 + "pushType": 0, // 推送方式:0=定时,1=立即 + "targetType": 1, // 目标类型:1=群推送 + "groupPushSubType": 1, // 群推送子类型:1=群群发,2=群公告 + "startTime": "09:00", // 推送起始时间 + "endTime": "20:00", // 推送结束时间 + "maxPerDay": 200, // 每日最大推送群数 + "pushOrder": 1, // 推送顺序:1=最早优先,2=最新优先 + "wechatGroups": [102, 205, 318], // 选择的微信群 ID 列表 + "contentGroups": [11, 12], // 关联内容库 ID 列表 + "friendIntervalMin": 10, // 群间最小间隔(秒) + "friendIntervalMax": 25, // 群间最大间隔(秒) + "messageIntervalMin": 2, // 同一群消息间最小间隔(秒) + "messageIntervalMax": 6, // 同一群消息间最大间隔(秒) + "isRandomTemplate": 1, // 是否随机选择话术模板 + "postPushTags": [301, 302], // 推送完成后打的标签 + ownerWechatIds:[123123,1231231] //客服id +} + +//群公告传参实例 +{ + "name": "群公告-双11活动", // 任务名称 + "type": 3, // 群消息推送 + "autoStart": 0, // 不自动启动 + "status": 1, // 启用 + "pushType": 1, // 立即推送 + "targetType": 1, // 群推送 + "groupPushSubType": 2, // 群公告 + "startTime": "08:30", // 开始时间 + "endTime": "18:30", // 结束时间 + "maxPerDay": 80, // 每日最大公告数 + "pushOrder": 2, // 最新优先 + "wechatGroups": [5021, 5026], // 公告目标群 + "announcementContent": "…", // 公告正文 + "enableAiRewrite": 1, // 启用 AI 改写 + "aiRewritePrompt": "保持活泼口吻…", // AI 改写提示词 + "contentGroups": [21], // 关联内容库 + "friendIntervalMin": 15, // 群间最小间隔 + "friendIntervalMax": 30, // 群间最大间隔 + "messageIntervalMin": 3, // 消息间最小间隔 + "messageIntervalMax": 9, // 消息间最大间隔 + "isRandomTemplate": 0, // 不随机模板 + "postPushTags": [], // 推送后标签 + ownerWechatIds:[123123,1231231] //客服id +} + +//好友传参实例 +{ + "name": "好友私聊-新客转化", // 任务名称 + "type": 3, // 群消息推送 + "autoStart": 1, // 自动启动 + "status": 1, // 启用 + "pushType": 0, // 定时推送 + "targetType": 2, // 目标类型:2=好友推送 + "groupPushSubType": 1, // 固定为群群发(好友推送不支持公告) + "startTime": "10:00", // 开始时间 + "endTime": "22:00", // 结束时间 + "maxPerDay": 150, // 每日最大推送好友数 + "pushOrder": 1, // 最早优先 + "wechatFriends": ["12312"], // 指定好友列表(可为空数组) + "deviceGroups": [9001, 9002], // 必选:推送设备分组 ID + "contentGroups": [41, 42], // 话术内容库 + "friendIntervalMin": 12, // 好友间最小间隔 + "friendIntervalMax": 28, // 好友间最大间隔 + "messageIntervalMin": 4, // 消息间最小间隔 + "messageIntervalMax": 10, // 消息间最大间隔 + "isRandomTemplate": 1, // 随机话术 + "postPushTags": [501], // 推送后标签 + ownerWechatIds:[123123,1231231] //客服id +} + +请求接口是 queryWorkbenchCreate diff --git a/Touchkebao/src/utils/dbAction/message.ts b/Touchkebao/src/utils/dbAction/message.ts index e1dfa5ea..f6deae3d 100644 --- a/Touchkebao/src/utils/dbAction/message.ts +++ b/Touchkebao/src/utils/dbAction/message.ts @@ -7,6 +7,7 @@ * 4. 提供回调机制通知组件更新 */ +import Dexie from "dexie"; import { db, chatSessionService, ChatSession } from "../db"; import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; @@ -243,7 +244,9 @@ export class MessageManager { "userId", userId, )) as ChatSession[]; - const localSessionMap = new Map(localSessions.map(s => [s.id, s])); + const localSessionMap = new Map( + localSessions.map(session => [session.serverId, session]), + ); // 2. 转换服务器数据为统一格式 const serverSessions: ChatSession[] = []; @@ -264,16 +267,18 @@ export class MessageManager { serverSessions.push(...groups); } - const serverSessionMap = new Map(serverSessions.map(s => [s.id, s])); + const serverSessionMap = new Map( + serverSessions.map(session => [session.serverId, session]), + ); // 3. 计算差异 const toAdd: ChatSession[] = []; const toUpdate: ChatSession[] = []; - const toDelete: number[] = []; + const toDelete: string[] = []; // 检查新增和更新 for (const serverSession of serverSessions) { - const localSession = localSessionMap.get(serverSession.id); + const localSession = localSessionMap.get(serverSession.serverId); if (!localSession) { toAdd.push(serverSession); @@ -286,8 +291,8 @@ export class MessageManager { // 检查删除 for (const localSession of localSessions) { - if (!serverSessionMap.has(localSession.id)) { - toDelete.push(localSession.id); + if (!serverSessionMap.has(localSession.serverId)) { + toDelete.push(localSession.serverId); } } @@ -334,7 +339,19 @@ export class MessageManager { serverId: `${session.type}_${session.id}`, })); - await db.chatSessions.bulkAdd(dataToInsert); + try { + await db.chatSessions.bulkAdd(dataToInsert); + } catch (error) { + if (error instanceof Dexie.BulkError) { + console.warn( + `批量新增会话时检测到重复主键,切换为 bulkPut 以覆盖更新。错误详情:`, + error, + ); + await db.chatSessions.bulkPut(dataToInsert); + } else { + throw error; + } + } } /** @@ -357,14 +374,16 @@ export class MessageManager { */ private static async batchDeleteSessions( userId: number, - sessionIds: number[], + serverIds: string[], ) { - if (sessionIds.length === 0) return; + if (serverIds.length === 0) return; + + const serverIdSet = new Set(serverIds); await db.chatSessions .where("userId") .equals(userId) - .and(session => sessionIds.includes(session.id)) + .and(session => serverIdSet.has(session.serverId)) .delete(); } From d6a003c3e260c806abcd2e18ab40c98d0b69d87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 12 Nov 2025 12:30:57 +0800 Subject: [PATCH 09/26] =?UTF-8?q?=E9=80=9A=E8=BF=87=E6=B7=BB=E5=8A=A0AI?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BAStepSendM?= =?UTF-8?q?essage=E7=BB=84=E4=BB=B6=E3=80=82=E4=B8=BAAI=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=96=B0=E7=9A=84=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=B8=BA=E5=86=85=E5=AE=B9=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=96=B0=E7=9A=84AiRewrite=E6=96=B9=E6=B3=95?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=9B=B4=E6=96=B0AI=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=9A=84=E6=A0=B7=E5=BC=8F=E3=80=82=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=8A=A0=E8=BD=BD=E6=8C=87=E7=A4=BA=E7=AC=A6=E5=92=8C?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=B6=88=E6=81=AF=E6=94=B9=E5=96=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StepSendMessage/index.module.scss | 10 ++ .../components/StepSendMessage/index.tsx | 127 ++++++++++++++++-- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss index 12ee6679..2fe9b07f 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -273,6 +273,16 @@ .aiRewriteInput { max-width: 240px; } + + .aiRewriteActions { + display: flex; + align-items: center; + gap: 8px; + } + + .aiRewriteButton { + min-width: 96px; + } } } diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx index 8a19ad74..991f3a2a 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -10,7 +10,12 @@ import { Switch, message as antdMessage, } from "antd"; -import { CopyOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import { + CopyOutlined, + DeleteOutlined, + PlusOutlined, + ReloadOutlined, +} from "@ant-design/icons"; import type { CheckboxChangeEvent } from "antd/es/checkbox"; import styles from "./index.module.scss"; @@ -21,6 +26,7 @@ import type { ContentItem } from "@/components/ContentSelection/data"; import { createContentLibrary, deleteContentLibrary, + aiEditContent, type CreateContentLibraryParams, } from "./api"; @@ -80,6 +86,7 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ onSelectedContentLibrariesChange, }) => { const [savingScriptGroup, setSavingScriptGroup] = useState(false); + const [aiRewriting, setAiRewriting] = useState(false); const [deletingGroupIds, setDeletingGroupIds] = useState<string[]>([]); const handleAddMessage = useCallback( @@ -172,6 +179,99 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ savingScriptGroup, ]); + const handleAiRewrite = useCallback(async () => { + if (!aiRewriteEnabled) { + antdMessage.warning("请先开启AI智能话术改写"); + return; + } + const trimmedPrompt = aiPrompt.trim(); + const originalContent = messageContent; + const trimmedContent = originalContent.trim(); + if (!trimmedPrompt) { + antdMessage.warning("请输入改写提示词"); + return; + } + if (!trimmedContent) { + antdMessage.warning("请输入需要改写的内容"); + return; + } + if (aiRewriting) { + return; + } + let hideLoading: ReturnType<typeof antdMessage.loading> | undefined; + try { + setAiRewriting(true); + hideLoading = antdMessage.loading("AI正在改写话术...", 0); + const response = await aiEditContent({ + aiPrompt: trimmedPrompt, + content: originalContent, + }); + hideLoading?.(); + const normalizedResponse = response as { + content?: string; + contentAfter?: string; + contentFront?: string; + data?: + | string + | { + content?: string; + contentAfter?: string; + contentFront?: string; + }; + result?: string; + }; + const dataField = normalizedResponse?.data; + const dataContent = + typeof dataField === "string" + ? dataField + : (dataField?.content ?? undefined); + const dataContentAfter = + typeof dataField === "string" ? undefined : dataField?.contentAfter; + const dataContentFront = + typeof dataField === "string" ? undefined : dataField?.contentFront; + + const primaryAfter = + normalizedResponse?.contentAfter ?? dataContentAfter ?? undefined; + const primaryFront = + normalizedResponse?.contentFront ?? dataContentFront ?? undefined; + + let rewrittenContent = ""; + if (typeof response === "string") { + rewrittenContent = response; + } else if (primaryAfter) { + rewrittenContent = primaryFront + ? `${primaryFront}\n${primaryAfter}` + : primaryAfter; + } else if (typeof normalizedResponse?.content === "string") { + rewrittenContent = normalizedResponse.content; + } else if (typeof dataContent === "string") { + rewrittenContent = dataContent; + } else if (typeof normalizedResponse?.result === "string") { + rewrittenContent = normalizedResponse.result; + } else if (primaryFront) { + rewrittenContent = primaryFront; + } + if (!rewrittenContent || typeof rewrittenContent !== "string") { + antdMessage.error("AI改写失败,请稍后重试"); + return; + } + onMessageContentChange(rewrittenContent.trim()); + antdMessage.success("AI改写完成,请确认内容"); + } catch (error) { + hideLoading?.(); + console.error("AI改写失败:", error); + antdMessage.error("AI改写失败,请稍后重试"); + } finally { + setAiRewriting(false); + } + }, [ + aiPrompt, + aiRewriting, + aiRewriteEnabled, + messageContent, + onMessageContentChange, + ]); + const handleApplyGroup = useCallback( (group: ScriptGroup) => { onCurrentScriptMessagesChange(group.messages); @@ -389,13 +489,24 @@ const StepSendMessage: React.FC<StepSendMessageProps> = ({ </div> </div> - <Button - type="primary" - icon={<PlusOutlined />} - onClick={() => handleAddMessage(undefined, true)} - > - 添加 - </Button> + <div className={styles.aiRewriteActions}> + <Button + icon={<ReloadOutlined />} + onClick={handleAiRewrite} + disabled={!aiRewriteEnabled} + loading={aiRewriting} + className={styles.aiRewriteButton} + > + AI改写 + </Button> + <Button + type="primary" + icon={<PlusOutlined />} + onClick={() => handleAddMessage(undefined, true)} + > + 添加 + </Button> + </div> </div> </div> </div> From eb5dbe506602cdf2fc0a6fc675a75c6fd99391aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 12 Nov 2025 16:17:29 +0800 Subject: [PATCH 10/26] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95API=E4=BB=A5=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=B0=E7=AB=AF=E7=82=B9=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=84=E7=90=86=E3=80=82=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?PushHistory=E7=BB=84=E4=BB=B6=E4=B8=AD=E7=9A=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E5=92=8C=E5=88=86=E9=A1=B5=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BB=A5=E6=94=B9=E5=96=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pc/ckbox/powerCenter/push-history/api.ts | 46 ++++-------------- .../ckbox/powerCenter/push-history/index.tsx | 47 ++++++++++--------- .../components/MessageEnter/index.tsx | 16 ------- .../components/QuickWords/index.tsx | 31 ++++++++++++ 4 files changed, 66 insertions(+), 74 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts index dceeed01..a6402538 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts @@ -25,41 +25,13 @@ export interface GetPushHistoryResponse { /** * 获取推送历史列表 */ -export const getPushHistory = async ( - params: GetPushHistoryParams -): Promise<GetPushHistoryResponse> => { - try { - // TODO: 替换为实际的API接口地址 - const response = await request.get("/api/push-history", { params }); - - // 如果接口返回的数据格式不同,需要在这里进行转换 - if (response.data && response.data.success !== undefined) { - return response.data; - } - - // 兼容不同的响应格式 - return { - success: true, - data: { - list: response.data?.list || response.data?.data || [], - total: response.data?.total || 0, - page: response.data?.page || params.page || 1, - pageSize: response.data?.pageSize || params.pageSize || 10, - }, - }; - } catch (error: any) { - console.error("获取推送历史失败:", error); - return { - success: false, - message: error?.message || "获取推送历史失败", - }; - } +export interface GetGroupPushHistoryParams { + keyword?: string; + limit: string; + page: string; + workbenchId?: string; + [property: string]: any; +} +export const getPushHistory = async (params: GetGroupPushHistoryParams) => { + return request("/v1/workbench/group-push-history", { params }); }; - - - - - - - - diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/index.tsx index 0697b99d..bd2545a7 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/index.tsx @@ -76,18 +76,31 @@ const PushHistory: React.FC = () => { } const response = await getPushHistory(params); + const result = response?.data ?? response ?? {}; - if (response.success) { - setDataSource(response.data?.list || []); - setPagination(prev => ({ - ...prev, - current: response.data?.page || page, - total: response.data?.total || 0, - })); - } else { - message.error(response.message || "获取推送历史失败"); + if (!result || typeof result !== "object") { + message.error("获取推送历史失败"); setDataSource([]); + return; } + + const toNumber = (value: unknown, fallback: number) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + }; + + const list = Array.isArray(result.list) ? result.list : []; + const total = toNumber(result.total, pagination.total); + const currentPage = toNumber(result.page, page); + const pageSize = toNumber(result.pageSize, pagination.pageSize); + + setDataSource(list); + setPagination(prev => ({ + ...prev, + current: currentPage, + pageSize, + total, + })); } catch (error) { console.error("获取推送历史失败:", error); message.error("获取推送历史失败,请稍后重试"); @@ -211,9 +224,7 @@ const PushHistory: React.FC = () => { dataIndex: "pushContent", key: "pushContent", ellipsis: true, - render: (text: string) => ( - <span style={{ color: "#333" }}>{text}</span> - ), + render: (text: string) => <span style={{ color: "#333" }}>{text}</span>, }, { title: "目标数量", @@ -287,7 +298,9 @@ const PushHistory: React.FC = () => { subtitle="查看所有推送任务的历史记录" showBackButton={true} backButtonText="返回" - onBackClick={() => navigate("/pc/powerCenter/message-push-assistant")} + onBackClick={() => + navigate("/pc/powerCenter/message-push-assistant") + } /> </div> } @@ -369,11 +382,3 @@ const PushHistory: React.FC = () => { }; export default PushHistory; - - - - - - - - diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx index 8ae096e8..864ccb6e 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx @@ -167,27 +167,11 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => { // AI 消息处理 useEffect(() => { if (quoteMessageContent) { - console.log( - "🤖 AI消息到达 - aiQuoteMessageContent:", - aiQuoteMessageContent, - ); - - // 检查:如果用户输入框已有内容(且不是之前的AI内容),不覆盖 - if (inputValue && inputValue !== quoteMessageContent) { - console.log("⚠️ 用户正在输入,不覆盖输入内容"); - updateQuoteMessageContent(""); // 清空AI回复 - return; - } - if (isAiAssist) { - // AI辅助模式:填充到输入框,等待人工确认 - console.log("✨ AI辅助模式:填充消息到输入框"); setInputValue(quoteMessageContent); } if (isAiTakeover) { - // AI接管模式:直接发送消息(传入内容,避免 state 闭包问题) - console.log("🚀 AI接管模式:自动发送消息"); handleSend(quoteMessageContent); } } diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx index 561200e5..8b1315a9 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx @@ -39,6 +39,7 @@ import QuickReplyModal from "./components/QuickReplyModal"; import GroupModal from "./components/GroupModal"; import { useWeChatStore } from "@/store/module/weChat/weChat"; import { useWebSocketStore } from "@/store/module/websocket/websocket"; +import { ChatRecord } from "@/pages/pc/ckbox/data"; // 消息类型枚举 export enum MessageType { @@ -82,10 +83,12 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => { state => state.updateQuoteMessageContent, ); const currentContract = useWeChatStore(state => state.currentContract); + const addMessage = useWeChatStore(state => state.addMessage); const { sendCommand } = useWebSocketStore.getState(); const sendQuickReplyNow = (reply: QuickWordsReply) => { if (!currentContract) return; + const messageId = Date.now(); const params = { wechatAccountId: currentContract.wechatAccountId, wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0, @@ -93,7 +96,35 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => { msgSubType: 0, msgType: reply.msgType, content: reply.content, + seq: messageId, } as any; + + if (reply.msgType !== MessageType.TEXT) { + const localMessage: ChatRecord = { + id: messageId, + wechatAccountId: params.wechatAccountId, + wechatFriendId: params.wechatFriendId, + wechatChatroomId: params.wechatChatroomId, + tenantId: 0, + accountId: 0, + synergyAccountId: 0, + content: params.content, + msgType: reply.msgType, + msgSubType: params.msgSubType, + msgSvrId: "", + isSend: true, + createTime: new Date().toISOString(), + isDeleted: false, + deleteTime: "", + sendStatus: 1, + wechatTime: Date.now(), + origin: 0, + msgId: 0, + recalled: false, + seq: messageId, + }; + addMessage(localMessage); + } sendCommand("CmdSendMessage", params); }; From ae4a165b0740fbb3e6f1decb60d81cf97451cf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Thu, 13 Nov 2025 11:58:12 +0800 Subject: [PATCH 11/26] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E7=AE=A1=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=B5=81=E7=A8=8B=E3=80=82=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=B1=BB=E4=BB=A5=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=90=8D=E7=A7=B0=E5=92=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E3=80=82=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=90=AF=E5=8A=A8=E9=80=BB=E8=BE=91=E4=BB=A5?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=9C=A8=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=97=B6=E6=AD=A3=E7=A1=AE=E5=88=9D=E5=A7=8B=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E3=80=82=E5=A2=9E=E5=BC=BA=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=95=B0=E6=8D=AE=E6=81=A2=E5=A4=8D=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E5=8F=AF=E9=9D=A0=E6=80=A7=E5=92=8C=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Touchkebao/src/main.tsx | 90 +--------- .../pc/ckbox/powerCenter/push-history/api.ts | 2 +- .../components/ProfileCard/index.tsx | 65 +++---- Touchkebao/src/store/module/user.ts | 87 +++++++++- .../src/store/module/websocket/websocket.ts | 78 +++++++-- Touchkebao/src/store/persistUtils.ts | 6 + Touchkebao/src/utils/db.ts | 164 +++++++++++++++++- 7 files changed, 342 insertions(+), 150 deletions(-) diff --git a/Touchkebao/src/main.tsx b/Touchkebao/src/main.tsx index a3392df2..dde8d3ad 100644 --- a/Touchkebao/src/main.tsx +++ b/Touchkebao/src/main.tsx @@ -7,97 +7,18 @@ import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import App from "./App"; import "./styles/global.scss"; -import { db } from "./utils/db"; // 引入数据库实例 +import { initializeDatabaseFromPersistedUser } from "./utils/db"; // 设置dayjs为中文 dayjs.locale("zh-cn"); -// 清理旧数据库 -async function cleanupOldDatabase() { +async function bootstrap() { try { - // 获取所有数据库 - const databases = await indexedDB.databases(); - - for (const dbInfo of databases) { - if (dbInfo.name === "CunkebaoDatabase") { - console.log("检测到旧版数据库,开始清理..."); - - // 打开数据库检查版本 - const openRequest = indexedDB.open(dbInfo.name); - - await new Promise<void>((resolve, reject) => { - openRequest.onsuccess = async event => { - const database = (event.target as IDBOpenDBRequest).result; - const objectStoreNames = Array.from(database.objectStoreNames); - - // 检查是否存在旧表 - const hasOldTables = objectStoreNames.some(name => - [ - "kfUsers", - "weChatGroup", - "contracts", - "newContactList", - "messageList", - ].includes(name), - ); - - if (hasOldTables) { - console.log("发现旧表,删除整个数据库:", objectStoreNames); - database.close(); - - // 删除整个数据库 - const deleteRequest = indexedDB.deleteDatabase(dbInfo.name); - deleteRequest.onsuccess = () => { - console.log("旧数据库已删除"); - resolve(); - }; - deleteRequest.onerror = () => { - console.error("删除旧数据库失败"); - reject(); - }; - } else { - console.log("数据库结构正确,无需清理"); - database.close(); - resolve(); - } - }; - - openRequest.onerror = () => { - console.error("无法打开数据库进行检查"); - reject(); - }; - }); - } - } + await initializeDatabaseFromPersistedUser(); } catch (error) { - console.warn("清理旧数据库时出错(可忽略):", error); - } -} - -// 数据库初始化 -async function initializeApp() { - try { - // 1. 清理旧数据库 - await cleanupOldDatabase(); - - // 2. 打开新数据库 - await db.open(); - console.log("数据库初始化成功"); - - // 3. 开发环境清空数据(可选) - if (process.env.NODE_ENV === "development") { - console.log("开发环境:跳过数据清理"); - // 如需清空数据,取消下面的注释 - // await db.chatSessions.clear(); - // await db.contactsUnified.clear(); - // await db.contactLabelMap.clear(); - // await db.userLoginRecords.clear(); - } - } catch (error) { - console.error("数据库初始化失败:", error); + console.warn("Failed to prepare database before app bootstrap:", error); } - // 渲染应用 const root = createRoot(document.getElementById("root")!); root.render( <ConfigProvider locale={zhCN}> @@ -106,5 +27,4 @@ async function initializeApp() { ); } -// 启动应用 -initializeApp(); +void bootstrap(); diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts index a6402538..a3627033 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/push-history/api.ts @@ -33,5 +33,5 @@ export interface GetGroupPushHistoryParams { [property: string]: any; } export const getPushHistory = async (params: GetGroupPushHistoryParams) => { - return request("/v1/workbench/group-push-history", { params }); + return request("/v1/workbench/group-push-history", params, "GET"); }; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx index d738db4b..831a60a2 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Layout, Tabs } from "antd"; import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import styles from "./Person.module.scss"; @@ -9,6 +9,8 @@ import LayoutFiexd from "@/components/Layout/LayoutFiexd"; const { Sider } = Layout; +const noop = () => {}; + interface PersonProps { contract: ContractData | weChatGroup; } @@ -16,47 +18,46 @@ interface PersonProps { const Person: React.FC<PersonProps> = ({ contract }) => { const [activeKey, setActiveKey] = useState("profile"); const isGroup = "chatroomId" in contract; + const tabItems = useMemo(() => { + const baseItems = [ + { + key: "quickwords", + label: "快捷语录", + }, + { + key: "profile", + label: isGroup ? "群资料" : "个人资料", + }, + ]; + if (!isGroup) { + baseItems.push({ + key: "moments", + label: "朋友圈", + }); + } + return baseItems; + }, [isGroup]); + + const handleTabChange = useCallback((key: string) => { + setActiveKey(key); + }, []); + + const tabBarStyle = useMemo(() => ({ padding: "0 30px" }), []); + return ( <Sider width={330} className={styles.profileSider}> <LayoutFiexd header={ <Tabs activeKey={activeKey} - onChange={key => setActiveKey(key)} - tabBarStyle={{ - padding: "0 30px", - }} - items={[ - { - key: "quickwords", - label: "快捷语录", - }, - { - key: "profile", - label: isGroup ? "群资料" : "个人资料", - }, - - ...(!isGroup - ? [ - { - key: "moments", - label: "朋友圈", - }, - ] - : []), - ]} + onChange={handleTabChange} + tabBarStyle={tabBarStyle} + items={tabItems} /> } > {activeKey === "profile" && <ProfileModules contract={contract} />} - {activeKey === "quickwords" && ( - <QuickWords - words={[]} - onInsert={() => {}} - onAdd={() => {}} - onRemove={() => {}} - /> - )} + {activeKey === "quickwords" && <QuickWords onInsert={noop} />} {activeKey === "moments" && !isGroup && ( <FriendsCircle wechatFriendId={contract.id} /> )} diff --git a/Touchkebao/src/store/module/user.ts b/Touchkebao/src/store/module/user.ts index 745af11c..7d1dea2f 100644 --- a/Touchkebao/src/store/module/user.ts +++ b/Touchkebao/src/store/module/user.ts @@ -1,5 +1,52 @@ import { createPersistStore } from "@/store/createPersistStore"; import { Toast } from "antd-mobile"; +import { databaseManager } from "@/utils/db"; + +const STORE_CACHE_KEYS = [ + "user-store", + "app-store", + "settings-store", + "websocket-store", + "ckchat-store", + "wechat-storage", + "contacts-storage", + "message-storage", + "customer-storage", +]; + +const allStorages = (): Storage[] => { + if (typeof window === "undefined") { + return []; + } + const storages: Storage[] = []; + try { + storages.push(window.localStorage); + } catch (error) { + console.warn("无法访问 localStorage:", error); + } + try { + storages.push(window.sessionStorage); + } catch (error) { + console.warn("无法访问 sessionStorage:", error); + } + return storages; +}; + +const clearStoreCaches = () => { + const storages = allStorages(); + if (!storages.length) { + return; + } + STORE_CACHE_KEYS.forEach(key => { + storages.forEach(storage => { + try { + storage.removeItem(key); + } catch (error) { + console.warn(`清理持久化数据失败: ${key}`, error); + } + }); + }); +}; export interface User { id: number; @@ -28,7 +75,7 @@ interface UserState { setToken: (token: string) => void; setToken2: (token2: string) => void; clearUser: () => void; - login: (token: string, userInfo: User) => void; + login: (token: string, userInfo: User) => Promise<void>; login2: (token2: string) => void; logout: () => void; } @@ -39,12 +86,27 @@ export const useUserStore = createPersistStore<UserState>( token: null, token2: null, isLoggedIn: false, - setUser: user => set({ user, isLoggedIn: true }), + setUser: user => { + set({ user, isLoggedIn: true }); + databaseManager.ensureDatabase(user.id).catch(error => { + console.warn("Failed to initialize database for user:", error); + }); + }, setToken: token => set({ token }), setToken2: token2 => set({ token2 }), - clearUser: () => - set({ user: null, token: null, token2: null, isLoggedIn: false }), - login: (token, userInfo) => { + clearUser: () => { + databaseManager.closeCurrentDatabase().catch(error => { + console.warn("Failed to close database on clearUser:", error); + }); + clearStoreCaches(); + set({ user: null, token: null, token2: null, isLoggedIn: false }); + }, + login: async (token, userInfo) => { + clearStoreCaches(); + + // 清除旧的双token缓存 + localStorage.removeItem("token2"); + // 只将token存储到localStorage localStorage.setItem("token", token); @@ -66,6 +128,11 @@ export const useUserStore = createPersistStore<UserState>( lastLoginIp: userInfo.lastLoginIp, lastLoginTime: userInfo.lastLoginTime, }; + try { + await databaseManager.ensureDatabase(user.id); + } catch (error) { + console.error("Failed to initialize user database:", error); + } set({ user, token, isLoggedIn: true }); Toast.show({ content: "登录成功", position: "top" }); @@ -80,6 +147,10 @@ export const useUserStore = createPersistStore<UserState>( // 清除localStorage中的token localStorage.removeItem("token"); localStorage.removeItem("token2"); + databaseManager.closeCurrentDatabase().catch(error => { + console.warn("Failed to close user database on logout:", error); + }); + clearStoreCaches(); set({ user: null, token: null, token2: null, isLoggedIn: false }); }, }), @@ -92,7 +163,11 @@ export const useUserStore = createPersistStore<UserState>( isLoggedIn: state.isLoggedIn, }), onRehydrateStorage: () => state => { - // console.log("User store hydrated:", state); + if (state?.user?.id) { + databaseManager.ensureDatabase(state.user!.id).catch(error => { + console.warn("Failed to restore user database:", error); + }); + } }, }, ); diff --git a/Touchkebao/src/store/module/websocket/websocket.ts b/Touchkebao/src/store/module/websocket/websocket.ts index 68a17c45..359fcf4a 100644 --- a/Touchkebao/src/store/module/websocket/websocket.ts +++ b/Touchkebao/src/store/module/websocket/websocket.ts @@ -1,7 +1,7 @@ import { createPersistStore } from "@/store/createPersistStore"; -import { Toast } from "antd-mobile"; import { useUserStore } from "../user"; import { useCkChatStore } from "@/store/module/ckchat/ckchat"; +import { useCustomerStore } from "@/store/module/weChat/customer"; const { getAccountId } = useCkChatStore.getState(); import { msgManageCore } from "./msgManage"; // WebSocket消息类型 @@ -52,6 +52,7 @@ interface WebSocketState { reconnectAttempts: number; reconnectTimer: NodeJS.Timeout | null; aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器 + aliveStatusUnsubscribe: (() => void) | null; // 方法 connect: (config: Partial<WebSocketConfig>) => void; @@ -97,6 +98,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( reconnectAttempts: 0, reconnectTimer: null, aliveStatusTimer: null, + aliveStatusUnsubscribe: null, // 连接WebSocket connect: (config: Partial<WebSocketConfig>) => { @@ -405,7 +407,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( }, // 内部方法:处理连接关闭 - _handleClose: (event: CloseEvent) => { + _handleClose: () => { const currentState = get(); // console.log("WebSocket连接关闭:", event.code, event.reason); @@ -431,7 +433,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( }, // 内部方法:处理连接错误 - _handleError: (event: Event) => { + _handleError: () => { // console.error("WebSocket连接错误:", event); set({ status: WebSocketStatus.ERROR }); @@ -477,42 +479,84 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( // 先停止现有定时器 currentState._stopAliveStatusTimer(); - // 获取客服用户列表 - const { kfUserList } = useCkChatStore.getState(); + const requestAliveStatus = () => { + const state = get(); + if (state.status !== WebSocketStatus.CONNECTED) { + return; + } - // 如果没有客服用户,不启动定时器 - if (!kfUserList || kfUserList.length === 0) { - return; - } + const { customerList } = useCustomerStore.getState(); + const { kfUserList } = useCkChatStore.getState(); + const targets = + customerList && customerList.length > 0 + ? customerList + : kfUserList && kfUserList.length > 0 + ? kfUserList + : []; + + if (targets.length > 0) { + state.sendCommand("CmdRequestWechatAccountsAliveStatus", { + wechatAccountIds: targets.map(v => v.id), + }); + } + }; + + // 尝试立即请求一次,如果客服列表尚未加载,后续定时器会继续检查 + requestAliveStatus(); + + const unsubscribeCustomer = useCustomerStore.subscribe(state => { + if ( + get().status === WebSocketStatus.CONNECTED && + state.customerList && + state.customerList.length > 0 + ) { + requestAliveStatus(); + } + }); + + const unsubscribeKf = useCkChatStore.subscribe(state => { + if ( + get().status === WebSocketStatus.CONNECTED && + state.kfUserList && + state.kfUserList.length > 0 + ) { + requestAliveStatus(); + } + }); // 启动定时器,每5秒查询一次 const timer = setInterval(() => { const state = get(); // 检查连接状态 if (state.status === WebSocketStatus.CONNECTED) { - const { kfUserList: currentKfUserList } = useCkChatStore.getState(); - if (currentKfUserList && currentKfUserList.length > 0) { - state.sendCommand("CmdRequestWechatAccountsAliveStatus", { - wechatAccountIds: currentKfUserList.map(v => v.id), - }); - } + requestAliveStatus(); } else { // 如果连接断开,停止定时器 state._stopAliveStatusTimer(); } }, 5 * 1000); - set({ aliveStatusTimer: timer }); + set({ + aliveStatusTimer: timer, + aliveStatusUnsubscribe: () => { + unsubscribeCustomer(); + unsubscribeKf(); + }, + }); }, // 内部方法:停止客服状态查询定时器 _stopAliveStatusTimer: () => { const currentState = get(); + if (currentState.aliveStatusUnsubscribe) { + currentState.aliveStatusUnsubscribe(); + } + if (currentState.aliveStatusTimer) { clearInterval(currentState.aliveStatusTimer); - set({ aliveStatusTimer: null }); } + set({ aliveStatusTimer: null, aliveStatusUnsubscribe: null }); }, }), { diff --git a/Touchkebao/src/store/persistUtils.ts b/Touchkebao/src/store/persistUtils.ts index abfb1e8c..bcbc93bb 100644 --- a/Touchkebao/src/store/persistUtils.ts +++ b/Touchkebao/src/store/persistUtils.ts @@ -5,6 +5,12 @@ export const PERSIST_KEYS = { USER_STORE: "user-store", APP_STORE: "app-store", SETTINGS_STORE: "settings-store", + CKCHAT_STORE: "ckchat-store", + WEBSOCKET_STORE: "websocket-store", + WECHAT_STORAGE: "wechat-storage", + CONTACTS_STORAGE: "contacts-storage", + MESSAGE_STORAGE: "message-storage", + CUSTOMER_STORAGE: "customer-storage", } as const; // 存储类型 diff --git a/Touchkebao/src/utils/db.ts b/Touchkebao/src/utils/db.ts index abd22d02..61bffeb8 100644 --- a/Touchkebao/src/utils/db.ts +++ b/Touchkebao/src/utils/db.ts @@ -16,6 +16,8 @@ */ import Dexie, { Table } from "dexie"; +import { getPersistedData, PERSIST_KEYS } from "@/store/persistUtils"; +const DB_NAME_PREFIX = "CunkebaoDatabase"; // ==================== 用户登录记录 ==================== export interface UserLoginRecord { @@ -123,8 +125,8 @@ class CunkebaoDatabase extends Dexie { contactLabelMap!: Table<ContactLabelMap>; // 联系人标签映射表 userLoginRecords!: Table<UserLoginRecord>; // 用户登录记录表 - constructor() { - super("CunkebaoDatabase"); + constructor(dbName: string) { + super(dbName); // 版本1:统一表结构 this.version(1).stores({ @@ -188,12 +190,148 @@ class CunkebaoDatabase extends Dexie { } } -// 创建数据库实例 -export const db = new CunkebaoDatabase(); +class DatabaseManager { + private currentDb: CunkebaoDatabase | null = null; + private currentUserId: number | null = null; + + private getDatabaseName(userId: number) { + return `${DB_NAME_PREFIX}_${userId}`; + } + + private async openDatabase(dbName: string) { + const instance = new CunkebaoDatabase(dbName); + await instance.open(); + return instance; + } + + async ensureDatabase(userId: number) { + if (userId === undefined || userId === null) { + throw new Error("Invalid userId provided for database initialization"); + } + + if ( + this.currentDb && + this.currentUserId === userId && + this.currentDb.isOpen() + ) { + return this.currentDb; + } + + await this.closeCurrentDatabase(); + + const dbName = this.getDatabaseName(userId); + this.currentDb = await this.openDatabase(dbName); + this.currentUserId = userId; + + return this.currentDb; + } + + getCurrentDatabase(): CunkebaoDatabase { + if (!this.currentDb) { + throw new Error("Database has not been initialized for the current user"); + } + return this.currentDb; + } + + getCurrentUserId() { + return this.currentUserId; + } + + isInitialized(): boolean { + return !!this.currentDb && this.currentDb.isOpen(); + } + + async closeCurrentDatabase() { + if (this.currentDb) { + try { + this.currentDb.close(); + } catch (error) { + console.warn("Failed to close current database:", error); + } + this.currentDb = null; + } + this.currentUserId = null; + } +} + +export const databaseManager = new DatabaseManager(); + +let pendingDatabaseRestore: Promise<CunkebaoDatabase | null> | null = null; + +async function restoreDatabaseFromPersistedState() { + if (typeof window === "undefined") { + return null; + } + + const persistedData = getPersistedData<string | Record<string, any>>( + PERSIST_KEYS.USER_STORE, + "localStorage", + ); + + if (!persistedData) { + return null; + } + + let parsed: any = persistedData; + + if (typeof persistedData === "string") { + try { + parsed = JSON.parse(persistedData); + } catch (error) { + console.warn("Failed to parse persisted user-store value:", error); + return null; + } + } + + const state = parsed?.state ?? parsed; + const userId = state?.user?.id; + + if (!userId) { + return null; + } + + try { + return await databaseManager.ensureDatabase(userId); + } catch (error) { + console.warn("Failed to initialize database from persisted user:", error); + return null; + } +} + +export async function initializeDatabaseFromPersistedUser() { + if (databaseManager.isInitialized()) { + return databaseManager.getCurrentDatabase(); + } + + if (!pendingDatabaseRestore) { + pendingDatabaseRestore = restoreDatabaseFromPersistedState().finally(() => { + pendingDatabaseRestore = null; + }); + } + + return pendingDatabaseRestore; +} + +const dbProxy = new Proxy({} as CunkebaoDatabase, { + get(_target, prop: string | symbol) { + const currentDb = databaseManager.getCurrentDatabase(); + const value = (currentDb as any)[prop]; + if (typeof value === "function") { + return value.bind(currentDb); + } + return value; + }, +}); + +export const db = dbProxy; // 简单的数据库操作类 export class DatabaseService<T> { - constructor(private table: Table<T>) {} + constructor(private readonly tableAccessor: () => Table<T>) {} + + private get table(): Table<T> { + return this.tableAccessor(); + } // 基础 CRUD 操作 - 使用serverId作为主键 async create(data: Omit<T, "serverId">): Promise<string | number> { @@ -446,10 +584,18 @@ export class DatabaseService<T> { } // 创建统一表的服务实例 -export const chatSessionService = new DatabaseService(db.chatSessions); -export const contactUnifiedService = new DatabaseService(db.contactsUnified); -export const contactLabelMapService = new DatabaseService(db.contactLabelMap); -export const userLoginRecordService = new DatabaseService(db.userLoginRecords); +export const chatSessionService = new DatabaseService<ChatSession>( + () => databaseManager.getCurrentDatabase().chatSessions, +); +export const contactUnifiedService = new DatabaseService<Contact>( + () => databaseManager.getCurrentDatabase().contactsUnified, +); +export const contactLabelMapService = new DatabaseService<ContactLabelMap>( + () => databaseManager.getCurrentDatabase().contactLabelMap, +); +export const userLoginRecordService = new DatabaseService<UserLoginRecord>( + () => databaseManager.getCurrentDatabase().userLoginRecords, +); // 默认导出数据库实例 export default db; From a6ee45f3e326f0b37cca34f7a1cf61a1b186c677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Thu, 13 Nov 2025 16:07:52 +0800 Subject: [PATCH 12/26] =?UTF-8?q?=E9=87=8D=E6=9E=84ProfileCard=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E5=A2=9E=E5=BC=BA=E9=80=89=E9=A1=B9=E5=8D=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=B9=B6=E6=94=B9=E5=96=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82=E5=BC=95=E5=85=A5=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E9=94=AE=E7=9A=84=E5=8A=A8=E6=80=81=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=8D=A1=E5=91=88=E7=8E=B0=EF=BC=8C=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=80=89=E9=A1=B9=E5=8D=A1=E6=A0=87=E9=A2=98=E7=9A=84?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E3=80=82=E8=B0=83=E6=95=B4=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=B8=A7=E5=92=8C=E6=B8=B2=E6=9F=93=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E5=B8=A7=E7=9A=84=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E3=80=82=E6=9B=B4=E6=96=B0QuickWords=E5=92=8CProfileModules?= =?UTF-8?q?=E9=9B=86=E6=88=90=E3=80=82=E4=BF=AE=E6=94=B9GroupModal?= =?UTF-8?q?=E5=92=8CQuickReplyModal=E4=BB=A5=E4=BD=BF=E7=94=A8=E2=80=9Cdes?= =?UTF-8?q?troyOnHidden=E2=80=9D=E4=BB=A5=E8=8E=B7=E5=BE=97=E6=9B=B4?= =?UTF-8?q?=E5=A5=BD=E7=9A=84=E6=A8=A1=E5=BC=8F=E5=A4=84=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ProfileCard/Person.module.scss | 48 ++++++++++ .../components/ProfileModules/index.tsx | 19 ++-- .../QuickWords/components/GroupModal.tsx | 2 +- .../QuickWords/components/QuickReplyModal.tsx | 2 +- .../components/ProfileCard/index.tsx | 94 +++++++++++++++---- .../SidebarMenu/MessageList/index.tsx | 8 +- .../src/store/module/websocket/websocket.ts | 2 +- Touchkebao/src/utils/db.ts | 52 ++-------- Touchkebao/src/utils/dbAction/contact.ts | 4 +- Touchkebao/src/utils/dbAction/message.ts | 6 ++ 10 files changed, 157 insertions(+), 80 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/Person.module.scss b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/Person.module.scss index 66cf4b59..add242f8 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/Person.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/Person.module.scss @@ -4,3 +4,51 @@ height: 100%; overflow-y: auto; } + +.tabHeader { + display: flex; + align-items: center; + padding: 0 30px; + border-bottom: 1px solid #f0f0f0; + min-height: 48px; +} + +.tabItem { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 24px; + padding: 12px 0; + font-size: 14px; + color: #333; + cursor: pointer; + transition: color 0.2s ease; +} + +.tabItem:last-child { + margin-right: 0; +} + +.tabItem:hover { + color: #1677ff; +} + +.tabItemActive { + color: #1677ff; + font-weight: 500; +} + +.tabUnderline { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: transparent; + transition: background 0.2s ease; +} + +.tabItemActive .tabUnderline { + background: #1677ff; +} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx index 9e06f593..847b3b41 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Input, Button, @@ -22,7 +22,7 @@ import { SwapOutlined, } from "@ant-design/icons"; import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; -import { useCkChatStore } from "@/store/module/ckchat/ckchat"; +import { useCustomerStore } from "@/store/module/weChat/customer"; import { useWebSocketStore } from "@/store/module/websocket/websocket"; import { useWeChatStore } from "@/store/module/weChat/weChat"; import { useContactStore } from "@/store/module/weChat/contacts"; @@ -209,9 +209,14 @@ const Person: React.FC<PersonProps> = ({ contract }) => { // 构建联系人或群聊详细信息 - const kfSelectedUser = useCkChatStore(state => - state.getKfUserInfo(contract.wechatAccountId || 0), - ); + const customerList = useCustomerStore(state => state.customerList); + const kfSelectedUser = useMemo(() => { + if (!contract.wechatAccountId) return null; + const matchedCustomer = customerList.find( + customer => customer.id === contract.wechatAccountId, + ); + return matchedCustomer || null; + }, [customerList, contract.wechatAccountId]); const { getContactsByCustomer } = useContactStore(); @@ -221,16 +226,12 @@ const Person: React.FC<PersonProps> = ({ contract }) => { const hasGroupManagePermission = () => { // 暂时给所有用户完整的群管理权限 return true; - // if (!kfSelectedUser || !contract) return false; - // // 当客服的wechatId与contract的chatroomOwner相同时,才有完整的群管理权限 - // return kfSelectedUser.nickname === (contract as any).chatroomOwnerNickname; }; // 获取所有可用标签 useEffect(() => { const fetchAvailableTags = async () => { try { - // 从kfSelectedUser.labels和contract.labels合并获取所有标签 const kfTags = kfSelectedUser?.labels || []; const contractTags = contract.labels || []; const allTags = [...new Set([...kfTags, ...contractTags])]; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/GroupModal.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/GroupModal.tsx index 420d942b..0754db88 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/GroupModal.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/GroupModal.tsx @@ -28,7 +28,7 @@ const GroupModal: React.FC<GroupModalProps> = ({ form.resetFields(); }} footer={null} - destroyOnClose + destroyOnHidden > <Form form={form} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx index 478f1097..f905f792 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx @@ -126,7 +126,7 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ form.resetFields(); }} footer={null} - destroyOnClose + destroyOnHidden > <Form form={form} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx index 831a60a2..5fe73a75 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { Layout, Tabs } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { Layout } from "antd"; import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import styles from "./Person.module.scss"; import ProfileModules from "./components/ProfileModules"; @@ -16,51 +16,107 @@ interface PersonProps { } const Person: React.FC<PersonProps> = ({ contract }) => { - const [activeKey, setActiveKey] = useState("profile"); + const [activeKey, setActiveKey] = useState("quickwords"); const isGroup = "chatroomId" in contract; const tabItems = useMemo(() => { const baseItems = [ { key: "quickwords", label: "快捷语录", + children: <QuickWords onInsert={noop} />, }, { key: "profile", label: isGroup ? "群资料" : "个人资料", + children: <ProfileModules contract={contract} />, }, ]; if (!isGroup) { baseItems.push({ key: "moments", label: "朋友圈", + children: <FriendsCircle wechatFriendId={contract.id} />, }); } return baseItems; - }, [isGroup]); + }, [contract, isGroup]); - const handleTabChange = useCallback((key: string) => { - setActiveKey(key); - }, []); + useEffect(() => { + setActiveKey("quickwords"); + setRenderedKeys(["quickwords"]); + }, [contract]); - const tabBarStyle = useMemo(() => ({ padding: "0 30px" }), []); + const tabHeaderItems = useMemo( + () => tabItems.map(({ key, label }) => ({ key, label })), + [tabItems], + ); + + const availableKeys = useMemo( + () => tabItems.map(item => item.key), + [tabItems], + ); + + const [renderedKeys, setRenderedKeys] = useState<string[]>(() => [ + "quickwords", + ]); + + useEffect(() => { + if (!availableKeys.includes(activeKey) && availableKeys.length > 0) { + setActiveKey(availableKeys[0]); + } + }, [activeKey, availableKeys]); + + useEffect(() => { + setRenderedKeys(keys => { + const filtered = keys.filter(key => availableKeys.includes(key)); + if (!filtered.includes(activeKey)) { + filtered.push(activeKey); + } + const isSameLength = filtered.length === keys.length; + const isSameOrder = + isSameLength && filtered.every((key, index) => key === keys[index]); + return isSameOrder ? keys : filtered; + }); + }, [activeKey, availableKeys]); return ( <Sider width={330} className={styles.profileSider}> <LayoutFiexd header={ - <Tabs - activeKey={activeKey} - onChange={handleTabChange} - tabBarStyle={tabBarStyle} - items={tabItems} - /> + <div className={styles.tabHeader}> + {tabHeaderItems.map(({ key, label }) => { + const isActive = key === activeKey; + return ( + <div + key={key} + className={`${styles.tabItem}${ + isActive ? ` ${styles.tabItemActive}` : "" + }`} + onClick={() => { + setActiveKey(key); + }} + > + <span>{label}</span> + <div className={styles.tabUnderline} /> + </div> + ); + })} + </div> } > - {activeKey === "profile" && <ProfileModules contract={contract} />} - {activeKey === "quickwords" && <QuickWords onInsert={noop} />} - {activeKey === "moments" && !isGroup && ( - <FriendsCircle wechatFriendId={contract.id} /> - )} + {renderedKeys.map(key => { + const item = tabItems.find(tab => tab.key === key); + if (!item) return null; + const isActive = key === activeKey; + return ( + <div + key={key} + style={{ display: isActive ? "block" : "none", height: "100%" }} + > + {item.children} + </div> + ); + })} </LayoutFiexd> </Sider> ); diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx index b24c8343..21712a08 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx @@ -343,9 +343,6 @@ const MessageList: React.FC<MessageListProps> = () => { }; }); - console.log("群聊数据示例:", groups[0]); // 调试:查看第一个群聊数据 - console.log("好友数据示例:", friends[0]); // 调试:查看第一个好友数据 - // 执行增量同步 const syncResult = await MessageManager.syncSessions(currentUserId, { friends, @@ -655,6 +652,8 @@ const MessageList: React.FC<MessageListProps> = () => { top: 0, }, sortKey: "", + phone: msgData.phone || "", + region: msgData.region || "", }; await MessageManager.addSession(newSession); @@ -681,6 +680,8 @@ const MessageList: React.FC<MessageListProps> = () => { top: 0, }, sortKey: "", + phone: msgData.phone || "", + region: msgData.region || "", }; await MessageManager.addSession(newSession); @@ -710,7 +711,6 @@ const MessageList: React.FC<MessageListProps> = () => { // 点击会话 const onContactClick = async (session: ChatSession) => { console.log("onContactClick", session); - console.log("session.aiType:", session.aiType); // 调试:查看 aiType 字段 // 设置当前会话 setCurrentContact(session as any); diff --git a/Touchkebao/src/store/module/websocket/websocket.ts b/Touchkebao/src/store/module/websocket/websocket.ts index 359fcf4a..51134cc2 100644 --- a/Touchkebao/src/store/module/websocket/websocket.ts +++ b/Touchkebao/src/store/module/websocket/websocket.ts @@ -394,7 +394,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( set({ messages: [...currentState.messages, newMessage], - unreadCount: currentState.config.unreadCount + 1, + unreadCount: (currentState.unreadCount ?? 0) + 1, }); //消息处理器 msgManageCore(data); diff --git a/Touchkebao/src/utils/db.ts b/Touchkebao/src/utils/db.ts index 61bffeb8..836d9b46 100644 --- a/Touchkebao/src/utils/db.ts +++ b/Touchkebao/src/utils/db.ts @@ -60,6 +60,8 @@ export interface ChatSession { chatroomOwner?: string; // 群主 selfDisplayName?: string; // 群内昵称 notice?: string; // 群公告 + phone?: string; // 联系人电话 + region?: string; // 联系人地区 } // ==================== 统一联系人表(兼容好友和群聊) ==================== @@ -128,15 +130,14 @@ class CunkebaoDatabase extends Dexie { constructor(dbName: string) { super(dbName); - // 版本1:统一表结构 this.version(1).stores({ // 会话表索引:支持按用户、类型、时间、置顶等查询 chatSessions: - "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], sortKey, nickname, conRemark, avatar, content, lastUpdateTime", + "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region", // 联系人表索引:支持按用户、类型、标签、搜索等查询 contactsUnified: - "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId", + "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region", // 联系人标签映射表索引:支持按用户、标签、联系人、类型查询 contactLabelMap: @@ -146,47 +147,6 @@ class CunkebaoDatabase extends Dexie { userLoginRecords: "serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime", }); - - // 版本2:添加 aiType 字段 - this.version(2) - .stores({ - // 会话表索引:添加 aiType 索引 - chatSessions: - "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType", - - // 联系人表索引:添加 aiType 索引 - contactsUnified: - "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType", - - // 联系人标签映射表索引:保持不变 - contactLabelMap: - "serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime", - - // 用户登录记录表索引:保持不变 - userLoginRecords: - "serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime", - }) - .upgrade(tx => { - // 数据迁移:为现有数据添加 aiType 默认值 - return tx - .table("chatSessions") - .toCollection() - .modify(session => { - if (session.aiType === undefined) { - session.aiType = 0; // 默认为普通类型 - } - }) - .then(() => { - return tx - .table("contactsUnified") - .toCollection() - .modify(contact => { - if (contact.aiType === undefined) { - contact.aiType = 0; // 默认为普通类型 - } - }); - }); - }); } } @@ -344,6 +304,8 @@ export class DatabaseService<T> { const dataToInsert = { ...data, serverId: data.id, // 使用接口的id作为serverId主键 + phone: data.phone ?? "", + region: data.region ?? "", }; return await this.table.add(dataToInsert as T); } @@ -407,6 +369,8 @@ export class DatabaseService<T> { const processedData = newData.map(item => ({ ...item, serverId: item.id, // 使用接口的id作为serverId主键 + phone: item.phone ?? "", + region: item.region ?? "", })); return await this.table.bulkAdd(processedData as T[], { allKeys: true }); diff --git a/Touchkebao/src/utils/dbAction/contact.ts b/Touchkebao/src/utils/dbAction/contact.ts index 254f2ec8..5630d4d4 100644 --- a/Touchkebao/src/utils/dbAction/contact.ts +++ b/Touchkebao/src/utils/dbAction/contact.ts @@ -184,7 +184,9 @@ export class ContactManager { local.conRemark !== server.conRemark || local.avatar !== server.avatar || local.wechatAccountId !== server.wechatAccountId || - (local.aiType ?? 0) !== (server.aiType ?? 0) // 添加 aiType 比较 + (local.aiType ?? 0) !== (server.aiType ?? 0) || // 添加 aiType 比较 + (local.phone ?? "") !== (server.phone ?? "") || + (local.region ?? "") !== (server.region ?? "") ); } diff --git a/Touchkebao/src/utils/dbAction/message.ts b/Touchkebao/src/utils/dbAction/message.ts index f6deae3d..9df03116 100644 --- a/Touchkebao/src/utils/dbAction/message.ts +++ b/Touchkebao/src/utils/dbAction/message.ts @@ -93,6 +93,8 @@ export class MessageManager { content: (friend as any).content || "", lastUpdateTime: friend.lastUpdateTime || new Date().toISOString(), aiType: (friend as any).aiType ?? 0, // AI类型,默认为0(普通) + phone: (friend as any).phone ?? "", + region: (friend as any).region ?? "", config: { unreadCount: friend.config?.unreadCount || 0, top: (friend.config as any)?.top || false, @@ -126,6 +128,8 @@ export class MessageManager { content: (group as any).content || "", lastUpdateTime: (group as any).lastUpdateTime || new Date().toISOString(), aiType: (group as any).aiType ?? 0, // AI类型,默认为0(普通) + phone: (group as any).phone ?? "", + region: (group as any).region ?? "", config: { unreadCount: (group.config as any)?.unreadCount || 0, top: (group.config as any)?.top || false, @@ -199,6 +203,8 @@ export class MessageManager { "avatar", "wechatAccountId", // 添加wechatAccountId比较 "aiType", // 添加aiType比较 + "phone", + "region", ]; for (const field of fieldsToCompare) { From c41d8125da2d12af208b6f591fbffe8c799068fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Thu, 13 Nov 2025 17:51:22 +0800 Subject: [PATCH 13/26] =?UTF-8?q?=E6=9B=B4=E6=96=B0ContractData=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=BB=A5=E4=BD=BFextendFields=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=94=B9=E7=9B=B8=E5=85=B3=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E9=80=BB=E8=BE=91=E3=80=82=E5=A2=9E=E5=BC=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=A8=A1=E5=BC=8F=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?extendFields=E4=BD=9C=E4=B8=BAJSON=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=A1=AE=E4=BF=9D=E5=9C=A8=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=9C=9F=E9=97=B4=E8=BF=9B=E8=A1=8C=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=A4=84=E7=90=86=E3=80=82=E6=94=B9=E8=BF=9BContactMa?= =?UTF-8?q?nager=E4=B8=AD=E7=9A=84=E6=AF=94=E8=BE=83=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=BB=A5=E5=8C=85=E5=90=ABextendFields=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Touchkebao/src/pages/pc/ckbox/data.ts | 2 +- .../SidebarMenu/MessageList/data.ts | 2 +- .../SidebarMenu/WechatFriends/extend.ts | 33 +++++++ Touchkebao/src/pages/pc/ckbox/weChat/data.ts | 2 +- .../src/store/module/weChat/contacts.data.ts | 2 +- Touchkebao/src/utils/db.ts | 88 ++++++++++++++++--- Touchkebao/src/utils/dbAction/contact.ts | 9 +- Touchkebao/src/utils/dbAction/message.ts | 17 ++++ 8 files changed, 136 insertions(+), 19 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/data.ts b/Touchkebao/src/pages/pc/ckbox/data.ts index 037a4367..9b8aa285 100644 --- a/Touchkebao/src/pages/pc/ckbox/data.ts +++ b/Touchkebao/src/pages/pc/ckbox/data.ts @@ -146,7 +146,7 @@ export interface ContractData { labels: string[]; signature: string; accountId: number; - extendFields: null; + extendFields?: Record<string, any> | null; city?: string; lastUpdateTime: string; isPassed: boolean; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/data.ts b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/data.ts index 5aa5c726..bf431076 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/data.ts +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/data.ts @@ -15,7 +15,7 @@ export interface ContractData { labels: string[]; signature: string; accountId: number; - extendFields: null; + extendFields?: Record<string, any> | null; city?: string; lastUpdateTime: string; isPassed: boolean; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/extend.ts b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/extend.ts index 220c4d56..cf2b8117 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/extend.ts +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/extend.ts @@ -82,6 +82,20 @@ export const getAllGroups = async () => { } }; +const serializeExtendFields = (value: any) => { + if (typeof value === "string") { + return value.trim() ? value : "{}"; + } + if (value && typeof value === "object") { + try { + return JSON.stringify(value); + } catch (error) { + console.warn("序列化 extendFields 失败:", error); + } + } + return "{}"; +}; + /** * 将好友数据转换为统一的 Contact 格式 */ @@ -95,11 +109,21 @@ export const convertFriendsToContacts = ( id: friend.id, type: "friend" as const, wechatAccountId: friend.wechatAccountId, + wechatFriendId: friend.id, wechatId: friend.wechatId, nickname: friend.nickname || "", conRemark: friend.conRemark || "", avatar: friend.avatar || "", + alias: friend.alias || "", + gender: friend.gender, + aiType: friend.aiType ?? 0, + phone: friend.phone ?? "", + region: friend.region ?? "", + quanPin: friend.quanPin || "", + signature: friend.signature || "", + config: friend.config || {}, groupId: friend.groupId, // 保留标签ID + extendFields: serializeExtendFields(friend.extendFields), lastUpdateTime: new Date().toISOString(), sortKey: "", searchKey: "", @@ -120,10 +144,19 @@ export const convertGroupsToContacts = ( type: "group" as const, wechatAccountId: group.wechatAccountId, wechatId: group.chatroomId || "", + chatroomId: group.chatroomId || "", + chatroomOwner: group.chatroomOwner || "", nickname: group.nickname || "", conRemark: group.conRemark || "", avatar: group.chatroomAvatar || group.avatar || "", + selfDisplayName: group.selfDisplyName || "", + notice: group.notice || "", + aiType: group.aiType ?? 0, + phone: group.phone ?? "", + region: group.region ?? "", + config: group.config || {}, groupId: group.groupId, // 保留标签ID + extendFields: serializeExtendFields(group.extendFields), lastUpdateTime: new Date().toISOString(), sortKey: "", searchKey: "", diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/data.ts b/Touchkebao/src/pages/pc/ckbox/weChat/data.ts index 7b0a7aca..d69fe801 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/data.ts +++ b/Touchkebao/src/pages/pc/ckbox/weChat/data.ts @@ -144,7 +144,7 @@ export interface ContractData { labels: string[]; signature: string; accountId: number; - extendFields: null; + extendFields?: Record<string, any> | null; city?: string; lastUpdateTime: string; isPassed: boolean; diff --git a/Touchkebao/src/store/module/weChat/contacts.data.ts b/Touchkebao/src/store/module/weChat/contacts.data.ts index 0ef039e1..c0769fd9 100644 --- a/Touchkebao/src/store/module/weChat/contacts.data.ts +++ b/Touchkebao/src/store/module/weChat/contacts.data.ts @@ -50,7 +50,7 @@ export interface ContractData { labels: string[]; signature: string; accountId: number; - extendFields: null; + extendFields?: Record<string, any> | null; city?: string; lastUpdateTime: string; isPassed: boolean; diff --git a/Touchkebao/src/utils/db.ts b/Touchkebao/src/utils/db.ts index 836d9b46..69da02e2 100644 --- a/Touchkebao/src/utils/db.ts +++ b/Touchkebao/src/utils/db.ts @@ -62,6 +62,7 @@ export interface ChatSession { notice?: string; // 群公告 phone?: string; // 联系人电话 region?: string; // 联系人地区 + extendFields?: string; // 扩展字段(JSON 字符串) } // ==================== 统一联系人表(兼容好友和群聊) ==================== @@ -92,6 +93,7 @@ export interface Contact { signature?: string; // 个性签名 phone?: string; // 手机号 quanPin?: string; // 全拼 + extendFields?: string; // 扩展字段(JSON 字符串) // 群聊特有字段(type='group'时有效) chatroomId?: string; // 群聊ID @@ -147,6 +149,41 @@ class CunkebaoDatabase extends Dexie { userLoginRecords: "serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime", }); + + this.version(2) + .stores({ + chatSessions: + "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+lastUpdateTime], [userId+aiType], sortKey, nickname, conRemark, avatar, content, lastUpdateTime, aiType, phone, region, extendFields", + contactsUnified: + "serverId, userId, id, type, wechatAccountId, [userId+type], [userId+wechatAccountId], [userId+aiType], sortKey, searchKey, nickname, conRemark, avatar, lastUpdateTime, groupId, aiType, phone, region, extendFields", + contactLabelMap: + "serverId, userId, labelId, contactId, contactType, [userId+labelId], [userId+contactId], [userId+labelId+sortKey], sortKey, searchKey, avatar, nickname, conRemark, unreadCount, lastUpdateTime", + userLoginRecords: + "serverId, userId, lastLoginTime, loginCount, createTime, lastActiveTime", + }) + .upgrade(async tx => { + await tx + .table("chatSessions") + .toCollection() + .modify(session => { + if (!("extendFields" in session) || session.extendFields == null) { + session.extendFields = "{}"; + } else if (typeof session.extendFields !== "string") { + session.extendFields = JSON.stringify(session.extendFields); + } + }); + + await tx + .table("contactsUnified") + .toCollection() + .modify(contact => { + if (!("extendFields" in contact) || contact.extendFields == null) { + contact.extendFields = "{}"; + } else if (typeof contact.extendFields !== "string") { + contact.extendFields = JSON.stringify(contact.extendFields); + } + }); + }); } } @@ -295,18 +332,18 @@ export class DatabaseService<T> { // 基础 CRUD 操作 - 使用serverId作为主键 async create(data: Omit<T, "serverId">): Promise<string | number> { - return await this.table.add(data as T); + return await this.table.add(this.prepareDataForWrite(data) as T); } // 创建数据(直接使用接口数据) // 接口数据的id字段直接作为serverId主键,原id字段保留 async createWithServerId(data: any): Promise<string | number> { - const dataToInsert = { + const dataToInsert = this.prepareDataForWrite({ ...data, serverId: data.id, // 使用接口的id作为serverId主键 phone: data.phone ?? "", region: data.region ?? "", - }; + }); return await this.table.add(dataToInsert as T); } @@ -325,7 +362,10 @@ export class DatabaseService<T> { } async update(serverId: string | number, data: Partial<T>): Promise<number> { - return await this.table.update(serverId, data as any); + return await this.table.update( + serverId, + this.prepareDataForWrite(data) as any, + ); } async updateMany( @@ -334,7 +374,7 @@ export class DatabaseService<T> { return await this.table.bulkUpdate( dataList.map(item => ({ key: item.serverId, - changes: item.data as any, + changes: this.prepareDataForWrite(item.data) as any, })), ); } @@ -342,7 +382,8 @@ export class DatabaseService<T> { async createMany( dataList: Omit<T, "serverId">[], ): Promise<(string | number)[]> { - return await this.table.bulkAdd(dataList as T[], { allKeys: true }); + const processed = dataList.map(item => this.prepareDataForWrite(item)); + return await this.table.bulkAdd(processed as T[], { allKeys: true }); } // 批量创建数据(直接使用接口数据) @@ -366,12 +407,14 @@ export class DatabaseService<T> { return []; } - const processedData = newData.map(item => ({ - ...item, - serverId: item.id, // 使用接口的id作为serverId主键 - phone: item.phone ?? "", - region: item.region ?? "", - })); + const processedData = newData.map(item => + this.prepareDataForWrite({ + ...item, + serverId: item.id, // 使用接口的id作为serverId主键 + phone: item.phone ?? "", + region: item.region ?? "", + }), + ); return await this.table.bulkAdd(processedData as T[], { allKeys: true }); } @@ -545,6 +588,27 @@ export class DatabaseService<T> { .equals(value) .count(); } + + private prepareDataForWrite(data: any) { + if (!data || typeof data !== "object") { + return data; + } + + const prepared = { ...data }; + + if ("extendFields" in prepared) { + const value = prepared.extendFields; + if (typeof value === "string" && value.trim() !== "") { + prepared.extendFields = value; + } else if (value && typeof value === "object") { + prepared.extendFields = JSON.stringify(value); + } else { + prepared.extendFields = "{}"; + } + } + + return prepared; + } } // 创建统一表的服务实例 diff --git a/Touchkebao/src/utils/dbAction/contact.ts b/Touchkebao/src/utils/dbAction/contact.ts index 5630d4d4..abbb7fdb 100644 --- a/Touchkebao/src/utils/dbAction/contact.ts +++ b/Touchkebao/src/utils/dbAction/contact.ts @@ -186,7 +186,8 @@ export class ContactManager { local.wechatAccountId !== server.wechatAccountId || (local.aiType ?? 0) !== (server.aiType ?? 0) || // 添加 aiType 比较 (local.phone ?? "") !== (server.phone ?? "") || - (local.region ?? "") !== (server.region ?? "") + (local.region ?? "") !== (server.region ?? "") || + (local.extendFields ?? "{}") !== (server.extendFields ?? "{}") ); } @@ -194,10 +195,12 @@ export class ContactManager { * 获取联系人分组列表 */ static async getContactGroups( - userId: number, - customerId?: number, + _userId: number, + _customerId?: number, ): Promise<ContactGroupByLabel[]> { try { + void _userId; + void _customerId; // 这里应该根据实际的标签系统来实现 // 暂时返回空数组,实际实现需要根据标签表来查询 return []; diff --git a/Touchkebao/src/utils/dbAction/message.ts b/Touchkebao/src/utils/dbAction/message.ts index 9df03116..d42289bf 100644 --- a/Touchkebao/src/utils/dbAction/message.ts +++ b/Touchkebao/src/utils/dbAction/message.ts @@ -11,6 +11,20 @@ import Dexie from "dexie"; import { db, chatSessionService, ChatSession } from "../db"; import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; +const serializeExtendFields = (value: any) => { + if (typeof value === "string") { + return value.trim() ? value : "{}"; + } + if (value && typeof value === "object") { + try { + return JSON.stringify(value); + } catch (error) { + console.warn("序列化 extendFields 失败:", error); + } + } + return "{}"; +}; + export class MessageManager { private static updateCallbacks = new Set<(sessions: ChatSession[]) => void>(); @@ -103,6 +117,7 @@ export class MessageManager { wechatFriendId: friend.id, wechatId: friend.wechatId, alias: friend.alias, + extendFields: serializeExtendFields((friend as any).extendFields), }; } @@ -139,6 +154,7 @@ export class MessageManager { chatroomOwner: group.chatroomOwner, selfDisplayName: group.selfDisplyName, notice: group.notice, + extendFields: serializeExtendFields((group as any).extendFields), }; } @@ -205,6 +221,7 @@ export class MessageManager { "aiType", // 添加aiType比较 "phone", "region", + "extendFields", ]; for (const field of fieldsToCompare) { From b3924cdb71c90a6dbfd566418a3baf4c22d53905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 10:07:38 +0800 Subject: [PATCH 14/26] =?UTF-8?q?=E4=BC=98=E5=8C=96CreatePushTask=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=AD=E7=9A=84=E8=81=94=E7=B3=BB=E4=BA=BA=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E6=A0=87=E7=AD=BE=E3=80=81=E5=9F=8E=E5=B8=82=E5=92=8C?= =?UTF-8?q?=E6=98=B5=E7=A7=B0/=E5=A4=87=E6=B3=A8=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=A8=A1=E6=80=81=E3=80=82=E6=9B=B4=E6=96=B0=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=A8=A1=E6=80=81=E7=9A=84=E6=A0=B7=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=BF=87=E6=BB=A4=E5=80=BC=E7=9A=84=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C=E4=BB=A5=E6=94=B9=E5=96=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Moncter/src/pages/pc/ckbox/weChat/api.ts | 0 .../components/ProfileCard/components/ProfileModules/index.tsx | 0 Moncter/src/store/module/websocket/websocket.ts | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Moncter/src/pages/pc/ckbox/weChat/api.ts create mode 100644 Moncter/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx create mode 100644 Moncter/src/store/module/websocket/websocket.ts diff --git a/Moncter/src/pages/pc/ckbox/weChat/api.ts b/Moncter/src/pages/pc/ckbox/weChat/api.ts new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Moncter/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/store/module/websocket/websocket.ts b/Moncter/src/store/module/websocket/websocket.ts new file mode 100644 index 00000000..e69de29b From dbe4ee692ca8edee3f1b6c52c48455c1a82c915f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 11:00:21 +0800 Subject: [PATCH 15/26] =?UTF-8?q?=E6=9B=B4=E6=96=B0WeChat=20API=E4=BB=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=96=B0=E7=AB=AF=E7=82=B9=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E8=8E=B7=E5=8F=96=E5=8F=AF=E8=BD=AC=E7=A7=BB=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E5=88=97=E8=A1=A8=E7=9A=84=E9=80=BB=E8=BE=91=E3=80=82?= =?UTF-8?q?=E9=87=8D=E6=9E=84ProfileCard=E7=BB=84=E4=BB=B6=E4=BB=A5?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E9=BB=98=E8=AE=A4=E6=B4=BB=E5=8A=A8=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=8D=A1=E4=B8=BA=E2=80=9Cprofile=E2=80=9D=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E3=80=82=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E9=80=BB=E8=BE=91=EF=BC=8C=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Touchkebao/src/pages/pc/ckbox/weChat/api.ts | 2 +- .../components/toContract/index.tsx | 2 +- .../ProfileModules/components/detailValue.tsx | 102 ++++++++++++++++++ .../components/ProfileModules/index.tsx | 26 +---- .../components/ProfileCard/index.tsx | 10 +- 5 files changed, 112 insertions(+), 30 deletions(-) create mode 100644 Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/api.ts b/Touchkebao/src/pages/pc/ckbox/weChat/api.ts index 8ce1bcac..32bb9604 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/weChat/api.ts @@ -94,7 +94,7 @@ export function WechatFriendAllot(params: { //获取可转移客服列表 export function getTransferableAgentList() { - return request2("/api/account/myDepartmentAccountsForTransfer", {}, "GET"); + return request("/v1/kefu/accounts/list", {}, "GET"); } // 微信好友列表 diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx index 1c7975f5..21dfa37a 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx @@ -49,7 +49,7 @@ const ToContract: React.FC<ToContractProps> = ({ const openModal = () => { setVisible(true); getTransferableAgentList().then(data => { - setCustomerServiceList(data); + setCustomerServiceList(data.list); }); }; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx new file mode 100644 index 00000000..b6377058 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx @@ -0,0 +1,102 @@ +import React, { useCallback } from "react"; +import { Button, Input } from "antd"; + +import styles from "../Person.module.scss"; + +export interface DetailValueField { + label: string; + key: string; + ifEdit?: boolean; + placeholder?: string; +} + +export interface DetailValueProps { + fields: DetailValueField[]; + value?: Record<string, string>; + onChange?: (next: Record<string, string>) => void; + onSubmit?: (next: Record<string, string>) => void; + submitText?: string; + submitting?: boolean; + renderFooter?: React.ReactNode; +} + +const DetailValue: React.FC<DetailValueProps> = ({ + fields, + value, + onChange, + onSubmit, + submitText = "保存", + submitting = false, + renderFooter, +}) => { + const handleFieldChange = useCallback( + (fieldKey: string, nextVal: string) => { + const baseValue = value ?? {}; + const nextValue = { + ...baseValue, + [fieldKey]: nextVal, + }; + onChange?.(nextValue); + }, + [onChange, value], + ); + + const handleSubmit = useCallback(() => { + onSubmit?.(value ?? {}); + }, [onSubmit, value]); + + const formValue = value ?? {}; + + return ( + <div> + {fields.map(field => { + const disabled = field.ifEdit === false; + const fieldValue = formValue[field.key] ?? ""; + + return ( + <div key={field.key} className={styles.infoItem}> + <span className={styles.infoLabel}>{field.label}:</span> + <div className={styles.infoValue}> + {disabled ? ( + <span>{fieldValue || field.placeholder || "--"}</span> + ) : ( + <Input + value={fieldValue} + placeholder={field.placeholder} + onChange={event => + handleFieldChange(field.key, event.target.value) + } + onPressEnter={handleSubmit} + /> + )} + </div> + </div> + ); + })} + + {(onSubmit || renderFooter) && ( + <div + style={{ + display: "flex", + justifyContent: "flex-end", + marginTop: 16, + }} + > + {renderFooter} + {onSubmit && ( + <Button + type="primary" + loading={submitting} + onClick={handleSubmit} + style={{ marginLeft: renderFooter ? 8 : 0 }} + > + {submitText} + </Button> + )} + </div> + )} + </div> + ); +}; + +export default DetailValue; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx index 847b3b41..b71db1e0 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx @@ -97,9 +97,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => { useState(false); const [isTransferOwnerSelectionVisible, setIsTransferOwnerSelectionVisible] = useState(false); - const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>( - [], - ); + const [contractList, setContractList] = useState<any[]>([]); const handleAddFriend = member => { @@ -374,16 +372,6 @@ const Person: React.FC<PersonProps> = ({ contract }) => { messageApi.success("已应用AI生成的群公告内容"); }; - // 点击编辑群公告按钮 - const handleEditGroupNotice = () => { - if (!hasGroupManagePermission()) { - messageApi.error("只有群主才能修改群公告"); - return; - } - setGroupNoticeValue(contract.notice || ""); - setIsGroupNoticeModalVisible(true); - }; - // 处理我在本群中的昵称保存 const handleSaveSelfDisplayName = () => { sendCommand("CmdChatroomOperate", { @@ -397,12 +385,6 @@ const Person: React.FC<PersonProps> = ({ contract }) => { setIsEditingSelfDisplayName(false); }; - // 点击编辑群昵称按钮 - const handleEditSelfDisplayName = () => { - setSelfDisplayNameValue(contract.selfDisplyName || ""); - setIsEditingSelfDisplayName(true); - }; - // 处理取消编辑 const handleCancelEdit = () => { setRemarkValue(contract.conRemark || ""); @@ -508,18 +490,19 @@ const Person: React.FC<PersonProps> = ({ contract }) => { }, }); }; - + const extendFields = JSON.parse(contract.extendFields || "{}"); // 构建联系人或群聊详细信息 const contractInfo = { name: contract.name || contract.nickname, nickname: contract.nickname, - conRemark: remarkValue, // 使用当前编辑的备注值 alias: contract.alias, wechatId: contract.wechatId, chatroomId: isGroup ? contract.chatroomId : undefined, chatroomOwner: isGroup ? contract.chatroomOwner : undefined, avatar: contract.avatar || contract.chatroomAvatar, phone: contract.phone || "-", + conRemark: remarkValue, // 使用当前编辑的备注值 + remark: extendFields.remark || "-", email: contract.email || "-", department: contract.department || "-", position: contract.position || "-", @@ -1278,7 +1261,6 @@ const Person: React.FC<PersonProps> = ({ contract }) => { visible={isFriendSelectionVisible} onCancel={() => setIsFriendSelectionVisible(false)} onConfirm={(selectedIds, selectedItems) => { - setSelectedFriends(selectedItems); handleAddMember( selectedIds.map(id => parseInt(id)), selectedItems, diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx index 5fe73a75..7d322637 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx @@ -16,7 +16,7 @@ interface PersonProps { } const Person: React.FC<PersonProps> = ({ contract }) => { - const [activeKey, setActiveKey] = useState("quickwords"); + const [activeKey, setActiveKey] = useState("profile"); const isGroup = "chatroomId" in contract; const tabItems = useMemo(() => { const baseItems = [ @@ -42,8 +42,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => { }, [contract, isGroup]); useEffect(() => { - setActiveKey("quickwords"); - setRenderedKeys(["quickwords"]); + setActiveKey("profile"); + setRenderedKeys(["profile"]); }, [contract]); const tabHeaderItems = useMemo( @@ -56,9 +56,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => { [tabItems], ); - const [renderedKeys, setRenderedKeys] = useState<string[]>(() => [ - "quickwords", - ]); + const [renderedKeys, setRenderedKeys] = useState<string[]>(() => ["profile"]); useEffect(() => { if (!availableKeys.includes(activeKey) && availableKeys.length > 0) { From 01bf7ee2711399bd5923f188db773eb4a90f256a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 15:30:00 +0800 Subject: [PATCH 16/26] =?UTF-8?q?=E6=B7=BB=E5=8A=A0WebSocket=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=AD=E7=9A=84=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=AF=B7=E6=B1=82=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E9=A2=91=E7=B9=81=E8=AF=B7=E6=B1=82=E3=80=82?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=96=B0=E7=9A=84=E7=8A=B6=E6=80=81=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E4=BB=A5=E8=B7=9F=E8=B8=AA=E6=9C=80=E5=90=8E=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=97=B6=E9=97=B4=EF=BC=8C=E5=B9=B6=E5=9C=A8=E5=8F=91?= =?UTF-8?q?=E9=80=81=E6=B4=BB=E8=B7=83=E7=8A=B6=E6=80=81=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=97=B6=E8=BF=9B=E8=A1=8C=E6=97=B6=E9=97=B4=E9=97=B4=E9=9A=94?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E3=80=82=E6=B8=85=E7=90=86=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BB=A5=E7=A1=AE=E4=BF=9D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/store/module/websocket/websocket.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Touchkebao/src/store/module/websocket/websocket.ts b/Touchkebao/src/store/module/websocket/websocket.ts index 51134cc2..57ed7f15 100644 --- a/Touchkebao/src/store/module/websocket/websocket.ts +++ b/Touchkebao/src/store/module/websocket/websocket.ts @@ -53,6 +53,7 @@ interface WebSocketState { reconnectTimer: NodeJS.Timeout | null; aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器 aliveStatusUnsubscribe: (() => void) | null; + aliveStatusLastRequest: number | null; // 方法 connect: (config: Partial<WebSocketConfig>) => void; @@ -88,6 +89,8 @@ const DEFAULT_CONFIG: WebSocketConfig = { maxReconnectAttempts: 5, }; +const ALIVE_STATUS_MIN_INTERVAL = 5 * 1000; // ms + export const useWebSocketStore = createPersistStore<WebSocketState>( (set, get) => ({ status: WebSocketStatus.DISCONNECTED, @@ -99,6 +102,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( reconnectTimer: null, aliveStatusTimer: null, aliveStatusUnsubscribe: null, + aliveStatusLastRequest: null, // 连接WebSocket connect: (config: Partial<WebSocketConfig>) => { @@ -234,11 +238,6 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( currentState.status !== WebSocketStatus.CONNECTED || !currentState.ws ) { - // Toast.show({ - // content: "WebSocket未连接,正在重新连接...", - // position: "top", - // }); - // 重置连接状态并发起重新连接 set({ status: WebSocketStatus.DISCONNECTED }); if (currentState.config) { @@ -485,6 +484,14 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( return; } + const now = Date.now(); + if ( + state.aliveStatusLastRequest && + now - state.aliveStatusLastRequest < ALIVE_STATUS_MIN_INTERVAL + ) { + return; + } + const { customerList } = useCustomerStore.getState(); const { kfUserList } = useCkChatStore.getState(); const targets = @@ -498,6 +505,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( state.sendCommand("CmdRequestWechatAccountsAliveStatus", { wechatAccountIds: targets.map(v => v.id), }); + set({ aliveStatusLastRequest: now }); } }; @@ -556,7 +564,11 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( if (currentState.aliveStatusTimer) { clearInterval(currentState.aliveStatusTimer); } - set({ aliveStatusTimer: null, aliveStatusUnsubscribe: null }); + set({ + aliveStatusTimer: null, + aliveStatusUnsubscribe: null, + aliveStatusLastRequest: null, + }); }, }), { @@ -568,6 +580,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>( messages: state.messages.slice(-100), // 只保留最近100条消息 unreadCount: state.unreadCount, reconnectAttempts: state.reconnectAttempts, + aliveStatusLastRequest: state.aliveStatusLastRequest, // 注意:定时器不需要持久化,重新连接时会重新创建 }), onRehydrateStorage: () => state => { From eca55495924fc3c120ddd96ab2f02545cc12056b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 16:19:18 +0800 Subject: [PATCH 17/26] =?UTF-8?q?=E4=BC=98=E5=8C=96SidebarMenu=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E5=86=85=E5=AE=B9=E6=B8=B2=E6=9F=93=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8useRef=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E5=8D=A1=E5=86=85=E5=AE=B9=E4=BB=A5=E6=8F=90?= =?UTF-8?q?=E9=AB=98=E6=80=A7=E8=83=BD=E3=80=82=E6=9B=B4=E6=96=B0=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=90=9C=E7=B4=A2=E5=85=B3=E9=94=AE=E8=AF=8D?= =?UTF-8?q?=E7=9A=84=E9=98=B2=E6=8A=96=E5=A4=84=E7=90=86=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=90=9C=E7=B4=A2=E8=AF=B7=E6=B1=82=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E9=A2=91=E7=B9=81=E8=A7=A6=E5=8F=91=E3=80=82=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=94=A8=E6=88=B7ID=E7=9A=84=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=BB=A5=E4=BC=98=E5=8C=96=E6=90=9C=E7=B4=A2=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E7=9A=84=E5=A4=84=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weChat/components/SidebarMenu/index.tsx | 52 +++++++++++++++---- .../src/store/module/weChat/contacts.ts | 23 +++++++- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/index.tsx index 3699b995..cf72b962 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Input, Skeleton, Button, Dropdown, MenuProps } from "antd"; import { SearchOutlined, @@ -193,6 +193,27 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => { </div> ); + const tabContentCacheRef = useRef<Record<string, React.ReactNode>>({}); + + const getTabContent = (tabKey: string) => { + if (!tabContentCacheRef.current[tabKey]) { + switch (tabKey) { + case "chats": + tabContentCacheRef.current[tabKey] = <MessageList />; + break; + case "contracts": + tabContentCacheRef.current[tabKey] = <WechatFriends />; + break; + case "friendsCicle": + tabContentCacheRef.current[tabKey] = <FriendsCircle />; + break; + default: + tabContentCacheRef.current[tabKey] = null; + } + } + return tabContentCacheRef.current[tabKey]; + }; + // 渲染内容部分 const renderContent = () => { // 如果正在切换tab到聊天,显示骨架屏 @@ -200,16 +221,27 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => { return renderSkeleton(); } - switch (activeTab) { - case "chats": - return <MessageList />; - case "contracts": - return <WechatFriends />; - case "friendsCicle": - return <FriendsCircle />; - default: - return null; + const availableTabs = ["chats", "contracts"]; + if (currentCustomer && currentCustomer.id !== 0) { + availableTabs.push("friendsCicle"); } + + return ( + <> + {availableTabs.map(tabKey => ( + <div + key={tabKey} + style={{ + display: activeTab === tabKey ? "block" : "none", + height: "100%", + }} + aria-hidden={activeTab !== tabKey} + > + {getTabContent(tabKey)} + </div> + ))} + </> + ); }; if (loading) { diff --git a/Touchkebao/src/store/module/weChat/contacts.ts b/Touchkebao/src/store/module/weChat/contacts.ts index ca875b97..ee8827f2 100644 --- a/Touchkebao/src/store/module/weChat/contacts.ts +++ b/Touchkebao/src/store/module/weChat/contacts.ts @@ -3,6 +3,10 @@ import { persist } from "zustand/middleware"; import { ContactGroupByLabel } from "@/pages/pc/ckbox/data"; import { Contact } from "@/utils/db"; import { ContactManager } from "@/utils/dbAction"; +import { useUserStore } from "@/store/module/user"; + +const SEARCH_DEBOUNCE_DELAY = 300; +let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null; /** * 联系人状态管理接口 @@ -171,8 +175,16 @@ export const useContactStore = create<ContactState>()( setSearchKeyword: (keyword: string) => { set({ searchKeyword: keyword }); + + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } + if (keyword.trim()) { - get().searchContacts(keyword); + searchDebounceTimer = setTimeout(() => { + get().searchContacts(keyword); + }, SEARCH_DEBOUNCE_DELAY); } else { set({ isSearchMode: false, searchResults: [] }); } @@ -204,8 +216,15 @@ export const useContactStore = create<ContactState>()( set({ loading: true, isSearchMode: true }); try { + const currentUserId = useUserStore.getState().user?.id; + + if (!currentUserId) { + set({ searchResults: [], isSearchMode: false, loading: false }); + return; + } + const results = await ContactManager.searchContacts( - get().currentContact?.userId || 0, + currentUserId, keyword, ); set({ searchResults: results }); From 8d5869e6c271cbc1c391c92c61165e154aad5ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 17:09:43 +0800 Subject: [PATCH 18/26] /v1/kefu/message/details --- Touchkebao/src/pages/pc/ckbox/api.ts | 28 ++++++++++++++++++-- Touchkebao/src/store/module/weChat/weChat.ts | 4 +-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/api.ts b/Touchkebao/src/pages/pc/ckbox/api.ts index 1fe61086..3840caff 100644 --- a/Touchkebao/src/pages/pc/ckbox/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/api.ts @@ -44,7 +44,7 @@ export function getChatMessages(params: { Count: number; olderData: boolean; }) { - return request2("/api/FriendMessage/SearchMessage", params, "GET"); + return request("/v1/kefu/message/details", params, "GET"); } export function getChatroomMessages(params: { wechatAccountId: number; @@ -55,8 +55,32 @@ export function getChatroomMessages(params: { Count: number; olderData: boolean; }) { - return request2("/api/ChatroomMessage/SearchMessage", params, "GET"); + return request("/v1/kefu/message/details", params, "GET"); } +//=====================旧============================== + +// export function getChatMessages(params: { +// wechatAccountId: number; +// wechatFriendId?: number; +// wechatChatroomId?: number; +// From: number; +// To: number; +// Count: number; +// olderData: boolean; +// }) { +// return request2("/api/FriendMessage/SearchMessage", params, "GET"); +// } +// export function getChatroomMessages(params: { +// wechatAccountId: number; +// wechatFriendId?: number; +// wechatChatroomId?: number; +// From: number; +// To: number; +// Count: number; +// olderData: boolean; +// }) { +// return request2("/api/ChatroomMessage/SearchMessage", params, "GET"); +// } //获取群列表 export function getGroupList(params: { prevId: number; count: number }) { diff --git a/Touchkebao/src/store/module/weChat/weChat.ts b/Touchkebao/src/store/module/weChat/weChat.ts index f161bac0..889d528b 100644 --- a/Touchkebao/src/store/module/weChat/weChat.ts +++ b/Touchkebao/src/store/module/weChat/weChat.ts @@ -499,7 +499,7 @@ export const useWeChatStore = create<WeChatState>()( } else { set({ currentMessages: [ - ...(messages || []), + ...(messages.list || []), ...state.currentMessages, ], }); @@ -513,7 +513,7 @@ export const useWeChatStore = create<WeChatState>()( } else { set({ currentMessages: [ - ...(messages || []), + ...(messages.list || []), ...state.currentMessages, ], }); From 4684e880b18fb10f9d1eed8428c14bd589289ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 18:23:54 +0800 Subject: [PATCH 19/26] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86=E5=92=8C=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E3=80=82=E4=B8=BA=E6=B6=88=E6=81=AF=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=82=E6=95=B0=E5=BC=95=E5=85=A5=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=BB=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8=E6=80=A7=E3=80=82?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=8E=B7=E5=8F=96=E8=81=8A=E5=A4=A9=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=92=8C=E8=81=8A=E5=A4=A9=E5=AE=A4=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=9A=84API=E8=B0=83=E7=94=A8=EF=BC=8C=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=86=E9=A1=B5=E3=80=82=E9=80=9A=E8=BF=87=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=99=A8=E5=92=8C=E4=BC=98=E5=8C=96=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E6=9D=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96MessageList=E7=BB=84=E4=BB=B6=E3=80=82=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=A1=AE=E4=BF=9D=E7=BB=84=E4=BB=B6=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E6=B6=88=E6=81=AF=E5=8A=A0=E8=BD=BD=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E8=87=B4=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Touchkebao/src/pages/pc/ckbox/api.ts | 48 ++-- .../components/toContract/index.tsx | 11 +- .../components/MessageRecord/index.tsx | 53 ++-- .../SidebarMenu/MessageList/index.tsx | 158 +++++------ .../src/store/module/weChat/message.data.ts | 22 +- Touchkebao/src/store/module/weChat/message.ts | 91 ++++++- .../src/store/module/weChat/weChat.data.ts | 8 +- Touchkebao/src/store/module/weChat/weChat.ts | 252 ++++++++++++++---- Touchkebao/src/utils/dbAction/message.ts | 17 +- 9 files changed, 448 insertions(+), 212 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/api.ts b/Touchkebao/src/pages/pc/ckbox/api.ts index 3840caff..f5717fa7 100644 --- a/Touchkebao/src/pages/pc/ckbox/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/api.ts @@ -35,26 +35,38 @@ export function updateConfig(params) { return request2("/api/WechatFriend/updateConfig", params, "PUT"); } //获取聊天记录-2 获取列表 -export function getChatMessages(params: { - wechatAccountId: number; - wechatFriendId?: number; - wechatChatroomId?: number; - From: number; - To: number; - Count: number; - olderData: boolean; -}) { +export interface messreocrParams { + From?: number | string; + To?: number | string; + /** + * 当前页码,从 1 开始 + */ + page?: number; + /** + * 每页条数 + */ + limit?: number; + /** + * 群id + */ + wechatChatroomId?: number | string; + /** + * 好友id + */ + wechatFriendId?: number | string; + /** + * 微信账号ID + */ + wechatAccountId?: number | string; + /** + * 关键词、类型等扩展参数 + */ + [property: string]: any; +} +export function getChatMessages(params: messreocrParams) { return request("/v1/kefu/message/details", params, "GET"); } -export function getChatroomMessages(params: { - wechatAccountId: number; - wechatFriendId?: number; - wechatChatroomId?: number; - From: number; - To: number; - Count: number; - olderData: boolean; -}) { +export function getChatroomMessages(params: messreocrParams) { return request("/v1/kefu/message/details", params, "GET"); } //=====================旧============================== diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx index 21dfa37a..1b4d86f7 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/toContract/index.tsx @@ -9,7 +9,6 @@ import { import { useCurrentContact } from "@/store/module/weChat/weChat"; import { ContactManager } from "@/utils/dbAction/contact"; import { MessageManager } from "@/utils/dbAction/message"; -import { triggerRefresh } from "@/store/module/weChat/message"; import { useUserStore } from "@/store/module/user"; import { useWeChatStore } from "@/store/module/weChat/weChat"; const { TextArea } = Input; @@ -110,10 +109,7 @@ const ToContract: React.FC<ToContractProps> = ({ await ContactManager.deleteContact(currentContact.id); console.log("✅ 已从联系人数据库删除"); - // 3. 触发会话列表刷新 - triggerRefresh(); - - // 4. 清空当前选中的联系人(关闭聊天窗口) + // 3. 清空当前选中的联系人(关闭聊天窗口) clearCurrentContact(); message.success("转接成功,已清理本地数据"); @@ -167,10 +163,7 @@ const ToContract: React.FC<ToContractProps> = ({ await ContactManager.deleteContact(currentContact.id); console.log("✅ 已从联系人数据库删除"); - // 3. 触发会话列表刷新 - triggerRefresh(); - - // 4. 清空当前选中的联系人(关闭聊天窗口) + // 3. 清空当前选中的联系人(关闭聊天窗口) clearCurrentContact(); message.success("转回成功,已清理本地数据"); diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx index 1da7da52..29a01439 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx @@ -145,6 +145,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { const [selectedRecords, setSelectedRecords] = useState<ChatRecord[]>([]); const currentMessages = useWeChatStore(state => state.currentMessages); + const currentMessagesHasMore = useWeChatStore( + state => state.currentMessagesHasMore, + ); const loadChatMessages = useWeChatStore(state => state.loadChatMessages); const messagesLoading = useWeChatStore(state => state.messagesLoading); @@ -552,8 +555,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { }; // 用于分组消息并添加时间戳的辅助函数 - const groupMessagesByTime = (messages: ChatRecord[]) => { - return messages + const groupMessagesByTime = (messages: ChatRecord[] | null | undefined) => { + const safeMessages = Array.isArray(messages) + ? messages + : Array.isArray((messages as any)?.list) + ? ((messages as any).list as ChatRecord[]) + : []; + + return safeMessages .filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息 .map(msg => ({ time: formatWechatTime(String(msg?.wechatTime)), @@ -680,33 +689,10 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { ); }; const loadMoreMessages = () => { - // 兼容性处理:检查消息数组和时间戳 - if (!currentMessages || currentMessages.length === 0) { - console.warn("No messages available for loading more"); + if (messagesLoading || !currentMessagesHasMore) { return; } - - const firstMessage = currentMessages[0]; - if (!firstMessage || !firstMessage.createTime) { - console.warn("Invalid message or createTime"); - return; - } - - // 兼容性处理:确保时间戳格式正确 - let timestamp; - try { - const date = new Date(firstMessage.createTime); - if (isNaN(date.getTime())) { - console.warn("Invalid createTime format:", firstMessage.createTime); - return; - } - timestamp = date.getTime() - 24 * 36000 * 1000; - } catch (error) { - console.error("Error parsing createTime:", error); - return; - } - - loadChatMessages(false, timestamp); + loadChatMessages(false); }; const handleForwardMessage = (messageData: ChatRecord) => { @@ -785,8 +771,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { return ( <div className={styles.messagesContainer}> - <div className={styles.loadMore} onClick={() => loadMoreMessages()}> - 点击加载更早的信息 {messagesLoading ? <LoadingOutlined /> : ""} + <div + className={styles.loadMore} + onClick={() => loadMoreMessages()} + style={{ + cursor: + currentMessagesHasMore && !messagesLoading ? "pointer" : "default", + opacity: currentMessagesHasMore ? 1 : 0.6, + }} + > + {currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"} + {messagesLoading ? <LoadingOutlined /> : ""} </div> {groupMessagesByTime(currentMessages).map((group, groupIndex) => ( <React.Fragment key={`group-${groupIndex}`}> diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx index 21712a08..2625b581 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx @@ -15,7 +15,7 @@ import { getWechatFriendDetail, getWechatChatroomDetail, } from "./api"; -import { useMessageStore, triggerRefresh } from "@weChatStore/message"; +import { useMessageStore } from "@weChatStore/message"; import { useWebSocketStore } from "@storeModule/websocket/websocket"; import { useCustomerStore } from "@weChatStore/customer"; import { useContactStore } from "@weChatStore/contacts"; @@ -39,14 +39,12 @@ const MessageList: React.FC<MessageListProps> = () => { // Store状态 const { loading, - refreshTrigger, hasLoadedOnce, setLoading, setHasLoadedOnce, + sessions, + setSessions: setSessionState, } = useMessageStore(); - - // 组件内部状态:会话列表数据 - const [sessions, setSessions] = useState<ChatSession[]>([]); const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]); // 右键菜单相关状态 @@ -74,6 +72,8 @@ const MessageList: React.FC<MessageListProps> = () => { }); const contextMenuRef = useRef<HTMLDivElement>(null); + const previousUserIdRef = useRef<number | null>(null); + const loadRequestRef = useRef(0); // 右键菜单事件处理 const handleContextMenu = (e: React.MouseEvent, session: ChatSession) => { @@ -105,7 +105,7 @@ const MessageList: React.FC<MessageListProps> = () => { try { // 1. 立即更新UI并重新排序(乐观更新) - setSessions(prev => { + setSessionState(prev => { const updatedSessions = prev.map(s => s.id === session.id ? { @@ -141,7 +141,7 @@ const MessageList: React.FC<MessageListProps> = () => { message.success(`${newPinned === 1 ? "置顶" : "取消置顶"}成功`); } catch (error) { // 4. 失败时回滚UI - setSessions(prev => + setSessionState(prev => prev.map(s => s.id === session.id ? { ...s, config: { ...s.config, top: currentPinned } } @@ -162,7 +162,7 @@ const MessageList: React.FC<MessageListProps> = () => { onOk: async () => { try { // 1. 立即从UI移除 - setSessions(prev => prev.filter(s => s.id !== session.id)); + setSessionState(prev => prev.filter(s => s.id !== session.id)); // 2. 后台调用API await updateConfig({ @@ -180,7 +180,7 @@ const MessageList: React.FC<MessageListProps> = () => { message.success("删除成功"); } catch (error) { // 4. 失败时恢复UI - setSessions(prev => [...prev, session]); + setSessionState(prev => [...prev, session]); message.error("删除失败"); } @@ -212,7 +212,7 @@ const MessageList: React.FC<MessageListProps> = () => { try { // 1. 立即更新UI - setSessions(prev => + setSessionState(prev => prev.map(s => s.id === session.id ? { ...s, conRemark: editRemarkModal.remark } : s, ), @@ -258,7 +258,7 @@ const MessageList: React.FC<MessageListProps> = () => { message.success("备注更新成功"); } catch (error) { // 4. 失败时回滚UI - setSessions(prev => + setSessionState(prev => prev.map(s => s.id === session.id ? { ...s, conRemark: oldRemark } : s, ), @@ -357,112 +357,93 @@ const MessageList: React.FC<MessageListProps> = () => { `会话同步完成: 新增${syncResult.added}, 更新${syncResult.updated}, 删除${syncResult.deleted}`, ); - // 如果有数据变更,触发UI刷新 - if ( - syncResult.added > 0 || - syncResult.updated > 0 || - syncResult.deleted > 0 - ) { - triggerRefresh(); - } + // 会话管理器会在有变更时触发订阅回调 } catch (error) { console.error("同步服务器数据失败:", error); } }; + // 切换账号时重置加载状态 + useEffect(() => { + if (!currentUserId) return; + if (previousUserIdRef.current === currentUserId) return; + previousUserIdRef.current = currentUserId; + setHasLoadedOnce(false); + setSessionState([]); + }, [currentUserId, setHasLoadedOnce, setSessionState]); + // 初始化加载会话列表 useEffect(() => { + if (!currentUserId || currentUserId === 0) { + console.warn("currentUserId 无效,跳过加载:", currentUserId); + return; + } + + let isCancelled = false; + const requestId = ++loadRequestRef.current; + const initializeSessions = async () => { - if (!currentUserId || currentUserId === 0) { - console.warn("currentUserId 无效,跳过加载:", currentUserId); - return; - } - - // 如果已经加载过一次,只从本地数据库读取,不请求接口 - if (hasLoadedOnce) { - console.log("已加载过,只从本地数据库读取"); - setLoading(true); // 显示骨架屏 - - try { - const cachedSessions = - await MessageManager.getUserSessions(currentUserId); - console.log("从本地加载会话数:", cachedSessions.length); - - // 如果本地数据为空,重置 hasLoadedOnce 并重新加载 - if (cachedSessions.length === 0) { - console.warn("本地数据为空,重置加载状态并重新加载"); - setHasLoadedOnce(false); - // 不 return,继续执行下面的首次加载逻辑 - } else { - setSessions(cachedSessions); - setLoading(false); // 数据加载完成,关闭骨架屏 - return; - } - } catch (error) { - console.error("从本地加载会话列表失败:", error); - setLoading(false); - return; - } - } - - console.log("首次加载,开始初始化..."); setLoading(true); try { - // 1. 优先从本地数据库加载 const cachedSessions = await MessageManager.getUserSessions(currentUserId); - console.log("本地缓存会话数:", cachedSessions.length); + if (isCancelled || loadRequestRef.current !== requestId) { + return; + } if (cachedSessions.length > 0) { - // 有缓存数据,立即显示 - console.log("有缓存数据,立即显示"); - setSessions(cachedSessions); - setLoading(false); + setSessionState(cachedSessions); + } - // 2. 后台静默同步(不显示同步提示) - console.log("后台静默同步中..."); + const needsFullSync = cachedSessions.length === 0 || !hasLoadedOnce; + + if (needsFullSync) { await syncWithServer(); - setHasLoadedOnce(true); // 标记已加载过 - console.log("同步完成"); + if (isCancelled || loadRequestRef.current !== requestId) { + return; + } + setHasLoadedOnce(true); } else { - // 无缓存,直接API加载 - console.log("无缓存,从服务器加载..."); - await syncWithServer(); - const newSessions = - await MessageManager.getUserSessions(currentUserId); - console.log("从服务器加载会话数:", newSessions.length); - setSessions(newSessions); - setLoading(false); - setHasLoadedOnce(true); // 标记已加载过 + syncWithServer().catch(error => { + console.error("后台同步失败:", error); + }); } } catch (error) { - console.error("初始化会话列表失败:", error); - setLoading(false); + if (!isCancelled) { + console.error("初始化会话列表失败:", error); + } + } finally { + if (!isCancelled && loadRequestRef.current === requestId) { + setLoading(false); + } } }; initializeSessions(); + + return () => { + isCancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentUserId]); - // 监听refreshTrigger,重新查询数据库 + // 订阅数据库变更,自动更新Store useEffect(() => { - const refreshSessions = async () => { - if (!currentUserId || refreshTrigger === 0) return; + if (!currentUserId) { + return; + } - try { - const updatedSessions = - await MessageManager.getUserSessions(currentUserId); - setSessions(updatedSessions); - } catch (error) { - console.error("刷新会话列表失败:", error); - } - }; + const unsubscribe = MessageManager.onSessionsUpdate( + ({ userId: ownerId, sessions: updatedSessions }) => { + if (ownerId !== currentUserId) return; + setSessionState(updatedSessions); + }, + ); - refreshSessions(); - }, [refreshTrigger, currentUserId]); + return unsubscribe; + }, [currentUserId, setSessionState]); // 根据客服和搜索关键词筛选会话 useEffect(() => { @@ -689,8 +670,7 @@ const MessageList: React.FC<MessageListProps> = () => { } } - // 触发静默刷新:通知组件从数据库重新查询 - triggerRefresh(); + // MessageManager 的回调会自动把最新数据发给 Store }; window.addEventListener( @@ -718,7 +698,7 @@ const MessageList: React.FC<MessageListProps> = () => { // 标记为已读(不更新时间和排序) if (session.config.unreadCount > 0) { // 立即更新UI(只更新未读数量) - setSessions(prev => + setSessionState(prev => prev.map(s => s.id === session.id ? { ...s, config: { ...s.config, unreadCount: 0 } } diff --git a/Touchkebao/src/store/module/weChat/message.data.ts b/Touchkebao/src/store/module/weChat/message.data.ts index 0925bbfd..e59e7506 100644 --- a/Touchkebao/src/store/module/weChat/message.data.ts +++ b/Touchkebao/src/store/module/weChat/message.data.ts @@ -1,3 +1,5 @@ +import { ChatSession } from "@/utils/db"; + export interface Message { id: number; wechatId: string; @@ -26,13 +28,15 @@ export interface Message { } //Store State - 会话列表状态管理(不存储数据,只管理状态) +export type SessionsUpdater = + | ChatSession[] + | ((previous: ChatSession[]) => ChatSession[]); + export interface MessageState { //加载状态 loading: boolean; //后台同步状态 refreshing: boolean; - //刷新触发器(用于通知组件重新查询数据库) - refreshTrigger: number; //最后刷新时间 lastRefreshTime: string | null; //是否已经加载过一次(避免重复请求) @@ -42,8 +46,6 @@ export interface MessageState { setLoading: (loading: boolean) => void; //设置同步状态 setRefreshing: (refreshing: boolean) => void; - //触发刷新(通知组件重新查询) - triggerRefresh: () => void; //设置已加载标识 setHasLoadedOnce: (loaded: boolean) => void; //重置加载状态(用于登出或切换用户) @@ -60,4 +62,16 @@ export interface MessageState { updateMessageStatus: (messageId: number, status: string) => void; //更新当前选中的消息(废弃,保留兼容) updateCurrentMessage: (message: Message) => void; + + // ==================== 新的会话数据接口 ==================== + // 当前会话列表 + sessions: ChatSession[]; + // 设置或更新会话列表(支持回调写法) + setSessions: (updater: SessionsUpdater) => void; + // 新增或替换某个会话 + upsertSession: (session: ChatSession) => void; + // 按 ID 和类型移除会话 + removeSessionById: (sessionId: number, type: ChatSession["type"]) => void; + // 清空所有会话(登出/切账号使用) + clearSessions: () => void; } diff --git a/Touchkebao/src/store/module/weChat/message.ts b/Touchkebao/src/store/module/weChat/message.ts index 51503f19..c1b2d12c 100644 --- a/Touchkebao/src/store/module/weChat/message.ts +++ b/Touchkebao/src/store/module/weChat/message.ts @@ -1,6 +1,43 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { Message, MessageState } from "./message.data"; +import { ChatSession } from "@/utils/db"; +import { Message, MessageState, SessionsUpdater } from "./message.data"; + +const computeSortKey = (session: ChatSession) => { + const isTop = session.config?.top ? 1 : 0; + const timestamp = new Date(session.lastUpdateTime || new Date()).getTime(); + const displayName = ( + session.conRemark || + session.nickname || + (session as any).wechatId || + "" + ).toLowerCase(); + + return `${isTop}|${timestamp}|${displayName}`; +}; + +const normalizeSessions = (sessions: ChatSession[]) => { + if (!Array.isArray(sessions)) { + return []; + } + + return [...sessions] + .map(session => ({ + ...session, + sortKey: computeSortKey(session), + })) + .sort((a, b) => b.sortKey.localeCompare(a.sortKey)); +}; + +const resolveUpdater = ( + updater: SessionsUpdater, + previous: ChatSession[], +): ChatSession[] => { + if (typeof updater === "function") { + return updater(previous); + } + return updater; +}; /** * 会话列表状态管理Store @@ -13,24 +50,18 @@ export const useMessageStore = create<MessageState>()( // ==================== 新增状态管理 ==================== loading: false, refreshing: false, - refreshTrigger: 0, lastRefreshTime: null, hasLoadedOnce: false, setLoading: (loading: boolean) => set({ loading }), setRefreshing: (refreshing: boolean) => set({ refreshing }), - triggerRefresh: () => - set({ - refreshTrigger: get().refreshTrigger + 1, - lastRefreshTime: new Date().toISOString(), - }), setHasLoadedOnce: (loaded: boolean) => set({ hasLoadedOnce: loaded }), resetLoadState: () => set({ hasLoadedOnce: false, loading: false, refreshing: false, - refreshTrigger: 0, + sessions: [], }), // ==================== 保留原有接口(向后兼容) ==================== @@ -45,6 +76,45 @@ export const useMessageStore = create<MessageState>()( message.id === messageId ? { ...message, status } : message, ), }), + + // ==================== 会话数据接口 ==================== + sessions: [], + setSessions: (updater: SessionsUpdater) => + set(state => ({ + sessions: normalizeSessions(resolveUpdater(updater, state.sessions)), + lastRefreshTime: new Date().toISOString(), + })), + upsertSession: (session: ChatSession) => + set(state => { + const next = [...state.sessions]; + const index = next.findIndex( + s => s.id === session.id && s.type === session.type, + ); + + if (index > -1) { + next[index] = session; + } else { + next.push(session); + } + return { + sessions: normalizeSessions(next), + lastRefreshTime: new Date().toISOString(), + }; + }), + removeSessionById: (sessionId: number, type: ChatSession["type"]) => + set(state => ({ + sessions: normalizeSessions( + state.sessions.filter( + s => !(s.id === sessionId && s.type === type), + ), + ), + lastRefreshTime: new Date().toISOString(), + })), + clearSessions: () => + set({ + sessions: [], + lastRefreshTime: new Date().toISOString(), + }), }), { name: "message-storage", @@ -105,11 +175,6 @@ export const setLoading = (loading: boolean) => export const setRefreshing = (refreshing: boolean) => useMessageStore.getState().setRefreshing(refreshing); -/** - * 触发刷新(通知组件重新查询数据库) - */ -export const triggerRefresh = () => useMessageStore.getState().triggerRefresh(); - /** * 设置已加载标识 * @param loaded 是否已加载 diff --git a/Touchkebao/src/store/module/weChat/weChat.data.ts b/Touchkebao/src/store/module/weChat/weChat.data.ts index d23dfdf8..dc9aa163 100644 --- a/Touchkebao/src/store/module/weChat/weChat.data.ts +++ b/Touchkebao/src/store/module/weChat/weChat.data.ts @@ -40,6 +40,12 @@ export interface WeChatState { // ==================== 聊天消息管理 ==================== /** 当前聊天的消息列表 */ currentMessages: ChatRecord[]; + /** 当前聊天记录分页页码 */ + currentMessagesPage: number; + /** 单页消息条数 */ + currentMessagesPageSize: number; + /** 是否还有更多历史消息 */ + currentMessagesHasMore: boolean; /** 添加新消息 */ addMessage: (message: ChatRecord) => void; /** 更新指定消息 */ @@ -83,7 +89,7 @@ export interface WeChatState { // ==================== 消息加载方法 ==================== /** 加载聊天消息 */ - loadChatMessages: (Init: boolean, To?: number) => Promise<void>; + loadChatMessages: (Init: boolean, pageOverride?: number) => Promise<void>; /** 搜索消息 */ SearchMessage: (params: { From: number; diff --git a/Touchkebao/src/store/module/weChat/weChat.ts b/Touchkebao/src/store/module/weChat/weChat.ts index 889d528b..89a001a9 100644 --- a/Touchkebao/src/store/module/weChat/weChat.ts +++ b/Touchkebao/src/store/module/weChat/weChat.ts @@ -28,6 +28,7 @@ let pendingMessages: ChatRecord[] = []; // 待处理的消息队列 let currentAiGenerationId: string | null = null; // 当前AI生成的唯一ID const AI_REQUEST_DELAY = 3000; // 3秒延迟 const FILE_MESSAGE_TYPE = "file"; +const DEFAULT_MESSAGE_PAGE_SIZE = 20; type FileMessagePayload = { type?: string; @@ -120,6 +121,108 @@ const isFileLikeMessage = (msg: ChatRecord): boolean => { return false; }; +const normalizeMessages = (source: any): ChatRecord[] => { + if (Array.isArray(source)) { + return source; + } + if (Array.isArray(source?.list)) { + return source.list; + } + return []; +}; + +const parseTimeValue = (value: unknown): number => { + if (value === null || value === undefined) { + return 0; + } + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const numeric = Number(value); + if (!Number.isNaN(numeric)) { + return numeric; + } + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + if (value instanceof Date) { + return value.getTime(); + } + return 0; +}; + +const getMessageTimestamp = (msg: ChatRecord): number => { + const candidates = [ + (msg as any)?.wechatTime, + (msg as any)?.createTime, + (msg as any)?.msgTime, + (msg as any)?.timestamp, + (msg as any)?.time, + ]; + + for (const candidate of candidates) { + const parsed = parseTimeValue(candidate); + if (parsed) { + return parsed; + } + } + + return typeof msg.id === "number" ? msg.id : 0; +}; + +const sortMessagesByTime = (messages: ChatRecord[]): ChatRecord[] => { + return [...messages].sort( + (a, b) => getMessageTimestamp(a) - getMessageTimestamp(b), + ); +}; + +const resolvePaginationState = ( + source: any, + requestedPage: number, + requestedLimit: number, + listLength: number, +) => { + const page = + typeof source?.page === "number" + ? source.page + : typeof source?.current === "number" + ? source.current + : requestedPage; + + const limit = + typeof source?.limit === "number" + ? source.limit + : typeof source?.pageSize === "number" + ? source.pageSize + : requestedLimit; + + let hasMore: boolean; + if (typeof source?.hasNext === "boolean") { + hasMore = source.hasNext; + } else if (typeof source?.hasNextPage === "boolean") { + hasMore = source.hasNextPage; + } else if (typeof source?.pages === "number") { + hasMore = page < source.pages; + } else if (typeof source?.total === "number" && limit > 0) { + hasMore = page * limit < source.total; + } else { + hasMore = listLength >= limit && listLength > 0; + } + + if (listLength === 0) { + hasMore = false; + } + + return { + page, + limit: limit || requestedLimit || DEFAULT_MESSAGE_PAGE_SIZE, + hasMore, + }; +}; + const normalizeFilePayload = ( payload: FileMessagePayload | null | undefined, msg: ChatRecord, @@ -348,6 +451,10 @@ export const useWeChatStore = create<WeChatState>()( currentContract: null, /** 当前聊天的消息列表 */ currentMessages: [], + /** 当前消息分页信息 */ + currentMessagesPage: 1, + currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE, + currentMessagesHasMore: true, // ==================== 聊天消息管理方法 ==================== /** 添加新消息到当前聊天 */ @@ -429,7 +536,13 @@ export const useWeChatStore = create<WeChatState>()( aiRequestTimer = null; } pendingMessages = []; - set({ currentContract: null, currentMessages: [] }); + set({ + currentContract: null, + currentMessages: [], + currentMessagesPage: 1, + currentMessagesHasMore: true, + currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE, + }); }, /** 设置当前联系人并加载相关数据 */ setCurrentContact: (contract: ContractData | weChatGroup) => { @@ -443,7 +556,13 @@ export const useWeChatStore = create<WeChatState>()( const state = useWeChatStore.getState(); // 切换联系人时清空当前消息,等待重新加载 - set({ currentMessages: [], isLoadingAiChat: false }); + set({ + currentMessages: [], + currentMessagesPage: 1, + currentMessagesHasMore: true, + currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE, + isLoadingAiChat: false, + }); const params: any = {}; @@ -468,62 +587,91 @@ export const useWeChatStore = create<WeChatState>()( id: contract.id, config: { chat: true }, }); - state.loadChatMessages(true, 4704624000000); + state.loadChatMessages(true); }, // ==================== 消息加载方法 ==================== /** 加载聊天消息 */ - loadChatMessages: async (Init: boolean, To?: number) => { + loadChatMessages: async (Init: boolean, pageOverride?: number) => { const state = useWeChatStore.getState(); const contact = state.currentContract; - set({ messagesLoading: true }); - set({ isLoadingData: Init }); + + if (!contact) { + return; + } + + if (!Init && !state.currentMessagesHasMore) { + return; + } + + const nextPage = Init + ? 1 + : (pageOverride ?? state.currentMessagesPage + 1); + const limit = + state.currentMessagesPageSize || DEFAULT_MESSAGE_PAGE_SIZE; + + if (state.messagesLoading && !Init) { + return; + } + + set({ + messagesLoading: true, + isLoadingData: Init, + }); + try { const params: any = { wechatAccountId: contact.wechatAccountId, - From: 1, - To: To || +new Date(), - Count: 20, - olderData: true, + page: nextPage, + limit, }; - if ("chatroomId" in contact && contact.chatroomId) { - // 群聊消息加载 + const isGroup = + "chatroomId" in contact && Boolean(contact.chatroomId); + + if (isGroup) { params.wechatChatroomId = contact.id; - const messages = await getChatroomMessages(params); - const currentGroupMembers = await getGroupMembers({ + } else { + params.wechatFriendId = contact.id; + } + + const response = isGroup + ? await getChatroomMessages(params) + : await getChatMessages(params); + + const normalizedMessages = normalizeMessages(response); + const sortedMessages = sortMessagesByTime(normalizedMessages); + const paginationMeta = resolvePaginationState( + response, + nextPage, + limit, + sortedMessages.length, + ); + + let nextGroupMembers = state.currentGroupMembers; + if (Init && isGroup) { + nextGroupMembers = await getGroupMembers({ id: contact.id, }); - if (Init) { - set({ currentMessages: messages || [], currentGroupMembers }); - } else { - set({ - currentMessages: [ - ...(messages.list || []), - ...state.currentMessages, - ], - }); - } - } else { - // 私聊消息加载 - params.wechatFriendId = contact.id; - const messages = await getChatMessages(params); - if (Init) { - set({ currentMessages: messages || [] }); - } else { - set({ - currentMessages: [ - ...(messages.list || []), - ...state.currentMessages, - ], - }); - } } - set({ messagesLoading: false }); + + set(current => ({ + currentMessages: Init + ? sortedMessages + : [...sortedMessages, ...current.currentMessages], + currentGroupMembers: + Init && isGroup ? nextGroupMembers : current.currentGroupMembers, + currentMessagesPage: paginationMeta.page, + currentMessagesPageSize: paginationMeta.limit, + currentMessagesHasMore: paginationMeta.hasMore, + })); } catch (error) { console.error("获取聊天消息失败:", error); } finally { - set({ messagesLoading: false }); + set({ + messagesLoading: false, + isLoadingData: false, + }); } }, @@ -546,11 +694,11 @@ export const useWeChatStore = create<WeChatState>()( try { const params: any = { wechatAccountId: contact.wechatAccountId, + keyword, From, To, - keyword, - Count, - olderData: true, + page: 1, + limit: Count, }; if ("chatroomId" in contact && contact.chatroomId) { @@ -560,12 +708,23 @@ export const useWeChatStore = create<WeChatState>()( const currentGroupMembers = await getGroupMembers({ id: contact.id, }); - set({ currentMessages: messages || [], currentGroupMembers }); + set({ + currentMessages: sortMessagesByTime(normalizeMessages(messages)), + currentGroupMembers, + currentMessagesPage: 1, + currentMessagesHasMore: false, + currentMessagesPageSize: Count || state.currentMessagesPageSize, + }); } else { // 私聊消息搜索 params.wechatFriendId = contact.id; const messages = await getChatMessages(params); - set({ currentMessages: messages || [] }); + set({ + currentMessages: sortMessagesByTime(normalizeMessages(messages)), + currentMessagesPage: 1, + currentMessagesHasMore: false, + currentMessagesPageSize: Count || state.currentMessagesPageSize, + }); } set({ messagesLoading: false }); } catch (error) { @@ -831,6 +990,9 @@ export const useWeChatStore = create<WeChatState>()( set({ currentContract: null, currentMessages: [], + currentMessagesPage: 1, + currentMessagesHasMore: true, + currentMessagesPageSize: DEFAULT_MESSAGE_PAGE_SIZE, messagesLoading: false, }); }, diff --git a/Touchkebao/src/utils/dbAction/message.ts b/Touchkebao/src/utils/dbAction/message.ts index d42289bf..e34041b9 100644 --- a/Touchkebao/src/utils/dbAction/message.ts +++ b/Touchkebao/src/utils/dbAction/message.ts @@ -25,8 +25,15 @@ const serializeExtendFields = (value: any) => { return "{}"; }; +interface SessionUpdatePayload { + userId: number; + sessions: ChatSession[]; +} + export class MessageManager { - private static updateCallbacks = new Set<(sessions: ChatSession[]) => void>(); + private static updateCallbacks = new Set< + (payload: SessionUpdatePayload) => void + >(); // ==================== 回调管理 ==================== @@ -35,9 +42,11 @@ export class MessageManager { * @param callback 回调函数 * @returns 取消注册的函数 */ - static onSessionsUpdate(callback: (sessions: ChatSession[]) => void) { + static onSessionsUpdate(callback: (payload: SessionUpdatePayload) => void) { this.updateCallbacks.add(callback); - return () => this.updateCallbacks.delete(callback); + return () => { + this.updateCallbacks.delete(callback); + }; } /** @@ -49,7 +58,7 @@ export class MessageManager { const sessions = await this.getUserSessions(userId); this.updateCallbacks.forEach(callback => { try { - callback(sessions); + callback({ userId, sessions }); } catch (error) { console.error("会话更新回调执行失败:", error); } From 1582e22756a478ca6135009478b31d01625cc855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Fri, 14 Nov 2025 18:54:04 +0800 Subject: [PATCH 20/26] =?UTF-8?q?=E4=BC=98=E5=8C=96MessageRecord=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=AD=E7=9A=84=E6=9D=A1=E4=BB=B6=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8=E5=8F=8C=E9=87=8D?= =?UTF-8?q?=E5=90=A6=E5=AE=9A=E7=A1=AE=E4=BF=9DisOwn=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E7=A1=AE=E5=88=A4=E6=96=AD=E3=80=82=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=A4=9A=E4=BD=99=E7=9A=84=E6=8D=A2=E8=A1=8C=E4=BB=A5?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ChatWindow/components/MessageRecord/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx index 29a01439..4bfe7c51 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx @@ -648,7 +648,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { </div> </> )} - {isOwn && ( + {!!isOwn && ( <> {/* Checkbox 显示控制 */} {showCheckbox && ( @@ -659,7 +659,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { /> </div> )} - <Avatar size={32} src={currentCustomer?.avatar || ""} From 7d9f9fbd084ce662621d665f0ac1136dfa5a1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Tue, 18 Nov 2025 11:57:54 +0800 Subject: [PATCH 21/26] =?UTF-8?q?feat=EF=BC=9A=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=88=90=E5=91=98=E5=8A=9F=E8=83=BD=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TwoColumnSelection/TwoColumnSelection.tsx | 34 ++- .../components/ProfileModules/index.tsx | 198 +++++++++++++----- 2 files changed, 173 insertions(+), 59 deletions(-) diff --git a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx index e5400bf6..6ba96274 100644 --- a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx +++ b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx @@ -17,6 +17,7 @@ const FriendListItem = memo<{ onClick={() => onSelect(friend)} > <Checkbox checked={isSelected} /> +     <Avatar src={friend.avatar} size={40}> {friend.nickname?.charAt(0)} </Avatar> @@ -41,6 +42,9 @@ interface TwoColumnSelectionProps { deviceIds?: number[]; enableDeviceFilter?: boolean; dataSource?: FriendSelectionItem[]; + onLoadMore?: () => void; // 加载更多回调 + hasMore?: boolean; // 是否有更多数据 + loading?: boolean; // 是否正在加载 } const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ @@ -51,13 +55,16 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ deviceIds = [], enableDeviceFilter = true, dataSource, + onLoadMore, + hasMore = false, + loading = false, }) => { const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]); const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>( [], ); const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -81,10 +88,10 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ const [displayPage, setDisplayPage] = useState(1); const friends = useMemo(() => { - const startIndex = 0; - const endIndex = displayPage * ITEMS_PER_PAGE; - return filteredFriends.slice(startIndex, endIndex); - }, [filteredFriends, displayPage]); + // 直接使用完整的过滤列表,不再进行本地分页 + // 因为我们已经在外部进行了分页加载 + return filteredFriends; + }, [filteredFriends]); const hasMoreFriends = filteredFriends.length > friends.length; @@ -100,7 +107,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ // 获取好友列表 const fetchFriends = useCallback( async (page: number, keyword: string = "") => { - setLoading(true); + setIsLoading(true); try { const params: any = { page, @@ -128,7 +135,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ console.error("获取好友列表失败:", error); message.error("获取好友列表失败"); } finally { - setLoading(false); + setIsLoading(false); } }, [deviceIds, enableDeviceFilter], @@ -148,7 +155,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ if (visible) { setSearchQuery(""); setSelectedFriends([]); - setLoading(false); + setIsLoading(false); } }, [visible]); @@ -257,7 +264,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ </div> <div className={styles.friendList}> - {loading ? ( + {isLoading && !loading ? ( <div className={styles.loading}>加载中...</div> ) : friends.length > 0 ? ( // 使用 React.memo 优化列表项渲染 @@ -280,9 +287,14 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ </div> )} - {hasMoreFriends && ( + {/* 使用外部传入的加载更多 */} + {hasMore && ( <div className={styles.loadMoreWrapper}> - <Button type="link" onClick={handleLoadMore} loading={loading}> + <Button + type="link" + onClick={() => (onLoadMore ? onLoadMore() : handleLoadMore())} + loading={loading} + > 加载更多 </Button> </div> diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx index b71db1e0..150e9ceb 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx @@ -25,7 +25,7 @@ import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import { useCustomerStore } from "@/store/module/weChat/customer"; import { useWebSocketStore } from "@/store/module/websocket/websocket"; import { useWeChatStore } from "@/store/module/weChat/weChat"; -import { useContactStore } from "@/store/module/weChat/contacts"; +import { contactUnifiedService } from "@/utils/db"; import { generateAiText } from "@/api/ai"; import TwoColumnSelection from "@/components/TwoColumnSelection/TwoColumnSelection"; import TwoColumnMemberSelection from "@/components/MemberSelection/TwoColumnMemberSelection"; @@ -216,7 +216,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => { return matchedCustomer || null; }, [customerList, contract.wechatAccountId]); - const { getContactsByCustomer } = useContactStore(); + // 不再需要从useContactStore获取getContactsByCustomer const { sendCommand } = useWebSocketStore(); @@ -516,6 +516,125 @@ const Person: React.FC<PersonProps> = ({ contract }) => { bio: contract.bio || contract.signature || "-", }; + // 分页状态 + const [currentContactPage, setCurrentContactPage] = useState(1); + const [contactPageSize] = useState(10); + const [isLoadingContacts, setIsLoadingContacts] = useState(false); + + // 从数据库获取联系人数据的通用函数 + const fetchContacts = async (page = 1) => { + try { + const { databaseManager, initializeDatabaseFromPersistedUser } = + await import("@/utils/db"); + + // 检查数据库初始化状态 + if (!databaseManager.isInitialized()) { + await initializeDatabaseFromPersistedUser(); + } + + // 获取当前用户ID + const userId = kfSelectedUser?.userId || 0; + const storeUserId = databaseManager.getCurrentUserId(); + const effectiveUserId = storeUserId || userId; + + if (!effectiveUserId) { + messageApi.error("无法获取用户信息,请尝试重新登录"); + return []; + } + + // 查询联系人数据 + const allContacts = await contactUnifiedService.findWhereMultiple([ + { field: "userId", operator: "equals", value: effectiveUserId }, + { + field: "wechatAccountId", + operator: "equals", + value: contract.wechatAccountId, + }, + { field: "type", operator: "equals", value: "friend" }, + ]); + + // 手动分页 + const startIndex = (page - 1) * contactPageSize; + const endIndex = startIndex + contactPageSize; + return allContacts.slice(startIndex, endIndex); + } catch (error) { + console.error("获取联系人数据失败:", error); + messageApi.error("获取联系人数据失败"); + return []; + } + }; + + const addMember = async () => { + try { + setIsLoadingContacts(true); + const pagedContacts = await fetchContacts(currentContactPage); + // 转换为选择器需要的数据格式 + const friendSelectionData = pagedContacts.map(item => ({ + id: item.id || item.serverId, + wechatId: item.wechatId, + nickname: item.nickname, + avatar: item.avatar || "", + conRemark: item.conRemark, + name: item.conRemark || item.nickname, // 用于搜索显示 + })); + + setContractList(friendSelectionData); + setIsFriendSelectionVisible(true); + + // 如果没有联系人数据,显示提示 + if (friendSelectionData.length === 0) { + messageApi.info("未找到可添加的联系人,可能需要先同步联系人数据"); + } + } catch (error) { + console.error("获取联系人列表失败:", error); + messageApi.error("获取联系人列表失败"); + } finally { + setIsLoadingContacts(false); + } + }; + + // 加载更多联系人 + const loadMoreContacts = async () => { + if (isLoadingContacts) return; + try { + setIsLoadingContacts(true); + const nextPage = currentContactPage + 1; + setCurrentContactPage(nextPage); + // 使用通用函数获取下一页联系人数据 + const pagedContacts = await fetchContacts(nextPage); + // 转换数据格式 + const newFriendSelectionData = pagedContacts.map(item => ({ + id: item.id || item.serverId, + wechatId: item.wechatId, + nickname: item.nickname, + avatar: item.avatar || "", + conRemark: item.conRemark, + name: item.conRemark || item.nickname, + })); + + // 更新列表并去重 + setContractList(prev => { + const newList = [...prev, ...newFriendSelectionData]; + // 确保列表中没有重复项 + const uniqueMap = new Map(); + const uniqueList = newList.filter(item => { + if (uniqueMap.has(item.id)) { + return false; + } + uniqueMap.set(item.id, true); + return true; + }); + return uniqueList; + }); + + messageApi.success(`已加载${pagedContacts.length}条联系人数据`); + } catch (error) { + console.error("加载更多联系人失败:", error); + messageApi.error("加载更多联系人失败"); + } finally { + setIsLoadingContacts(false); + } + }; return ( <> {contextHolder} @@ -774,27 +893,25 @@ const Person: React.FC<PersonProps> = ({ contract }) => { <Card title="标签" className={styles.profileCard}> <div className={styles.tagsContainer}> {/* 渲染所有可用标签,选中的排在前面 */} - {[...new Set([...selectedTags, ...allAvailableTags])].map( - (tag, index) => { - const isSelected = selectedTags.includes(tag); - return ( - <Tag - key={tag} - color={isSelected ? "blue" : "default"} - style={{ - cursor: "pointer", - border: isSelected - ? "1px solid #1890ff" - : "1px solid #d9d9d9", - backgroundColor: isSelected ? "#e6f7ff" : "#fafafa", - }} - onClick={() => handleTagToggle(tag)} - > - {tag} - </Tag> - ); - }, - )} + {[...new Set([...selectedTags, ...allAvailableTags])].map(tag => { + const isSelected = selectedTags.includes(tag); + return ( + <Tag + key={tag} + color={isSelected ? "blue" : "default"} + style={{ + cursor: "pointer", + border: isSelected + ? "1px solid #1890ff" + : "1px solid #d9d9d9", + backgroundColor: isSelected ? "#e6f7ff" : "#fafafa", + }} + onClick={() => handleTagToggle(tag)} + > + {tag} + </Tag> + ); + })} {/* 新增标签区域 */} {isAddingTag ? ( @@ -917,29 +1034,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => { > <Button icon={<PlusOutlined />} - onClick={async () => { - try { - const contractData = getContactsByCustomer( - contract.wechatAccountId, - ); - // 转换 Contact[] 为 FriendSelectionItem[] - const friendSelectionData = (contractData || []).map( - item => ({ - id: item.id || item.serverId, - wechatId: item.wechatId, - nickname: item.nickname, - avatar: item.avatar || "", - conRemark: item.conRemark, - name: item.conRemark || item.nickname, // 用于搜索显示 - }), - ); - setContractList(friendSelectionData); - setIsFriendSelectionVisible(true); - } catch (error) { - console.error("获取联系人列表失败:", error); - messageApi.error("获取联系人列表失败"); - } - }} + onClick={addMember} type="primary" style={{ flex: 1, @@ -1028,7 +1123,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => { className={styles.groupMemberList} style={{ maxHeight: "400px", overflowY: "auto" }} > - {currentGroupMembers.map((member, index) => ( + {currentGroupMembers.map(member => ( <Tooltip title={member.nickname} key={member.wechatId}> <div className={styles.groupMember} @@ -1259,15 +1354,22 @@ const Person: React.FC<PersonProps> = ({ contract }) => { {/* 添加成员弹窗 */} <TwoColumnSelection visible={isFriendSelectionVisible} - onCancel={() => setIsFriendSelectionVisible(false)} + onCancel={() => { + setIsFriendSelectionVisible(false); + setCurrentContactPage(1); // 重置页码 + }} onConfirm={(selectedIds, selectedItems) => { handleAddMember( selectedIds.map(id => parseInt(id)), selectedItems, ); + setCurrentContactPage(1); // 重置页码 }} dataSource={contractList} title="添加群成员" + onLoadMore={loadMoreContacts} + hasMore={true} // 强制设置为true,确保显示加载更多按钮 + loading={isLoadingContacts} /> {/* 删除成员弹窗 */} From caef9d7d46b58ae8ebbc08ce84341f19d7aa8f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Tue, 18 Nov 2025 14:22:42 +0800 Subject: [PATCH 22/26] =?UTF-8?q?=E5=A2=9E=E5=BC=BATwoColumnSelection?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E5=8A=9F=E8=83=BD=E5=92=8C=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E4=BC=98=E5=8C=96=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=9A=84=E5=88=86=E9=A1=B5=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E3=80=82=E9=87=8D=E6=9E=84=E8=81=94=E7=B3=BB=E4=BA=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=8E=B7=E5=8F=96=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=80=A7=E8=83=BD=E5=92=8C=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82=E6=9B=B4=E6=96=B0ProfileCard=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E6=96=B0=E7=9A=84=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=8A=A0=E8=BD=BD=E6=96=B9=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TwoColumnSelection/TwoColumnSelection.tsx | 69 ++++--------------- 1 file changed, 12 insertions(+), 57 deletions(-) diff --git a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx index 6ba96274..e1475cdc 100644 --- a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx +++ b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx @@ -65,8 +65,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ ); const [searchQuery, setSearchQuery] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); // 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算 const filteredFriends = useMemo(() => { @@ -83,17 +81,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ ); }, [dataSource, rawFriends, searchQuery]); - // 分页显示好友列表,避免一次性渲染太多项目 - const ITEMS_PER_PAGE = 50; - const [displayPage, setDisplayPage] = useState(1); - - const friends = useMemo(() => { - // 直接使用完整的过滤列表,不再进行本地分页 - // 因为我们已经在外部进行了分页加载 - return filteredFriends; - }, [filteredFriends]); - - const hasMoreFriends = filteredFriends.length > friends.length; + // 好友列表直接使用过滤后的结果 + const friends = filteredFriends; // 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算 const selectedFriendsMap = useMemo(() => { @@ -126,7 +115,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ if (response.success) { setRawFriends(response.data.list || []); - setTotalPages(Math.ceil((response.data.total || 0) / 20)); } else { setRawFriends([]); message.error(response.message || "获取好友列表失败"); @@ -146,7 +134,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ if (visible && !dataSource) { // 只有在没有外部数据源时才调用 API fetchFriends(1); - setCurrentPage(1); } }, [visible, dataSource, fetchFriends]); @@ -160,44 +147,18 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ }, [visible]); // 防抖搜索处理 - const handleSearch = useCallback(() => { - let timeoutId: NodeJS.Timeout; - return (value: string) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - setDisplayPage(1); // 重置分页 - if (!dataSource) { - fetchFriends(1, value); - } - }, 300); - }; - }, [dataSource, fetchFriends])(); - - // API搜索处理(当没有外部数据源时) - const handleApiSearch = useCallback( - async (keyword: string) => { + const handleSearch = useCallback( + (value: string) => { if (!dataSource) { - await fetchFriends(1, keyword); + const timer = setTimeout(() => { + fetchFriends(1, value); + }, 300); + return () => clearTimeout(timer); } }, [dataSource, fetchFriends], ); - // 加载更多好友 - const handleLoadMore = useCallback(() => { - setDisplayPage(prev => prev + 1); - }, []); - - // 防抖搜索 - useEffect(() => { - if (!dataSource && searchQuery.trim()) { - const timer = setTimeout(() => { - handleApiSearch(searchQuery); - }, 300); - return () => clearTimeout(timer); - } - }, [searchQuery, dataSource, handleApiSearch]); - // 选择好友 - 使用 useCallback 优化性能 const handleSelectFriend = useCallback((friend: FriendSelectionItem) => { setSelectedFriends(prev => { @@ -223,10 +184,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ setSearchQuery(""); }, [selectedFriends, onConfirm]); - // 取消选择 - 使用 useCallback 优化性能 + // 取消选择 const handleCancel = useCallback(() => { - setSelectedFriends([]); - setSearchQuery(""); onCancel(); }, [onCancel]); @@ -255,8 +214,8 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ value={searchQuery} onChange={e => { const value = e.target.value; - setSearchQuery(value); // 立即更新显示 - handleSearch(value); // 防抖处理搜索 + setSearchQuery(value); + handleSearch(value); }} prefix={<SearchOutlined />} allowClear @@ -290,11 +249,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({ {/* 使用外部传入的加载更多 */} {hasMore && ( <div className={styles.loadMoreWrapper}> - <Button - type="link" - onClick={() => (onLoadMore ? onLoadMore() : handleLoadMore())} - loading={loading} - > + <Button type="link" onClick={onLoadMore} loading={loading}> 加载更多 </Button> </div> From 6eeabe420388ad19464680ba99b0bd821c2acd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Tue, 18 Nov 2025 18:02:30 +0800 Subject: [PATCH 23/26] =?UTF-8?q?=E9=87=8D=E6=9E=84ProfileCard=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E6=96=B0=E7=9A=84DetailVal?= =?UTF-8?q?ue=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E8=B5=84=E6=96=99=E5=92=8C=E7=BE=A4=E7=BB=84=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E7=9A=84=E7=BC=96=E8=BE=91=E9=80=BB=E8=BE=91=E3=80=82?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=BB=A5?= =?UTF-8?q?=E5=A4=84=E7=90=86contract=E7=9A=84=E5=8F=98=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=95=B0=E6=8D=AE=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E3=80=82=E6=9B=B4=E6=96=B0=E6=A0=B7=E5=BC=8F=E4=BB=A5=E6=94=B9?= =?UTF-8?q?=E5=96=84=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileModules/Person.module.scss | 6 + .../components/ProfileModules/api.ts | 88 +++ .../ProfileModules/components/detailValue.tsx | 291 ++++++-- .../components/ProfileModules/index.tsx | 695 ++++++++++-------- .../components/ProfileCard/index.tsx | 16 +- 5 files changed, 757 insertions(+), 339 deletions(-) create mode 100644 Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/api.ts diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/Person.module.scss b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/Person.module.scss index 0a061cc4..89005006 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/Person.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/Person.module.scss @@ -197,6 +197,12 @@ } } +.footerActions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + // 响应式设计 @media (max-width: 768px) { .profileSider { diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/api.ts b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/api.ts new file mode 100644 index 00000000..2f2ea0dc --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/api.ts @@ -0,0 +1,88 @@ +import request from "@/api/request"; +// 更新好友信息 +export interface UpdateFriendInfoParams { + id: number; + phone: string; + company: string; + name: string; + position: string; + email: string; + address: string; + qq: string; + remark: string; +} +export function updateFriendInfo(params: UpdateFriendInfoParams): Promise<any> { + return request("/v1/kefu/wechatFriend/updateInfo", params, "POST"); +} + +// 更新本地数据库中的好友信息 +export interface UpdateLocalDBParams { + wechatFriendId: number; + extendFields: string; + updateConversation?: boolean; // 是否同时更新会话列表 +} + +export function updateLocalDBFriendInfo( + params: UpdateLocalDBParams, +): Promise<any> { + return request("/v1/kefu/wechatFriend/updateLocalDB", params, "POST"); +} +// 获取好友信息 +export interface GetFriendInfoParams { + id: number; +} + +export interface FriendDetailResponse { + detail: { + id: number; + wechatAccountId: number; + alias: string; + wechatId: string; + conRemark: string; + nickname: string; + pyInitial: string; + quanPin: string; + avatar: string; + gender: number; + region: string; + addFrom: number; + labels: any[]; + siteLabels: string[]; + signature: string; + isDeleted: number; + isPassed: number; + deleteTime: number; + accountId: number; + extendFields: string; + accountUserName: string; + accountRealName: string; + accountNickname: string; + ownerAlias: string; + ownerWechatId: string; + ownerNickname: string; + ownerAvatar: string; + phone: string; + thirdParty: string; + groupId: number; + passTime: string; + additionalPicture: string; + desc: string; + country: string; + privince: string; + city: string; + createTime: string; + updateTime: string; + R: string; + F: string; + M: string; + realName: null | string; + company: null | string; + position: null | string; + aiType: number; + }; +} +export function getFriendInfo( + params: GetFriendInfoParams, +): Promise<FriendDetailResponse> { + return request("/v1/kefu/wechatFriend/detail", params, "GET"); +} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx index b6377058..4966a863 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/components/detailValue.tsx @@ -1,5 +1,8 @@ -import React, { useCallback } from "react"; -import { Button, Input } from "antd"; +import React, { useCallback, useState, useEffect } from "react"; +import { Input, message } from "antd"; +import { Button } from "antd-mobile"; +import { EditOutlined } from "@ant-design/icons"; +import { updateFriendInfo, UpdateFriendInfoParams } from "../api"; import styles from "../Person.module.scss"; @@ -8,91 +11,295 @@ export interface DetailValueField { key: string; ifEdit?: boolean; placeholder?: string; + type?: "text" | "textarea"; + editable?: boolean; } export interface DetailValueProps { fields: DetailValueField[]; value?: Record<string, string>; onChange?: (next: Record<string, string>) => void; - onSubmit?: (next: Record<string, string>) => void; + onSubmit?: (next: Record<string, string>, changedKeys: string[]) => void; submitText?: string; submitting?: boolean; renderFooter?: React.ReactNode; + saveHandler?: ( + values: Record<string, string>, + changedKeys: string[], + ) => Promise<void>; + onSaveSuccess?: ( + values: Record<string, string>, + changedKeys: string[], + ) => void; + isGroup?: boolean; } const DetailValue: React.FC<DetailValueProps> = ({ fields, - value, + value = {}, onChange, onSubmit, submitText = "保存", submitting = false, renderFooter, + saveHandler, + onSaveSuccess, + isGroup = false, }) => { + const [messageApi, contextHolder] = message.useMessage(); + const [editingFields, setEditingFields] = useState<Record<string, boolean>>( + {}, + ); + const [fieldValues, setFieldValues] = useState<Record<string, string>>(value); + + const [originalValues, setOriginalValues] = + useState<Record<string, string>>(value); + const [changedKeys, setChangedKeys] = useState<string[]>([]); + + // 当外部value变化时,更新内部状态 + useEffect(() => { + setFieldValues(value); + setOriginalValues(value); + setChangedKeys([]); + // 重置所有编辑状态 + const newEditingFields: Record<string, boolean> = {}; + fields.forEach(field => { + newEditingFields[field.key] = false; + }); + setEditingFields(newEditingFields); + }, [value, fields]); + const handleFieldChange = useCallback( (fieldKey: string, nextVal: string) => { - const baseValue = value ?? {}; - const nextValue = { - ...baseValue, + setFieldValues(prev => ({ + ...prev, [fieldKey]: nextVal, - }; - onChange?.(nextValue); + })); + + // 检查值是否发生变化,更新changedKeys + if (nextVal !== originalValues[fieldKey]) { + if (!changedKeys.includes(fieldKey)) { + setChangedKeys(prev => [...prev, fieldKey]); + } + } else { + // 如果值恢复到原始值,从changedKeys中移除 + setChangedKeys(prev => prev.filter(key => key !== fieldKey)); + } + + // 调用外部onChange,但不触发自动保存 + if (onChange) { + onChange({ + ...fieldValues, + [fieldKey]: nextVal, + }); + } }, - [onChange, value], + [onChange, fieldValues, originalValues, changedKeys], ); - const handleSubmit = useCallback(() => { - onSubmit?.(value ?? {}); - }, [onSubmit, value]); + const handleEditField = useCallback((fieldKey: string) => { + setEditingFields(prev => ({ + ...prev, + [fieldKey]: true, + })); + }, []); - const formValue = value ?? {}; + const handleCancelEdit = useCallback( + (fieldKey: string) => { + // 恢复原始值 + setFieldValues(prev => ({ + ...prev, + [fieldKey]: originalValues[fieldKey] || "", + })); + + // 从changedKeys中移除 + setChangedKeys(prev => prev.filter(key => key !== fieldKey)); + + // 关闭编辑状态 + setEditingFields(prev => ({ + ...prev, + [fieldKey]: false, + })); + }, + [originalValues], + ); + + const handleSubmit = useCallback(async () => { + if (changedKeys.length === 0) { + messageApi.info("没有需要保存的更改"); + return; + } + + try { + if (isGroup) { + // 群组信息使用传入的saveHandler + if (saveHandler) { + await saveHandler(fieldValues, changedKeys); + } else { + onSubmit?.(fieldValues, changedKeys); + } + } else { + // 个人资料信息处理 + if (changedKeys.includes("conRemark")) { + // 微信备注是特例,使用WebSocket更新 + if (saveHandler) { + await saveHandler(fieldValues, changedKeys); + } else { + onSubmit?.(fieldValues, changedKeys); + } + } else { + // 其他个人资料信息使用updateFriendInfo API + const params: UpdateFriendInfoParams = { + id: Number(value.id) || 0, + phone: fieldValues.phone || "", + company: fieldValues.company || "", + name: fieldValues.name || "", + position: fieldValues.position || "", + email: fieldValues.email || "", + address: fieldValues.address || "", + qq: fieldValues.qq || "", + remark: fieldValues.remark || "", + }; + await updateFriendInfo(params); + } + } + + // 更新原始值 + setOriginalValues(fieldValues); + // 清空changedKeys + setChangedKeys([]); + // 关闭所有编辑状态 + const newEditingFields: Record<string, boolean> = {}; + fields.forEach(field => { + newEditingFields[field.key] = false; + }); + setEditingFields(newEditingFields); + // 调用保存成功回调 + onSaveSuccess?.(fieldValues, changedKeys); + messageApi.success("保存成功"); + } catch (error) { + messageApi.error("保存失败"); + console.error("保存失败:", error); + } + }, [ + onSubmit, + saveHandler, + onSaveSuccess, + fieldValues, + changedKeys, + fields, + messageApi, + isGroup, + value.id, + ]); + + const isEditing = Object.values(editingFields).some(Boolean); return ( <div> + {contextHolder} {fields.map(field => { const disabled = field.ifEdit === false; - const fieldValue = formValue[field.key] ?? ""; + const fieldValue = fieldValues[field.key] ?? ""; + const isFieldEditing = editingFields[field.key]; + const InputComponent = + field.type === "textarea" ? Input.TextArea : Input; return ( <div key={field.key} className={styles.infoItem}> <span className={styles.infoLabel}>{field.label}:</span> <div className={styles.infoValue}> {disabled ? ( - <span>{fieldValue || field.placeholder || "--"}</span> + <span>{fieldValue || field.placeholder || ""}</span> + ) : isFieldEditing ? ( + <div + style={{ + display: "flex", + flexDirection: "column", + width: "100%", + }} + > + <InputComponent + value={fieldValue} + placeholder={field.placeholder} + onChange={event => + handleFieldChange(field.key, event.target.value) + } + onPressEnter={undefined} + autoFocus + rows={field.type === "textarea" ? 4 : undefined} + /> + <div + style={{ + marginTop: 8, + display: "flex", + justifyContent: "flex-end", + }} + > + <Button + size="small" + onClick={() => handleCancelEdit(field.key)} + style={{ marginRight: 8 }} + > + 取消 + </Button> + <Button size="small" color="primary" onClick={handleSubmit}> + 确定 + </Button> + </div> + </div> ) : ( - <Input - value={fieldValue} - placeholder={field.placeholder} - onChange={event => - handleFieldChange(field.key, event.target.value) - } - onPressEnter={handleSubmit} - /> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + cursor: "pointer", + padding: "4px 8px", + borderRadius: 4, + border: "1px solid transparent", + transition: "all 0.3s", + width: "100%", + }} + onClick={() => handleEditField(field.key)} + onMouseEnter={e => { + e.currentTarget.style.backgroundColor = "#f5f5f5"; + e.currentTarget.style.borderColor = "#d9d9d9"; + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = "transparent"; + e.currentTarget.style.borderColor = "transparent"; + }} + > + <span + style={{ + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: + field.type === "textarea" ? "pre-wrap" : "nowrap", + }} + > + {fieldValue || field.placeholder || ""} + </span> + <EditOutlined style={{ color: "#1890ff", marginLeft: 8 }} /> + </div> )} </div> </div> ); })} - {(onSubmit || renderFooter) && ( - <div - style={{ - display: "flex", - justifyContent: "flex-end", - marginTop: 16, - }} - > + {(onSubmit || renderFooter) && !isEditing && changedKeys.length > 0 && ( + <div className={styles.footerActions}> {renderFooter} - {onSubmit && ( - <Button - type="primary" - loading={submitting} - onClick={handleSubmit} - style={{ marginLeft: renderFooter ? 8 : 0 }} - > - {submitText} - </Button> - )} + <Button + loading={submitting} + onClick={handleSubmit} + style={{ marginLeft: renderFooter ? 8 : 0 }} + > + {submitText} + </Button> </div> )} </div> diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx index 150e9ceb..6f40e175 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx @@ -9,11 +9,9 @@ import { message, Modal, } from "antd"; +// 不再需要导入updateFriendInfo,因为已经在detailValue.tsx中使用 import { - PhoneOutlined, UserOutlined, - TeamOutlined, - EnvironmentOutlined, CloseOutlined, EditOutlined, CheckOutlined, @@ -30,6 +28,12 @@ import { generateAiText } from "@/api/ai"; import TwoColumnSelection from "@/components/TwoColumnSelection/TwoColumnSelection"; import TwoColumnMemberSelection from "@/components/MemberSelection/TwoColumnMemberSelection"; import { FriendSelectionItem } from "@/components/FriendSelection/data"; +import DetailValue from "./components/detailValue"; +import { + getFriendInfo, + FriendDetailResponse, + updateLocalDBFriendInfo, +} from "./api"; import styles from "./Person.module.scss"; interface PersonProps { contract: ContractData | weChatGroup; @@ -37,7 +41,6 @@ interface PersonProps { const Person: React.FC<PersonProps> = ({ contract }) => { const [messageApi, contextHolder] = message.useMessage(); - const [isEditingRemark, setIsEditingRemark] = useState(false); const [remarkValue, setRemarkValue] = useState(contract.conRemark || ""); const [selectedTags, setSelectedTags] = useState<string[]>( contract.labels || [], @@ -45,18 +48,18 @@ const Person: React.FC<PersonProps> = ({ contract }) => { const [allAvailableTags, setAllAvailableTags] = useState<string[]>([]); const [isAddingTag, setIsAddingTag] = useState(false); const [newTagValue, setNewTagValue] = useState(""); + const [friendDetail, setFriendDetail] = useState<FriendDetailResponse | null>( + null, + ); // 判断是否为群聊 const isGroup = "chatroomId" in contract; // 群聊相关状态 - const [isEditingGroupName, setIsEditingGroupName] = useState(false); const [groupNameValue, setGroupNameValue] = useState(contract.name || ""); const [groupNoticeValue, setGroupNoticeValue] = useState( contract.notice || "", ); - const [isEditingSelfDisplayName, setIsEditingSelfDisplayName] = - useState(false); const [selfDisplayNameValue, setSelfDisplayNameValue] = useState( contract.selfDisplyName || "", ); @@ -241,76 +244,204 @@ const Person: React.FC<PersonProps> = ({ contract }) => { fetchAvailableTags(); }, [kfSelectedUser, contract.labels]); + // 获取好友详细信息 - 静默请求,成功时更新数据,失败时不做任何处理 + const fetchFriendDetail = React.useCallback(async () => { + if (isGroup) return; // 群聊不需要获取好友详情 + + try { + // 静默请求,不显示加载状态 + const response = await getFriendInfo({ id: contract.id }); + // 请求成功时更新数据 + setFriendDetail(response); + + // 解析扩展字段 + try { + const extendFieldsObj = JSON.parse( + response.detail.extendFields || "{}", + ); + setExtendFields(extendFieldsObj); + } catch (e) { + console.error("Failed to parse extendFields:", e); + // 解析失败时不更新状态,保持原有数据 + } + } catch (err) { + // 请求失败时静默处理,只记录日志,不更新UI状态 + console.error("获取好友详情失败:", err); + } + }, [contract.id, isGroup]); + + // 当contract变化时在后台静默获取好友详情 + useEffect(() => { + if (!isGroup && contract.id) { + // 使用setTimeout将请求移至下一个事件循环,确保UI先渲染 + setTimeout(() => { + fetchFriendDetail(); + }, 0); + } + }, [contract.id, isGroup, fetchFriendDetail]); + // 当contract变化时更新各种值 useEffect(() => { setRemarkValue(contract.conRemark || ""); - setIsEditingRemark(false); setSelectedTags(contract.labels || []); + try { + // 确保extendFields是最新的值 + const extFieldsObj = + typeof contract.extendFields === "string" + ? JSON.parse(contract.extendFields || "{}") + : contract.extendFields || {}; + setExtendFields(extFieldsObj); + } catch (e) { + console.error("Failed to parse extendFields in useEffect:", e); + setExtendFields({}); + } if (isGroup) { setGroupNameValue(contract.name || ""); - setIsEditingGroupName(false); setGroupNoticeValue(contract.notice || ""); setSelfDisplayNameValue(contract.selfDisplyName || ""); - setIsEditingSelfDisplayName(false); } + + // 不再需要在这里触发获取好友详情,已在单独的useEffect中处理 }, [ contract.conRemark, contract.labels, contract.name, contract.notice, contract.selfDisplyName, + contract.extendFields, + contract.id, isGroup, + fetchFriendDetail, ]); // 处理备注保存 - const handleSaveRemark = () => { - if (isGroup) { - // 群聊备注修改 - sendCommand("CmdModifyGroupRemark", { - wechatAccountId: contract.wechatAccountId, - chatroomId: contract.chatroomId, - newRemark: remarkValue, - }); - } else { - // 好友备注修改 - sendCommand("CmdModifyFriendRemark", { - wechatAccountId: contract.wechatAccountId, - wechatFriendId: contract.id, - newRemark: remarkValue, - }); + const handleSaveRemark = async ( + values: Record<string, string>, + changedKeys: string[], + ) => { + // 构建更新后的扩展字段 + const updatedExtendFields = { ...extendFields }; + + // 更新各个扩展字段 + const extendFieldKeys = [ + "phone", + "company", + "position", + "email", + "address", + "qq", + "remark", + ]; + extendFieldKeys.forEach(key => { + if (changedKeys.includes(key) && values[key] !== undefined) { + updatedExtendFields[key] = values[key]; + } + }); + + const extendFieldsStr = JSON.stringify(updatedExtendFields || {}); + + // 更新remarkValue + if (changedKeys.includes("conRemark")) { + setRemarkValue(values.conRemark); } - messageApi.success("备注保存成功"); - setIsEditingRemark(false); - // 更新contract对象中的备注(实际项目中应该通过props回调或状态管理) + // 更新所有扩展字段 + setExtendFields(updatedExtendFields); + + // 更新父组件中的contract副本,确保切换tab后数据不会丢失 + if (contract && typeof contract === "object") { + // 更新contract的extendFields字段 + contract.extendFields = extendFieldsStr; + + // 如果有备注变更,同时更新备注 + if (changedKeys.includes("conRemark")) { + contract.conRemark = values.conRemark; + } + } + + try { + // 仅使用WebSocket命令同步备注信息 + if ( + changedKeys.includes("conRemark") || + changedKeys.some(key => extendFieldKeys.includes(key)) + ) { + if (isGroup) { + // 群聊备注修改 + sendCommand("CmdModifyGroupRemark", { + wechatAccountId: contract.wechatAccountId, + chatroomId: contract.chatroomId, + newRemark: values.conRemark, + extendFields: extendFieldsStr, + }); + } else { + // 好友备注修改 + sendCommand("CmdModifyFriendRemark", { + wechatAccountId: contract.wechatAccountId, + wechatFriendId: contract.id, + newRemark: values.conRemark, + extendFields: extendFieldsStr, + }); + + // 同时更新会话列表和好友本地数据库中的extendFields字段 + updateLocalDBFriendInfo({ + wechatFriendId: contract.id, + extendFields: extendFieldsStr, + updateConversation: true, // 同时更新会话列表 + }).catch(err => { + console.error("更新本地数据库失败:", err); + }); + } + } + + // 如果有API返回的详情数据,更新本地状态 + if (friendDetail && !isGroup) { + setFriendDetail(prev => { + if (!prev) return prev; + return { + ...prev, + detail: { + ...prev.detail, + conRemark: changedKeys.includes("conRemark") + ? values.conRemark + : prev.detail.conRemark, + extendFields: extendFieldsStr, + }, + }; + }); + } + } catch (error) { + console.error("保存好友信息失败:", error); + return Promise.reject(error); + } + + // 返回Promise以便DetailValue组件处理成功状态 + return Promise.resolve(); }; // 处理群名称保存 - const handleSaveGroupName = () => { + const handleSaveGroupName = async ( + values: Record<string, string>, + changedKeys: string[], + ) => { if (!hasGroupManagePermission()) { messageApi.error("只有群主才能修改群名称"); - return; + return Promise.reject("没有权限"); } + + // 更新groupNameValue + if (changedKeys.includes("groupName")) { + setGroupNameValue(values.groupName); + } + sendCommand("CmdChatroomOperate", { wechatAccountId: contract.wechatAccountId, wechatChatroomId: contract.id, chatroomOperateType: 6, - extra: `{"chatroomName":"${groupNameValue}"}`, + extra: `{"chatroomName":"${values.groupName}"}`, }); - messageApi.success("群名称修改成功"); - setIsEditingGroupName(false); - }; - - // 点击编辑群名称按钮 - const handleEditGroupName = () => { - if (!hasGroupManagePermission()) { - messageApi.error("只有群主才能修改群名称"); - return; - } - setGroupNameValue(contractInfo.name || ""); - setIsEditingGroupName(true); + return Promise.resolve(); }; // 处理群公告保存 @@ -373,23 +504,26 @@ const Person: React.FC<PersonProps> = ({ contract }) => { }; // 处理我在本群中的昵称保存 - const handleSaveSelfDisplayName = () => { + const handleSaveSelfDisplayName = async ( + values: Record<string, string>, + changedKeys: string[], + ) => { + // 更新selfDisplayNameValue + if (changedKeys.includes("selfDisplayName")) { + setSelfDisplayNameValue(values.selfDisplayName); + } + sendCommand("CmdChatroomOperate", { wechatAccountId: contract.wechatAccountId, wechatChatroomId: contract.id, chatroomOperateType: 8, - extra: `${selfDisplayNameValue}`, + extra: `${values.selfDisplayName}`, }); - messageApi.success("群昵称修改成功"); - setIsEditingSelfDisplayName(false); + return Promise.resolve(); }; - // 处理取消编辑 - const handleCancelEdit = () => { - setRemarkValue(contract.conRemark || ""); - setIsEditingRemark(false); - }; + // 这里不再需要handleCancelEdit,已由DetailValue组件内部处理 // 处理标签点击切换 const handleTagToggle = (tagName: string) => { @@ -490,31 +624,84 @@ const Person: React.FC<PersonProps> = ({ contract }) => { }, }); }; - const extendFields = JSON.parse(contract.extendFields || "{}"); + const [extendFields, setExtendFields] = useState(() => { + try { + return JSON.parse(contract.extendFields || "{}"); + } catch (e) { + console.error("Failed to parse extendFields:", e); + return {}; + } + }); // 构建联系人或群聊详细信息 - const contractInfo = { - name: contract.name || contract.nickname, - nickname: contract.nickname, - alias: contract.alias, - wechatId: contract.wechatId, - chatroomId: isGroup ? contract.chatroomId : undefined, - chatroomOwner: isGroup ? contract.chatroomOwner : undefined, - avatar: contract.avatar || contract.chatroomAvatar, - phone: contract.phone || "-", - conRemark: remarkValue, // 使用当前编辑的备注值 - remark: extendFields.remark || "-", - email: contract.email || "-", - department: contract.department || "-", - position: contract.position || "-", - company: contract.company || "-", - region: contract.region || "-", - joinDate: contract.joinDate || "-", - notice: isGroup ? contract.notice : undefined, - selfDisplyName: isGroup ? contract.selfDisplyName : undefined, - status: "在线", - tags: selectedTags, - bio: contract.bio || contract.signature || "-", - }; + const contractInfo = useMemo(() => { + // 如果是个人资料且有API返回的详情数据,优先使用API数据 + if (!isGroup && friendDetail?.detail) { + const detail = friendDetail.detail; + // 解析扩展字段 + let extendFieldsObj: Record<string, string> = {}; + try { + extendFieldsObj = JSON.parse(detail.extendFields || "{}"); + } catch (e) { + console.error("Failed to parse extendFields in contractInfo:", e); + } + + return { + name: detail.nickname || detail.alias, + nickname: detail.nickname, + alias: detail.alias, + wechatId: detail.wechatId, + avatar: detail.avatar, + phone: extendFieldsObj.phone || detail.phone || "", + conRemark: detail.conRemark || remarkValue, + remark: extendFieldsObj.remark || "", + email: extendFieldsObj.email || "", + department: detail.company || "", // 使用company作为department + position: extendFieldsObj.position || detail.position || "", + company: extendFieldsObj.company || detail.company || "", + region: detail.region || "", + joinDate: detail.createTime || "", // 使用createTime作为joinDate + status: "在线", + tags: detail.labels || selectedTags, + bio: detail.signature || "", + address: extendFieldsObj.address || "", + qq: extendFieldsObj.qq || "", + }; + } + + // 否则使用传入的contract数据 + return { + name: contract.name || contract.nickname, + nickname: contract.nickname, + alias: contract.alias, + wechatId: contract.wechatId, + chatroomId: isGroup ? contract.chatroomId : undefined, + chatroomOwner: isGroup ? contract.chatroomOwner : undefined, + avatar: contract.avatar || contract.chatroomAvatar, + phone: contract.phone || "", + conRemark: remarkValue, // 使用当前编辑的备注值 + remark: extendFields?.remark || "", + email: contract.email || "", + department: contract.department || "", + position: contract.position || "", + company: contract.company || "", + region: contract.region || "", + joinDate: contract.joinDate || "", + notice: isGroup ? contract.notice : undefined, + selfDisplyName: isGroup ? contract.selfDisplyName : undefined, + status: "在线", + tags: selectedTags, + bio: contract.bio || contract.signature || "", + address: contract.address || "", + qq: contract.qq || "", + }; + }, [ + contract, + friendDetail, + isGroup, + remarkValue, + selectedTags, + extendFields, + ]); // 分页状态 const [currentContactPage, setCurrentContactPage] = useState(1); @@ -635,6 +822,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => { setIsLoadingContacts(false); } }; + // 不再需要加载状态和错误状态的渲染,始终显示缓存数据 + return ( <> {contextHolder} @@ -665,226 +854,144 @@ const Person: React.FC<PersonProps> = ({ contract }) => { <Card title="详细信息" className={styles.profileCard}> {isGroup ? ( // 群聊信息 - <> - <div className={styles.infoItem}> - <TeamOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>群名称:</span> - <div className={styles.infoValue}> - {isEditingGroupName ? ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - }} - > - <Input - value={groupNameValue} - onChange={e => setGroupNameValue(e.target.value)} - placeholder="请输入群名称" - size="small" - style={{ flex: 1 }} - /> - <Button - type="text" - size="small" - icon={<CheckOutlined />} - onClick={handleSaveGroupName} - style={{ color: "#52c41a" }} - /> - <Button - type="text" - size="small" - icon={<CloseOutlined />} - onClick={() => { - setGroupNameValue(contract.name || ""); - setIsEditingGroupName(false); - }} - style={{ color: "#ff4d4f" }} - /> - </div> - ) : ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - }} - > - <Tooltip - title={contractInfo.nickname || contractInfo.name} - placement="top" - > - <h4 - className={styles.profileNickname} - style={{ margin: 0, flexShrink: 0 }} - > - {contractInfo.nickname || contractInfo.name} - </h4> - </Tooltip> - {hasGroupManagePermission() && ( - <Button - type="text" - size="small" - icon={<EditOutlined />} - onClick={handleEditGroupName} - /> - )} - </div> - )} - </div> - </div> - <div className={styles.infoItem}> - <TeamOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>群ID:</span> - <span className={styles.infoValue}> - {contractInfo.chatroomId} - </span> - </div> - <div className={styles.infoItem}> - <UserOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>群主:</span> - <span className={styles.infoValue}> - {contractInfo.chatroomOwner} - </span> - </div> - <div className={styles.infoItem}> - <UserOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>群昵称:</span> - <div className={styles.infoValue}> - {isEditingSelfDisplayName ? ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - }} - > - <Input - value={selfDisplayNameValue} - onChange={e => setSelfDisplayNameValue(e.target.value)} - placeholder="请输入群昵称" - size="small" - style={{ flex: 1 }} - /> - <Button - type="text" - size="small" - icon={<CheckOutlined />} - onClick={handleSaveSelfDisplayName} - style={{ color: "#52c41a" }} - /> - <Button - type="text" - size="small" - icon={<CloseOutlined />} - onClick={() => { - setSelfDisplayNameValue( - contract.selfDisplyName || "", - ); - setIsEditingSelfDisplayName(false); - }} - style={{ color: "#ff4d4f" }} - /> - </div> - ) : ( - <div - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - }} - > - <span> - {contractInfo.selfDisplyName || "点击添加群昵称"} - </span> - <Button - type="text" - size="small" - icon={<EditOutlined />} - onClick={() => setIsEditingSelfDisplayName(true)} - /> - </div> - )} - </div> - </div> - </> + <DetailValue + fields={[ + { + key: "groupName", + label: "群名称", + ifEdit: hasGroupManagePermission(), + placeholder: "请输入群名称", + type: "text", + }, + { + key: "chatroomId", + label: "群ID", + ifEdit: false, + }, + { + key: "chatroomOwner", + label: "群主", + ifEdit: false, + }, + { + key: "selfDisplayName", + label: "群昵称", + placeholder: "点击添加群昵称", + type: "text", + }, + ]} + value={{ + groupName: groupNameValue, + chatroomId: contractInfo.chatroomId || "", + chatroomOwner: contractInfo.chatroomOwner || "", + selfDisplayName: selfDisplayNameValue, + }} + saveHandler={async (values, changedKeys) => { + if (changedKeys.includes("groupName")) { + await handleSaveGroupName(values, ["groupName"]); + } + if (changedKeys.includes("selfDisplayName")) { + await handleSaveSelfDisplayName(values, ["selfDisplayName"]); + } + return Promise.resolve(); + }} + onSaveSuccess={(values, changedKeys) => { + // 更新本地值 + if (changedKeys.includes("groupName")) { + setGroupNameValue(values.groupName); + } + if (changedKeys.includes("selfDisplayName")) { + setSelfDisplayNameValue(values.selfDisplayName); + } + }} + isGroup={true} + /> ) : ( // 好友信息 - <> - <div className={styles.infoItem}> - <TeamOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>微信号:</span> - <span className={styles.infoValue}> - {contractInfo.alias || contractInfo.wechatId} - </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}> - <EnvironmentOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>地区:</span> - <span className={styles.infoValue}>{contractInfo.region}</span> - </div> - </> - )} - {!isGroup && ( - <div className={styles.infoItem}> - <EditOutlined className={styles.infoIcon} /> - <span className={styles.infoLabel}>备注:</span> - <div className={styles.infoValue}> - {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>{contractInfo.conRemark || "点击添加备注"}</span> - <Button - type="text" - size="small" - icon={<EditOutlined />} - onClick={() => setIsEditingRemark(true)} - /> - </div> - )} - </div> - </div> + <DetailValue + fields={[ + { + key: "wechatId", + label: "微信号", + ifEdit: false, + }, + { + key: "region", + label: "地区", + ifEdit: false, + }, + { + key: "address", + label: "地址", + placeholder: "点击添加", + ifEdit: true, + }, + { + key: "phone", + label: "电话", + placeholder: "点击添加", + ifEdit: true, + }, + { + key: "qq", + label: "QQ", + placeholder: "点击添加", + ifEdit: true, + }, + { + key: "email", + label: "邮箱", + placeholder: "点击添加", + ifEdit: true, + }, + { + key: "company", + label: "公司", + placeholder: "点击添加", + type: "textarea", + ifEdit: true, + }, + { + key: "position", + label: "职位", + placeholder: "点击添加", + ifEdit: true, + }, + { + key: "conRemark", + label: "微信备注", + placeholder: "点击添加备注", + type: "text", + }, + { + key: "remark", + label: "描述", + placeholder: "点击添加描述", + type: "textarea", + }, + ]} + value={{ + id: contract.id.toString() || "", + wechatId: contractInfo.alias || contractInfo.wechatId || "", + phone: contractInfo.phone || "", + region: contractInfo.region || "", + conRemark: contractInfo.conRemark || "", + remark: contractInfo.remark || "", + address: contractInfo.address || "", + qq: contractInfo.qq || "", + email: contractInfo.email || "", + company: contractInfo.company || "", + position: contractInfo.position || "", + }} + saveHandler={handleSaveRemark} + onSaveSuccess={(values, changedKeys) => { + // 更新本地值 + if (changedKeys.includes("conRemark")) { + setRemarkValue(values.conRemark); + } + }} + isGroup={false} + /> )} </Card> diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx index 7d322637..df066762 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/index.tsx @@ -18,6 +18,16 @@ interface PersonProps { const Person: React.FC<PersonProps> = ({ contract }) => { const [activeKey, setActiveKey] = useState("profile"); const isGroup = "chatroomId" in contract; + // 使用state保存当前contract的副本,确保在切换tab时不会丢失修改 + const [currentContract, setCurrentContract] = useState< + ContractData | weChatGroup + >(contract); + + // 当外部contract变化时,更新内部状态 + useEffect(() => { + setCurrentContract(contract); + }, [contract]); + const tabItems = useMemo(() => { const baseItems = [ { @@ -28,18 +38,18 @@ const Person: React.FC<PersonProps> = ({ contract }) => { { key: "profile", label: isGroup ? "群资料" : "个人资料", - children: <ProfileModules contract={contract} />, + children: <ProfileModules contract={currentContract} />, }, ]; if (!isGroup) { baseItems.push({ key: "moments", label: "朋友圈", - children: <FriendsCircle wechatFriendId={contract.id} />, + children: <FriendsCircle wechatFriendId={currentContract.id} />, }); } return baseItems; - }, [contract, isGroup]); + }, [currentContract, isGroup]); useEffect(() => { setActiveKey("profile"); From e9aa500800dc986a4b26bb49d4c1662080461082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 19 Nov 2025 09:43:06 +0800 Subject: [PATCH 24/26] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=8A=A9=E6=89=8B=E7=BB=84=E4=BB=B6=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=AF=BC=E8=88=AA=E8=B7=AF=E5=BE=84=E7=9A=84=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E6=96=B9=E5=BC=8F=EF=BC=8C=E6=9B=B4=E6=96=B0=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E6=96=87=E6=9C=AC=E4=BB=A5=E6=94=B9=E5=96=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PushTaskModal.tsx | 2 +- .../create-push-task/index.tsx | 2 +- .../message-push-assistant/index.tsx | 21 +++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx index 74cec1e1..af9d7373 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx @@ -105,7 +105,7 @@ const PushTaskModal: React.FC<PushTaskModalProps> = ({ }; const getSubtitle = () => { - return "智能批量推送,AI智能话术改写"; + return "智能批量推送,AI智能话术改写"; }; // 步骤2的标题 diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx index d682b46c..5ee5b4bd 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/index.tsx @@ -127,7 +127,7 @@ const CreatePushTask: React.FC = () => { } }, [validPushType]); - const subtitle = "智能批量推送,AI智能话术改写"; + const subtitle = "智能批量推送,AI智能话术改写"; const step2Title = useMemo(() => { switch (validPushType) { diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/index.tsx index acf205f3..2a855937 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/index.tsx @@ -12,7 +12,10 @@ import { } from "@ant-design/icons"; import styles from "./index.module.scss"; -export type PushType = "friend-message" | "group-message" | "group-announcement"; +export type PushType = + | "friend-message" + | "group-message" + | "group-announcement"; const MessagePushAssistant: React.FC = () => { const navigate = useNavigate(); @@ -26,7 +29,9 @@ const MessagePushAssistant: React.FC = () => { icon: <UserOutlined />, color: "#1890ff", onClick: () => { - navigate("/pc/powerCenter/message-push-assistant/create-push-task/friend-message"); + navigate( + "/pc/powerCenter/message-push-assistant/create-push-task/friend-message", + ); }, }, { @@ -36,17 +41,21 @@ const MessagePushAssistant: React.FC = () => { icon: <MessageOutlined />, color: "#52c41a", onClick: () => { - navigate("/pc/powerCenter/message-push-assistant/create-push-task/group-message"); + navigate( + "/pc/powerCenter/message-push-assistant/create-push-task/group-message", + ); }, }, { id: "group-announcement", title: "群公告推送", - description: "向选定的微信群发布群公告", + description: "向选定的微信群批量发布群公告", icon: <SoundOutlined />, color: "#722ed1", onClick: () => { - navigate("/pc/powerCenter/message-push-assistant/create-push-task/group-announcement"); + navigate( + "/pc/powerCenter/message-push-assistant/create-push-task/group-announcement", + ); }, }, ]; @@ -81,7 +90,7 @@ const MessagePushAssistant: React.FC = () => { <div style={{ padding: "20px" }}> <PowerNavigation title="消息推送助手" - subtitle="智能批量推送,AI智能话术改写" + subtitle="智能批量推送,AI智能话术改写" showBackButton={true} backButtonText="返回" onBackClick={() => navigate("/pc/powerCenter")} From bb2b681ae07a2b04c042fef2f3eb2b065ac400f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 19 Nov 2025 10:04:32 +0800 Subject: [PATCH 25/26] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=85=85=E5=80=BC?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=AE=A1=E7=90=86=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?OrderList=E6=8E=A5=E5=8F=A3=E4=BB=A5=E6=94=AF=E6=8C=81=E7=81=B5?= =?UTF-8?q?=E6=B4=BB=E7=9A=84=E6=95=B0=E6=8D=AE=E7=B1=BB=E5=9E=8B=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E5=B9=B6=E6=94=B9=E8=BF=9B=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=9A=84UI=E4=BA=A4=E4=BA=92=E3=80=82?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BB=98=E6=AC=BE=E8=BF=9E=E7=BB=AD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E5=B9=B6=E9=80=9A=E8=BF=87=E6=9B=B4=E5=A5=BD?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E8=A1=A8=E7=A4=BA=E6=9D=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AE=A2=E5=8D=95=E8=AF=A6=E7=BB=86=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=91=88=E7=8E=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/mobile/mine/recharge/index/api.ts | 24 +- .../mine/recharge/index/index.module.scss | 12 + .../mobile/mine/recharge/index/index.tsx | 202 ++++--- .../pages/mobile/mine/recharge/order/api.ts | 202 +------ .../pages/mobile/mine/recharge/order/data.ts | 54 +- .../recharge/order/detail/index.module.scss | 154 ++++++ .../mine/recharge/order/detail/index.tsx | 328 +++++++++++ .../mobile/mine/recharge/order/index.tsx | 508 +++++++++++------- .../plan/new/steps/BasicSettings.tsx | 2 +- Cunkebao/src/router/index.tsx | 2 +- Cunkebao/src/router/module/mine.tsx | 6 + 11 files changed, 1042 insertions(+), 452 deletions(-) create mode 100644 Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts index 6a870667..de13a1e0 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts @@ -75,7 +75,7 @@ export interface OrderListParams { [property: string]: any; } -interface OrderList { +export interface OrderList { id?: number; mchId?: number; companyId?: number; @@ -84,22 +84,24 @@ interface OrderList { status?: number; goodsId?: number; goodsName?: string; - goodsSpecs?: { - id: number; - name: string; - price: number; - tokens: number; - }; + goodsSpecs?: + | { + id: number; + name: string; + price: number; + tokens: number; + } + | string; money?: number; orderNo?: string; ip?: string; nonceStr?: string; - createTime?: string; + createTime?: string | number; payType?: number; - payTime?: string; + payTime?: string | number; payInfo?: any; - deleteTime?: string; - tokens?: string; + deleteTime?: string | number; + tokens?: string | number; statusText?: string; orderTypeText?: string; payTypeText?: string; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss index be4e8f82..99d1355e 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss @@ -258,6 +258,18 @@ border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); border: 1px solid #f0f0f0; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.recordItem:active { + transform: scale(0.98); +} + +.recordItem:hover { + box-shadow: 0 6px 18px rgba(22, 119, 255, 0.08); } .recordHeader { diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx index d713b2f3..3f52a632 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx @@ -12,17 +12,74 @@ import { import NavCommon from "@/components/NavCommon"; import Layout from "@/components/Layout/Layout"; import { getStatistics, getOrderList } from "./api"; -import type { Statistics } from "./api"; +import type { Statistics, OrderList } from "./api"; import { Pagination } from "antd"; -type OrderRecordView = { - id: number; - type: string; - status: string; - amount: number; // 元 - power: number; - description: string; - createTime: string; +type TagColor = NonNullable<React.ComponentProps<typeof Tag>["color"]>; + +type GoodsSpecs = + | { + id: number; + name: string; + price: number; + tokens: number; + } + | undefined; + +const parseGoodsSpecs = (value: OrderList["goodsSpecs"]): GoodsSpecs => { + if (!value) return undefined; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch (error) { + console.warn("解析 goodsSpecs 失败:", error, value); + return undefined; + } + } + return value; +}; + +const formatTimestamp = (value?: number | string | null) => { + if (value === undefined || value === null) return ""; + if (typeof value === "string" && value.trim() === "") return ""; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + + if (Number.isNaN(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) { + return num / 100; + } + return num; }; const PowerManagement: React.FC = () => { @@ -30,7 +87,7 @@ const PowerManagement: React.FC = () => { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); const [stats, setStats] = useState<Statistics | null>(null); - const [records, setRecords] = useState<OrderRecordView[]>([]); + const [records, setRecords] = useState<OrderList[]>([]); const [filterType, setFilterType] = useState<string>("all"); const [filterStatus, setFilterStatus] = useState<string>("all"); const [filterTypeVisible, setFilterTypeVisible] = useState(false); @@ -50,11 +107,19 @@ const PowerManagement: React.FC = () => { const statusOptions = [ { label: "全部状态", value: "all" }, - { label: "已完成", value: "completed" }, - { label: "进行中", value: "processing" }, - { label: "已取消", value: "cancelled" }, + { label: "待支付", value: "pending", requestValue: "0" }, + { label: "已支付", value: "paid", requestValue: "1" }, + { label: "已取消", value: "cancelled", requestValue: "2" }, + { label: "已退款", value: "refunded", requestValue: "3" }, ]; + const statusMeta: Record<number, { label: string; color: TagColor }> = { + 0: { label: "待支付", color: "warning" }, + 1: { label: "已支付", color: "success" }, + 2: { label: "已取消", color: "default" }, + 3: { label: "已退款", color: "primary" }, + }; + useEffect(() => { fetchStats(); }, []); @@ -81,35 +146,20 @@ const PowerManagement: React.FC = () => { setLoading(true); try { const reqPage = customPage !== undefined ? customPage : page; - // 映射状态到订单状态:0待支付 1已支付 2已取消 3已退款 - const statusMap: Record<string, string | undefined> = { - all: undefined, - completed: "1", - processing: "0", - cancelled: "2", - }; - + const statusRequestValue = statusOptions.find( + opt => opt.value === filterStatus, + )?.requestValue; const res = await getOrderList({ page: String(reqPage), limit: String(pageSize), orderType: "1", - status: statusMap[filterStatus], + status: statusRequestValue, }); - - const list = (res.list || []).map((o: any) => ({ - id: o.id, - type: o.orderTypeText || o.goodsName || "充值订单", - status: o.statusText || "", - amount: typeof o.money === "number" ? o.money / 100 : 0, - power: Number(o.goodsSpecs?.tokens ?? o.tokens ?? 0), - description: o.goodsName || "", - createTime: o.createTime || "", - })); - setRecords(list); + setRecords(res.list || []); setTotal(Number(res.total || 0)); } catch (error) { - console.error("获取消费记录失败:", error); - Toast.show({ content: "获取消费记录失败", position: "top" }); + console.error("获取订单记录失败:", error); + Toast.show({ content: "获取订单记录失败", position: "top" }); } finally { setLoading(false); } @@ -225,7 +275,7 @@ const PowerManagement: React.FC = () => { </div> ); - // 渲染消费记录Tab + // 渲染订单记录Tab const renderRecords = () => ( <div className={style.recordsContent}> {/* 筛选器 */} @@ -273,42 +323,72 @@ const PowerManagement: React.FC = () => { </Picker> </div> - {/* 消费记录列表 */} + {/* 订单记录列表 */} <div className={style.recordList}> {loading && records.length === 0 ? ( <div className={style.loadingContainer}> <div className={style.loadingText}>加载中...</div> </div> ) : records.length > 0 ? ( - records.map(record => ( - <Card key={record.id} className={style.recordItem}> - <div className={style.recordHeader}> - <div className={style.recordLeft}> - <div className={style.recordType}>{record.type}</div> - <Tag - color={record.status === "已完成" ? "success" : "primary"} - className={style.recordStatus} - > - {record.status} - </Tag> - </div> - <div className={style.recordRight}> - <div className={style.recordAmount}> - -¥{record.amount.toFixed(1)} + records.map(record => { + const statusCode = + record.status !== undefined ? Number(record.status) : undefined; + const tagColor = + statusCode !== undefined + ? statusMeta[statusCode]?.color || "default" + : "default"; + const tagLabel = + record.statusText || + (statusCode !== undefined + ? statusMeta[statusCode]?.label || "未知状态" + : "未知状态"); + const goodsSpecs = parseGoodsSpecs(record.goodsSpecs); + const amount = centsToYuan(record.money); + const powerValue = Number(goodsSpecs?.tokens ?? record.tokens ?? 0); + const power = Number.isNaN(powerValue) ? 0 : powerValue; + const description = + record.orderTypeText || + goodsSpecs?.name || + record.goodsName || + ""; + const createTime = formatTimestamp(record.createTime); + + return ( + <Card + key={record.id ?? record.orderNo} + className={style.recordItem} + onClick={() => + record.orderNo && + navigate(`/recharge/order/${record.orderNo}`) + } + > + <div className={style.recordHeader}> + <div className={style.recordLeft}> + <div className={style.recordType}> + {record.goodsName || "算力充值"} + </div> + <Tag color={tagColor} className={style.recordStatus}> + {tagLabel} + </Tag> </div> - <div className={style.recordPower}> - {formatNumber(record.power)} 算力 + <div className={style.recordRight}> + <div className={style.recordAmount}> + -¥{amount.toFixed(2)} + </div> + <div className={style.recordPower}> + {formatNumber(power)} 算力 + </div> </div> </div> - </div> - <div className={style.recordDesc}>{record.description}</div> - <div className={style.recordTime}>{record.createTime}</div> - </Card> - )) + <div className={style.recordDesc}>{description}</div> + <div className={style.recordTime}>{createTime}</div> + </Card> + ); + }) ) : ( <div className={style.emptyRecords}> <div className={style.emptyIcon}>📋</div> - <div className={style.emptyText}>暂无消费记录</div> + <div className={style.emptyText}>暂无订单记录</div> </div> )} </div> @@ -334,7 +414,7 @@ const PowerManagement: React.FC = () => { className={style.powerTabs} > <Tabs.Tab title="概览" key="overview" /> - <Tabs.Tab title="消费记录" key="records" /> + <Tabs.Tab title="订单记录" key="records" /> </Tabs> </> } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts index 11d4573e..acf1b12d 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts @@ -1,197 +1,37 @@ import { - RechargeOrdersResponse, RechargeOrderDetail, RechargeOrderParams, + GetRechargeOrderDetailParams, } from "./data"; - -// 模拟数据 -const mockOrders = [ - { - id: "1", - orderNo: "RC20241201001", - amount: 100.0, - paymentMethod: "wechat", - status: "success" as const, - createTime: "2024-12-01T10:30:00Z", - payTime: "2024-12-01T10:32:15Z", - description: "账户充值", - balance: 150.0, - }, - { - id: "2", - orderNo: "RC20241201002", - amount: 200.0, - paymentMethod: "alipay", - status: "pending" as const, - createTime: "2024-12-01T14:20:00Z", - description: "账户充值", - balance: 350.0, - }, - { - id: "3", - orderNo: "RC20241130001", - amount: 50.0, - paymentMethod: "bank", - status: "success" as const, - createTime: "2024-11-30T09:15:00Z", - payTime: "2024-11-30T09:18:30Z", - description: "账户充值", - balance: 50.0, - }, - { - id: "4", - orderNo: "RC20241129001", - amount: 300.0, - paymentMethod: "wechat", - status: "failed" as const, - createTime: "2024-11-29T16:45:00Z", - description: "账户充值", - }, - { - id: "5", - orderNo: "RC20241128001", - amount: 150.0, - paymentMethod: "alipay", - status: "cancelled" as const, - createTime: "2024-11-28T11:20:00Z", - description: "账户充值", - }, - { - id: "6", - orderNo: "RC20241127001", - amount: 80.0, - paymentMethod: "wechat", - status: "success" as const, - createTime: "2024-11-27T13:10:00Z", - payTime: "2024-11-27T13:12:45Z", - description: "账户充值", - balance: 80.0, - }, - { - id: "7", - orderNo: "RC20241126001", - amount: 120.0, - paymentMethod: "bank", - status: "success" as const, - createTime: "2024-11-26T08:30:00Z", - payTime: "2024-11-26T08:33:20Z", - description: "账户充值", - balance: 120.0, - }, - { - id: "8", - orderNo: "RC20241125001", - amount: 250.0, - paymentMethod: "alipay", - status: "pending" as const, - createTime: "2024-11-25T15:45:00Z", - description: "账户充值", - balance: 370.0, - }, -]; - -// 模拟延迟 -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +import request from "@/api/request"; // 获取充值记录列表 -export async function getRechargeOrders( - params: RechargeOrderParams, -): Promise<RechargeOrdersResponse> { - await delay(800); // 模拟网络延迟 - - let filteredOrders = [...mockOrders]; - - // 状态筛选 - if (params.status && params.status !== "all") { - filteredOrders = filteredOrders.filter( - order => order.status === params.status, - ); - } - - // 时间筛选 - if (params.startTime) { - filteredOrders = filteredOrders.filter( - order => new Date(order.createTime) >= new Date(params.startTime!), - ); - } - if (params.endTime) { - filteredOrders = filteredOrders.filter( - order => new Date(order.createTime) <= new Date(params.endTime!), - ); - } - - // 分页 - const startIndex = (params.page - 1) * params.limit; - const endIndex = startIndex + params.limit; - const paginatedOrders = filteredOrders.slice(startIndex, endIndex); - - return { - list: paginatedOrders, - total: filteredOrders.length, - page: params.page, - limit: params.limit, - }; +export async function getRechargeOrders(params: RechargeOrderParams) { + return request("/v1/tokens/orderList", params, "GET"); } // 获取充值记录详情 export async function getRechargeOrderDetail( - id: string, + params: GetRechargeOrderDetailParams, ): Promise<RechargeOrderDetail> { - await delay(500); - - const order = mockOrders.find(o => o.id === id); - if (!order) { - throw new Error("订单不存在"); - } - - return { - ...order, - paymentChannel: - order.paymentMethod === "wechat" - ? "微信支付" - : order.paymentMethod === "alipay" - ? "支付宝" - : "银行转账", - transactionId: `TX${order.orderNo}`, - }; + return request("/v1/tokens/queryOrder", params, "GET"); } -// 取消充值订单 -export async function cancelRechargeOrder(id: string): Promise<void> { - await delay(1000); - - const orderIndex = mockOrders.findIndex(o => o.id === id); - if (orderIndex === -1) { - throw new Error("订单不存在"); - } - - if (mockOrders[orderIndex].status !== "pending") { - throw new Error("只能取消处理中的订单"); - } - - // 模拟更新订单状态 - (mockOrders[orderIndex] as any).status = "cancelled"; +export interface ContinuePayParams { + orderNo: string; + [property: string]: any; } -// 申请退款 -export async function refundRechargeOrder( - id: string, - reason: string, -): Promise<void> { - await delay(1200); - - const orderIndex = mockOrders.findIndex(o => o.id === id); - if (orderIndex === -1) { - throw new Error("订单不存在"); - } - - if (mockOrders[orderIndex].status !== "success") { - throw new Error("只能对成功的订单申请退款"); - } - - // 模拟添加退款信息 - const order = mockOrders[orderIndex]; - (order as any).refundAmount = order.amount; - (order as any).refundTime = new Date().toISOString(); - (order as any).refundReason = reason; +export interface ContinuePayResponse { + code_url?: string; + codeUrl?: string; + payUrl?: string; + [property: string]: any; +} + +// 继续支付 +export function continuePay( + params: ContinuePayParams, +): Promise<ContinuePayResponse> { + return request("/v1/tokens/pay", params, "POST"); } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts b/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts index 95b9a0e9..2fc56bbd 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts @@ -1,40 +1,62 @@ // 充值记录类型定义 export interface RechargeOrder { - id: string; - orderNo: string; - amount: number; - paymentMethod: string; - status: "success" | "pending" | "failed" | "cancelled"; - createTime: string; - payTime?: string; + id?: number | string; + orderNo?: string; + money?: number; + amount?: number; + paymentMethod?: string; + paymentChannel?: string; + status?: number | string; + statusText?: string; + orderType?: number; + orderTypeText?: string; + createTime?: string | number; + payTime?: string | number; description?: string; + goodsName?: string; + goodsSpecs?: + | { + id: number; + name: string; + price: number; + tokens: number; + } + | string; remark?: string; operator?: string; balance?: number; + tokens?: number | string; + payType?: number; + payTypeText?: string; + transactionId?: string; } // API响应类型 export interface RechargeOrdersResponse { list: RechargeOrder[]; - total: number; - page: number; - limit: number; + total?: number; + page?: number; + limit?: number; } // 充值记录详情 export interface RechargeOrderDetail extends RechargeOrder { - paymentChannel?: string; - transactionId?: string; refundAmount?: number; - refundTime?: string; + refundTime?: string | number; refundReason?: string; } // 查询参数 export interface RechargeOrderParams { - page: number; - limit: number; - status?: string; + page?: number | string; + limit?: number | string; + status?: number | string; startTime?: string; endTime?: string; + [property: string]: any; +} + +export interface GetRechargeOrderDetailParams { + orderNo: string; + [property: string]: any; } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss new file mode 100644 index 00000000..72f68cb2 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss @@ -0,0 +1,154 @@ +.detailPage { + padding: 16px 16px 80px; +} + +.statusCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(0, 95, 204, 0.06); + margin-bottom: 20px; + text-align: center; +} + +.statusIcon { + font-size: 56px; + margin-bottom: 12px; +} + +.statusTitle { + font-size: 20px; + font-weight: 600; + color: #1d2129; +} + +.statusDesc { + margin-top: 6px; + font-size: 14px; + color: #86909c; +} + +.amountHighlight { + margin-top: 12px; + font-size: 24px; + font-weight: 600; + color: #00b578; +} + +.section { + background: #ffffff; + border-radius: 16px; + padding: 20px 16px; + box-shadow: 0 6px 24px rgba(15, 54, 108, 0.05); + margin-bottom: 16px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + color: #1d2129; + margin-bottom: 12px; +} + +.sectionList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.row:not(:last-child) { + padding-bottom: 12px; + border-bottom: 1px dashed #e5e6eb; +} + +.label { + font-size: 14px; + color: #86909c; + flex-shrink: 0; +} + +.value { + font-size: 14px; + color: #1d2129; + text-align: right; + word-break: break-all; +} + +.copyBtn { + margin-left: 8px; + font-size: 13px; + color: #1677ff; + cursor: pointer; +} + +.tagGroup { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.actions { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px 24px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 70%); + box-shadow: 0 -4px 20px rgba(20, 66, 125, 0.06); + display: flex; + gap: 12px; +} + +.invoiceBtn { + flex: 1; + border: 1px solid #1677ff; + color: #1677ff; + border-radius: 20px; +} + +.backBtn { + flex: 1; + background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%); + color: #ffffff; + border-radius: 20px; +} + +.loadingWrapper { + display: flex; + align-items: center; + justify-content: center; + height: 200px; +} + +.emptyWrapper { + text-align: center; + color: #86909c; + padding: 40px 0; +} + +.refundBlock { + margin-top: 12px; + padding: 12px; + border-radius: 12px; + background: #f5f7ff; + color: #1d2129; + line-height: 1.6; +} + +.refundTitle { + font-weight: 600; + margin-bottom: 6px; +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx new file mode 100644 index 00000000..bb8c64d1 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button, SpinLoading, Tag, Toast } from "antd-mobile"; +import { + CheckCircleOutline, + CloseCircleOutline, + ClockCircleOutline, + ExclamationCircleOutline, +} from "antd-mobile-icons"; +import { CopyOutlined } from "@ant-design/icons"; +import Layout from "@/components/Layout/Layout"; +import NavCommon from "@/components/NavCommon"; +import { getRechargeOrderDetail } from "../api"; +import type { RechargeOrderDetail } from "../data"; +import style from "./index.module.scss"; + +type StatusMeta = { + title: string; + description: string; + amountPrefix: string; + color: string; + icon: React.ReactNode; +}; + +type StatusCode = 0 | 1 | 2 | 3 | 4; + +const statusMetaMap: Record<StatusCode, StatusMeta> = { + 1: { + title: "支付成功", + description: "订单已完成支付", + amountPrefix: "已支付", + color: "#00b578", + icon: <CheckCircleOutline className={style.statusIcon} color="#00b578" />, + }, + 0: { + title: "待支付", + description: "请尽快完成支付,以免订单失效", + amountPrefix: "待支付", + color: "#faad14", + icon: <ClockCircleOutline className={style.statusIcon} color="#faad14" />, + }, + 4: { + title: "支付失败", + description: "支付未成功,可重新发起支付", + amountPrefix: "需支付", + color: "#ff4d4f", + icon: <CloseCircleOutline className={style.statusIcon} color="#ff4d4f" />, + }, + 2: { + title: "订单已取消", + description: "该订单已取消,如需继续请重新创建订单", + amountPrefix: "订单金额", + color: "#86909c", + icon: ( + <ExclamationCircleOutline className={style.statusIcon} color="#86909c" /> + ), + }, + 3: { + title: "订单已退款", + description: "订单款项已退回,请注意查收", + amountPrefix: "退款金额", + color: "#1677ff", + icon: ( + <ExclamationCircleOutline className={style.statusIcon} color="#1677ff" /> + ), + }, +}; + +const parseStatusCode = (status?: RechargeOrderDetail["status"]) => { + if (status === undefined || status === null) return undefined; + if (typeof status === "number") + return statusMetaMap[status] ? status : undefined; + const numeric = Number(status); + if (!Number.isNaN(numeric) && statusMetaMap[numeric as StatusCode]) { + return numeric as StatusCode; + } + const map: Record<string, StatusCode> = { + success: 1, + pending: 0, + failed: 4, + cancelled: 2, + refunded: 3, + }; + return map[status] ?? undefined; +}; + +const formatDateTime = (value?: string | number | null) => { + if (value === undefined || value === null) return "-"; + if (typeof value === "string" && value.trim() === "") return "-"; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) return num / 100; + return num; +}; + +const RechargeOrderDetailPage: React.FC = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState<RechargeOrderDetail | null>(null); + + useEffect(() => { + if (!id) { + Toast.show({ content: "缺少订单ID", position: "top" }); + navigate(-1); + return; + } + + const fetchDetail = async () => { + try { + setLoading(true); + const res = await getRechargeOrderDetail({ orderNo: id }); + setDetail(res); + } catch (error) { + console.error("获取订单详情失败:", error); + Toast.show({ content: "获取订单详情失败", position: "top" }); + } finally { + setLoading(false); + } + }; + + fetchDetail(); + }, [id, navigate]); + + const meta = useMemo<StatusMeta>(() => { + if (!detail) { + return statusMetaMap[0]; + } + const code = parseStatusCode(detail.status); + if (code !== undefined && statusMetaMap[code]) { + return statusMetaMap[code]; + } + return statusMetaMap[0]; + }, [detail]); + + const handleCopy = async (text?: string) => { + if (!text) return; + if (!navigator.clipboard) { + Toast.show({ content: "当前环境不支持复制", position: "top" }); + return; + } + try { + await navigator.clipboard.writeText(text); + Toast.show({ content: "复制成功", position: "top" }); + } catch (error) { + console.error("复制失败:", error); + Toast.show({ content: "复制失败,请手动复制", position: "top" }); + } + }; + + const handleApplyInvoice = () => { + Toast.show({ content: "发票功能即将上线,敬请期待", position: "top" }); + }; + + const handleBack = () => { + navigate("/recharge"); + }; + + const renderRefundInfo = () => { + if (!detail?.refundAmount) return null; + return ( + <div className={style.refundBlock}> + <div className={style.refundTitle}>退款信息</div> + <div>退款金额:¥{centsToYuan(detail.refundAmount).toFixed(2)}</div> + {detail.refundTime ? ( + <div>退款时间:{formatDateTime(detail.refundTime)}</div> + ) : null} + {detail.refundReason ? ( + <div>退款原因:{detail.refundReason}</div> + ) : null} + </div> + ); + }; + + return ( + <Layout + header={<NavCommon title="订单详情" />} + loading={loading && !detail} + footer={ + <div className={style.actions}> + <Button className={style.invoiceBtn} onClick={handleApplyInvoice}> + 申请发票 + </Button> + <Button className={style.backBtn} onClick={handleBack}> + 返回算力中心 + </Button> + </div> + } + > + <div className={style.detailPage}> + {loading && !detail ? ( + <div className={style.loadingWrapper}> + <SpinLoading color="primary" /> + </div> + ) : !detail ? ( + <div className={style.emptyWrapper}>未找到订单详情</div> + ) : ( + <> + <div className={style.statusCard}> + {meta.icon} + <div className={style.statusTitle}>{meta.title}</div> + <div className={style.statusDesc}>{meta.description}</div> + <div + className={style.amountHighlight} + style={{ color: meta.color }} + > + {meta.amountPrefix} ¥ + {centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)} + </div> + </div> + + <div className={style.section}> + <div className={style.sectionTitle}>订单信息</div> + <div className={style.sectionList}> + <div className={style.row}> + <span className={style.label}>订单号</span> + <span className={style.value}> + {detail.orderNo || "-"} + <span + className={style.copyBtn} + onClick={() => handleCopy(detail.orderNo)} + > + <CopyOutlined /> + </span> + </span> + </div> + <div className={style.row}> + <span className={style.label}>套餐名称</span> + <span className={style.value}> + {detail.description || detail.goodsName || "算力充值"} + </span> + </div> + <div className={style.row}> + <span className={style.label}>订单金额</span> + <span className={style.value}> + ¥ + {centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)} + </span> + </div> + <div className={style.row}> + <span className={style.label}>创建时间</span> + <span className={style.value}> + {formatDateTime(detail.createTime)} + </span> + </div> + <div className={style.row}> + <span className={style.label}>支付时间</span> + <span className={style.value}> + {formatDateTime(detail.payTime)} + </span> + </div> + {detail.balance !== undefined ? ( + <div className={style.row}> + <span className={style.label}>充值后余额</span> + <span className={style.value}> + ¥{centsToYuan(detail.balance).toFixed(2)} + </span> + </div> + ) : null} + </div> + {renderRefundInfo()} + </div> + + <div className={style.section}> + <div className={style.sectionTitle}>支付信息</div> + <div className={style.sectionList}> + <div className={style.row}> + <span className={style.label}>支付方式</span> + <span className={style.value}> + <span className={style.tagGroup}> + <Tag color="primary" fill="outline"> + {detail.payTypeText || + detail.paymentChannel || + detail.paymentMethod || + "其他支付"} + </Tag> + </span> + </span> + </div> + <div className={style.row}> + <span className={style.label}>交易流水号</span> + <span className={style.value}>{detail.id || "-"}</span> + </div> + {detail.remark ? ( + <div className={style.row}> + <span className={style.label}>备注信息</span> + <span className={style.value}>{detail.remark}</span> + </div> + ) : null} + </div> + </div> + </> + )} + </div> + </Layout> + ); +}; + +export default RechargeOrderDetailPage; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx index 7e5260e6..240589b6 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Card, SpinLoading, Empty, Toast, Dialog } from "antd-mobile"; import { @@ -10,14 +10,110 @@ import { } from "@ant-design/icons"; import NavCommon from "@/components/NavCommon"; import Layout from "@/components/Layout/Layout"; -import { - getRechargeOrders, - cancelRechargeOrder, - refundRechargeOrder, -} from "./api"; +import { getRechargeOrders, continuePay } from "./api"; import { RechargeOrder } from "./data"; import style from "./index.module.scss"; +type StatusCode = 0 | 1 | 2 | 3 | 4; + +const STATUS_META: Record< + StatusCode, + { label: string; color: string; tagBgOpacity?: number } +> = { + 0: { label: "待支付", color: "#faad14" }, + 1: { label: "充值成功", color: "#52c41a" }, + 2: { label: "已取消", color: "#999999" }, + 3: { label: "已退款", color: "#1890ff" }, + 4: { label: "充值失败", color: "#ff4d4f" }, +}; + +const parseStatusCode = ( + status?: RechargeOrder["status"], +): StatusCode | undefined => { + if (status === undefined || status === null) return undefined; + + if (typeof status === "number") { + return STATUS_META[status as StatusCode] + ? (status as StatusCode) + : undefined; + } + + const numeric = Number(status); + if (!Number.isNaN(numeric) && STATUS_META[numeric as StatusCode]) { + return numeric as StatusCode; + } + + const stringMap: Record<string, StatusCode> = { + success: 1, + pending: 0, + cancelled: 2, + refunded: 3, + failed: 4, + }; + + return stringMap[status] ?? undefined; +}; + +const formatTimestamp = (value?: string | number | null) => { + if (value === undefined || value === null) return "-"; + if (typeof value === "string" && value.trim() === "") return "-"; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) { + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + } + if (days === 1) { + return `昨天 ${date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })}`; + } + if (days < 7) { + return `${days}天前`; + } + return date.toLocaleDateString("zh-CN"); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) return num / 100; + return num; +}; + +const getPaymentMethodText = (order: RechargeOrder) => { + if (order.payTypeText) return order.payTypeText; + if (order.paymentChannel) return order.paymentChannel; + if (order.paymentMethod) return order.paymentMethod; + return "其他支付"; +}; + const RechargeOrders: React.FC = () => { const navigate = useNavigate(); const [orders, setOrders] = useState<RechargeOrder[]>([]); @@ -25,6 +121,7 @@ const RechargeOrders: React.FC = () => { const [hasMore, setHasMore] = useState(true); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState<string>("all"); + const [payingOrderNo, setPayingOrderNo] = useState<string | null>(null); const loadOrders = async (reset = false) => { setLoading(true); @@ -33,6 +130,7 @@ const RechargeOrders: React.FC = () => { const params = { page: currentPage, limit: 20, + orderType: 1, ...(statusFilter !== "all" && { status: statusFilter }), }; @@ -53,6 +151,7 @@ const RechargeOrders: React.FC = () => { // 初始化加载 useEffect(() => { loadOrders(true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 筛选条件变化时重新加载 @@ -63,36 +162,6 @@ const RechargeOrders: React.FC = () => { loadOrders(true); }; - const getStatusText = (status: string) => { - switch (status) { - case "success": - return "充值成功"; - case "pending": - return "处理中"; - case "failed": - return "充值失败"; - case "cancelled": - return "已取消"; - default: - return "未知状态"; - } - }; - - const getStatusColor = (status: string) => { - switch (status) { - case "success": - return "#52c41a"; - case "pending": - return "#faad14"; - case "failed": - return "#ff4d4f"; - case "cancelled": - return "#999"; - default: - return "#666"; - } - }; - const getPaymentMethodIcon = (method: string) => { switch (method.toLowerCase()) { case "wechat": @@ -119,131 +188,231 @@ const RechargeOrders: React.FC = () => { } }; - const formatTime = (timeStr: string) => { - const date = new Date(timeStr); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - if (days === 0) { - return date.toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", + const handleViewDetail = (order: RechargeOrder) => { + const identifier = order.orderNo || order.id; + if (!identifier) { + Toast.show({ + content: "无法打开订单详情", + position: "top", }); - } else if (days === 1) { - return ( - "昨天 " + - date.toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - }) + return; + } + navigate(`/recharge/order/${identifier}`); + }; + + const openPayDialog = ( + order: RechargeOrder, + options: { codeUrl?: string; payUrl?: string }, + ) => { + const { codeUrl, payUrl } = options; + if (codeUrl) { + Dialog.show({ + content: ( + <div style={{ textAlign: "center", padding: "20px" }}> + <div + style={{ + marginBottom: "16px", + fontSize: "16px", + fontWeight: 500, + }} + > + 请使用微信扫码完成支付 + </div> + <img + src={codeUrl} + alt="支付二维码" + style={{ width: "220px", height: "220px", margin: "0 auto" }} + /> + <div + style={{ + marginTop: "16px", + color: "#666", + fontSize: "14px", + }} + > + 支付金额:¥ + {centsToYuan(order.money ?? order.amount ?? 0).toFixed(2)} + </div> + </div> + ), + closeOnMaskClick: true, + }); + return; + } + + if (payUrl) { + window.location.href = payUrl; + return; + } + + Toast.show({ + content: "暂未获取到支付信息,请稍后重试", + position: "top", + }); + }; + + const handleContinuePay = async (order: RechargeOrder) => { + if (!order.orderNo) { + Toast.show({ + content: "订单号缺失,无法继续支付", + position: "top", + }); + return; + } + + const orderNo = String(order.orderNo); + setPayingOrderNo(orderNo); + try { + const res = await continuePay({ orderNo }); + const codeUrl = res?.code_url || res?.codeUrl; + const payUrl = res?.payUrl; + if (!codeUrl && !payUrl) { + Toast.show({ + content: "未获取到支付链接,请稍后重试", + position: "top", + }); + return; + } + openPayDialog(order, { codeUrl, payUrl }); + } catch (error) { + console.error("继续支付失败:", error); + Toast.show({ + content: "继续支付失败,请重试", + position: "top", + }); + } finally { + setPayingOrderNo(prev => (prev === orderNo ? null : prev)); + } + }; + + const renderOrderItem = (order: RechargeOrder) => { + const statusCode = parseStatusCode(order.status); + const statusMeta = + statusCode !== undefined ? STATUS_META[statusCode] : undefined; + const paymentMethod = getPaymentMethodText(order); + const paymentMethodKey = paymentMethod.toLowerCase(); + const statusBgOpacity = statusMeta?.tagBgOpacity ?? 0.15; + const statusBgColor = statusMeta + ? `${statusMeta.color}${Math.round(statusBgOpacity * 255) + .toString(16) + .padStart(2, "0")}` + : "#66666626"; + const amount = centsToYuan(order.money ?? order.amount ?? 0) || 0; + const isPaying = payingOrderNo === order.orderNo; + const actions: React.ReactNode[] = []; + + if (statusCode === 0) { + actions.push( + <button + key="continue" + className={`${style["action-btn"]} ${style["primary"]}`} + onClick={() => handleContinuePay(order)} + disabled={isPaying} + > + {isPaying ? "处理中..." : "继续支付"} + </button>, ); - } else if (days < 7) { - return `${days}天前`; - } else { - return date.toLocaleDateString("zh-CN"); } - }; - const handleCancelOrder = async (orderId: string) => { - const result = await Dialog.confirm({ - content: "确定要取消这个充值订单吗?", - confirmText: "确定取消", - cancelText: "再想想", - }); - - if (result) { - try { - await cancelRechargeOrder(orderId); - Toast.show({ content: "订单已取消", position: "top" }); - loadOrders(true); - } catch (error) { - console.error("取消订单失败:", error); - Toast.show({ content: "取消失败,请重试", position: "top" }); - } + if (statusCode === 4) { + actions.push( + <button + key="retry" + className={`${style["action-btn"]} ${style["primary"]}`} + onClick={() => navigate("/recharge")} + > + 重新充值 + </button>, + ); } - }; - const handleRefundOrder = async (orderId: string) => { - const result = await Dialog.confirm({ - content: "确定要申请退款吗?退款将在1-3个工作日内处理。", - confirmText: "申请退款", - cancelText: "取消", - }); - - if (result) { - try { - await refundRechargeOrder(orderId, "用户主动申请退款"); - Toast.show({ content: "退款申请已提交", position: "top" }); - loadOrders(true); - } catch (error) { - console.error("申请退款失败:", error); - Toast.show({ content: "申请失败,请重试", position: "top" }); - } + if (statusCode === 1 || statusCode === 3 || statusCode === 2) { + actions.push( + <button + key="purchase-again" + className={`${style["action-btn"]} ${style["secondary"]}`} + onClick={() => navigate("/recharge")} + > + 再次购买 + </button>, + ); } - }; - const renderOrderItem = (order: RechargeOrder) => ( - <Card key={order.id} className={style["order-card"]}> - <div className={style["order-header"]}> - <div className={style["order-info"]}> - <div className={style["order-no"]}>订单号:{order.orderNo}</div> - <div className={style["order-time"]}> - <ClockCircleOutlined style={{ fontSize: 12 }} /> - {formatTime(order.createTime)} + actions.push( + <button + key="detail" + className={`${style["action-btn"]} ${style["secondary"]}`} + onClick={() => handleViewDetail(order)} + > + 查看详情 + </button>, + ); + + return ( + <Card key={order.id} className={style["order-card"]}> + <div className={style["order-header"]}> + <div className={style["order-info"]}> + <div className={style["order-no"]}> + 订单号:{order.orderNo || "-"} + </div> + <div className={style["order-time"]}> + <ClockCircleOutlined style={{ fontSize: 12 }} /> + {formatTimestamp(order.createTime)} + </div> + </div> + <div className={style["order-amount"]}> + <div className={style["amount-text"]}>¥{amount.toFixed(2)}</div> + <div + className={style["status-tag"]} + style={{ + backgroundColor: statusBgColor, + color: statusMeta?.color || "#666", + }} + > + {statusMeta?.label || "未知状态"} + </div> </div> </div> - <div className={style["order-amount"]}> - <div className={style["amount-text"]}> - ¥{order.amount.toFixed(2)} - </div> - <div - className={style["status-tag"]} - style={{ - backgroundColor: `${getStatusColor(order.status)}20`, - color: getStatusColor(order.status), - }} - > - {getStatusText(order.status)} - </div> - </div> - </div> - <div className={style["order-details"]}> - <div className={style["payment-method"]}> - <div - className={style["method-icon"]} - style={{ - backgroundColor: getPaymentMethodColor(order.paymentMethod), - }} - > - {getPaymentMethodIcon(order.paymentMethod)} + <div className={style["order-details"]}> + <div className={style["payment-method"]}> + <div + className={style["method-icon"]} + style={{ + backgroundColor: getPaymentMethodColor(paymentMethodKey), + }} + > + {getPaymentMethodIcon(paymentMethod)} + </div> + <div className={style["method-text"]}>{paymentMethod}</div> </div> - <div className={style["method-text"]}>{order.paymentMethod}</div> + + {(order.description || order.remark) && ( + <div className={style["detail-row"]}> + <span className={style["label"]}>备注</span> + <span className={style["value"]}> + {order.description || order.remark} + </span> + </div> + )} + + {order.payTime && ( + <div className={style["detail-row"]}> + <span className={style["label"]}>支付时间</span> + <span className={style["value"]}> + {formatTimestamp(order.payTime)} + </span> + </div> + )} + + {order.balance !== undefined && ( + <div className={style["balance-info"]}> + 充值后余额: ¥{order.balance.toFixed(2)} + </div> + )} </div> - {order.description && ( - <div className={style["detail-row"]}> - <span className={style["label"]}>备注</span> - <span className={style["value"]}>{order.description}</span> - </div> - )} - - {order.payTime && ( - <div className={style["detail-row"]}> - <span className={style["label"]}>支付时间</span> - <span className={style["value"]}>{formatTime(order.payTime)}</span> - </div> - )} - - {order.balance !== undefined && ( - <div className={style["balance-info"]}> - 充值后余额: ¥{order.balance.toFixed(2)} - </div> - )} - </div> - - {order.status === "pending" && ( + {/* {order.status === "pending" && ( <div className={style["order-actions"]}> <button className={`${style["action-btn"]} ${style["danger"]}`} @@ -252,44 +421,21 @@ const RechargeOrders: React.FC = () => { 取消订单 </button> </div> - )} + )} */} - {order.status === "success" && ( - <div className={style["order-actions"]}> - <button - className={`${style["action-btn"]} ${style["secondary"]}`} - onClick={() => navigate(`/recharge/order/${order.id}`)} - > - 查看详情 - </button> - <button - className={`${style["action-btn"]} ${style["primary"]}`} - onClick={() => handleRefundOrder(order.id)} - > - 申请退款 - </button> - </div> - )} - - {order.status === "failed" && ( - <div className={style["order-actions"]}> - <button - className={`${style["action-btn"]} ${style["primary"]}`} - onClick={() => navigate("/recharge")} - > - 重新充值 - </button> - </div> - )} - </Card> - ); + {actions.length > 0 && ( + <div className={style["order-actions"]}>{actions}</div> + )} + </Card> + ); + }; const filterTabs = [ { key: "all", label: "全部" }, - { key: "success", label: "成功" }, - { key: "pending", label: "处理中" }, - { key: "failed", label: "失败" }, - { key: "cancelled", label: "已取消" }, + { key: "1", label: "成功" }, + { key: "0", label: "待支付" }, + { key: "2", label: "已取消" }, + { key: "3", label: "已退款" }, ]; return ( diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx index 7590f6fa..76d460c8 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx @@ -197,7 +197,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ // 下载模板 const handleDownloadTemplate = () => { const template = - "电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03"; + "姓名/备注,电话号码,微信号,来源,订单金额,下单日期\n张三,13800138000,wxid_123,抖音,99.00,2024-03-03"; const blob = new Blob([template], { type: "text/csv" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/Cunkebao/src/router/index.tsx b/Cunkebao/src/router/index.tsx index d52ba041..fc9adb97 100644 --- a/Cunkebao/src/router/index.tsx +++ b/Cunkebao/src/router/index.tsx @@ -44,7 +44,7 @@ const AppRouter: React.FC = () => ( }} > <AppRoutes /> - <FloatingVideoHelp /> + {/* <FloatingVideoHelp /> */} </BrowserRouter> ); diff --git a/Cunkebao/src/router/module/mine.tsx b/Cunkebao/src/router/module/mine.tsx index bf2bc20c..58e9ec87 100644 --- a/Cunkebao/src/router/module/mine.tsx +++ b/Cunkebao/src/router/module/mine.tsx @@ -9,6 +9,7 @@ import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index"; import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index"; import Recharge from "@/pages/mobile/mine/recharge/index"; import RechargeOrder from "@/pages/mobile/mine/recharge/order/index"; +import RechargeOrderDetail from "@/pages/mobile/mine/recharge/order/detail"; import BuyPower from "@/pages/mobile/mine/recharge/buy-power"; import UsageRecords from "@/pages/mobile/mine/recharge/usage-records"; import Setting from "@/pages/mobile/mine/setting/index"; @@ -76,6 +77,11 @@ const routes = [ element: <RechargeOrder />, auth: true, }, + { + path: "/recharge/order/:id", + element: <RechargeOrderDetail />, + auth: true, + }, { path: "/recharge/buy-power", element: <BuyPower />, From c60e361aa3ac40a8e693e97ca89d01c9b4c78986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= <fsmecx@gmail.com> Date: Wed, 19 Nov 2025 10:27:21 +0800 Subject: [PATCH 26/26] gogo --- .gitignore | 7 +++++++ Moncter/.gitignore | 7 +++++++ Moncter/runtime/logs/workerman.log | 5 +++++ Touchkebao/.gitignore | 7 +++++++ Touchkebao/.vite/deps/_metadata.json | 8 ++++++++ Touchkebao/.vite/deps/package.json | 3 +++ 6 files changed, 37 insertions(+) create mode 100644 .gitignore create mode 100644 Moncter/.gitignore create mode 100644 Moncter/runtime/logs/workerman.log create mode 100644 Touchkebao/.gitignore create mode 100644 Touchkebao/.vite/deps/_metadata.json create mode 100644 Touchkebao/.vite/deps/package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a2bf4a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +build/ +yarn.lock +.env +.DS_Store +dist/* diff --git a/Moncter/.gitignore b/Moncter/.gitignore new file mode 100644 index 00000000..a2bf4a07 --- /dev/null +++ b/Moncter/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +build/ +yarn.lock +.env +.DS_Store +dist/* diff --git a/Moncter/runtime/logs/workerman.log b/Moncter/runtime/logs/workerman.log new file mode 100644 index 00000000..9f4c1a7e --- /dev/null +++ b/Moncter/runtime/logs/workerman.log @@ -0,0 +1,5 @@ +2025-11-07 15:58:25 pid:842 Workerman[start.php] start in DEBUG mode +2025-11-07 16:03:24 pid:842 Workerman[start.php] reloading +2025-11-07 18:26:50 pid:842 Workerman[start.php] received signal SIGHUP +2025-11-07 18:26:50 pid:842 Workerman[start.php] stopping +2025-11-07 18:26:50 pid:842 Workerman[start.php] has been stopped diff --git a/Touchkebao/.gitignore b/Touchkebao/.gitignore new file mode 100644 index 00000000..a2bf4a07 --- /dev/null +++ b/Touchkebao/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +build/ +yarn.lock +.env +.DS_Store +dist/* diff --git a/Touchkebao/.vite/deps/_metadata.json b/Touchkebao/.vite/deps/_metadata.json new file mode 100644 index 00000000..b6c585a4 --- /dev/null +++ b/Touchkebao/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "e6c7dab6", + "configHash": "151db3de", + "lockfileHash": "4c9521f4", + "browserHash": "696ef760", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/Touchkebao/.vite/deps/package.json b/Touchkebao/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/Touchkebao/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}