diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss new file mode 100644 index 00000000..308c00d5 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss @@ -0,0 +1,128 @@ +.modalMask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.3s ease; +} + +.videoContainer { + width: 100%; + max-width: 90vw; + max-height: 90vh; + background: #000; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + .title { + font-size: 16px; + font-weight: 600; + color: #fff; + } + + .closeButton { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + + &:active { + transform: scale(0.95); + } + + svg { + font-size: 16px; + } + } +} + +.videoWrapper { + width: 100%; + position: relative; + padding-top: 56.25%; // 16:9 比例 + background: #000; +} + +.video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + outline: none; +} + +// 动画 +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +// 移动端适配 +@media (max-width: 768px) { + .modalMask { + padding: 0; + } + + .videoContainer { + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + } + + .header { + padding: 10px 12px; + + .title { + font-size: 14px; + } + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx new file mode 100644 index 00000000..a26e824a --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx @@ -0,0 +1,101 @@ +import React, { useRef, useEffect } from "react"; +import { CloseOutlined } from "@ant-design/icons"; +import styles from "./VideoPlayer.module.scss"; + +interface VideoPlayerProps { + /** 视频URL */ + videoUrl: string; + /** 是否显示播放器 */ + visible: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 视频标题 */ + title?: string; +} + +const VideoPlayer: React.FC = ({ + videoUrl, + visible, + onClose, + title = "操作视频", +}) => { + const videoRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (visible && videoRef.current) { + // 播放器打开时播放视频 + videoRef.current.play().catch(err => { + console.error("视频播放失败:", err); + }); + // 阻止背景滚动 + document.body.style.overflow = "hidden"; + } else if (videoRef.current) { + // 播放器关闭时暂停视频 + videoRef.current.pause(); + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [visible]); + + // 点击遮罩层关闭 + const handleMaskClick = (e: React.MouseEvent) => { + // 如果点击的是遮罩层本身(不是视频容器),则关闭 + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + const handleClose = () => { + if (videoRef.current) { + videoRef.current.pause(); + } + onClose(); + }; + + // 阻止事件冒泡 + const handleContentClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + if (!visible) { + return null; + } + + return ( +
+
+
+ {title} + +
+
+ +
+
+
+ ); +}; + +export default VideoPlayer; diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.module.scss b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss new file mode 100644 index 00000000..0b35e258 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss @@ -0,0 +1,56 @@ +.floatingButton { + position: fixed; + right: 20px; + bottom: 80px; + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 9998; + transition: all 0.3s ease; + animation: float 3s ease-in-out infinite; + + &:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5); + } + + &:active { + transform: scale(0.95); + } + + .icon { + font-size: 28px; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + } + + // 移动端适配 + @media (max-width: 768px) { + right: 16px; + bottom: 70px; + width: 50px; + height: 50px; + + .icon { + font-size: 24px; + } + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.tsx b/Cunkebao/src/components/FloatingVideoHelp/index.tsx new file mode 100644 index 00000000..a85ad480 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.tsx @@ -0,0 +1,68 @@ +import React, { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { PlayCircleOutlined } from "@ant-design/icons"; +import VideoPlayer from "./VideoPlayer"; +import { getVideoUrlByRoute } from "./videoConfig"; +import styles from "./index.module.scss"; + +interface FloatingVideoHelpProps { + /** 是否显示悬浮窗,默认为 true */ + visible?: boolean; + /** 自定义样式类名 */ + className?: string; +} + +const FloatingVideoHelp: React.FC = ({ + visible = true, + className, +}) => { + const location = useLocation(); + const [showPlayer, setShowPlayer] = useState(false); + const [currentVideoUrl, setCurrentVideoUrl] = useState(null); + + // 根据当前路由获取视频URL + useEffect(() => { + const videoUrl = getVideoUrlByRoute(location.pathname); + setCurrentVideoUrl(videoUrl); + }, [location.pathname]); + + const handleClick = () => { + if (currentVideoUrl) { + setShowPlayer(true); + } else { + // 如果没有对应的视频,可以显示提示 + console.warn("当前路由没有对应的操作视频"); + } + }; + + const handleClose = () => { + setShowPlayer(false); + }; + + // 如果没有视频URL,不显示悬浮窗 + if (!visible || !currentVideoUrl) { + return null; + } + + return ( + <> +
+ +
+ + {showPlayer && currentVideoUrl && ( + + )} + + ); +}; + +export default FloatingVideoHelp; diff --git a/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts new file mode 100644 index 00000000..35798056 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts @@ -0,0 +1,110 @@ +/** + * 路由到视频URL的映射配置 + * key: 路由路径(支持正则表达式) + * value: 视频URL + */ +interface VideoConfig { + [route: string]: string; +} + +// 视频URL配置 +const videoConfig: VideoConfig = { + // 首页 + "/": "/videos/home.mp4", + "/mobile/home": "/videos/home.mp4", + + // 工作台 + "/workspace": "/videos/workspace.mp4", + "/workspace/auto-like": "/videos/auto-like-list.mp4", + "/workspace/auto-like/new": "/videos/auto-like-new.mp4", + "/workspace/auto-like/record": "/videos/auto-like-record.mp4", + "/workspace/auto-group": "/videos/auto-group-list.mp4", + "/workspace/auto-group/new": "/videos/auto-group-new.mp4", + "/workspace/group-push": "/videos/group-push-list.mp4", + "/workspace/group-push/new": "/videos/group-push-new.mp4", + "/workspace/moments-sync": "/videos/moments-sync-list.mp4", + "/workspace/moments-sync/new": "/videos/moments-sync-new.mp4", + "/workspace/ai-assistant": "/videos/ai-assistant.mp4", + "/workspace/ai-analyzer": "/videos/ai-analyzer.mp4", + "/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4", + "/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4", + "/workspace/contact-import": "/videos/contact-import-list.mp4", + "/workspace/contact-import/form": "/videos/contact-import-form.mp4", + "/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4", + "/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4", + + // 我的 + "/mobile/mine": "/videos/mine.mp4", + "/mobile/mine/devices": "/videos/devices.mp4", + "/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4", + "/mobile/mine/content": "/videos/content.mp4", + "/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4", + "/mobile/mine/recharge": "/videos/recharge.mp4", + "/mobile/mine/setting": "/videos/setting.mp4", + + // 场景 + "/mobile/scenarios": "/videos/scenarios.mp4", + "/mobile/scenarios/plan": "/videos/scenarios-plan.mp4", +}; + +/** + * 根据路由路径获取对应的视频URL + * @param routePath 当前路由路径 + * @returns 视频URL,如果没有匹配则返回 null + */ +export function getVideoUrlByRoute(routePath: string): string | null { + // 精确匹配 + if (videoConfig[routePath]) { + return videoConfig[routePath]; + } + + // 模糊匹配(支持动态路由参数) + // 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id + const routeKeys = Object.keys(videoConfig); + for (const key of routeKeys) { + // 将配置中的 :id 等参数转换为正则表达式 + const regexPattern = key.replace(/:\w+/g, "[^/]+"); + const regex = new RegExp(`^${regexPattern}$`); + if (regex.test(routePath)) { + return videoConfig[key]; + } + } + + // 前缀匹配(作为兜底方案) + // 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like + const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列 + for (const key of sortedKeys) { + if (routePath.startsWith(key)) { + return videoConfig[key]; + } + } + + return null; +} + +/** + * 添加或更新视频配置 + * @param route 路由路径 + * @param videoUrl 视频URL + */ +export function setVideoConfig(route: string, videoUrl: string): void { + videoConfig[route] = videoUrl; +} + +/** + * 批量添加视频配置 + * @param config 视频配置对象 + */ +export function setVideoConfigs(config: VideoConfig): void { + Object.assign(videoConfig, config); +} + +/** + * 获取所有视频配置 + * @returns 视频配置对象 + */ +export function getAllVideoConfigs(): VideoConfig { + return { ...videoConfig }; +} + +export default videoConfig; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts index 6a870667..de13a1e0 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/api.ts @@ -75,7 +75,7 @@ export interface OrderListParams { [property: string]: any; } -interface OrderList { +export interface OrderList { id?: number; mchId?: number; companyId?: number; @@ -84,22 +84,24 @@ interface OrderList { status?: number; goodsId?: number; goodsName?: string; - goodsSpecs?: { - id: number; - name: string; - price: number; - tokens: number; - }; + goodsSpecs?: + | { + id: number; + name: string; + price: number; + tokens: number; + } + | string; money?: number; orderNo?: string; ip?: string; nonceStr?: string; - createTime?: string; + createTime?: string | number; payType?: number; - payTime?: string; + payTime?: string | number; payInfo?: any; - deleteTime?: string; - tokens?: string; + deleteTime?: string | number; + tokens?: string | number; statusText?: string; orderTypeText?: string; payTypeText?: string; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss index be4e8f82..99d1355e 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.module.scss @@ -258,6 +258,18 @@ border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); border: 1px solid #f0f0f0; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.recordItem:active { + transform: scale(0.98); +} + +.recordItem:hover { + box-shadow: 0 6px 18px rgba(22, 119, 255, 0.08); } .recordHeader { diff --git a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx index d713b2f3..3f52a632 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/recharge/index/index.tsx @@ -12,17 +12,74 @@ import { import NavCommon from "@/components/NavCommon"; import Layout from "@/components/Layout/Layout"; import { getStatistics, getOrderList } from "./api"; -import type { Statistics } from "./api"; +import type { Statistics, OrderList } from "./api"; import { Pagination } from "antd"; -type OrderRecordView = { - id: number; - type: string; - status: string; - amount: number; // 元 - power: number; - description: string; - createTime: string; +type TagColor = NonNullable["color"]>; + +type GoodsSpecs = + | { + id: number; + name: string; + price: number; + tokens: number; + } + | undefined; + +const parseGoodsSpecs = (value: OrderList["goodsSpecs"]): GoodsSpecs => { + if (!value) return undefined; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch (error) { + console.warn("解析 goodsSpecs 失败:", error, value); + return undefined; + } + } + return value; +}; + +const formatTimestamp = (value?: number | string | null) => { + if (value === undefined || value === null) return ""; + if (typeof value === "string" && value.trim() === "") return ""; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + + if (Number.isNaN(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) { + return num / 100; + } + return num; }; const PowerManagement: React.FC = () => { @@ -30,7 +87,7 @@ const PowerManagement: React.FC = () => { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); const [stats, setStats] = useState(null); - const [records, setRecords] = useState([]); + const [records, setRecords] = useState([]); const [filterType, setFilterType] = useState("all"); const [filterStatus, setFilterStatus] = useState("all"); const [filterTypeVisible, setFilterTypeVisible] = useState(false); @@ -50,11 +107,19 @@ const PowerManagement: React.FC = () => { const statusOptions = [ { label: "全部状态", value: "all" }, - { label: "已完成", value: "completed" }, - { label: "进行中", value: "processing" }, - { label: "已取消", value: "cancelled" }, + { label: "待支付", value: "pending", requestValue: "0" }, + { label: "已支付", value: "paid", requestValue: "1" }, + { label: "已取消", value: "cancelled", requestValue: "2" }, + { label: "已退款", value: "refunded", requestValue: "3" }, ]; + const statusMeta: Record = { + 0: { label: "待支付", color: "warning" }, + 1: { label: "已支付", color: "success" }, + 2: { label: "已取消", color: "default" }, + 3: { label: "已退款", color: "primary" }, + }; + useEffect(() => { fetchStats(); }, []); @@ -81,35 +146,20 @@ const PowerManagement: React.FC = () => { setLoading(true); try { const reqPage = customPage !== undefined ? customPage : page; - // 映射状态到订单状态:0待支付 1已支付 2已取消 3已退款 - const statusMap: Record = { - all: undefined, - completed: "1", - processing: "0", - cancelled: "2", - }; - + const statusRequestValue = statusOptions.find( + opt => opt.value === filterStatus, + )?.requestValue; const res = await getOrderList({ page: String(reqPage), limit: String(pageSize), orderType: "1", - status: statusMap[filterStatus], + status: statusRequestValue, }); - - const list = (res.list || []).map((o: any) => ({ - id: o.id, - type: o.orderTypeText || o.goodsName || "充值订单", - status: o.statusText || "", - amount: typeof o.money === "number" ? o.money / 100 : 0, - power: Number(o.goodsSpecs?.tokens ?? o.tokens ?? 0), - description: o.goodsName || "", - createTime: o.createTime || "", - })); - setRecords(list); + setRecords(res.list || []); setTotal(Number(res.total || 0)); } catch (error) { - console.error("获取消费记录失败:", error); - Toast.show({ content: "获取消费记录失败", position: "top" }); + console.error("获取订单记录失败:", error); + Toast.show({ content: "获取订单记录失败", position: "top" }); } finally { setLoading(false); } @@ -225,7 +275,7 @@ const PowerManagement: React.FC = () => { ); - // 渲染消费记录Tab + // 渲染订单记录Tab const renderRecords = () => (
{/* 筛选器 */} @@ -273,42 +323,72 @@ const PowerManagement: React.FC = () => {
- {/* 消费记录列表 */} + {/* 订单记录列表 */}
{loading && records.length === 0 ? (
加载中...
) : records.length > 0 ? ( - records.map(record => ( - -
-
-
{record.type}
- - {record.status} - -
-
-
- -¥{record.amount.toFixed(1)} + records.map(record => { + const statusCode = + record.status !== undefined ? Number(record.status) : undefined; + const tagColor = + statusCode !== undefined + ? statusMeta[statusCode]?.color || "default" + : "default"; + const tagLabel = + record.statusText || + (statusCode !== undefined + ? statusMeta[statusCode]?.label || "未知状态" + : "未知状态"); + const goodsSpecs = parseGoodsSpecs(record.goodsSpecs); + const amount = centsToYuan(record.money); + const powerValue = Number(goodsSpecs?.tokens ?? record.tokens ?? 0); + const power = Number.isNaN(powerValue) ? 0 : powerValue; + const description = + record.orderTypeText || + goodsSpecs?.name || + record.goodsName || + ""; + const createTime = formatTimestamp(record.createTime); + + return ( + + record.orderNo && + navigate(`/recharge/order/${record.orderNo}`) + } + > +
+
+
+ {record.goodsName || "算力充值"} +
+ + {tagLabel} +
-
- {formatNumber(record.power)} 算力 +
+
+ -¥{amount.toFixed(2)} +
+
+ {formatNumber(power)} 算力 +
-
-
{record.description}
-
{record.createTime}
-
- )) +
{description}
+
{createTime}
+ + ); + }) ) : (
📋
-
暂无消费记录
+
暂无订单记录
)}
@@ -334,7 +414,7 @@ const PowerManagement: React.FC = () => { className={style.powerTabs} > - + } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts b/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts index 11d4573e..acf1b12d 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/api.ts @@ -1,197 +1,37 @@ import { - RechargeOrdersResponse, RechargeOrderDetail, RechargeOrderParams, + GetRechargeOrderDetailParams, } 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)); +import request from "@/api/request"; // 获取充值记录列表 -export async function getRechargeOrders( - params: RechargeOrderParams, -): Promise { - 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 getRechargeOrders(params: RechargeOrderParams) { + return request("/v1/tokens/orderList", params, "GET"); } // 获取充值记录详情 export async function getRechargeOrderDetail( - id: string, + params: GetRechargeOrderDetailParams, ): Promise { - 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}`, - }; + return request("/v1/tokens/queryOrder", params, "GET"); } -// 取消充值订单 -export async function cancelRechargeOrder(id: string): Promise { - 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 interface ContinuePayParams { + orderNo: string; + [property: string]: any; } -// 申请退款 -export async function refundRechargeOrder( - id: string, - reason: string, -): Promise { - 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; +export interface ContinuePayResponse { + code_url?: string; + codeUrl?: string; + payUrl?: string; + [property: string]: any; +} + +// 继续支付 +export function continuePay( + params: ContinuePayParams, +): Promise { + return request("/v1/tokens/pay", params, "POST"); } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts b/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts index 95b9a0e9..2fc56bbd 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/data.ts @@ -1,40 +1,62 @@ // 充值记录类型定义 export interface RechargeOrder { - id: string; - orderNo: string; - amount: number; - paymentMethod: string; - status: "success" | "pending" | "failed" | "cancelled"; - createTime: string; - payTime?: string; + id?: number | string; + orderNo?: string; + money?: number; + amount?: number; + paymentMethod?: string; + paymentChannel?: string; + status?: number | string; + statusText?: string; + orderType?: number; + orderTypeText?: string; + createTime?: string | number; + payTime?: string | number; description?: string; + goodsName?: string; + goodsSpecs?: + | { + id: number; + name: string; + price: number; + tokens: number; + } + | string; remark?: string; operator?: string; balance?: number; + tokens?: number | string; + payType?: number; + payTypeText?: string; + transactionId?: string; } // API响应类型 export interface RechargeOrdersResponse { list: RechargeOrder[]; - total: number; - page: number; - limit: number; + total?: number; + page?: number; + limit?: number; } // 充值记录详情 export interface RechargeOrderDetail extends RechargeOrder { - paymentChannel?: string; - transactionId?: string; refundAmount?: number; - refundTime?: string; + refundTime?: string | number; refundReason?: string; } // 查询参数 export interface RechargeOrderParams { - page: number; - limit: number; - status?: string; + page?: number | string; + limit?: number | string; + status?: number | string; startTime?: string; endTime?: string; + [property: string]: any; +} + +export interface GetRechargeOrderDetailParams { + orderNo: string; + [property: string]: any; } diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss new file mode 100644 index 00000000..72f68cb2 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.module.scss @@ -0,0 +1,154 @@ +.detailPage { + padding: 16px 16px 80px; +} + +.statusCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + border-radius: 16px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(0, 95, 204, 0.06); + margin-bottom: 20px; + text-align: center; +} + +.statusIcon { + font-size: 56px; + margin-bottom: 12px; +} + +.statusTitle { + font-size: 20px; + font-weight: 600; + color: #1d2129; +} + +.statusDesc { + margin-top: 6px; + font-size: 14px; + color: #86909c; +} + +.amountHighlight { + margin-top: 12px; + font-size: 24px; + font-weight: 600; + color: #00b578; +} + +.section { + background: #ffffff; + border-radius: 16px; + padding: 20px 16px; + box-shadow: 0 6px 24px rgba(15, 54, 108, 0.05); + margin-bottom: 16px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + color: #1d2129; + margin-bottom: 12px; +} + +.sectionList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.row:not(:last-child) { + padding-bottom: 12px; + border-bottom: 1px dashed #e5e6eb; +} + +.label { + font-size: 14px; + color: #86909c; + flex-shrink: 0; +} + +.value { + font-size: 14px; + color: #1d2129; + text-align: right; + word-break: break-all; +} + +.copyBtn { + margin-left: 8px; + font-size: 13px; + color: #1677ff; + cursor: pointer; +} + +.tagGroup { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.actions { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px 24px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 70%); + box-shadow: 0 -4px 20px rgba(20, 66, 125, 0.06); + display: flex; + gap: 12px; +} + +.invoiceBtn { + flex: 1; + border: 1px solid #1677ff; + color: #1677ff; + border-radius: 20px; +} + +.backBtn { + flex: 1; + background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%); + color: #ffffff; + border-radius: 20px; +} + +.loadingWrapper { + display: flex; + align-items: center; + justify-content: center; + height: 200px; +} + +.emptyWrapper { + text-align: center; + color: #86909c; + padding: 40px 0; +} + +.refundBlock { + margin-top: 12px; + padding: 12px; + border-radius: 12px; + background: #f5f7ff; + color: #1d2129; + line-height: 1.6; +} + +.refundTitle { + font-weight: 600; + margin-bottom: 6px; +} diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx new file mode 100644 index 00000000..bb8c64d1 --- /dev/null +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/detail/index.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button, SpinLoading, Tag, Toast } from "antd-mobile"; +import { + CheckCircleOutline, + CloseCircleOutline, + ClockCircleOutline, + ExclamationCircleOutline, +} from "antd-mobile-icons"; +import { CopyOutlined } from "@ant-design/icons"; +import Layout from "@/components/Layout/Layout"; +import NavCommon from "@/components/NavCommon"; +import { getRechargeOrderDetail } from "../api"; +import type { RechargeOrderDetail } from "../data"; +import style from "./index.module.scss"; + +type StatusMeta = { + title: string; + description: string; + amountPrefix: string; + color: string; + icon: React.ReactNode; +}; + +type StatusCode = 0 | 1 | 2 | 3 | 4; + +const statusMetaMap: Record = { + 1: { + title: "支付成功", + description: "订单已完成支付", + amountPrefix: "已支付", + color: "#00b578", + icon: , + }, + 0: { + title: "待支付", + description: "请尽快完成支付,以免订单失效", + amountPrefix: "待支付", + color: "#faad14", + icon: , + }, + 4: { + title: "支付失败", + description: "支付未成功,可重新发起支付", + amountPrefix: "需支付", + color: "#ff4d4f", + icon: , + }, + 2: { + title: "订单已取消", + description: "该订单已取消,如需继续请重新创建订单", + amountPrefix: "订单金额", + color: "#86909c", + icon: ( + + ), + }, + 3: { + title: "订单已退款", + description: "订单款项已退回,请注意查收", + amountPrefix: "退款金额", + color: "#1677ff", + icon: ( + + ), + }, +}; + +const parseStatusCode = (status?: RechargeOrderDetail["status"]) => { + if (status === undefined || status === null) return undefined; + if (typeof status === "number") + return statusMetaMap[status] ? status : undefined; + const numeric = Number(status); + if (!Number.isNaN(numeric) && statusMetaMap[numeric as StatusCode]) { + return numeric as StatusCode; + } + const map: Record = { + success: 1, + pending: 0, + failed: 4, + cancelled: 2, + refunded: 3, + }; + return map[status] ?? undefined; +}; + +const formatDateTime = (value?: string | number | null) => { + if (value === undefined || value === null) return "-"; + if (typeof value === "string" && value.trim() === "") return "-"; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) return num / 100; + return num; +}; + +const RechargeOrderDetailPage: React.FC = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(null); + + useEffect(() => { + if (!id) { + Toast.show({ content: "缺少订单ID", position: "top" }); + navigate(-1); + return; + } + + const fetchDetail = async () => { + try { + setLoading(true); + const res = await getRechargeOrderDetail({ orderNo: id }); + setDetail(res); + } catch (error) { + console.error("获取订单详情失败:", error); + Toast.show({ content: "获取订单详情失败", position: "top" }); + } finally { + setLoading(false); + } + }; + + fetchDetail(); + }, [id, navigate]); + + const meta = useMemo(() => { + if (!detail) { + return statusMetaMap[0]; + } + const code = parseStatusCode(detail.status); + if (code !== undefined && statusMetaMap[code]) { + return statusMetaMap[code]; + } + return statusMetaMap[0]; + }, [detail]); + + const handleCopy = async (text?: string) => { + if (!text) return; + if (!navigator.clipboard) { + Toast.show({ content: "当前环境不支持复制", position: "top" }); + return; + } + try { + await navigator.clipboard.writeText(text); + Toast.show({ content: "复制成功", position: "top" }); + } catch (error) { + console.error("复制失败:", error); + Toast.show({ content: "复制失败,请手动复制", position: "top" }); + } + }; + + const handleApplyInvoice = () => { + Toast.show({ content: "发票功能即将上线,敬请期待", position: "top" }); + }; + + const handleBack = () => { + navigate("/recharge"); + }; + + const renderRefundInfo = () => { + if (!detail?.refundAmount) return null; + return ( +
+
退款信息
+
退款金额:¥{centsToYuan(detail.refundAmount).toFixed(2)}
+ {detail.refundTime ? ( +
退款时间:{formatDateTime(detail.refundTime)}
+ ) : null} + {detail.refundReason ? ( +
退款原因:{detail.refundReason}
+ ) : null} +
+ ); + }; + + return ( + } + loading={loading && !detail} + footer={ +
+ + +
+ } + > +
+ {loading && !detail ? ( +
+ +
+ ) : !detail ? ( +
未找到订单详情
+ ) : ( + <> +
+ {meta.icon} +
{meta.title}
+
{meta.description}
+
+ {meta.amountPrefix} ¥ + {centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)} +
+
+ +
+
订单信息
+
+
+ 订单号 + + {detail.orderNo || "-"} + handleCopy(detail.orderNo)} + > + + + +
+
+ 套餐名称 + + {detail.description || detail.goodsName || "算力充值"} + +
+
+ 订单金额 + + ¥ + {centsToYuan(detail.money ?? detail.amount ?? 0).toFixed(2)} + +
+
+ 创建时间 + + {formatDateTime(detail.createTime)} + +
+
+ 支付时间 + + {formatDateTime(detail.payTime)} + +
+ {detail.balance !== undefined ? ( +
+ 充值后余额 + + ¥{centsToYuan(detail.balance).toFixed(2)} + +
+ ) : null} +
+ {renderRefundInfo()} +
+ +
+
支付信息
+
+
+ 支付方式 + + + + {detail.payTypeText || + detail.paymentChannel || + detail.paymentMethod || + "其他支付"} + + + +
+
+ 交易流水号 + {detail.id || "-"} +
+ {detail.remark ? ( +
+ 备注信息 + {detail.remark} +
+ ) : null} +
+
+ + )} +
+
+ ); +}; + +export default RechargeOrderDetailPage; diff --git a/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx b/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx index 7e5260e6..240589b6 100644 --- a/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/recharge/order/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Card, SpinLoading, Empty, Toast, Dialog } from "antd-mobile"; import { @@ -10,14 +10,110 @@ import { } from "@ant-design/icons"; import NavCommon from "@/components/NavCommon"; import Layout from "@/components/Layout/Layout"; -import { - getRechargeOrders, - cancelRechargeOrder, - refundRechargeOrder, -} from "./api"; +import { getRechargeOrders, continuePay } from "./api"; import { RechargeOrder } from "./data"; import style from "./index.module.scss"; +type StatusCode = 0 | 1 | 2 | 3 | 4; + +const STATUS_META: Record< + StatusCode, + { label: string; color: string; tagBgOpacity?: number } +> = { + 0: { label: "待支付", color: "#faad14" }, + 1: { label: "充值成功", color: "#52c41a" }, + 2: { label: "已取消", color: "#999999" }, + 3: { label: "已退款", color: "#1890ff" }, + 4: { label: "充值失败", color: "#ff4d4f" }, +}; + +const parseStatusCode = ( + status?: RechargeOrder["status"], +): StatusCode | undefined => { + if (status === undefined || status === null) return undefined; + + if (typeof status === "number") { + return STATUS_META[status as StatusCode] + ? (status as StatusCode) + : undefined; + } + + const numeric = Number(status); + if (!Number.isNaN(numeric) && STATUS_META[numeric as StatusCode]) { + return numeric as StatusCode; + } + + const stringMap: Record = { + success: 1, + pending: 0, + cancelled: 2, + refunded: 3, + failed: 4, + }; + + return stringMap[status] ?? undefined; +}; + +const formatTimestamp = (value?: string | number | null) => { + if (value === undefined || value === null) return "-"; + if (typeof value === "string" && value.trim() === "") return "-"; + + const numericValue = + typeof value === "number" ? value : Number.parseFloat(value); + if (!Number.isFinite(numericValue)) { + return String(value); + } + + const timestamp = + numericValue > 1e12 + ? numericValue + : numericValue > 1e10 + ? numericValue + : numericValue * 1000; + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + 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", + }); + } + if (days === 1) { + return `昨天 ${date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })}`; + } + if (days < 7) { + return `${days}天前`; + } + return date.toLocaleDateString("zh-CN"); +}; + +const centsToYuan = (value?: number | string | null) => { + if (value === undefined || value === null) return 0; + if (typeof value === "string" && value.trim() === "") return 0; + const num = Number(value); + if (!Number.isFinite(num)) return 0; + if (Number.isInteger(num)) return num / 100; + return num; +}; + +const getPaymentMethodText = (order: RechargeOrder) => { + if (order.payTypeText) return order.payTypeText; + if (order.paymentChannel) return order.paymentChannel; + if (order.paymentMethod) return order.paymentMethod; + return "其他支付"; +}; + const RechargeOrders: React.FC = () => { const navigate = useNavigate(); const [orders, setOrders] = useState([]); @@ -25,6 +121,7 @@ const RechargeOrders: React.FC = () => { const [hasMore, setHasMore] = useState(true); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState("all"); + const [payingOrderNo, setPayingOrderNo] = useState(null); const loadOrders = async (reset = false) => { setLoading(true); @@ -33,6 +130,7 @@ const RechargeOrders: React.FC = () => { const params = { page: currentPage, limit: 20, + orderType: 1, ...(statusFilter !== "all" && { status: statusFilter }), }; @@ -53,6 +151,7 @@ const RechargeOrders: React.FC = () => { // 初始化加载 useEffect(() => { loadOrders(true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 筛选条件变化时重新加载 @@ -63,36 +162,6 @@ const RechargeOrders: React.FC = () => { 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": @@ -119,131 +188,231 @@ const RechargeOrders: React.FC = () => { } }; - 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", + const handleViewDetail = (order: RechargeOrder) => { + const identifier = order.orderNo || order.id; + if (!identifier) { + Toast.show({ + content: "无法打开订单详情", + position: "top", }); - } else if (days === 1) { - return ( - "昨天 " + - date.toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - }) + return; + } + navigate(`/recharge/order/${identifier}`); + }; + + const openPayDialog = ( + order: RechargeOrder, + options: { codeUrl?: string; payUrl?: string }, + ) => { + const { codeUrl, payUrl } = options; + if (codeUrl) { + Dialog.show({ + content: ( +
+
+ 请使用微信扫码完成支付 +
+ 支付二维码 +
+ 支付金额:¥ + {centsToYuan(order.money ?? order.amount ?? 0).toFixed(2)} +
+
+ ), + closeOnMaskClick: true, + }); + return; + } + + if (payUrl) { + window.location.href = payUrl; + return; + } + + Toast.show({ + content: "暂未获取到支付信息,请稍后重试", + position: "top", + }); + }; + + const handleContinuePay = async (order: RechargeOrder) => { + if (!order.orderNo) { + Toast.show({ + content: "订单号缺失,无法继续支付", + position: "top", + }); + return; + } + + const orderNo = String(order.orderNo); + setPayingOrderNo(orderNo); + try { + const res = await continuePay({ orderNo }); + const codeUrl = res?.code_url || res?.codeUrl; + const payUrl = res?.payUrl; + if (!codeUrl && !payUrl) { + Toast.show({ + content: "未获取到支付链接,请稍后重试", + position: "top", + }); + return; + } + openPayDialog(order, { codeUrl, payUrl }); + } catch (error) { + console.error("继续支付失败:", error); + Toast.show({ + content: "继续支付失败,请重试", + position: "top", + }); + } finally { + setPayingOrderNo(prev => (prev === orderNo ? null : prev)); + } + }; + + const renderOrderItem = (order: RechargeOrder) => { + const statusCode = parseStatusCode(order.status); + const statusMeta = + statusCode !== undefined ? STATUS_META[statusCode] : undefined; + const paymentMethod = getPaymentMethodText(order); + const paymentMethodKey = paymentMethod.toLowerCase(); + const statusBgOpacity = statusMeta?.tagBgOpacity ?? 0.15; + const statusBgColor = statusMeta + ? `${statusMeta.color}${Math.round(statusBgOpacity * 255) + .toString(16) + .padStart(2, "0")}` + : "#66666626"; + const amount = centsToYuan(order.money ?? order.amount ?? 0) || 0; + const isPaying = payingOrderNo === order.orderNo; + const actions: React.ReactNode[] = []; + + if (statusCode === 0) { + actions.push( + , ); - } 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" }); - } + if (statusCode === 4) { + actions.push( + , + ); } - }; - 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" }); - } + if (statusCode === 1 || statusCode === 3 || statusCode === 2) { + actions.push( + , + ); } - }; - const renderOrderItem = (order: RechargeOrder) => ( - -
-
-
订单号:{order.orderNo}
-
- - {formatTime(order.createTime)} + actions.push( + , + ); + + return ( + +
+
+
+ 订单号:{order.orderNo || "-"} +
+
+ + {formatTimestamp(order.createTime)} +
+
+
+
¥{amount.toFixed(2)}
+
+ {statusMeta?.label || "未知状态"} +
-
-
- ¥{order.amount.toFixed(2)} -
-
- {getStatusText(order.status)} -
-
-
-
-
-
- {getPaymentMethodIcon(order.paymentMethod)} +
+
+
+ {getPaymentMethodIcon(paymentMethod)} +
+
{paymentMethod}
-
{order.paymentMethod}
+ + {(order.description || order.remark) && ( +
+ 备注 + + {order.description || order.remark} + +
+ )} + + {order.payTime && ( +
+ 支付时间 + + {formatTimestamp(order.payTime)} + +
+ )} + + {order.balance !== undefined && ( +
+ 充值后余额: ¥{order.balance.toFixed(2)} +
+ )}
- {order.description && ( -
- 备注 - {order.description} -
- )} - - {order.payTime && ( -
- 支付时间 - {formatTime(order.payTime)} -
- )} - - {order.balance !== undefined && ( -
- 充值后余额: ¥{order.balance.toFixed(2)} -
- )} -
- - {order.status === "pending" && ( + {/* {order.status === "pending" && (
- )} + )} */} - {order.status === "success" && ( -
- - -
- )} - - {order.status === "failed" && ( -
- -
- )} - - ); + {actions.length > 0 && ( +
{actions}
+ )} + + ); + }; const filterTabs = [ { key: "all", label: "全部" }, - { key: "success", label: "成功" }, - { key: "pending", label: "处理中" }, - { key: "failed", label: "失败" }, - { key: "cancelled", label: "已取消" }, + { key: "1", label: "成功" }, + { key: "0", label: "待支付" }, + { key: "2", label: "已取消" }, + { key: "3", label: "已退款" }, ]; return ( diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx index 7590f6fa..76d460c8 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx @@ -197,7 +197,7 @@ const BasicSettings: React.FC = ({ // 下载模板 const handleDownloadTemplate = () => { const template = - "电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03"; + "姓名/备注,电话号码,微信号,来源,订单金额,下单日期\n张三,13800138000,wxid_123,抖音,99.00,2024-03-03"; const blob = new Blob([template], { type: "text/csv" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/Cunkebao/src/router/index.tsx b/Cunkebao/src/router/index.tsx index 117681a7..fc9adb97 100644 --- a/Cunkebao/src/router/index.tsx +++ b/Cunkebao/src/router/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom"; import PermissionRoute from "./permissionRoute"; +import FloatingVideoHelp from "@/components/FloatingVideoHelp"; // 动态导入所有 module 下的 ts/tsx 路由模块 const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true }); @@ -43,6 +44,7 @@ const AppRouter: React.FC = () => ( }} > + {/* */} ); diff --git a/Cunkebao/src/router/module/mine.tsx b/Cunkebao/src/router/module/mine.tsx index bf2bc20c..58e9ec87 100644 --- a/Cunkebao/src/router/module/mine.tsx +++ b/Cunkebao/src/router/module/mine.tsx @@ -9,6 +9,7 @@ import WechatAccounts from "@/pages/mobile/mine/wechat-accounts/list/index"; import WechatAccountDetail from "@/pages/mobile/mine/wechat-accounts/detail/index"; import Recharge from "@/pages/mobile/mine/recharge/index"; import RechargeOrder from "@/pages/mobile/mine/recharge/order/index"; +import RechargeOrderDetail from "@/pages/mobile/mine/recharge/order/detail"; import BuyPower from "@/pages/mobile/mine/recharge/buy-power"; import UsageRecords from "@/pages/mobile/mine/recharge/usage-records"; import Setting from "@/pages/mobile/mine/setting/index"; @@ -76,6 +77,11 @@ const routes = [ element: , auth: true, }, + { + path: "/recharge/order/:id", + element: , + auth: true, + }, { path: "/recharge/buy-power", element: , diff --git a/Moncter/runtime/logs/workerman.log b/Moncter/runtime/logs/workerman.log new file mode 100644 index 00000000..9f4c1a7e --- /dev/null +++ b/Moncter/runtime/logs/workerman.log @@ -0,0 +1,5 @@ +2025-11-07 15:58:25 pid:842 Workerman[start.php] start in DEBUG mode +2025-11-07 16:03:24 pid:842 Workerman[start.php] reloading +2025-11-07 18:26:50 pid:842 Workerman[start.php] received signal SIGHUP +2025-11-07 18:26:50 pid:842 Workerman[start.php] stopping +2025-11-07 18:26:50 pid:842 Workerman[start.php] has been stopped diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/InputMessage.tsx new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss new file mode 100644 index 00000000..b8085ce9 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/InputMessage/index.module.scss @@ -0,0 +1,147 @@ +.chatFooter { + background: #f7f7f7; + border-top: 1px solid #e1e1e1; + padding: 0; + height: auto; + border-radius: 8px; +} + +.inputContainer { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.inputToolbar { + display: flex; + align-items: center; + padding: 4px 0; +} + +.leftTool { + display: flex; + gap: 4px; + align-items: center; +} + +.toolbarButton { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: #666; + font-size: 16px; + transition: all 0.15s; + border: none; + background: transparent; + + &:hover { + background: #e6e6e6; + color: #333; + } + + &:active { + background: #d9d9d9; + } +} + +.inputArea { + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.inputWrapper { + border: 1px solid #d1d1d1; + border-radius: 4px; + background: #fff; + overflow: hidden; + + &:focus-within { + border-color: #07c160; + } +} + +.messageInput { + width: 100%; + border: none; + resize: none; + font-size: 13px; + line-height: 1.4; + padding: 8px 10px; + background: transparent; + + &:focus { + box-shadow: none; + outline: none; + } + + &::placeholder { + color: #b3b3b3; + } +} + +.sendButtonArea { + padding: 8px 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.sendButton { + height: 32px; + border-radius: 4px; + font-weight: normal; + min-width: 60px; + font-size: 13px; + background: #07c160; + border-color: #07c160; + + &:hover { + background: #06ad56; + border-color: #06ad56; + } + + &:active { + background: #059748; + border-color: #059748; + } + + &:disabled { + background: #b3b3b3; + border-color: #b3b3b3; + opacity: 1; + } +} + +.hintButton { + border: none; + background: transparent; + color: #666; + font-size: 12px; + + &:hover { + color: #333; + } +} + +.inputHint { + font-size: 11px; + color: #999; + text-align: right; + margin-top: 2px; +} + +@media (max-width: 768px) { + .inputToolbar { + flex-wrap: wrap; + gap: 8px; + } + + .sendButtonArea { + justify-content: space-between; + } +} diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss new file mode 100644 index 00000000..fee50c0a --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.module.scss @@ -0,0 +1,265 @@ +.stepContent { + .stepHeader { + margin-bottom: 20px; + + h3 { + font-size: 18px; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 8px 0; + } + + p { + font-size: 14px; + color: #666; + margin: 0; + } + } +} + +.step3Content { + display: flex; + gap: 24px; + align-items: flex-start; + + .leftColumn { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .rightColumn { + width: 400px; + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + } + + .messagePreview { + border: 2px dashed #52c41a; + border-radius: 8px; + padding: 20px; + background: #f6ffed; + + .previewTitle { + font-size: 14px; + color: #52c41a; + font-weight: 500; + margin-bottom: 12px; + } + + .messageBubble { + min-height: 60px; + padding: 12px; + background: #fff; + border-radius: 6px; + color: #666; + font-size: 14px; + line-height: 1.6; + + .currentEditingLabel { + font-size: 12px; + color: #999; + margin-bottom: 8px; + } + + .messageText { + color: #333; + white-space: pre-wrap; + word-break: break-word; + } + } + } + + .savedScriptGroups { + .scriptGroupTitle { + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 12px; + } + + .scriptGroupItem { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; + background: #fff; + + .scriptGroupHeader { + display: flex; + justify-content: space-between; + align-items: center; + + .scriptGroupLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + :global(.ant-radio) { + margin-right: 4px; + } + + .scriptGroupName { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .messageCount { + font-size: 12px; + color: #999; + margin-left: 8px; + } + } + + .scriptGroupActions { + display: flex; + gap: 4px; + + .actionButton { + padding: 4px; + color: #666; + + &:hover { + color: #1890ff; + } + } + } + } + + .scriptGroupContent { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f0f0f0; + font-size: 13px; + color: #666; + } + } + } + + .messageInputArea { + .messageInput { + margin-bottom: 12px; + } + + .attachmentButtons { + display: flex; + gap: 8px; + margin-bottom: 12px; + } + + .aiRewriteSection { + display: flex; + align-items: center; + margin-bottom: 8px; + } + + .messageHint { + font-size: 12px; + color: #999; + } + } + + .settingsPanel { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #fafafa; + + .settingsTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 16px; + } + + .settingItem { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + .settingControl { + display: flex; + align-items: center; + gap: 8px; + + span { + font-size: 14px; + color: #666; + min-width: 80px; + } + } + } + } + + .tagSection { + .settingLabel { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + } + + .pushPreview { + border: 1px solid #e8e8e8; + border-radius: 8px; + padding: 20px; + background: #f0f7ff; + + .previewTitle { + font-size: 14px; + font-weight: 500; + color: #1a1a1a; + margin-bottom: 12px; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: 14px; + color: #666; + line-height: 1.8; + } + } + } +} + +@media (max-width: 1200px) { + .step3Content { + .rightColumn { + width: 350px; + } + } +} + +@media (max-width: 768px) { + .step3Content { + flex-direction: column; + + .leftColumn { + width: 100%; + } + + .rightColumn { + width: 100%; + } + } +} + diff --git a/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx new file mode 100644 index 00000000..082831d5 --- /dev/null +++ b/Moncter/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSendMessage/index.tsx @@ -0,0 +1,6 @@ +import ContentSelection from "@/components/ContentSelection"; +import { ContentItem } from "@/components/ContentSelection/data"; +import InputMessage from "./InputMessage/InputMessage"; +import styles from "./index.module.scss"; + +interface StepSendMessageProps { diff --git a/Moncter/src/pages/pc/ckbox/weChat/api.ts b/Moncter/src/pages/pc/ckbox/weChat/api.ts new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx b/Moncter/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/Moncter/src/store/module/websocket/websocket.ts b/Moncter/src/store/module/websocket/websocket.ts new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md new file mode 100644 index 00000000..54d76890 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +#cunkebao_v3 diff --git a/Touchkebao/src/components/Layout/layout.module.scss b/Touchkebao/src/components/Layout/layout.module.scss index 3818f44d..c43e8901 100644 --- a/Touchkebao/src/components/Layout/layout.module.scss +++ b/Touchkebao/src/components/Layout/layout.module.scss @@ -2,7 +2,6 @@ display: flex; height: 100vh; flex-direction: column; - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); } .container main { diff --git a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx index e5400bf6..e1475cdc 100644 --- a/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx +++ b/Touchkebao/src/components/TwoColumnSelection/TwoColumnSelection.tsx @@ -17,6 +17,7 @@ const FriendListItem = memo<{ onClick={() => onSelect(friend)} > +     {friend.nickname?.charAt(0)} @@ -41,6 +42,9 @@ interface TwoColumnSelectionProps { deviceIds?: number[]; enableDeviceFilter?: boolean; dataSource?: FriendSelectionItem[]; + onLoadMore?: () => void; // 加载更多回调 + hasMore?: boolean; // 是否有更多数据 + loading?: boolean; // 是否正在加载 } const TwoColumnSelection: React.FC = ({ @@ -51,15 +55,16 @@ const TwoColumnSelection: React.FC = ({ deviceIds = [], enableDeviceFilter = true, dataSource, + onLoadMore, + hasMore = false, + loading = false, }) => { const [rawFriends, setRawFriends] = useState([]); const [selectedFriends, setSelectedFriends] = useState( [], ); const [searchQuery, setSearchQuery] = useState(""); - const [loading, setLoading] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); // 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算 const filteredFriends = useMemo(() => { @@ -76,17 +81,8 @@ const TwoColumnSelection: React.FC = ({ ); }, [dataSource, rawFriends, searchQuery]); - // 分页显示好友列表,避免一次性渲染太多项目 - const ITEMS_PER_PAGE = 50; - const [displayPage, setDisplayPage] = useState(1); - - const friends = useMemo(() => { - const startIndex = 0; - const endIndex = displayPage * ITEMS_PER_PAGE; - return filteredFriends.slice(startIndex, endIndex); - }, [filteredFriends, displayPage]); - - const hasMoreFriends = filteredFriends.length > friends.length; + // 好友列表直接使用过滤后的结果 + const friends = filteredFriends; // 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算 const selectedFriendsMap = useMemo(() => { @@ -100,7 +96,7 @@ const TwoColumnSelection: React.FC = ({ // 获取好友列表 const fetchFriends = useCallback( async (page: number, keyword: string = "") => { - setLoading(true); + setIsLoading(true); try { const params: any = { page, @@ -119,7 +115,6 @@ const TwoColumnSelection: React.FC = ({ if (response.success) { setRawFriends(response.data.list || []); - setTotalPages(Math.ceil((response.data.total || 0) / 20)); } else { setRawFriends([]); message.error(response.message || "获取好友列表失败"); @@ -128,7 +123,7 @@ const TwoColumnSelection: React.FC = ({ console.error("获取好友列表失败:", error); message.error("获取好友列表失败"); } finally { - setLoading(false); + setIsLoading(false); } }, [deviceIds, enableDeviceFilter], @@ -139,7 +134,6 @@ const TwoColumnSelection: React.FC = ({ if (visible && !dataSource) { // 只有在没有外部数据源时才调用 API fetchFriends(1); - setCurrentPage(1); } }, [visible, dataSource, fetchFriends]); @@ -148,49 +142,23 @@ const TwoColumnSelection: React.FC = ({ if (visible) { setSearchQuery(""); setSelectedFriends([]); - setLoading(false); + setIsLoading(false); } }, [visible]); // 防抖搜索处理 - const handleSearch = useCallback(() => { - let timeoutId: NodeJS.Timeout; - return (value: string) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - setDisplayPage(1); // 重置分页 - if (!dataSource) { - fetchFriends(1, value); - } - }, 300); - }; - }, [dataSource, fetchFriends])(); - - // API搜索处理(当没有外部数据源时) - const handleApiSearch = useCallback( - async (keyword: string) => { + const handleSearch = useCallback( + (value: string) => { if (!dataSource) { - await fetchFriends(1, keyword); + const timer = setTimeout(() => { + fetchFriends(1, value); + }, 300); + return () => clearTimeout(timer); } }, [dataSource, fetchFriends], ); - // 加载更多好友 - const handleLoadMore = useCallback(() => { - setDisplayPage(prev => prev + 1); - }, []); - - // 防抖搜索 - useEffect(() => { - if (!dataSource && searchQuery.trim()) { - const timer = setTimeout(() => { - handleApiSearch(searchQuery); - }, 300); - return () => clearTimeout(timer); - } - }, [searchQuery, dataSource, handleApiSearch]); - // 选择好友 - 使用 useCallback 优化性能 const handleSelectFriend = useCallback((friend: FriendSelectionItem) => { setSelectedFriends(prev => { @@ -216,10 +184,8 @@ const TwoColumnSelection: React.FC = ({ setSearchQuery(""); }, [selectedFriends, onConfirm]); - // 取消选择 - 使用 useCallback 优化性能 + // 取消选择 const handleCancel = useCallback(() => { - setSelectedFriends([]); - setSearchQuery(""); onCancel(); }, [onCancel]); @@ -248,8 +214,8 @@ const TwoColumnSelection: React.FC = ({ value={searchQuery} onChange={e => { const value = e.target.value; - setSearchQuery(value); // 立即更新显示 - handleSearch(value); // 防抖处理搜索 + setSearchQuery(value); + handleSearch(value); }} prefix={} allowClear @@ -257,7 +223,7 @@ const TwoColumnSelection: React.FC = ({
- {loading ? ( + {isLoading && !loading ? (
加载中...
) : friends.length > 0 ? ( // 使用 React.memo 优化列表项渲染 @@ -280,9 +246,10 @@ const TwoColumnSelection: React.FC = ({
)} - {hasMoreFriends && ( + {/* 使用外部传入的加载更多 */} + {hasMore && (
-
diff --git a/Touchkebao/src/main.tsx b/Touchkebao/src/main.tsx index a3392df2..dde8d3ad 100644 --- a/Touchkebao/src/main.tsx +++ b/Touchkebao/src/main.tsx @@ -7,97 +7,18 @@ import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import App from "./App"; import "./styles/global.scss"; -import { db } from "./utils/db"; // 引入数据库实例 +import { initializeDatabaseFromPersistedUser } from "./utils/db"; // 设置dayjs为中文 dayjs.locale("zh-cn"); -// 清理旧数据库 -async function cleanupOldDatabase() { +async function bootstrap() { try { - // 获取所有数据库 - const databases = await indexedDB.databases(); - - for (const dbInfo of databases) { - if (dbInfo.name === "CunkebaoDatabase") { - console.log("检测到旧版数据库,开始清理..."); - - // 打开数据库检查版本 - const openRequest = indexedDB.open(dbInfo.name); - - await new Promise((resolve, reject) => { - openRequest.onsuccess = async event => { - const database = (event.target as IDBOpenDBRequest).result; - const objectStoreNames = Array.from(database.objectStoreNames); - - // 检查是否存在旧表 - const hasOldTables = objectStoreNames.some(name => - [ - "kfUsers", - "weChatGroup", - "contracts", - "newContactList", - "messageList", - ].includes(name), - ); - - if (hasOldTables) { - console.log("发现旧表,删除整个数据库:", objectStoreNames); - database.close(); - - // 删除整个数据库 - const deleteRequest = indexedDB.deleteDatabase(dbInfo.name); - deleteRequest.onsuccess = () => { - console.log("旧数据库已删除"); - resolve(); - }; - deleteRequest.onerror = () => { - console.error("删除旧数据库失败"); - reject(); - }; - } else { - console.log("数据库结构正确,无需清理"); - database.close(); - resolve(); - } - }; - - openRequest.onerror = () => { - console.error("无法打开数据库进行检查"); - reject(); - }; - }); - } - } + await initializeDatabaseFromPersistedUser(); } catch (error) { - console.warn("清理旧数据库时出错(可忽略):", error); - } -} - -// 数据库初始化 -async function initializeApp() { - try { - // 1. 清理旧数据库 - await cleanupOldDatabase(); - - // 2. 打开新数据库 - await db.open(); - console.log("数据库初始化成功"); - - // 3. 开发环境清空数据(可选) - if (process.env.NODE_ENV === "development") { - console.log("开发环境:跳过数据清理"); - // 如需清空数据,取消下面的注释 - // await db.chatSessions.clear(); - // await db.contactsUnified.clear(); - // await db.contactLabelMap.clear(); - // await db.userLoginRecords.clear(); - } - } catch (error) { - console.error("数据库初始化失败:", error); + console.warn("Failed to prepare database before app bootstrap:", error); } - // 渲染应用 const root = createRoot(document.getElementById("root")!); root.render( @@ -106,5 +27,4 @@ async function initializeApp() { ); } -// 启动应用 -initializeApp(); +void bootstrap(); diff --git a/Touchkebao/src/pages/pc/ckbox/api.ts b/Touchkebao/src/pages/pc/ckbox/api.ts index 1fe61086..f5717fa7 100644 --- a/Touchkebao/src/pages/pc/ckbox/api.ts +++ b/Touchkebao/src/pages/pc/ckbox/api.ts @@ -35,28 +35,64 @@ export function updateConfig(params) { return request2("/api/WechatFriend/updateConfig", params, "PUT"); } //获取聊天记录-2 获取列表 -export function getChatMessages(params: { - wechatAccountId: number; - wechatFriendId?: number; - wechatChatroomId?: number; - From: number; - To: number; - Count: number; - olderData: boolean; -}) { - return request2("/api/FriendMessage/SearchMessage", params, "GET"); +export interface messreocrParams { + From?: number | string; + To?: number | string; + /** + * 当前页码,从 1 开始 + */ + page?: number; + /** + * 每页条数 + */ + limit?: number; + /** + * 群id + */ + wechatChatroomId?: number | string; + /** + * 好友id + */ + wechatFriendId?: number | string; + /** + * 微信账号ID + */ + wechatAccountId?: number | string; + /** + * 关键词、类型等扩展参数 + */ + [property: string]: any; } -export function getChatroomMessages(params: { - wechatAccountId: number; - wechatFriendId?: number; - wechatChatroomId?: number; - From: number; - To: number; - Count: number; - olderData: boolean; -}) { - return request2("/api/ChatroomMessage/SearchMessage", params, "GET"); +export function getChatMessages(params: messreocrParams) { + return request("/v1/kefu/message/details", params, "GET"); } +export function getChatroomMessages(params: messreocrParams) { + return request("/v1/kefu/message/details", params, "GET"); +} +//=====================旧============================== + +// export function getChatMessages(params: { +// wechatAccountId: number; +// wechatFriendId?: number; +// wechatChatroomId?: number; +// From: number; +// To: number; +// Count: number; +// olderData: boolean; +// }) { +// return request2("/api/FriendMessage/SearchMessage", params, "GET"); +// } +// export function getChatroomMessages(params: { +// wechatAccountId: number; +// wechatFriendId?: number; +// wechatChatroomId?: number; +// From: number; +// To: number; +// Count: number; +// olderData: boolean; +// }) { +// return request2("/api/ChatroomMessage/SearchMessage", params, "GET"); +// } //获取群列表 export function getGroupList(params: { prevId: number; count: number }) { diff --git a/Touchkebao/src/pages/pc/ckbox/data.ts b/Touchkebao/src/pages/pc/ckbox/data.ts index 037a4367..9b8aa285 100644 --- a/Touchkebao/src/pages/pc/ckbox/data.ts +++ b/Touchkebao/src/pages/pc/ckbox/data.ts @@ -146,7 +146,7 @@ export interface ContractData { labels: string[]; signature: string; accountId: number; - extendFields: null; + extendFields?: Record | null; city?: string; lastUpdateTime: string; isPassed: boolean; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx index 18ef6ce4..af9d7373 100644 --- a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/components/PushTaskModal.tsx @@ -23,12 +23,16 @@ import { SendOutlined, } from "@ant-design/icons"; import styles from "./PushTaskModal.module.scss"; -import { - useCustomerStore, -} from "@/store/module/weChat/customer"; +import { useCustomerStore } from "@/store/module/weChat/customer"; import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; -export type PushType = "friend-message" | "group-message" | "group-announcement"; +const DEFAULT_FRIEND_INTERVAL: [number, number] = [3, 10]; +const DEFAULT_MESSAGE_INTERVAL: [number, number] = [1, 3]; + +export type PushType = + | "friend-message" + | "group-message" + | "group-announcement"; interface PushTaskModalProps { visible: boolean; @@ -67,8 +71,12 @@ const PushTaskModal: React.FC = ({ const [selectedAccounts, setSelectedAccounts] = useState([]); const [selectedContacts, setSelectedContacts] = useState([]); const [messageContent, setMessageContent] = useState(""); - const [friendInterval, setFriendInterval] = useState(10); - const [messageInterval, setMessageInterval] = useState(1); + const [friendInterval, setFriendInterval] = useState<[number, number]>([ + ...DEFAULT_FRIEND_INTERVAL, + ]); + const [messageInterval, setMessageInterval] = useState<[number, number]>([ + ...DEFAULT_MESSAGE_INTERVAL, + ]); const [selectedTag, setSelectedTag] = useState(""); const [aiRewriteEnabled, setAiRewriteEnabled] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); @@ -97,7 +105,7 @@ const PushTaskModal: React.FC = ({ }; const getSubtitle = () => { - return "智能批量推送,AI智能话术改写"; + return "智能批量推送,AI智能话术改写"; }; // 步骤2的标题 @@ -120,8 +128,8 @@ const PushTaskModal: React.FC = ({ setSelectedAccounts([]); setSelectedContacts([]); setMessageContent(""); - setFriendInterval(10); - setMessageInterval(1); + setFriendInterval([...DEFAULT_FRIEND_INTERVAL]); + setMessageInterval([...DEFAULT_MESSAGE_INTERVAL]); setSelectedTag(""); setAiRewriteEnabled(false); setAiPrompt(""); @@ -270,7 +278,9 @@ const PushTaskModal: React.FC = ({ setCurrentStep(2); } else if (currentStep === 2) { if (selectedContacts.length === 0) { - message.warning(`请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`); + message.warning( + `请至少选择一个${pushType === "friend-message" ? "好友" : "群"}`, + ); return; } setCurrentStep(3); @@ -343,7 +353,9 @@ const PushTaskModal: React.FC = ({
{filteredAccounts.length > 0 ? ( filteredAccounts.map(account => { - const isSelected = selectedAccounts.some(a => a.id === account.id); + const isSelected = selectedAccounts.some( + a => a.id === account.id, + ); return (
= ({ size={48} style={{ backgroundColor: "#1890ff" }} > - {!account.avatar && (account.nickname || account.name || "").charAt(0)} + {!account.avatar && + (account.nickname || account.name || "").charAt(0)}
{account.nickname || account.name || "未知"} @@ -570,10 +583,7 @@ const PushTaskModal: React.FC = ({
- + AI智能话术改写 {aiRewriteEnabled && ( = ({
间隔时间(秒) setFriendInterval(value as [number, number])} style={{ flex: 1, margin: "0 16px" }} /> - {friendInterval} - 20 + + {friendInterval[0]} - {friendInterval[1]} +
@@ -612,13 +625,18 @@ const PushTaskModal: React.FC = ({
间隔时间(秒) + setMessageInterval(value as [number, number]) + } style={{ flex: 1, margin: "0 16px" }} /> - {messageInterval} - 12 + + {messageInterval[0]} - {messageInterval[1]} +
diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts new file mode 100644 index 00000000..34fa4bb4 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/api.ts @@ -0,0 +1,6 @@ +import request from "@/api/request"; + +// 获取客服列表 +export function queryWorkbenchCreate(params) { + return request("/v1/workbench/create", params, "POST"); +} diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx new file mode 100644 index 00000000..278889cc --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectAccount/index.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { Avatar, Empty, Input } from "antd"; +import { CheckCircleOutlined, SearchOutlined } from "@ant-design/icons"; + +import styles from "../../index.module.scss"; + +interface StepSelectAccountProps { + customerList: any[]; + selectedAccounts: any[]; + onChange: (accounts: any[]) => void; +} + +const StepSelectAccount: React.FC = ({ + customerList, + selectedAccounts, + onChange, +}) => { + const [searchKeyword, setSearchKeyword] = useState(""); + + const filteredAccounts = useMemo(() => { + if (!searchKeyword.trim()) return customerList; + const keyword = searchKeyword.toLowerCase(); + return customerList.filter( + account => + (account.nickname || "").toLowerCase().includes(keyword) || + (account.wechatId || "").toLowerCase().includes(keyword), + ); + }, [customerList, searchKeyword]); + + const handleAccountToggle = (account: any) => { + const isSelected = selectedAccounts.some(a => a.id === account.id); + if (isSelected) { + onChange(selectedAccounts.filter(a => a.id !== account.id)); + return; + } + onChange([...selectedAccounts, account]); + }; + + return ( +
+
+

选择微信账号

+

可选择多个微信账号进行推送

+
+ +
+ } + value={searchKeyword} + onChange={e => setSearchKeyword(e.target.value)} + allowClear + /> +
+ + {filteredAccounts.length > 0 ? ( +
+ {filteredAccounts.map(account => { + const isSelected = selectedAccounts.some(s => s.id === account.id); + return ( +
handleAccountToggle(account)} + > + + {!account.avatar && + (account.nickname || account.name || "").charAt(0)} + +
+
+ {account.nickname || account.name || "未知"} +
+
+ + + {account.isOnline ? "在线" : "离线"} + +
+
+ {isSelected && ( + + )} +
+ ); + })} +
+ ) : ( + + )} +
+ ); +}; + +export default StepSelectAccount; diff --git a/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx new file mode 100644 index 00000000..93bc0d31 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/powerCenter/message-push-assistant/create-push-task/components/StepSelectContacts/index.tsx @@ -0,0 +1,691 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Avatar, + Button, + Checkbox, + Empty, + Input, + Pagination, + Spin, + message, + Modal, + Select, +} from "antd"; +import { + CloseOutlined, + SearchOutlined, + TeamOutlined, + UserOutlined, +} from "@ant-design/icons"; + +import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api"; + +import styles from "../../index.module.scss"; +import { ContactItem, PushType } from "../../types"; +import PoolSelection from "@/components/PoolSelection"; +import type { PoolSelectionItem } from "@/components/PoolSelection/data"; + +interface ContactFilterValues { + includeTags: string[]; + excludeTags: string[]; + includeCities: string[]; + excludeCities: string[]; + nicknameRemark: string; + groupIds: string[]; +} + +const createDefaultFilterValues = (): ContactFilterValues => ({ + includeTags: [], + excludeTags: [], + includeCities: [], + excludeCities: [], + nicknameRemark: "", + groupIds: [], +}); + +const cloneFilterValues = ( + values: ContactFilterValues, +): ContactFilterValues => ({ + includeTags: [...values.includeTags], + excludeTags: [...values.excludeTags], + includeCities: [...values.includeCities], + excludeCities: [...values.excludeCities], + nicknameRemark: values.nicknameRemark, + groupIds: [...values.groupIds], +}); + +const DISABLED_TAG_LABELS = new Set(["请选择标签"]); + +interface StepSelectContactsProps { + pushType: PushType; + selectedAccounts: any[]; + selectedContacts: ContactItem[]; + onChange: (contacts: ContactItem[]) => void; + selectedTrafficPools: PoolSelectionItem[]; + onTrafficPoolsChange: (pools: PoolSelectionItem[]) => void; +} + +const StepSelectContacts: React.FC = ({ + pushType, + selectedAccounts, + selectedContacts, + onChange, + selectedTrafficPools, + onTrafficPoolsChange, +}) => { + const [contactsData, setContactsData] = useState([]); + const [loadingContacts, setLoadingContacts] = useState(false); + const [page, setPage] = useState(1); + const [searchValue, setSearchValue] = useState(""); + const [total, setTotal] = useState(0); + const [filterModalVisible, setFilterModalVisible] = useState(false); + const [filterValues, setFilterValues] = useState( + createDefaultFilterValues, + ); + const [draftFilterValues, setDraftFilterValues] = + useState(createDefaultFilterValues); + + const pageSize = 20; + + const stepTitle = useMemo(() => { + switch (pushType) { + case "friend-message": + return "好友"; + case "group-message": + case "group-announcement": + return "群"; + default: + return "选择"; + } + }, [pushType]); + + const loadContacts = useCallback(async () => { + if (selectedAccounts.length === 0) { + setContactsData([]); + setTotal(0); + return; + } + + setLoadingContacts(true); + try { + const accountIds = selectedAccounts.map(a => a.id); + const allData: ContactItem[] = []; + let totalCount = 0; + + for (const accountId of accountIds) { + const params: any = { + page, + limit: pageSize, + wechatAccountId: accountId, + }; + + if (searchValue.trim()) { + params.keyword = searchValue.trim(); + } + + const response = + pushType === "friend-message" + ? await getContactList(params) + : await getGroupList(params); + + const data = + response.data?.list || response.data || response.list || []; + const totalValue = response.data?.total || response.total || 0; + + const filteredData = data.filter((item: any) => { + const itemAccountId = item.wechatAccountId || item.accountId; + return itemAccountId === accountId; + }); + + filteredData.forEach((item: ContactItem) => { + if (!allData.some(d => d.id === item.id)) { + allData.push(item); + } + }); + + totalCount += totalValue; + } + + setContactsData(allData); + setTotal(totalCount > 0 ? totalCount : allData.length); + } catch (error) { + console.error("加载数据失败:", error); + message.error("加载数据失败"); + setContactsData([]); + setTotal(0); + } finally { + setLoadingContacts(false); + } + }, [page, pushType, searchValue, selectedAccounts]); + + useEffect(() => { + loadContacts(); + }, [loadContacts]); + + useEffect(() => { + if (!searchValue.trim()) { + return; + } + setPage(1); + }, [searchValue]); + + useEffect(() => { + setPage(1); + if (selectedAccounts.length === 0 && selectedContacts.length > 0) { + onChange([]); + } + }, [onChange, selectedAccounts, selectedContacts.length]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + if (!value.trim()) { + setPage(1); + } + }; + + const tagOptions = useMemo(() => { + const tagSet = new Set(); + contactsData.forEach(contact => { + (contact.labels || []).forEach(tag => { + const normalizedTag = (tag || "").trim(); + if (normalizedTag && !DISABLED_TAG_LABELS.has(normalizedTag)) { + tagSet.add(normalizedTag); + } + }); + }); + return Array.from(tagSet).map(tag => ({ label: tag, value: tag })); + }, [contactsData]); + + const cityOptions = useMemo(() => { + const citySet = new Set(); + contactsData.forEach(contact => { + const city = (contact.city || contact.region || "").trim(); + if (city) { + citySet.add(city); + } + }); + return Array.from(citySet).map(city => ({ label: city, value: city })); + }, [contactsData]); + + const groupOptions = useMemo(() => { + const groupMap = new Map(); + contactsData.forEach(contact => { + const key = + contact.groupName || + contact.groupLabel || + (contact.groupId !== undefined ? contact.groupId.toString() : ""); + if (key) { + const display = + contact.groupName || contact.groupLabel || `分组 ${key}`; + groupMap.set(key, display); + } + }); + return Array.from(groupMap.entries()).map(([value, label]) => ({ + value, + label, + })); + }, [contactsData]); + + const hasActiveFilter = useMemo(() => { + const { + includeTags, + excludeTags, + includeCities, + excludeCities, + nicknameRemark, + groupIds, + } = filterValues; + + if ( + includeTags.length || + excludeTags.length || + includeCities.length || + excludeCities.length || + groupIds.length || + nicknameRemark.trim() + ) { + return true; + } + return false; + }, [filterValues]); + + const filteredContacts = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + const nicknameKeyword = filterValues.nicknameRemark.trim().toLowerCase(); + + return contactsData.filter(contact => { + const labels = contact.labels || []; + const city = (contact.city || contact.region || "").toLowerCase(); + const groupValue = + contact.groupName || + contact.groupLabel || + (contact.groupId !== undefined ? contact.groupId.toString() : ""); + + if (keyword) { + const combined = `${contact.nickname || ""} ${ + contact.conRemark || "" + }`.toLowerCase(); + if (!combined.includes(keyword)) { + return false; + } + } + + if (filterValues.includeTags.length > 0) { + const hasAllIncludes = filterValues.includeTags.every(tag => + labels.includes(tag), + ); + if (!hasAllIncludes) { + return false; + } + } + + if (filterValues.excludeTags.length > 0) { + const hasExcluded = filterValues.excludeTags.some(tag => + labels.includes(tag), + ); + if (hasExcluded) { + return false; + } + } + + if (filterValues.includeCities.length > 0) { + const matchCity = filterValues.includeCities.some(value => + city.includes(value.toLowerCase()), + ); + if (!matchCity) { + return false; + } + } + + if (filterValues.excludeCities.length > 0) { + const matchExcludedCity = filterValues.excludeCities.some(value => + city.includes(value.toLowerCase()), + ); + if (matchExcludedCity) { + return false; + } + } + + if (nicknameKeyword) { + const combined = `${contact.nickname || ""} ${ + contact.conRemark || "" + }`.toLowerCase(); + if (!combined.includes(nicknameKeyword)) { + return false; + } + } + + if (filterValues.groupIds.length > 0) { + if (!groupValue) { + return false; + } + if ( + !filterValues.groupIds.some(value => value === groupValue.toString()) + ) { + return false; + } + } + + return true; + }); + }, [contactsData, filterValues, searchValue]); + + const displayTotal = useMemo(() => { + if (hasActiveFilter) { + return filteredContacts.length; + } + return total; + }, [filteredContacts, hasActiveFilter, total]); + + const handleContactToggle = (contact: ContactItem) => { + const isSelected = selectedContacts.some(c => c.id === contact.id); + if (isSelected) { + onChange(selectedContacts.filter(c => c.id !== contact.id)); + return; + } + onChange([...selectedContacts, contact]); + }; + + const handleRemoveContact = (contactId: number) => { + onChange(selectedContacts.filter(c => c.id !== contactId)); + }; + + const handleSelectAllContacts = () => { + if (filteredContacts.length === 0) return; + const allSelected = filteredContacts.every(contact => + selectedContacts.some(c => c.id === contact.id), + ); + if (allSelected) { + const currentIds = filteredContacts.map(c => c.id); + onChange(selectedContacts.filter(c => !currentIds.includes(c.id))); + return; + } + const toAdd = filteredContacts.filter( + contact => !selectedContacts.some(c => c.id === contact.id), + ); + onChange([...selectedContacts, ...toAdd]); + }; + + const openFilterModal = () => { + setDraftFilterValues(cloneFilterValues(filterValues)); + setFilterModalVisible(true); + }; + + const closeFilterModal = () => { + setFilterModalVisible(false); + }; + + const handleFilterConfirm = () => { + setFilterValues(cloneFilterValues(draftFilterValues)); + setPage(1); + setFilterModalVisible(false); + }; + + const handleFilterReset = () => { + const nextValues = createDefaultFilterValues(); + setDraftFilterValues(nextValues); + setFilterValues(nextValues); + setPage(1); + setFilterModalVisible(false); + }; + + const updateDraftFilter = ( + key: K, + value: ContactFilterValues[K], + ) => { + setDraftFilterValues(prev => ({ + ...prev, + [key]: value, + })); + }; + + const handlePageChange = (p: number) => { + setPage(p); + }; + + return ( +
+
+
+

选择{stepTitle}

+

从{stepTitle}列表中选择推送对象

+
+
+ } + value={searchValue} + onChange={e => handleSearchChange(e.target.value)} + allowClear + /> +
+ +
+
+
+ + {stepTitle}列表(共{displayTotal}个) + +
+ + +
+
+
+ {loadingContacts ? ( +
+ + 加载中... +
+ ) : filteredContacts.length > 0 ? ( + filteredContacts.map(contact => { + const isSelected = selectedContacts.some( + c => c.id === contact.id, + ); + return ( +
handleContactToggle(contact)} + > + + + ) : ( + + ) + } + /> +
+
+ {contact.nickname} +
+ {contact.conRemark && ( +
+ {contact.conRemark} +
+ )} +
+ {contact.type === "group" && ( + + )} +
+ ); + }) + ) : ( + + )} +
+ {displayTotal > 0 && ( +
+ +
+ )} +
+ +
+
+ + 已选{stepTitle}列表(共{selectedContacts.length}个) + + {selectedContacts.length > 0 && ( + + )} +
+
+ {selectedContacts.length > 0 ? ( + selectedContacts.map(contact => ( +
+
+ + ) : ( + + ) + } + /> +
+
{contact.nickname}
+ {contact.conRemark && ( +
+ {contact.conRemark} +
+ )} +
+ {contact.type === "group" && ( + + )} +
+ { + e.stopPropagation(); + handleRemoveContact(contact.id); + }} + /> +
+ )) + ) : ( + + )} +
+
+
+
+ + + 重置 + , + , + , + ]} + > +
+
标签
+
+
+ + + updateDraftFilter("excludeTags", value) + } + /> +
+
+
+ +
+
城市
+
+
+ + + updateDraftFilter("excludeCities", value) + } + /> +
+
+
+ +
+
昵称/备注
+
+ + updateDraftFilter("nicknameRemark", e.target.value) + } + /> +
+
+ +
+
分组
+
+