Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2939b63998 | ||
|
|
ff7097f2d9 | ||
|
|
33cd4de3c5 | ||
|
|
0bc6c3a22a | ||
|
|
44edfe7a81 | ||
|
|
d3fae3bbd0 | ||
|
|
31fdabaa15 | ||
|
|
43e9930b45 | ||
|
|
a77192ebc2 | ||
|
|
181a792d0a | ||
|
|
43f6a061e1 | ||
|
|
05bba1a461 | ||
|
|
97fc9959bb | ||
|
|
ad2d1c27ab | ||
|
|
97144e4ebe | ||
|
|
4baa82449c | ||
|
|
5a5160b92d | ||
|
|
3e78122659 |
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
70
Cunkebao/src/utils/cacheCleaner.ts
Normal file
70
Cunkebao/src/utils/cacheCleaner.ts
Normal 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();
|
||||
};
|
||||
73
Moncter/src/utils/cacheCleaner.ts
Normal file
73
Moncter/src/utils/cacheCleaner.ts
Normal 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();
|
||||
};
|
||||
|
||||
254
Server/README_scheduler.md
Normal file
254
Server/README_scheduler.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 统一任务调度器使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
统一任务调度器(TaskSchedulerCommand)是一个集中管理所有定时任务的调度系统,支持:
|
||||
- ✅ 单条 crontab 配置管理所有任务
|
||||
- ✅ 多进程并发执行任务
|
||||
- ✅ 自动根据 cron 表达式判断任务执行时间
|
||||
- ✅ 任务锁机制,防止重复执行
|
||||
- ✅ 完善的日志记录
|
||||
|
||||
## 安装配置
|
||||
|
||||
### 1. 配置文件
|
||||
|
||||
任务配置位于 `config/task_scheduler.php`,格式如下:
|
||||
|
||||
```php
|
||||
'任务标识' => [
|
||||
'command' => '命令名称', // 必填:执行的命令
|
||||
'schedule' => 'cron表达式', // 必填:cron表达式
|
||||
'options' => ['--option=value'], // 可选:命令参数
|
||||
'enabled' => true, // 可选:是否启用
|
||||
'max_concurrent' => 1, // 可选:最大并发数
|
||||
'timeout' => 3600, // 可选:超时时间(秒)
|
||||
'log_file' => 'custom.log', // 可选:自定义日志文件
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Cron 表达式格式
|
||||
|
||||
标准 cron 格式:`分钟 小时 日 月 星期`
|
||||
|
||||
示例:
|
||||
- `*/1 * * * *` - 每分钟执行
|
||||
- `*/5 * * * *` - 每5分钟执行
|
||||
- `*/30 * * * *` - 每30分钟执行
|
||||
- `0 2 * * *` - 每天凌晨2点执行
|
||||
- `0 3 */3 * *` - 每3天的3点执行
|
||||
|
||||
### 3. Crontab 配置
|
||||
|
||||
**只需要在 crontab 中添加一条任务:**
|
||||
|
||||
```bash
|
||||
# 每分钟执行一次调度器(调度器内部会根据 cron 表达式判断哪些任务需要执行)
|
||||
* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1
|
||||
```
|
||||
|
||||
### 4. 系统要求
|
||||
|
||||
- PHP >= 5.6.0
|
||||
- 推荐启用 `pcntl` 扩展以支持多进程并发(非必需,未启用时使用单进程顺序执行)
|
||||
|
||||
检查 pcntl 扩展:
|
||||
```bash
|
||||
php -m | grep pcntl
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 手动执行调度器
|
||||
|
||||
```bash
|
||||
# 执行调度器(会自动判断当前时间需要执行的任务)
|
||||
php think scheduler:run
|
||||
```
|
||||
|
||||
### 查看任务配置
|
||||
|
||||
```bash
|
||||
# 查看所有已注册的命令
|
||||
php think list
|
||||
```
|
||||
|
||||
### 启用/禁用任务
|
||||
|
||||
编辑 `config/task_scheduler.php`,设置 `'enabled' => false` 即可禁用任务。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 多进程并发执行
|
||||
|
||||
- 默认最大并发数:10 个进程
|
||||
- 自动管理进程池
|
||||
- 自动清理僵尸进程
|
||||
|
||||
### 2. 任务锁机制
|
||||
|
||||
- 每个任务在执行时会设置锁(5分钟内不重复执行)
|
||||
- 防止任务重复执行
|
||||
- 锁存储在缓存中,自动过期
|
||||
|
||||
### 3. 日志记录
|
||||
|
||||
- 调度器日志:`runtime/log/scheduler.log`
|
||||
- 每个任务的日志:`runtime/log/{log_file}`
|
||||
- 任务执行开始和结束都有标记
|
||||
|
||||
### 4. 超时控制
|
||||
|
||||
- 默认超时时间:3600 秒(1小时)
|
||||
- 可在配置中为每个任务单独设置超时时间
|
||||
- 超时后自动终止任务
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 高频任务(每分钟)
|
||||
|
||||
```php
|
||||
'wechat_friends_active' => [
|
||||
'command' => 'wechatFriends:list',
|
||||
'schedule' => '*/1 * * * *',
|
||||
'options' => ['--isDel=0'],
|
||||
'enabled' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### 中频任务(每5分钟)
|
||||
|
||||
```php
|
||||
'device_active' => [
|
||||
'command' => 'device:list',
|
||||
'schedule' => '*/5 * * * *',
|
||||
'options' => ['--isDel=0'],
|
||||
'enabled' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### 每日任务
|
||||
|
||||
```php
|
||||
'wechat_calculate_score' => [
|
||||
'command' => 'wechat:calculate-score',
|
||||
'schedule' => '0 2 * * *', // 每天凌晨2点
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### 定期任务(每3天)
|
||||
|
||||
```php
|
||||
'sync_all_friends' => [
|
||||
'command' => 'sync:allFriends',
|
||||
'schedule' => '0 3 */3 * *', // 每3天的3点
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
],
|
||||
```
|
||||
|
||||
## 从旧配置迁移
|
||||
|
||||
### 旧配置(多条 crontab)
|
||||
|
||||
```bash
|
||||
*/5 * * * * cd /path && php think device:list --isDel=0 >> log1.log 2>&1
|
||||
*/1 * * * * cd /path && php think wechatFriends:list >> log2.log 2>&1
|
||||
```
|
||||
|
||||
### 新配置(单条 crontab + 配置文件)
|
||||
|
||||
**Crontab:**
|
||||
```bash
|
||||
* * * * * cd /path && php think scheduler:run >> scheduler.log 2>&1
|
||||
```
|
||||
|
||||
**config/task_scheduler.php:**
|
||||
```php
|
||||
'device_active' => [
|
||||
'command' => 'device:list',
|
||||
'schedule' => '*/5 * * * *',
|
||||
'options' => ['--isDel=0'],
|
||||
'log_file' => 'log1.log',
|
||||
],
|
||||
'wechat_friends' => [
|
||||
'command' => 'wechatFriends:list',
|
||||
'schedule' => '*/1 * * * *',
|
||||
'log_file' => 'log2.log',
|
||||
],
|
||||
```
|
||||
|
||||
## 监控和调试
|
||||
|
||||
### 查看调度器日志
|
||||
|
||||
```bash
|
||||
tail -f runtime/log/scheduler.log
|
||||
```
|
||||
|
||||
### 查看任务执行日志
|
||||
|
||||
```bash
|
||||
tail -f runtime/log/crontab_device_active.log
|
||||
```
|
||||
|
||||
### 检查任务是否在执行
|
||||
|
||||
```bash
|
||||
# 查看进程
|
||||
ps aux | grep "php think"
|
||||
```
|
||||
|
||||
### 手动测试任务
|
||||
|
||||
```bash
|
||||
# 直接执行某个任务
|
||||
php think device:list --isDel=0
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **时间同步**:确保服务器时间准确,调度器依赖系统时间判断任务执行时间
|
||||
2. **资源限制**:根据服务器性能调整 `maxConcurrent` 参数
|
||||
3. **日志清理**:定期清理日志文件,避免占用过多磁盘空间
|
||||
4. **任务冲突**:如果任务执行时间较长,建议调整执行频率或增加并发数
|
||||
5. **缓存依赖**:任务锁使用缓存,确保缓存服务正常运行
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 任务未执行
|
||||
|
||||
1. 检查任务是否启用:`'enabled' => true`
|
||||
2. 检查 cron 表达式是否正确
|
||||
3. 检查调度器是否正常运行:查看 `scheduler.log`
|
||||
4. 检查任务锁:任务可能在5分钟内重复执行被跳过
|
||||
|
||||
### 任务执行失败
|
||||
|
||||
1. 查看任务日志:`runtime/log/{log_file}`
|
||||
2. 检查命令是否正确:手动执行命令测试
|
||||
3. 检查权限:确保有执行权限和日志写入权限
|
||||
|
||||
### 多进程不工作
|
||||
|
||||
1. 检查 pcntl 扩展:`php -m | grep pcntl`
|
||||
2. 检查系统限制:`ulimit -u` 查看最大进程数
|
||||
3. 查看调度器日志中的错误信息
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **合理设置并发数**:根据服务器 CPU 核心数和内存大小调整
|
||||
2. **错开高频任务**:避免所有任务在同一分钟执行
|
||||
3. **优化任务执行时间**:减少任务执行时长
|
||||
4. **使用队列**:对于耗时任务,建议使用队列异步处理
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-01-XX)
|
||||
- 初始版本
|
||||
- 支持多进程并发执行
|
||||
- 支持 cron 表达式调度
|
||||
- 支持任务锁机制
|
||||
|
||||
@@ -61,10 +61,8 @@ class MessageController extends BaseController
|
||||
// 发送请求获取好友列表
|
||||
$result = requestCurl($this->baseUrl . 'api/WechatFriend/listWechatFriendForMsgPagination', $params, 'POST', $header, 'json');
|
||||
$response = handleApiResponse($result);
|
||||
|
||||
// 获取同步消息标志
|
||||
$syncMessages = $this->request->param('syncMessages', true);
|
||||
|
||||
// 如果需要同步消息,则获取每个好友的消息
|
||||
if ($syncMessages && !empty($response['results'])) {
|
||||
$from = strtotime($fromTime) * 1000;
|
||||
@@ -90,7 +88,6 @@ class MessageController extends BaseController
|
||||
// 调用获取消息的接口
|
||||
$messageResult = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $messageParams, 'GET', $header, 'json');
|
||||
$messageResponse = handleApiResponse($messageResult);
|
||||
|
||||
// 保存消息到数据库
|
||||
if (!empty($messageResponse)) {
|
||||
foreach ($messageResponse as $item) {
|
||||
@@ -353,12 +350,6 @@ class MessageController extends BaseController
|
||||
{
|
||||
// 检查消息是否已存在
|
||||
$exists = WechatMessageModel::where('id', $item['id']) ->find();
|
||||
|
||||
// 如果消息已存在,直接返回
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 将毫秒时间戳转换为秒级时间戳
|
||||
$createTime = isset($item['createTime']) ? strtotime($item['createTime']) : null;
|
||||
$deleteTime = !empty($item['isDeleted']) ? strtotime($item['deleteTime']) : null;
|
||||
@@ -387,7 +378,8 @@ class MessageController extends BaseController
|
||||
'wechatTime' => $wechatTime
|
||||
];
|
||||
|
||||
//已被删除
|
||||
|
||||
//已被删除
|
||||
if ($item['msgType'] == 10000 && strpos($item['content'],'开启了朋友验证') !== false) {
|
||||
Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->update(['isDeleted'=> 1,'deleteTime' => $wechatTime]);
|
||||
}else{
|
||||
@@ -425,8 +417,19 @@ class MessageController extends BaseController
|
||||
}
|
||||
}
|
||||
}
|
||||
// 创建新记录
|
||||
$res = WechatMessageModel::create($data);
|
||||
|
||||
$id = '';
|
||||
if (empty($exists)){
|
||||
// 创建新记录
|
||||
$res = WechatMessageModel::create($data);
|
||||
$id= $res['id'];
|
||||
}else{
|
||||
$id = $data['id'];
|
||||
unset($data['id']);
|
||||
$res = $exists->save($data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 1 文字 3图片 47动态图片 34语言 43视频 42名片 40/20链接 49文件
|
||||
if (!empty($res) && empty($item['isSend']) && in_array($item['msgType'],[1,3,20,34,40,42,43,47,49])){
|
||||
@@ -439,13 +442,14 @@ class MessageController extends BaseController
|
||||
'companyId' => $friend['companyId'],
|
||||
'trafficPoolId' => $trafficPoolId,
|
||||
'source' => 0,
|
||||
'uniqueId' => $res['id'],
|
||||
'uniqueId' => $id,
|
||||
'sourceData' => json_encode([]),
|
||||
'remark' => '用户发送了消息',
|
||||
'createTime' => time(),
|
||||
'updateTime' => time()
|
||||
];
|
||||
Db::name('user_portrait')->insert($data);
|
||||
Db::name('user_portrait')->insert($data);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,11 +465,7 @@ class MessageController extends BaseController
|
||||
{
|
||||
// 检查消息是否已存在
|
||||
$exists = WechatMessageModel::where('id', $item['id'])->find();
|
||||
|
||||
// 如果消息已存在,直接返回
|
||||
if ($exists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// 处理发送者信息
|
||||
$sender = $item['sender'] ?? [];
|
||||
@@ -515,7 +515,12 @@ class MessageController extends BaseController
|
||||
|
||||
// 创建新记录
|
||||
try {
|
||||
WechatMessageModel::create($data);
|
||||
if(empty($exists)){
|
||||
WechatMessageModel::create($data);
|
||||
}else{
|
||||
unset($data['id']);
|
||||
$exists->save($data);
|
||||
}
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
|
||||
@@ -42,4 +42,7 @@ return [
|
||||
|
||||
'wechat:calculate-score' => 'app\command\CalculateWechatAccountScoreCommand', // 统一计算微信账号健康分
|
||||
'wechat:update-score' => 'app\command\UpdateWechatAccountScoreCommand', // 更新微信账号评分记录
|
||||
|
||||
// 统一任务调度器
|
||||
'scheduler:run' => 'app\command\TaskSchedulerCommand', // 统一任务调度器,支持多进程并发执行
|
||||
];
|
||||
|
||||
478
Server/application/command/TaskSchedulerCommand.php
Normal file
478
Server/application/command/TaskSchedulerCommand.php
Normal file
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 统一任务调度器
|
||||
* 支持多进程并发执行任务
|
||||
*
|
||||
* 使用方法:
|
||||
* php think scheduler:run
|
||||
*
|
||||
* 在 crontab 中配置:
|
||||
* * * * * * cd /path/to/project && php think scheduler:run >> /path/to/log/scheduler.log 2>&1
|
||||
*/
|
||||
class TaskSchedulerCommand extends Command
|
||||
{
|
||||
/**
|
||||
* 任务配置
|
||||
*/
|
||||
protected $tasks = [];
|
||||
|
||||
/**
|
||||
* 最大并发进程数
|
||||
*/
|
||||
protected $maxConcurrent = 10;
|
||||
|
||||
/**
|
||||
* 当前运行的进程数
|
||||
*/
|
||||
protected $runningProcesses = [];
|
||||
|
||||
/**
|
||||
* 日志目录
|
||||
*/
|
||||
protected $logDir = '';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('scheduler:run')
|
||||
->setDescription('统一任务调度器,支持多进程并发执行所有定时任务');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('==========================================');
|
||||
$output->writeln('任务调度器启动');
|
||||
$output->writeln('时间: ' . date('Y-m-d H:i:s'));
|
||||
$output->writeln('==========================================');
|
||||
|
||||
// 检查是否支持 pcntl 扩展
|
||||
if (!function_exists('pcntl_fork')) {
|
||||
$output->writeln('<error>错误:系统不支持 pcntl 扩展,无法使用多进程功能</error>');
|
||||
$output->writeln('<info>提示:将使用单进程顺序执行任务</info>');
|
||||
$this->maxConcurrent = 1;
|
||||
}
|
||||
|
||||
// 加载任务配置(优先使用框架配置,其次直接引入配置文件,避免加载失败)
|
||||
$this->tasks = Config::get('task_scheduler', []);
|
||||
|
||||
// 如果通过 Config 没有读到,再尝试直接 include 配置文件
|
||||
if (empty($this->tasks)) {
|
||||
// 以项目根目录为基准查找 config/task_scheduler.php
|
||||
$configFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php';
|
||||
if (is_file($configFile)) {
|
||||
$config = include $configFile;
|
||||
if (is_array($config) && !empty($config)) {
|
||||
$this->tasks = $config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->tasks)) {
|
||||
$output->writeln('<error>错误:未找到任务配置(task_scheduler),请检查 config/task_scheduler.php 是否存在且返回数组</error>');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置日志目录(ThinkPHP5 中无 runtime_path 辅助函数,直接使用 ROOT_PATH/runtime/log)
|
||||
if (!defined('ROOT_PATH')) {
|
||||
// CLI 下正常情况下 ROOT_PATH 已在入口脚本 define,这里兜底一次
|
||||
define('ROOT_PATH', dirname(__DIR__, 2));
|
||||
}
|
||||
$this->logDir = ROOT_PATH . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR;
|
||||
if (!is_dir($this->logDir)) {
|
||||
mkdir($this->logDir, 0755, true);
|
||||
}
|
||||
|
||||
// 获取当前时间
|
||||
$currentTime = time();
|
||||
$currentMinute = date('i', $currentTime);
|
||||
$currentHour = date('H', $currentTime);
|
||||
$currentDay = date('d', $currentTime);
|
||||
$currentMonth = date('m', $currentTime);
|
||||
$currentWeekday = date('w', $currentTime); // 0=Sunday, 6=Saturday
|
||||
|
||||
$output->writeln("当前时间: {$currentHour}:{$currentMinute}");
|
||||
$output->writeln("已加载 " . count($this->tasks) . " 个任务配置");
|
||||
|
||||
// 筛选需要执行的任务
|
||||
$tasksToRun = [];
|
||||
foreach ($this->tasks as $taskId => $task) {
|
||||
if (!isset($task['enabled']) || !$task['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) {
|
||||
$tasksToRun[$taskId] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($tasksToRun)) {
|
||||
$output->writeln('<info>当前时间没有需要执行的任务</info>');
|
||||
return true;
|
||||
}
|
||||
|
||||
$output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务");
|
||||
|
||||
// 执行任务
|
||||
if ($this->maxConcurrent > 1 && function_exists('pcntl_fork')) {
|
||||
$this->executeConcurrent($tasksToRun, $output);
|
||||
} else {
|
||||
$this->executeSequential($tasksToRun, $output);
|
||||
}
|
||||
|
||||
// 清理僵尸进程
|
||||
$this->cleanupZombieProcesses();
|
||||
|
||||
$output->writeln('==========================================');
|
||||
$output->writeln('任务调度器执行完成');
|
||||
$output->writeln('==========================================');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断任务是否应该执行
|
||||
*
|
||||
* @param string $schedule cron表达式,格式:分钟 小时 日 月 星期
|
||||
* @param int $minute 当前分钟
|
||||
* @param int $hour 当前小时
|
||||
* @param int $day 当前日期
|
||||
* @param int $month 当前月份
|
||||
* @param int $weekday 当前星期
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldRun($schedule, $minute, $hour, $day, $month, $weekday)
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($schedule));
|
||||
if (count($parts) < 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($scheduleMinute, $scheduleHour, $scheduleDay, $scheduleMonth, $scheduleWeekday) = $parts;
|
||||
|
||||
// 解析分钟
|
||||
if (!$this->matchCronField($scheduleMinute, $minute)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析小时
|
||||
if (!$this->matchCronField($scheduleHour, $hour)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析日期
|
||||
if (!$this->matchCronField($scheduleDay, $day)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析月份
|
||||
if (!$this->matchCronField($scheduleMonth, $month)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析星期(注意:cron中0和7都表示星期日)
|
||||
if ($scheduleWeekday !== '*') {
|
||||
$scheduleWeekday = str_replace('7', '0', $scheduleWeekday);
|
||||
if (!$this->matchCronField($scheduleWeekday, $weekday)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配cron字段
|
||||
*
|
||||
* @param string $field cron字段表达式
|
||||
* @param int $value 当前值
|
||||
* @return bool
|
||||
*/
|
||||
protected function matchCronField($field, $value)
|
||||
{
|
||||
// 通配符
|
||||
if ($field === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 列表(逗号分隔)
|
||||
if (strpos($field, ',') !== false) {
|
||||
$values = explode(',', $field);
|
||||
foreach ($values as $v) {
|
||||
if ($this->matchCronField(trim($v), $value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 范围(如 1-5)
|
||||
if (strpos($field, '-') !== false) {
|
||||
list($start, $end) = explode('-', $field);
|
||||
return $value >= (int)$start && $value <= (int)$end;
|
||||
}
|
||||
|
||||
// 步长(如 */5 或 0-59/5)
|
||||
if (strpos($field, '/') !== false) {
|
||||
$parts = explode('/', $field);
|
||||
$base = $parts[0];
|
||||
$step = (int)$parts[1];
|
||||
|
||||
if ($base === '*') {
|
||||
return $value % $step === 0;
|
||||
} else {
|
||||
// 处理范围步长,如 0-59/5
|
||||
if (strpos($base, '-') !== false) {
|
||||
list($start, $end) = explode('-', $base);
|
||||
if ($value >= (int)$start && $value <= (int)$end) {
|
||||
return ($value - (int)$start) % $step === 0;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return $value % $step === 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 精确匹配
|
||||
return (int)$field === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发执行任务(多进程)
|
||||
*
|
||||
* @param array $tasks 任务列表
|
||||
* @param Output $output 输出对象
|
||||
*/
|
||||
protected function executeConcurrent($tasks, Output $output)
|
||||
{
|
||||
$output->writeln('<info>使用多进程并发执行任务(最大并发数:' . $this->maxConcurrent . ')</info>');
|
||||
|
||||
foreach ($tasks as $taskId => $task) {
|
||||
// 等待可用进程槽
|
||||
while (count($this->runningProcesses) >= $this->maxConcurrent) {
|
||||
$this->waitForProcesses();
|
||||
usleep(100000); // 等待100ms
|
||||
}
|
||||
|
||||
// 检查任务是否已经在运行(防止重复执行)
|
||||
$lockKey = "scheduler_task_lock:{$taskId}";
|
||||
$lockTime = Cache::get($lockKey);
|
||||
if ($lockTime && (time() - $lockTime) < 300) { // 5分钟内不重复执行
|
||||
$output->writeln("<comment>任务 {$taskId} 正在运行中,跳过</comment>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建子进程
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid == -1) {
|
||||
// 创建进程失败
|
||||
$output->writeln("<error>创建子进程失败:{$taskId}</error>");
|
||||
Log::error("任务调度器:创建子进程失败", ['task' => $taskId]);
|
||||
continue;
|
||||
} elseif ($pid == 0) {
|
||||
// 子进程:执行任务
|
||||
$this->runTask($taskId, $task);
|
||||
exit(0);
|
||||
} else {
|
||||
// 父进程:记录子进程PID
|
||||
$this->runningProcesses[$pid] = [
|
||||
'task_id' => $taskId,
|
||||
'start_time' => time(),
|
||||
];
|
||||
$output->writeln("<info>启动任务:{$taskId} (PID: {$pid})</info>");
|
||||
|
||||
// 设置任务锁
|
||||
Cache::set($lockKey, time(), 600); // 10分钟过期
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有子进程完成
|
||||
while (!empty($this->runningProcesses)) {
|
||||
$this->waitForProcesses();
|
||||
usleep(500000); // 等待500ms
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序执行任务(单进程)
|
||||
*
|
||||
* @param array $tasks 任务列表
|
||||
* @param Output $output 输出对象
|
||||
*/
|
||||
protected function executeSequential($tasks, Output $output)
|
||||
{
|
||||
$output->writeln('<info>使用单进程顺序执行任务</info>');
|
||||
|
||||
foreach ($tasks as $taskId => $task) {
|
||||
$output->writeln("<info>执行任务:{$taskId}</info>");
|
||||
$this->runTask($taskId, $task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个任务
|
||||
*
|
||||
* @param string $taskId 任务ID
|
||||
* @param array $task 任务配置
|
||||
*/
|
||||
protected function runTask($taskId, $task)
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$logFile = $this->logDir . ($task['log_file'] ?? "scheduler_{$taskId}.log");
|
||||
|
||||
// 确保日志目录存在
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// 构建命令
|
||||
// 使用项目根目录下的 think 脚本(同命令行 php think)
|
||||
if (!defined('ROOT_PATH')) {
|
||||
define('ROOT_PATH', dirname(__DIR__, 2));
|
||||
}
|
||||
$thinkPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'think';
|
||||
$command = "php {$thinkPath} {$task['command']}";
|
||||
if (!empty($task['options'])) {
|
||||
foreach ($task['options'] as $option) {
|
||||
$command .= ' ' . escapeshellarg($option);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加日志重定向
|
||||
$command .= " >> " . escapeshellarg($logFile) . " 2>&1";
|
||||
|
||||
// 记录任务开始
|
||||
$logMessage = "\n" . str_repeat('=', 60) . "\n";
|
||||
$logMessage .= "任务开始执行: {$taskId}\n";
|
||||
$logMessage .= "执行时间: " . date('Y-m-d H:i:s') . "\n";
|
||||
$logMessage .= "命令: {$command}\n";
|
||||
$logMessage .= str_repeat('=', 60) . "\n";
|
||||
file_put_contents($logFile, $logMessage, FILE_APPEND);
|
||||
|
||||
// 执行命令
|
||||
$descriptorspec = [
|
||||
0 => ['file', (PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'), 'r'], // stdin
|
||||
1 => ['file', $logFile, 'a'], // stdout
|
||||
2 => ['file', $logFile, 'a'], // stderr
|
||||
];
|
||||
|
||||
$process = @proc_open($command, $descriptorspec, $pipes, ROOT_PATH);
|
||||
|
||||
if (is_resource($process)) {
|
||||
// 关闭管道
|
||||
if (isset($pipes[0])) @fclose($pipes[0]);
|
||||
if (isset($pipes[1])) @fclose($pipes[1]);
|
||||
if (isset($pipes[2])) @fclose($pipes[2]);
|
||||
|
||||
// 设置超时
|
||||
$timeout = $task['timeout'] ?? 3600;
|
||||
$startWaitTime = time();
|
||||
|
||||
// 等待进程完成或超时
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查超时
|
||||
if ((time() - $startWaitTime) > $timeout) {
|
||||
if (function_exists('proc_terminate')) {
|
||||
proc_terminate($process, SIGTERM);
|
||||
// 等待进程终止
|
||||
sleep(2);
|
||||
$status = proc_get_status($process);
|
||||
if ($status['running']) {
|
||||
// 强制终止
|
||||
proc_terminate($process, SIGKILL);
|
||||
}
|
||||
}
|
||||
Log::warning("任务执行超时", [
|
||||
'task' => $taskId,
|
||||
'timeout' => $timeout,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(500000); // 等待500ms
|
||||
}
|
||||
|
||||
// 关闭进程
|
||||
proc_close($process);
|
||||
} else {
|
||||
// 如果 proc_open 失败,尝试直接执行(后台执行)
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
pclose(popen("start /B " . $command, "r"));
|
||||
} else {
|
||||
exec($command . ' > /dev/null 2>&1 &');
|
||||
}
|
||||
}
|
||||
|
||||
$endTime = microtime(true);
|
||||
$duration = round($endTime - $startTime, 2);
|
||||
|
||||
// 记录任务完成
|
||||
$logMessage = "\n" . str_repeat('=', 60) . "\n";
|
||||
$logMessage .= "任务执行完成: {$taskId}\n";
|
||||
$logMessage .= "完成时间: " . date('Y-m-d H:i:s') . "\n";
|
||||
$logMessage .= "执行时长: {$duration} 秒\n";
|
||||
$logMessage .= str_repeat('=', 60) . "\n";
|
||||
file_put_contents($logFile, $logMessage, FILE_APPEND);
|
||||
|
||||
Log::info("任务执行完成", [
|
||||
'task' => $taskId,
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程完成
|
||||
*/
|
||||
protected function waitForProcesses()
|
||||
{
|
||||
foreach ($this->runningProcesses as $pid => $info) {
|
||||
$status = 0;
|
||||
$result = pcntl_waitpid($pid, $status, WNOHANG);
|
||||
|
||||
if ($result == $pid || $result == -1) {
|
||||
// 进程已结束
|
||||
unset($this->runningProcesses[$pid]);
|
||||
|
||||
$duration = time() - $info['start_time'];
|
||||
Log::info("子进程执行完成", [
|
||||
'pid' => $pid,
|
||||
'task' => $info['task_id'],
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理僵尸进程
|
||||
*/
|
||||
protected function cleanupZombieProcesses()
|
||||
{
|
||||
if (!function_exists('pcntl_waitpid')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = 0;
|
||||
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
|
||||
// 清理僵尸进程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -47,26 +47,74 @@
|
||||
"topthink/think-migration": "^2.0",
|
||||
"phpunit/phpunit": "^5.0|^6.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"app\\": "application",
|
||||
"Eison\\": "extend/Eison"
|
||||
},
|
||||
"files": [
|
||||
"application/common.php"
|
||||
],
|
||||
"classmap": []
|
||||
},
|
||||
"extra": {
|
||||
"think-path": "thinkphp"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"allow-plugins": {
|
||||
"topthink/think-installer": true,
|
||||
"easywechat-composer/easywechat-composer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"app\\": "application",
|
||||
"Eison\\": "extend/Eison"
|
||||
},
|
||||
"files": [
|
||||
"application/common.php"
|
||||
],
|
||||
"homepage": "http://thinkphp.cn/",
|
||||
"license": "Apache-2.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "liu21st",
|
||||
"email": "liu21st@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "yunwuxin",
|
||||
"email": "448901948@qq.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=5.6.0",
|
||||
"topthink/framework": "5.1.41",
|
||||
"topthink/think-installer": "~1.0",
|
||||
"topthink/think-captcha": "^2.0",
|
||||
"topthink/think-helper": "^3.0",
|
||||
"topthink/think-image": "^1.0",
|
||||
"topthink/think-queue": "^2.0",
|
||||
"topthink/think-worker": "^2.0",
|
||||
"textalk/websocket": "^1.2",
|
||||
"aliyuncs/oss-sdk-php": "^2.3",
|
||||
"monolog/monolog": "^1.24",
|
||||
"guzzlehttp/guzzle": "^6.3",
|
||||
"overtrue/wechat": "~4.0",
|
||||
"endroid/qr-code": "^3.5",
|
||||
"phpoffice/phpspreadsheet": "^1.8",
|
||||
"workerman/workerman": "^3.5",
|
||||
"workerman/gateway-worker": "^3.0",
|
||||
"hashids/hashids": "^2.0",
|
||||
"khanamiryan/qrcode-detector-decoder": "^1.0",
|
||||
"lizhichao/word": "^2.0",
|
||||
"adbario/php-dot-notation": "^2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/var-dumper": "^3.4",
|
||||
"topthink/think-migration": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"app\\": "application",
|
||||
"Eison\\": "extend/Eison"
|
||||
},
|
||||
"files": [
|
||||
"application/common.php"
|
||||
],
|
||||
"classmap": []
|
||||
},
|
||||
"extra": {
|
||||
"think-path": "thinkphp"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"allow-plugins": {
|
||||
"topthink/think-installer": true,
|
||||
"easywechat-composer/easywechat-composer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
}
|
||||
4299
Server/composer.lock
generated
4299
Server/composer.lock
generated
File diff suppressed because it is too large
Load Diff
301
Server/config/task_scheduler.php
Normal file
301
Server/config/task_scheduler.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 任务调度器配置文件
|
||||
// +----------------------------------------------------------------------
|
||||
// | 定义所有需要定时执行的任务及其执行频率
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
return [
|
||||
// 任务配置格式:
|
||||
// '任务标识' => [
|
||||
// 'command' => '命令名称', // 必填:执行的 ThinkPHP 命令(见 application/command.php)
|
||||
// 'schedule' => 'cron表达式', // 必填:cron 表达式,如 '*/5 * * * *' 表示每5分钟
|
||||
// 'options' => ['--option=value'], // 可选:命令参数(原来 crontab 里的 --xxx=yyy)
|
||||
// 'enabled' => true, // 可选:是否启用,默认 true
|
||||
// 'max_concurrent'=> 1, // 可选:单任务最大并发数(目前由调度器统一控制,可预留)
|
||||
// 'timeout' => 3600, // 可选:超时时间(秒),默认 3600
|
||||
// 'log_file' => 'custom.log', // 可选:日志文件名,默认使用任务标识
|
||||
// ]
|
||||
|
||||
// ===========================
|
||||
// 高频任务(每分钟或更频繁)
|
||||
// ===========================
|
||||
|
||||
// 同步微信好友列表(未删除好友),用于保持系统中好友数据实时更新
|
||||
'wechat_friends_active' => [
|
||||
'command' => 'wechatFriends:list',
|
||||
'schedule' => '*/1 * * * *', // 每1分钟
|
||||
'options' => ['--isDel=0'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_wechatFriends_active.log',
|
||||
],
|
||||
|
||||
// 拉取“添加好友任务”列表,驱动自动加好友的任务队列
|
||||
'friend_task' => [
|
||||
'command' => 'friendTask:list',
|
||||
'schedule' => '*/1 * * * *', // 每1分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_friendTask.log',
|
||||
],
|
||||
|
||||
// 同步微信好友私聊消息列表,写入消息表,供客服工作台使用
|
||||
'message_friends' => [
|
||||
'command' => 'message:friendsList',
|
||||
'schedule' => '*/1 * * * *', // 每1分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_messageFriends.log',
|
||||
],
|
||||
|
||||
// 同步微信群聊消息列表,写入消息表,供群聊记录与风控分析
|
||||
'message_chatroom' => [
|
||||
'command' => 'message:chatroomList',
|
||||
'schedule' => '*/1 * * * *', // 每1分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_messageChatroom.log',
|
||||
],
|
||||
|
||||
// 客服端消息提醒任务,负责给在线客服推送新消息通知
|
||||
'kf_notice' => [
|
||||
'command' => 'kf:notice',
|
||||
'schedule' => '*/1 * * * *', // 每1分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'kf_notice.log',
|
||||
],
|
||||
|
||||
// ===========================
|
||||
// 中频任务(每 2-5 分钟)
|
||||
// ===========================
|
||||
|
||||
// 同步微信设备列表(未删除设备),用于设备管理与监控
|
||||
'device_active' => [
|
||||
'command' => 'device:list',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => ['--isDel=0'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_device_active.log',
|
||||
],
|
||||
|
||||
// 同步微信群聊列表(未删除群),用于群管理与后续任务分配
|
||||
'wechat_chatroom_active' => [
|
||||
'command' => 'wechatChatroom:list',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => ['--isDel=0'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_wechatChatroom_active.log',
|
||||
],
|
||||
|
||||
// 同步微信群成员列表(群好友),维持群成员明细数据
|
||||
'group_friends' => [
|
||||
'command' => 'groupFriends:list',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_groupFriends.log',
|
||||
],
|
||||
|
||||
// 同步“微信客服列表”,获取绑定到公司的微信号,用于工作台与分配规则
|
||||
'wechat_list' => [
|
||||
'command' => 'wechatList:list',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_wechatList.log',
|
||||
],
|
||||
|
||||
// 同步公司账号列表(企业/租户账号),供后台管理与统计
|
||||
'account_list' => [
|
||||
'command' => 'account:list',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_account.log',
|
||||
],
|
||||
|
||||
// 内容采集任务,将外部或设备内容同步到系统内容库
|
||||
'content_collect' => [
|
||||
'command' => 'content:collect',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_contentCollect.log',
|
||||
],
|
||||
|
||||
// 工作台:自动点赞好友/客户朋友圈,提高账号活跃度
|
||||
'workbench_auto_like' => [
|
||||
'command' => 'workbench:autoLike',
|
||||
'schedule' => '*/6 * * * *', // 每6分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_workbench_autoLike.log',
|
||||
],
|
||||
|
||||
// 工作台:自动建群任务,按规则批量创建微信群
|
||||
'workbench_group_create' => [
|
||||
'command' => 'workbench:groupCreate',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'workbench_groupCreate.log',
|
||||
],
|
||||
|
||||
// 工作台:自动导入通讯录到系统,生成加粉/建群等任务
|
||||
'workbench_import_contact' => [
|
||||
'command' => 'workbench:import-contact',
|
||||
'schedule' => '*/5 * * * *', // 每5分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'import_contact.log',
|
||||
],
|
||||
|
||||
// ===========================
|
||||
// 低频任务(每 2 分钟)
|
||||
// ===========================
|
||||
|
||||
// 清洗并同步微信原始数据到存客宝业务表(数据治理任务)
|
||||
'sync_wechat_data' => [
|
||||
'command' => 'sync:wechatData',
|
||||
'schedule' => '*/2 * * * *', // 每2分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'sync_wechat_data.log',
|
||||
],
|
||||
|
||||
// 工作台:流量分发任务,把流量池中的线索按规则分配给微信号或员工
|
||||
'workbench_traffic_distribute' => [
|
||||
'command' => 'workbench:trafficDistribute',
|
||||
'schedule' => '*/2 * * * *', // 每2分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'traffic_distribute.log',
|
||||
],
|
||||
|
||||
// 工作台:朋友圈同步任务,拉取并落库朋友圈内容
|
||||
'workbench_moments' => [
|
||||
'command' => 'workbench:moments',
|
||||
'schedule' => '*/2 * * * *', // 每2分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'workbench_moments.log',
|
||||
],
|
||||
|
||||
// 预防性切换好友任务,监控频繁/风控风险,自动切换加人对象,保护微信号
|
||||
'switch_friends' => [
|
||||
'command' => 'switch:friends',
|
||||
'schedule' => '*/2 * * * *', // 每2分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'switch_friends.log',
|
||||
],
|
||||
|
||||
// ===========================
|
||||
// 低频任务(每 30 分钟)
|
||||
// ===========================
|
||||
|
||||
// 拉取设备通话记录(语音/电话),用于质检、统计或标签打分
|
||||
'call_recording' => [
|
||||
'command' => 'call-recording:list',
|
||||
'schedule' => '*/30 * * * *', // 每30分钟
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'call_recording.log',
|
||||
],
|
||||
|
||||
// ===========================
|
||||
// 每日 / 每几天任务
|
||||
// ===========================
|
||||
|
||||
// 每日 1:00 同步“已删除设备”列表,补齐历史状态
|
||||
'device_deleted' => [
|
||||
'command' => 'device:list',
|
||||
'schedule' => '0 1 * * *', // 每天1点
|
||||
'options' => ['--isDel=1'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_device_deleted.log',
|
||||
],
|
||||
|
||||
// 每日 1:10 同步“已停用设备”列表,更新停用状态
|
||||
'device_stopped' => [
|
||||
'command' => 'device:list',
|
||||
'schedule' => '10 1 * * *', // 每天1:10
|
||||
'options' => ['--isDel=2'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_device_stopped.log',
|
||||
],
|
||||
|
||||
// 每日 1:30 同步“已删除微信好友”,用于历史恢复与报表
|
||||
'wechat_friends_deleted' => [
|
||||
'command' => 'wechatFriends:list',
|
||||
'schedule' => '30 1 * * *', // 每天1:30
|
||||
'options' => ['--isDel=1'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_wechatFriends_deleted.log',
|
||||
],
|
||||
|
||||
// 每日 1:30 同步“已删除微信群聊”,用于统计与留痕
|
||||
'wechat_chatroom_deleted' => [
|
||||
'command' => 'wechatChatroom:list',
|
||||
'schedule' => '30 1 * * *', // 每天1:30
|
||||
'options' => ['--isDel=1'],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'crontab_wechatChatroom_deleted.log',
|
||||
],
|
||||
|
||||
// 每日 2:00 统一计算所有微信账号健康分(基础分 + 动态分)
|
||||
'wechat_calculate_score' => [
|
||||
'command' => 'wechat:calculate-score',
|
||||
'schedule' => '0 2 * * *', // 每天2点
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'calculate_score.log',
|
||||
],
|
||||
|
||||
// 每 3 天执行的全量任务
|
||||
|
||||
// 每 3 天 3:00 全量同步所有在线好友,做一次大规模校准
|
||||
'sync_all_friends' => [
|
||||
'command' => 'sync:allFriends',
|
||||
'schedule' => '0 3 */3 * *', // 每3天的3点
|
||||
'options' => [],
|
||||
'enabled' => true,
|
||||
'max_concurrent' => 1,
|
||||
'log_file' => 'all_friends.log',
|
||||
],
|
||||
|
||||
// 已禁用的任务(注释掉的任务)
|
||||
// 'workbench_group_push' => [
|
||||
// 'command' => 'workbench:groupPush',
|
||||
// 'schedule' => '*/2 * * * *',
|
||||
// 'options' => [],
|
||||
// 'enabled' => false,
|
||||
// 'log_file' => 'workbench_groupPush.log',
|
||||
// ],
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
<>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user