refactor(profile): 重构用户设置页面为个人资料页面
将用户设置功能从/mine/setting迁移到/profile路径 删除不再使用的mine模块相关代码 更新路由配置以指向新的个人资料页面
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
记录
|
||||
</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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"]}>更新时间:2025年8月1日</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;
|
||||
@@ -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. **性能优化**:避免频繁的设置更新影响性能
|
||||
|
||||
## 未来规划
|
||||
|
||||
- [ ] 多语言支持
|
||||
- [ ] 设置导入导出
|
||||
- [ ] 云端同步设置
|
||||
- [ ] 设置备份恢复
|
||||
- [ ] 高级设置选项
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
添加新标签
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPoolDetail;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user