feat: 本次提交更新内容如下

更新
This commit is contained in:
笔记本里的永平
2025-07-21 09:55:37 +08:00
parent 5122a0965a
commit f6f9e45198
7 changed files with 398 additions and 264 deletions

View File

@@ -1,21 +1,30 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { Bell, Smartphone, Users, Activity, MessageSquare, TrendingUp } from 'lucide-react'; import {
import Chart from 'chart.js/auto'; Bell,
import Layout from '@/components/Layout'; Smartphone,
import BottomNav from '@/components/BottomNav'; Users,
import UnifiedHeader, { HeaderPresets } from '@/components/UnifiedHeader'; Activity,
import { Card } from '@/components/ui/card'; MessageSquare,
import { Progress } from '@/components/ui/progress'; TrendingUp,
import '@/components/Layout.css'; } from "lucide-react";
import Chart from "chart.js/auto";
import Layout from "@/components/Layout";
import BottomNav from "@/components/BottomNav";
import UnifiedHeader, { HeaderPresets } from "@/components/UnifiedHeader";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import "@/components/Layout.css";
// API接口定义 // API接口定义
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "https://ckbapi.quwanzhi.com"; const API_BASE_URL =
process.env.REACT_APP_API_BASE_URL || "https://ckbapi.quwanzhi.com";
// 统一的API请求客户端 // 统一的API请求客户端
async function apiRequest<T>(url: string): Promise<T> { async function apiRequest<T>(url: string): Promise<T> {
try { try {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; const token =
typeof window !== "undefined" ? localStorage.getItem("token") : null;
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
@@ -99,7 +108,7 @@ export default function Home() {
growth: 12, growth: 12,
}, },
{ {
id: "xiaohongshu", id: "xiaohongshu",
name: "小红书获客", name: "小红书获客",
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png", icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
color: "bg-red-100 text-red-600", color: "bg-red-100 text-red-600",
@@ -135,7 +144,7 @@ export default function Home() {
}, },
{ {
title: "群发任务", title: "群发任务",
value: "8", value: "8",
icon: <Users className="h-4 w-4" />, icon: <Users className="h-4 w-4" />,
color: "text-orange-600", color: "text-orange-600",
path: "/workspace/group-push", path: "/workspace/group-push",
@@ -180,10 +189,11 @@ export default function Home() {
// 尝试请求API数据 // 尝试请求API数据
try { try {
// 并行请求多个接口 // 并行请求多个接口
const [deviceStatsResult, wechatStatsResult] = await Promise.allSettled([ const [deviceStatsResult, wechatStatsResult] =
apiRequest(`${API_BASE_URL}/v1/dashboard/device-stats`), await Promise.allSettled([
apiRequest(`${API_BASE_URL}/v1/dashboard/wechat-stats`), apiRequest(`${API_BASE_URL}/v1/dashboard/device-stats`),
]); apiRequest(`${API_BASE_URL}/v1/dashboard/wechat-stats`),
]);
const newStats = { const newStats = {
totalDevices: 0, totalDevices: 0,
@@ -213,7 +223,9 @@ export default function Home() {
setStats(newStats); setStats(newStats);
} catch (apiError) { } catch (apiError) {
console.warn("API请求失败使用默认数据:", apiError); console.warn("API请求失败使用默认数据:", apiError);
setApiError(apiError instanceof Error ? apiError.message : "API连接失败"); setApiError(
apiError instanceof Error ? apiError.message : "API连接失败"
);
// 使用默认数据 // 使用默认数据
setStats({ setStats({
@@ -247,11 +259,11 @@ export default function Home() {
}, []); // 移除stats依赖 }, []); // 移除stats依赖
const handleDevicesClick = () => { const handleDevicesClick = () => {
navigate('/profile/devices'); navigate("/profile/devices");
}; };
const handleWechatClick = () => { const handleWechatClick = () => {
navigate('/wechat-accounts'); navigate("/wechat-accounts");
}; };
// 使用Chart.js创建图表 // 使用Chart.js创建图表
@@ -263,7 +275,7 @@ export default function Home() {
} }
const ctx = chartRef.current.getContext("2d"); const ctx = chartRef.current.getContext("2d");
// 添加null检查 // 添加null检查
if (!ctx) return; if (!ctx) return;
@@ -391,9 +403,12 @@ export default function Home() {
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1"></span> <span className="text-xs text-gray-500 mb-1"></span>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">{stats.totalDevices}</span> <span className="text-lg font-bold text-blue-600">
{stats.totalDevices}
</span>
<Smartphone className="w-5 h-5 text-blue-600" /> <Smartphone className="w-5 h-5 text-blue-600" />
</div> </div>
<div className="h-2"></div>
</div> </div>
</Card> </Card>
</div> </div>
@@ -402,22 +417,31 @@ export default function Home() {
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1"></span> <span className="text-xs text-gray-500 mb-1"></span>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">{stats.totalWechatAccounts}</span> <span className="text-lg font-bold text-blue-600">
{stats.totalWechatAccounts}
</span>
<Users className="w-5 h-5 text-blue-600" /> <Users className="w-5 h-5 text-blue-600" />
</div> </div>
</div> </div>
<div className="h-2"></div>
</Card> </Card>
</div> </div>
<Card className="p-3 bg-white"> <Card className="p-3 bg-white">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs text-gray-500 mb-1">线</span> <span className="text-xs text-gray-500 mb-1">线</span>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-lg font-bold text-blue-600">{stats.onlineWechatAccounts}</span> <span className="text-lg font-bold text-blue-600">
{stats.onlineWechatAccounts}
</span>
<Activity className="w-5 h-5 text-blue-600" /> <Activity className="w-5 h-5 text-blue-600" />
</div> </div>
<Progress <Progress
value={ value={
stats.totalWechatAccounts > 0 ? (stats.onlineWechatAccounts / stats.totalWechatAccounts) * 100 : 0 stats.totalWechatAccounts > 0
? (stats.onlineWechatAccounts /
stats.totalWechatAccounts) *
100
: 0
} }
className="h-1" className="h-1"
/> />
@@ -435,16 +459,30 @@ export default function Home() {
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value)
.slice(0, 4) // 只显示前4个 .slice(0, 4) // 只显示前4个
.map((scenario) => ( .map((scenario) => (
<div <div
key={scenario.id} key={scenario.id}
className="block flex-1 cursor-pointer" className="block flex-1 cursor-pointer"
onClick={() => navigate(`/scenarios/${scenario.id}?name=${encodeURIComponent(scenario.name)}`)} onClick={() =>
navigate(
`/scenarios/${scenario.id}?name=${encodeURIComponent(
scenario.name
)}`
)
}
> >
<div className="flex flex-col items-center text-center space-y-1"> <div className="flex flex-col items-center text-center space-y-1">
<div className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}> <div
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-5 h-5" /> className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}
>
<img
src={scenario.icon || "/placeholder.svg"}
alt={scenario.name}
className="w-5 h-5"
/>
</div>
<div className="text-sm font-medium">
{scenario.value}
</div> </div>
<div className="text-sm font-medium">{scenario.value}</div>
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full"> <div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
{scenario.name} {scenario.name}
</div> </div>
@@ -466,7 +504,9 @@ export default function Home() {
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors" className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => stat.path && navigate(stat.path)} onClick={() => stat.path && navigate(stat.path)}
> >
<div className={`p-2 rounded-full bg-white ${stat.color}`}>{stat.icon}</div> <div className={`p-2 rounded-full bg-white ${stat.color}`}>
{stat.icon}
</div>
<div> <div>
<div className="text-lg font-semibold">{stat.value}</div> <div className="text-lg font-semibold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.title}</div> <div className="text-xs text-gray-500">{stat.title}</div>
@@ -487,4 +527,4 @@ export default function Home() {
</div> </div>
</Layout> </Layout>
); );
} }

14
nkebao.code-workspace Normal file
View File

@@ -0,0 +1,14 @@
{
"folders": [
{
"path": "nkebao"
},
{
"path": "Cunkebao"
},
{
"path": "../../MySelf/好版登项目/好版登小程序"
}
],
"settings": {}
}

View File

@@ -199,10 +199,6 @@
font-size: 12px; font-size: 12px;
color: #888; color: #888;
text-align: center; text-align: center;
margin-bottom: 6px;
line-height: 1.4;
min-height: 32px;
max-height: 32px;
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -262,9 +258,6 @@
// 响应式设计 // 响应式设计
@media (max-width: 480px) { @media (max-width: 480px) {
.scene-page {
padding: 8px;
}
.scenario-card { .scenario-card {
padding: 14px 16px; padding: 14px 16px;
@@ -307,20 +300,18 @@
padding: 12px 4px 10px 4px; padding: 12px 4px 10px 4px;
} }
.card-img-bg { .card-img-bg {
width: 40px; width: 60px;
height: 40px; height: 60px;
} }
.card-img { .card-img {
width: 26px; width: 40px;
height: 26px; height: 40px;
} }
.card-title { .card-title {
font-size: 15px; font-size: 15px;
} }
.card-desc { .card-desc {
font-size: 11px; font-size: 11px;
min-height: 24px;
max-height: 24px;
} }
.card-count { .card-count {
font-size: 12px; font-size: 12px;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { NavBar, Button, Toast } from "antd-mobile"; import { NavBar, Button, Toast } from "antd-mobile";
import { PlusOutlined, UpOutlined } from "@ant-design/icons"; import { PlusOutlined, RiseOutlined } from "@ant-design/icons";
import MeauMobile from "@/components/MeauMobile/MeauMoible"; import MeauMobile from "@/components/MeauMobile/MeauMoible";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import { getScenarios } from "./api"; import { getScenarios } from "./api";
@@ -162,7 +162,7 @@ const Scene: React.FC = () => {
: {scenario.count} : {scenario.count}
</span> </span>
<span className={style["card-growth"]}> <span className={style["card-growth"]}>
<UpOutlined <RiseOutlined
style={{ fontSize: 14, color: "#52c41a", marginRight: 2 }} style={{ fontSize: 14, color: "#52c41a", marginRight: 2 }}
/> />
{scenario.growth} {scenario.growth}

View File

@@ -0,0 +1,22 @@
export interface Task {
id: string;
name: string;
status: number;
created_at: string;
updated_at: string;
enabled: boolean;
total_customers?: number;
today_customers?: number;
lastUpdated?: string;
stats?: {
devices?: number;
acquired?: number;
added?: number;
};
}
export interface ApiSettings {
apiKey: string;
webhookUrl: string;
taskId: string;
}

View File

@@ -41,22 +41,12 @@
position: relative; position: relative;
flex: 1; flex: 1;
.adm-input { .ant-input {
padding-left: 40px;
border-radius: 8px; border-radius: 8px;
height: 40px;
} }
} }
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
z-index: 1;
}
.refresh-btn { .refresh-btn {
height: 40px; height: 40px;
width: 40px; width: 40px;
@@ -87,7 +77,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 16px;
} }
.plan-name { .plan-name {
@@ -98,21 +88,64 @@
margin-right: 12px; margin-right: 12px;
} }
.plan-meta { .plan-header-right {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
} }
.meta-item { .more-btn {
padding: 4px;
min-width: auto;
height: 28px;
width: 28px;
border-radius: 4px;
&:hover {
background-color: #f5f5f5;
}
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.stat-item {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
text-align: center;
border: 1px solid #e9ecef;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
font-weight: 500;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #333;
line-height: 1.2;
}
.plan-footer {
border-top: 1px solid #f0f0f0;
padding-top: 12px;
}
.last-execution {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
color: #666; color: #999;
svg { svg {
font-size: 14px; font-size: 14px;
@@ -120,12 +153,6 @@
} }
} }
.plan-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -147,6 +174,48 @@
border-radius: 20px; border-radius: 20px;
} }
.action-menu-dialog {
background: white;
border-radius: 16px 16px 0 0;
padding: 20px;
max-height: 60vh;
display: flex;
flex-direction: column;
}
.action-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background-color: #fff2f0;
}
}
}
.action-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.action-text {
font-size: 16px;
font-weight: 500;
}
.api-dialog { .api-dialog {
background: white; background: white;
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
@@ -188,7 +257,7 @@
font-weight: 500; font-weight: 500;
} }
.adm-input { .ant-input {
border-radius: 8px; border-radius: 8px;
} }
} }
@@ -198,7 +267,7 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
.adm-input { .ant-input {
flex: 1; flex: 1;
} }
} }

View File

@@ -7,12 +7,12 @@ import {
Toast, Toast,
SpinLoading, SpinLoading,
Dialog, Dialog,
Input,
Popup, Popup,
Card, Card,
Tag, Tag,
Space, Space,
} from "antd-mobile"; } from "antd-mobile";
import { Input } from "antd";
import { import {
PlusOutlined, PlusOutlined,
UserOutlined, UserOutlined,
@@ -24,6 +24,8 @@ import {
ReloadOutlined, ReloadOutlined,
QrcodeOutlined, QrcodeOutlined,
EditOutlined, EditOutlined,
MoreOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
@@ -36,40 +38,7 @@ import {
getWxMinAppCode, getWxMinAppCode,
} from "./api"; } from "./api";
import style from "./index.module.scss"; import style from "./index.module.scss";
import { Task, ApiSettings } from "./data";
interface Task {
id: string;
name: string;
status: number;
created_at: string;
updated_at: string;
enabled: boolean;
total_customers?: number;
today_customers?: number;
lastUpdated?: string;
stats?: {
devices?: number;
acquired?: number;
added?: number;
};
}
interface ScenarioData {
id: string;
name: string;
image: string;
description: string;
totalPlans: number;
totalCustomers: number;
todayCustomers: number;
growth: string;
}
interface ApiSettings {
apiKey: string;
webhookUrl: string;
taskId: string;
}
const ScenarioList: React.FC = () => { const ScenarioList: React.FC = () => {
const { scenarioId, scenarioName } = useParams<{ const { scenarioId, scenarioName } = useParams<{
@@ -78,10 +47,9 @@ const ScenarioList: React.FC = () => {
}>(); }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [scenario, setScenario] = useState<ScenarioData | null>(null); const [pageTitle, setPageTitle] = useState<string>("");
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showApiDialog, setShowApiDialog] = useState(false); const [showApiDialog, setShowApiDialog] = useState(false);
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({ const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
apiKey: "", apiKey: "",
@@ -93,6 +61,7 @@ const ScenarioList: React.FC = () => {
const [showQrDialog, setShowQrDialog] = useState(false); const [showQrDialog, setShowQrDialog] = useState(false);
const [qrLoading, setQrLoading] = useState(false); const [qrLoading, setQrLoading] = useState(false);
const [qrImg, setQrImg] = useState(""); const [qrImg, setQrImg] = useState("");
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
// 获取渠道中文名称 // 获取渠道中文名称
const getChannelName = (channel: string) => { const getChannelName = (channel: string) => {
@@ -111,27 +80,19 @@ const ScenarioList: React.FC = () => {
return channelMap[channel] || `${channel}获客`; return channelMap[channel] || `${channel}获客`;
}; };
// 获取场景描述 // 获取场景名称
const getScenarioDescription = (channel: string) => { const getScenarioName = useCallback(() => {
const descriptions: Record<string, string> = { const urlName = searchParams.get("name");
douyin: "通过抖音平台进行精准获客,利用短视频内容吸引目标用户", if (urlName) {
xiaohongshu: "利用小红书平台进行内容营销获客,通过优质内容建立品牌形象", return urlName;
gongzhonghao: "通过微信公众号进行获客,建立私域流量池", }
haibao: "通过海报分享进行获客,快速传播品牌信息", return getChannelName(scenarioId || "");
phone: "通过电话营销进行获客,直接与客户沟通", }, [searchParams, scenarioId]);
weixinqun: "通过微信群进行获客,利用社交裂变效应",
payment: "通过付款码进行获客,便捷的支付方式",
api: "通过API接口进行获客支持第三方系统集成",
};
return descriptions[channel] || "通过该平台进行获客";
};
useEffect(() => { useEffect(() => {
const fetchScenarioData = async () => { const fetchScenarioData = async () => {
if (!scenarioId) return; if (!scenarioId) return;
setLoading(true); setLoading(true);
setError("");
try { try {
// 获取计划列表 // 获取计划列表
@@ -142,98 +103,37 @@ const ScenarioList: React.FC = () => {
}); });
// 设置计划列表 // 设置计划列表
if (response && response.data && response.data.list) { setTasks(response.list);
setTasks(response.data.list);
} else {
setTasks([]);
}
// 构建场景数据 // 设置页面标题
const scenarioData: ScenarioData = { setPageTitle(getScenarioName());
id: scenarioId,
name: scenarioName || "",
image: "",
description: getScenarioDescription(scenarioId),
totalPlans: response?.data?.list?.length || 0,
totalCustomers: 0,
todayCustomers: 0,
growth: "",
};
setScenario(scenarioData);
} catch (error) { } catch (error) {
console.error("获取场景数据失败:", error); console.error("获取场景数据失败:", error);
// 即使API失败也要创建基本的场景数据
const scenarioData: ScenarioData = {
id: scenarioId,
name: getScenarioName(),
image: "",
description: getScenarioDescription(scenarioId),
totalPlans: 0,
totalCustomers: 0,
todayCustomers: 0,
growth: "",
};
setScenario(scenarioData);
setTasks([]); setTasks([]);
setPageTitle(getScenarioName());
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchScenarioData(); fetchScenarioData();
}, [scenarioId]); }, [scenarioId, getScenarioName]);
// 获取场景名称 // 更新页面标题
const getScenarioName = useCallback(() => {
const urlName = searchParams.get("name");
if (urlName) {
return urlName;
}
return getChannelName(scenarioId || "");
}, [searchParams, scenarioId]);
// 更新场景数据中的名称
useEffect(() => { useEffect(() => {
setScenario((prev) => setPageTitle(getScenarioName());
prev }, [getScenarioName]);
? {
...prev,
name: (() => {
const urlName = searchParams.get("name");
if (urlName) return urlName;
return getChannelName(scenarioId || "");
})(),
}
: null
);
}, [searchParams, scenarioId]);
const handleCopyPlan = async (taskId: string) => { const handleCopyPlan = async (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId); const taskToCopy = tasks.find((task) => task.id === taskId);
if (!taskToCopy) return; if (!taskToCopy) return;
await copyPlan(taskId);
try { Toast.show({
const response = await copyPlan(taskId); content: `已成功复制"${taskToCopy.name}"`,
if (response && response.code === 200) { position: "top",
Toast.show({ });
content: `已成功复制"${taskToCopy.name}"`, // 刷新列表
position: "top", handleRefresh();
});
// 刷新列表
handleRefresh();
} else {
Toast.show({
content: response?.msg || "复制失败",
position: "top",
});
}
} catch (error) {
Toast.show({
content: "复制失败,请重试",
position: "top",
});
}
}; };
const handleDeletePlan = async (taskId: string) => { const handleDeletePlan = async (taskId: string) => {
@@ -343,7 +243,7 @@ const ScenarioList: React.FC = () => {
const getStatusText = (status: number) => { const getStatusText = (status: number) => {
switch (status) { switch (status) {
case 1: case 1:
return "行中"; return "行中";
case 0: case 0:
return "已暂停"; return "已暂停";
case -1: case -1:
@@ -357,13 +257,11 @@ const ScenarioList: React.FC = () => {
setLoadingTasks(true); setLoadingTasks(true);
try { try {
const response = await getPlanList({ const response = await getPlanList({
scenarioId: scenarioId!, sceneId: scenarioId!,
page: 1, page: 1,
limit: 20, limit: 20,
}); });
if (response && response.data && response.data.list) { setTasks(response.list);
setTasks(response.data.list);
}
} catch (error) { } catch (error) {
Toast.show({ Toast.show({
content: "刷新失败", content: "刷新失败",
@@ -378,6 +276,77 @@ const ScenarioList: React.FC = () => {
task.name.toLowerCase().includes(searchTerm.toLowerCase()) task.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
// 计算通过率
const calculatePassRate = (acquired: number, added: number) => {
if (added === 0) return "0.00%";
return `${((acquired / added) * 100).toFixed(2)}%`;
};
// 格式化时间
const formatTime = (timeString: string) => {
if (!timeString) return "";
const date = new Date(timeString);
return date
.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
.replace(/\//g, "-");
};
// 生成操作菜单
const getActionMenu = (task: Task) => [
{
key: "edit",
text: "编辑",
icon: <EditOutlined />,
onClick: () => {
setShowActionMenu(null);
navigate(`/scenarios/edit/${task.id}`);
},
},
{
key: "settings",
text: "API设置",
icon: <SettingOutlined />,
onClick: () => {
setShowActionMenu(null);
handleOpenApiSettings(task.id);
},
},
{
key: "copy",
text: "复制",
icon: <CopyOutlined />,
onClick: () => {
setShowActionMenu(null);
handleCopyPlan(task.id);
},
},
{
key: "qrcode",
text: "二维码",
icon: <QrcodeOutlined />,
onClick: () => {
setShowActionMenu(null);
handleShowQrCode(task.id);
},
},
{
key: "delete",
text: "删除",
icon: <DeleteOutlined />,
onClick: () => {
setShowActionMenu(null);
handleDeletePlan(task.id);
},
danger: true,
},
];
if (loading) { if (loading) {
return ( return (
<Layout <Layout
@@ -402,7 +371,7 @@ const ScenarioList: React.FC = () => {
<NavBar <NavBar
back={null} back={null}
style={{ background: "#fff" }} style={{ background: "#fff" }}
left={<div className={style["nav-title"]}>{scenario?.name}</div>} left={<div className={style["nav-title"]}>{pageTitle}</div>}
right={ right={
<Button <Button
size="small" size="small"
@@ -421,12 +390,13 @@ const ScenarioList: React.FC = () => {
{/* 搜索栏 */} {/* 搜索栏 */}
<div className={style["search-bar"]}> <div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}> <div className={style["search-input-wrapper"]}>
<SearchOutlined className={style["search-icon"]} />
<Input <Input
placeholder="搜索计划名称" placeholder="搜索计划名称"
value={searchTerm} value={searchTerm}
onChange={setSearchTerm} onChange={(e) => setSearchTerm(e.target.value)}
clearable prefix={<SearchOutlined />}
allowClear
size="middle"
/> />
</div> </div>
<Button <Button
@@ -457,63 +427,59 @@ const ScenarioList: React.FC = () => {
) : ( ) : (
filteredTasks.map((task) => ( filteredTasks.map((task) => (
<Card key={task.id} className={style["plan-item"]}> <Card key={task.id} className={style["plan-item"]}>
{/* 头部:标题状态 */} {/* 头部:标题状态和操作菜单 */}
<div className={style["plan-header"]}> <div className={style["plan-header"]}>
<div className={style["plan-name"]}>{task.name}</div> <div className={style["plan-name"]}>{task.name}</div>
<Tag color={getStatusColor(task.status)}> <div className={style["plan-header-right"]}>
{getStatusText(task.status)} <Tag color={getStatusColor(task.status)}>
</Tag> {getStatusText(task.status)}
</Tag>
<Button
size="mini"
fill="none"
className={style["more-btn"]}
onClick={() => setShowActionMenu(task.id)}
>
<MoreOutlined />
</Button>
</div>
</div> </div>
{/* 中部:更新时间和统计 */} {/* 统计数据网格 */}
<div className={style["plan-meta"]}> <div className={style["stats-grid"]}>
<div className={style["meta-item"]}> <div className={style["stat-item"]}>
<CalendarOutlined /> <div className={style["stat-label"]}></div>
<span>: {task.updated_at || task.created_at}</span> <div className={style["stat-value"]}>
{task.stats?.devices || 0}
</div>
</div> </div>
<div className={style["meta-item"]}> <div className={style["stat-item"]}>
<UserOutlined /> <div className={style["stat-label"]}></div>
<span> <div className={style["stat-value"]}>
: {task.stats?.devices || 0} | :{" "} {task.stats?.acquired || 0}
{task.stats?.acquired || 0} | :{" "} </div>
</div>
<div className={style["stat-item"]}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{task.stats?.added || 0} {task.stats?.added || 0}
</div>
</div>
<div className={style["stat-item"]}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>{task.passRate}%</div>
</div>
</div>
{/* 底部:上次执行时间 */}
<div className={style["plan-footer"]}>
<div className={style["last-execution"]}>
<ClockCircleOutlined />
<span>
: {formatTime(task.updated_at || task.created_at)}
</span> </span>
</div> </div>
</div> </div>
{/* 底部:操作按钮 */}
<div className={style["plan-actions"]}>
<Space>
<Button
size="mini"
onClick={() => navigate(`/scenarios/edit/${task.id}`)}
>
<EditOutlined />
</Button>
<Button
size="mini"
onClick={() => handleOpenApiSettings(task.id)}
>
<SettingOutlined />
</Button>
<Button size="mini" onClick={() => handleCopyPlan(task.id)}>
<CopyOutlined />
</Button>
<Button
size="mini"
color="danger"
onClick={() => handleDeletePlan(task.id)}
>
<DeleteOutlined />
</Button>
<Button
size="mini"
onClick={() => handleShowQrCode(task.id)}
>
<QrcodeOutlined />
</Button>
</Space>
</div>
</Card> </Card>
)) ))
)} )}
@@ -537,7 +503,7 @@ const ScenarioList: React.FC = () => {
<div className={style["api-item"]}> <div className={style["api-item"]}>
<label>API Key:</label> <label>API Key:</label>
<div className={style["input-with-button"]}> <div className={style["input-with-button"]}>
<Input value={currentApiSettings.apiKey} readOnly /> <Input value={currentApiSettings.apiKey} disabled />
<Button <Button
size="mini" size="mini"
onClick={() => handleCopyApiUrl(currentApiSettings.apiKey)} onClick={() => handleCopyApiUrl(currentApiSettings.apiKey)}
@@ -549,7 +515,7 @@ const ScenarioList: React.FC = () => {
<div className={style["api-item"]}> <div className={style["api-item"]}>
<label>Webhook URL:</label> <label>Webhook URL:</label>
<div className={style["input-with-button"]}> <div className={style["input-with-button"]}>
<Input value={currentApiSettings.webhookUrl} readOnly /> <Input value={currentApiSettings.webhookUrl} disabled />
<Button <Button
size="mini" size="mini"
onClick={() => onClick={() =>
@@ -564,6 +530,38 @@ const ScenarioList: React.FC = () => {
</div> </div>
</Popup> </Popup>
{/* 操作菜单弹窗 */}
<Popup
visible={!!showActionMenu}
onMaskClick={() => setShowActionMenu(null)}
position="bottom"
bodyStyle={{ height: "auto", maxHeight: "60vh" }}
>
<div className={style["action-menu-dialog"]}>
<div className={style["dialog-header"]}>
<h3></h3>
<Button size="small" onClick={() => setShowActionMenu(null)}>
</Button>
</div>
<div className={style["dialog-content"]}>
{showActionMenu &&
getActionMenu(tasks.find((t) => t.id === showActionMenu)!).map(
(item) => (
<div
key={item.key}
className={`${style["action-menu-item"]} ${item.danger ? style["danger"] : ""}`}
onClick={item.onClick}
>
<span className={style["action-icon"]}>{item.icon}</span>
<span className={style["action-text"]}>{item.text}</span>
</div>
)
)}
</div>
</div>
</Popup>
{/* 二维码弹窗 */} {/* 二维码弹窗 */}
<Popup <Popup
visible={showQrDialog} visible={showQrDialog}