Merge branch 'release/v1.1.1-a'

This commit is contained in:
wong
2025-12-01 10:17:18 +08:00
13 changed files with 1248 additions and 170 deletions

View File

@@ -8,7 +8,6 @@ import {
LogoutOutlined,
SettingOutlined,
LockOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { useUserStore } from "@/store/module/user";
@@ -16,7 +15,7 @@ import { useSettingsStore } from "@/store/module/settings";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
import { sendMessageToParent, TYPE_EMUE } from "@/utils/postApp";
import { updateChecker } from "@/utils/updateChecker";
import { clearApplicationCache } from "@/utils/cacheCleaner";
interface SettingItem {
id: string;
@@ -58,13 +57,35 @@ const Setting: React.FC = () => {
const handleClearCache = () => {
Dialog.confirm({
content: "确定要清除缓存吗?这将清除所有本地数据。",
onConfirm: () => {
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
onConfirm: async () => {
const handler = Toast.show({
icon: "loading",
content: "正在清理缓存...",
duration: 0,
});
try {
await clearApplicationCache();
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
handler.close();
Toast.show({
icon: "success",
content: "缓存清理完成",
position: "top",
});
} catch (error) {
console.error("clear cache failed", error);
handler.close();
Toast.show({
icon: "fail",
content: "缓存清理失败,请稍后再试",
position: "top",
});
}
},
});
};

View File

@@ -1050,6 +1050,101 @@
height: 500px;
overflow-y: auto;
// 健康分评估区域
.health-score-section {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.health-score-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.health-score-info {
.health-score-status {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
.status-tag {
background: #ffebeb;
color: #ff4d4f;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.status-time {
font-size: 12px;
color: #999;
}
}
.health-score-display {
display: flex;
align-items: center;
.score-circle-wrapper {
width: 100px;
height: 100px;
margin-right: 24px;
position: relative;
.score-circle {
width: 100%;
height: 100%;
border-radius: 50%;
background: #fff;
border: 8px solid #ff4d4f;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.score-number {
font-size: 28px;
font-weight: 700;
color: #ff4d4f;
line-height: 1;
}
.score-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.health-score-stats {
flex: 1;
.stats-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
.stats-label {
font-size: 14px;
color: #666;
}
.stats-value {
font-size: 14px;
color: #333;
font-weight: 500;
}
}
}
}
}
}
.health-score-card {
background: #ffffff;
border-radius: 12px;

View File

@@ -11,6 +11,7 @@ import {
Avatar,
Tag,
Switch,
DatePicker,
} from "antd-mobile";
import { Input, Pagination } from "antd";
import NavCommon from "@/components/NavCommon";
@@ -25,11 +26,14 @@ import {
getWechatAccountDetail,
getWechatFriends,
transferWechatFriends,
getWechatAccountOverview,
getWechatMoments,
exportWechatMoments,
} from "./api";
import DeviceSelection from "@/components/DeviceSelection";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { WechatAccountSummary, Friend } from "./data";
import { WechatAccountSummary, Friend, MomentItem } from "./data";
const WechatAccountDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -38,11 +42,10 @@ const WechatAccountDetail: React.FC = () => {
const [accountSummary, setAccountSummary] =
useState<WechatAccountSummary | null>(null);
const [accountInfo, setAccountInfo] = useState<any>(null);
const [overviewData, setOverviewData] = useState<any>(null);
const [showRestrictions, setShowRestrictions] = useState(false);
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
[],
);
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>([]);
const [inheritInfo, setInheritInfo] = useState(true);
const [transferLoading, setTransferLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -56,6 +59,22 @@ const WechatAccountDetail: React.FC = () => {
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
const [moments, setMoments] = useState<MomentItem[]>([]);
const [momentsPage, setMomentsPage] = useState(1);
const [momentsTotal, setMomentsTotal] = useState(0);
const [isFetchingMoments, setIsFetchingMoments] = useState(false);
const [momentsError, setMomentsError] = useState<string | null>(null);
const MOMENTS_LIMIT = 10;
// 导出相关状态
const [showExportPopup, setShowExportPopup] = useState(false);
const [exportKeyword, setExportKeyword] = useState("");
const [exportType, setExportType] = useState<number | undefined>(undefined);
const [exportStartTime, setExportStartTime] = useState<Date | null>(null);
const [exportEndTime, setExportEndTime] = useState<Date | null>(null);
const [showStartTimePicker, setShowStartTimePicker] = useState(false);
const [showEndTimePicker, setShowEndTimePicker] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
// 获取基础信息
const fetchAccountInfo = useCallback(async () => {
@@ -86,6 +105,19 @@ const WechatAccountDetail: React.FC = () => {
}
}, [id]);
// 获取概览数据
const fetchOverviewData = useCallback(async () => {
if (!id) return;
try {
const response = await getWechatAccountOverview(id);
if (response) {
setOverviewData(response);
}
} catch (e) {
console.error("获取概览数据失败:", e);
}
}, [id]);
// 获取好友列表 - 封装为独立函数
const fetchFriendsList = useCallback(
async (page: number = 1, keyword: string = "") => {
@@ -102,26 +134,44 @@ const WechatAccountDetail: React.FC = () => {
keyword: keyword,
});
const newFriends = response.list.map((friend: any) => ({
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.memo || "",
addTime: friend.createTime || new Date().toISOString().split("T")[0],
lastInteraction:
friend.lastInteraction || new Date().toISOString().split("T")[0],
tags: friend.tags
? friend.tags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor(),
}))
: [],
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || "",
}));
const newFriends = response.list.map((friend: any) => {
const memoTags = Array.isArray(friend.memo)
? friend.memo
: friend.memo
? String(friend.memo)
.split(/[,\s、]+/)
.filter(Boolean)
: [];
const tagList = Array.isArray(friend.tags)
? friend.tags
: friend.tags
? [friend.tags]
: [];
return {
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.notes || "",
addTime:
friend.createTime || new Date().toISOString().split("T")[0],
lastInteraction:
friend.lastInteraction || new Date().toISOString().split("T")[0],
tags: memoTags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor(),
})),
statusTags: tagList,
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || "",
value: friend.value,
valueFormatted: friend.valueFormatted,
};
});
setFriends(newFriends);
setFriendsTotal(response.total);
@@ -143,6 +193,46 @@ const WechatAccountDetail: React.FC = () => {
[id],
);
const fetchMomentsList = useCallback(
async (page: number = 1, append: boolean = false) => {
if (!id) return;
setIsFetchingMoments(true);
setMomentsError(null);
try {
const response = await getWechatMoments({
wechatId: id,
page,
limit: MOMENTS_LIMIT,
});
const list: MomentItem[] = (response.list || []).map((moment: any) => ({
id: moment.id?.toString() || Math.random().toString(),
snsId: moment.snsId,
type: moment.type,
content: moment.content || "",
resUrls: moment.resUrls || [],
commentList: moment.commentList || [],
likeList: moment.likeList || [],
createTime: moment.createTime || "",
momentEntity: moment.momentEntity || {},
}));
setMoments(prev => (append ? [...prev, ...list] : list));
setMomentsTotal(response.total || list.length);
setMomentsPage(page);
} catch (error) {
console.error("获取朋友圈数据失败:", error);
setMomentsError("获取朋友圈数据失败");
if (!append) {
setMoments([]);
}
} finally {
setIsFetchingMoments(false);
}
},
[id],
);
// 搜索好友
const handleSearch = useCallback(() => {
setFriendsPage(1);
@@ -167,8 +257,9 @@ const WechatAccountDetail: React.FC = () => {
useEffect(() => {
if (id) {
fetchAccountInfo();
fetchOverviewData();
}
}, [id, fetchAccountInfo]);
}, [id, fetchAccountInfo, fetchOverviewData]);
// 监听标签切换 - 只在切换到好友列表时请求一次
useEffect(() => {
@@ -179,6 +270,14 @@ const WechatAccountDetail: React.FC = () => {
}
}, [activeTab, id, fetchFriendsList, searchQuery]);
useEffect(() => {
if (activeTab === "moments" && id) {
if (moments.length === 0) {
fetchMomentsList(1, false);
}
}
}, [activeTab, id, fetchMomentsList, moments.length]);
// 工具函数
const getRandomTagColor = (): string => {
const colors = [
@@ -222,7 +321,7 @@ const WechatAccountDetail: React.FC = () => {
await transferWechatFriends({
wechatId: id,
devices: selectedDevices.map(device => device.id),
inherit: inheritInfo,
inherit: inheritInfo
});
Toast.show({
@@ -277,6 +376,85 @@ const WechatAccountDetail: React.FC = () => {
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
};
const handleLoadMoreMoments = () => {
if (isFetchingMoments) return;
if (moments.length >= momentsTotal) return;
fetchMomentsList(momentsPage + 1, true);
};
// 处理朋友圈导出
const handleExportMoments = useCallback(async () => {
if (!id) {
Toast.show({ content: "微信ID不存在", position: "top" });
return;
}
setExportLoading(true);
try {
// 格式化时间
const formatDate = (date: Date | null): string | undefined => {
if (!date) return undefined;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
await exportWechatMoments({
wechatId: id,
keyword: exportKeyword || undefined,
type: exportType,
startTime: formatDate(exportStartTime),
endTime: formatDate(exportEndTime),
});
Toast.show({ content: "导出成功", position: "top" });
setShowExportPopup(false);
// 重置筛选条件
setExportKeyword("");
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
} catch (error: any) {
console.error("导出失败:", error);
Toast.show({
content: error.message || "导出失败,请重试",
position: "top",
});
} finally {
setExportLoading(false);
}
}, [id, exportKeyword, exportType, exportStartTime, exportEndTime]);
const formatMomentDateParts = (dateString: string) => {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return { day: "--", month: "--" };
}
const day = date.getDate().toString().padStart(2, "0");
const month = `${date.getMonth() + 1}`;
return { day, month };
};
const formatMomentTimeAgo = (dateString: string) => {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return dateString || "--";
}
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return date.toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
};
return (
<Layout header={<NavCommon title="微信号详情" />} loading={loadingInfo}>
<div className={style["wechat-account-detail-page"]}>
@@ -319,73 +497,223 @@ const WechatAccountDetail: React.FC = () => {
onChange={handleTabChange}
className={style["tabs"]}
>
<Tabs.Tab title="账号概览" key="overview">
<Tabs.Tab title="概览" key="overview">
<div className={style["overview-content"]}>
<div className={style["summary-grid"]}>
<div className={style["summary-item"]}>
<div className={style["summary-value"]}>
{accountInfo?.friendShip?.totalFriend ?? "-"}
{/* 健康分评估区域 */}
<div className={style["health-score-section"]}>
<div className={style["health-score-title"]}></div>
<div className={style["health-score-info"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
</div>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
+{accountSummary?.statistics.todayAdded ?? "-"}
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
</div>
<div className={style["health-score-stats"]}>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.dailyLimit || 0} </div>
</div>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.todayAdded || 0} </div>
</div>
</div>
</div>
<div className={style["summary-label"]}></div>
</div>
</div>
<div className={style["summary-progress-row"]}>
<span>:</span>
<span className={style["summary-progress-text"]}>
{accountSummary?.statistics.todayAdded ?? 0}/
{accountSummary?.statistics.addLimit ?? 0}
</span>
</div>
<div className={style["summary-progress-bar"]}>
<div className={style["progress-bg"]}>
<div
className={style["progress-fill"]}
style={{
width: `${Math.min(((accountSummary?.statistics.todayAdded ?? 0) / (accountSummary?.statistics.addLimit || 1)) * 100, 100)}%`,
}}
/>
</div>
</div>
<div className={style["summary-grid"]}>
<div className={style["summary-item"]}>
<div className={style["summary-value-blue"]}>
{accountInfo?.friendShip?.groupNumber ?? "-"}
{/* 账号价值和好友数量区域 */}
<div className={style["account-stats-grid"]}>
{/* 账号价值 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-up"]}></div>
</div>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
{accountInfo?.activity?.yesterdayMsgCount ?? "-"}
<div className={style["stat-value"]}>
{overviewData?.accountValue?.formatted || `¥${overviewData?.accountValue?.value || "29,800"}`}
</div>
</div>
{/* 今日价值变化 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
{overviewData?.todayValueChange?.formatted || `+${overviewData?.todayValueChange?.change || "500"}`}
</div>
<div className={style["summary-label"]}></div>
</div>
</div>
<div className={style["device-card"]}>
<div className={style["device-title"]}></div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceName ?? "-"}</span>
{/* 好友数量和今日新增好友区域 */}
<div className={style["account-stats-grid"]}>
{/* 好友总数 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-people"]}></div>
</div>
<div className={style["stat-value"]}>
{overviewData?.totalFriends || accountInfo?.friendShip?.totalFriend || "0"}
</div>
</div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceType ?? "-"}</span>
{/* 今日新增好友 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
+{overviewData?.todayNewFriends || accountSummary?.statistics.todayAdded || "0"}
</div>
</div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceVersion ?? "-"}</span>
</div>
{/* 高价群聊区域 */}
<div className={style["account-stats-grid"]}>
{/* 高价群聊 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-chat"]}></div>
</div>
<div className={style["stat-value"]}>
{overviewData?.highValueChatrooms || accountInfo?.friendShip?.groupNumber || "0"}
</div>
</div>
{/* 今日新增群聊 */}
<div className={style["stat-card"]}>
<div className={style["stat-header"]}>
<div className={style["stat-title"]}></div>
<div className={style["stat-icon-plus"]}></div>
</div>
<div className={style["stat-value-positive"]}>
+{overviewData?.todayNewChatrooms || "0"}
</div>
</div>
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="健康分" key="health">
<div className={style["health-content"]}>
{/* 健康分评估区域 */}
<div className={style["health-score-section"]}>
<div className={style["health-score-title"]}></div>
<div className={style["health-score-info"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
</div>
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
</div>
<div className={style["health-score-stats"]}>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.dailyLimit || 0} </div>
</div>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{overviewData?.healthScoreAssessment?.todayAdded || 0} </div>
</div>
</div>
</div>
</div>
</div>
{/* 基础构成 */}
<div className={style["health-section"]}>
<div className={style["health-section-title"]}></div>
{(overviewData?.healthScoreAssessment?.baseComposition &&
overviewData.healthScoreAssessment.baseComposition.length > 0
? overviewData.healthScoreAssessment.baseComposition
: [
{ name: "账号基础分", formatted: "+60" },
{ name: "已修改微信号", formatted: "+10" },
{ name: "好友数量加成", formatted: "+12", friendCount: 5595 },
]
).map((item, index) => (
<div className={style["health-item"]} key={`${item.name}-${index}`}>
<div className={style["health-item-label"]}>
{item.name}
{item.friendCount ? ` (${item.friendCount})` : ""}
</div>
<div
className={
(item.score ?? 0) >= 0
? style["health-item-value-positive"]
: style["health-item-value-negative"]
}
>
{item.formatted || `${item.score ?? 0}`}
</div>
</div>
))}
</div>
{/* 动态记录 */}
<div className={style["health-section"]}>
<div className={style["health-section-title"]}></div>
{overviewData?.healthScoreAssessment?.dynamicRecords &&
overviewData.healthScoreAssessment.dynamicRecords.length > 0 ? (
overviewData.healthScoreAssessment.dynamicRecords.map(
(record, index) => (
<div className={style["health-item"]} key={`record-${index}`}>
<div className={style["health-item-label"]}>
<span className={style["health-item-icon-warning"]}></span>
{record.title || record.description || "记录"}
{record.statusTag && (
<span className={style["health-item-tag"]}>
{record.statusTag}
</span>
)}
</div>
<div
className={
(record.score ?? 0) >= 0
? style["health-item-value-positive"]
: style["health-item-value-negative"]
}
>
{record.formatted ||
(record.score && record.score > 0
? `+${record.score}`
: record.score || "-")}
</div>
</div>
),
)
) : (
<div className={style["health-empty"]}></div>
)}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab
title={`好友列表${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
title={`好友${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
key="friends"
>
<div className={style["friends-content"]}>
@@ -412,6 +740,23 @@ const WechatAccountDetail: React.FC = () => {
</Button>
</div>
{/* 好友概要 */}
<div className={style["friends-summary"]}>
<div className={style["summary-item"]}>
<div className={style["summary-label"]}></div>
<div className={style["summary-value"]}>
{friendsTotal || overviewData?.totalFriends || 0}
</div>
</div>
<div className={style["summary-divider"]} />
<div className={style["summary-item"]}>
<div className={style["summary-label"]}></div>
<div className={style["summary-value-highlight"]}>
{overviewData?.accountValue?.formatted || "¥1,500,000"}
</div>
</div>
</div>
{/* 好友列表 */}
<div className={style["friends-list"]}>
{isFetchingFriends && friends.length === 0 ? (
@@ -437,36 +782,44 @@ const WechatAccountDetail: React.FC = () => {
{friends.map(friend => (
<div
key={friend.id}
className={style["friend-item"]}
className={style["friend-card"]}
onClick={() => handleFriendClick(friend)}
>
<Avatar
src={friend.avatar}
className={style["friend-avatar"]}
/>
<div className={style["friend-info"]}>
<div className={style["friend-header"]}>
<div className={style["friend-avatar"]}>
<Avatar src={friend.avatar} />
</div>
<div className={style["friend-main"]}>
<div className={style["friend-name-row"]}>
<div className={style["friend-name"]}>
{friend.nickname}
{friend.remark && (
<span className={style["friend-remark"]}>
({friend.remark})
</span>
)}
{friend.nickname || "未知好友"}
</div>
</div>
<div className={style["friend-wechat-id"]}>
{friend.wechatId}
<div className={style["friend-id-row"]}>
ID: {friend.wechatId || "-"}
</div>
<div className={style["friend-tags"]}>
{friend.tags?.map((tag, index) => (
<Tag
key={index}
className={style["friend-tag"]}
<div className={style["friend-status-row"]}>
{friend.statusTags?.map((tag, idx) => (
<span
key={idx}
className={style["friend-status-chip"]}
>
{typeof tag === "string" ? tag : tag.name}
</Tag>
{tag}
</span>
))}
{friend.remark && (
<span className={style["friend-status-chip"]}>
{friend.remark}
</span>
)}
</div>
</div>
<div className={style["friend-value"]}>
<div className={style["value-amount"]}>
{friend.valueFormatted
|| (typeof friend.value === "number"
? `¥${friend.value.toLocaleString()}`
: "估值 -")}
</div>
</div>
</div>
@@ -490,45 +843,118 @@ const WechatAccountDetail: React.FC = () => {
</div>
</Tabs.Tab>
<Tabs.Tab title="风险评估" key="risk">
<div className={style["risk-content"]}>
{accountSummary?.restrictions &&
accountSummary.restrictions.length > 0 ? (
<div className={style["restrictions-list"]}>
{accountSummary.restrictions.map(restriction => (
<div
key={restriction.id}
className={style["restriction-item"]}
>
<div className={style["restriction-info"]}>
<div className={style["restriction-reason"]}>
{restriction.reason}
</div>
<div className={style["restriction-date"]}>
{restriction.date
? formatDateTime(restriction.date)
: "暂无时间"}
</div>
</div>
<div className={style["restriction-level"]}>
<span
className={`${style["level-badge"]} ${style[`level-${restriction.level}`]}`}
>
{restriction.level === 1
? "低风险"
: restriction.level === 2
? "中风险"
: "高风险"}
</span>
</div>
</div>
))}
<Tabs.Tab title="朋友圈" key="moments">
<div className={style["moments-content"]}>
{/* 功能按钮栏 */}
<div className={style["moments-action-bar"]}>
<div className={style["action-button"]}>
<span className={style["action-icon-text"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-image"]}></span>
<span className={style["action-text"]}></span>
</div>
<div className={style["action-button"]}>
<span className={style["action-icon-video"]}></span>
<span className={style["action-text"]}></span>
</div>
<div
className={style["action-button-dark"]}
onClick={() => setShowExportPopup(true)}
>
<span className={style["action-icon-export"]}></span>
<span className={style["action-text-light"]}></span>
</div>
</div>
{/* 朋友圈列表 */}
<div className={style["moments-list"]}>
{isFetchingMoments && moments.length === 0 ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : momentsError ? (
<div className={style["error"]}>{momentsError}</div>
) : moments.length === 0 ? (
<div className={style["empty"]}></div>
) : (
moments.map(moment => {
const { day, month } = formatMomentDateParts(
moment.createTime,
);
const timeAgo = formatMomentTimeAgo(moment.createTime);
const imageCount = moment.resUrls?.length || 0;
// 根据图片数量选择对应的grid类参考素材管理的实现
let gridClass = "";
if (imageCount === 1) gridClass = style["single"];
else if (imageCount === 2) gridClass = style["double"];
else if (imageCount === 3) gridClass = style["triple"];
else if (imageCount === 4) gridClass = style["quad"];
else if (imageCount > 4) gridClass = style["grid"];
return (
<div className={style["moment-item"]} key={moment.id}>
<div className={style["moment-date"]}>
<div className={style["date-day"]}>{day}</div>
<div className={style["date-month"]}>{month}</div>
</div>
<div className={style["moment-content"]}>
{moment.content && (
<div className={style["moment-text"]}>
{moment.content}
</div>
)}
{imageCount > 0 && (
<div className={style["moment-images"]}>
<div
className={`${style["image-grid"]} ${gridClass}`}
>
{moment.resUrls
.slice(0, 9)
.map((url, index) => (
<img
key={`${moment.id}-img-${index}`}
src={url}
alt="朋友圈图片"
/>
))}
{imageCount > 9 && (
<div className={style["image-more"]}>
+{imageCount - 9}
</div>
)}
</div>
</div>
)}
<div className={style["moment-footer"]}>
<span className={style["moment-time"]}>
{timeAgo}
</span>
</div>
</div>
</div>
);
})
)}
</div>
{moments.length < momentsTotal && (
<div className={style["moments-load-more"]}>
<Button
size="small"
onClick={handleLoadMoreMoments}
loading={isFetchingMoments}
disabled={isFetchingMoments}
>
</Button>
</div>
) : (
<div className={style["empty"]}></div>
)}
</div>
</Tabs.Tab>
</Tabs>
</Card>
</div>
@@ -614,7 +1040,10 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["form-item"]}>
<div className={style["form-label"]}></div>
<div className={style["form-control-switch"]}>
<Switch checked={inheritInfo} onChange={setInheritInfo} />
<Switch
checked={inheritInfo}
onChange={setInheritInfo}
/>
<span className={style["switch-label"]}>
{inheritInfo ? "是" : "否"}
</span>
@@ -647,6 +1076,153 @@ const WechatAccountDetail: React.FC = () => {
</div>
</Popup>
{/* 朋友圈导出弹窗 */}
<Popup
visible={showExportPopup}
onMaskClick={() => setShowExportPopup(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
<Button
size="small"
fill="outline"
onClick={() => setShowExportPopup(false)}
>
</Button>
</div>
<div className={style["export-form"]}>
{/* 关键词搜索 */}
<div className={style["form-item"]}>
<label></label>
<Input
placeholder="请输入关键词"
value={exportKeyword}
onChange={e => setExportKeyword(e.target.value)}
allowClear
/>
</div>
{/* 类型筛选 */}
<div className={style["form-item"]}>
<label></label>
<div className={style["type-selector"]}>
<div
className={`${style["type-option"]} ${
exportType === undefined ? style["active"] : ""
}`}
onClick={() => setExportType(undefined)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 4 ? style["active"] : ""
}`}
onClick={() => setExportType(4)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 1 ? style["active"] : ""
}`}
onClick={() => setExportType(1)}
>
</div>
<div
className={`${style["type-option"]} ${
exportType === 3 ? style["active"] : ""
}`}
onClick={() => setExportType(3)}
>
</div>
</div>
</div>
{/* 开始时间 */}
<div className={style["form-item"]}>
<label></label>
<Input
readOnly
placeholder="请选择开始时间"
value={
exportStartTime
? exportStartTime.toLocaleDateString("zh-CN")
: ""
}
onClick={() => setShowStartTimePicker(true)}
/>
<DatePicker
visible={showStartTimePicker}
title="开始时间"
value={exportStartTime}
onClose={() => setShowStartTimePicker(false)}
onConfirm={val => {
setExportStartTime(val);
setShowStartTimePicker(false);
}}
/>
</div>
{/* 结束时间 */}
<div className={style["form-item"]}>
<label></label>
<Input
readOnly
placeholder="请选择结束时间"
value={
exportEndTime ? exportEndTime.toLocaleDateString("zh-CN") : ""
}
onClick={() => setShowEndTimePicker(true)}
/>
<DatePicker
visible={showEndTimePicker}
title="结束时间"
value={exportEndTime}
onClose={() => setShowEndTimePicker(false)}
onConfirm={val => {
setExportEndTime(val);
setShowEndTimePicker(false);
}}
/>
</div>
</div>
<div className={style["popup-footer"]}>
<Button
block
color="primary"
onClick={handleExportMoments}
loading={exportLoading}
disabled={exportLoading}
>
{exportLoading ? "导出中..." : "确认导出"}
</Button>
<Button
block
color="danger"
fill="outline"
onClick={() => {
setShowExportPopup(false);
setExportKeyword("");
setExportType(undefined);
setExportStartTime(null);
setExportEndTime(null);
}}
style={{ marginTop: 12 }}
>
</Button>
</div>
</div>
</Popup>
{/* 好友详情弹窗 */}
{/* Removed */}
</Layout>

View File

@@ -2,13 +2,11 @@
export * from "./module/user";
export * from "./module/app";
export * from "./module/settings";
export * from "./module/websocket/websocket";
// 导入store实例
import { useUserStore } from "./module/user";
import { useAppStore } from "./module/app";
import { useSettingsStore } from "./module/settings";
import { useWebSocketStore } from "./module/websocket/websocket";
// 导出持久化store创建函数
export {
@@ -34,7 +32,6 @@ export interface StoreState {
user: ReturnType<typeof useUserStore.getState>;
app: ReturnType<typeof useAppStore.getState>;
settings: ReturnType<typeof useSettingsStore.getState>;
websocket: ReturnType<typeof useWebSocketStore.getState>;
}
// 便利的store访问函数
@@ -42,14 +39,12 @@ export const getStores = (): StoreState => ({
user: useUserStore.getState(),
app: useAppStore.getState(),
settings: useSettingsStore.getState(),
websocket: useWebSocketStore.getState(),
});
// 获取特定store状态
export const getUserStore = () => useUserStore.getState();
export const getAppStore = () => useAppStore.getState();
export const getSettingsStore = () => useSettingsStore.getState();
export const getWebSocketStore = () => useWebSocketStore.getState();
// 清除所有持久化数据(使用工具函数)
export const clearAllPersistedData = clearAllData;
@@ -61,7 +56,6 @@ export const getPersistKeys = () => Object.values(PERSIST_KEYS);
export const subscribeToUserStore = useUserStore.subscribe;
export const subscribeToAppStore = useAppStore.subscribe;
export const subscribeToSettingsStore = useSettingsStore.subscribe;
export const subscribeToWebSocketStore = useWebSocketStore.subscribe;
// 组合订阅函数
export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
@@ -74,14 +68,10 @@ export const subscribeToAllStores = (callback: (state: StoreState) => void) => {
const unsubscribeSettings = useSettingsStore.subscribe(() => {
callback(getStores());
});
const unsubscribeWebSocket = useWebSocketStore.subscribe(() => {
callback(getStores());
});
return () => {
unsubscribeUser();
unsubscribeApp();
unsubscribeSettings();
unsubscribeWebSocket();
};
};

View File

@@ -0,0 +1,70 @@
// 全局缓存清理工具:浏览器存储 + IndexedDB + Zustand store
import { clearAllPersistedData } from "@/store";
import { useUserStore } from "@/store/module/user";
import { useAppStore } from "@/store/module/app";
import { useSettingsStore } from "@/store/module/settings";
const isBrowser = typeof window !== "undefined";
const safeStorageClear = (storage?: Storage) => {
if (!storage) return;
try {
storage.clear();
} catch (error) {
console.warn("清理存储失败:", error);
}
};
export const clearBrowserStorage = () => {
if (!isBrowser) return;
safeStorageClear(window.localStorage);
safeStorageClear(window.sessionStorage);
try {
clearAllPersistedData();
} catch (error) {
console.warn("清理持久化 store 失败:", error);
}
};
export const clearAllIndexedDB = async (): Promise<void> => {
if (!isBrowser || !window.indexedDB || !indexedDB.databases) return;
const databases = await indexedDB.databases();
const deleteJobs = databases
.map(db => db.name)
.filter((name): name is string => Boolean(name))
.map(
name =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`删除数据库 ${name} 失败`));
request.onblocked = () => {
setTimeout(() => {
const retry = indexedDB.deleteDatabase(name);
retry.onsuccess = () => resolve();
retry.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
}, 100);
};
}),
);
await Promise.allSettled(deleteJobs);
};
export const resetAllStores = () => {
const userStore = useUserStore.getState();
const appStore = useAppStore.getState();
const settingsStore = useSettingsStore.getState();
userStore?.clearUser?.();
appStore?.resetAppState?.();
settingsStore?.resetSettings?.();
};
export const clearApplicationCache = async () => {
clearBrowserStorage();
await clearAllIndexedDB();
resetAllStores();
};

View File

@@ -0,0 +1,73 @@
// 缓存清理工具,统一处理浏览器存储与 Zustand store
import { clearAllPersistedData } from "@/store";
import { useUserStore } from "@/store/module/user";
import { useAppStore } from "@/store/module/app";
import { useSettingsStore } from "@/store/module/settings";
const isBrowser = typeof window !== "undefined";
const safeStorageClear = (storage?: Storage) => {
if (!storage) return;
try {
storage.clear();
} catch (error) {
console.warn("清理存储失败:", error);
}
};
export const clearBrowserStorage = () => {
if (!isBrowser) return;
safeStorageClear(window.localStorage);
safeStorageClear(window.sessionStorage);
// 清理自定义持久化数据
try {
clearAllPersistedData();
} catch (error) {
console.warn("清理持久化 store 失败:", error);
}
};
export const clearAllIndexedDB = async (): Promise<void> => {
if (!isBrowser || !window.indexedDB || !indexedDB.databases) return;
const databases = await indexedDB.databases();
const deleteJobs = databases
.map(db => db.name)
.filter((name): name is string => Boolean(name))
.map(
name =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
request.onblocked = () => {
setTimeout(() => {
const retry = indexedDB.deleteDatabase(name);
retry.onsuccess = () => resolve();
retry.onerror = () =>
reject(new Error(`删除数据库 ${name} 失败`));
}, 100);
};
}),
);
await Promise.allSettled(deleteJobs);
};
export const resetAllStores = () => {
const userStore = useUserStore.getState();
const appStore = useAppStore.getState();
const settingsStore = useSettingsStore.getState();
userStore?.clearUser?.();
appStore?.resetAppState?.();
settingsStore?.resetSettings?.();
};
export const clearApplicationCache = async () => {
clearBrowserStorage();
await clearAllIndexedDB();
resetAllStores();
};

View File

@@ -74,7 +74,7 @@ class PostTransferFriends extends BaseController
$taskId = Db::name('customer_acquisition_task')->insertGetId([
'name' => '迁移好友('. $wechat['nickname'] .'',
'sceneId' => 1,
'sceneId' => 10,
'sceneConf' => json_encode($sceneConf),
'reqConf' => json_encode($reqConf),
'tagConf' => json_encode([]),

View File

@@ -28,14 +28,30 @@ export function getTrafficPoolList() {
"GET",
);
}
type ListRequestOptions = {
debounceGap?: number;
};
// 好友列表
export function getContactList(params) {
return request("/v1/kefu/wechatFriend/list", params, "GET");
export function getContactList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatFriend/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
// 群列表
export function getGroupList(params) {
return request("/v1/kefu/wechatChatroom/list", params, "GET");
export function getGroupList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatChatroom/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
// 分组列表
export function getLabelsListByGroup(params) {

View File

@@ -0,0 +1,160 @@
// 红包消息样式
.redPacketMessage {
background: transparent;
box-shadow: none;
max-width: 300px;
}
.redPacketCard {
position: relative;
display: flex;
flex-direction: column;
padding: 16px 20px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
overflow: hidden;
// 红包装饰背景
&::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(255, 215, 0, 0.15) 0%,
transparent 70%
);
animation: shimmer 3s ease-in-out infinite;
}
// 金色装饰边框
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid rgba(255, 215, 0, 0.4);
border-radius: 8px;
pointer-events: none;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(255, 107, 107, 0.4);
background: linear-gradient(135deg, #ff7b7b 0%, #ff6b7f 100%);
}
&:active {
transform: translateY(0);
}
}
@keyframes shimmer {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
}
.redPacketHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.redPacketIcon {
font-size: 32px;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.redPacketTitle {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
letter-spacing: 0.5px;
line-height: 1.4;
word-break: break-word;
}
.redPacketFooter {
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
z-index: 1;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.redPacketLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
&::before {
content: "💰";
margin-right: 4px;
font-size: 14px;
}
}
// 消息文本样式(用于错误提示)
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: #8c8c8c;
font-size: 13px;
}
// 响应式设计
@media (max-width: 768px) {
.redPacketMessage {
max-width: 200px;
}
.redPacketCard {
padding: 12px 16px;
}
.redPacketIcon {
font-size: 28px;
}
.redPacketTitle {
font-size: 14px;
}
.redPacketLabel {
font-size: 11px;
}
}

View File

@@ -0,0 +1,62 @@
import React from "react";
import styles from "./RedPacketMessage.module.scss";
interface RedPacketData {
nativeurl?: string;
paymsgid?: string;
sendertitle?: string;
[key: string]: any;
}
interface RedPacketMessageProps {
content: string;
}
const RedPacketMessage: React.FC<RedPacketMessageProps> = ({ content }) => {
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[红包消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
const jsonData: RedPacketData = JSON.parse(trimmedContent);
// 验证是否为红包消息
const isRedPacket =
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
);
if (!isRedPacket) {
return renderErrorMessage("[红包消息 - 格式错误]");
}
const title = jsonData.sendertitle || "恭喜发财,大吉大利";
const paymsgid = jsonData.paymsgid || "";
return (
<div className={styles.redPacketMessage}>
<div className={styles.redPacketCard}>
<div className={styles.redPacketHeader}>
<div className={styles.redPacketIcon}>🧧</div>
<div className={styles.redPacketTitle}>{title}</div>
</div>
<div className={styles.redPacketFooter}>
<span className={styles.redPacketLabel}></span>
</div>
</div>
</div>
);
} catch (e) {
console.warn("红包消息解析失败:", e);
return renderErrorMessage("[红包消息 - 解析失败]");
}
};
export default RedPacketMessage;

View File

@@ -7,6 +7,7 @@ import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import LocationMessage from "./components/LocationMessage";
import SystemRecommendRemarkMessage from "./components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "./components/RedPacketMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
@@ -254,6 +255,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
msg?: ChatRecord,
contract?: ContractData | weChatGroup,
) => {
console.log("红包");
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
@@ -261,6 +263,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return <RedPacketMessage content={rawContent} />;
}
if (jsonData.type === "file" && msg && contract) {
return (
<SmallProgramMessage
@@ -378,13 +391,15 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
if (!msg) {
return { avatar: "", nickname: "" };
}
const member =
groupRender.find(user => user?.identifier === msg?.sender?.wechatId) ||
groupRender.find(user => user?.wechatId === msg?.sender?.wechatId);
const member = groupRender.find(
user => user?.identifier === msg?.senderWechatId,
);
console.log(member, "member");
return {
avatar: member?.avatar || msg?.sender?.avatar || "",
nickname: member?.nickname || msg?.sender?.nickname || "",
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
};
};
@@ -615,7 +630,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const isOwn = msg?.isSend;
const isGroup = !!contract.chatroomId;
const groupUser = isGroup ? renderGroupUser(msg) : null;
return (
<div
key={msg.id || `msg-${Date.now()}`}
@@ -667,14 +682,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
)}
<Avatar
size={32}
src={groupUser?.avatar}
src={renderGroupUser(msg)?.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{groupUser?.nickname}
{renderGroupUser(msg)?.nickname}
</div>
)}
<>

View File

@@ -18,7 +18,7 @@ export const getAllFriends = async () => {
let hasMore = true;
while (hasMore) {
const result = await getContactList({ page, limit });
const result = await getContactList({ page, limit }, { debounceGap: 0 });
const friendList = result?.list || [];
if (
@@ -56,7 +56,7 @@ export const getAllGroups = async () => {
let hasMore = true;
while (hasMore) {
const result = await getGroupList({ page, limit });
const result = await getGroupList({ page, limit }, { debounceGap: 0 });
const groupList = result?.list || [];
if (!groupList || !Array.isArray(groupList) || groupList.length === 0) {

View File

@@ -659,7 +659,7 @@ export class MessageManager {
updatedSession.sortKey = this.generateSortKey(updatedSession);
await chatSessionService.update(serverId, updatedSession);
console.log(`会话时间已更新: ${serverId} -> ${newTime}`);
await this.triggerCallbacks(userId);
}
} catch (error) {
console.error("更新会话时间失败:", error);
@@ -830,7 +830,7 @@ export class MessageManager {
};
await chatSessionService.create(sessionWithSortKey);
console.log(`创建新会话: ${session.nickname || session.wechatId}`);
await this.triggerCallbacks(userId);
} catch (error) {
console.error("创建会话失败:", error);
throw error;