This commit is contained in:
wong
2025-12-18 15:36:25 +08:00
parent 183df2cac3
commit 66c8623138
6 changed files with 353 additions and 115 deletions

View File

@@ -594,43 +594,52 @@ const ScenarioList: React.FC = () => {
<SpinLoading color="primary" />
<div>...</div>
</div>
) : qrImg ? (
<>
<img
src={qrImg}
alt="小程序二维码"
className={style["qr-image"]}
/>
{/* 链接复制区域 */}
<div className={style["qr-link-section"]}>
<div className={style["link-label"]}></div>
<div className={style["link-input-wrapper"]}>
<Input
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`}
readOnly
className={style["link-input"]}
placeholder="小程序链接"
/>
<Button
size="small"
onClick={() => {
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`;
navigator.clipboard.writeText(link);
Toast.show({
content: "链接已复制到剪贴板",
position: "top",
});
}}
className={style["copy-button"]}
>
<CopyOutlined />
</Button>
</div>
</div>
</>
) : (
<div className={style["qr-error"]}></div>
<>
{/* 二维码显示区域 */}
{qrImg ? (
<img
src={qrImg}
alt="小程序二维码"
className={style["qr-image"]}
/>
) : (
<div className={style["qr-error"]}>
<QrcodeOutlined style={{ fontSize: 48, color: "#999", marginBottom: 12 }} />
<div></div>
</div>
)}
{/* H5链接展示 - 无论二维码是否成功都要显示 */}
{currentTaskId && (
<div className={style["qr-link-section"]}>
<div className={style["link-label"]}>H5链接</div>
<div className={style["link-input-wrapper"]}>
<Input
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`}
readOnly
className={style["link-input"]}
placeholder="H5链接"
/>
<Button
size="small"
onClick={() => {
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`;
navigator.clipboard.writeText(link);
Toast.show({
content: "链接已复制到剪贴板",
position: "top",
});
}}
className={style["copy-button"]}
>
<CopyOutlined />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>

View File

@@ -5,6 +5,7 @@ import {
SearchOutlined,
DeleteOutlined,
QrcodeOutlined,
CopyOutlined,
} from "@ant-design/icons";
import { Checkbox, Popup } from "antd-mobile";
import styles from "./base.module.scss";
@@ -60,11 +61,46 @@ const DistributionSettings: React.FC<DistributionSettingsProps> = ({
const PAGE_SIZE = 20;
// 生成H5链接独立生成不依赖二维码
const generateH5Url = (channelId: string | number, channelCode?: string): string => {
// 生成H5链接参考列表中的实现
// 格式: https://h5.ckb.quwanzhi.com/#/pages/form/input2?id={planId}&channelId={channelId}
if (planId) {
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${planId}&channelId=${channelId}`;
} else if (channelCode) {
// 新建状态使用渠道code
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?channelCode=${channelCode}`;
}
return "";
};
// 生成渠道二维码
const generateChannelQRCode = async (channelId: string | number, channelCode?: string) => {
// 如果已经有二维码,直接返回
if (qrCodeMap[channelId]?.qrCode) {
return;
// 先生成H5链接无论二维码是否成功都要显示
const h5Url = generateH5Url(channelId, channelCode);
// 如果已经有二维码数据只更新H5链接如果还没有
if (qrCodeMap[channelId]) {
if (!qrCodeMap[channelId].url) {
setQrCodeMap(prev => ({
...prev,
[channelId]: { ...prev[channelId], url: h5Url },
}));
}
// 如果已经有二维码,直接返回
if (qrCodeMap[channelId].qrCode) {
return;
}
} else {
// 初始化先设置H5链接
setQrCodeMap(prev => ({
...prev,
[channelId]: {
qrCode: "",
url: h5Url,
loading: true,
},
}));
}
// 设置加载状态
@@ -85,18 +121,20 @@ const DistributionSettings: React.FC<DistributionSettingsProps> = ({
}
// 调用API生成二维码参考列表中的实现
// 接口返回格式: { code: 200, msg: "获取小程序码成功", data: "data:image/png;base64,..." }
const response = await request(
`/v1/plan/getWxMinAppCode`,
params,
"GET"
);
if (response && response.qrCode) {
// response 已经是 base64 字符串(因为 request 拦截器返回了 res.data.data
if (response && typeof response === 'string' && response.startsWith('data:image')) {
setQrCodeMap(prev => ({
...prev,
[channelId]: {
qrCode: response.qrCode,
url: response.url || "",
qrCode: response, // base64 图片数据
url: h5Url, // H5链接确保即使失败也有
loading: false,
},
}));
@@ -104,13 +142,19 @@ const DistributionSettings: React.FC<DistributionSettingsProps> = ({
throw new Error("二维码生成失败");
}
} catch (error: any) {
// 即使二维码生成失败也要保留H5链接
Toast.show({
content: error.message || "生成二维码失败",
position: "top",
});
setQrCodeMap(prev => ({
...prev,
[channelId]: { ...prev[channelId], loading: false },
[channelId]: {
...prev[channelId],
qrCode: "", // 二维码为空
url: h5Url, // H5链接保留
loading: false,
},
}));
}
};
@@ -673,63 +717,93 @@ const DistributionSettings: React.FC<DistributionSettingsProps> = ({
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div style={{ fontSize: 14, color: "#666" }}>...</div>
</div>
) : qrCodeMap[currentQrChannel.id]?.qrCode ? (
) : (
<>
<img
src={qrCodeMap[currentQrChannel.id].qrCode}
alt="渠道二维码"
style={{
width: 200,
height: 200,
border: "1px solid #e5e6eb",
borderRadius: 8,
padding: 8,
background: "#fff",
}}
/>
{qrCodeMap[currentQrChannel.id].url && (
{/* 二维码显示区域 */}
{qrCodeMap[currentQrChannel.id]?.qrCode ? (
<img
src={qrCodeMap[currentQrChannel.id].qrCode}
alt="渠道二维码"
style={{
width: 200,
height: 200,
border: "1px solid #e5e6eb",
borderRadius: 8,
padding: 8,
background: "#fff",
}}
/>
) : (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "40px 20px",
color: "#999",
}}>
<QrcodeOutlined style={{ fontSize: 48 }} />
<div style={{ fontSize: 14 }}></div>
<Button
size="small"
color="primary"
onClick={() => currentQrChannel && generateChannelQRCode(currentQrChannel.id, currentQrChannel.code)}
>
</Button>
</div>
)}
{/* H5链接展示 - 无论二维码是否成功都要显示 */}
{qrCodeMap[currentQrChannel.id]?.url && (
<div style={{
width: "100%",
padding: "12px",
background: "#f5f5f5",
borderRadius: 8,
marginTop: 16,
}}>
<div style={{
fontSize: 12,
fontSize: 14,
color: "#666",
marginBottom: 8,
fontWeight: 500,
}}>
H5链接
</div>
<div style={{
fontSize: 12,
color: "#333",
wordBreak: "break-all",
display: "flex",
gap: 8,
alignItems: "center",
}}>
{qrCodeMap[currentQrChannel.id].url}
<Input
value={qrCodeMap[currentQrChannel.id].url}
readOnly
style={{
flex: 1,
fontSize: 12,
}}
/>
<Button
size="small"
onClick={() => {
const link = qrCodeMap[currentQrChannel.id].url;
navigator.clipboard.writeText(link);
Toast.show({
content: "链接已复制到剪贴板",
position: "top",
});
}}
style={{
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<CopyOutlined />
</Button>
</div>
</div>
)}
</>
) : (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "40px 20px",
color: "#999",
}}>
<QrcodeOutlined style={{ fontSize: 48 }} />
<div style={{ fontSize: 14 }}></div>
<Button
size="small"
color="primary"
onClick={() => currentQrChannel && generateChannelQRCode(currentQrChannel.id, currentQrChannel.code)}
>
</Button>
</div>
)}
</>
)}

View File

@@ -133,7 +133,7 @@ export const markAsPaid = async (
return request(`/v1/distribution/withdrawals/${id}/mark-paid`, data, "POST");
};
// 生成二维码
// 生成渠道扫码创建二维码
export const generateQRCode = async (
type: "h5" | "miniprogram",
): Promise<{
@@ -143,3 +143,18 @@ export const generateQRCode = async (
}> => {
return request("/v1/distribution/channel/generate-qrcode", { type }, "POST");
};
// 生成渠道登录二维码
export const generateLoginQRCode = async (
type: "h5" | "miniprogram" = "h5",
): Promise<{
type: "h5" | "miniprogram";
qrCode: string; // base64 或图片URL
url: string; // H5 或小程序落地页URL
}> => {
return request(
"/v1/distribution/channel/generate-login-qrcode",
{ type },
"POST",
);
};

View File

@@ -70,23 +70,11 @@ const AddChannelModal: React.FC<AddChannelModalProps> = ({
}
}, [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) {
// 重置状态后重新生成
// 弹窗打开时,如果是扫码创建模式,无论之前是否有数据都重新生成
if (visible && !isEdit && createMethod === "scan" && !qrCodeLoading && !generatingRef.current) {
// 重置状态后重新生成(无论之前是否有数据)
setQrCodeData(null);
setScanning(false);
// 使用 setTimeout 确保状态更新完成
@@ -96,7 +84,22 @@ const AddChannelModal: React.FC<AddChannelModalProps> = ({
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qrCodeType]);
}, [visible]);
// 当二维码类型变化时,重新生成二维码(无论之前是否成功或失败都要重新生成)
useEffect(() => {
if (visible && !isEdit && createMethod === "scan" && !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, visible, createMethod]);
// 验证手机号格式
const validatePhone = (phone: string): boolean => {
@@ -213,20 +216,18 @@ const AddChannelModal: React.FC<AddChannelModalProps> = ({
// 当切换到扫码创建时,自动生成二维码
useEffect(() => {
if (visible && createMethod === "scan" && !isEdit && !qrCodeData && !qrCodeLoading) {
handleGenerateQRCode();
if (visible && createMethod === "scan" && !isEdit && !qrCodeLoading && !generatingRef.current) {
// 重置状态后重新生成(无论之前是否有数据)
setQrCodeData(null);
setScanning(false);
const timer = setTimeout(() => {
handleGenerateQRCode();
}, 100);
return () => clearTimeout(timer);
}
// 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 (
<Modal
open={visible}

View File

@@ -213,7 +213,8 @@ Route::group('v1/', function () {
Route::put(':id', 'app\cunkebao\controller\distribution\ChannelController@update'); // 编辑渠道
Route::delete(':id', 'app\cunkebao\controller\distribution\ChannelController@delete'); // 删除渠道
Route::post(':id/toggle-status', 'app\cunkebao\controller\distribution\ChannelController@toggleStatus'); // 禁用/启用渠道
Route::post('generate-qrcode', 'app\cunkebao\controller\distribution\ChannelController@generateQrCode'); // 生成渠道二维码
Route::post('generate-qrcode', 'app\cunkebao\controller\distribution\ChannelController@generateQrCode'); // 生成渠道注册二维码
Route::post('generate-login-qrcode', 'app\cunkebao\controller\distribution\ChannelController@generateLoginQrCode'); // 生成渠道登录二维码
});
// 提现申请管理
Route::group('withdrawals', function () {

View File

@@ -864,7 +864,7 @@ class ChannelController extends BaseController
if ($type === 'h5') {
// 生成H5二维码
// 获取H5页面URL需要根据实际项目配置
$h5BaseUrl = Env::get('h5.base_url', $this->request->domain());
$h5BaseUrl = Env::get('rpc.H5_FORM_URL', 'https://h5.ckb.quwanzhi.com/#');
// 确保URL格式正确去除末尾斜杠
$h5BaseUrl = rtrim($h5BaseUrl, '/');
$h5Url = $h5BaseUrl . '/pages/channel/add?token=' . urlencode($token);
@@ -970,6 +970,144 @@ class ChannelController extends BaseController
}
}
/**
* 生成渠道登录二维码H5或小程序码
* 通用登录二维码,不绑定特定渠道,用户扫码后输入手机号和密码登录
* @return \think\response\Json
*/
public function generateLoginQrCode()
{
try {
// 获取参数
$type = $this->request->param('type', 'h5'); // h5 或 miniprogram
$companyId = $this->getUserInfo('companyId');
// 参数验证
if (!in_array($type, ['h5', 'miniprogram'])) {
return json([
'code' => 400,
'success' => false,
'msg' => '类型参数错误,必须为 h5 或 miniprogram',
'data' => null
]);
}
// 生成登录token只包含公司ID有效期1小时
// 用户扫码后需要自己输入手机号和密码
$tokenData = [
'companyId' => $companyId,
'expireTime' => time() + 3600 // 1小时后过期
];
$token = base64_encode(json_encode($tokenData));
if ($type === 'h5') {
// 生成H5登录二维码
$h5BaseUrl = Env::get('rpc.H5_FORM_URL', 'https://h5.ckb.quwanzhi.com/#');
$h5BaseUrl = rtrim($h5BaseUrl, '/');
// H5登录页面路径需要根据实际项目调整
$h5Url = $h5BaseUrl . '/pages/channel/login?token=' . urlencode($token);
// 生成二维码
$qrCode = new QrCode($h5Url);
$qrCode->setSize(300);
$qrCode->setMargin(10);
$qrCode->setWriterByName('png');
$qrCode->setEncoding('UTF-8');
$qrCode->setErrorCorrectionLevel(ErrorCorrectionLevel::HIGH);
// 转换为base64
$qrCodeBase64 = 'data:image/png;base64,' . base64_encode($qrCode->writeString());
return json([
'code' => 200,
'success' => true,
'msg' => '生成H5登录二维码成功',
'data' => [
'type' => 'h5',
'qrCode' => $qrCodeBase64,
'url' => $h5Url
]
]);
} else {
// 生成小程序登录码
try {
// 从环境变量获取小程序配置
$miniProgramConfig = [
'app_id' => Env::get('weChat.appidMiniApp', 'wx789850448e26c91d'),
'secret' => Env::get('weChat.secretMiniApp', 'd18f75b3a3623cb40da05648b08365a1'),
'response_type' => 'array'
];
$app = Factory::miniProgram($miniProgramConfig);
// scene参数长度限制为32位使用token的MD5值
$scene = substr(md5($token), 0, 32);
// 调用接口生成小程序码
// 小程序登录页面路径,需要根据实际项目调整
$response = $app->app_code->getUnlimit($scene, [
'page' => 'pages/channel/login', // 请确保小程序里存在该页面
'width' => 430,
]);
// 成功时返回的是 StreamResponse
if ($response instanceof \EasyWeChat\Kernel\Http\StreamResponse) {
$img = $response->getBody()->getContents();
$imgBase64 = 'data:image/png;base64,' . base64_encode($img);
return json([
'code' => 200,
'success' => true,
'msg' => '生成小程序登录码成功',
'data' => [
'type' => 'miniprogram',
'qrCode' => $imgBase64,
'scene' => $scene,
'token' => $token
]
]);
}
// 如果不是流响应,而是数组(错误信息),则解析错误返回
if (is_array($response) && isset($response['errcode']) && $response['errcode'] != 0) {
$errMsg = isset($response['errmsg']) ? $response['errmsg'] : '微信接口返回错误';
return json([
'code' => 500,
'success' => false,
'msg' => '生成小程序登录码失败:' . $errMsg,
'data' => $response
]);
}
// 其他未知格式
return json([
'code' => 500,
'success' => false,
'msg' => '生成小程序登录码失败:响应格式错误',
'data' => $response
]);
} catch (\Exception $e) {
return json([
'code' => 500,
'success' => false,
'msg' => '生成小程序登录码失败:' . $e->getMessage(),
'data' => null
]);
}
}
} catch (Exception $e) {
return json([
'code' => $e->getCode() ?: 500,
'success' => false,
'msg' => '生成登录二维码失败:' . $e->getMessage(),
'data' => null
]);
}
}
/**
* 扫码提交渠道信息H5和小程序共用
* GET请求返回预填信息