From 7dda34a7793d83464f576f628141e83adf517bb3 Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Wed, 17 Dec 2025 16:20:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E9=94=80=E5=8A=9F=E8=83=BD=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cunkebao/src/components/NavCommon/index.tsx | 2 +- .../wechat-accounts/detail/detail.module.scss | 60 +- .../mobile/scenarios/plan/new/index.data.ts | 19 + .../pages/mobile/scenarios/plan/new/index.tsx | 89 +- .../plan/new/steps/BasicSettings.tsx | 512 ++++++- .../scenarios/plan/new/steps/base.module.scss | 211 +++ .../workspace/distribution-management/api.ts | 145 ++ .../components/AddChannelModal.module.scss | 290 ++++ .../components/AddChannelModal.tsx | 431 ++++++ .../workspace/distribution-management/data.ts | 67 + .../distribution-management/detail/api.ts | 192 +++ .../distribution-management/detail/data.ts | 52 + .../detail/index.module.scss | 574 ++++++++ .../distribution-management/detail/index.tsx | 623 +++++++++ .../distribution-management/index.module.scss | 1015 ++++++++++++++ .../distribution-management/index.tsx | 1205 +++++++++++++++++ .../src/pages/mobile/workspace/main/index.tsx | 15 + Cunkebao/src/router/module/workspace.tsx | 15 +- Server/application/cunkebao/config/route.php | 41 + .../distribution/ChannelController.php | 1163 ++++++++++++++++ .../distribution/ChannelUserController.php | 590 ++++++++ .../distribution/WithdrawalController.php | 670 +++++++++ .../GetAddFriendPlanDetailV1Controller.php | 34 + .../PostCreateAddFriendPlanV1Controller.php | 84 +- .../plan/PostExternalApiV1Controller.php | 52 +- .../PostUpdateAddFriendPlanV1Controller.php | 85 +- .../plan/PosterWeChatMiniProgram.php | 106 +- .../wechat/GetWechatMomentsV1Controller.php | 3 +- .../cunkebao/model/DistributionChannel.php | 87 ++ .../cunkebao/model/DistributionWithdrawal.php | 71 + .../service/DistributionRewardService.php | 276 ++++ .../cunkebao/validate/DistributionChannel.php | 31 + .../Adapters/ChuKeBao/Adapter.php | 35 + Server/sql.sql | 219 ++- 34 files changed, 8959 insertions(+), 105 deletions(-) create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/api.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/data.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/detail/api.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/detail/data.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/detail/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/detail/index.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/distribution-management/index.tsx create mode 100644 Server/application/cunkebao/controller/distribution/ChannelController.php create mode 100644 Server/application/cunkebao/controller/distribution/ChannelUserController.php create mode 100644 Server/application/cunkebao/controller/distribution/WithdrawalController.php create mode 100644 Server/application/cunkebao/model/DistributionChannel.php create mode 100644 Server/application/cunkebao/model/DistributionWithdrawal.php create mode 100644 Server/application/cunkebao/service/DistributionRewardService.php create mode 100644 Server/application/cunkebao/validate/DistributionChannel.php diff --git a/Cunkebao/src/components/NavCommon/index.tsx b/Cunkebao/src/components/NavCommon/index.tsx index 0ee5ec1b..5085a6ea 100644 --- a/Cunkebao/src/components/NavCommon/index.tsx +++ b/Cunkebao/src/components/NavCommon/index.tsx @@ -4,7 +4,7 @@ import { ArrowLeftOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; import { getSafeAreaHeight } from "@/utils/common"; interface NavCommonProps { - title: string; + title: string | React.ReactNode; backFn?: () => void; right?: React.ReactNode; left?: React.ReactNode; diff --git a/Cunkebao/src/pages/mobile/mine/wechat-accounts/detail/detail.module.scss b/Cunkebao/src/pages/mobile/mine/wechat-accounts/detail/detail.module.scss index 41530573..0f6ed761 100644 --- a/Cunkebao/src/pages/mobile/mine/wechat-accounts/detail/detail.module.scss +++ b/Cunkebao/src/pages/mobile/mine/wechat-accounts/detail/detail.module.scss @@ -709,7 +709,7 @@ .adm-avatar { width: 52px; height: 52px; - border-radius: 50%; + border-radius: 50%; border: 2px solid #f0f0f0; } } @@ -723,13 +723,13 @@ } .friend-header { - display: flex; - align-items: center; + display: flex; + align-items: center; justify-content: space-between; gap: 12px; } - .friend-name { + .friend-name { font-size: 16px; font-weight: 600; color: #111; @@ -754,7 +754,7 @@ display: flex; flex-direction: column; gap: 4px; - } + } .friend-info-item { font-size: 13px; @@ -764,7 +764,7 @@ gap: 4px; .info-label { - color: #999; + color: #999; flex-shrink: 0; } @@ -775,11 +775,11 @@ text-overflow: ellipsis; white-space: nowrap; } - } + } .friend-tags { - display: flex; - flex-wrap: wrap; + display: flex; + flex-wrap: wrap; gap: 6px; margin-top: 4px; } @@ -866,11 +866,11 @@ margin-top: 20px; } - .popup-footer { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid #f0f0f0; - } + .popup-footer { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + } .friend-info-card { display: flex; @@ -1441,22 +1441,22 @@ } } -.moments-action-bar { - display: flex; - justify-content: space-between; + .moments-action-bar { + display: flex; + justify-content: space-between; align-items: center; padding: 12px 16px; background: white; border-bottom: 1px solid #f0f0f0; - .action-button, .action-button-dark { - display: flex; - align-items: center; - justify-content: center; + .action-button, .action-button-dark { + display: flex; + align-items: center; + justify-content: center; width: 40px; - height: 40px; - border-radius: 8px; - background: #1677ff; + height: 40px; + border-radius: 8px; + background: #1677ff; border: none; cursor: pointer; transition: all 0.2s; @@ -1465,22 +1465,22 @@ &:active { background: #0958d9; transform: scale(0.95); - } + } svg { font-size: 20px; - color: white; - } - } + color: white; + } + } - .action-button-dark { + .action-button-dark { background: #1677ff; color: white; &:active { background: #0958d9; + } } - } } .moments-content { diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.data.ts b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.data.ts index 3cd21563..a3921add 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.data.ts +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.data.ts @@ -30,6 +30,20 @@ export interface FormData { wechatGroups: string[]; wechatGroupsOptions: GroupSelectionItem[]; messagePlans: any[]; + // 分销相关 + distributionEnabled?: boolean; + // 选中的分销渠道ID列表(前端使用,提交时转为 distributionChannels) + distributionChannelIds?: Array; + // 选中的分销渠道选项(用于回显名称) + distributionChannelsOptions?: Array<{ + id: string | number; + name: string; + code?: string; + }>; + // 获客奖励金额(元,前端使用,提交时转为 customerRewardAmount) + distributionCustomerReward?: number; + // 添加奖励金额(元,前端使用,提交时转为 addFriendRewardAmount) + distributionAddReward?: number; [key: string]: any; } export const defFormData: FormData = { @@ -56,4 +70,9 @@ export const defFormData: FormData = { wechatGroupsOptions: [], contentGroups: [], contentGroupsOptions: [], + distributionEnabled: false, + distributionChannelIds: [], + distributionChannelsOptions: [], + distributionCustomerReward: undefined, + distributionAddReward: undefined, }; diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx index e7032d04..b9c4a20b 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/index.tsx @@ -15,6 +15,7 @@ import { updatePlan, } from "./index.api"; import { FormData, defFormData, steps } from "./index.data"; +import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api"; export default function NewPlan() { const router = useNavigate(); @@ -49,6 +50,42 @@ export default function NewPlan() { //获取计划详情 const detail = await getPlanDetail(planId); + + // 处理分销相关数据回填 + const distributionChannels = detail.distributionChannels || []; + let distributionChannelsOptions: Array<{ id: string | number; name: string; code?: string }> = []; + + if (distributionChannels.length > 0) { + // 判断 distributionChannels 是对象数组还是ID数组 + const isObjectArray = distributionChannels.some((item: any) => typeof item === 'object' && item !== null); + + if (isObjectArray) { + // 如果已经是对象数组,直接使用(包含 id, code, name) + distributionChannelsOptions = distributionChannels.map((channel: any) => ({ + id: channel.id, + name: channel.name || `渠道${channel.id}`, + code: channel.code, + })); + } else { + // 如果是ID数组,需要查询渠道信息 + try { + const channelRes = await fetchChannelList({ page: 1, limit: 200, status: "enabled" }); + distributionChannelsOptions = distributionChannels.map((channelId: number) => { + const channel = channelRes.list.find((c: any) => c.id === channelId); + return channel + ? { id: channelId, name: channel.name, code: channel.code } + : { id: channelId, name: `渠道${channelId}` }; + }); + } catch { + // 如果获取渠道信息失败,使用默认名称 + distributionChannelsOptions = distributionChannels.map((channelId: number) => ({ + id: channelId, + name: `渠道${channelId}`, + })); + } + } + } + setFormData(prev => ({ ...prev, name: detail.name ?? "", @@ -76,6 +113,12 @@ export default function NewPlan() { contentGroupsOptions: detail.contentGroupsOptions ?? [], status: detail.status ?? 0, messagePlans: detail.messagePlans ?? [], + // 分销相关数据回填 + distributionEnabled: detail.distributionEnabled ?? false, + distributionChannelIds: distributionChannelsOptions.map(item => item.id), + distributionChannelsOptions: distributionChannelsOptions, + distributionCustomerReward: detail.customerRewardAmount, + distributionAddReward: detail.addFriendRewardAmount, })); } else { if (scenarioId) { @@ -118,21 +161,45 @@ export default function NewPlan() { setSubmitting(true); try { + // 构建提交数据,转换分销相关字段为接口需要的格式 + const submitData: any = { + ...formData, + sceneId: Number(formData.scenario), + }; + + // 转换分销相关字段为接口需要的格式 + if (formData.distributionEnabled) { + submitData.distributionEnabled = true; + // 转换渠道ID数组,确保都是数字类型 + submitData.distributionChannels = (formData.distributionChannelIds || []).map(id => + typeof id === 'string' ? Number(id) : id + ); + // 转换奖励金额,确保是浮点数,最多2位小数 + submitData.customerRewardAmount = formData.distributionCustomerReward + ? Number(Number(formData.distributionCustomerReward).toFixed(2)) + : 0; + submitData.addFriendRewardAmount = formData.distributionAddReward + ? Number(Number(formData.distributionAddReward).toFixed(2)) + : 0; + } else { + // 如果未开启分销,设置为false + submitData.distributionEnabled = false; + } + + // 移除前端使用的字段,避免提交到后端 + delete submitData.distributionChannelIds; + delete submitData.distributionChannelsOptions; + delete submitData.distributionCustomerReward; + delete submitData.distributionAddReward; + if (isEdit && planId) { // 编辑:拼接后端需要的完整参数 - const editData = { - ...formData, - ...{ sceneId: Number(formData.scenario) }, - id: Number(planId), - planId: Number(planId), - // 兼容后端需要的字段 - // 你可以根据实际需要补充其它字段 - }; - await updatePlan(editData); + submitData.id = Number(planId); + submitData.planId = Number(planId); + await updatePlan(submitData); } else { // 新建 - formData.sceneId = Number(formData.scenario); - await createPlan(formData); + await createPlan(submitData); } message.success(isEdit ? "计划已更新" : "获客计划已创建"); const sceneItem = sceneList.find(v => formData.scenario === v.id); 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 6c61b6da..0573b0d2 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/BasicSettings.tsx @@ -1,17 +1,24 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Input, Button, Tag, Switch, Modal, Spin } from "antd"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Input, Button, Tag, Switch, Spin, message, Modal } from "antd"; import { PlusOutlined, EyeOutlined, CloseOutlined, DownloadOutlined, + SearchOutlined, + DeleteOutlined, } from "@ant-design/icons"; +import { Checkbox, Popup } from "antd-mobile"; import { uploadFile } from "@/api/common"; import styles from "./base.module.scss"; +import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api"; import { posterTemplates } from "./base.data"; import GroupSelection from "@/components/GroupSelection"; import FileUpload from "@/components/Upload/FileUpload"; import { GroupSelectionItem } from "@/components/GroupSelection/data"; +import Layout from "@/components/Layout/Layout"; +import PopupHeader from "@/components/PopuLayout/header"; +import PopupFooter from "@/components/PopuLayout/footer"; interface BasicSettingsProps { isEdit: boolean; @@ -68,6 +75,26 @@ const BasicSettings: React.FC = ({ questionExtraction: formData.phoneSettings?.questionExtraction ?? true, }); + // 分销相关状态 + const [distributionEnabled, setDistributionEnabled] = useState( + formData.distributionEnabled ?? false, + ); + const [channelModalVisible, setChannelModalVisible] = useState(false); + const [channelLoading, setChannelLoading] = useState(false); + const [channelList, setChannelList] = useState([]); + const [tempSelectedChannelIds, setTempSelectedChannelIds] = useState< + Array + >(formData.distributionChannelIds || []); + const [channelSearchQuery, setChannelSearchQuery] = useState(""); + const [channelCurrentPage, setChannelCurrentPage] = useState(1); + const [channelTotal, setChannelTotal] = useState(0); + const [customerReward, setCustomerReward] = useState( + formData.distributionCustomerReward + ); + const [addReward, setAddReward] = useState( + formData.distributionAddReward + ); + // 新增:自定义海报相关状态 const [customPosters, setCustomPosters] = useState([]); const [previewUrl, setPreviewUrl] = useState(null); @@ -97,6 +124,19 @@ const BasicSettings: React.FC = ({ setTips(formData.tips || ""); }, [formData.tips]); + // 同步分销相关的外部表单数据到本地状态 + useEffect(() => { + setDistributionEnabled(formData.distributionEnabled ?? false); + setTempSelectedChannelIds(formData.distributionChannelIds || []); + setCustomerReward(formData.distributionCustomerReward); + setAddReward(formData.distributionAddReward); + }, [ + formData.distributionEnabled, + formData.distributionChannelIds, + formData.distributionCustomerReward, + formData.distributionAddReward, + ]); + // 选中场景 const handleScenarioSelect = (sceneId: number) => { onChange({ ...formData, scenario: sceneId }); @@ -225,6 +265,181 @@ const BasicSettings: React.FC = ({ wechatGroupsOptions: groups, }); }; + + const PAGE_SIZE = 20; + + // 加载分销渠道列表,支持keyword和分页,强制只获取启用的渠道 + const loadDistributionChannels = useCallback( + async (keyword: string = "", page: number = 1) => { + setChannelLoading(true); + try { + const res = await fetchChannelList({ + page, + limit: PAGE_SIZE, + keyword: keyword.trim() || undefined, + status: "enabled", // 强制只获取启用的渠道 + }); + setChannelList(res.list || []); + setChannelTotal(res.total || 0); + } catch (error: any) { + + } finally { + setChannelLoading(false); + } + }, + [] + ); + + const handleToggleDistribution = (value: boolean) => { + setDistributionEnabled(value); + // 关闭时清空已选渠道和奖励金额 + if (!value) { + setTempSelectedChannelIds([]); + setCustomerReward(undefined); + setAddReward(undefined); + onChange({ + ...formData, + distributionEnabled: false, + distributionChannelIds: [], + distributionChannelsOptions: [], + distributionCustomerReward: undefined, + distributionAddReward: undefined, + }); + } else { + onChange({ + ...formData, + distributionEnabled: true, + }); + } + }; + + // 打开弹窗时获取第一页 + useEffect(() => { + if (channelModalVisible) { + setChannelSearchQuery(""); + setChannelCurrentPage(1); + // 复制一份已选渠道到临时变量 + setTempSelectedChannelIds(formData.distributionChannelIds || []); + loadDistributionChannels("", 1); + } + }, [channelModalVisible, loadDistributionChannels, formData.distributionChannelIds]); + + // 搜索防抖 + useEffect(() => { + if (!channelModalVisible) return; + const timer = setTimeout(() => { + setChannelCurrentPage(1); + loadDistributionChannels(channelSearchQuery, 1); + }, 500); + return () => clearTimeout(timer); + }, [channelSearchQuery, channelModalVisible, loadDistributionChannels]); + + // 翻页时重新请求 + useEffect(() => { + if (!channelModalVisible) return; + loadDistributionChannels(channelSearchQuery, channelCurrentPage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channelCurrentPage]); + + const handleOpenChannelModal = () => { + setChannelModalVisible(true); + }; + + const handleChannelToggle = (channel: any) => { + const id = channel.id; + setTempSelectedChannelIds(prev => + prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id], + ); + }; + + // 直接使用从API返回的渠道列表(API已过滤为只返回启用的) + const filteredChannels = channelList; + + const channelTotalPages = Math.max(1, Math.ceil(channelTotal / PAGE_SIZE)); + + // 全选当前页 + const handleSelectAllCurrentPage = (checked: boolean) => { + if (checked) { + // 全选:添加当前页面所有未选中的渠道 + const currentPageChannels = filteredChannels.filter( + (channel: any) => !tempSelectedChannelIds.includes(channel.id), + ); + setTempSelectedChannelIds(prev => [ + ...prev, + ...currentPageChannels.map((c: any) => c.id), + ]); + } else { + // 取消全选:移除当前页面的所有渠道 + const currentPageChannelIds = filteredChannels.map((c: any) => c.id); + setTempSelectedChannelIds(prev => + prev.filter(id => !currentPageChannelIds.includes(id)), + ); + } + }; + + // 检查当前页是否全选 + const isCurrentPageAllSelected = + filteredChannels.length > 0 && + filteredChannels.every((channel: any) => + tempSelectedChannelIds.includes(channel.id), + ); + + const handleConfirmChannels = () => { + const selectedOptions = + channelList + .filter(c => tempSelectedChannelIds.includes(c.id)) + .map(c => ({ + id: c.id, + name: c.name, + })) || []; + + onChange({ + ...formData, + distributionEnabled: true, + distributionChannelIds: tempSelectedChannelIds, + distributionChannelsOptions: selectedOptions, + }); + setDistributionEnabled(true); + setChannelModalVisible(false); + }; + + const handleCancelChannels = () => { + setChannelModalVisible(false); + // 取消时恢复为表单中的已有值 + setTempSelectedChannelIds(formData.distributionChannelIds || []); + }; + + // 获取显示文本(参考设备选择) + const getChannelDisplayText = () => { + const selectedChannels = formData.distributionChannelsOptions || []; + if (selectedChannels.length === 0) return ""; + return `已选择 ${selectedChannels.length} 个渠道`; + }; + + // 删除已选渠道 + const handleRemoveChannel = (id: string | number) => { + const newChannelIds = (formData.distributionChannelIds || []).filter( + (cid: string | number) => cid !== id + ); + const newChannelOptions = (formData.distributionChannelsOptions || []).filter( + (item: { id: string | number; name: string }) => item.id !== id + ); + onChange({ + ...formData, + distributionChannelIds: newChannelIds, + distributionChannelsOptions: newChannelOptions, + }); + }; + + // 清除所有已选渠道 + const handleClearAllChannels = () => { + onChange({ + ...formData, + distributionChannelIds: [], + distributionChannelsOptions: [], + }); + }; + return (
{/* 场景选择区块 */} @@ -473,6 +688,190 @@ const BasicSettings: React.FC = ({
)} + {/* 分销设置 */} +
+
+
+
分销设置
+
+ 开启后,可将当前场景的获客用户同步到指定分销渠道 +
+
+ +
+ + {distributionEnabled && ( + <> + {/* 输入框 - 参考设备选择样式 */} +
+ } + allowClear + onClear={handleClearAllChannels} + size="large" + readOnly + style={{ cursor: "pointer" }} + /> +
+ {/* 已选渠道列表 - 参考设备选择样式 */} + {formData.distributionChannelsOptions && + formData.distributionChannelsOptions.length > 0 ? ( +
+ {formData.distributionChannelsOptions.map( + (item: { id: string | number; name: string; code?: string }) => ( +
+ {/* 渠道图标 */} +
+ + {(item.name || "渠")[0]} + +
+ +
+
+ {item.name} +
+ {item.code && ( +
+ 编码: {item.code} +
+ )} +
+
+ ), + )} +
+ ) : null} + {/* 奖励金额设置 */} +
+
获客奖励金额(元)
+ { + const value = e.target.value ? Number(e.target.value) : undefined; + setCustomerReward(value); + onChange({ + ...formData, + distributionCustomerReward: value, + }); + }} + min={0} + step={0.01} + style={{ marginBottom: 12 }} + /> +
添加奖励金额(元)
+ { + const value = e.target.value ? Number(e.target.value) : undefined; + setAddReward(value); + onChange({ + ...formData, + distributionAddReward: value, + }); + }} + min={0} + step={0.01} + /> +
+ + )} +
+ {/* 订单导入区块 - 使用FileUpload组件 */}
订单表格上传
@@ -559,6 +958,115 @@ const BasicSettings: React.FC = ({ onChange={value => onChange({ ...formData, status: value ? 1 : 0 })} />
+ + {/* 分销渠道选择弹框 - 参考设备选择样式 */} + + loadDistributionChannels(channelSearchQuery, channelCurrentPage)} + showTabs={false} + /> + } + footer={ + + } + > +
+ {channelLoading && channelList.length === 0 ? ( +
+
加载中...
+
+ ) : filteredChannels.length === 0 ? ( +
+
+ 暂无分销渠道,请先在「分销管理」中创建渠道 +
+
+ ) : ( +
+ {filteredChannels.map((channel: any) => ( +
+ {/* 顶部行:选择框和编码 */} +
+
+ handleChannelToggle(channel)} + className={styles["channelCheckbox"]} + /> +
+ + 编码: {channel.code} + +
+ + {/* 主要内容区域:渠道信息 */} +
+ {/* 渠道信息 */} +
+
+ + {channel.name} + +
+ {channel.status === "enabled" ? "启用" : "禁用"} +
+
+
+ {channel.phone && ( +
+ 手机号: + + {channel.phone} + +
+ )} + {channel.wechatId && ( +
+ 微信号: + + {channel.wechatId} + +
+ )} +
+
+
+
+ ))} +
+ )} +
+
+
); }; diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/base.module.scss b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/base.module.scss index aac76ca4..a51e743c 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/base.module.scss +++ b/Cunkebao/src/pages/mobile/scenarios/plan/new/steps/base.module.scss @@ -161,3 +161,214 @@ justify-content: space-between; margin: 16px 0; } + +// 分销渠道选择弹框样式 - 参考设备选择 +.channelList { + flex: 1; + overflow-y: auto; +} + +.channelListInner { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +.channelItem { + display: flex; + flex-direction: column; + padding: 12px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + transition: all 0.2s ease; + border: 1px solid #f5f5f5; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + } +} + +.headerRow { + display: flex; + align-items: center; + gap: 8px; +} + +.checkboxContainer { + flex-shrink: 0; +} + +.codeText { + font-size: 13px; + color: #666; + font-family: monospace; + flex: 1; +} + +.mainContent { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f8f9fa; + } +} + +.channelCheckbox { + flex-shrink: 0; +} + +.channelContent { + flex: 1; + min-width: 0; +} + +.channelInfoRow { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.channelName { + font-size: 16px; + font-weight: 600; + color: #1a1a1a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusEnabled { + font-size: 11px; + padding: 1px 6px; + border-radius: 8px; + color: #52c41a; + background: #f6ffed; + border: 1px solid #b7eb8f; + font-weight: 500; +} + +.statusDisabled { + font-size: 11px; + padding: 1px 6px; + border-radius: 8px; + color: #ff4d4f; + background: #fff2f0; + border: 1px solid #ffccc7; + font-weight: 500; +} + +.channelInfoDetail { + display: flex; + flex-direction: column; + gap: 4px; +} + +.infoItem { + display: flex; + align-items: center; + gap: 8px; +} + +.infoLabel { + font-size: 13px; + color: #666; + min-width: 60px; +} + +.infoValue { + font-size: 13px; + color: #333; + + &.customerCount { + font-weight: 500; + } +} + +.loadingBox { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.loadingText { + color: #888; + font-size: 15px; +} + +// 分销设置 +.basic-distribution { + margin: 16px 0; + padding: 16px; + background: #f7f8fa; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + + .basic-distribution-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + + .basic-distribution-title { + font-size: 15px; + font-weight: 500; + color: #333; + } + + .basic-distribution-desc { + font-size: 12px; + color: #999; + margin-top: 4px; + } + } + + .distribution-input-wrapper { + position: relative; + margin-top: 12px; + + .ant-input { + padding-left: 38px !important; + height: 56px; + border-radius: 16px !important; + border: 1px solid #e5e6eb !important; + font-size: 16px; + background: #f8f9fa; + } + } + + .distribution-selected-list { + .distribution-selected-item { + &:last-child { + border-bottom: none; + } + } + } + + .distribution-rewards { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #e5e6eb; + } +} + +.basic-distribution-modal-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.basic-distribution-modal-tag { + margin-bottom: 4px; +} diff --git a/Cunkebao/src/pages/mobile/workspace/distribution-management/api.ts b/Cunkebao/src/pages/mobile/workspace/distribution-management/api.ts new file mode 100644 index 00000000..6f637d33 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/distribution-management/api.ts @@ -0,0 +1,145 @@ +// 分销管理 API + +import request from "@/api/request"; +import type { + Channel, + Statistics, + FundStatistics, + ChannelEarnings, + WithdrawalRequest, + WithdrawalStatus, +} from "./data"; + +// 获取统计数据 +export const fetchStatistics = async (): Promise => { + return request("/v1/distribution/channels/statistics", {}, "GET"); +}; + +// 获取渠道列表 +export const fetchChannelList = async (params: { + page?: number; + limit?: number; + keyword?: string; + status?: "enabled" | "disabled"; // 渠道状态筛选 +}): Promise<{ list: Channel[]; total: number }> => { + return request("/v1/distribution/channels", params, "GET"); +}; + +// 创建渠道 +export const createChannel = async (data: { + name: string; + phone?: string; + wechatId?: string; + remarks?: string; +}): Promise => { + return request("/v1/distribution/channel", data, "POST"); +}; + +// 更新渠道 +export const updateChannel = async ( + id: string, + data: { + name: string; + phone?: string; + wechatId?: string; + remarks?: string; + }, +): Promise => { + return request(`/v1/distribution/channel/${id}`, data, "PUT"); +}; + +// 删除渠道 +export const deleteChannel = async (id: string): Promise => { + return request(`/v1/distribution/channel/${id}`, null, "DELETE"); +}; + +// 禁用/启用渠道 +export const toggleChannelStatus = async ( + id: string, + status: "enabled" | "disabled", +): Promise => { + return request(`/v1/distribution/channel/${id}/status`, { status }, "PUT"); +}; + +// 获取资金统计数据 +export const fetchFundStatistics = async (): Promise => { + return request("/v1/distribution/channels/revenue-statistics", {}, "GET"); +}; + +// 获取渠道收益列表 +export const fetchChannelEarningsList = async (params: { + page?: number; + limit?: number; + keyword?: string; +}): Promise<{ list: ChannelEarnings[]; total: number }> => { + const queryParams: any = {}; + if (params.page) queryParams.page = params.page; + if (params.limit) queryParams.limit = params.limit; + if (params.keyword) queryParams.keyword = params.keyword; + + return request("/v1/distribution/channels/revenue-detail", queryParams, "GET"); +}; + +// 获取提现申请列表 +export const fetchWithdrawalList = async (params: { + page?: number; + limit?: number; + status?: WithdrawalStatus; + date?: string; + keyword?: string; +}): Promise<{ list: WithdrawalRequest[]; total: number }> => { + const queryParams: any = {}; + if (params.page) queryParams.page = params.page; + if (params.limit) queryParams.limit = params.limit; + if (params.status && params.status !== "all") { + queryParams.status = params.status; + } + if (params.date) queryParams.date = params.date; + if (params.keyword) queryParams.keyword = params.keyword; + + return request("/v1/distribution/withdrawals", queryParams, "GET"); +}; + +// 审核提现申请 +export const reviewWithdrawal = async ( + id: string, + action: "approve" | "reject", + remark?: string, +): Promise => { + const data: any = { action }; + // 拒绝时 remark 必填,通过时可选 + if (action === "reject") { + if (!remark || !remark.trim()) { + throw new Error("拒绝时必须填写审核备注"); + } + data.remark = remark.trim(); + } else if (remark) { + // 通过时如果有备注也传递 + data.remark = remark.trim(); + } + return request(`/v1/distribution/withdrawals/${id}/review`, data, "POST"); +}; + +// 标记为已打款 +export const markAsPaid = async ( + id: string, + payType: "wechat" | "alipay" | "bankcard", + remark?: string, +): Promise => { + const data: any = { payType }; + if (remark) { + data.remark = remark.trim(); + } + return request(`/v1/distribution/withdrawals/${id}/mark-paid`, data, "POST"); +}; + +// 生成二维码 +export const generateQRCode = async ( + type: "h5" | "miniprogram", +): Promise<{ + type: "h5" | "miniprogram"; + qrCode: string; + url: string; +}> => { + return request("/v1/distribution/channel/generate-qrcode", { type }, "POST"); +}; diff --git a/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.module.scss b/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.module.scss new file mode 100644 index 00000000..c677408e --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.module.scss @@ -0,0 +1,290 @@ +.modalWrapper { + :global(.ant-modal-content) { + padding: 0; + border-radius: 16px; + overflow: hidden; + } + + :global(.ant-modal-body) { + padding: 0; + max-height: 85vh; + overflow: hidden; + } +} + +.modal { + display: flex; + flex-direction: column; + max-height: 85vh; + background: #fff; +} + +// 头部 +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid #f0f0f0; +} + +.headerLeft { + flex: 1; +} + +.title { + font-size: 18px; + font-weight: 600; + color: #222; + margin: 0 0 8px 0; +} + +.subtitle { + font-size: 12px; + color: #888; + margin: 0; + line-height: 1.4; +} + +.closeBtn { + font-size: 20px; + color: #888; + cursor: pointer; + padding: 4px; + flex-shrink: 0; + margin-left: 12px; + + &:hover { + color: #222; + } +} + +// 创建方式选择 +.methodTabs { + display: flex; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; +} + +.methodTab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #f8f9fa; + color: #666; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: #1890ff; + color: #1890ff; + } + + &.active { + background: #fff; + border-color: #1890ff; + color: #222; + font-weight: 500; + } +} + +.tabIcon { + font-size: 16px; +} + +// 内容区域 +.content { + flex: 1; + overflow-y: auto; + padding: 20px; + min-height: 0; +} + +// 表单样式 +.form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.formItem { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + font-size: 14px; + color: #222; + font-weight: 500; +} + +.required { + color: #ff4d4f; + margin-left: 2px; +} + +.input { + :global(.ant-input) { + border-radius: 8px; + height: 44px; + font-size: 14px; + } +} + +.phoneHint { + margin-top: 4px; + min-height: 18px; + display: flex; + align-items: center; +} + +.textarea { + :global(.adm-text-area) { + border-radius: 8px; + font-size: 14px; + padding: 12px; + } +} + +// 扫码创建样式 +.scanContent { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0; +} + +.qrCodeContainer { + margin-bottom: 20px; +} + +.qrCodeBox { + width: 200px; + height: 200px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.qrCode { + width: 100%; + height: 100%; + object-fit: contain; +} + +.qrCodePlaceholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.qrCodeIcon { + font-size: 80px; + color: #ddd; +} + +.scanInstruction { + font-size: 16px; + color: #222; + margin: 0 0 8px 0; + font-weight: 500; +} + +.scanDescription { + font-size: 13px; + color: #888; + margin: 0 0 20px 0; + text-align: center; +} + +.qrCodeTypeSelector { + width: 100%; + margin-bottom: 24px; +} + +.typeTabs { + display: flex; + gap: 12px; + background: #f5f5f5; + padding: 4px; + border-radius: 8px; +} + +.typeTab { + flex: 1; + padding: 8px 16px; + border: none; + border-radius: 6px; + background: transparent; + color: #666; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: #1890ff; + } + + &.active { + background: #fff; + color: #1890ff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +} + +// 底部按钮 +.footer { + display: flex; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid #f0f0f0; + background: #fff; +} + +.cancelBtn { + flex: 1; + height: 44px; + font-size: 16px; +} + +.submitBtn { + flex: 1; + height: 44px; + font-size: 16px; +} + +// 响应式 +@media (max-width: 375px) { + .header { + padding: 16px; + } + + .methodTabs { + padding: 12px 16px; + } + + .content { + padding: 16px; + } + + .qrCodeBox { + width: 180px; + height: 180px; + } +} diff --git a/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.tsx b/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.tsx new file mode 100644 index 00000000..6f2fe465 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/distribution-management/components/AddChannelModal.tsx @@ -0,0 +1,431 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button, TextArea, SpinLoading } from "antd-mobile"; +import { Modal, Input, message } from "antd"; +import { CloseOutlined, UserOutlined, QrcodeOutlined } from "@ant-design/icons"; +import { generateQRCode } from "../api"; +import styles from "./AddChannelModal.module.scss"; + +interface AddChannelModalProps { + visible: boolean; + onClose: () => void; + editData?: { + id: string; + name: string; + phone?: string; + wechatId?: string; + remarks?: string; + }; + onSubmit?: (data: { + id?: string; + name: string; + phone?: string; + wechatId?: string; + remarks?: string; + }) => void; +} + +type CreateMethod = "manual" | "scan"; + +const AddChannelModal: React.FC = ({ + visible, + onClose, + editData, + onSubmit, +}) => { + const isEdit = !!editData; + const [createMethod, setCreateMethod] = useState("manual"); + const [formData, setFormData] = useState({ + name: "", + phone: "", + wechatId: "", + remarks: "", + }); + const [loading, setLoading] = useState(false); + const [scanning, setScanning] = useState(false); + const [qrCodeType, setQrCodeType] = useState<"h5" | "miniprogram">("h5"); + const [qrCodeData, setQrCodeData] = useState<{ + qrCode: string; + url: string; + type: "h5" | "miniprogram"; + } | null>(null); + const [qrCodeLoading, setQrCodeLoading] = useState(false); + const generatingRef = useRef(false); // 用于防止重复请求 + + // 当编辑数据变化时,更新表单数据 + useEffect(() => { + if (editData) { + setFormData({ + name: editData.name || "", + phone: editData.phone || "", + wechatId: editData.wechatId || "", + remarks: editData.remarks || "", + }); + } else { + setFormData({ + name: "", + phone: "", + wechatId: "", + remarks: "", + }); + } + }, [editData, visible]); + + // 当弹窗打开或切换到扫码创建时,自动生成二维码 + useEffect(() => { + // 只有在弹窗可见、非编辑模式、选择扫码方式、没有二维码数据、且不在加载中时才生成 + if (visible && !isEdit && createMethod === "scan" && !qrCodeData && !qrCodeLoading && !generatingRef.current) { + // 使用 setTimeout 确保状态更新完成 + const timer = setTimeout(() => { + handleGenerateQRCode(); + }, 100); + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, createMethod]); + + // 当二维码类型变化时,重新生成二维码 + useEffect(() => { + if (visible && !isEdit && createMethod === "scan" && qrCodeData && !qrCodeLoading && !generatingRef.current) { + // 重置状态后重新生成 + setQrCodeData(null); + setScanning(false); + // 使用 setTimeout 确保状态更新完成 + const timer = setTimeout(() => { + handleGenerateQRCode(); + }, 100); + return () => clearTimeout(timer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qrCodeType]); + + // 验证手机号格式 + const validatePhone = (phone: string): boolean => { + if (!phone) return true; // 手机号是可选的,空值视为有效 + const phoneRegex = /^1[3-9]\d{9}$/; + return phoneRegex.test(phone); + }; + + // 处理手机号输入,只允许输入数字,最多11位 + const handlePhoneChange = (value: string) => { + // 只保留数字 + const numbersOnly = value.replace(/\D/g, ""); + // 限制最多11位 + const limitedValue = numbersOnly.slice(0, 11); + handleInputChange("phone", limitedValue); + }; + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ + ...prev, + [field]: value, + })); + }; + + const handleSubmit = async () => { + if (createMethod === "manual") { + if (!formData.name.trim()) { + message.error("请输入渠道名称"); + return; + } + + // 验证手机号格式 + if (formData.phone && formData.phone.trim()) { + if (!validatePhone(formData.phone.trim())) { + message.error("请输入正确的手机号(11位数字,1开头)"); + return; + } + } + + setLoading(true); + try { + await onSubmit?.({ + id: editData?.id, + name: formData.name.trim(), + phone: formData.phone?.trim() || undefined, + wechatId: formData.wechatId?.trim() || undefined, + remarks: formData.remarks?.trim() || undefined, + }); + // 成功后关闭弹窗(父组件会处理成功提示) + handleClose(); + } catch (e) { + // 错误已在父组件处理,这里不需要再次提示 + // 保持弹窗打开,让用户修改后重试 + } finally { + setLoading(false); + } + } else { + // 扫码创建逻辑 + if (!scanning) { + setScanning(true); + // TODO: 实现扫码创建逻辑 + message.info("扫码创建功能开发中"); + } + } + }; + + const handleClose = () => { + setFormData({ + name: "", + phone: "", + wechatId: "", + remarks: "", + }); + setScanning(false); + setQrCodeData(null); + setQrCodeType("h5"); + onClose(); + }; + + // 生成二维码 + const handleGenerateQRCode = async () => { + // 如果正在生成,直接返回,避免重复请求 + if (generatingRef.current || qrCodeLoading) { + return; + } + generatingRef.current = true; + setQrCodeLoading(true); + try { + const res = await generateQRCode(qrCodeType); + // 确保返回的数据有效 + if (res && res.qrCode) { + setQrCodeData(res); + setScanning(true); + } else { + throw new Error("二维码数据格式错误"); + } + } catch (e: any) { + // 接口拦截器已经显示了错误提示,这里不需要再次显示 + // 请求失败时重置状态,允许重试 + setQrCodeData(null); + setScanning(false); + } finally { + setQrCodeLoading(false); + generatingRef.current = false; + } + }; + + // 重新生成二维码 + const handleRegenerateQR = async () => { + setScanning(false); + setQrCodeData(null); + await handleGenerateQRCode(); + }; + + // 当切换到扫码创建时,自动生成二维码 + useEffect(() => { + if (visible && createMethod === "scan" && !isEdit && !qrCodeData && !qrCodeLoading) { + handleGenerateQRCode(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createMethod, visible]); + + // 当二维码类型变化时,重新生成二维码 + useEffect(() => { + if (visible && createMethod === "scan" && !isEdit && qrCodeData) { + handleGenerateQRCode(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qrCodeType, visible]); + + return ( + +
+ {/* 头部 */} +
+
+

{isEdit ? "编辑渠道" : "新增渠道"}

+

+ {isEdit + ? "修改渠道信息" + : "选择创建方式: 手动填写或扫码获取微信信息"} +

+
+ +
+ + {/* 创建方式选择 */} + {!isEdit && ( +
+ + +
+ )} + + {/* 内容区域 */} +
+ {createMethod === "manual" || isEdit ? ( +
+
+ + handleInputChange("name", e.target.value)} + className={styles.input} + /> +
+ +
+ + handlePhoneChange(e.target.value)} + className={styles.input} + maxLength={11} + type="tel" + /> + {formData.phone && formData.phone.length > 0 && ( +
+ {formData.phone.length < 11 ? ( + + 还需输入 {11 - formData.phone.length} 位数字 + + ) : !validatePhone(formData.phone) ? ( + + 手机号格式不正确,请以1开头 + + ) : ( + + ✓ 手机号格式正确 + + )} +
+ )} +
+ +
+ + handleInputChange("wechatId", e.target.value)} + className={styles.input} + /> +
+ +
+ +