|
@@ -1,459 +1,375 @@
|
|
|
"use client"
|
|
"use client"
|
|
|
|
|
|
|
|
-import React, { useState, useRef, useEffect } from "react"
|
|
|
|
|
|
|
+import React, {useEffect, useRef, useState} from "react"
|
|
|
import Image from "next/image"
|
|
import Image from "next/image"
|
|
|
-import { Button, Form, Input, message, Modal, Pagination, ConfigProvider } from "antd"
|
|
|
|
|
|
|
+import {Button, ConfigProvider, Form, Input, message, Modal, Pagination} from "antd"
|
|
|
import Link from "next/link";
|
|
import Link from "next/link";
|
|
|
-import { clientPost } from "@/utils/clientRequest";
|
|
|
|
|
-import type { ApiResponse } from '@/utils/clientRequest'
|
|
|
|
|
|
|
+import type {ApiResponse} from '@/utils/clientRequest'
|
|
|
|
|
+import {clientPost} from "@/utils/clientRequest";
|
|
|
|
|
+import {defaultVerifyCodeConfig, drawVerifyCode as drawVerifyCodeUtil} from "@/utils/verifyCodeUtils";
|
|
|
|
|
|
|
|
// 留言表单数据接口(新增 verifyCode 字段)
|
|
// 留言表单数据接口(新增 verifyCode 字段)
|
|
|
interface QuoteFormValues {
|
|
interface QuoteFormValues {
|
|
|
- name: string;
|
|
|
|
|
- phone: string;
|
|
|
|
|
- message: string;
|
|
|
|
|
- productId: string;
|
|
|
|
|
- productName: string;
|
|
|
|
|
- verifyCode: string; // 新增:验证码字段
|
|
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ phone: string;
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ productId: string;
|
|
|
|
|
+ productName: string;
|
|
|
|
|
+ verifyCode: string; // 新增:验证码字段
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface SaveQuotePayload {
|
|
interface SaveQuotePayload {
|
|
|
- content: string;
|
|
|
|
|
- deviceName: string;
|
|
|
|
|
- name: string;
|
|
|
|
|
- phone: string;
|
|
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ deviceName: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ phone: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ProductItem 接口定义
|
|
// ProductItem 接口定义
|
|
|
interface ProductItem {
|
|
interface ProductItem {
|
|
|
- productId: string;
|
|
|
|
|
- productName: string;
|
|
|
|
|
- productUrl?: string;
|
|
|
|
|
- productIntroduction: string;
|
|
|
|
|
- productModel: string;
|
|
|
|
|
|
|
+ productId: string;
|
|
|
|
|
+ productName: string;
|
|
|
|
|
+ productUrl?: string;
|
|
|
|
|
+ productIntroduction: string;
|
|
|
|
|
+ productModel: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface ProductGridProps {
|
|
interface ProductGridProps {
|
|
|
- products: ProductItem[]
|
|
|
|
|
|
|
+ products: ProductItem[]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export default function ProductGrid({ products }: ProductGridProps) {
|
|
|
|
|
- // 修复:增加空值判断,避免环境变量未定义报错
|
|
|
|
|
- const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || ''
|
|
|
|
|
-
|
|
|
|
|
- // 分页控制
|
|
|
|
|
- const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
|
- const pageSize = 6
|
|
|
|
|
-
|
|
|
|
|
- // 弹窗控制
|
|
|
|
|
- const [isModalOpen, setIsModalOpen] = useState(false)
|
|
|
|
|
- const [currentProduct, setCurrentProduct] = useState<ProductItem | null>(null)
|
|
|
|
|
- const [form] = Form.useForm()
|
|
|
|
|
- const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
-
|
|
|
|
|
- // 新增:验证码相关状态与Ref(无第三方依赖)
|
|
|
|
|
- const [correctVerifyCode, setCorrectVerifyCode] = useState<string>('') // 存储正确的验证码
|
|
|
|
|
- const canvasRef = useRef<HTMLCanvasElement>(null) // canvas 元素Ref
|
|
|
|
|
- // 验证码配置(可自定义)
|
|
|
|
|
- const verifyCodeConfig = {
|
|
|
|
|
- length: 4, // 验证码长度
|
|
|
|
|
- width: 120, // 验证码图片宽度
|
|
|
|
|
- height: 40, // 验证码图片高度
|
|
|
|
|
- fontSize: 20, // 字符字体大小
|
|
|
|
|
- lineCount: 4, // 干扰线数量
|
|
|
|
|
- dotCount: 50, // 干扰点数量
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 创建 message 实例(AntD v5 必须)
|
|
|
|
|
- const [messageApi, contextHolder] = message.useMessage()
|
|
|
|
|
-
|
|
|
|
|
- // 当前页数据(增加边界保护)
|
|
|
|
|
- const startIndex = Math.max(0, (currentPage - 1) * pageSize)
|
|
|
|
|
- const currentProducts = products.slice(startIndex, startIndex + pageSize)
|
|
|
|
|
-
|
|
|
|
|
- // 核心:生成随机验证码字符串(数字+大小写字母)
|
|
|
|
|
- const generateVerifyCode = (length: number): string => {
|
|
|
|
|
- const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
|
|
|
- let code = ''
|
|
|
|
|
- for (let i = 0; i < length; i++) {
|
|
|
|
|
- const randomIndex = Math.floor(Math.random() * chars.length)
|
|
|
|
|
- code += chars[randomIndex]
|
|
|
|
|
- }
|
|
|
|
|
- return code
|
|
|
|
|
|
|
+export default function ProductGrid({products}: ProductGridProps) {
|
|
|
|
|
+ // 修复:增加空值判断,避免环境变量未定义报错
|
|
|
|
|
+ const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || ''
|
|
|
|
|
+
|
|
|
|
|
+ // 分页控制
|
|
|
|
|
+ const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
|
+ const pageSize = 6
|
|
|
|
|
+
|
|
|
|
|
+ // 弹窗控制
|
|
|
|
|
+ const [isModalOpen, setIsModalOpen] = useState(false)
|
|
|
|
|
+ const [currentProduct, setCurrentProduct] = useState<ProductItem | null>(null)
|
|
|
|
|
+ const [form] = Form.useForm()
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ // 新增:验证码相关状态与Ref(无第三方依赖)
|
|
|
|
|
+ const [correctVerifyCode, setCorrectVerifyCode] = useState<string>('') // 存储正确的验证码
|
|
|
|
|
+ const canvasRef = useRef<HTMLCanvasElement>(null) // canvas 元素Ref
|
|
|
|
|
+ // 验证码配置(可自定义)
|
|
|
|
|
+ const verifyCodeConfig = defaultVerifyCodeConfig
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 message 实例(AntD v5 必须)
|
|
|
|
|
+ const [messageApi, contextHolder] = message.useMessage()
|
|
|
|
|
+
|
|
|
|
|
+ // 当前页数据(增加边界保护)
|
|
|
|
|
+ const startIndex = Math.max(0, (currentPage - 1) * pageSize)
|
|
|
|
|
+ const currentProducts = products.slice(startIndex, startIndex + pageSize)
|
|
|
|
|
+
|
|
|
|
|
+ // 核心:绘制验证码图片(利用 canvas)
|
|
|
|
|
+ const drawVerifyCode = () => {
|
|
|
|
|
+ const canvas = canvasRef.current
|
|
|
|
|
+ if (!canvas) return
|
|
|
|
|
+
|
|
|
|
|
+ const code = drawVerifyCodeUtil(canvas, verifyCodeConfig)
|
|
|
|
|
+ if (code) {
|
|
|
|
|
+ setCorrectVerifyCode(code)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 核心:绘制验证码图片(利用 canvas)
|
|
|
|
|
- const drawVerifyCode = () => {
|
|
|
|
|
- const canvas = canvasRef.current
|
|
|
|
|
- if (!canvas) return
|
|
|
|
|
-
|
|
|
|
|
- const ctx = canvas.getContext('2d')
|
|
|
|
|
- if (!ctx) return
|
|
|
|
|
-
|
|
|
|
|
- // 1. 重置 canvas(清空原有内容)
|
|
|
|
|
- ctx.clearRect(0, 0, verifyCodeConfig.width, verifyCodeConfig.height)
|
|
|
|
|
-
|
|
|
|
|
- // 2. 绘制背景(浅灰色,带圆角视觉效果)
|
|
|
|
|
- ctx.fillStyle = '#f5f5f5'
|
|
|
|
|
- ctx.fillRect(0, 0, verifyCodeConfig.width, verifyCodeConfig.height)
|
|
|
|
|
- ctx.strokeStyle = '#d9d9d9'
|
|
|
|
|
- ctx.strokeRect(0, 0, verifyCodeConfig.width, verifyCodeConfig.height)
|
|
|
|
|
-
|
|
|
|
|
- // 3. 生成并存储正确验证码
|
|
|
|
|
- const code = generateVerifyCode(verifyCodeConfig.length)
|
|
|
|
|
- setCorrectVerifyCode(code)
|
|
|
|
|
-
|
|
|
|
|
- // 4. 绘制验证码字符(随机位置、旋转角度,增加识别难度)
|
|
|
|
|
- ctx.font = `${verifyCodeConfig.fontSize}px Arial`
|
|
|
|
|
- ctx.textBaseline = 'middle'
|
|
|
|
|
-
|
|
|
|
|
- const charWidth = verifyCodeConfig.width / code.length
|
|
|
|
|
- for (let i = 0; i < code.length; i++) {
|
|
|
|
|
- const char = code[i]
|
|
|
|
|
- // 随机字符颜色(深色调,避免与背景混淆)
|
|
|
|
|
- ctx.fillStyle = `rgb(${Math.floor(Math.random() * 80)}, ${Math.floor(Math.random() * 80)}, ${Math.floor(Math.random() * 80)})`
|
|
|
|
|
- // 随机旋转角度(-30° 到 30°)
|
|
|
|
|
- const rotateAngle = (Math.random() * 60 - 30) * Math.PI / 180
|
|
|
|
|
- // 保存当前画布状态
|
|
|
|
|
- ctx.save()
|
|
|
|
|
- // 移动画布原点到字符绘制位置
|
|
|
|
|
- ctx.translate(
|
|
|
|
|
- i * charWidth + charWidth / 2,
|
|
|
|
|
- verifyCodeConfig.height / 2
|
|
|
|
|
- )
|
|
|
|
|
- // 旋转画布
|
|
|
|
|
- ctx.rotate(rotateAngle)
|
|
|
|
|
- // 绘制字符
|
|
|
|
|
- ctx.fillText(
|
|
|
|
|
- char,
|
|
|
|
|
- -verifyCodeConfig.fontSize / 2,
|
|
|
|
|
- 0
|
|
|
|
|
- )
|
|
|
|
|
- // 恢复画布状态
|
|
|
|
|
- ctx.restore()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 5. 绘制干扰线(随机位置、颜色、粗细)
|
|
|
|
|
- for (let i = 0; i < verifyCodeConfig.lineCount; i++) {
|
|
|
|
|
- ctx.strokeStyle = `rgb(${Math.floor(Math.random() * 150)}, ${Math.floor(Math.random() * 150)}, ${Math.floor(Math.random() * 150)})`
|
|
|
|
|
- ctx.lineWidth = Math.random() * 2 + 1
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.moveTo(
|
|
|
|
|
- Math.random() * verifyCodeConfig.width,
|
|
|
|
|
- Math.random() * verifyCodeConfig.height
|
|
|
|
|
- )
|
|
|
|
|
- ctx.lineTo(
|
|
|
|
|
- Math.random() * verifyCodeConfig.width,
|
|
|
|
|
- Math.random() * verifyCodeConfig.height
|
|
|
|
|
- )
|
|
|
|
|
- ctx.stroke()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 6. 绘制干扰点(随机位置、颜色、大小)
|
|
|
|
|
- for (let i = 0; i < verifyCodeConfig.dotCount; i++) {
|
|
|
|
|
- ctx.fillStyle = `rgb(${Math.floor(Math.random() * 200)}, ${Math.floor(Math.random() * 200)}, ${Math.floor(Math.random() * 200)})`
|
|
|
|
|
- ctx.beginPath()
|
|
|
|
|
- ctx.arc(
|
|
|
|
|
- Math.random() * verifyCodeConfig.width,
|
|
|
|
|
- Math.random() * verifyCodeConfig.height,
|
|
|
|
|
- Math.random() * 1.5,
|
|
|
|
|
- 0,
|
|
|
|
|
- 2 * Math.PI
|
|
|
|
|
- )
|
|
|
|
|
- ctx.fill()
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 新增:刷新验证码(供点击、弹窗打开时调用)
|
|
|
|
|
+ const refreshVerifyCode = () => {
|
|
|
|
|
+ if (submitting) return // 提交中不允许刷新
|
|
|
|
|
+ drawVerifyCode()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 打开弹窗(新增:刷新验证码)
|
|
|
|
|
+ const showModal = (product: ProductItem) => {
|
|
|
|
|
+ setCurrentProduct(product)
|
|
|
|
|
+ setIsModalOpen(true)
|
|
|
|
|
+ // 立即设置表单值
|
|
|
|
|
+ form.setFieldsValue({
|
|
|
|
|
+ productId: product.productId,
|
|
|
|
|
+ productName: product.productName,
|
|
|
|
|
+ verifyCode: '' // 初始化验证码输入框为空
|
|
|
|
|
+ })
|
|
|
|
|
+ // 刷新验证码(避免重复使用同一个验证码)
|
|
|
|
|
+ setTimeout(refreshVerifyCode, 50) // 延迟少量时间,确保canvas已渲染
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭弹窗
|
|
|
|
|
+ const handleCancel = () => {
|
|
|
|
|
+ setIsModalOpen(false)
|
|
|
|
|
+ setCurrentProduct(null)
|
|
|
|
|
+ form.resetFields()
|
|
|
|
|
+ setSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 提交报价表单(新增:验证码校验)
|
|
|
|
|
+ const handleSubmit = async (values: QuoteFormValues) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 新增:第一步先校验验证码(不区分大小写,提升用户体验)
|
|
|
|
|
+ if (values.verifyCode.toLowerCase() !== correctVerifyCode.toLowerCase()) {
|
|
|
|
|
+ messageApi.error("验证码输入错误,请重新输入")
|
|
|
|
|
+ // 验证码错误后刷新验证码,防止用户反复尝试
|
|
|
|
|
+ refreshVerifyCode()
|
|
|
|
|
+ // 清空验证码输入框
|
|
|
|
|
+ form.setFieldsValue({verifyCode: ''})
|
|
|
|
|
+ return // 终止提交流程
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSubmitting(true)
|
|
|
|
|
+
|
|
|
|
|
+ // 直接使用 clientPost 发送 POST 请求
|
|
|
|
|
+ const response: ApiResponse = await clientPost('/webSite/save', {
|
|
|
|
|
+ name: values.name,
|
|
|
|
|
+ phone: values.phone,
|
|
|
|
|
+ content: values.message,
|
|
|
|
|
+ deviceName: values.productName
|
|
|
|
|
+ }, {
|
|
|
|
|
+ timeout: 20000, // 自定义超时
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'X-Custom-Header': 'custom-value'
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 新增:刷新验证码(供点击、弹窗打开时调用)
|
|
|
|
|
- const refreshVerifyCode = () => {
|
|
|
|
|
- if (submitting) return // 提交中不允许刷新
|
|
|
|
|
- drawVerifyCode()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 打开弹窗(新增:刷新验证码)
|
|
|
|
|
- const showModal = (product: ProductItem) => {
|
|
|
|
|
- setCurrentProduct(product)
|
|
|
|
|
- setIsModalOpen(true)
|
|
|
|
|
- // 立即设置表单值
|
|
|
|
|
- form.setFieldsValue({
|
|
|
|
|
- productId: product.productId,
|
|
|
|
|
- productName: product.productName,
|
|
|
|
|
- verifyCode: '' // 初始化验证码输入框为空
|
|
|
|
|
- })
|
|
|
|
|
- // 刷新验证码(避免重复使用同一个验证码)
|
|
|
|
|
- setTimeout(refreshVerifyCode, 50) // 延迟少量时间,确保canvas已渲染
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 关闭弹窗
|
|
|
|
|
- const handleCancel = () => {
|
|
|
|
|
- setIsModalOpen(false)
|
|
|
|
|
- setCurrentProduct(null)
|
|
|
|
|
- form.resetFields()
|
|
|
|
|
- setSubmitting(false)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 提交报价表单(新增:验证码校验)
|
|
|
|
|
- const handleSubmit = async (values: QuoteFormValues) => {
|
|
|
|
|
- try {
|
|
|
|
|
- // 新增:第一步先校验验证码(不区分大小写,提升用户体验)
|
|
|
|
|
- if (values.verifyCode.toLowerCase() !== correctVerifyCode.toLowerCase()) {
|
|
|
|
|
- messageApi.error("验证码输入错误,请重新输入")
|
|
|
|
|
- // 验证码错误后刷新验证码,防止用户反复尝试
|
|
|
|
|
- refreshVerifyCode()
|
|
|
|
|
- // 清空验证码输入框
|
|
|
|
|
- form.setFieldsValue({ verifyCode: '' })
|
|
|
|
|
- return // 终止提交流程
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- setSubmitting(true)
|
|
|
|
|
-
|
|
|
|
|
- // 直接使用 clientPost 发送 POST 请求
|
|
|
|
|
- const response: ApiResponse = await clientPost('/webSite/save', {
|
|
|
|
|
- name: values.name,
|
|
|
|
|
- phone: values.phone,
|
|
|
|
|
- content: values.message,
|
|
|
|
|
- deviceName: values.productName
|
|
|
|
|
- }, {
|
|
|
|
|
- timeout: 20000, // 自定义超时
|
|
|
|
|
- headers: {
|
|
|
|
|
- 'X-Custom-Header': 'custom-value'
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // 显示成功提示并关闭弹窗
|
|
|
|
|
- messageApi.success("报价请求提交成功!我们会尽快联系你")
|
|
|
|
|
- handleCancel()
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- // 更友好的错误提示
|
|
|
|
|
- let errorMsg = "提交失败,请稍后重试"
|
|
|
|
|
- if (error instanceof Error) {
|
|
|
|
|
- if (error.message.includes('Network')) {
|
|
|
|
|
- errorMsg = "网络连接失败,请检查您的网络"
|
|
|
|
|
- } else if (error.message.includes('timeout')) {
|
|
|
|
|
- errorMsg = "请求超时,请稍后重试"
|
|
|
|
|
- } else {
|
|
|
|
|
- errorMsg = error.message
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- messageApi.error(errorMsg)
|
|
|
|
|
- console.error("提交报价失败:", error)
|
|
|
|
|
- } finally {
|
|
|
|
|
- // 无论成功失败都重置提交状态
|
|
|
|
|
- setSubmitting(false)
|
|
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 显示成功提示并关闭弹窗
|
|
|
|
|
+ messageApi.success("报价请求提交成功!我们会尽快联系你")
|
|
|
|
|
+ handleCancel()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // 更友好的错误提示
|
|
|
|
|
+ let errorMsg = "提交失败,请稍后重试"
|
|
|
|
|
+ if (error instanceof Error) {
|
|
|
|
|
+ if (error.message.includes('Network')) {
|
|
|
|
|
+ errorMsg = "网络连接失败,请检查您的网络"
|
|
|
|
|
+ } else if (error.message.includes('timeout')) {
|
|
|
|
|
+ errorMsg = "请求超时,请稍后重试"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ errorMsg = error.message
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
+ messageApi.error(errorMsg)
|
|
|
|
|
+ console.error("提交报价失败:", error)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ // 无论成功失败都重置提交状态
|
|
|
|
|
+ setSubmitting(false)
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 处理图片路径(增加容错)
|
|
|
|
|
- const getProductImageUrl = (product: ProductItem) => {
|
|
|
|
|
- if (product.productUrl) {
|
|
|
|
|
- return `${BASE_URL}${product.productUrl}`
|
|
|
|
|
- }
|
|
|
|
|
- return "/assets/productions/2.png"
|
|
|
|
|
|
|
+ // 处理图片路径(增加容错)
|
|
|
|
|
+ const getProductImageUrl = (product: ProductItem) => {
|
|
|
|
|
+ if (product.productUrl) {
|
|
|
|
|
+ return `${BASE_URL}${product.productUrl}`
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 初始化:组件挂载后绘制一次验证码(防止弹窗打开时验证码为空)
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- refreshVerifyCode()
|
|
|
|
|
- }, [])
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <ConfigProvider>
|
|
|
|
|
- {contextHolder} {/* 必须挂载 message 上下文 */}
|
|
|
|
|
-
|
|
|
|
|
- <div className="flex flex-col gap-6">
|
|
|
|
|
- {/* 产品网格(增加空数据处理) */}
|
|
|
|
|
- {currentProducts.length === 0 ? (
|
|
|
|
|
- <div className="text-center py-10">暂无产品数据</div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
|
|
|
- {currentProducts.map((product) => (
|
|
|
|
|
- <div
|
|
|
|
|
- key={product.productId}
|
|
|
|
|
- className="bg-white rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-[1.02]"
|
|
|
|
|
- >
|
|
|
|
|
- <div className="p-2 aspect-square relative">
|
|
|
|
|
- <Image
|
|
|
|
|
- className="h-full w-full object-contain"
|
|
|
|
|
- src={getProductImageUrl(product)}
|
|
|
|
|
- alt={product.productName || '产品图片'}
|
|
|
|
|
- fill // Next.js 13+ 推荐用法
|
|
|
|
|
- sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
|
|
|
- priority={false}
|
|
|
|
|
- // 图片加载失败兜底
|
|
|
|
|
- onError={(e) => {
|
|
|
|
|
- e.currentTarget.src = "/assets/productions/2.png"
|
|
|
|
|
- }}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="p-4 sm:py-6 text-center">
|
|
|
|
|
- <h3 className="font-bold text-lg mb-1">{product.productName}</h3>
|
|
|
|
|
- <p className="text-sm text-gray-500 mb-2 line-clamp-1">
|
|
|
|
|
- {product.productIntroduction || '暂无介绍'}
|
|
|
|
|
- </p>
|
|
|
|
|
- <p className="text-sm mb-3">{product.productModel || '暂无型号'}</p>
|
|
|
|
|
-
|
|
|
|
|
- <div className="w-full flex justify-center gap-2">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => showModal(product)}
|
|
|
|
|
- className="bg-red-600 text-white px-4 py-1.5 rounded hover:bg-red-700 transition-colors duration-200"
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- >
|
|
|
|
|
- 获取报价
|
|
|
|
|
- </button>
|
|
|
|
|
- <Link
|
|
|
|
|
- href={`/products/${product.productId}`}
|
|
|
|
|
- className="bg-blue-600 text-white px-4 py-1.5 rounded hover:bg-blue-700 transition-colors duration-200"
|
|
|
|
|
- >
|
|
|
|
|
- 了解详情
|
|
|
|
|
- </Link>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* 分页器(只有数据大于分页大小时显示) */}
|
|
|
|
|
- {products.length > pageSize && (
|
|
|
|
|
- <div className="flex justify-center">
|
|
|
|
|
- <Pagination
|
|
|
|
|
- current={currentPage}
|
|
|
|
|
- pageSize={pageSize}
|
|
|
|
|
- total={products.length}
|
|
|
|
|
- onChange={(page) => setCurrentPage(page)}
|
|
|
|
|
- showSizeChanger={false}
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* 报价弹窗 */}
|
|
|
|
|
- <Modal
|
|
|
|
|
- title={`获取 "${currentProduct?.productName || '产品'}" 报价`}
|
|
|
|
|
- open={isModalOpen}
|
|
|
|
|
- onCancel={handleCancel}
|
|
|
|
|
- footer={null}
|
|
|
|
|
- maskClosable={false}
|
|
|
|
|
- width={600}
|
|
|
|
|
|
|
+ return "/assets/productions/2.png"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化:组件挂载后绘制一次验证码(防止弹窗打开时验证码为空)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ refreshVerifyCode()
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ConfigProvider>
|
|
|
|
|
+ {contextHolder} {/* 必须挂载 message 上下文 */}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
|
|
+ {/* 产品网格(增加空数据处理) */}
|
|
|
|
|
+ {currentProducts.length === 0 ? (
|
|
|
|
|
+ <div className="text-center py-10">暂无产品数据</div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
|
|
|
+ {currentProducts.map((product) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={product.productId}
|
|
|
|
|
+ className="bg-white rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-[1.02]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Link
|
|
|
|
|
+ href={`/products/${product.productId}`}
|
|
|
|
|
+ className="block p-2 aspect-square relative cursor-pointer"
|
|
|
>
|
|
>
|
|
|
- <Form
|
|
|
|
|
- form={form}
|
|
|
|
|
- layout="vertical"
|
|
|
|
|
- onFinish={handleSubmit}
|
|
|
|
|
- initialValues={{
|
|
|
|
|
- name: '',
|
|
|
|
|
- phone: '',
|
|
|
|
|
- message: '',
|
|
|
|
|
- verifyCode: '' // 新增:初始化验证码输入框
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ <Image
|
|
|
|
|
+ className="h-full w-full object-contain"
|
|
|
|
|
+ src={getProductImageUrl(product)}
|
|
|
|
|
+ alt={product.productName || '产品图片'}
|
|
|
|
|
+ fill // Next.js 13+ 推荐用法
|
|
|
|
|
+ sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
|
|
|
+ priority={false}
|
|
|
|
|
+ // 图片加载失败兜底
|
|
|
|
|
+ onError={(e) => {
|
|
|
|
|
+ e.currentTarget.src = "/assets/productions/2.png"
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ <div className="p-4 sm:py-6 text-center">
|
|
|
|
|
+ <h3 className="font-bold text-lg mb-1">{product.productName}</h3>
|
|
|
|
|
+ <p className="text-sm text-gray-500 mb-2 line-clamp-1">
|
|
|
|
|
+ {product.productIntroduction || '暂无介绍'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-sm mb-3">{product.productModel || '暂无型号'}</p>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="w-full flex justify-center gap-2">
|
|
|
|
|
+ <div
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ if (!submitting) showModal(product)
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center justify-center bg-red-600 text-white px-4 py-1.5 rounded hover:bg-red-700 transition-colors duration-200 text-sm leading-6 ${submitting ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
|
>
|
|
>
|
|
|
- {/* 隐藏字段 */}
|
|
|
|
|
- <Form.Item name="productId" hidden>
|
|
|
|
|
- <Input />
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
- <Form.Item name="productName" hidden>
|
|
|
|
|
- <Input />
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
-
|
|
|
|
|
- {/* 联系人姓名 */}
|
|
|
|
|
- <Form.Item
|
|
|
|
|
- name="name"
|
|
|
|
|
- label="联系人姓名"
|
|
|
|
|
- rules={[{
|
|
|
|
|
- required: true,
|
|
|
|
|
- message: "请输入您的姓名",
|
|
|
|
|
- whitespace: true // 禁止纯空格
|
|
|
|
|
- }]}
|
|
|
|
|
- >
|
|
|
|
|
- <Input placeholder="请输入您的姓名" disabled={submitting} />
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
-
|
|
|
|
|
- {/* 联系电话(优化正则) */}
|
|
|
|
|
- <Form.Item
|
|
|
|
|
- name="phone"
|
|
|
|
|
- label="联系电话"
|
|
|
|
|
- rules={[
|
|
|
|
|
- { required: true, message: "请输入您的联系电话" },
|
|
|
|
|
- {
|
|
|
|
|
- pattern: /^1[3-9]\d{9}$/,
|
|
|
|
|
- message: "请输入有效的11位手机号码"
|
|
|
|
|
- }
|
|
|
|
|
- ]}
|
|
|
|
|
- >
|
|
|
|
|
- <Input
|
|
|
|
|
- placeholder="请输入您的手机号码"
|
|
|
|
|
- maxLength={11}
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- />
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
-
|
|
|
|
|
- {/* 留言/备注 */}
|
|
|
|
|
- <Form.Item
|
|
|
|
|
- name="message"
|
|
|
|
|
- label="留言/备注"
|
|
|
|
|
- >
|
|
|
|
|
- <Input.TextArea
|
|
|
|
|
- rows={4}
|
|
|
|
|
- placeholder="请输入您的其他需求或备注信息(选填)"
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- />
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
-
|
|
|
|
|
- {/* 新增:验证码输入项(无第三方依赖,基于 canvas) */}
|
|
|
|
|
- <Form.Item
|
|
|
|
|
- name="verifyCode"
|
|
|
|
|
- label="验证码"
|
|
|
|
|
- rules={[
|
|
|
|
|
- { required: true, message: "请输入图片中的验证码" },
|
|
|
|
|
- { whitespace: true, message: "验证码不能为空" }
|
|
|
|
|
- ]}
|
|
|
|
|
- >
|
|
|
|
|
- <div className="flex items-center gap-3">
|
|
|
|
|
- <Input
|
|
|
|
|
- placeholder="请输入验证码(不区分大小写)"
|
|
|
|
|
- maxLength={verifyCodeConfig.length}
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- style={{ flex: 1 }}
|
|
|
|
|
- />
|
|
|
|
|
- {/* 原生 canvas 元素:绘制验证码图片 */}
|
|
|
|
|
- <canvas
|
|
|
|
|
- ref={canvasRef}
|
|
|
|
|
- width={verifyCodeConfig.width}
|
|
|
|
|
- height={verifyCodeConfig.height}
|
|
|
|
|
- onClick={refreshVerifyCode}
|
|
|
|
|
- style={{
|
|
|
|
|
- cursor: submitting ? 'not-allowed' : 'pointer',
|
|
|
|
|
- borderRadius: 4,
|
|
|
|
|
- userSelect: 'none'
|
|
|
|
|
- }}
|
|
|
|
|
- // disabled={submitting}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
-
|
|
|
|
|
- {/* 表单操作按钮 */}
|
|
|
|
|
- <Form.Item>
|
|
|
|
|
- <div className="flex justify-end gap-2">
|
|
|
|
|
- <Button
|
|
|
|
|
- onClick={handleCancel}
|
|
|
|
|
- type="default"
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- >
|
|
|
|
|
- 取消
|
|
|
|
|
- </Button>
|
|
|
|
|
- <Button
|
|
|
|
|
- type="primary"
|
|
|
|
|
- htmlType="submit"
|
|
|
|
|
- loading={submitting}
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- >
|
|
|
|
|
- 提交报价请求
|
|
|
|
|
- </Button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </Form.Item>
|
|
|
|
|
- </Form>
|
|
|
|
|
- </Modal>
|
|
|
|
|
- </div>
|
|
|
|
|
- </ConfigProvider>
|
|
|
|
|
- )
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ 获取报价
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Link
|
|
|
|
|
+ href={`/products/${product.productId}`}
|
|
|
|
|
+ className="flex items-center justify-center bg-blue-600 text-white px-4 py-1.5 rounded hover:bg-blue-700 transition-colors duration-200 text-sm leading-6"
|
|
|
|
|
+ >
|
|
|
|
|
+ 了解详情
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 分页器(只有数据大于分页大小时显示) */}
|
|
|
|
|
+ {products.length > pageSize && (
|
|
|
|
|
+ <div className="flex justify-center">
|
|
|
|
|
+ <Pagination
|
|
|
|
|
+ current={currentPage}
|
|
|
|
|
+ pageSize={pageSize}
|
|
|
|
|
+ total={products.length}
|
|
|
|
|
+ onChange={(page) => setCurrentPage(page)}
|
|
|
|
|
+ showSizeChanger={false}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 报价弹窗 */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title={`获取 "${currentProduct?.productName || '产品'}" 报价`}
|
|
|
|
|
+ open={isModalOpen}
|
|
|
|
|
+ onCancel={handleCancel}
|
|
|
|
|
+ footer={null}
|
|
|
|
|
+ maskClosable={false}
|
|
|
|
|
+ width={600}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form
|
|
|
|
|
+ form={form}
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={handleSubmit}
|
|
|
|
|
+ initialValues={{
|
|
|
|
|
+ name: '',
|
|
|
|
|
+ phone: '',
|
|
|
|
|
+ message: '',
|
|
|
|
|
+ verifyCode: '' // 新增:初始化验证码输入框
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* 隐藏字段 */}
|
|
|
|
|
+ <Form.Item name="productId" hidden>
|
|
|
|
|
+ <Input/>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="productName" hidden>
|
|
|
|
|
+ <Input/>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 联系人姓名 */}
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="name"
|
|
|
|
|
+ label="联系人姓名"
|
|
|
|
|
+ rules={[{
|
|
|
|
|
+ required: true,
|
|
|
|
|
+ message: "请输入您的姓名",
|
|
|
|
|
+ whitespace: true // 禁止纯空格
|
|
|
|
|
+ }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入您的姓名" disabled={submitting}/>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 联系电话(优化正则) */}
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="phone"
|
|
|
|
|
+ label="联系电话"
|
|
|
|
|
+ rules={[
|
|
|
|
|
+ {required: true, message: "请输入您的联系电话"},
|
|
|
|
|
+ {
|
|
|
|
|
+ pattern: /^1[3-9]\d{9}$/,
|
|
|
|
|
+ message: "请输入有效的11位手机号码"
|
|
|
|
|
+ }
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder="请输入您的手机号码"
|
|
|
|
|
+ maxLength={11}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 留言/备注 */}
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="message"
|
|
|
|
|
+ label="留言/备注"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input.TextArea
|
|
|
|
|
+ rows={4}
|
|
|
|
|
+ placeholder="请输入您的其他需求或备注信息(选填)"
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 新增:验证码输入项(无第三方依赖,基于 canvas) */}
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="verifyCode"
|
|
|
|
|
+ label="验证码"
|
|
|
|
|
+ rules={[
|
|
|
|
|
+ {required: true, message: "请输入图片中的验证码"},
|
|
|
|
|
+ {whitespace: true, message: "验证码不能为空"}
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder="请输入验证码(不区分大小写)"
|
|
|
|
|
+ maxLength={verifyCodeConfig.length}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ style={{flex: 1}}
|
|
|
|
|
+ />
|
|
|
|
|
+ {/* 原生 canvas 元素:绘制验证码图片 */}
|
|
|
|
|
+ <canvas
|
|
|
|
|
+ ref={canvasRef}
|
|
|
|
|
+ width={verifyCodeConfig.width}
|
|
|
|
|
+ height={verifyCodeConfig.height}
|
|
|
|
|
+ onClick={refreshVerifyCode}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ cursor: submitting ? 'not-allowed' : 'pointer',
|
|
|
|
|
+ borderRadius: 4,
|
|
|
|
|
+ userSelect: 'none'
|
|
|
|
|
+ }}
|
|
|
|
|
+ // disabled={submitting}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 表单操作按钮 */}
|
|
|
|
|
+ <Form.Item>
|
|
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={handleCancel}
|
|
|
|
|
+ type="default"
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ >
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ htmlType="submit"
|
|
|
|
|
+ loading={submitting}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ >
|
|
|
|
|
+ 提交报价请求
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </ConfigProvider>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|