健康分功能提交 + 微信客服页面改版

This commit is contained in:
wong
2025-11-26 11:17:23 +08:00
parent cd41190663
commit 7e2dd2914d
13 changed files with 2945 additions and 481 deletions

View File

@@ -5,6 +5,20 @@ export function getWechatAccountDetail(id: string) {
return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET");
}
// 获取微信号概览数据
export function getWechatAccountOverview(id: string) {
return request("/v1/wechats/overview", { wechatId: id }, "GET");
}
// 获取微信号朋友圈列表
export function getWechatMoments(params: {
wechatId: string;
page?: number;
limit?: number;
}) {
return request("/v1/wechats/moments", params, "GET");
}
// 获取微信号好友列表
export function getWechatFriends(params: {
wechatAccount: string;

View File

@@ -1,3 +1,41 @@
// 概览数据接口
export interface WechatAccountOverview {
healthScoreAssessment: {
score: number;
dailyLimit: number;
todayAdded: number;
lastAddTime: string;
statusTag: string;
baseComposition?: Array<{
name: string;
score: number;
formatted: string;
friendCount?: number;
}>;
dynamicRecords?: Array<{
title?: string;
description?: string;
time?: string;
score?: number;
formatted?: string;
statusTag?: string;
}>;
};
accountValue: {
value: number;
formatted: string;
};
todayValueChange: {
change: number;
formatted: string;
isPositive: boolean;
};
totalFriends: number;
todayNewFriends: number;
highValueChatrooms: number;
todayNewChatrooms: number;
}
export interface WechatAccountSummary {
accountAge: string;
activityLevel: {
@@ -15,12 +53,51 @@ export interface WechatAccountSummary {
todayAdded: number;
addLimit: number;
};
healthScore?: {
score: number;
lastUpdate?: string;
lastAddTime?: string;
baseScore?: number;
verifiedScore?: number;
friendsScore?: number;
activities?: {
type: string;
time?: string;
score: number;
description?: string;
status?: string;
}[];
};
moments?: {
id: string;
date: string;
month: string;
day: string;
content: string;
images?: string[];
timeAgo?: string;
hasEmoji?: boolean;
}[];
accountValue?: {
value: number;
todayChange?: number;
};
friendsCount?: {
total: number;
todayAdded?: number;
};
groupsCount?: {
total: number;
todayAdded?: number;
};
restrictions: {
id: number;
level: number;
reason: string;
date: string;
}[];
// 新增概览数据
overview?: WechatAccountOverview;
}
export interface Friend {
@@ -39,6 +116,27 @@ export interface Friend {
region: string;
source: string;
notes: string;
value?: number;
valueFormatted?: string;
statusTags?: string[];
}
export interface MomentItem {
id: string;
snsId: string;
type: number;
content: string;
resUrls: string[];
commentList?: any[];
likeList?: any[];
createTime: string;
momentEntity?: {
lat?: string;
lng?: string;
location?: string;
picSize?: number;
userName?: string;
};
}
export interface WechatFriendDetail {

View File

@@ -143,67 +143,235 @@
}
.overview-content {
.info-grid {
// 健康分评估区域
.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;
}
}
}
}
}
}
// 账号统计卡片网格
.account-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
.info-card {
background: linear-gradient(135deg, #e6f7ff, #f0f8ff);
.stat-card {
background: #ffffff;
padding: 16px;
border-radius: 12px;
border: 1px solid #bae7ff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.info-header {
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.info-icon {
font-size: 16px;
color: #1677ff;
padding: 6px;
background: #e6f7ff;
border-radius: 8px;
.stat-title {
font-size: 14px;
color: #666;
}
.info-title {
flex: 1;
.stat-icon-up {
width: 20px;
height: 20px;
background: #f0f0f0;
border-radius: 50%;
position: relative;
.title-text {
font-size: 12px;
font-weight: 600;
color: #1677ff;
margin-bottom: 2px;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
width: 8px;
height: 8px;
border-top: 2px solid #722ed1;
border-right: 2px solid #722ed1;
}
}
.stat-icon-plus {
width: 20px;
height: 20px;
background: #f0f0f0;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 2px;
background: #52c41a;
}
.title-sub {
font-size: 10px;
color: #666;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 10px;
background: #52c41a;
}
}
.stat-icon-people {
width: 20px;
height: 20px;
background: #f0f0f0;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 6px;
left: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #1677ff;
}
&::after {
content: '';
position: absolute;
top: 13px;
left: 5px;
width: 10px;
height: 5px;
border-radius: 10px 10px 0 0;
background: #1677ff;
}
}
.stat-icon-chat {
width: 20px;
height: 20px;
background: #f0f0f0;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 6px;
left: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: #fa8c16;
}
}
}
.info-value {
text-align: right;
font-size: 18px;
font-weight: 700;
color: #1677ff;
.stat-value {
font-size: 20px;
font-weight: 600;
color: #333;
}
.value-unit {
font-size: 12px;
color: #666;
margin-left: 4px;
}
.stat-value-positive {
font-size: 20px;
font-weight: 600;
color: #52c41a;
}
}
}
@@ -449,6 +617,47 @@
}
}
.friends-summary {
display: flex;
align-items: center;
justify-content: space-between;
background: #f5f9ff;
border: 1px solid #e0edff;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 16px;
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-label {
font-size: 12px;
color: #666;
}
.summary-value {
font-size: 20px;
font-weight: 600;
color: #111;
}
.summary-value-highlight {
font-size: 20px;
font-weight: 600;
color: #fa541c;
}
.summary-divider {
width: 1px;
height: 32px;
background: #e6e6e6;
margin: 0 12px;
}
}
.friends-list {
.empty {
text-align: center;
@@ -467,83 +676,100 @@
}
}
.friend-item {
.friend-card {
display: flex;
align-items: center;
padding: 12px;
padding: 14px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 8px;
}
border: 1px solid #f0f0f0;
border-radius: 12px;
margin-bottom: 10px;
gap: 12px;
transition: box-shadow 0.2s, border-color 0.2s;
.friend-item-static {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 8px;
&:hover {
border-color: #cfe2ff;
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.15);
}
}
.friend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
width: 48px;
height: 48px;
.adm-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
}
.friend-info {
.friend-main {
flex: 1;
min-width: 0;
}
.friend-header {
display: flex;
align-items: center;
justify-content: space-between;
.friend-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.friend-name {
font-size: 15px;
font-weight: 600;
color: #111;
flex-shrink: 0;
}
.friend-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.friend-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: #f5f5f5;
color: #666;
}
.friend-id-row {
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
.friend-status-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.friend-status-chip {
background: #f0f7ff;
color: #1677ff;
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
}
.friend-value {
text-align: right;
.value-label {
font-size: 11px;
color: #999;
margin-bottom: 4px;
.friend-name {
font-size: 14px;
font-weight: 500;
color: #333;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.friend-remark {
color: #666;
margin-left: 4px;
}
}
.friend-arrow {
font-size: 12px;
color: #ccc;
}
}
.friend-wechat-id {
font-size: 12px;
color: #666;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
.friend-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
}
.value-amount {
font-size: 14px;
font-weight: 600;
color: #fa541c;
}
}
}
@@ -769,6 +995,416 @@
margin-right: 8px;
}
.health-content {
padding: 16px 0;
height: 500px;
overflow-y: auto;
.health-score-card {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.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-section {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.health-section-title {
font-size: 16px;
font-weight: 600;
color: #ff8800;
margin-bottom: 12px;
position: relative;
padding-left: 12px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background: #ff8800;
border-radius: 2px;
}
}
.health-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.health-item-label {
font-size: 14px;
color: #333;
display: flex;
align-items: center;
.health-item-icon-warning {
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffebeb;
margin-right: 8px;
position: relative;
&::before {
content: '!';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ff4d4f;
font-size: 12px;
font-weight: bold;
}
}
.health-item-tag {
background: #fff7e6;
color: #fa8c16;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
margin-left: 8px;
}
}
.health-item-value-positive {
font-size: 14px;
font-weight: 600;
color: #52c41a;
}
.health-item-value-negative {
font-size: 14px;
font-weight: 600;
color: #ff4d4f;
}
.health-item-value-empty {
width: 20px;
}
}
.health-empty {
text-align: center;
color: #999;
font-size: 14px;
padding: 20px 0;
}
}
}
.moments-content {
padding: 16px 0;
height: 500px;
overflow-y: auto;
background: #f5f5f5;
.moments-action-bar {
display: flex;
justify-content: space-between;
padding: 0 16px 16px;
.action-button, .action-button-dark {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 70px;
height: 40px;
border-radius: 8px;
background: #1677ff;
.action-icon-text, .action-icon-image, .action-icon-video, .action-icon-export {
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
margin-bottom: 2px;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 2px;
background: white;
}
}
.action-icon-image::after {
content: '';
position: absolute;
top: 6px;
left: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: white;
}
.action-icon-video::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent white;
}
.action-text, .action-text-light {
font-size: 12px;
color: white;
}
}
.action-button-dark {
background: #333;
}
}
.moments-list {
padding: 0 16px;
.moment-item {
display: flex;
margin-bottom: 16px;
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
.moment-date {
margin-right: 12px;
text-align: center;
.date-day {
font-size: 20px;
font-weight: 600;
color: #333;
line-height: 1;
}
.date-month {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
.moment-content {
flex: 1;
.moment-text {
font-size: 14px;
line-height: 1.5;
color: #333;
margin-bottom: 8px;
white-space: pre-wrap; // 保留换行和空格,确保文本完整显示
word-wrap: break-word; // 长单词自动换行
.moment-emoji {
display: inline;
font-size: 16px;
vertical-align: middle;
}
}
.moment-images {
margin-bottom: 8px;
.image-grid {
display: grid;
gap: 8px;
width: 100%;
// 1张图片宽度拉伸高度自适应
&.single {
grid-template-columns: 1fr;
img {
width: 100%;
height: auto;
object-fit: cover;
border-radius: 8px;
}
}
// 2张图片左右并列
&.double {
grid-template-columns: 1fr 1fr;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
// 3张图片三张并列
&.triple {
grid-template-columns: 1fr 1fr 1fr;
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
}
// 4张图片2x2网格布局
&.quad {
grid-template-columns: repeat(2, 1fr);
img {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 8px;
}
}
// 5张及以上网格布局9宫格
&.grid {
grid-template-columns: repeat(3, 1fr);
img {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
}
.image-more {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
color: white;
font-size: 12px;
font-weight: 500;
height: 100px;
}
}
}
}
.moment-footer {
display: flex;
justify-content: flex-end;
.moment-time {
font-size: 12px;
color: #999;
}
}
}
}
}
}
.risk-content {
padding: 16px 0;
height: 500px;

View File

@@ -21,11 +21,17 @@ import {
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./detail.module.scss";
import { getWechatAccountDetail, getWechatFriends, transferWechatFriends } from "./api";
import {
getWechatAccountDetail,
getWechatFriends,
transferWechatFriends,
getWechatAccountOverview,
getWechatMoments,
} 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 }>();
@@ -34,6 +40,7 @@ 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[]>([]);
@@ -50,6 +57,12 @@ 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 fetchAccountInfo = useCallback(async () => {
@@ -80,6 +93,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 = "") => {
@@ -96,26 +122,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);
@@ -137,6 +181,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);
@@ -161,8 +245,9 @@ const WechatAccountDetail: React.FC = () => {
useEffect(() => {
if (id) {
fetchAccountInfo();
fetchOverviewData();
}
}, [id, fetchAccountInfo]);
}, [id, fetchAccountInfo, fetchOverviewData]);
// 监听标签切换 - 只在切换到好友列表时请求一次
useEffect(() => {
@@ -173,6 +258,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 = [
@@ -271,6 +364,41 @@ 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 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"]}>
@@ -313,73 +441,220 @@ 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-card"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}></span>
<span className={style["status-time"]}>: {accountSummary?.healthScore?.lastAddTime || "18:36:06"}</span>
</div>
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{accountSummary?.healthScore?.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"]}>{accountSummary?.statistics.addLimit || 0} </div>
</div>
<div className={style["stats-row"]}>
<div className={style["stats-label"]}></div>
<div className={style["stats-value"]}>{accountSummary?.statistics.todayAdded || 0} </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"]}>
@@ -406,6 +681,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 ? (
@@ -431,36 +723,53 @@ 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})
{friend.nickname || "未知好友"}
</div>
<div className={style["friend-tags"]}>
{friend.tags?.map((tag, index) => (
<span
key={index}
className={style["friend-tag"]}
>
{typeof tag === "string" ? tag : tag.name}
</span>
)}
))}
</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>
@@ -484,45 +793,115 @@ 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"]}>
<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>

View File

@@ -2,6 +2,39 @@
padding: 0 12px;
}
.filter-bar {
padding: 12px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.filter-buttons {
display: flex;
gap: 8px;
.filter-button {
flex: 1;
height: 32px;
border-radius: 6px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
font-size: 14px;
transition: all 0.2s;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
&.filter-button-active {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
}
}
}
.nav-title {
font-size: 18px;
font-weight: 600;

View File

@@ -33,11 +33,12 @@ const WechatAccounts: React.FC = () => {
const [totalAccounts, setTotalAccounts] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [statusFilter, setStatusFilter] = useState<"all" | "online" | "offline">("all");
// 获取路由参数 wechatStatus
const wechatStatus = searchParams.get("wechatStatus");
const fetchAccounts = async (page = 1, keyword = "") => {
const fetchAccounts = async (page = 1, keyword = "", status?: "all" | "online" | "offline") => {
setIsLoading(true);
try {
const params: any = {
@@ -46,8 +47,12 @@ const WechatAccounts: React.FC = () => {
keyword,
};
// 如果有 wechatStatus 参数,添加到请求参数中
if (wechatStatus) {
// 优先使用传入的status参数否则使用路由参数,最后使用状态中的筛选
const filterStatus = status || wechatStatus || statusFilter;
if (filterStatus && filterStatus !== "all") {
params.wechatStatus = filterStatus === "online" ? "1" : "0";
} else if (wechatStatus) {
params.wechatStatus = wechatStatus;
}
@@ -60,7 +65,7 @@ const WechatAccounts: React.FC = () => {
setTotalAccounts(0);
}
} catch (e) {
Toast.show({ content: "获取微信号失败", position: "top" });
setAccounts([]);
setTotalAccounts(0);
} finally {
@@ -69,18 +74,24 @@ const WechatAccounts: React.FC = () => {
};
useEffect(() => {
fetchAccounts(currentPage, searchTerm);
fetchAccounts(currentPage, searchTerm, statusFilter);
// eslint-disable-next-line
}, [currentPage]);
}, [currentPage, statusFilter]);
const handleSearch = () => {
setCurrentPage(1);
fetchAccounts(1, searchTerm);
fetchAccounts(1, searchTerm, statusFilter);
};
const handleStatusFilterChange = (status: "all" | "online" | "offline") => {
setStatusFilter(status);
setCurrentPage(1);
fetchAccounts(1, searchTerm, status);
};
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchAccounts(currentPage, searchTerm);
await fetchAccounts(currentPage, searchTerm, statusFilter);
setIsRefreshing(false);
Toast.show({ content: "刷新成功", position: "top" });
};
@@ -122,6 +133,31 @@ const WechatAccounts: React.FC = () => {
<ReloadOutlined />
</Button>
</div>
<div className={style["filter-bar"]}>
<div className={style["filter-buttons"]}>
<Button
size="small"
className={`${style["filter-button"]} ${statusFilter === "all" ? style["filter-button-active"] : ""}`}
onClick={() => handleStatusFilterChange("all")}
>
</Button>
<Button
size="small"
className={`${style["filter-button"]} ${statusFilter === "online" ? style["filter-button-active"] : ""}`}
onClick={() => handleStatusFilterChange("online")}
>
线
</Button>
<Button
size="small"
className={`${style["filter-button"]} ${statusFilter === "offline" ? style["filter-button-active"] : ""}`}
onClick={() => handleStatusFilterChange("offline")}
>
线
</Button>
</div>
</div>
</>
}
>