refactor(profile): 重构用户设置页面为个人资料页面

将用户设置功能从/mine/setting迁移到/profile路径
删除不再使用的mine模块相关代码
更新路由配置以指向新的个人资料页面
This commit is contained in:
超级老白兔
2025-09-11 17:20:15 +08:00
parent 1977aa50c2
commit dd2f58dc78
63 changed files with 2 additions and 12216 deletions

View File

@@ -1,31 +0,0 @@
import request from "@/api/request";
// 设备统计
export function getDeviceStats() {
return request("/v1/dashboard/device-stats", {}, "GET");
}
// 微信号统计
export function getWechatStats() {
return request("/v1/dashboard/wechat-stats", {}, "GET");
}
// 今日数据统计
export function getTodayStats() {
return request("/v1/dashboard/today-stats", {}, "GET");
}
// 首页仪表盘总览
export function getDashboard() {
return request("/v1/dashboard", {}, "GET");
}
// 获客场景统计
export function getPlanStats(params: any) {
return request("/v1/dashboard/plan-stats", params, "GET");
}
// 近七天统计
export function getSevenDayStats() {
return request("/v1/dashboard/sevenDay-stats", {}, "GET");
}

View File

@@ -1,356 +0,0 @@
.home-page {
padding: 12px;
}
.content-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
// 导航栏样式
.nav-title {
display: flex;
width: 100%;
justify-content: center;
}
.nav-text {
color: var(--primary-color);
font-weight: 700;
font-size: 18px;
text-shadow: 0 2px 4px rgba(24, 142, 238, 0.2);
}
.nav-right {
display: flex;
align-items: center;
gap: 8px;
}
.error-tip {
font-size: 12px;
color: #f97316;
background: #fef3c7;
padding: 4px 8px;
border-radius: 4px;
margin-right: 8px;
}
.nav-button {
padding: 8px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #f3f4f6;
}
}
// 统计卡片网格
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
}
.stat-label {
font-size: 14px;
color: #333;
line-height: 1.2;
font-weight: bold;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #3b82f6;
line-height: 1.2;
display: flex;
align-items: center;
justify-content: space-between;
}
.progress-bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
border-radius: 2px;
transition: width 0.3s ease;
}
// Loading状态样式
.stat-card {
.stat-label:empty::before {
content: "";
display: block;
width: 60px;
height: 12px;
background: #f0f0f0;
border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
.stat-value {
span:empty::before {
content: "";
display: block;
width: 40px;
height: 20px;
background: #f0f0f0;
border-radius: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
div:empty::before {
content: "";
display: block;
width: 20px;
height: 20px;
background: #f0f0f0;
border-radius: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// 通用区域样式
.section {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.section-header {
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
position: relative;
padding-left: 8px;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 14px;
background: var(--primary-gradient);
border-radius: 2px;
}
}
// 场景统计网格
.scene-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.scene-item {
text-align: center;
padding: 8px 4px;
}
.scene-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.scene-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.scene-value {
font-size: 16px;
font-weight: 700;
color: #333;
margin-bottom: 2px;
line-height: 1.2;
}
.scene-label {
font-size: 10px;
color: #666;
line-height: 1.2;
}
// 今日数据网格
.today-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.today-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: #f1f5f9;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.today-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: white;
border-radius: 50%;
flex-shrink: 0;
}
.today-value {
font-size: 18px;
font-weight: 700;
color: #333;
line-height: 1.2;
}
.today-label {
font-size: 12px;
color: #666;
line-height: 1.2;
}
// 图表容器
.chart-container {
width: 100%;
min-height: 160px;
border-radius: 8px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 12px;
}
// 响应式设计
@media (max-width: 375px) {
.home-page {
padding: 8px;
}
.stats-grid {
gap: 6px;
margin-bottom: 12px;
}
.stat-card {
padding: 10px 6px;
}
.stat-icon {
width: 28px;
height: 28px;
}
.stat-value {
font-size: 16px;
}
.stat-label {
font-size: 10px;
}
.section {
padding: 12px;
margin-bottom: 8px;
}
.scene-grid,
.today-grid {
gap: 6px;
}
.scene-icon {
width: 32px;
height: 32px;
}
.scene-value {
font-size: 14px;
}
.scene-label {
font-size: 9px;
}
.today-value {
font-size: 12px;
}
.today-label {
font-size: 9px;
}
.chart-container {
min-height: 140px;
padding: 8px;
}
}

View File

@@ -1,262 +0,0 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import NavCommon from "@/components/NavCommon";
import {
MobileOutlined,
MessageOutlined,
TeamOutlined,
RiseOutlined,
LineChartOutlined,
} from "@ant-design/icons";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import Layout from "@/components/Layout/Layout";
import LineChart from "@/components/LineChart";
import {
getPlanStats,
getSevenDayStats,
getTodayStats,
getDashboard,
} from "./api";
import style from "./index.module.scss";
import UpdateNotification from "@/components/UpdateNotification";
interface DashboardData {
deviceNum?: number;
wechatNum?: number;
aliveWechatNum?: number;
}
interface SevenDayStatsData {
date?: string[];
allNum?: number[];
}
const Home: React.FC = () => {
const navigate = useNavigate();
const [sceneStats, setSceneStats] = useState<any[]>([]);
const [todayStats, setTodayStats] = useState<any[]>([]);
const [dashboard, setDashboard] = useState<DashboardData>({});
const [sevenDayStats, setSevenDayStats] = useState<SevenDayStatsData>({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// 并行请求多个接口
const [dashboardResult, planStatsResult, sevenDayResult, todayResult] =
await Promise.allSettled([
getDashboard(),
getPlanStats({ num: 4 }),
getSevenDayStats(),
getTodayStats(),
]);
// 处理仪表板数据
if (dashboardResult.status === "fulfilled") {
setDashboard(dashboardResult.value);
} else {
console.warn("仪表板API失败:", dashboardResult.reason);
}
// 处理计划统计数据
if (planStatsResult.status === "fulfilled") {
setSceneStats(planStatsResult.value);
} else {
console.warn("计划统计API失败:", planStatsResult.reason);
}
// 处理七天统计数据
if (sevenDayResult.status === "fulfilled") {
setSevenDayStats(sevenDayResult.value);
} else {
console.warn("七天统计API失败:", sevenDayResult.reason);
}
// 处理今日统计数据
if (todayResult.status === "fulfilled") {
const todayStatsData = [
{
label: "同步朋友圈",
value: todayResult.value?.momentsNum || 0,
icon: (
<MessageOutlined style={{ fontSize: 16, color: "#8b5cf6" }} />
),
color: "#8b5cf6",
path: "/workspace/moments-sync",
},
{
label: "群发任务",
value: todayResult.value?.groupPushNum || 0,
icon: <TeamOutlined style={{ fontSize: 16, color: "#f97316" }} />,
color: "#f97316",
path: "/workspace/group-push",
},
{
label: "获客转化率",
value: todayResult.value?.passRate || "0%",
icon: <RiseOutlined style={{ fontSize: 16, color: "#22c55e" }} />,
color: "#22c55e",
path: "/scenarios",
},
{
label: "系统活跃度",
value: todayResult.value?.sysActive || "0%",
icon: (
<LineChartOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
),
color: "#3b82f6",
path: "/workspace",
},
];
setTodayStats(todayStatsData);
} else {
console.warn("今日统计API失败:", todayResult.reason);
}
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleDevicesClick = () => {
navigate("/mine/devices");
};
const handleWechatClick = () => {
navigate("/wechat-accounts");
};
const handleAliveWechatClick = () => {
navigate("/wechat-accounts?wechatStatus=1");
};
return (
<Layout
header={<NavCommon left={<></>} title="存客宝" />}
footer={<MeauMobile activeKey="home" />}
loading={isLoading}
>
<div className={style["home-page"]}>
<div className={style["content-wrapper"]}>
{/* 统计卡片 */}
<div className={style["stats-grid"]}>
<div className={style["stat-card"]} onClick={handleDevicesClick}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
<span>{dashboard.deviceNum || 42}</span>
<MobileOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
</div>
<div className={style["stat-card"]} onClick={handleWechatClick}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
<span>{dashboard.wechatNum || 42}</span>
<TeamOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
</div>
<div
className={style["stat-card"]}
onClick={handleAliveWechatClick}
>
<div className={style["stat-label"]}>线</div>
<div className={style["stat-value"]}>
<span>{dashboard.aliveWechatNum || 35}</span>
<LineChartOutlined style={{ fontSize: 20, color: "#3b82f6" }} />
</div>
<div className={style["progress-bar"]}>
<div
className={style["progress-fill"]}
style={{
width: `${
(dashboard.wechatNum || 0) > 0
? ((dashboard.aliveWechatNum || 0) /
(dashboard.wechatNum || 1)) *
100
: 0
}%`,
}}
></div>
</div>
</div>
</div>
{/* 场景获客统计 */}
<div className={style["section"]}>
<div className={style["section-header"]}>
<h2 className={style["section-title"]}></h2>
</div>
<div className={style["scene-grid"]}>
{sceneStats.map(scenario => (
<div
key={scenario.id}
className={style["scene-item"]}
onClick={() =>
navigate(
`/scenarios/list/${scenario.id}/${encodeURIComponent(
scenario.name,
)}`,
)
}
>
<div className={style["scene-icon"]}>
<img
src={scenario.image || "/placeholder.svg"}
alt={scenario.name}
className={style["scene-image"]}
/>
</div>
<div className={style["scene-value"]}>{scenario.allNum}</div>
<div className={style["scene-label"]}>{scenario.name}</div>
</div>
))}
</div>
</div>
{/* 今日数据统计 */}
<div className={style["section"]}>
<div className={style["section-header"]}>
<h2 className={style["section-title"]}></h2>
</div>
<div className={style["today-grid"]}>
{todayStats.map((stat, index) => (
<div
key={index}
className={style["today-item"]}
onClick={() => stat.path && navigate(stat.path)}
>
<div className={style["today-icon"]}>{stat.icon}</div>
<div>
<div className={style["today-value"]}>{stat.value}</div>
<div className={style["today-label"]}>{stat.label}</div>
</div>
</div>
))}
</div>
</div>
{/* 趋势图表 - 保持原有实现 */}
<div className={style["section"]}>
<div className={style["section-header"]}>
<span className={style["section-title"]}></span>
</div>
<div className={style["chart-container"]}>
<LineChart
xData={sevenDayStats.date || []}
yData={sevenDayStats.allNum || []}
/>
</div>
</div>
</div>
</div>
<UpdateNotification position="top" autoReload={false} showToast={true} />
</Layout>
);
};
export default Home;

View File

@@ -1,26 +0,0 @@
import request from "@/api/request";
import {
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> {
return request("/v1/content/library/detail", { id }, "GET");
}
// 创建内容库
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
}
// 更新内容库
export function updateContentLibrary(
params: UpdateContentLibraryParams,
): Promise<any> {
const { id, ...data } = params;
return request(`/v1/content/library/update`, { id, ...data }, "POST");
}

View File

@@ -1,61 +0,0 @@
// 内容库表单数据类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

View File

@@ -1,140 +0,0 @@
.form-page {
background: #f7f8fa;
padding: 16px;
}
.form-main {
max-width: 420px;
margin: 0 auto;
padding: 16px 0 0 0;
}
.form-section {
margin-bottom: 18px;
}
.form-card {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
padding: 24px 18px 18px 18px;
background: #fff;
}
.form-label {
font-weight: 600;
font-size: 16px;
color: #222;
display: block;
margin-bottom: 6px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #222;
margin-top: 28px;
margin-bottom: 12px;
letter-spacing: 0.5px;
}
.section-block {
padding: 12px 0 8px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.tabs-bar {
.adm-tabs-header {
background: #f7f8fa;
border-radius: 8px;
margin-bottom: 8px;
}
.adm-tabs-tab {
font-size: 15px;
font-weight: 500;
padding: 8px 0;
}
}
.collapse {
margin-top: 12px;
.adm-collapse-panel-content {
padding-bottom: 8px;
background: #f8fafc;
border-radius: 10px;
padding: 18px 14px 10px 14px;
margin-top: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.form-section {
margin-bottom: 22px;
}
.form-label {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.adm-input {
min-height: 42px;
font-size: 15px;
border-radius: 7px;
margin-bottom: 2px;
}
}
.ai-row,
.section-block {
display: flex;
align-items: center;
gap: 12px;
}
.ai-desc {
color: #888;
font-size: 13px;
flex: 1;
}
.date-row,
.section-block {
display: flex;
gap: 12px;
align-items: center;
}
.adm-input {
min-height: 44px;
font-size: 15px;
border-radius: 8px;
}
.submit-btn {
margin-top: 32px;
height: 48px !important;
border-radius: 10px !important;
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
}
@media (max-width: 600px) {
.form-main {
max-width: 100vw;
padding: 0;
}
.form-card {
border-radius: 0;
box-shadow: none;
padding: 16px 6px 12px 6px;
}
.section-title {
font-size: 15px;
margin-top: 22px;
margin-bottom: 8px;
}
.submit-btn {
height: 44px !important;
font-size: 15px;
}
}

View File

@@ -1,372 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Input as AntdInput, Switch } from "antd";
import { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
import NavCommon from "@/components/NavCommon";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
import { getContentLibraryDetail, updateContentLibrary } from "./api";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
const { TextArea } = AntdInput;
function formatDate(date: Date | null) {
if (!date) return "";
// 格式化为 YYYY-MM-DD
const y = date.getFullYear();
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const d = date.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
export default function ContentForm() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = !!id;
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
const [name, setName] = useState("");
const [friendsGroups, setSelectedFriends] = useState<string[]>([]);
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
>([]);
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [selectedGroupsOptions, setSelectedGroupsOptions] = useState<
GroupSelectionItem[]
>([]);
const [useAI, setUseAI] = useState(false);
const [aiPrompt, setAIPrompt] = useState("");
const [enabled, setEnabled] = useState(true);
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
null,
null,
]);
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const [keywordsInclude, setKeywordsInclude] = useState("");
const [keywordsExclude, setKeywordsExclude] = useState("");
const [catchType, setCatchType] = useState<string[]>([
"text",
"image",
"video",
]);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(false);
// 编辑模式下拉详情并回填
useEffect(() => {
if (isEdit && id) {
setLoading(true);
getContentLibraryDetail(id)
.then(data => {
setName(data.name || "");
setSourceType(data.sourceType === 1 ? "friends" : "groups");
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
setSelectedFriendsOptions(data.friendsGroupsOptions || []);
setKeywordsInclude((data.keywordInclude || []).join(","));
setKeywordsExclude((data.keywordExclude || []).join(","));
setCatchType(data.catchType || ["text", "image", "video"]);
setAIPrompt(data.aiPrompt || "");
setUseAI(!!data.aiPrompt);
setEnabled(data.status === 1);
// 时间范围
const start = data.timeStart || data.startTime;
const end = data.timeEnd || data.endTime;
setDateRange([
start ? new Date(start) : null,
end ? new Date(end) : null,
]);
})
.catch(e => {
Toast.show({
content: e?.message || "获取详情失败",
position: "top",
});
})
.finally(() => setLoading(false));
}
}, [isEdit, id]);
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
Toast.show({ content: "请输入内容库名称", position: "top" });
return;
}
setSubmitting(true);
try {
const payload = {
name,
sourceType: sourceType === "friends" ? 1 : 2,
friendsGroups: friendsGroups,
wechatGroups: selectedGroups,
groupMembers: {},
keywordInclude: keywordsInclude
.split(/,||\n|\s+/)
.map(s => s.trim())
.filter(Boolean),
keywordExclude: keywordsExclude
.split(/,||\n|\s+/)
.map(s => s.trim())
.filter(Boolean),
catchType,
aiPrompt,
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
status: enabled ? 1 : 0,
};
if (isEdit && id) {
await updateContentLibrary({ id, ...payload });
Toast.show({ content: "保存成功", position: "top" });
} else {
await request("/v1/content/library/create", payload, "POST");
Toast.show({ content: "创建成功", position: "top" });
}
navigate("/mine/content");
} catch (e: any) {
Toast.show({
content: e?.message || (isEdit ? "保存失败" : "创建失败"),
position: "top",
});
} finally {
setSubmitting(false);
}
};
const handleGroupsChange = (groups: GroupSelectionItem[]) => {
setSelectedGroups(groups.map(g => g.id.toString()));
setSelectedGroupsOptions(groups);
};
const handleFriendsChange = (friends: FriendSelectionItem[]) => {
setSelectedFriends(friends.map(f => f.id.toString()));
setSelectedFriendsOptions(friends);
};
return (
<Layout
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
footer={
<div style={{ padding: "16px", backgroundColor: "#fff" }}>
<Button
block
color="primary"
loading={submitting || loading}
disabled={submitting || loading}
onClick={handleSubmit}
>
{isEdit
? submitting
? "保存中..."
: "保存内容库"
: submitting
? "创建中..."
: "创建内容库"}
</Button>
</div>
}
>
<div className={style["form-page"]}>
<form
className={style["form-main"]}
onSubmit={e => e.preventDefault()}
autoComplete="off"
>
<div className={style["form-section"]}>
<label className={style["form-label"]}>
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
</label>
<AntdInput
placeholder="请输入内容库名称"
value={name}
onChange={e => setName(e.target.value)}
className={style["input"]}
/>
</div>
<div className={style["section-title"]}></div>
<div className={style["form-section"]}>
<Tabs
activeKey={sourceType}
onChange={key => setSourceType(key as "friends" | "groups")}
className={style["tabs-bar"]}
>
<Tabs.Tab title="选择微信好友" key="friends">
<FriendSelection
selectedOptions={friendsGroupsOptions}
onSelect={handleFriendsChange}
placeholder="选择微信好友"
/>
</Tabs.Tab>
<Tabs.Tab title="选择聊天群" key="groups">
<GroupSelection
selectedOptions={selectedGroupsOptions}
onSelect={handleGroupsChange}
placeholder="选择聊天群"
/>
</Tabs.Tab>
</Tabs>
</div>
<Collapse className={style["collapse"]}>
<Collapse.Panel
key="keywords"
title={<span className={style["form-label"]}></span>}
>
<div className={style["form-section"]}>
<label className={style["form-label"]}></label>
<TextArea
placeholder="多个关键词用逗号分隔"
value={keywordsInclude}
onChange={e => setKeywordsInclude(e.target.value)}
className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
<div className={style["form-section"]}>
<label className={style["form-label"]}></label>
<TextArea
placeholder="多个关键词用逗号分隔"
value={keywordsExclude}
onChange={e => setKeywordsExclude(e.target.value)}
className={style["input"]}
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</div>
</Collapse.Panel>
</Collapse>
{/* 采集内容类型 */}
<div className={style["section-title"]}></div>
<div className={style["form-section"]}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
{["text", "image", "video"].map(type => (
<div
key={type}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
border: "1px solid #d9d9d9",
borderRadius: "6px",
backgroundColor: catchType.includes(type)
? "#1890ff"
: "#fff",
color: catchType.includes(type) ? "#fff" : "#333",
cursor: "pointer",
}}
onClick={() => {
setCatchType(prev =>
prev.includes(type)
? prev.filter(t => t !== type)
: [...prev, type],
);
}}
>
<span>
{type === "text"
? "文本"
: type === "image"
? "图片"
: "视频"}
</span>
</div>
))}
</div>
</div>
<div className={style["section-title"]}>AI</div>
<div
className={style["form-section"]}
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<Switch checked={useAI} onChange={setUseAI} />
<span className={style["ai-desc"]}>
AI后AI生成
</span>
</div>
{useAI && (
<div className={style["form-section"]}>
<label className={style["form-label"]}>AI提示词</label>
<AntdInput
placeholder="请输入AI提示词"
value={aiPrompt}
onChange={e => setAIPrompt(e.target.value)}
className={style["input"]}
/>
</div>
)}
<div className={style["section-title"]}></div>
<div
className={style["form-section"]}
style={{ display: "flex", gap: 12 }}
>
<label></label>
<div style={{ flex: 1 }}>
<AntdInput
readOnly
value={dateRange[0] ? dateRange[0].toLocaleDateString() : ""}
placeholder="年/月/日"
className={style["input"]}
onClick={() => setShowStartPicker(true)}
/>
<DatePicker
visible={showStartPicker}
title="开始时间"
value={dateRange[0]}
onClose={() => setShowStartPicker(false)}
onConfirm={val => {
setDateRange([val, dateRange[1]]);
setShowStartPicker(false);
}}
/>
</div>
<label></label>
<div style={{ flex: 1 }}>
<AntdInput
readOnly
value={dateRange[1] ? dateRange[1].toLocaleDateString() : ""}
placeholder="年/月/日"
className={style["input"]}
onClick={() => setShowEndPicker(true)}
/>
<DatePicker
visible={showEndPicker}
title="结束时间"
value={dateRange[1]}
onClose={() => setShowEndPicker(false)}
onConfirm={val => {
setDateRange([dateRange[0], val]);
setShowEndPicker(false);
}}
/>
</div>
</div>
<div
className={style["section-title"]}
style={{
marginTop: 24,
marginBottom: 8,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch checked={enabled} onChange={setEnabled} />
</div>
</form>
</div>
</Layout>
);
}

View File

@@ -1,49 +0,0 @@
import request from "@/api/request";
import {
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库列表
export function getContentLibraryList(params: {
page?: number;
limit?: number;
keyword?: string;
sourceType?: number;
}): Promise<any> {
return request("/v1/content/library/list", params, "GET");
}
// 获取内容库详情
export function getContentLibraryDetail(id: string): Promise<any> {
return request("/v1/content/library/detail", { id }, "GET");
}
// 创建内容库
export function createContentLibrary(
params: CreateContentLibraryParams,
): Promise<any> {
return request("/v1/content/library/create", params, "POST");
}
// 更新内容库
export function updateContentLibrary(
params: UpdateContentLibraryParams,
): Promise<any> {
const { id, ...data } = params;
return request(`/v1/content/library/update`, { id, ...data }, "POST");
}
// 删除内容库
export function deleteContentLibrary(id: string): Promise<any> {
return request("/v1/content/library/delete", { id }, "DELETE");
}
// 切换内容库状态
export function toggleContentLibraryStatus(
id: string,
status: number,
): Promise<any> {
return request("/v1/content/library/update-status", { id, status }, "POST");
}

View File

@@ -1,66 +0,0 @@
// 内容库接口类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface LibraryListResponse {
list: ContentLibrary[];
total: number;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

View File

@@ -1,217 +0,0 @@
.content-library-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
z-index: 1;
}
.search-input {
padding-left: 36px;
border-radius: 20px;
border: 1px solid #e0e0e0;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.create-btn {
border-radius: 20px;
padding: 0 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tabs {
flex: 1;
}
.library-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
color: #999;
margin-bottom: 20px;
font-size: 14px;
}
.empty-btn {
border-radius: 20px;
padding: 0 20px;
}
.library-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.library-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.library-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.menu-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #999;
border-radius: 4px;
&:hover {
background: #f5f5f5;
color: #666;
}
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: 4px;
margin-top: 4px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
color: #333;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background: #fff2f0;
}
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
align-items: center;
font-size: 13px;
}
.label {
color: #999;
min-width: 70px;
margin-right: 8px;
}
.value {
color: #333;
flex: 1;
}

View File

@@ -1,314 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
Toast,
SpinLoading,
Dialog,
Card,
Avatar,
Tag,
} from "antd-mobile";
import { Input } from "antd";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
MoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getContentLibraryList, deleteContentLibrary } from "./api";
import { ContentLibrary } from "./data";
import style from "./index.module.scss";
import { Tabs } from "antd-mobile";
// 卡片菜单组件
interface CardMenuProps {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
onViewMaterials: () => void;
}
const CardMenu: React.FC<CardMenuProps> = ({
onView,
onEdit,
onDelete,
onViewMaterials,
}) => {
const [open, setOpen] = useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen(v => !v)} className={style["menu-btn"]}>
<MoreOutlined />
</button>
{open && (
<div ref={menuRef} className={style["menu-dropdown"]}>
<div
onClick={() => {
onEdit();
setOpen(false);
}}
className={style["menu-item"]}
>
<EditOutlined />
</div>
<div
onClick={() => {
onDelete();
setOpen(false);
}}
className={`${style["menu-item"]} ${style["danger"]}`}
>
<DeleteOutlined />
</div>
<div
onClick={() => {
onViewMaterials();
setOpen(false);
}}
className={style["menu-item"]}
>
<EyeOutlined />
</div>
</div>
)}
</div>
);
};
const ContentLibraryList: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [loading, setLoading] = useState(false);
// 获取内容库列表
const fetchLibraries = useCallback(async () => {
setLoading(true);
try {
const response = await getContentLibraryList({
page: 1,
limit: 100,
keyword: searchQuery,
sourceType:
activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined,
});
setLibraries(response.list || []);
} catch (error: any) {
console.error("获取内容库列表失败:", error);
} finally {
setLoading(false);
}
}, [searchQuery, activeTab]);
useEffect(() => {
fetchLibraries();
}, [fetchLibraries]);
const handleCreateNew = () => {
navigate("/mine/content/new");
};
const handleEdit = (id: string) => {
navigate(`/mine/content/edit/${id}`);
};
const handleDelete = async (id: string) => {
const result = await Dialog.confirm({
content: "确定要删除这个内容库吗?",
confirmText: "删除",
cancelText: "取消",
});
if (result) {
try {
const response = await deleteContentLibrary(id);
if (response.code === 200) {
Toast.show({
content: "删除成功",
position: "top",
});
fetchLibraries();
} else {
Toast.show({
content: response.msg || "删除失败",
position: "top",
});
}
} catch (error: any) {
console.error("删除内容库失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
}
}
};
const handleViewMaterials = (id: string) => {
navigate(`/mine/content/materials/${id}`);
};
const handleRefresh = () => {
fetchLibraries();
};
const filteredLibraries = libraries.filter(
library =>
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<Layout
header={
<>
<NavCommon
title="内容库"
backFn={() => navigate("/mine")}
right={
<Button size="small" color="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
}
/>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索内容库"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
size="small"
onClick={handleRefresh}
loading={loading}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
{/* 标签页 */}
<div className={style["tabs"]}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.Tab title="全部" key="all" />
<Tabs.Tab title="微信好友" key="friends" />
<Tabs.Tab title="聊天群" key="groups" />
</Tabs>
</div>
</>
}
>
<div className={style["content-library-page"]}>
{/* 内容库列表 */}
<div className={style["library-list"]}>
{loading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : filteredLibraries.length === 0 ? (
<div className={style["empty-state"]}>
<div className={style["empty-icon"]}>📚</div>
<div className={style["empty-text"]}>
</div>
<Button
color="primary"
size="small"
onClick={handleCreateNew}
className={style["empty-btn"]}
>
</Button>
</div>
) : (
filteredLibraries.map(library => (
<Card key={library.id} className={style["library-card"]}>
<div className={style["card-header"]}>
<div className={style["library-info"]}>
<h3 className={style["library-name"]}>{library.name}</h3>
<Tag
color={library.status === 1 ? "success" : "default"}
className={style["status-tag"]}
>
{library.status === 1 ? "已启用" : "未启用"}
</Tag>
</div>
<CardMenu
onView={() => navigate(`/content/${library.id}`)}
onEdit={() => handleEdit(library.id)}
onDelete={() => handleDelete(library.id)}
onViewMaterials={() => handleViewMaterials(library.id)}
/>
</div>
<div className={style["card-content"]}>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.sourceType === 1 ? "微信好友" : "聊天群"}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.creatorName || "系统"}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.itemCount || 0}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{new Date(library.updateTime).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default ContentLibraryList;

View File

@@ -1,20 +0,0 @@
import request from "@/api/request";
// 获取素材详情
export function getContentItemDetail(id: string) {
return request("/v1/content/library/get-item-detail", { id }, "GET");
}
// 创建素材
export function createContentItem(params: any) {
return request("/v1/content/library/create-item", params, "POST");
}
// 更新素材
export function updateContentItem(params: any) {
return request(`/v1/content/library/update-item`, params, "POST");
}
// 获取内容库详情
export function getContentLibraryDetail(id: string) {
return request("/v1/content/library/detail", { id }, "GET");
}

View File

@@ -1,93 +0,0 @@
// 素材数据类型定义
export interface ContentItem {
id: number; // 修改为number类型
libraryId: number; // 修改为number类型
type?: string;
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
title: string;
content: string;
contentAi?: string;
contentData?: any;
snsId?: string | null;
msgId?: string | null;
wechatId?: string | null;
friendId?: string | null;
createMomentTime?: number;
createTime: string;
updateTime: string;
coverImage?: string;
resUrls?: string[];
urls?: any[];
location?: string | null;
lat?: string;
lng?: string;
status?: number;
isDel?: number;
delTime?: number;
wechatChatroomId?: string | null;
senderNickname?: string;
createMessageTime?: string | null;
comment?: string;
sendTime?: string; // 字符串格式的时间
sendTimes?: number;
contentTypeName?: string;
}
// 内容库类型
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 创建素材参数
export interface CreateContentItemParams {
libraryId: string;
title: string;
content: string;
contentType: number;
resUrls?: string[];
urls?: string[];
comment?: string;
sendTime?: string;
}
// 更新素材参数
export interface UpdateContentItemParams
extends Partial<CreateContentItemParams> {
id: string;
}

View File

@@ -1,160 +0,0 @@
.form-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
padding: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.form-item {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
.required {
color: #ff4d4f;
margin-right: 4px;
}
}
.form-input {
width: 100%;
height: 40px;
border-radius: 6px;
border: 1px solid #d9d9d9;
padding: 0 12px;
font-size: 14px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.form-select {
width: 100%;
border-radius: 6px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.form-textarea {
width: 100%;
border-radius: 6px;
border: 1px solid #d9d9d9;
padding: 8px 12px;
font-size: 14px;
resize: vertical;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.select-option {
display: flex;
align-items: center;
gap: 8px;
.anticon {
font-size: 16px;
color: #1677ff;
}
}
.form-actions {
display: flex;
gap: 12px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-top: 16px;
}
.back-btn {
flex: 1;
border-radius: 6px;
border: 1px solid #d9d9d9;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.submit-btn {
flex: 1;
border-radius: 6px;
}
// 覆盖 antd-mobile 的默认样式
:global {
.adm-form-item {
margin-bottom: 16px;
}
.adm-form-item-label {
font-size: 14px;
color: #333;
font-weight: 500;
}
.adm-input {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.adm-select {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
}

View File

@@ -1,403 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Toast, SpinLoading, Card } from "antd-mobile";
import { Input, Select } from "antd";
import {
ArrowLeftOutlined,
SaveOutlined,
PictureOutlined,
LinkOutlined,
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import {
getContentItemDetail,
createContentItem,
updateContentItem,
} from "./api";
import style from "./index.module.scss";
const { Option } = Select;
const { TextArea } = Input;
// 内容类型选项
const contentTypeOptions = [
{ value: 1, label: "图片", icon: <PictureOutlined /> },
{ value: 2, label: "链接", icon: <LinkOutlined /> },
{ value: 3, label: "视频", icon: <VideoCameraOutlined /> },
{ value: 4, label: "文本", icon: <FileTextOutlined /> },
{ value: 5, label: "小程序", icon: <AppstoreOutlined /> },
];
const MaterialForm: React.FC = () => {
const navigate = useNavigate();
const { id: libraryId, materialId } = useParams<{
id: string;
materialId: string;
}>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// 表单状态
const [contentType, setContentType] = useState<number>(4);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [comment, setComment] = useState("");
const [sendTime, setSendTime] = useState("");
const [resUrls, setResUrls] = useState<string[]>([]);
// 链接相关状态
const [linkDesc, setLinkDesc] = useState("");
const [linkImage, setLinkImage] = useState("");
const [linkUrl, setLinkUrl] = useState("");
// 小程序相关状态
const [appTitle, setAppTitle] = useState("");
const [appId, setAppId] = useState("");
const isEdit = !!materialId;
// 获取素材详情
const fetchMaterialDetail = useCallback(async () => {
if (!materialId) return;
setLoading(true);
try {
const response = await getContentItemDetail(materialId);
// 填充表单数据
setTitle(response.title || "");
setContent(response.content || "");
setContentType(response.contentType || 4);
setComment(response.comment || "");
// 处理时间格式 - sendTime是字符串格式需要转换为datetime-local格式
if (response.sendTime) {
// 将 "2025-07-28 16:11:00" 转换为 "2025-07-28T16:11"
const dateTime = new Date(response.sendTime);
setSendTime(dateTime.toISOString().slice(0, 16));
} else {
setSendTime("");
}
setResUrls(response.resUrls || []);
// 设置链接相关数据
if (response.urls && response.urls.length > 0) {
const firstUrl = response.urls[0];
if (typeof firstUrl === "object" && firstUrl !== null) {
setLinkDesc(firstUrl.desc || "");
setLinkImage(firstUrl.image || "");
setLinkUrl(firstUrl.url || "");
}
}
} catch (error: unknown) {
console.error("获取素材详情失败:", error);
} finally {
setLoading(false);
}
}, [materialId]);
useEffect(() => {
if (isEdit && materialId) {
fetchMaterialDetail();
}
}, [isEdit, materialId, fetchMaterialDetail]);
const handleSubmit = async () => {
if (!libraryId) return;
if (!content.trim()) {
Toast.show({
content: "请输入素材内容",
position: "top",
});
return;
}
setSaving(true);
try {
// 构建urls数据
let finalUrls: { desc: string; image: string; url: string }[] = [];
if (contentType === 2 && linkUrl) {
finalUrls = [
{
desc: linkDesc,
image: linkImage,
url: linkUrl,
},
];
}
const params = {
libraryId,
title,
content,
contentType,
comment,
sendTime: sendTime || "",
resUrls,
urls: finalUrls,
type: contentType,
};
if (isEdit) {
await updateContentItem({
id: materialId!,
...params,
});
} else {
await createContentItem(params);
}
// 直接使用返回数据无需判断code
Toast.show({
content: isEdit ? "更新成功" : "创建成功",
position: "top",
});
navigate(`/mine/content/materials/${libraryId}`);
} catch (error: unknown) {
console.error("保存素材失败:", error);
Toast.show({
content: error instanceof Error ? error.message : "请检查网络连接",
position: "top",
});
} finally {
setSaving(false);
}
};
const handleBack = () => {
navigate(`/mine/content/materials/${libraryId}`);
};
if (loading) {
return (
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
</Layout>
);
}
return (
<Layout
header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}
footer={
<div className={style["form-actions"]}>
<Button
fill="outline"
onClick={handleBack}
className={style["back-btn"]}
>
<ArrowLeftOutlined />
</Button>
<Button
color="primary"
onClick={handleSubmit}
loading={saving}
className={style["submit-btn"]}
>
<SaveOutlined />
{isEdit ? " 保存修改" : " 保存素材"}
</Button>
</div>
}
>
<div className={style["form-page"]}>
<div className={style["form"]}>
{/* 基础信息 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<Input
type="datetime-local"
value={sendTime}
onChange={e => setSendTime(e.target.value)}
placeholder="请选择发布时间"
className={style["form-input"]}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}>
<span className={style["required"]}>*</span>
</label>
<Select
value={contentType}
onChange={value => setContentType(value)}
placeholder="请选择类型"
className={style["form-select"]}
>
{contentTypeOptions.map(option => (
<Option key={option.value} value={option.value}>
<div className={style["select-option"]}>
{option.icon}
<span>{option.label}</span>
</div>
</Option>
))}
</Select>
</div>
</Card>
{/* 内容信息 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<div className={style["form-item"]}>
<label className={style["form-label"]}>
<span className={style["required"]}>*</span>
</label>
<TextArea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="请输入内容"
rows={6}
className={style["form-textarea"]}
/>
</div>
{/* 链接类型特有字段 */}
{contentType === 2 && (
<>
<div className={style["form-item"]}>
<label className={style["form-label"]}>
<span className={style["required"]}>*</span>
</label>
<Input
value={linkDesc}
onChange={e => setLinkDesc(e.target.value)}
placeholder="请输入描述"
className={style["form-input"]}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<UploadComponent
value={linkImage ? [linkImage] : []}
onChange={urls => setLinkImage(urls[0] || "")}
count={1}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}>
<span className={style["required"]}>*</span>
</label>
<Input
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="请输入链接地址"
className={style["form-input"]}
/>
</div>
</>
)}
{/* 视频类型特有字段 */}
{contentType === 3 && (
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<VideoUpload
value={resUrls[0] || ""}
onChange={url => setResUrls([url])}
/>
</div>
)}
</Card>
{/* 素材上传(仅图片类型和小程序类型) */}
{[1, 5].includes(contentType) && (
<Card className={style["form-card"]}>
<div className={style["card-title"]}>
(: {contentType})
</div>
{contentType === 1 && (
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div>
<UploadComponent
value={resUrls}
onChange={setResUrls}
count={9}
/>
</div>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
: {contentType}, : {resUrls.length}
</div>
</div>
)}
{contentType === 5 && (
<>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<Input
value={appTitle}
onChange={e => setAppTitle(e.target.value)}
placeholder="请输入小程序名称"
className={style["form-input"]}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}>AppID</label>
<Input
value={appId}
onChange={e => setAppId(e.target.value)}
placeholder="请输入AppID"
className={style["form-input"]}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<UploadComponent
value={resUrls}
onChange={setResUrls}
count={9}
/>
</div>
</>
)}
</Card>
)}
{/* 评论/备注 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}>/</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<TextArea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="请输入评论或备注"
rows={4}
className={style["form-textarea"]}
/>
</div>
</Card>
</div>
</div>
</Layout>
);
};
export default MaterialForm;

View File

@@ -1,37 +0,0 @@
import request from "@/api/request";
import {
GetContentItemListParams,
CreateContentItemParams,
UpdateContentItemParams,
} from "./data";
// 获取素材列表
export function getContentItemList(params: GetContentItemListParams) {
return request("/v1/content/library/item-list", params, "GET");
}
// 获取素材详情
export function getContentItemDetail(id: string) {
return request("/v1/content/item/detail", { id }, "GET");
}
// 创建素材
export function createContentItem(params: CreateContentItemParams) {
return request("/v1/content/item/create", params, "POST");
}
// 更新素材
export function updateContentItem(params: UpdateContentItemParams) {
const { id, ...data } = params;
return request(`/v1/content/item/update`, { id, ...data }, "POST");
}
// 删除素材
export function deleteContentItem(id: string) {
return request("/v1/content/library/delete-item", { id }, "DELETE");
}
// 获取内容库详情
export function getContentLibraryDetail(id: string) {
return request("/v1/content/library/detail", { id }, "GET");
}

View File

@@ -1,106 +0,0 @@
// 素材数据类型定义
export interface ContentItem {
id: number;
libraryId: number;
type: string;
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
title: string;
content: string;
contentAi?: string | null;
contentData?: string | null;
snsId?: string | null;
msgId?: string | null;
wechatId?: string | null;
friendId?: string | null;
createMomentTime: number;
createTime: string;
updateTime: string;
coverImage: string;
resUrls: string[];
urls: { desc: string; image: string; url: string }[];
location?: string | null;
lat: string;
lng: string;
status: number;
isDel: number;
delTime: number;
wechatChatroomId?: string | null;
senderNickname: string;
createMessageTime?: string | null;
comment: string;
sendTime: number;
sendTimes: number;
contentTypeName: string;
}
// 内容库类型
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface ItemListResponse {
list: ContentItem[];
total: number;
}
// 获取素材列表参数
export interface GetContentItemListParams {
libraryId: string;
page?: number;
limit?: number;
keyword?: string;
}
// 创建素材参数
export interface CreateContentItemParams {
libraryId: string;
title: string;
content: string;
contentType: number;
resUrls?: string[];
urls?: (string | { desc?: string; image?: string; url: string })[];
comment?: string;
sendTime?: string;
}
// 更新素材参数
export interface UpdateContentItemParams
extends Partial<CreateContentItemParams> {
id: string;
}

View File

@@ -1,615 +0,0 @@
.materials-page {
padding: 16px;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
z-index: 1;
}
.search-input {
padding-left: 36px;
border-radius: 20px;
border: 1px solid #e0e0e0;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.create-btn {
border-radius: 20px;
padding: 0 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.materials-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
color: #999;
margin-bottom: 20px;
font-size: 14px;
}
.empty-btn {
border-radius: 20px;
padding: 0 20px;
}
.material-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.avatar-section {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #e6f7ff;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-icon {
font-size: 24px;
color: #1677ff;
}
.header-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.creator-name {
font-size: 16px;
font-weight: 600;
color: #333;
line-height: 1.2;
}
.material-id {
background: #e6f7ff;
color: #1677ff;
font-size: 12px;
font-weight: 600;
border-radius: 12px;
padding: 2px 8px;
display: inline-block;
}
.material-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
line-height: 1.4;
}
.content-icon {
font-size: 16px;
color: #1677ff;
}
.type-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.menu-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #999;
border-radius: 4px;
&:hover {
background: #f5f5f5;
color: #666;
}
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: 4px;
margin-top: 4px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
color: #333;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background: #fff2f0;
}
}
}
.link-preview {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 16px;
border: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e9ecef;
border-color: #1677ff;
}
}
.link-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.link-content {
flex: 1;
min-width: 0;
}
.link-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-url {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-buttons {
display: flex;
margin-top: 16px;
justify-content: space-between;
}
.action-btn-group {
display: flex;
gap: 8px;
}
.action-btn {
border-radius: 6px;
font-size: 16px;
padding: 6px 12px;
border: 1px solid #d9d9d9;
background: white;
color: #333;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.delete-btn {
border-radius: 6px;
font-size: 16px;
padding: 6px 12px;
background: #ff4d4f;
border-color: #ff4d4f;
color: white;
&:hover {
background: #ff7875;
border-color: #ff7875;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
background: white;
border-top: 1px solid #f0f0f0;
}
// 内容类型标签样式
.content-type-tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid currentColor;
}
// 图片类型预览样式
.material-image-preview {
margin: 12px 0;
.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张及以上网格布局
&.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: #f5f5f5;
border-radius: 8px;
color: #666;
font-size: 12px;
font-weight: 500;
height: 100px;
}
}
}
.no-image {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 链接类型预览样式
.material-link-preview {
margin: 12px 0;
.link-card {
display: flex;
background: #e9f8ff;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #cde6ff;
&:hover {
background: #cde6ff;
}
.link-image {
width: 60px;
height: 60px;
margin-right: 12px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
}
.link-content {
flex: 1;
min-width: 0;
.link-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-url {
font-size: 12px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 视频类型预览样式
.material-video-preview {
margin: 12px 0;
.video-thumbnail {
video {
width: 100%;
max-height: 200px;
border-radius: 8px;
}
}
.no-video {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
background: #f5f5f5;
border-radius: 8px;
color: #999;
font-size: 14px;
}
}
// 文本类型预览样式
.material-text-preview {
margin: 12px 0;
.text-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
line-height: 1.6;
color: #333;
font-size: 14px;
}
}
// 小程序类型预览样式
.material-miniprogram-preview {
margin: 12px 0;
.miniprogram-card {
display: flex;
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
width: 100%;
img {
width: 60px;
height: 60px;
border-radius: 8px;
margin-right: 12px;
flex-shrink: 0;
object-fit: cover;
}
.miniprogram-info {
flex: 1;
min-width: 0;
.miniprogram-title {
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
// 图文类型预览样式
.material-article-preview {
margin: 12px 0;
.article-image {
margin-bottom: 12px;
img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
}
.article-content {
.article-title {
font-weight: 500;
color: #333;
margin-bottom: 8px;
font-size: 16px;
}
.article-text {
color: #666;
line-height: 1.6;
font-size: 14px;
}
}
}
// 默认预览样式
.material-default-preview {
margin: 12px 0;
.default-content {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
color: #333;
line-height: 1.6;
}
}

View File

@@ -1,409 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Toast, SpinLoading, Dialog, Card } from "antd-mobile";
import { Input, Pagination, Button } from "antd";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined,
BarChartOutlined,
PictureOutlined,
LinkOutlined,
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getContentItemList, deleteContentItem } from "./api";
import { ContentItem } from "./data";
import style from "./index.module.scss";
// 内容类型配置
const contentTypeConfig = {
1: { label: "图片", icon: PictureOutlined, color: "#52c41a" },
2: { label: "链接", icon: LinkOutlined, color: "#1890ff" },
3: { label: "视频", icon: VideoCameraOutlined, color: "#722ed1" },
4: { label: "文本", icon: FileTextOutlined, color: "#fa8c16" },
5: { label: "小程序", icon: AppstoreOutlined, color: "#eb2f96" },
6: { label: "图文", icon: PictureOutlined, color: "#13c2c2" },
};
const MaterialsList: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [materials, setMaterials] = useState<ContentItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;
// 获取素材列表
const fetchMaterials = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const response = await getContentItemList({
libraryId: id,
page: currentPage,
limit: pageSize,
keyword: searchQuery,
});
setMaterials(response.list || []);
setTotal(response.total || 0);
} catch (error: unknown) {
console.error("获取素材列表失败:", error);
Toast.show({
content: error instanceof Error ? error.message : "请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
}, [id, currentPage, searchQuery]);
useEffect(() => {
fetchMaterials();
}, [fetchMaterials]);
const handleCreateNew = () => {
navigate(`/mine/content/materials/new/${id}`);
};
const handleEdit = (materialId: number) => {
navigate(`/mine/content/materials/edit/${id}/${materialId}`);
};
const handleDelete = async (materialId: number) => {
const result = await Dialog.confirm({
content: "确定要删除这个素材吗?",
confirmText: "删除",
cancelText: "取消",
});
if (result) {
try {
await deleteContentItem(materialId.toString());
Toast.show({
content: "删除成功",
position: "top",
});
fetchMaterials();
} catch (error: unknown) {
console.error("删除素材失败:", error);
Toast.show({
content: error instanceof Error ? error.message : "请检查网络连接",
position: "top",
});
}
}
};
const handleView = (materialId: number) => {
// 可以跳转到素材详情页面或显示弹窗
console.log("查看素材:", materialId);
};
const handleRefresh = () => {
fetchMaterials();
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 渲染内容类型标签
const renderContentTypeTag = (contentType: number) => {
const config =
contentTypeConfig[contentType as keyof typeof contentTypeConfig];
if (!config) return null;
const IconComponent = config.icon;
return (
<div
className={style["content-type-tag"]}
style={{ backgroundColor: config.color + "20", color: config.color }}
>
<IconComponent style={{ fontSize: 12, marginRight: 4 }} />
{config.label}
</div>
);
};
// 渲染素材内容预览
const renderContentPreview = (material: ContentItem) => {
const { contentType, content, resUrls, urls, coverImage } = material;
switch (contentType) {
case 1: // 图片
return (
<div className={style["material-image-preview"]}>
{resUrls && resUrls.length > 0 ? (
<div
className={`${style["image-grid"]} ${
resUrls.length === 1
? style.single
: resUrls.length === 2
? style.double
: resUrls.length === 3
? style.triple
: resUrls.length === 4
? style.quad
: style.grid
}`}
>
{resUrls.slice(0, 9).map((url, index) => (
<img key={index} src={url} alt={`图片${index + 1}`} />
))}
{resUrls.length > 9 && (
<div className={style["image-more"]}>
+{resUrls.length - 9}
</div>
)}
</div>
) : coverImage ? (
<div className={`${style["image-grid"]} ${style.single}`}>
<img src={coverImage} alt="封面图" />
</div>
) : (
<div className={style["no-image"]}></div>
)}
</div>
);
case 2: // 链接
return (
<div className={style["material-link-preview"]}>
{urls && urls.length > 0 && (
<div
className={style["link-card"]}
onClick={() => {
window.open(urls[0].url, "_blank");
}}
>
{urls[0].image && (
<div className={style["link-image"]}>
<img src={urls[0].image} alt="链接预览" />
</div>
)}
<div className={style["link-content"]}>
<div className={style["link-title"]}>
{urls[0].desc || "链接"}
</div>
<div className={style["link-url"]}>{urls[0].url}</div>
</div>
</div>
)}
</div>
);
case 3: // 视频
return (
<div className={style["material-video-preview"]}>
{resUrls && resUrls.length > 0 ? (
<div className={style["video-thumbnail"]}>
<video src={resUrls[0]} controls />
</div>
) : (
<div className={style["no-video"]}></div>
)}
</div>
);
case 4: // 文本
return (
<div className={style["material-text-preview"]}>
<div className={style["text-content"]}>
{content.length > 100
? `${content.substring(0, 100)}...`
: content}
</div>
</div>
);
case 5: // 小程序
return (
<div className={style["material-miniprogram-preview"]}>
{resUrls && resUrls.length > 0 && (
<div className={style["miniprogram-card"]}>
<img src={resUrls[0]} alt="小程序封面" />
<div className={style["miniprogram-info"]}>
<div className={style["miniprogram-title"]}>
{material.title || "小程序"}
</div>
</div>
</div>
)}
</div>
);
case 6: // 图文
return (
<div className={style["material-article-preview"]}>
{coverImage && (
<div className={style["article-image"]}>
<img src={coverImage} alt="文章封面" />
</div>
)}
<div className={style["article-content"]}>
<div className={style["article-title"]}>
{material.title || "图文内容"}
</div>
<div className={style["article-text"]}>
{content.length > 80
? `${content.substring(0, 80)}...`
: content}
</div>
</div>
</div>
);
default:
return (
<div className={style["material-default-preview"]}>
<div className={style["default-content"]}>{content}</div>
</div>
);
}
};
return (
<Layout
header={
<>
<NavCommon
title="素材管理"
backFn={() => navigate("/mine/content")}
right={
<Button type="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
}
/>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索素材内容"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
onClick={handleRefresh}
loading={loading}
icon={<ReloadOutlined />}
size="large"
></Button>
</div>
</>
}
footer={
<div className={style["pagination-wrapper"]}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
onChange={handlePageChange}
showSizeChanger={false}
/>
</div>
}
loading={loading}
>
<div className={style["materials-page"]}>
{/* 素材列表 */}
<div className={style["materials-list"]}>
{loading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : materials.length === 0 ? (
<div className={style["empty-state"]}>
<div className={style["empty-icon"]}>📄</div>
<div className={style["empty-text"]}>
</div>
<Button
color="primary"
onClick={handleCreateNew}
className={style["empty-btn"]}
>
</Button>
</div>
) : (
<>
{materials.map(material => (
<Card key={material.id} className={style["material-card"]}>
{/* 顶部信息 */}
<div className={style["card-header"]}>
<div className={style["avatar-section"]}>
<div className={style["avatar"]}>
<UserOutlined className={style["avatar-icon"]} />
</div>
<div className={style["header-info"]}>
<span className={style["creator-name"]}>
{material.senderNickname || "系统创建"}
</span>
<span className={style["material-id"]}>
ID: {material.id}
</span>
</div>
</div>
{renderContentTypeTag(material.contentType)}
</div>
{/* 标题 */}
{material.contentType != 4 && (
<div className={style["card-title"]}>
{material.content}
</div>
)}
{/* 内容预览 */}
{renderContentPreview(material)}
{/* 操作按钮区 */}
<div className={style["action-buttons"]}>
<div className={style["action-btn-group"]}>
<Button
onClick={() => handleEdit(material.id)}
className={style["action-btn"]}
>
<EditOutlined />
</Button>
<Button
onClick={() => handleView(material.id)}
className={style["action-btn"]}
>
<BarChartOutlined />
AI改写
</Button>
</div>
<Button
color="danger"
onClick={() => handleDelete(material.id)}
className={style["delete-btn"]}
>
<DeleteOutlined />
</Button>
</div>
</Card>
))}
</>
)}
</div>
</div>
</Layout>
);
};
export default MaterialsList;

View File

@@ -1,392 +0,0 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
NavBar,
Tabs,
Switch,
Toast,
SpinLoading,
Button,
Avatar,
} from "antd-mobile";
import { SettingOutlined, RedoOutlined, UserOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import {
fetchDeviceDetail,
fetchDeviceRelatedAccounts,
fetchDeviceHandleLogs,
updateDeviceTaskConfig,
} from "./api";
import type { Device, WechatAccount, HandleLog } from "@/types/device";
import NavCommon from "@/components/NavCommon";
const DeviceDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [device, setDevice] = useState<Device | null>(null);
const [tab, setTab] = useState("info");
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
const [accountsLoading, setAccountsLoading] = useState(false);
const [logs, setLogs] = useState<HandleLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [featureSaving, setFeatureSaving] = useState<{ [k: string]: boolean }>(
{},
);
// 获取设备详情
const loadDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const res = await fetchDeviceDetail(id);
setDevice(res);
} catch (e: any) {
Toast.show({ content: e.message || "获取设备详情失败", position: "top" });
} finally {
setLoading(false);
}
}, [id]);
// 获取关联账号
const loadAccounts = useCallback(async () => {
if (!id) return;
setAccountsLoading(true);
try {
const res = await fetchDeviceRelatedAccounts(id);
setAccounts(Array.isArray(res.accounts) ? res.accounts : []);
} catch (e: any) {
Toast.show({ content: e.message || "获取关联账号失败", position: "top" });
} finally {
setAccountsLoading(false);
}
}, [id]);
// 获取操作日志
const loadLogs = useCallback(async () => {
if (!id) return;
setLogsLoading(true);
try {
const res = await fetchDeviceHandleLogs(id, 1, 20);
setLogs(Array.isArray(res.list) ? res.list : []);
} catch (e: any) {
Toast.show({ content: e.message || "获取操作日志失败", position: "top" });
} finally {
setLogsLoading(false);
}
}, [id]);
useEffect(() => {
loadDetail();
// eslint-disable-next-line
}, [id]);
useEffect(() => {
if (tab === "accounts") loadAccounts();
if (tab === "logs") loadLogs();
// eslint-disable-next-line
}, [tab]);
// 功能开关
const handleFeatureChange = async (
feature: keyof Device["features"],
checked: boolean,
) => {
if (!id) return;
setFeatureSaving(prev => ({ ...prev, [feature]: true }));
try {
await updateDeviceTaskConfig({ deviceId: id, [feature]: checked });
setDevice(prev =>
prev
? {
...prev,
features: { ...prev.features, [feature]: checked },
}
: prev,
);
Toast.show({
content: `${getFeatureName(feature)}${checked ? "开启" : "关闭"}`,
});
} catch (e: any) {
Toast.show({ content: e.message || "设置失败", position: "top" });
} finally {
setFeatureSaving(prev => ({ ...prev, [feature]: false }));
}
};
const getFeatureName = (feature: string) => {
const map: Record<string, string> = {
autoAddFriend: "自动加好友",
autoReply: "自动回复",
momentsSync: "朋友圈同步",
aiChat: "AI会话",
};
return map[feature] || feature;
};
return (
<Layout
header={
<>
<NavCommon title="设备详情" />
{/* 基本信息卡片 */}
{device && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
boxShadow: "0 1px 4px #eee",
margin: "0 12px",
}}
>
<div style={{ fontWeight: 600, fontSize: 18 }}>
{device.memo || "未命名设备"}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
IMEI: {device.imei}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
: {device.wechatId || "未绑定"}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
: {device.totalFriend ?? "-"}
</div>
<div
style={{
fontSize: 13,
color:
device.status === "online" || device.alive === 1
? "#52c41a"
: "#aaa",
marginTop: 4,
}}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</div>
</div>
)}
</>
}
loading={loading}
>
{!device ? (
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
<SpinLoading style={{ "--size": "32px" }} />
<div style={{ marginTop: 16 }}>...</div>
</div>
) : (
<div style={{ padding: 12 }}>
{/* 标签页 */}
<Tabs activeKey={tab} onChange={setTab} style={{ marginBottom: 12 }}>
<Tabs.Tab title="功能开关" key="info" />
<Tabs.Tab title="关联账号" key="accounts" />
<Tabs.Tab title="操作日志" key="logs" />
</Tabs>
{/* 功能开关 */}
{tab === "info" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
(f, index) => (
<div
key={`${f}-${index}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontWeight: 500 }}>{getFeatureName(f)}</div>
</div>
<Switch
checked={
!!device.features?.[f as keyof Device["features"]]
}
loading={!!featureSaving[f]}
onChange={checked =>
handleFeatureChange(
f as keyof Device["features"],
checked,
)
}
/>
</div>
),
)}
</div>
)}
{/* 关联账号 */}
{tab === "accounts" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
}}
>
{accountsLoading ? (
<div
style={{ textAlign: "center", color: "#888", padding: 32 }}
>
<SpinLoading />
</div>
) : accounts.length === 0 ? (
<div
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
>
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
{accounts.map((acc, index) => (
<div
key={`${acc.id}-${index}`}
style={{
display: "flex",
alignItems: "center",
gap: 12,
background: "#f7f8fa",
borderRadius: 8,
padding: 10,
}}
onClick={() => {
navigate(`/wechat-accounts/detail/${acc.wechatId}`);
}}
>
<Avatar
src={acc.avatar}
alt={acc.nickname}
style={{
width: 40,
height: 40,
background: "#eee",
}}
fallback={
<div
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
background:
"linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontSize: 16,
borderRadius: "50%",
}}
>
<UserOutlined />
</div>
}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{acc.nickname}</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {acc.wechatId}
</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {acc.totalFriend}
</div>
<div style={{ fontSize: 12, color: "#aaa" }}>
: {acc.lastActive}
</div>
</div>
<span
style={{
fontSize: 12,
color: acc.wechatAlive === 1 ? "#52c41a" : "#aaa",
}}
>
{acc.wechatAliveText}
</span>
</div>
))}
</div>
)}
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button size="small" onClick={loadAccounts}>
<RedoOutlined />
</Button>
</div>
</div>
)}
{/* 操作日志 */}
{tab === "logs" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
}}
>
{logsLoading ? (
<div
style={{ textAlign: "center", color: "#888", padding: 32 }}
>
<SpinLoading />
</div>
) : logs.length === 0 ? (
<div
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
>
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
{logs.map((log, index) => (
<div
key={`${log.id}-${index}`}
style={{
display: "flex",
flexDirection: "column",
gap: 2,
background: "#f7f8fa",
borderRadius: 8,
padding: 10,
}}
>
<div style={{ fontWeight: 500 }}>{log.content}</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {log.username} · {log.createTime}
</div>
</div>
))}
</div>
)}
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button size="small" onClick={loadLogs}>
<RedoOutlined />
</Button>
</div>
</div>
)}
</div>
)}
</Layout>
);
};
export default DeviceDetail;

View File

@@ -1,44 +0,0 @@
import request from "@/api/request";
// 获取设备列表
export const fetchDeviceList = (params: {
page?: number;
limit?: number;
keyword?: string;
}) => request("/v1/devices", params, "GET");
// 获取设备详情
export const fetchDeviceDetail = (id: string | number) =>
request(`/v1/devices/${id}`);
// 获取设备关联微信账号
export const fetchDeviceRelatedAccounts = (id: string | number) =>
request(`/v1/wechats/related-device/${id}`);
// 获取设备操作日志
export const fetchDeviceHandleLogs = (
id: string | number,
page = 1,
limit = 10,
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
// 更新设备任务配置
export const updateDeviceTaskConfig = (config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
}) => request("/v1/devices/task-config", config, "POST");
// 删除设备
export const deleteDevice = (id: number) =>
request(`/v1/devices/${id}`, undefined, "DELETE");
// 获取设备二维码
export const fetchDeviceQRCode = (accountId: string) =>
request("/v1/api/device/add", { accountId }, "POST");
// 通过IMEI添加设备
export const addDeviceByImei = (imei: string, name: string) =>
request("/v1/api/device/add-by-imei", { imei, name }, "POST");

View File

@@ -1,173 +0,0 @@
.deviceList {
display: flex;
flex-direction: column;
gap: 12px;
}
.deviceCard {
background: #fff;
border-radius: 12px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
&.selected {
border: 2px solid #1677ff;
}
&:not(.selected) {
border: 1px solid #f5f5f5;
}
}
.headerRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.checkboxContainer {
flex-shrink: 0;
}
.imeiText {
font-size: 13px;
color: #666;
font-family: monospace;
flex: 1;
}
.mainContent {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: #f8f9fa;
}
}
.avatar {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
flex-shrink: 0;
border-radius: 6px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatarText {
font-size: 18px;
color: #fff;
font-weight: 700;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.deviceName {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
&.online {
color: #52c41a;
background: #f6ffed;
border: 1px solid #b7eb8f;
}
&.offline {
color: #ff4d4f;
background: #fff2f0;
border: 1px solid #ffccc7;
}
}
.infoList {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoItem {
display: flex;
align-items: center;
gap: 8px;
}
.infoLabel {
font-size: 13px;
color: #666;
min-width: 50px;
}
.infoValue {
font-size: 13px;
color: #333;
&.friendCount {
font-weight: 500;
}
}
.arrowIcon {
color: #999;
font-size: 14px;
margin-left: auto;
transition: transform 0.2s ease;
}
.mainContent:hover .arrowIcon {
transform: translateX(3px);
color: #1677ff;
}
.paginationContainer {
padding: 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: center;
}

View File

@@ -1,442 +0,0 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import { Popup, Tabs, Toast, SpinLoading } from "antd-mobile";
import { Button, Input, Pagination, Checkbox } from "antd";
import { useNavigate } from "react-router-dom";
import { AddOutline, DeleteOutline } from "antd-mobile-icons";
import {
ReloadOutlined,
SearchOutlined,
QrcodeOutlined,
RightOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import {
fetchDeviceList,
fetchDeviceQRCode,
addDeviceByImei,
deleteDevice,
} from "./api";
import type { Device } from "@/types/device";
import { comfirm } from "@/utils/common";
import { useUserStore } from "@/store/module/user";
import NavCommon from "@/components/NavCommon";
import styles from "./index.module.scss";
const Devices: React.FC = () => {
// 设备列表相关
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<(string | number)[]>([]);
const observerRef = useRef<HTMLDivElement>(null);
const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页
// 添加设备弹窗
const [addVisible, setAddVisible] = useState(false);
const [addTab, setAddTab] = useState("scan");
const [qrLoading, setQrLoading] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [imei, setImei] = useState("");
const [name, setName] = useState("");
const [addLoading, setAddLoading] = useState(false);
// 删除弹窗
const [delVisible, setDelVisible] = useState(false);
const [delLoading, setDelLoading] = useState(false);
const navigate = useNavigate();
const { user } = useUserStore();
// 加载设备列表
const loadDevices = useCallback(
async (reset = false) => {
if (loading) return;
setLoading(true);
try {
const params: any = { page: reset ? 1 : page, limit: 20 };
if (search) params.keyword = search;
const res = await fetchDeviceList(params);
const list = Array.isArray(res.list) ? res.list : [];
setDevices(prev => (reset ? list : [...prev, ...list]));
setTotal(res.total || 0);
setHasMore(list.length === 20);
if (reset) setPage(1);
} catch (e) {
Toast.show({ content: "获取设备列表失败", position: "top" });
setHasMore(false); // 请求失败后不再继续请求
} finally {
setLoading(false);
}
},
[loading, search, page],
);
// 首次加载和搜索
useEffect(() => {
loadDevices(true);
// eslint-disable-next-line
}, [search]);
// 无限滚动
useEffect(() => {
if (!hasMore || loading) return;
const observer = new window.IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !loading) {
setPage(p => p + 1);
}
},
{ threshold: 0.5 },
);
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasMore, loading]);
// 分页加载
useEffect(() => {
if (page === 1) return;
loadDevices();
// eslint-disable-next-line
}, [page]);
// 状态筛选
const filtered = devices.filter(d => {
if (status === "all") return true;
if (status === "online") return d.status === "online" || d.alive === 1;
if (status === "offline") return d.status === "offline" || d.alive === 0;
return true;
});
// 获取二维码
const handleGetQr = async () => {
setQrLoading(true);
setQrCode(null);
try {
const accountId = user.s2_accountId;
if (!accountId) throw new Error("未获取到用户信息");
const res = await fetchDeviceQRCode(accountId);
setQrCode(res.qrCode);
} catch (e: any) {
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
} finally {
setQrLoading(false);
}
};
const addDevice = async () => {
await handleGetQr();
setAddVisible(true);
};
// 手动添加设备
const handleAddDevice = async () => {
if (!imei.trim() || !name.trim()) {
Toast.show({ content: "请填写完整信息", position: "top" });
return;
}
setAddLoading(true);
try {
await addDeviceByImei(imei, name);
Toast.show({ content: "添加成功", position: "top" });
setAddVisible(false);
setImei("");
setName("");
loadDevices(true);
} catch (e: any) {
Toast.show({ content: e.message || "添加失败", position: "top" });
} finally {
setAddLoading(false);
}
};
// 删除设备
const handleDelete = async () => {
setDelLoading(true);
try {
for (const id of selected) {
await deleteDevice(Number(id));
}
Toast.show({ content: `删除成功`, position: "top" });
setSelected([]);
loadDevices(true);
} catch (e: any) {
if (e) Toast.show({ content: e.message || "删除失败", position: "top" });
} finally {
setDelLoading(false);
}
};
// 删除按钮点击
const handleDeleteClick = async () => {
try {
await comfirm(
`将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`,
{ title: "确认删除", confirmText: "确认删除", cancelText: "取消" },
);
handleDelete();
} catch {
// 用户取消,无需处理
}
};
// 跳转详情
const goDetail = (id: string | number) => {
navigate(`/mine/devices/${id}`);
};
// 分页切换
const handlePageChange = (p: number) => {
setPage(p);
loadDevices(true);
};
return (
<Layout
header={
<>
<NavCommon
title="设备管理"
right={
<Button size="small" type="primary" onClick={() => addDevice()}>
<AddOutline />
</Button>
}
/>
<div style={{ padding: "12px 12px 0 12px", background: "#fff" }}>
{/* 搜索栏 */}
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<Input
placeholder="搜索设备IMEI/备注"
value={search}
onChange={e => setSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
style={{ flex: 1 }}
/>
<Button
onClick={() => loadDevices(true)}
icon={<ReloadOutlined />}
>
</Button>
</div>
{/* 筛选和删除 */}
<div style={{ display: "flex", gap: 8 }}>
<Tabs
activeKey={status}
onChange={k => setStatus(k as any)}
style={{ flex: 1 }}
>
<Tabs.Tab title="全部" key="all" />
<Tabs.Tab title="在线" key="online" />
<Tabs.Tab title="离线" key="offline" />
</Tabs>
<div style={{ paddingTop: 8 }}>
<Button
size="small"
type="primary"
danger
icon={<DeleteOutline />}
disabled={selected.length === 0}
onClick={handleDeleteClick}
>
</Button>
</div>
</div>
</div>
</>
}
footer={
<div className={styles.paginationContainer}>
<Pagination
current={page}
pageSize={20}
total={total}
showSizeChanger={false}
onChange={handlePageChange}
/>
</div>
}
loading={loading && devices.length === 0}
>
<div style={{ padding: 12 }}>
{/* 设备列表 */}
<div className={styles.deviceList}>
{filtered.map(device => (
<div key={device.id} className={styles.deviceCard}>
{/* 顶部行选择框和IMEI */}
<div className={styles.headerRow}>
<div className={styles.checkboxContainer}>
<Checkbox
checked={selected.includes(device.id)}
onChange={e => {
e.stopPropagation();
setSelected(prev =>
e.target.checked
? [...prev, device.id!]
: prev.filter(id => id !== device.id),
);
}}
onClick={e => e.stopPropagation()}
/>
</div>
<span className={styles.imeiText}>
IMEI: {device.imei?.toUpperCase()}
</span>
</div>
{/* 主要内容区域:头像和详细信息 */}
<div className={styles.mainContent}>
{/* 头像 */}
<div className={styles.avatar}>
{device.avatar ? (
<img src={device.avatar} alt="头像" />
) : (
<span className={styles.avatarText}>
{(device.memo || device.wechatId || "设")[0]}
</span>
)}
</div>
{/* 设备信息 */}
<div className={styles.deviceInfo}>
<div className={styles.deviceHeader}>
<h3 className={styles.deviceName}>
{device.memo || "未命名设备"}
</h3>
<span
className={`${styles.statusBadge} ${
device.status === "online" || device.alive === 1
? styles.online
: styles.offline
}`}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</span>
</div>
<div className={styles.infoList}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>:</span>
<span className={styles.infoValue}>
{device.wechatId || "未绑定"}
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>:</span>
<span
className={`${styles.infoValue} ${styles.friendCount}`}
>
{device.totalFriend ?? "-"}
</span>
</div>
</div>
</div>
{/* 箭头图标 */}
<RightOutlined
className={styles.arrowIcon}
onClick={() => goDetail(device.id!)}
/>
</div>
</div>
))}
{/* 无限滚动提示(仅在不分页时显示) */}
{!usePagination && (
<div
ref={observerRef}
style={{ padding: 12, textAlign: "center", color: "#888" }}
>
{loading && <SpinLoading style={{ "--size": "24px" }} />}
{!hasMore && devices.length > 0 && "没有更多设备了"}
{!hasMore && devices.length === 0 && "暂无设备"}
</div>
)}
</div>
</div>
{/* 添加设备弹窗 */}
<Popup
visible={addVisible}
onMaskClick={() => setAddVisible(false)}
bodyStyle={{
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
minHeight: 320,
}}
>
<div style={{ padding: 20 }}>
<Tabs
activeKey={addTab}
onChange={setAddTab}
style={{ marginBottom: 16 }}
>
<Tabs.Tab title="扫码添加" key="scan" />
</Tabs>
{addTab === "scan" && (
<div style={{ textAlign: "center", minHeight: 200 }}>
<Button
type="primary"
onClick={handleGetQr}
loading={qrLoading}
icon={<QrcodeOutlined />}
>
</Button>
{qrCode && (
<div style={{ marginTop: 16 }}>
<img
src={qrCode}
alt="二维码"
style={{
width: 180,
height: 180,
background: "#f5f5f5",
borderRadius: 8,
margin: "0 auto",
}}
/>
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
</div>
</div>
)}
</div>
)}
{addTab === "manual" && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Input
placeholder="设备名称"
value={name}
onChange={e => setName(e.target.value)}
allowClear
/>
<Input
placeholder="设备IMEI"
value={imei}
onChange={e => setImei(e.target.value)}
allowClear
/>
<Button
color="primary"
onClick={handleAddDevice}
loading={addLoading}
>
</Button>
</div>
)}
</div>
</Popup>
</Layout>
);
};
export default Devices;

View File

@@ -1,9 +0,0 @@
import request from "@/api/request";
// 首页仪表盘总览
export function getDashboard() {
return request("/v1/dashboard", {}, "GET");
}
// 用户信息统计
export function getUserInfoStats() {
return request("/v1/dashboard/userInfoStats", {}, "GET");
}

View File

@@ -1,210 +0,0 @@
.mine-page {
padding: 12px;
}
.user-card {
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin: 16px 0 12px 0;
padding: 0 0 0 0;
}
.user-info-row {
display: flex;
align-items: center;
padding: 20px 24px 16px 24px;
}
.user-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: #666;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
color: #1890ff;
margin-right: 18px;
overflow: hidden;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
color: #1890ff;
background: #e6f7ff;
border-radius: 50%;
}
.user-main-info {
flex: 1;
min-width: 0;
position: relative;
}
.user-main-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-name {
font-size: 20px;
font-weight: 700;
color: #222;
margin-right: 2px;
}
.role-badge {
background: #fa8c16;
color: #fff;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
padding: 2px 10px;
margin-right: 8px;
}
.balance-label {
color: #666;
font-size: 15px;
margin-right: 2px;
}
.balance-value {
color: #16b364;
font-size: 20px;
font-weight: 700;
margin-right: 4px;
}
.recharge-btn {
margin-right: 8px;
padding: 0 14px;
font-size: 14px;
height: 28px;
line-height: 28px;
border-radius: 8px;
}
.icon-setting {
font-size: 26px;
color: #666;
position: absolute;
right: 0px;
top: 0px;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: none;
font-size: 20px;
color: #666;
cursor: pointer;
margin-left: 2px;
}
.last-login {
color: #888;
font-size: 13px;
margin-top: 6px;
margin-left: 2px;
}
.menu-card {
margin-bottom: 16px;
border-radius: 12px;
:global(.adm-list-body) {
border: none;
}
:global(.adm-card-body) {
padding: 14px 0 0 0;
}
:global(.adm-list-body-inner) {
margin-top: 0px;
}
:global(.adm-list-item) {
padding: 0px;
:global(.adm-list-item-content) {
border: 1px solid #f0f0f0;
margin-bottom: 12px;
padding: 0 12px;
border-radius: 12px;
}
:global(.adm-list-item-content-prefix) {
margin-right: 12px;
color: var(--primary-color);
font-size: 20px;
}
:global(.adm-list-item-content-main) {
flex: 1;
}
:global(.adm-list-item-title) {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}
:global(.adm-list-item-description) {
font-size: 12px;
color: #666;
}
:global(.adm-list-item-content-arrow) {
color: #ccc;
}
}
}
.logout-btn {
border-radius: 8px;
height: 48px;
font-size: 16px;
font-weight: 500;
}
// 响应式设计
@media (max-width: 375px) {
.mine-page {
padding: 12px;
}
.user-info {
gap: 12px;
}
.user-avatar {
width: 50px;
height: 50px;
background: #666;
}
.user-name {
font-size: 16px;
}
.menu-card {
:global(.adm-list-item) {
padding: 12px;
:global(.adm-list-item-content-prefix) {
font-size: 18px;
}
:global(.adm-list-item-title) {
font-size: 14px;
}
}
}
}

View File

@@ -1,232 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Card, List, Button } from "antd-mobile";
import {
PhoneOutlined,
MessageOutlined,
DatabaseOutlined,
FolderOpenOutlined,
SettingOutlined,
} from "@ant-design/icons";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { useUserStore } from "@/store/module/user";
import { getDashboard, getUserInfoStats } from "./api";
import NavCommon from "@/components/NavCommon";
const Mine: React.FC = () => {
const navigate = useNavigate();
const { user } = useUserStore();
const [stats, setStats] = useState({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
balance: 0,
});
const [userInfoStats, setUserInfoStats] = useState({
contentLibraryNum: 0,
deviceNum: 0,
userNum: 0,
wechatNum: 0,
});
// 用户信息
const currentUserInfo = {
name: user?.username || "-",
email: user?.account || "-",
role: user?.isAdmin === 1 ? "管理员" : "普通用户",
lastLogin: user?.lastLoginTime
? new Date(user.lastLoginTime * 1000).toLocaleString()
: "-",
avatar: user?.avatar || "",
};
// 功能模块数据
const functionModules = [
{
id: "devices",
title: "设备管理",
description: "管理您的设备和微信账号",
icon: <PhoneOutlined />,
count: userInfoStats.deviceNum,
path: "/mine/devices",
bgColor: "#e6f7ff",
iconColor: "#1890ff",
},
{
id: "wechat",
title: "微信号管理",
description: "管理微信账号和好友",
icon: <MessageOutlined />,
count: userInfoStats.wechatNum,
path: "/wechat-accounts",
bgColor: "#f6ffed",
iconColor: "#52c41a",
},
{
id: "traffic",
title: "流量池",
description: "管理用户流量池和分组",
icon: <DatabaseOutlined />,
count: userInfoStats.userNum,
path: "/mine/traffic-pool",
bgColor: "#f9f0ff",
iconColor: "#722ed1",
},
{
id: "content",
title: "内容库",
description: "管理营销内容和素材",
icon: <FolderOpenOutlined />,
count: userInfoStats.contentLibraryNum,
path: "/mine/content",
bgColor: "#fff7e6",
iconColor: "#fa8c16",
},
{
id: "ckb",
title: "触客宝",
description: "触客宝",
icon: <PhoneOutlined />,
count: 0,
path: "/ckbox/weChat",
bgColor: "#fff7e6",
iconColor: "#fa8c16",
},
];
// 加载统计数据
const loadStats = async () => {
try {
const res = await getDashboard();
setStats({
devices: res.deviceNum,
wechat: res.wechatNum,
traffic: 999,
content: 999,
balance: res.balance || 0,
});
const res2 = await getUserInfoStats();
setUserInfoStats(res2);
} catch (error) {
console.error("加载统计数据失败:", error);
}
};
useEffect(() => {
loadStats();
}, []);
const handleFunctionClick = (path: string) => {
navigate(path);
};
// 渲染功能模块图标
const renderModuleIcon = (module: any) => (
<div
style={{
width: "40px",
height: "40px",
backgroundColor: module.bgColor,
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: module.iconColor,
fontSize: "20px",
}}
>
{module.icon}
</div>
);
return (
<Layout
header={<NavCommon title="我的" />}
footer={<MeauMobile activeKey="mine" />}
>
<div className={style["mine-page"]}>
{/* 用户信息卡片(严格按图片风格) */}
<Card className={style["user-card"]}>
<div className={style["user-info-row"]}>
{/* 头像 */}
<div className={style["user-avatar"]}>
{currentUserInfo.avatar ? (
<img src={currentUserInfo.avatar} />
) : (
<div className={style["avatar-placeholder"]}></div>
)}
</div>
{/* 右侧内容 */}
<div className={style["user-main-info"]}>
<div className={style["user-main-row"]}>
<span className={style["user-name"]}>
{currentUserInfo.name}
</span>
<span className={style["role-badge"]}>
{currentUserInfo.role}
</span>
<span className={style["icon-btn"]}>
<i className="iconfont icon-bell" />
</span>
</div>
<div>
<span className={style["balance-label"]}></span>
<span className={style["balance-value"]}>
{Number(stats.balance || 0).toFixed(2)}
</span>
<Button
size="small"
color="primary"
onClick={() => navigate("/recharge")}
>
</Button>
</div>
<div className={style["last-login"]}>
{currentUserInfo.lastLogin}
</div>
<SettingOutlined
className={style["icon-setting"]}
onClick={() => navigate("/settings")}
/>
</div>
</div>
</Card>
{/* 我的功能 */}
<Card className={style["menu-card"]}>
<List>
{functionModules.map(module => (
<List.Item
key={module.id}
prefix={renderModuleIcon(module)}
title={module.title}
description={module.description}
extra={
<span
style={{
padding: "2px 8px",
backgroundColor: "#f0f0f0",
borderRadius: "12px",
fontSize: "12px",
color: "#666",
}}
>
{module.count}
</span>
}
arrow
onClick={() => handleFunctionClick(module.path)}
/>
))}
</List>
</Card>
</div>
</Layout>
);
};
export default Mine;

View File

@@ -1,440 +0,0 @@
.recharge-page {
}
.record-btn {
color: var(--primary-color);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(24, 142, 238, 0.1);
}
&:active {
background-color: rgba(24, 142, 238, 0.2);
}
}
.recharge-tabs {
:global(.adm-tabs-header) {
background: #fff;
border-bottom: 1px solid #f0f0f0;
position: sticky;
top: 0;
z-index: 10;
}
:global(.adm-tabs-tab) {
font-size: 16px;
font-weight: 500;
}
:global(.adm-tabs-tab-active) {
color: var(--primary-color);
}
:global(.adm-tabs-tab-line) {
background: var(--primary-color);
}
}
.tab-content {
}
.balance-card {
margin-bottom: 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 12px;
padding: 18px 0 18px 0;
display: flex;
align-items: center;
.balance-content {
display: flex;
color: #16b364;
padding-left: 30px;
}
.wallet-icon {
color: #16b364;
font-size: 30px;
flex-shrink: 0;
}
.balance-info {
margin-left: 15px;
display: flex;
flex-direction: column;
justify-content: center;
}
.balance-label {
font-size: 14px;
font-weight: normal;
color: #666;
margin-bottom: 2px;
}
.balance-amount {
font-size: 24px;
font-weight: 700;
color: #16b364;
line-height: 1.1;
}
}
.quick-card {
margin-bottom: 16px;
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-start;
margin-bottom: 8px;
}
}
.desc-card {
margin: 16px 0px;
background: #fffbe6;
border: 1px solid #ffe58f;
}
.warn-card {
margin: 16px 0;
background: #fff2e8;
border: 1px solid #ffbb96;
}
.quick-title {
font-weight: 500;
margin-bottom: 8px;
font-size: 16px;
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
margin-bottom: 8px;
}
.quick-btn {
min-width: 80px;
margin: 4px 0;
font-size: 16px;
border-radius: 8px;
}
.quick-btn-active {
@extend .quick-btn;
font-weight: 600;
}
.recharge-main-btn {
margin-top: 16px;
font-size: 18px;
border-radius: 8px;
}
.desc-title {
font-weight: 500;
margin-bottom: 8px;
font-size: 16px;
}
.desc-text {
color: #666;
font-size: 14px;
}
.warn-content {
display: flex;
align-items: center;
gap: 8px;
color: #faad14;
font-size: 14px;
}
.warn-icon {
font-size: 30px;
color: #faad14;
flex-shrink: 0;
}
.warn-info {
display: flex;
flex-direction: column;
}
.warn-title {
font-weight: 600;
font-size: 15px;
}
.warn-text {
color: #faad14;
font-size: 14px;
}
// AI服务样式
.ai-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.ai-title {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 700;
color: #222;
}
.ai-icon {
font-size: 24px;
color: var(--primary-color);
margin-right: 8px;
}
.ai-tag {
background: #ff6b35;
color: #fff;
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
}
.ai-description {
color: #666;
font-size: 14px;
margin-bottom: 20px;
}
.ai-services {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-service-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.service-header {
margin-bottom: 12px;
}
.service-info {
display: flex;
align-items: center;
gap: 12px;
}
.service-icon {
font-size: 24px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 8px;
}
.service-details {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.service-name {
font-size: 16px;
font-weight: 600;
color: #222;
}
.service-price {
font-size: 16px;
font-weight: 700;
color: #ff4d4f;
}
.service-description {
color: #666;
font-size: 14px;
margin-bottom: 12px;
line-height: 1.5;
}
.service-features {
margin-bottom: 16px;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 14px;
color: #333;
}
.feature-check {
color: #52c41a;
font-weight: bold;
font-size: 16px;
}
.usage-progress {
margin-top: 12px;
}
.usage-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
border-radius: 3px;
transition: width 0.3s ease;
}
.usage-text {
font-size: 12px;
color: #999;
text-align: right;
}
// 版本套餐样式
.version-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 20px;
font-weight: 700;
color: #222;
}
.version-icon {
font-size: 24px;
color: #722ed1;
}
.version-description {
color: #666;
font-size: 14px;
margin-bottom: 20px;
}
.version-packages {
display: flex;
flex-direction: column;
gap: 16px;
}
.version-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
}
.package-header {
margin-bottom: 12px;
}
.package-info {
display: flex;
align-items: center;
gap: 12px;
}
.package-icon {
font-size: 24px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
border-radius: 8px;
}
.package-details {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.package-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #222;
}
.package-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.tag-blue {
background: #e6f7ff;
color: #1890ff;
}
.tag-green {
background: #f6ffed;
color: #52c41a;
}
.package-price {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
}
.package-description {
color: #666;
font-size: 14px;
margin-bottom: 12px;
line-height: 1.5;
}
.package-features {
margin-bottom: 16px;
}
.features-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.package-status {
text-align: center;
color: #52c41a;
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
}
.upgrade-btn {
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}

View File

@@ -1,371 +0,0 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, Button, Toast, Tabs } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
import style from "./index.module.scss";
import {
WalletOutlined,
WarningOutlined,
ClockCircleOutlined,
RobotOutlined,
CrownOutlined,
} from "@ant-design/icons";
import NavCommon from "@/components/NavCommon";
import Layout from "@/components/Layout/Layout";
const quickAmounts = [50, 100, 200, 500, 1000];
// AI服务套餐数据
const aiServicePackages = [
{
id: 1,
name: "入门套餐",
tag: "推荐",
tagColor: "blue",
description: "适合个人用户体验AI服务",
usage: "可使用AI服务约110次",
price: 100,
originalPrice: 110,
gift: 10,
actualAmount: 110,
},
{
id: 2,
name: "标准套餐",
tag: "热门",
tagColor: "green",
description: "适合小团队日常使用",
usage: "可使用AI服务约580次",
price: 500,
originalPrice: 580,
gift: 80,
actualAmount: 580,
},
];
// AI服务列表数据
const aiServices = [
{
id: 1,
name: "添加好友及打招呼",
icon: "💬",
price: 1,
description: "AI智能添加好友并发送个性化打招呼消息",
features: ["智能筛选目标用户", "发送个性化打招呼消息", "自动记录添加结果"],
usage: { current: 15, total: 450 },
},
{
id: 2,
name: "小室AI内容生产",
icon: "⚡",
price: 1,
description: "AI智能创建朋友圈内容,智能配文与朋友圈内容",
features: ["智能生成朋友圈文案", "AI配文智能文案", "内容智能排版优化"],
usage: { current: 28, total: 680 },
},
{
id: 3,
name: "智能分发服务",
icon: "📤",
price: 1,
description: "AI智能分发内容到多个平台",
features: ["多平台智能分发", "内容智能优化", "分发效果分析"],
usage: { current: 12, total: 300 },
},
];
// 版本套餐数据
const versionPackages = [
{
id: 1,
name: "普通版本",
icon: "📦",
price: "免费",
description: "充值即可使用,包含基础AI功能",
features: ["基础AI服务", "标准客服支持", "基础数据统计"],
status: "当前使用中",
buttonText: null,
tagColor: undefined,
},
{
id: 2,
name: "标准版本",
icon: "👑",
price: "¥98/月",
tag: "推荐",
tagColor: "blue",
description: "适合中小企业,AI功能更丰富",
features: ["高级AI服务", "优先客服支持", "详细数据分析", "API接口访问"],
status: null,
buttonText: "立即升级",
},
{
id: 3,
name: "企业版本",
icon: "🏢",
price: "¥1980/月",
description: "适合大型企业,提供专属服务",
features: [
"专属AI服务",
"24小时专属客服",
"高级数据分析",
"API接口访问",
"专属技术支持",
],
status: null,
buttonText: "立即升级",
tagColor: undefined,
},
];
const Recharge: React.FC = () => {
const navigate = useNavigate();
const { user } = useUserStore();
// 假设余额从后端接口获取实际可用props或store传递
const [balance, setBalance] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState("account");
// 充值操作
const handleRecharge = async () => {
if (!selected) {
Toast.show({ content: "请选择充值金额", position: "top" });
return;
}
setLoading(true);
setTimeout(() => {
setBalance(b => b + selected);
Toast.show({ content: `充值成功,已到账¥${selected}` });
setLoading(false);
}, 1200);
};
// 渲染账户充值tab内容
const renderAccountRecharge = () => (
<div className={style["tab-content"]}>
<Card className={style["balance-card"]}>
<div className={style["balance-content"]}>
<WalletOutlined className={style["wallet-icon"]} />
<div className={style["balance-info"]}>
<div className={style["balance-label"]}></div>
<div className={style["balance-amount"]}>
{balance.toFixed(2)}
</div>
</div>
</div>
</Card>
<Card className={style["quick-card"]}>
<div className={style["quick-title"]}></div>
<div className={style["quick-list"]}>
{quickAmounts.map(amt => (
<Button
key={amt}
color={selected === amt ? "primary" : "default"}
className={
selected === amt
? style["quick-btn-active"]
: style["quick-btn"]
}
onClick={() => setSelected(amt)}
>
{amt}
</Button>
))}
</div>
<Button
block
color="primary"
size="large"
className={style["recharge-main-btn"]}
loading={loading}
onClick={handleRecharge}
>
</Button>
</Card>
<Card className={style["desc-card"]}>
<div className={style["desc-title"]}></div>
<div className={style["desc-text"]}>
使
</div>
</Card>
{balance < 10 && (
<Card className={style["warn-card"]}>
<div className={style["warn-content"]}>
<WarningOutlined className={style["warn-icon"]} />
<div className={style["warn-info"]}>
<div className={style["warn-title"]}></div>
<div className={style["warn-text"]}>
使
</div>
</div>
</div>
</Card>
)}
</div>
);
// 渲染AI服务tab内容
const renderAiServices = () => (
<div className={style["tab-content"]}>
<div className={style["ai-header"]}>
<div className={style["ai-title"]}>
<RobotOutlined className={style["ai-icon"]} />
AI智能服务收费
</div>
<div className={style["ai-tag"]}></div>
</div>
<div className={style["ai-description"]}>
AI服务,使,1
</div>
<div className={style["ai-services"]}>
{aiServices.map(service => (
<Card key={service.id} className={style["ai-service-card"]}>
<div className={style["service-header"]}>
<div className={style["service-info"]}>
<div className={style["service-icon"]}>{service.icon}</div>
<div className={style["service-details"]}>
<div className={style["service-name"]}>{service.name}</div>
<div className={style["service-price"]}>
¥{service.price}/
</div>
</div>
</div>
</div>
<div className={style["service-description"]}>
{service.description}
</div>
<div className={style["service-features"]}>
{service.features.map((feature, index) => (
<div key={index} className={style["feature-item"]}>
<span className={style["feature-check"]}></span>
{feature}
</div>
))}
</div>
<div className={style["usage-progress"]}>
<div className={style["usage-label"]}>使</div>
<div className={style["progress-bar"]}>
<div
className={style["progress-fill"]}
style={{
width: `${(service.usage.current / service.usage.total) * 100}%`,
}}
></div>
</div>
<div className={style["usage-text"]}>
{service.usage.current} / {service.usage.total}
</div>
</div>
</Card>
))}
</div>
</div>
);
// 渲染版本套餐tab内容
const renderVersionPackages = () => (
<div className={style["tab-content"]}>
<div className={style["version-header"]}>
<CrownOutlined className={style["version-icon"]} />
<span></span>
</div>
<div className={style["version-description"]}>
,AI服务
</div>
<div className={style["version-packages"]}>
{versionPackages.map(pkg => (
<Card key={pkg.id} className={style["version-card"]}>
<div className={style["package-header"]}>
<div className={style["package-info"]}>
<div className={style["package-icon"]}>{pkg.icon}</div>
<div className={style["package-details"]}>
<div className={style["package-name"]}>
{pkg.name}
{pkg.tag && (
<span
className={`${style["package-tag"]} ${style[`tag-${pkg.tagColor || "blue"}`]}`}
>
{pkg.tag}
</span>
)}
</div>
<div className={style["package-price"]}>{pkg.price}</div>
</div>
</div>
</div>
<div className={style["package-description"]}>
{pkg.description}
</div>
<div className={style["package-features"]}>
<div className={style["features-title"]}>:</div>
{pkg.features.map((feature, index) => (
<div key={index} className={style["feature-item"]}>
<span className={style["feature-check"]}></span>
{feature}
</div>
))}
</div>
{pkg.status && (
<div className={style["package-status"]}>{pkg.status}</div>
)}
{pkg.buttonText && (
<Button
block
color="primary"
className={style["upgrade-btn"]}
onClick={() => {
Toast.show({ content: "升级功能开发中", position: "top" });
}}
>
{pkg.buttonText}
</Button>
)}
</Card>
))}
</div>
</div>
);
return (
<Layout
header={
<NavCommon
title="充值中心"
right={
<div
className={style["record-btn"]}
onClick={() => navigate("/recharge/order")}
>
<ClockCircleOutlined />
&nbsp;
</div>
}
/>
}
>
<div className={style["recharge-page"]}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className={style["recharge-tabs"]}
>
<Tabs.Tab title="账户充值" key="account">
{renderAccountRecharge()}
</Tabs.Tab>
<Tabs.Tab title="AI服务" key="ai">
{renderAiServices()}
</Tabs.Tab>
<Tabs.Tab title="版本套餐" key="version">
{renderVersionPackages()}
</Tabs.Tab>
</Tabs>
</div>
</Layout>
);
};
export default Recharge;

View File

@@ -1,197 +0,0 @@
import {
RechargeOrdersResponse,
RechargeOrderDetail,
RechargeOrderParams,
} from "./data";
// 模拟数据
const mockOrders = [
{
id: "1",
orderNo: "RC20241201001",
amount: 100.0,
paymentMethod: "wechat",
status: "success" as const,
createTime: "2024-12-01T10:30:00Z",
payTime: "2024-12-01T10:32:15Z",
description: "账户充值",
balance: 150.0,
},
{
id: "2",
orderNo: "RC20241201002",
amount: 200.0,
paymentMethod: "alipay",
status: "pending" as const,
createTime: "2024-12-01T14:20:00Z",
description: "账户充值",
balance: 350.0,
},
{
id: "3",
orderNo: "RC20241130001",
amount: 50.0,
paymentMethod: "bank",
status: "success" as const,
createTime: "2024-11-30T09:15:00Z",
payTime: "2024-11-30T09:18:30Z",
description: "账户充值",
balance: 50.0,
},
{
id: "4",
orderNo: "RC20241129001",
amount: 300.0,
paymentMethod: "wechat",
status: "failed" as const,
createTime: "2024-11-29T16:45:00Z",
description: "账户充值",
},
{
id: "5",
orderNo: "RC20241128001",
amount: 150.0,
paymentMethod: "alipay",
status: "cancelled" as const,
createTime: "2024-11-28T11:20:00Z",
description: "账户充值",
},
{
id: "6",
orderNo: "RC20241127001",
amount: 80.0,
paymentMethod: "wechat",
status: "success" as const,
createTime: "2024-11-27T13:10:00Z",
payTime: "2024-11-27T13:12:45Z",
description: "账户充值",
balance: 80.0,
},
{
id: "7",
orderNo: "RC20241126001",
amount: 120.0,
paymentMethod: "bank",
status: "success" as const,
createTime: "2024-11-26T08:30:00Z",
payTime: "2024-11-26T08:33:20Z",
description: "账户充值",
balance: 120.0,
},
{
id: "8",
orderNo: "RC20241125001",
amount: 250.0,
paymentMethod: "alipay",
status: "pending" as const,
createTime: "2024-11-25T15:45:00Z",
description: "账户充值",
balance: 370.0,
},
];
// 模拟延迟
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// 获取充值记录列表
export async function getRechargeOrders(
params: RechargeOrderParams,
): Promise<RechargeOrdersResponse> {
await delay(800); // 模拟网络延迟
let filteredOrders = [...mockOrders];
// 状态筛选
if (params.status && params.status !== "all") {
filteredOrders = filteredOrders.filter(
order => order.status === params.status,
);
}
// 时间筛选
if (params.startTime) {
filteredOrders = filteredOrders.filter(
order => new Date(order.createTime) >= new Date(params.startTime!),
);
}
if (params.endTime) {
filteredOrders = filteredOrders.filter(
order => new Date(order.createTime) <= new Date(params.endTime!),
);
}
// 分页
const startIndex = (params.page - 1) * params.limit;
const endIndex = startIndex + params.limit;
const paginatedOrders = filteredOrders.slice(startIndex, endIndex);
return {
list: paginatedOrders,
total: filteredOrders.length,
page: params.page,
limit: params.limit,
};
}
// 获取充值记录详情
export async function getRechargeOrderDetail(
id: string,
): Promise<RechargeOrderDetail> {
await delay(500);
const order = mockOrders.find(o => o.id === id);
if (!order) {
throw new Error("订单不存在");
}
return {
...order,
paymentChannel:
order.paymentMethod === "wechat"
? "微信支付"
: order.paymentMethod === "alipay"
? "支付宝"
: "银行转账",
transactionId: `TX${order.orderNo}`,
};
}
// 取消充值订单
export async function cancelRechargeOrder(id: string): Promise<void> {
await delay(1000);
const orderIndex = mockOrders.findIndex(o => o.id === id);
if (orderIndex === -1) {
throw new Error("订单不存在");
}
if (mockOrders[orderIndex].status !== "pending") {
throw new Error("只能取消处理中的订单");
}
// 模拟更新订单状态
(mockOrders[orderIndex] as any).status = "cancelled";
}
// 申请退款
export async function refundRechargeOrder(
id: string,
reason: string,
): Promise<void> {
await delay(1200);
const orderIndex = mockOrders.findIndex(o => o.id === id);
if (orderIndex === -1) {
throw new Error("订单不存在");
}
if (mockOrders[orderIndex].status !== "success") {
throw new Error("只能对成功的订单申请退款");
}
// 模拟添加退款信息
const order = mockOrders[orderIndex];
(order as any).refundAmount = order.amount;
(order as any).refundTime = new Date().toISOString();
(order as any).refundReason = reason;
}

View File

@@ -1,40 +0,0 @@
// 充值记录类型定义
export interface RechargeOrder {
id: string;
orderNo: string;
amount: number;
paymentMethod: string;
status: "success" | "pending" | "failed" | "cancelled";
createTime: string;
payTime?: string;
description?: string;
remark?: string;
operator?: string;
balance?: number;
}
// API响应类型
export interface RechargeOrdersResponse {
list: RechargeOrder[];
total: number;
page: number;
limit: number;
}
// 充值记录详情
export interface RechargeOrderDetail extends RechargeOrder {
paymentChannel?: string;
transactionId?: string;
refundAmount?: number;
refundTime?: string;
refundReason?: string;
}
// 查询参数
export interface RechargeOrderParams {
page: number;
limit: number;
status?: string;
startTime?: string;
endTime?: string;
}

View File

@@ -1,242 +0,0 @@
.recharge-orders-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
.orders-list {
.order-card {
margin-bottom: 12px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
.order-info {
flex: 1;
.order-no {
font-size: 14px;
color: #333;
font-weight: 500;
margin-bottom: 4px;
}
.order-time {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
gap: 4px;
}
}
.order-amount {
text-align: right;
.amount-text {
font-size: 18px;
font-weight: 600;
color: #52c41a;
margin-bottom: 4px;
}
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
}
}
.order-details {
padding: 12px 16px;
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
&:last-child {
margin-bottom: 0;
}
.label {
color: #666;
}
.value {
color: #333;
font-weight: 500;
}
}
.payment-method {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.method-icon {
width: 20px;
height: 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
}
.method-text {
font-size: 14px;
color: #333;
}
}
.balance-info {
background: #f8f9fa;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
color: #666;
margin-top: 8px;
}
}
.order-actions {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 8px;
.action-btn {
flex: 1;
height: 32px;
border-radius: 6px;
font-size: 12px;
border: none;
cursor: pointer;
transition: all 0.2s;
&.primary {
background: #1677ff;
color: #fff;
&:hover {
background: #0958d9;
}
}
&.secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
&:hover {
background: #e6e6e6;
}
}
&.danger {
background: #ff4d4f;
color: #fff;
&:hover {
background: #cf1322;
}
}
}
}
}
}
.empty-state {
padding: 60px 20px;
text-align: center;
.empty-icon {
font-size: 48px;
color: #d9d9d9;
margin-bottom: 16px;
}
.empty-text {
font-size: 14px;
color: #999;
}
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #666;
}
}
.load-more {
text-align: center;
padding: 16px;
color: #1677ff;
font-size: 14px;
cursor: pointer;
background: #fff;
border-radius: 8px;
margin-top: 12px;
&:hover {
background: #f0f8ff;
}
}
.filter-bar {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.filter-tabs {
display: flex;
gap: 8px;
.filter-tab {
flex: 1;
height: 32px;
border-radius: 16px;
font-size: 12px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
cursor: pointer;
transition: all 0.2s;
&.active {
background: #1677ff;
color: #fff;
border-color: #1677ff;
}
&:hover:not(.active) {
border-color: #1677ff;
color: #1677ff;
}
}
}
}
}

View File

@@ -1,344 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Card, SpinLoading, Empty, Toast, Dialog } from "antd-mobile";
import {
WalletOutlined,
ClockCircleOutlined,
WechatOutlined,
AlipayCircleOutlined,
BankOutlined,
} from "@ant-design/icons";
import NavCommon from "@/components/NavCommon";
import Layout from "@/components/Layout/Layout";
import {
getRechargeOrders,
cancelRechargeOrder,
refundRechargeOrder,
} from "./api";
import { RechargeOrder } from "./data";
import style from "./index.module.scss";
const RechargeOrders: React.FC = () => {
const navigate = useNavigate();
const [orders, setOrders] = useState<RechargeOrder[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("all");
const loadOrders = async (reset = false) => {
setLoading(true);
try {
const currentPage = reset ? 1 : page;
const params = {
page: currentPage,
limit: 20,
...(statusFilter !== "all" && { status: statusFilter }),
};
const response = await getRechargeOrders(params);
const newOrders = response.list || [];
setOrders(prev => (reset ? newOrders : [...prev, ...newOrders]));
setHasMore(newOrders.length === 20);
if (reset) setPage(1);
else setPage(currentPage + 1);
} catch (error) {
console.error("加载充值记录失败:", error);
Toast.show({ content: "加载失败,请重试", position: "top" });
} finally {
setLoading(false);
}
};
// 初始化加载
useEffect(() => {
loadOrders(true);
}, []);
// 筛选条件变化时重新加载
const handleFilterChange = (newStatus: string) => {
setStatusFilter(newStatus);
setPage(1);
setOrders([]);
loadOrders(true);
};
const getStatusText = (status: string) => {
switch (status) {
case "success":
return "充值成功";
case "pending":
return "处理中";
case "failed":
return "充值失败";
case "cancelled":
return "已取消";
default:
return "未知状态";
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "success":
return "#52c41a";
case "pending":
return "#faad14";
case "failed":
return "#ff4d4f";
case "cancelled":
return "#999";
default:
return "#666";
}
};
const getPaymentMethodIcon = (method: string) => {
switch (method.toLowerCase()) {
case "wechat":
return <WechatOutlined style={{ color: "#07c160" }} />;
case "alipay":
return <AlipayCircleOutlined style={{ color: "#1677ff" }} />;
case "bank":
return <BankOutlined style={{ color: "#722ed1" }} />;
default:
return <WalletOutlined style={{ color: "#666" }} />;
}
};
const getPaymentMethodColor = (method: string) => {
switch (method.toLowerCase()) {
case "wechat":
return "#07c160";
case "alipay":
return "#1677ff";
case "bank":
return "#722ed1";
default:
return "#666";
}
};
const formatTime = (timeStr: string) => {
const date = new Date(timeStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
} else if (days === 1) {
return (
"昨天 " +
date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})
);
} else if (days < 7) {
return `${days}天前`;
} else {
return date.toLocaleDateString("zh-CN");
}
};
const handleCancelOrder = async (orderId: string) => {
const result = await Dialog.confirm({
content: "确定要取消这个充值订单吗?",
confirmText: "确定取消",
cancelText: "再想想",
});
if (result) {
try {
await cancelRechargeOrder(orderId);
Toast.show({ content: "订单已取消", position: "top" });
loadOrders(true);
} catch (error) {
console.error("取消订单失败:", error);
Toast.show({ content: "取消失败,请重试", position: "top" });
}
}
};
const handleRefundOrder = async (orderId: string) => {
const result = await Dialog.confirm({
content: "确定要申请退款吗退款将在1-3个工作日内处理。",
confirmText: "申请退款",
cancelText: "取消",
});
if (result) {
try {
await refundRechargeOrder(orderId, "用户主动申请退款");
Toast.show({ content: "退款申请已提交", position: "top" });
loadOrders(true);
} catch (error) {
console.error("申请退款失败:", error);
Toast.show({ content: "申请失败,请重试", position: "top" });
}
}
};
const renderOrderItem = (order: RechargeOrder) => (
<Card key={order.id} className={style["order-card"]}>
<div className={style["order-header"]}>
<div className={style["order-info"]}>
<div className={style["order-no"]}>{order.orderNo}</div>
<div className={style["order-time"]}>
<ClockCircleOutlined style={{ fontSize: 12 }} />
{formatTime(order.createTime)}
</div>
</div>
<div className={style["order-amount"]}>
<div className={style["amount-text"]}>
{order.amount.toFixed(2)}
</div>
<div
className={style["status-tag"]}
style={{
backgroundColor: `${getStatusColor(order.status)}20`,
color: getStatusColor(order.status),
}}
>
{getStatusText(order.status)}
</div>
</div>
</div>
<div className={style["order-details"]}>
<div className={style["payment-method"]}>
<div
className={style["method-icon"]}
style={{
backgroundColor: getPaymentMethodColor(order.paymentMethod),
}}
>
{getPaymentMethodIcon(order.paymentMethod)}
</div>
<div className={style["method-text"]}>{order.paymentMethod}</div>
</div>
{order.description && (
<div className={style["detail-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>{order.description}</span>
</div>
)}
{order.payTime && (
<div className={style["detail-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>{formatTime(order.payTime)}</span>
</div>
)}
{order.balance !== undefined && (
<div className={style["balance-info"]}>
: {order.balance.toFixed(2)}
</div>
)}
</div>
{order.status === "pending" && (
<div className={style["order-actions"]}>
<button
className={`${style["action-btn"]} ${style["danger"]}`}
onClick={() => handleCancelOrder(order.id)}
>
</button>
</div>
)}
{order.status === "success" && (
<div className={style["order-actions"]}>
<button
className={`${style["action-btn"]} ${style["secondary"]}`}
onClick={() => navigate(`/recharge/order/${order.id}`)}
>
</button>
<button
className={`${style["action-btn"]} ${style["primary"]}`}
onClick={() => handleRefundOrder(order.id)}
>
退
</button>
</div>
)}
{order.status === "failed" && (
<div className={style["order-actions"]}>
<button
className={`${style["action-btn"]} ${style["primary"]}`}
onClick={() => navigate("/recharge")}
>
</button>
</div>
)}
</Card>
);
const filterTabs = [
{ key: "all", label: "全部" },
{ key: "success", label: "成功" },
{ key: "pending", label: "处理中" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
return (
<Layout
header={<NavCommon title="充值记录" />}
loading={loading && page === 1}
>
<div className={style["recharge-orders-page"]}>
<div className={style["filter-bar"]}>
<div className={style["filter-tabs"]}>
{filterTabs.map(tab => (
<button
key={tab.key}
className={`${style["filter-tab"]} ${
statusFilter === tab.key ? style["active"] : ""
}`}
onClick={() => handleFilterChange(tab.key)}
>
{tab.label}
</button>
))}
</div>
</div>
{orders.length === 0 && !loading ? (
<Empty
className={style["empty-state"]}
description="暂无充值记录"
image={<WalletOutlined className={style["empty-icon"]} />}
/>
) : (
<div className={style["orders-list"]}>
{orders.map(renderOrderItem)}
{loading && page > 1 && (
<div className={style["loading-container"]}>
<SpinLoading color="primary" />
<div className={style["loading-text"]}>...</div>
</div>
)}
{!loading && hasMore && (
<div className={style["load-more"]} onClick={() => loadOrders()}>
</div>
)}
</div>
)}
</div>
</Layout>
);
};
export default RechargeOrders;

View File

@@ -1,152 +0,0 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { NavBar, Card } from "antd-mobile";
import {
InfoCircleOutlined,
MailOutlined,
PhoneOutlined,
GlobalOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
const About: React.FC = () => {
const navigate = useNavigate();
// 应用信息
const appInfo = {
name: "存客宝管理系统",
version: "1.0.0",
buildNumber: "20241201",
description: "专业的存客宝管理平台,提供设备管理、自动营销、数据分析等功能",
};
// 功能特性
const features = [
{
title: "设备管理",
description: "统一管理微信设备和账号,实时监控设备状态",
},
{
title: "自动营销",
description: "智能点赞、群发推送、朋友圈同步等自动化营销功能",
},
{
title: "流量池管理",
description: "高效管理用户流量池,精准分组和标签管理",
},
{
title: "内容库",
description: "丰富的营销内容库,支持多种媒体格式",
},
{
title: "数据分析",
description: "详细的数据统计和分析,助力营销决策",
},
];
// 联系信息
const contractInfo = [
{
id: "email",
title: "邮箱支持",
value: "support@example.com",
icon: <MailOutlined />,
action: () => {
// 复制邮箱到剪贴板
navigator.clipboard.writeText("support@example.com");
},
},
{
id: "phone",
title: "客服热线",
value: "400-123-4567",
icon: <PhoneOutlined />,
action: () => {
// 拨打电话
window.location.href = "tel:400-123-4567";
},
},
{
id: "website",
title: "官方网站",
value: "www.example.com",
icon: <GlobalOutlined />,
action: () => {
// 打开网站
window.open("https://www.example.com", "_blank");
},
},
];
return (
<Layout header={<NavCommon title="关于我们" />}>
<div className={style["setting-page"]}>
{/* 应用信息卡片 */}
<Card className={style["app-info-card"]}>
<div className={style["app-info"]}>
<div className={style["app-logo"]}>
<div className={style["logo-placeholder"]}>
<img src="/logo.png" alt="logo" />
</div>
</div>
<div className={style["app-details"]}>
<div className={style["app-name"]}>{appInfo.name}</div>
<div className={style["app-version"]}> {appInfo.version}</div>
<div className={style["app-build"]}>
Build {appInfo.buildNumber}
</div>
</div>
</div>
<div className={style["app-description"]}>{appInfo.description}</div>
</Card>
{/* 功能特性 */}
<Card className={style["setting-group"]}>
<div className={style["group-title"]}></div>
<div className={style["features-grid"]}>
{features.map((feature, index) => (
<div key={index} className={style["feature-card"]}>
<div className={style["feature-icon"]}>
<div className={style["icon-placeholder"]}>{index + 1}</div>
</div>
<div className={style["feature-content"]}>
<div className={style["feature-title"]}>{feature.title}</div>
<div className={style["feature-description"]}>
{feature.description}
</div>
</div>
</div>
))}
</div>
</Card>
{/* 联系信息 */}
{/* <Card className={style["setting-group"]}>
<div className={style["group-title"]}>联系我们</div>
<List>
{contractInfo.map(item => (
<List.Item
key={item.id}
prefix={item.icon}
title={item.title}
description={item.value}
extra={<RightOutlined style={{ color: "#ccc" }} />}
onClick={item.action}
arrow
/>
))}
</List>
</Card> */}
{/* 版权信息 */}
<div className={style["copyright-info"]}>
<div className={style["copyright-text"]}>© 2024 </div>
<div className={style["copyright-subtext"]}></div>
</div>
</div>
</Layout>
);
};
export default About;

View File

@@ -1,125 +0,0 @@
import React from "react";
import { Card } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
const Privacy: React.FC = () => {
return (
<Layout header={<NavCommon title="用户隐私协议" />}>
<div className={style["setting-page"]}>
<Card className={style["privacy-card"]}>
<div className={style["privacy-content"]}>
<h2></h2>
<p className={style["update-time"]}>202581</p>
<section>
<h3>1. </h3>
<p></p>
<ul>
<li></li>
<li></li>
<li>使使访</li>
<li></li>
</ul>
</section>
<section>
<h3>2. 使</h3>
<p>使</p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section>
<h3>3. </h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section>
<h3>4. </h3>
<p></p>
<ul>
<li></li>
<li>访</li>
<li></li>
<li></li>
</ul>
</section>
<section>
<h3>5. </h3>
<p></p>
<ul>
<li>访</li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section>
<h3>6. </h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</section>
<section>
<h3>7. </h3>
<p>
13
</p>
</section>
<section>
<h3>8. </h3>
<p>
</p>
</section>
<section>
<h3>9. </h3>
<p>
</p>
</section>
<section>
<h3>10. </h3>
<p></p>
<ul>
<li>privacy@example.com</li>
<li>400-123-4567</li>
<li>xxx大厦</li>
</ul>
</section>
<div className={style["privacy-footer"]}>
<p>使</p>
</div>
</div>
</Card>
</div>
</Layout>
);
};
export default Privacy;

View File

@@ -1,188 +0,0 @@
# 设置功能说明
## 概述
设置功能为存客宝管理系统提供了完整的用户配置管理,包括账户设置、通知设置、应用设置等多个模块。
## 功能模块
### 1. 主设置页面 (`index.tsx`)
**功能特性:**
- 用户信息展示
- 分组设置项管理
- 设置状态持久化
**主要组件:**
- 用户信息卡片:显示头像、昵称、账号、角色
- 设置分组:账户设置、通知设置、应用设置、其他
- 版本信息:显示应用版本和版权信息
### 2. 安全设置页面 (`SecuritySetting.tsx`)
**功能特性:**
- 密码修改
- 手机号绑定
- 登录设备管理
- 安全建议
**主要功能:**
- 修改密码:支持旧密码验证和新密码确认
- 绑定手机号:提高账号安全性
- 设备管理:查看和管理已登录设备
- 安全提醒:提供账号安全建议
### 3. 关于页面 (`About.tsx`)
**功能特性:**
- 应用信息展示
- 功能特性介绍
- 联系方式
- 法律信息
**主要内容:**
- 应用版本信息
- 功能介绍:设备管理、自动营销、流量池管理等
- 联系方式:邮箱、电话、官网
- 法律文档:隐私政策、用户协议、开源许可
## 设置管理
### 设置Store (`settings.ts`)
**功能特性:**
- 全局设置状态管理
- 设置持久化存储
- 设置工具函数
**支持的设置项:**
#### 通知设置
- `pushNotification`: 推送通知开关
- `emailNotification`: 邮件通知开关
- `soundNotification`: 声音提醒开关
#### 应用设置
- `autoLogin`: 自动登录开关
- `language`: 语言设置
- `timezone`: 时区设置
#### 隐私设置
- `analyticsEnabled`: 数据分析开关
- `crashReportEnabled`: 崩溃报告开关
#### 功能设置
- `autoSave`: 自动保存开关
- `showTutorial`: 教程显示开关
### 工具函数
```typescript
// 获取设置值
const value = getSetting("pushNotification");
// 设置值
setSetting("autoLogin", true);
```
## 样式设计
### 设计原则
- 移动端优先设计
- 统一的视觉风格
- 良好的用户体验
### 样式特性
- 响应式布局
- 卡片式设计
- 圆角边框
- 阴影效果
- 渐变背景
## 路由配置
```typescript
// 设置相关路由
{
path: "/settings",
element: <Setting />,
auth: true,
},
{
path: "/security",
element: <SecuritySetting />,
auth: true,
},
{
path: "/about",
element: <About />,
auth: true,
}
```
## 使用示例
### 基本使用
```typescript
import { useSettingsStore } from '@/store/module/settings';
const MyComponent = () => {
const { settings, updateSetting } = useSettingsStore();
const handleToggleNotification = () => {
updateSetting('pushNotification', !settings.pushNotification);
};
return (
<Switch
checked={settings.pushNotification}
onChange={handleToggleNotification}
/>
);
};
```
## 扩展功能
### 添加新设置项
1.`AppSettings` 接口中添加新字段
2.`defaultSettings` 中设置默认值
3. 在设置页面中添加对应的UI组件
4. 在样式文件中添加相应的样式
### 添加新设置页面
1. 创建新的页面组件
2. 在路由配置中添加路由
3. 在主设置页面中添加导航链接
4. 添加相应的样式
## 注意事项
1. **数据持久化**:所有设置都会自动保存到本地存储
2. **权限控制**:某些设置可能需要管理员权限
3. **兼容性**:确保在不同设备和浏览器上的兼容性
4. **性能优化**:避免频繁的设置更新影响性能
## 未来规划
- [ ] 多语言支持
- [ ] 设置导入导出
- [ ] 云端同步设置
- [ ] 设置备份恢复
- [ ] 高级设置选项

View File

@@ -1,224 +0,0 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { NavBar, List, Dialog, Toast, Card, Input } from "antd-mobile";
import {
LockOutlined,
MobileOutlined,
SafetyOutlined,
RightOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { useUserStore } from "@/store/module/user";
import style from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
const SecuritySetting: React.FC = () => {
const navigate = useNavigate();
const { user } = useUserStore();
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [passwordForm, setPasswordForm] = useState({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
// 修改密码
const handleChangePassword = async () => {
const { oldPassword, newPassword, confirmPassword } = passwordForm;
if (!oldPassword || !newPassword || !confirmPassword) {
Toast.show({ content: "请填写完整信息", position: "top" });
return;
}
if (newPassword !== confirmPassword) {
Toast.show({ content: "两次输入的新密码不一致", position: "top" });
return;
}
if (newPassword.length < 6) {
Toast.show({ content: "新密码长度不能少于6位", position: "top" });
return;
}
try {
// TODO: 调用修改密码API
Toast.show({ content: "密码修改成功", position: "top" });
setShowPasswordDialog(false);
setPasswordForm({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (error: any) {
Toast.show({ content: error.message || "密码修改失败", position: "top" });
}
};
// 绑定手机号
const handleBindPhone = () => {
Toast.show({ content: "功能开发中", position: "top" });
};
// 登录设备管理
const handleDeviceManagement = () => {
Toast.show({ content: "功能开发中", position: "top" });
};
// 安全设置项
const securityItems = [
{
id: "password",
title: "修改密码",
description: "定期更换密码,保护账号安全",
icon: <LockOutlined />,
onClick: () => setShowPasswordDialog(true),
},
{
id: "phone",
title: "绑定手机号",
description: user?.phone
? `已绑定:${user.phone}`
: "绑定手机号,提高账号安全性",
icon: <MobileOutlined />,
onClick: handleBindPhone,
},
{
id: "devices",
title: "登录设备管理",
description: "查看和管理已登录的设备",
icon: <SafetyOutlined />,
onClick: handleDeviceManagement,
},
];
return (
<Layout header={<NavCommon title="安全设置" />}>
<div className={style["setting-page"]}>
{/* 安全提示卡片 */}
<Card className={style["security-tip-card"]}>
<div className={style["tip-content"]}>
<SafetyOutlined className={style["tip-icon"]} />
<div className={style["tip-text"]}>
<div className={style["tip-title"]}></div>
<div className={style["tip-description"]}>
</div>
</div>
</div>
</Card>
{/* 安全设置列表 */}
<Card className={style["setting-group"]}>
<div className={style["group-title"]}></div>
<List>
{securityItems.map(item => (
<List.Item
key={item.id}
prefix={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
/>
))}
</List>
</Card>
{/* 安全建议 */}
<Card className={style["security-advice-card"]}>
<div className={style["advice-title"]}></div>
<div className={style["advice-list"]}>
<div className={style["advice-item"]}>
<span className={style["advice-dot"]}></span>
<span>使</span>
</div>
<div className={style["advice-item"]}>
<span className={style["advice-dot"]}></span>
<span>3</span>
</div>
<div className={style["advice-item"]}>
<span className={style["advice-dot"]}></span>
<span></span>
</div>
<div className={style["advice-item"]}>
<span className={style["advice-dot"]}></span>
<span></span>
</div>
</div>
</Card>
</div>
{/* 修改密码对话框 */}
<Dialog
visible={showPasswordDialog}
title="修改密码"
content={
<div className={style["password-form"]}>
<div className={style["line"]}>
<Input
type="password"
placeholder="请输入当前密码"
value={passwordForm.oldPassword}
onChange={value =>
setPasswordForm(prev => ({ ...prev, oldPassword: value }))
}
/>
</div>
<div className={style["line"]}>
<Input
type="password"
placeholder="请输入新密码"
value={passwordForm.newPassword}
onChange={value =>
setPasswordForm(prev => ({ ...prev, newPassword: value }))
}
/>
</div>
<div className={style["line"]}>
<Input
type="password"
placeholder="请确认新密码"
value={passwordForm.confirmPassword}
onChange={value =>
setPasswordForm(prev => ({ ...prev, confirmPassword: value }))
}
/>
</div>
</div>
}
closeOnAction
actions={[
[
{
key: "cancel",
text: "取消",
onClick: () => {
setShowPasswordDialog(false);
setPasswordForm({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
},
},
{
key: "confirm",
text: "确认修改",
bold: true,
onClick: handleChangePassword,
},
],
]}
onClose={() => {
setShowPasswordDialog(false);
setPasswordForm({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
}}
/>
</Layout>
);
};
export default SecuritySetting;

View File

@@ -1,306 +0,0 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { List, Switch, Button, Dialog, Toast, Card } from "antd-mobile";
import {
UserOutlined,
SafetyOutlined,
InfoCircleOutlined,
LogoutOutlined,
SettingOutlined,
LockOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import { useUserStore } from "@/store/module/user";
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";
interface SettingItem {
id: string;
title: string;
description?: string;
icon: React.ReactNode;
type: "navigate" | "switch" | "button";
value?: boolean;
path?: string;
onClick?: () => void;
badge?: string;
color?: string;
}
const Setting: React.FC = () => {
const navigate = useNavigate();
const { user, logout } = useUserStore();
const { settings } = useSettingsStore();
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// 处理头像加载错误
const handleAvatarError = () => {
setAvatarError(true);
};
// 退出登录
const handleLogout = () => {
logout();
setShowLogoutDialog(false);
navigate("/login");
Toast.show({
content: "退出成功",
position: "top",
});
};
// 清除缓存
const handleClearCache = () => {
Dialog.confirm({
content: "确定要清除缓存吗?这将清除所有本地数据。",
onConfirm: () => {
sendMessageToParent(
{
action: "clearCache",
},
TYPE_EMUE.FUNCTION,
);
},
});
};
// 设置项配置
const settingGroups: { title: string; items: SettingItem[] }[] = [
{
title: "账户设置",
items: [
{
id: "profile",
title: "个人信息",
description: "修改头像、昵称等基本信息",
icon: <UserOutlined />,
type: "navigate",
path: "/userSet",
color: "var(--primary-color)",
},
{
id: "security",
title: "安全设置",
description: "密码修改、登录设备管理",
icon: <SafetyOutlined />,
type: "navigate",
path: "/security",
color: "var(--primary-color)",
},
],
},
{
title: "应用设置",
items: [
{
id: "privacy",
title: "隐私保护",
description: "数据隐私、权限管理",
icon: <LockOutlined />,
type: "navigate",
path: "/privacy",
color: "var(--primary-color)",
},
{
id: "clearCache",
title: "清除缓存",
description: "清除本地缓存数据",
icon: <SettingOutlined />,
type: "button",
onClick: handleClearCache,
color: "var(--primary-color)",
badge: "2.3MB",
},
],
},
{
title: "其他",
items: [
{
id: "about",
title: "关于我们",
description: "版本信息、联系方式",
icon: <InfoCircleOutlined />,
type: "navigate",
path: "/about",
color: "var(--primary-color)",
},
{
id: "logout",
title: "退出登录",
description: "安全退出当前账号",
icon: <LogoutOutlined />,
type: "button",
onClick: () => setShowLogoutDialog(true),
color: "#ff4d4f",
},
],
},
];
// 渲染设置项
const renderSettingItem = (item: SettingItem) => {
const handleClick = () => {
if (item.type === "navigate" && item.path) {
navigate(item.path);
} else if (item.type === "switch" && item.onClick) {
item.onClick();
} else if (item.type === "button" && item.onClick) {
item.onClick();
}
};
return (
<List.Item
key={item.id}
prefix={
<div
className={style["setting-icon"]}
style={{
color: item.color || "var(--primary-color)",
background: `${item.color || "var(--primary-color)"}15`,
}}
>
{item.icon}
</div>
}
title={
<div className={style["setting-title"]}>
{item.title}
{item.badge && (
<span className={style["setting-badge"]}>{item.badge}</span>
)}
</div>
}
description={item.description}
extra={
item.type === "switch" ? (
<Switch
checked={item.value}
onChange={() => item.onClick?.()}
style={
{
"--checked-color": item.color || "var(--primary-color)",
} as React.CSSProperties
}
/>
) : null
}
onClick={handleClick}
arrow={item.type === "navigate"}
className={style["setting-item"]}
/>
);
};
return (
<Layout header={<NavCommon title="设置" />}>
<div className={style["setting-page"]}>
{/* 用户信息卡片 */}
<Card className={style["user-card"]}>
<div className={style["user-info"]}>
<div className={style["avatar"]}>
{user?.avatar && !avatarError ? (
<img src={user.avatar} alt="头像" onError={handleAvatarError} />
) : (
<div className={style["avatar-placeholder"]}>
{user?.username?.charAt(0) || "用"}
</div>
)}
</div>
<div className={style["user-details"]}>
<div className={style["username"]}>
{user?.username || "未设置昵称"}
</div>
<div className={style["account"]}>
{user?.account || "未知账号"}
</div>
<div className={style["role"]}>
{user?.isAdmin === 1 ? "管理员" : "普通用户"}
</div>
</div>
<div className={style["user-actions"]}>
<Button
size="small"
fill="outline"
onClick={() => navigate("/userSet")}
style={{
color: "#fff",
borderColor: "#fff",
fontSize: "12px",
padding: "4px 8px",
height: "auto",
}}
>
</Button>
</div>
</div>
</Card>
{/* 设置列表 */}
{settingGroups.map((group, groupIndex) => (
<Card key={groupIndex} className={style["setting-group"]}>
<div className={style["group-title"]}>
<span className={style["group-icon"]}></span>
{group.title}
</div>
<List className={style["setting-list"]}>
{group.items.map(renderSettingItem)}
</List>
</Card>
))}
{/* 版本信息 */}
<div className={style["version-info"]}>
<div className={style["version-card"]}>
<div className={style["app-logo"]}>
<img src="/logo.png" alt="" />
</div>
<div className={style["version-details"]}>
<div className={style["app-name"]}></div>
<div className={style["version-text"]}>
{settings.appVersion}
</div>
<div className={style["build-info"]}>Build 2025-08-04</div>
</div>
</div>
<div className={style["copyright"]}>
<span>© 2024 </span>
<span></span>
</div>
</div>
</div>
{/* 退出登录确认对话框 */}
<Dialog
content="您确定要退出登录吗?退出后需要重新登录才能使用完整功能。"
visible={showLogoutDialog}
closeOnAction
actions={[
[
{
key: "cancel",
text: "取消",
},
{
key: "confirm",
text: "确认退出",
bold: true,
danger: true,
onClick: handleLogout,
},
],
]}
onClose={() => setShowLogoutDialog(false)}
/>
</Layout>
);
};
export default Setting;

View File

@@ -1,25 +0,0 @@
import request from "@/api/request";
import type { UserTagsResponse } from "./data";
export function getTrafficPoolDetail(wechatId: string) {
return request("/v1/wechats/getWechatInfo", { wechatId }, "GET");
}
// 获取用户旅程记录
export function getUserJourney(params: {
page: number;
pageSize: number;
userId: string;
}) {
return request("/v1/traffic/pool/getUserJourney", params, "GET");
}
// 获取用户标签
export function getUserTags(userId: string): Promise<UserTagsResponse> {
return request("/v1/traffic/pool/getUserTags", { userId }, "GET");
}
// 添加用户标签
export function addUserTag(userId: string, tagData: any): Promise<any> {
return request("/v1/user/tags", { userId, ...tagData }, "POST");
}

View File

@@ -1,108 +0,0 @@
// 用户详情类型
export interface TrafficPoolUserDetail {
id: number;
nickname: string;
avatar: string;
wechatId: string;
status: number | string;
addTime: string;
lastInteraction: string;
deviceName?: string;
wechatAccountName?: string;
customerServiceName?: string;
poolNames?: string[];
rfmScore?: {
recency: number;
frequency: number;
monetary: number;
segment?: string;
};
totalSpent?: number;
interactionCount?: number;
conversionRate?: number;
tags?: string[];
packages?: string[];
interactions?: Array<{
id: string;
type: string;
content: string;
timestamp: string;
value?: number;
}>;
}
// 扩展的用户详情类型
export interface ExtendedUserDetail extends TrafficPoolUserDetail {
userInfo: {
nickname: string;
avatar: string;
wechatId: string;
friendShip: {
totalFriend: number;
maleFriend: number;
femaleFriend: number;
unknowFriend: number;
};
};
rfmScore: {
recency: number;
frequency: number;
monetary: number;
totalScore: number;
};
trafficPools: {
currentPool: string;
availablePools: string[];
};
userTags: Array<{
id: string;
name: string;
color: string;
type: string;
}>;
valueTags: Array<{
id: string;
name: string;
color: string;
icon: string;
rfmScore: number;
valueLevel: string;
}>;
restrictions?: Array<{
id: string;
reason: string;
level: number;
date: number | null;
}>;
}
// 互动记录类型
export interface InteractionRecord {
id: string;
type: string;
content: string;
timestamp: string;
value?: number;
}
// 用户旅程记录类型
export interface UserJourneyRecord {
id: string;
type: number;
remark: string;
createTime: string;
}
// 用户标签响应类型
export interface UserTagsResponse {
wechat: string[];
siteLabels: UserTagItem[];
}
// 用户标签项类型
export interface UserTagItem {
id: string;
name: string;
color?: string;
type?: string;
}

View File

@@ -1,432 +0,0 @@
.container {
padding: 0;
background: #f5f5f5;
min-height: 100vh;
}
// 头部样式
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.closeBtn {
padding: 8px;
border: none;
background: transparent;
color: #999;
font-size: 16px;
}
}
// 用户卡片
.userCard {
margin: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.userInfo {
display: flex;
align-items: flex-start;
gap: 16px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
flex-shrink: 0;
}
.avatarFallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
border-radius: 50%;
}
.userDetails {
flex: 1;
min-width: 0;
}
.nickname {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.wechatId {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.userTag {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
}
}
// 标签导航
.tabNav {
display: flex;
background: #fff;
margin: 0 16px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.tabItem {
flex: 1;
padding: 12px 16px;
text-align: center;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.3s ease;
border-bottom: 2px solid transparent;
&.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: rgba(24, 142, 238, 0.05);
}
&:hover {
background: rgba(24, 142, 238, 0.05);
}
}
}
// 内容区域
.content {
padding: 16px;
}
.tabContent {
display: flex;
flex-direction: column;
gap: 16px;
}
// 信息卡片
.infoCard {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
:global(.adm-card-header) {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
color: #333;
}
:global(.adm-card-body) {
padding: 0;
}
}
// RFM评分网格
.rfmGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.rfmItem {
text-align: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.rfmLabel {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.rfmValue {
font-size: 18px;
font-weight: 600;
}
// 流量池区域
.poolSection {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.currentPool,
.availablePools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.poolLabel {
font-size: 14px;
color: #666;
white-space: nowrap;
}
// 统计数据网格
.statsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.statItem {
text-align: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.statValue {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.statLabel {
font-size: 12px;
color: #666;
}
// 用户旅程
.journeyItem {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
margin-top: 4px;
}
.timestamp {
color: #999;
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
text-align: center;
}
.loadingText {
font-size: 14px;
color: #999;
margin-top: 8px;
}
.loadingMore {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: #666;
font-size: 14px;
}
.loadMoreBtn {
display: flex;
justify-content: center;
padding: 16px;
}
// 标签区域
.tagsSection {
padding: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.valueTagsSection {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tagItem {
font-size: 12px;
padding: 6px 12px;
border-radius: 16px;
}
.valueTagContainer {
display: flex;
flex-direction: column;
gap: 8px;
}
.valueTagRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.rfmScoreText {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.valueLevelLabel {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.valueTagItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.valueInfo {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
// 添加标签按钮
.addTagBtn {
margin-top: 16px;
border-radius: 8px;
height: 48px;
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
// 空状态
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 16px;
text-align: center;
}
.emptyIcon {
margin-bottom: 16px;
opacity: 0.6;
}
.emptyText {
font-size: 14px;
color: #666;
margin-bottom: 4px;
font-weight: 500;
}
.emptyDesc {
font-size: 12px;
color: #999;
line-height: 1.4;
}
// 限制记录样式
.restrictionTitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 1.4;
}
.restrictionLevel {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.restrictionContent {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #666;
line-height: 1.4;
margin-top: 4px;
}
// 响应式设计
@media (max-width: 375px) {
.rfmGrid,
.statsGrid {
grid-template-columns: 1fr;
}
.userInfo {
flex-direction: column;
text-align: center;
}
.avatar {
align-self: center;
}
.restrictionTitle {
font-size: 13px;
}
.restrictionContent {
font-size: 11px;
}
}

View File

@@ -1,709 +0,0 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, Button, Avatar, Tag, List, SpinLoading } from "antd-mobile";
import {
UserOutlined,
CrownOutlined,
EyeOutlined,
DollarOutlined,
MobileOutlined,
TagOutlined,
FileTextOutlined,
UserAddOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getTrafficPoolDetail, getUserJourney, getUserTags } from "./api";
import type {
ExtendedUserDetail,
UserJourneyRecord,
UserTagsResponse,
UserTagItem,
} from "./data";
import styles from "./index.module.scss";
const TrafficPoolDetail: React.FC = () => {
const { wxid, userId } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<ExtendedUserDetail | null>(null);
const [activeTab, setActiveTab] = useState("basic");
// 用户旅程相关状态
const [journeyLoading, setJourneyLoading] = useState(false);
const [journeyList, setJourneyList] = useState<UserJourneyRecord[]>([]);
const [journeyPage, setJourneyPage] = useState(1);
const [journeyTotal, setJourneyTotal] = useState(0);
const pageSize = 10;
// 用户标签相关状态
const [tagsLoading, setTagsLoading] = useState(false);
const [userTagsList, setUserTagsList] = useState<UserTagItem[]>([]);
const [wechatTagsList, setWechatTagsList] = useState<string[]>([]);
useEffect(() => {
if (!wxid) return;
setLoading(true);
getTrafficPoolDetail(wxid as string)
.then(res => {
// 将API数据转换为扩展的用户详情数据
const extendedUser: ExtendedUserDetail = {
...res,
// 添加userInfo属性
userInfo: res.userInfo,
// 模拟RFM评分数据
rfmScore: {
recency: 5,
frequency: 5,
monetary: 5,
totalScore: 15,
},
// 模拟流量池数据
trafficPools: {
currentPool: "新用户池",
availablePools: ["高价值客户池", "活跃用户池"],
},
// 模拟用户标签数据
userTags: [
{ id: "1", name: "近期活跃", color: "success", type: "user" },
{ id: "2", name: "高频互动", color: "primary", type: "user" },
{ id: "3", name: "高消费", color: "warning", type: "user" },
{ id: "4", name: "老客户", color: "danger", type: "user" },
],
// 模拟价值标签数据
valueTags: [
{
id: "1",
name: "重要保持客户",
color: "primary",
icon: "crown",
rfmScore: 14,
valueLevel: "高价值",
},
],
};
console.log(extendedUser);
setUser(extendedUser);
})
.finally(() => setLoading(false));
}, [wxid]);
// 获取用户旅程数据
const fetchUserJourney = async (page: number = 1) => {
if (!userId) return;
setJourneyLoading(true);
try {
const response = await getUserJourney({
page,
pageSize,
userId: userId,
});
if (page === 1) {
setJourneyList(response.list);
} else {
setJourneyList(prev => [...prev, ...response.list]);
}
setJourneyTotal(response.total);
setJourneyPage(page);
} catch (error) {
console.error("获取用户旅程失败:", error);
} finally {
setJourneyLoading(false);
}
};
// 获取用户标签数据
const fetchUserTags = async () => {
if (!userId) return;
setTagsLoading(true);
try {
const response: UserTagsResponse = await getUserTags(userId);
setUserTagsList(response.siteLabels || []);
setWechatTagsList(response.wechat || []);
} catch (error) {
console.error("获取用户标签失败:", error);
} finally {
setTagsLoading(false);
}
};
// 标签切换处理
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === "journey" && journeyList.length === 0) {
fetchUserJourney(1);
}
if (tab === "tags" && userTagsList.length === 0) {
fetchUserTags();
}
};
const getJourneyTypeIcon = (type: number) => {
switch (type) {
case 0: // 浏览
return <EyeOutlined style={{ color: "#722ed1" }} />;
case 2: // 提交订单
return <FileTextOutlined style={{ color: "#52c41a" }} />;
case 3: // 注册
return <UserAddOutlined style={{ color: "#1677ff" }} />;
default:
return <MobileOutlined style={{ color: "#999" }} />;
}
};
const getJourneyTypeText = (type: number) => {
switch (type) {
case 0:
return "浏览行为";
case 2:
return "提交订单";
case 3:
return "注册行为";
default:
return "其他行为";
}
};
const formatDateTime = (dateTime: string) => {
try {
const date = new Date(dateTime);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (error) {
return dateTime;
}
};
const getActionIcon = (type: string) => {
switch (type) {
case "click":
return <MobileOutlined style={{ color: "#1677ff" }} />;
case "view":
return <EyeOutlined style={{ color: "#722ed1" }} />;
case "purchase":
return <DollarOutlined style={{ color: "#52c41a" }} />;
default:
return <MobileOutlined style={{ color: "#999" }} />;
}
};
const getRestrictionLevelText = (level: number) => {
switch (level) {
case 1:
return "轻微";
case 2:
return "中等";
case 3:
return "严重";
default:
return "未知";
}
};
const getRestrictionLevelColor = (level: number) => {
switch (level) {
case 1:
return "warning";
case 2:
return "danger";
case 3:
return "danger";
default:
return "default";
}
};
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "--";
try {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("zh-CN");
} catch (error) {
return "--";
}
};
// 获取标签颜色
const getTagColor = (index: number): string => {
const colors = ["primary", "success", "warning", "danger", "default"];
return colors[index % colors.length];
};
if (!user) {
return (
<Layout header={<NavCommon title="用户详情" />} loading={loading}>
<div className={styles.emptyState}>
<div className={styles.emptyText}></div>
</div>
</Layout>
);
}
return (
<Layout
loading={loading}
header={
<>
<NavCommon title="用户详情" />
{/* 用户基本信息 */}
<Card className={styles.userCard}>
<div className={styles.userInfo}>
<Avatar
src={user.userInfo.avatar}
className={styles.avatar}
fallback={
<div className={styles.avatarFallback}>
<UserOutlined />
</div>
}
/>
<div className={styles.userDetails}>
<div className={styles.nickname}>{user.userInfo.nickname}</div>
<div className={styles.wechatId}>{user.userInfo.wechatId}</div>
<div className={styles.tags}>
<Tag
color="warning"
fill="outline"
className={styles.userTag}
>
<CrownOutlined />
</Tag>
<Tag color="danger" fill="outline" className={styles.userTag}>
</Tag>
</div>
</div>
</div>
</Card>
{/* 导航标签 */}
<div className={styles.tabNav}>
<div
className={`${styles.tabItem} ${
activeTab === "basic" ? styles.active : ""
}`}
onClick={() => handleTabChange("basic")}
>
</div>
<div
className={`${styles.tabItem} ${
activeTab === "journey" ? styles.active : ""
}`}
onClick={() => handleTabChange("journey")}
>
</div>
<div
className={`${styles.tabItem} ${
activeTab === "tags" ? styles.active : ""
}`}
onClick={() => handleTabChange("tags")}
>
</div>
</div>
</>
}
>
<div className={styles.container}>
{/* 内容区域 */}
<div className={styles.content}>
{activeTab === "basic" && (
<div className={styles.tabContent}>
{/* 关联信息 */}
<Card title="关联信息" className={styles.infoCard}>
<List>
<List.Item extra="设备4"></List.Item>
<List.Item extra="微信4-1"></List.Item>
<List.Item extra="客服1"></List.Item>
<List.Item extra="2025/07/21"></List.Item>
<List.Item extra="2025/07/25"></List.Item>
</List>
</Card>
{/* RFM评分 */}
{user.rfmScore && (
<Card title="RFM评分" className={styles.infoCard}>
<div className={styles.rfmGrid}>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(R)</div>
<div
className={styles.rfmValue}
style={{ color: "#1677ff" }}
>
{user.rfmScore.recency}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(F)</div>
<div
className={styles.rfmValue}
style={{ color: "#52c41a" }}
>
{user.rfmScore.frequency}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}>(M)</div>
<div
className={styles.rfmValue}
style={{ color: "#722ed1" }}
>
{user.rfmScore.monetary}
</div>
</div>
<div className={styles.rfmItem}>
<div className={styles.rfmLabel}></div>
<div
className={styles.rfmValue}
style={{ color: "#ff4d4f" }}
>
{user.rfmScore.totalScore}/15
</div>
</div>
</div>
</Card>
)}
{/* 流量池 */}
{user.trafficPools && (
<Card title="流量池" className={styles.infoCard}>
<div className={styles.poolSection}>
<div className={styles.currentPool}>
<span className={styles.poolLabel}></span>
<Tag color="primary" fill="outline">
{user.trafficPools.currentPool}
</Tag>
</div>
<div className={styles.availablePools}>
<span className={styles.poolLabel}></span>
{user.trafficPools.availablePools.map((pool, index) => (
<Tag key={index} color="default" fill="outline">
{pool}
</Tag>
))}
</div>
</div>
</Card>
)}
{/* 统计数据 */}
<Card title="统计数据" className={styles.infoCard}>
<div className={styles.statsGrid}>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#52c41a" }}
>
¥9561
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
6
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#722ed1" }}
>
3%
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div className={styles.statValue} style={{ color: "#999" }}>
</div>
<div className={styles.statLabel}></div>
</div>
</div>
</Card>
{/* 好友统计 */}
<Card title="好友统计" className={styles.infoCard}>
<div className={styles.statsGrid}>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
{user.userInfo.friendShip.totalFriend}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#1677ff" }}
>
{user.userInfo.friendShip.maleFriend}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div
className={styles.statValue}
style={{ color: "#eb2f96" }}
>
{user.userInfo.friendShip.femaleFriend}
</div>
<div className={styles.statLabel}></div>
</div>
<div className={styles.statItem}>
<div className={styles.statValue} style={{ color: "#999" }}>
{user.userInfo.friendShip.unknowFriend}
</div>
<div className={styles.statLabel}></div>
</div>
</div>
</Card>
{/* 限制记录 */}
<Card title="限制记录" className={styles.infoCard}>
{user.restrictions && user.restrictions.length > 0 ? (
<List>
{user.restrictions.map(restriction => (
<List.Item
key={restriction.id}
title={
<div className={styles.restrictionTitle}>
<span>{restriction.reason || "未知原因"}</span>
<Tag
color={getRestrictionLevelColor(
restriction.level,
)}
fill="outline"
className={styles.restrictionLevel}
>
{getRestrictionLevelText(restriction.level)}
</Tag>
</div>
}
description={
<div className={styles.restrictionContent}>
<span>ID: {restriction.id}</span>
{restriction.date && (
<span>
: {formatDate(restriction.date)}
</span>
)}
</div>
}
/>
))}
</List>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<UserOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
)}
</Card>
</div>
)}
{activeTab === "journey" && (
<div className={styles.tabContent}>
<Card title="互动记录" className={styles.infoCard}>
{journeyLoading && journeyList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 24 }} />
<div className={styles.loadingText}>...</div>
</div>
) : journeyList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<EyeOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<List>
{journeyList.map(record => (
<List.Item
key={record.id}
prefix={getJourneyTypeIcon(record.type)}
title={getJourneyTypeText(record.type)}
description={
<div className={styles.journeyItem}>
<span>{record.remark}</span>
<span className={styles.timestamp}>
{formatDateTime(record.createTime)}
</span>
</div>
}
/>
))}
{journeyLoading && journeyList.length > 0 && (
<div className={styles.loadingMore}>
<SpinLoading color="primary" style={{ fontSize: 16 }} />
<span>...</span>
</div>
)}
{!journeyLoading && journeyList.length < journeyTotal && (
<div className={styles.loadMoreBtn}>
<Button
size="small"
fill="outline"
onClick={() => fetchUserJourney(journeyPage + 1)}
>
</Button>
</div>
)}
</List>
)}
</Card>
</div>
)}
{activeTab === "tags" && (
<div className={styles.tabContent}>
{/* 站内标签 */}
<Card title="站内标签" className={styles.infoCard}>
{tagsLoading && userTagsList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 20 }} />
<div className={styles.loadingText}>...</div>
</div>
) : userTagsList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<TagOutlined style={{ fontSize: 36, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<div className={styles.tagsSection}>
{userTagsList.map((tag, index) => (
<Tag
key={tag.id}
color={getTagColor(index)}
fill="outline"
className={styles.tagItem}
>
{tag.name}
</Tag>
))}
</div>
)}
</Card>
{/* 微信标签 */}
<Card title="微信标签" className={styles.infoCard}>
{tagsLoading && wechatTagsList.length === 0 ? (
<div className={styles.loadingContainer}>
<SpinLoading color="primary" style={{ fontSize: 24 }} />
<div className={styles.loadingText}>...</div>
</div>
) : wechatTagsList.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<TagOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
) : (
<div className={styles.tagsSection}>
{wechatTagsList.map((tag, index) => (
<Tag
key={index}
color="danger"
fill="outline"
className={styles.tagItem}
>
{tag}
</Tag>
))}
</div>
)}
</Card>
{/* 价值标签 */}
<Card title="价值标签" className={styles.infoCard}>
{user.valueTags && user.valueTags.length > 0 ? (
<div className={styles.valueTagsSection}>
{user.valueTags.map(tag => (
<div key={tag.id} className={styles.valueTagContainer}>
<div className={styles.valueTagRow}>
<Tag
color={tag.color}
fill="outline"
className={styles.tagItem}
>
{tag.icon === "crown" && <CrownOutlined />}
{tag.name}
</Tag>
<span className={styles.rfmScoreText}>
RFM总分: {tag.rfmScore}/15
</span>
</div>
<div className={styles.valueTagRow}>
<span className={styles.valueLevelLabel}>
:
</span>
<Tag color="danger" fill="outline">
{tag.valueLevel}
</Tag>
</div>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>
<CrownOutlined style={{ fontSize: 48, color: "#ccc" }} />
</div>
<div className={styles.emptyText}></div>
<div className={styles.emptyDesc}>
</div>
</div>
)}
</Card>
{/* 添加新标签按钮 */}
<Button block color="primary" className={styles.addTagBtn}>
<TagOutlined />
&nbsp;
</Button>
</div>
)}
</div>
</div>
</Layout>
);
};
export default TrafficPoolDetail;

View File

@@ -1,57 +0,0 @@
import React from "react";
import { Popup, Selector } from "antd-mobile";
import type { PackageOption } from "./data";
interface BatchAddModalProps {
visible: boolean;
onClose: () => void;
packageOptions: PackageOption[];
batchTarget: string;
setBatchTarget: (v: string) => void;
selectedCount: number;
onConfirm: () => void;
}
const BatchAddModal: React.FC<BatchAddModalProps> = ({
visible,
onClose,
packageOptions = [],
batchTarget,
setBatchTarget,
selectedCount,
onConfirm,
}) => (
// <Modal visible={visible} title="批量加入分组" onConfirm={onConfirm}>
// <div style={{ marginBottom: 12 }}>
// <div>选择目标分组</div>
// <Selector
// options={packageOptions.map(p => ({ label: p.name, value: p.id }))}
// value={[batchTarget]}
// onChange={v => setBatchTarget(v[0])}
// />
// </div>
// <div style={{ color: "#888", fontSize: 13 }}>
// 将选中的{selectedCount}个用户加入所选分组
// </div>
// </Modal>
<Popup
visible={visible}
onMaskClick={() => onClose()}
position="bottom"
bodyStyle={{ height: "80vh" }}
>
<div style={{ marginBottom: 12 }}>
<div></div>
<Selector
options={packageOptions.map(p => ({ label: p.name, value: p.id }))}
value={[batchTarget]}
onChange={v => setBatchTarget(v[0])}
/>
</div>
<div style={{ color: "#888", fontSize: 13 }}>
{selectedCount}
</div>
</Popup>
);
export default BatchAddModal;

View File

@@ -1,84 +0,0 @@
import React from "react";
import { Card, Button } from "antd-mobile";
interface DataAnalysisPanelProps {
stats: {
total: number;
highValue: number;
added: number;
pending: number;
failed: number;
addSuccessRate: number;
};
showStats: boolean;
setShowStats: (v: boolean) => void;
}
const DataAnalysisPanel: React.FC<DataAnalysisPanelProps> = ({
stats,
showStats,
setShowStats,
}) => {
if (!showStats) return null;
return (
<div
style={{
background: "#fff",
padding: "16px",
margin: "8px 0",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
}}
>
<div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#1677ff" }}>
{stats.total}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#eb2f96" }}>
{stats.highValue}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
</div>
<div style={{ display: "flex", gap: 16 }}>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#52c41a" }}>
{stats.addSuccessRate}%
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#faad14" }}>
{stats.added}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#bfbfbf" }}>
{stats.pending}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
<Card style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 600, color: "#ff4d4f" }}>
{stats.failed}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</Card>
</div>
<Button
size="small"
style={{ marginTop: 12 }}
onClick={() => setShowStats(false)}
>
</Button>
</div>
);
};
export default DataAnalysisPanel;

View File

@@ -1,169 +0,0 @@
import React, { useState, useEffect } from "react";
import { Popup } from "antd-mobile";
import { Select, Button } from "antd";
import DeviceSelection from "@/components/DeviceSelection";
import type { UserStatus, ScenarioOption } from "./data";
import { fetchScenarioOptions, fetchPackageOptions } from "./api";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
interface FilterModalProps {
visible: boolean;
onClose: () => void;
onConfirm: (filters: {
deviceIds: string[];
packageId: string;
scenarioId: string;
userValue: number;
userStatus: number;
}) => void;
scenarioOptions: ScenarioOption[];
}
const valueLevelOptions = [
{ label: "全部价值", value: 0 },
{ label: "高价值", value: 1 },
{ label: "中价值", value: 2 },
{ label: "低价值", value: 3 },
];
const statusOptions = [
{ label: "全部状态", value: 0 },
{ label: "已添加", value: 1 },
{ label: "待添加", value: 2 },
{ label: "重复", value: 3 },
{ label: "添加失败", value: -1 },
];
const FilterModal: React.FC<FilterModalProps> = ({
visible,
onClose,
onConfirm,
}) => {
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
[],
);
const [packageId, setPackageId] = useState<string>("");
const [scenarioId, setScenarioId] = useState<string>("");
const [userValue, setUserValue] = useState<number>(0);
const [userStatus, setUserStatus] = useState<number>(0);
const [scenarioOptions, setScenarioOptions] = useState<any[]>([]);
const [packageOptions, setPackageOptions] = useState<any[]>([]);
useEffect(() => {
if (visible) {
fetchScenarioOptions()
.then(res => {
setScenarioOptions(Array.isArray(res) ? res : []);
})
.catch(err => {
console.error("获取场景选项失败:", err);
setScenarioOptions([]);
});
fetchPackageOptions()
.then(res => {
setPackageOptions(Array.isArray(res) ? res : []);
})
.catch(err => {
console.error("获取流量池选项失败:", err);
setPackageOptions([]);
});
}
}, [visible]);
const handleApply = () => {
const params = {
deviceIds: selectedDevices.map(d => d.id.toString()),
packageId,
scenarioId,
userValue,
userStatus,
};
console.log(params);
onConfirm(params);
onClose();
};
const handleReset = () => {
setSelectedDevices([]);
setPackageId("");
setScenarioId("");
setUserValue(0);
setUserStatus(0);
};
return (
<Popup
visible={visible}
onMaskClick={onClose}
position="right"
bodyStyle={{ width: "80vw", maxWidth: 360, padding: 24 }}
>
<div style={{ fontWeight: 600, fontSize: 18, marginBottom: 20 }}>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<DeviceSelection
selectedOptions={selectedDevices}
onSelect={setSelectedDevices}
placeholder="选择设备"
showSelectedList={false}
selectedListMaxHeight={120}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={packageId}
onChange={setPackageId}
options={[
{ label: "全部流量池", value: "" },
...packageOptions.map(p => ({ label: p.name, value: p.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={scenarioId}
onChange={setScenarioId}
options={[
{ label: "全部场景", value: "" },
...scenarioOptions.map(s => ({ label: s.name, value: s.id })),
]}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={userValue}
onChange={v => setUserValue(v as number)}
options={valueLevelOptions}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 6 }}></div>
<Select
style={{ width: "100%" }}
value={userStatus}
onChange={v => setUserStatus(Number(v))}
options={statusOptions}
/>
</div>
<div style={{ display: "flex", gap: 12, marginTop: 32 }}>
<Button onClick={handleReset} style={{ flex: 1 }}>
</Button>
<Button type="primary" onClick={handleApply} style={{ flex: 1 }}>
</Button>
</div>
</Popup>
);
};
export default FilterModal;

View File

@@ -1,18 +0,0 @@
import request from "@/api/request";
// 获取流量池列表
export function fetchTrafficPoolList(params: {
page?: number;
pageSize?: number;
keyword?: string;
}) {
return request("/v1/traffic/pool", params, "GET");
}
export async function fetchScenarioOptions() {
return request("/v1/plan/scenes", {}, "GET");
}
export async function fetchPackageOptions() {
return request("/v1/traffic/pool/getPackage", {}, "GET");
}

View File

@@ -1,51 +0,0 @@
// 流量池用户类型
export interface TrafficPoolUser {
id: number;
identifier: string;
mobile: string;
wechatId: string;
fromd: string;
status: number;
createTime: string;
companyId: number;
sourceId: string;
type: number;
nickname: string;
avatar: string;
gender: number;
phone: string;
packages: string[];
tags: string[];
}
// 列表响应类型
export interface TrafficPoolUserListResponse {
list: TrafficPoolUser[];
total: number;
page: number;
pageSize: number;
}
// 设备类型
export interface DeviceOption {
id: string;
name: string;
}
// 分组类型
export interface PackageOption {
id: string;
name: string;
}
// 用户价值类型
export type ValueLevel = "all" | "high" | "medium" | "low";
// 状态类型
export type UserStatus = "all" | "added" | "pending" | "failed" | "duplicate";
// 获客场景类型
export interface ScenarioOption {
id: string;
name: string;
}

View File

@@ -1,182 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import {
fetchTrafficPoolList,
fetchPackageOptions,
fetchScenarioOptions,
} from "./api";
import type { TrafficPoolUser, PackageOption, ScenarioOption } from "./data";
import { Toast } from "antd-mobile";
export function useTrafficPoolListLogic() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<TrafficPoolUser[]>([]);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
// 筛选相关
const [showFilter, setShowFilter] = useState(false);
const [packageOptions, setPackageOptions] = useState<PackageOption[]>([]);
const [scenarioOptions, setScenarioOptions] = useState<ScenarioOption[]>([]);
const [selectedDevices, setSelectedDevices] = useState<any[]>([]);
const [packageId, setPackageId] = useState<number>(0);
const [scenarioId, setScenarioId] = useState<number>(0);
const [userValue, setUserValue] = useState<number>(0);
const [userStatus, setUserStatus] = useState<number>(0);
// 批量相关
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [batchModal, setBatchModal] = useState(false);
const [batchTarget, setBatchTarget] = useState<string>("");
// 数据分析
const [showStats, setShowStats] = useState(false);
const stats = useMemo(() => {
const total = list.length;
const highValue = list.filter(
u => u.tags && u.tags.includes("高价值客户池"),
).length;
const added = list.filter(u => u.status === 1).length;
const pending = list.filter(u => u.status === 0).length;
const failed = list.filter(u => u.status === -1).length;
const addSuccessRate = total ? Math.round((added / total) * 100) : 0;
return { total, highValue, added, pending, failed, addSuccessRate };
}, [list]);
// 获取列表
const getList = async () => {
setLoading(true);
try {
const params: any = {
page,
pageSize,
keyword: search,
packageId,
taskId: scenarioId,
userValue,
addStatus: userStatus,
};
// 添加筛选参数
if (selectedDevices.length > 0) {
params.deviceId = selectedDevices.map(d => d.id).join(",");
}
const res = await fetchTrafficPoolList(params);
setList(res.list || []);
setTotal(res.total || 0);
} catch (error) {
// 忽略请求过于频繁的错误,避免页面崩溃
if (error !== "请求过于频繁,请稍后再试") {
console.error("获取列表失败:", error);
}
} finally {
setLoading(false);
}
};
// 获取筛选项
useEffect(() => {
fetchPackageOptions().then(res => {
setPackageOptions(res.list || []);
});
fetchScenarioOptions().then(res => {
setScenarioOptions(res.list || []);
});
}, []);
// 筛选条件变化时刷新列表
useEffect(() => {
getList();
// eslint-disable-next-line
}, [
page,
search,
selectedDevices,
packageId,
scenarioId,
userValue,
userStatus,
]);
// 全选/反选
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(list.map(item => item.id));
} else {
setSelectedIds([]);
}
};
// 单选
const handleSelect = (id: number, checked: boolean) => {
setSelectedIds(prev =>
checked ? [...prev, id] : prev.filter(i => i !== id),
);
};
// 批量加入分组/流量池
const handleBatchAdd = () => {
if (!batchTarget) {
Toast.show({ content: "请选择目标分组", position: "top" });
return;
}
// TODO: 调用后端批量接口,这里仅模拟
Toast.show({
content: `已将${selectedIds.length}个用户加入${packageOptions.find(p => p.id === batchTarget)?.name || ""}`,
position: "top",
});
setBatchModal(false);
setSelectedIds([]);
setBatchTarget("");
// 可刷新列表
};
// 筛选重置
const resetFilter = () => {
setSelectedDevices([]);
setPackageId(0);
setScenarioId(0);
setUserValue(0);
setUserStatus(0);
};
return {
loading,
list,
page,
setPage,
pageSize,
total,
search,
setSearch,
showFilter,
setShowFilter,
packageOptions,
scenarioOptions,
selectedDevices,
setSelectedDevices,
packageId,
setPackageId,
scenarioId,
setScenarioId,
userValue,
setUserValue,
userStatus,
setUserStatus,
selectedIds,
setSelectedIds,
handleSelectAll,
handleSelect,
batchModal,
setBatchModal,
batchTarget,
setBatchTarget,
handleBatchAdd,
showStats,
setShowStats,
stats,
getList,
resetFilter,
};
}

View File

@@ -1,65 +0,0 @@
.listWrap {
padding: 12px;
}
.cardContent {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
.checkbox {
position: absolute;
top: 0;
left: 0;
}
.cardWrap {
background: #fff;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-bottom: 12px;
}
.card {
margin-bottom: 12px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #222;
}
.desc {
font-size: 13px;
color: #888;
margin: 6px 0 4px 0;
}
.count {
font-size: 13px;
color: #1677ff;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 16px 0;
}
.pagination button {
background: #f5f5f5;
border: none;
border-radius: 4px;
padding: 4px 12px;
color: #1677ff;
cursor: pointer;
}
.pagination button:disabled {
color: #ccc;
cursor: not-allowed;
}

View File

@@ -1,260 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import Layout from "@/components/Layout/Layout";
import {
SearchOutlined,
ReloadOutlined,
BarChartOutlined,
} from "@ant-design/icons";
import { Input, Button, Checkbox, Pagination } from "antd";
import styles from "./index.module.scss";
import { Empty, Avatar } from "antd-mobile";
import { useNavigate } from "react-router-dom";
import NavCommon from "@/components/NavCommon";
import { useTrafficPoolListLogic } from "./dataAnyx";
import DataAnalysisPanel from "./DataAnalysisPanel";
import FilterModal from "./FilterModal";
import BatchAddModal from "./BatchAddModal";
const defaultAvatar =
"https://cdn.jsdelivr.net/gh/maokaka/static/avatar-default.png";
const TrafficPoolList: React.FC = () => {
const navigate = useNavigate();
const {
loading,
list,
page,
setPage,
total,
search,
setSearch,
showFilter,
setShowFilter,
packageOptions,
scenarioOptions,
setSelectedDevices,
setPackageId,
setScenarioId,
setUserValue,
setUserStatus,
selectedIds,
handleSelectAll,
handleSelect,
batchModal,
setBatchModal,
batchTarget,
setBatchTarget,
handleBatchAdd,
showStats,
setShowStats,
stats,
getList,
} = useTrafficPoolListLogic();
// 搜索防抖处理
const [searchInput, setSearchInput] = useState(search);
const debouncedSearch = useCallback(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
}, 500); // 500ms 防抖延迟
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
useEffect(() => {
const cleanup = debouncedSearch();
return cleanup;
}, [debouncedSearch]);
return (
<Layout
loading={loading}
header={
<>
<NavCommon
title="流量池用户列表"
right={
<Button
onClick={() => setShowStats(s => !s)}
style={{ marginLeft: 8 }}
>
<BarChartOutlined /> {showStats ? "收起分析" : "数据分析"}
</Button>
}
/>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索计划名称"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
onClick={getList}
loading={loading}
size="large"
icon={<ReloadOutlined />}
></Button>
</div>
{/* 数据分析面板 */}
<DataAnalysisPanel
stats={stats}
showStats={showStats}
setShowStats={setShowStats}
/>
{/* 批量操作栏 */}
<div
style={{
display: "flex",
alignItems: "center",
padding: "8px 12px",
background: "#fff",
borderBottom: "1px solid #f0f0f0",
}}
>
<Checkbox
checked={selectedIds.length === list.length && list.length > 0}
onChange={e => handleSelectAll(e.target.checked)}
style={{ marginRight: 8 }}
/>
<span></span>
{selectedIds.length > 0 && (
<>
<span
style={{ marginLeft: 16, color: "#1677ff" }}
>{`已选${selectedIds.length}`}</span>
<Button
size="small"
color="primary"
style={{ marginLeft: 16 }}
onClick={() => setBatchModal(true)}
>
</Button>
</>
)}
<div style={{ flex: 1 }} />
<Button
size="small"
style={{ marginLeft: 8 }}
onClick={() => setShowFilter(true)}
>
</Button>
</div>
</>
}
footer={
<div className="pagination-container">
<Pagination
current={page}
pageSize={20}
total={total}
showSizeChanger={false}
onChange={setPage}
/>
</div>
}
>
{/* 批量加入分组弹窗 */}
<BatchAddModal
visible={batchModal}
onClose={() => setBatchModal(false)}
packageOptions={packageOptions}
batchTarget={batchTarget}
setBatchTarget={setBatchTarget}
selectedCount={selectedIds.length}
onConfirm={handleBatchAdd}
/>
{/* 筛选弹窗 */}
<FilterModal
visible={showFilter}
onClose={() => setShowFilter(false)}
onConfirm={filters => {
// 更新筛选条件
setSelectedDevices(
filters.deviceIds.map(id => ({
id: parseInt(id),
memo: "",
imei: "",
wechatId: "",
status: "offline" as const,
})),
);
setPackageId(filters.packageId ? parseInt(filters.packageId) : 0);
setScenarioId(filters.scenarioId ? parseInt(filters.scenarioId) : 0);
setUserValue(filters.userValue);
setUserStatus(filters.userStatus);
// 重新获取列表
getList();
}}
scenarioOptions={scenarioOptions}
/>
<div className={styles.listWrap}>
{list.length === 0 && !loading ? (
<Empty description="暂无数据" />
) : (
<div>
{list.map(item => (
<div key={item.id} className={styles.cardWrap}>
<div
className={styles.card}
style={{ cursor: "pointer" }}
onClick={() =>
navigate(
`/mine/traffic-pool/detail/${item.sourceId}/${item.id}`,
)
}
>
<div className={styles.cardContent}>
<Checkbox
checked={selectedIds.includes(item.id)}
onChange={e => handleSelect(item.id, e.target.checked)}
style={{ marginRight: 8 }}
onClick={e => e.stopPropagation()}
className={styles.checkbox}
/>
<Avatar
src={item.avatar || defaultAvatar}
style={{ "--size": "60px" }}
/>
<div style={{ flex: 1 }}>
<div className={styles.title}>
{item.nickname || item.identifier}
{/* 性别icon可自行封装 */}
</div>
<div className={styles.desc}>
{item.wechatId || "-"}
</div>
<div className={styles.desc}>
{item.fromd || "-"}
</div>
<div className={styles.desc}>
{item.packages && item.packages.length
? item.packages.join("")
: "-"}
</div>
<div className={styles.desc}>
{item.createTime}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</Layout>
);
};
export default TrafficPoolList;

View File

@@ -1,29 +0,0 @@
import request from "@/api/request";
// 获取微信号详情
export function getWechatAccountDetail(id: string) {
return request("/v1/wechats/getWechatInfo", { wechatId: id }, "GET");
}
// 获取微信号好友列表
export function getWechatFriends(params: {
wechatAccount: string;
page: number;
limit: number;
keyword?: string;
}) {
return request(
`/v1/wechats/${params.wechatAccount}/friends`,
{
page: params.page,
limit: params.limit,
keyword: params.keyword,
},
"GET",
);
}
// 获取微信好友详情
export function getWechatFriendDetail(id: string) {
return request("/v1/WechatFriend/detail", { id }, "GET");
}

View File

@@ -1,54 +0,0 @@
export interface WechatAccountSummary {
accountAge: string;
activityLevel: {
allTimes: number;
dayTimes: number;
};
accountWeight: {
scope: number;
ageWeight: number;
activityWeigth: number;
restrictWeight: number;
realNameWeight: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: {
id: number;
level: string;
reason: string;
date: string;
}[];
}
export interface Friend {
id: string;
avatar: string;
nickname: string;
wechatId: string;
remark: string;
addTime: string;
lastInteraction: string;
tags: Array<{
id: string;
name: string;
color: string;
}>;
region: string;
source: string;
notes: string;
}
export interface WechatFriendDetail {
id: number;
avatar: string;
nickname: string;
region: string;
wechatId: string;
addDate: string;
tags: string[];
memo: string;
source: string;
}

View File

@@ -1,740 +0,0 @@
.wechat-account-detail-page {
padding: 16px;
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.account-card {
margin-bottom: 16px;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e8f4fd;
.account-info {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
.avatar-section {
position: relative;
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 4px solid #e8f4fd;
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
&.status-normal {
background: #52c41a;
}
&.status-abnormal {
background: #ff4d4f;
}
}
}
.info-section {
flex: 1;
min-width: 0;
.name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.nickname {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
}
}
.wechat-id {
font-size: 14px;
color: #666;
margin: 0 0 12px 0;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
.action-btn {
font-size: 12px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #bfbfbf;
}
}
}
}
}
}
.tabs-card {
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e8f4fd;
.tabs {
.adm-tabs-header {
border-bottom: 1px solid #e8e8e8;
background: #fff;
border-radius: 16px 16px 0 0;
.adm-tabs-tab {
font-size: 14px;
font-weight: 500;
color: #666;
transition: all 0.2s;
&.adm-tabs-tab-active {
color: #1677ff;
font-weight: 600;
}
}
.adm-tabs-tab-line {
background: #1677ff;
height: 2px;
}
}
.adm-tabs-content {
padding: 16px;
}
}
}
.overview-content {
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
.info-card {
background: linear-gradient(135deg, #e6f7ff, #f0f8ff);
padding: 16px;
border-radius: 12px;
border: 1px solid #bae7ff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.info-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.info-icon {
font-size: 16px;
color: #1677ff;
padding: 6px;
background: #e6f7ff;
border-radius: 8px;
}
.info-title {
flex: 1;
.title-text {
font-size: 12px;
font-weight: 600;
color: #1677ff;
margin-bottom: 2px;
}
.title-sub {
font-size: 10px;
color: #666;
}
}
}
.info-value {
text-align: right;
font-size: 18px;
font-weight: 700;
color: #1677ff;
.value-unit {
font-size: 12px;
color: #666;
margin-left: 4px;
}
}
}
}
.weight-card {
background: linear-gradient(135deg, #fff7e6, #fff2d9);
padding: 20px;
border-radius: 12px;
border: 1px solid #ffd591;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.weight-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.weight-icon {
font-size: 16px;
color: #fa8c16;
margin-right: 8px;
}
.weight-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #fa8c16;
}
.weight-score {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 16px;
font-weight: 600;
&.text-green-600 {
background: #f6ffed;
color: #52c41a;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
.score-value {
font-size: 20px;
font-weight: 700;
}
.score-unit {
font-size: 12px;
}
}
}
.weight-description {
font-size: 12px;
color: #fa8c16;
background: #fff7e6;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #ffd591;
margin-bottom: 16px;
}
.weight-items {
.weight-item {
display: flex;
align-items: center;
margin-bottom: 12px;
.item-label {
flex-shrink: 0;
width: 64px;
font-size: 12px;
font-weight: 500;
color: #fa8c16;
}
.progress-bar {
flex: 1;
margin: 0 12px;
height: 8px;
background: #ffd591;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fa8c16, #ffa940);
border-radius: 4px;
transition: width 0.5s ease;
}
}
.item-value {
flex-shrink: 0;
width: 40px;
font-size: 12px;
font-weight: 500;
color: #fa8c16;
text-align: right;
}
}
}
}
.restrictions-card {
background: linear-gradient(135deg, #fff2f0, #fff1f0);
padding: 16px;
border-radius: 12px;
border: 1px solid #ffccc7;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.restrictions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.restrictions-icon {
font-size: 16px;
color: #ff4d4f;
margin-right: 8px;
}
.restrictions-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #ff4d4f;
}
.restrictions-btn {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
}
}
.restrictions-list {
.restriction-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ffccc7;
&:last-child {
border-bottom: none;
}
.restriction-info {
flex: 1;
.restriction-reason {
display: block;
font-size: 12px;
color: #333;
margin-bottom: 2px;
}
.restriction-date {
font-size: 10px;
color: #666;
}
}
.restriction-level {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-gray-600 {
background: #f5f5f5;
color: #666;
}
}
}
}
}
}
.friends-content {
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
.search-input-wrapper {
flex: 1;
.adm-input {
border-radius: 8px;
border: 1px solid #d9d9d9;
}
}
.search-btn {
padding: 8px 12px;
border-radius: 8px;
}
.refresh-btn {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #bfbfbf;
}
}
}
.friends-list {
.empty {
text-align: center;
color: #999;
padding: 40px 0;
font-size: 14px;
}
.error {
text-align: center;
color: #ff4d4f;
padding: 40px 0;
p {
margin-bottom: 12px;
}
}
.friend-item {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 8px;
}
.friend-item-static {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 8px;
}
.friend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
}
.friend-info {
flex: 1;
min-width: 0;
.friend-header {
display: flex;
align-items: center;
justify-content: space-between;
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;
}
}
}
}
.loading-more {
display: flex;
justify-content: center;
padding: 16px 0;
}
}
}
.popup-content {
padding: 20px;
max-height: 80vh;
overflow-y: auto;
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.popup-description {
font-size: 14px;
color: #666;
margin-bottom: 16px;
line-height: 1.5;
}
.popup-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
}
.restrictions-detail {
.restriction-detail-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.restriction-detail-info {
flex: 1;
.restriction-detail-reason {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.restriction-detail-date {
font-size: 12px;
color: #666;
}
}
.restriction-detail-level {
font-size: 12px;
padding: 4px 8px;
border-radius: 8px;
font-weight: 500;
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-gray-600 {
background: #f5f5f5;
color: #666;
}
}
}
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
margin-top: 16px;
border-top: 1px solid #f0f0f0;
}
.summary-grid {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.summary-item {
flex: 1;
background: #fafbfc;
border-radius: 10px;
padding: 16px 0 8px 0;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.summary-value {
font-size: 24px;
font-weight: 600;
color: #222;
margin-bottom: 2px;
}
.summary-value-green {
font-size: 24px;
font-weight: 600;
color: #27ae60;
margin-bottom: 2px;
}
.summary-value-blue {
font-size: 24px;
font-weight: 600;
color: #3498db;
margin-bottom: 2px;
}
.summary-label {
font-size: 13px;
color: #888;
}
.summary-progress-row {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
margin-bottom: 4px;
gap: 8px;
}
.summary-progress-text {
font-weight: 500;
color: #222;
}
.summary-progress-bar {
width: 100%;
margin-bottom: 16px;
}
.progress-bg {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 8px;
background: #3498db;
border-radius: 6px 0 0 6px;
transition: width 0.3s;
}
.device-card {
background: #fafbfc;
border-radius: 10px;
padding: 16px;
margin-top: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.device-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
color: #222;
}
.device-row {
display: flex;
align-items: center;
font-size: 14px;
margin-bottom: 4px;
}
.device-label {
color: #888;
min-width: 70px;
margin-right: 8px;
}

View File

@@ -1,557 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
NavBar,
Card,
Tabs,
Button,
SpinLoading,
Popup,
Toast,
Avatar,
Tag,
} from "antd-mobile";
import { Input, Pagination } from "antd";
import NavCommon from "@/components/NavCommon";
import {
SearchOutlined,
ReloadOutlined,
UserOutlined,
ClockCircleOutlined,
MessageOutlined,
StarOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./detail.module.scss";
import { getWechatAccountDetail, getWechatFriends } from "./api";
import { WechatAccountSummary, Friend } from "./data";
const WechatAccountDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [accountSummary, setAccountSummary] =
useState<WechatAccountSummary | null>(null);
const [accountInfo, setAccountInfo] = useState<any>(null);
const [showRestrictions, setShowRestrictions] = useState(false);
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("overview");
const [isLoading, setIsLoading] = useState(false);
const [loadingInfo, setLoadingInfo] = useState(true);
// 好友列表相关状态
const [friends, setFriends] = useState<Friend[]>([]);
const [friendsPage, setFriendsPage] = useState(1);
const [friendsTotal, setFriendsTotal] = useState(0);
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
// 获取基础信息
const fetchAccountInfo = useCallback(async () => {
if (!id) return;
setLoadingInfo(true);
try {
const response = await getWechatAccountDetail(id);
if (response && response.userInfo) {
setAccountInfo(response.userInfo);
// 构造 summary 数据结构
setAccountSummary({
accountAge: response.accountAge,
activityLevel: response.activityLevel,
accountWeight: response.accountWeight,
statistics: response.statistics,
restrictions: response.restrictions || [],
});
} else {
Toast.show({
content: "获取账号信息失败",
position: "top",
});
}
} catch (e) {
Toast.show({ content: "获取账号信息失败", position: "top" });
} finally {
setLoadingInfo(false);
}
}, [id]);
// 获取好友列表 - 封装为独立函数
const fetchFriendsList = useCallback(
async (page: number = 1, keyword: string = "") => {
if (!id) return;
setIsFetchingFriends(true);
setHasFriendLoadError(false);
try {
const response = await getWechatFriends({
wechatAccount: id,
page: page,
limit: 20,
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 || "",
}));
setFriends(newFriends);
setFriendsTotal(response.total);
setFriendsPage(page);
setIsFriendsEmpty(newFriends.length === 0);
} catch (error) {
console.error("获取好友列表失败:", error);
setHasFriendLoadError(true);
setFriends([]);
setIsFriendsEmpty(true);
Toast.show({
content: "获取好友列表失败,请检查网络连接",
position: "top",
});
} finally {
setIsFetchingFriends(false);
}
},
[id],
);
// 搜索好友
const handleSearch = useCallback(() => {
setFriendsPage(1);
fetchFriendsList(1, searchQuery);
}, [searchQuery, fetchFriendsList]);
// 刷新好友列表
const handleRefreshFriends = useCallback(() => {
fetchFriendsList(friendsPage, searchQuery);
}, [friendsPage, searchQuery, fetchFriendsList]);
// 分页切换
const handlePageChange = useCallback(
(page: number) => {
setFriendsPage(page);
fetchFriendsList(page, searchQuery);
},
[searchQuery, fetchFriendsList],
);
// 初始化数据
useEffect(() => {
if (id) {
fetchAccountInfo();
}
}, [id, fetchAccountInfo]);
// 监听标签切换 - 只在切换到好友列表时请求一次
useEffect(() => {
if (activeTab === "friends" && id) {
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriendsList(1, searchQuery);
}
}, [activeTab, id, fetchFriendsList, searchQuery]);
// 工具函数
const getRandomTagColor = (): string => {
const colors = [
"bg-blue-100 text-blue-800",
"bg-green-100 text-green-800",
"bg-red-100 text-red-800",
"bg-pink-100 text-pink-800",
"bg-emerald-100 text-emerald-800",
"bg-amber-100 text-amber-800",
];
return colors[Math.floor(Math.random() * colors.length)];
};
const calculateAccountAge = (registerTime: string) => {
const registerDate = new Date(registerTime);
const now = new Date();
const diffTime = Math.abs(now.getTime() - registerDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const years = Math.floor(diffDays / 365);
const months = Math.floor((diffDays % 365) / 30);
return { years, months };
};
const formatAccountAge = (age: { years: number; months: number }) => {
if (age.years > 0) {
return `${age.years}${age.months}个月`;
}
return `${age.months}个月`;
};
const getWeightColor = (weight: number) => {
if (weight >= 80) return "text-green-600";
if (weight >= 60) return "text-yellow-600";
return "text-red-600";
};
const getWeightDescription = (weight: number) => {
if (weight >= 80) return "账号质量优秀,可以正常使用";
if (weight >= 60) return "账号质量良好,需要注意使用频率";
return "账号质量较差,建议谨慎使用";
};
const handleTransferFriends = () => {
setShowTransferConfirm(true);
};
const confirmTransferFriends = () => {
Toast.show({
content: "好友转移计划已创建,请在场景获客中查看详情",
position: "top",
});
setShowTransferConfirm(false);
navigate("/scenarios");
};
const getRestrictionLevelColor = (level: string) => {
switch (level) {
case "high":
return "text-red-600";
case "medium":
return "text-yellow-600";
default:
return "text-gray-600";
}
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date
.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/\//g, "-");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
};
return (
<Layout header={<NavCommon title="微信号详情" />} loading={loadingInfo}>
<div className={style["wechat-account-detail-page"]}>
{/* 账号基本信息卡片 */}
<Card className={style["account-card"]}>
<div className={style["account-info"]}>
<div className={style["avatar-section"]}>
<Avatar
src={accountInfo?.avatar || "/placeholder.svg"}
className={style["avatar"]}
/>
</div>
<div className={style["info-section"]}>
<div className={style["name-row"]}>
<h2 className={style["nickname"]}>
{accountInfo?.nickname || "未知昵称"}
</h2>
</div>
<p className={style["wechat-id"]}>
{accountInfo?.wechatId || "未知"}
</p>
<div className={style["action-buttons"]}>
<Button
size="small"
fill="outline"
className={style["action-btn"]}
onClick={handleTransferFriends}
>
<UserOutlined />
</Button>
</div>
</div>
</div>
</Card>
{/* 标签页 */}
<Card className={style["tabs-card"]}>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
className={style["tabs"]}
>
<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>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
+{accountSummary?.statistics.todayAdded ?? "-"}
</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>
<div className={style["summary-label"]}></div>
</div>
<div className={style["summary-item"]}>
<div className={style["summary-value-green"]}>
{accountInfo?.activity?.yesterdayMsgCount ?? "-"}
</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>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceType ?? "-"}</span>
</div>
<div className={style["device-row"]}>
<span className={style["device-label"]}>:</span>
<span>{accountInfo?.deviceVersion ?? "-"}</span>
</div>
</div>
</div>
</Tabs.Tab>
<Tabs.Tab
title={`好友列表${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
key="friends"
>
<div className={style["friends-content"]}>
{/* 搜索栏 */}
<div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}>
<Input
placeholder="搜索好友昵称/微信号"
value={searchQuery}
onChange={(e: any) => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
onPressEnter={handleSearch}
/>
</div>
<Button
size="small"
onClick={handleRefreshFriends}
loading={isFetchingFriends}
className={style["refresh-btn"]}
>
<ReloadOutlined />
</Button>
</div>
{/* 好友列表 */}
<div className={style["friends-list"]}>
{isFetchingFriends && friends.length === 0 ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : isFriendsEmpty ? (
<div className={style["empty"]}></div>
) : hasFriendLoadError ? (
<div className={style["error"]}>
<p></p>
<Button
size="small"
onClick={() =>
fetchFriendsList(friendsPage, searchQuery)
}
>
</Button>
</div>
) : (
<>
{friends.map(friend => (
<div key={friend.id} className={style["friend-item"]}>
<Avatar
src={friend.avatar}
className={style["friend-avatar"]}
/>
<div className={style["friend-info"]}>
<div className={style["friend-header"]}>
<div className={style["friend-name"]}>
{friend.nickname}
{friend.remark && (
<span className={style["friend-remark"]}>
({friend.remark})
</span>
)}
</div>
</div>
<div className={style["friend-wechat-id"]}>
{friend.wechatId}
</div>
<div className={style["friend-tags"]}>
{friend.tags?.map((tag, index) => (
<Tag
key={index}
className={style["friend-tag"]}
>
{typeof tag === "string" ? tag : tag.name}
</Tag>
))}
</div>
</div>
</div>
))}
</>
)}
</div>
{/* 分页组件 */}
{friendsTotal > 20 &&
!isFriendsEmpty &&
!hasFriendLoadError && (
<div className={style["pagination-wrapper"]}>
<Pagination
total={Math.ceil(friendsTotal / 20)}
current={friendsPage}
onChange={handlePageChange}
showText={true}
/>
</div>
)}
</div>
</Tabs.Tab>
</Tabs>
</Card>
</div>
{/* 限制记录详情弹窗 */}
<Popup
visible={showRestrictions}
onMaskClick={() => setShowRestrictions(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={() => setShowRestrictions(false)}
>
</Button>
</div>
<p className={style["popup-description"]}>24</p>
{accountSummary && accountSummary.restrictions && (
<div className={style["restrictions-detail"]}>
{accountSummary.restrictions.map(restriction => (
<div
key={restriction.id}
className={style["restriction-detail-item"]}
>
<div className={style["restriction-detail-info"]}>
<div className={style["restriction-detail-reason"]}>
{restriction.reason}
</div>
<div className={style["restriction-detail-date"]}>
{formatDateTime(restriction.date)}
</div>
</div>
<span
className={`${style["restriction-detail-level"]} ${getRestrictionLevelColor(restriction.level)}`}
>
{restriction.level === "high"
? "高风险"
: restriction.level === "medium"
? "中风险"
: "低风险"}
</span>
</div>
))}
</div>
)}
</div>
</Popup>
{/* 好友转移确认弹窗 */}
<Popup
visible={showTransferConfirm}
onMaskClick={() => setShowTransferConfirm(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
</div>
<p className={style["popup-description"]}>
</p>
<div className={style["popup-actions"]}>
<Button block color="primary" onClick={confirmTransferFriends}>
</Button>
<Button
block
color="danger"
fill="outline"
onClick={() => setShowTransferConfirm(false)}
>
</Button>
</div>
</div>
</Popup>
{/* 好友详情弹窗 */}
{/* Removed */}
</Layout>
);
};
export default WechatAccountDetail;

View File

@@ -1,31 +0,0 @@
import request from "@/api/request";
// 获取微信号列表
export function getWechatAccounts(params: {
page: number;
page_size: number;
keyword?: string;
wechatStatus?: string;
}) {
return request("v1/wechats", params, "GET");
}
// 获取微信号详情
export function getWechatAccountDetail(id: string) {
return request("v1/WechatAccount/detail", { id }, "GET");
}
// 获取微信号好友列表
export function getWechatFriends(params: {
wechatAccountKeyword: string;
pageIndex: number;
pageSize: number;
friendKeyword?: string;
}) {
return request("v1/WechatFriend/friendlistData", params, "POST");
}
// 获取微信好友详情
export function getWechatFriendDetail(id: string) {
return request("v1/WechatFriend/detail", { id }, "GET");
}

View File

@@ -1,171 +0,0 @@
.wechat-accounts-page {
padding: 0 12px;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #222;
}
.card-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.account-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 14px 14px 10px 14px;
transition: box-shadow 0.2s;
cursor: pointer;
border: 1px solid #f0f0f0;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-color: #e6f7ff;
}
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.avatar-wrapper {
position: relative;
margin-right: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid #e6f0fa;
box-shadow: 0 0 0 2px #1677ff33;
object-fit: cover;
}
.status-dot-normal {
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
background: #52c41a;
border: 2px solid #fff;
border-radius: 50%;
}
.status-dot-abnormal {
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
background: #ff4d4f;
border: 2px solid #fff;
border-radius: 50%;
}
.header-info {
flex: 1;
min-width: 0;
}
.nickname-row {
display: flex;
align-items: center;
gap: 8px;
}
.nickname {
font-weight: 600;
font-size: 16px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-label-normal {
background: #e6fffb;
color: #13c2c2;
font-size: 12px;
border-radius: 8px;
padding: 2px 8px;
margin-left: 4px;
}
.status-label-abnormal {
background: #fff1f0;
color: #ff4d4f;
font-size: 12px;
border-radius: 8px;
padding: 2px 8px;
margin-left: 4px;
}
.wechat-id {
color: #888;
font-size: 13px;
margin-top: 2px;
}
.card-action {
margin-left: 8px;
}
.card-body {
margin-top: 2px;
}
.row-group {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
gap: 8px;
}
.row-item {
font-size: 13px;
color: #555;
display: flex;
align-items: center;
gap: 2px;
}
.strong {
font-weight: 600;
color: #222;
}
.strong-green {
font-weight: 600;
color: #52c41a;
}
.progress-bar {
margin: 6px 0 8px 0;
}
.progress-bg {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 8px;
background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%);
border-radius: 6px;
transition: width 0.3s;
}
.pagination {
margin: 16px 0 0 0;
display: flex;
justify-content: center;
}
.popup-content {
padding: 16px 0 8px 0;
}
.popup-content img {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.empty {
text-align: center;
color: #999;
padding: 48px 0 32px 0;
font-size: 15px;
}

View File

@@ -1,250 +0,0 @@
import React, { useState, useEffect } from "react";
import { Button, SpinLoading, Toast } from "antd-mobile";
import { Pagination, Input, Tooltip } from "antd";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { useNavigate, useSearchParams } from "react-router-dom";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { getWechatAccounts } from "./api";
import NavCommon from "@/components/NavCommon";
interface WechatAccount {
id: number;
nickname: string;
avatar: string;
wechatId: string;
deviceId: number;
times: number; // 今日可添加
addedCount: number; // 今日新增
wechatStatus: number; // 1正常 0异常
totalFriend: number;
deviceMemo: string; // 设备名
activeTime: string; // 最后活跃
}
const PAGE_SIZE = 10;
const WechatAccounts: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
// 获取路由参数 wechatStatus
const wechatStatus = searchParams.get("wechatStatus");
const fetchAccounts = async (page = 1, keyword = "") => {
setIsLoading(true);
try {
const params: any = {
page,
page_size: PAGE_SIZE,
keyword,
};
// 如果有 wechatStatus 参数,添加到请求参数中
if (wechatStatus) {
params.wechatStatus = wechatStatus;
}
const res = await getWechatAccounts(params);
if (res && res.list) {
setAccounts(res.list);
setTotalAccounts(res.total || 0);
} else {
setAccounts([]);
setTotalAccounts(0);
}
} catch (e) {
Toast.show({ content: "获取微信号失败", position: "top" });
setAccounts([]);
setTotalAccounts(0);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAccounts(currentPage, searchTerm);
// eslint-disable-next-line
}, [currentPage]);
const handleSearch = () => {
setCurrentPage(1);
fetchAccounts(1, searchTerm);
};
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchAccounts(currentPage, searchTerm);
setIsRefreshing(false);
Toast.show({ content: "刷新成功", position: "top" });
};
const handleAccountClick = (account: WechatAccount) => {
navigate(`/wechat-accounts/detail/${account.wechatId}`);
};
const handleTransferFriends = (account: WechatAccount) => {
// TODO: 实现好友转移弹窗或跳转
Toast.show({ content: `好友转移:${account.nickname}` });
};
return (
<Layout
header={
<>
<NavCommon
title={wechatStatus === "1" ? "在线微信号" : "微信号管理"}
/>
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索微信号/昵称"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
onPressEnter={handleSearch}
/>
</div>
<Button
size="small"
onClick={handleRefresh}
loading={isRefreshing}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
</>
}
>
<div className={style["wechat-accounts-page"]}>
{isLoading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : accounts.length === 0 ? (
<div className={style["empty"]}></div>
) : (
<div className={style["card-list"]}>
{accounts.map(account => {
const percent =
account.times > 0
? Math.min((account.addedCount / account.times) * 100, 100)
: 0;
return (
<div
key={account.id}
className={style["account-card"]}
onClick={() => handleAccountClick(account)}
>
<div className={style["card-header"]}>
<div className={style["avatar-wrapper"]}>
<img
src={account.avatar}
alt={account.nickname}
className={style["avatar"]}
/>
<span
className={
account.wechatStatus === 1
? style["status-dot-normal"]
: style["status-dot-abnormal"]
}
/>
</div>
<div className={style["header-info"]}>
<div className={style["nickname-row"]}>
<span className={style["nickname"]}>
{account.nickname}
</span>
<span
className={
account.wechatStatus === 1
? style["status-label-normal"]
: style["status-label-abnormal"]
}
>
{account.wechatStatus === 1 ? "正常" : "异常"}
</span>
</div>
<div className={style["wechat-id"]}>
{account.wechatId}
</div>
</div>
</div>
<div className={style["card-body"]}>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span className={style["strong"]}>
{account.totalFriend}
</span>
</div>
<div className={style["row-item"]}>
<span></span>
<span className={style["strong-green"]}>
+{account.addedCount}
</span>
</div>
</div>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span>{account.times}</span>
</div>
<div className={style["row-item"]}>
<Tooltip title={`每日最多添加 ${account.times} 个好友`}>
<span></span>
<span>
{account.addedCount}/{account.times}
</span>
</Tooltip>
</div>
</div>
<div className={style["progress-bar"]}>
<div className={style["progress-bg"]}>
<div
className={style["progress-fill"]}
style={{ width: `${percent}%` }}
/>
</div>
</div>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span>{account.deviceMemo || "-"}</span>
</div>
<div className={style["row-item"]}>
<span></span>
<span>{account.activeTime}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className={style["pagination"]}>
{totalAccounts > PAGE_SIZE && (
<Pagination
total={Math.ceil(totalAccounts / PAGE_SIZE)}
current={currentPage}
onChange={setCurrentPage}
/>
)}
</div>
</div>
</Layout>
);
};
export default WechatAccounts;