Open signature in a landscape overlay and fix mobile touch
The inline signature pad on the confirm page was unusable on phones: the canvas had a fixed 480px internal width but shrank via max-width on small screens, so pointer coordinates landed in only the top-left fraction of the drawing buffer. Edge-of-screen strokes also collided with iOS Safari back-swipe. Tapping the new signature trigger now opens a full-screen overlay that rotates to landscape (via 100dvh/100dvw + CSS rotate on portrait phones, plus an opportunistic screen.orientation.lock) so customers get the widest possible signing area, away from the system edge. Also swap the hand-rolled drawing logic for signature_pad, with a ResizeObserver-driven resize that preserves strokes via toData/fromData and scales the canvas to devicePixelRatio. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -17,7 +17,8 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1"
|
"react-router-dom": "^7.13.1",
|
||||||
|
"signature_pad": "^5.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
|||||||
Generated
+8
@@ -29,6 +29,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.13.1
|
specifier: ^7.13.1
|
||||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
signature_pad:
|
||||||
|
specifier: ^5.1.3
|
||||||
|
version: 5.1.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
@@ -1146,6 +1149,9 @@ packages:
|
|||||||
set-cookie-parser@2.7.2:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
signature_pad@5.1.3:
|
||||||
|
resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2368,6 +2374,8 @@ snapshots:
|
|||||||
|
|
||||||
set-cookie-parser@2.7.2: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
signature_pad@5.1.3: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
string-convert@0.2.1: {}
|
string-convert@0.2.1: {}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.signature-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: #f8fafc;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-overlay-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phones in portrait: rotate the whole stage to use landscape space */
|
||||||
|
@media (orientation: portrait) and (max-width: 820px) {
|
||||||
|
.signature-overlay-stage {
|
||||||
|
inset: auto;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100dvh;
|
||||||
|
height: 100dvw;
|
||||||
|
transform: translate(-50%, -50%) rotate(90deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-overlay-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-overlay-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-overlay-pad {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px dashed #94a3b8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
/* keep room from screen edges so iOS back-swipe doesn't intercept */
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-pad-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-pad-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-overlay-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import SignaturePad, { type SignaturePadHandle } from './SignaturePad';
|
||||||
|
import './SignatureOverlay.css';
|
||||||
|
|
||||||
|
interface SignatureOverlayProps {
|
||||||
|
open: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (dataUrl: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps) {
|
||||||
|
const padRef = useRef<SignaturePadHandle>(null);
|
||||||
|
const [data, setData] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const orientation = (screen as Screen & {
|
||||||
|
orientation?: { lock?: (o: string) => Promise<void> };
|
||||||
|
}).orientation;
|
||||||
|
if (orientation?.lock) {
|
||||||
|
orientation.lock('landscape').catch(() => {
|
||||||
|
// ignore; iOS Safari etc. don't permit lock outside fullscreen
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
padRef.current?.clear();
|
||||||
|
setData('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (padRef.current?.isEmpty()) return;
|
||||||
|
const url = padRef.current?.getDataURL() || data;
|
||||||
|
if (!url) return;
|
||||||
|
onConfirm(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signature-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="signature-overlay-stage">
|
||||||
|
<div className="signature-overlay-bar">
|
||||||
|
<Button type="text" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<span className="signature-overlay-title">请在框内签名</span>
|
||||||
|
<Button type="text" onClick={handleClear}>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="signature-overlay-pad">
|
||||||
|
<SignaturePad ref={padRef} onChange={setData} />
|
||||||
|
</div>
|
||||||
|
<div className="signature-overlay-actions">
|
||||||
|
<Button size="large" type="primary" block onClick={handleConfirm}>
|
||||||
|
保存签名
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignatureOverlay;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
|
import SignaturePadLib, { type PointGroup } from 'signature_pad';
|
||||||
|
|
||||||
export interface SignaturePadHandle {
|
export interface SignaturePadHandle {
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
@@ -7,123 +8,86 @@ export interface SignaturePadHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SignaturePadProps {
|
interface SignaturePadProps {
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
onChange?: (dataUrl: string) => void;
|
onChange?: (dataUrl: string) => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
|
const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
|
||||||
{ width = 480, height = 180, onChange },
|
{ onChange, className },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const drawingRef = useRef(false);
|
const padRef = useRef<SignaturePadLib | null>(null);
|
||||||
const emptyRef = useRef(true);
|
const onChangeRef = useRef(onChange);
|
||||||
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
const getContext = () => canvasRef.current?.getContext('2d') ?? null;
|
|
||||||
|
|
||||||
const resetCanvas = () => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const ctx = getContext();
|
|
||||||
if (!canvas || !ctx) return;
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.lineWidth = 2.5;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.strokeStyle = '#111827';
|
|
||||||
emptyRef.current = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
const wrap = wrapRef.current;
|
||||||
// 高 DPI 适配
|
if (!canvas || !wrap) return;
|
||||||
const ratio = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = width * ratio;
|
|
||||||
canvas.height = height * ratio;
|
|
||||||
canvas.style.width = `${width}px`;
|
|
||||||
canvas.style.height = `${height}px`;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) ctx.scale(ratio, ratio);
|
|
||||||
resetCanvas();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [width, height]);
|
|
||||||
|
|
||||||
const pointFromEvent = (
|
const pad = new SignaturePadLib(canvas, {
|
||||||
e: React.PointerEvent<HTMLCanvasElement>,
|
penColor: '#111827',
|
||||||
): { x: number; y: number } | null => {
|
backgroundColor: '#ffffff',
|
||||||
const canvas = canvasRef.current;
|
minWidth: 0.8,
|
||||||
if (!canvas) return null;
|
maxWidth: 2.6,
|
||||||
const rect = canvas.getBoundingClientRect();
|
});
|
||||||
return {
|
padRef.current = pad;
|
||||||
x: e.clientX - rect.left,
|
|
||||||
y: e.clientY - rect.top,
|
const resize = () => {
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
const cssW = Math.max(1, Math.floor(rect.width));
|
||||||
|
const cssH = Math.max(1, Math.floor(rect.height));
|
||||||
|
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
const data: PointGroup[] = pad.toData();
|
||||||
|
canvas.width = cssW * ratio;
|
||||||
|
canvas.height = cssH * ratio;
|
||||||
|
canvas.style.width = `${cssW}px`;
|
||||||
|
canvas.style.height = `${cssH}px`;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx?.scale(ratio, ratio);
|
||||||
|
pad.clear();
|
||||||
|
if (data.length) pad.fromData(data);
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
resize();
|
||||||
e.preventDefault();
|
|
||||||
const ctx = getContext();
|
|
||||||
const pt = pointFromEvent(e);
|
|
||||||
if (!ctx || !pt) return;
|
|
||||||
drawingRef.current = true;
|
|
||||||
lastPointRef.current = pt;
|
|
||||||
canvasRef.current?.setPointerCapture(e.pointerId);
|
|
||||||
// 画一个点(防止只点一下没移动看不到痕迹)
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(pt.x, pt.y, 1.2, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = '#111827';
|
|
||||||
ctx.fill();
|
|
||||||
emptyRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const handleEnd = () => {
|
||||||
if (!drawingRef.current) return;
|
onChangeRef.current?.(pad.isEmpty() ? '' : pad.toDataURL('image/png'));
|
||||||
e.preventDefault();
|
};
|
||||||
const ctx = getContext();
|
pad.addEventListener('endStroke', handleEnd);
|
||||||
const pt = pointFromEvent(e);
|
|
||||||
const last = lastPointRef.current;
|
|
||||||
if (!ctx || !pt || !last) return;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(last.x, last.y);
|
|
||||||
ctx.lineTo(pt.x, pt.y);
|
|
||||||
ctx.stroke();
|
|
||||||
lastPointRef.current = pt;
|
|
||||||
emptyRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const ro = new ResizeObserver(resize);
|
||||||
if (!drawingRef.current) return;
|
ro.observe(wrap);
|
||||||
drawingRef.current = false;
|
window.addEventListener('orientationchange', resize);
|
||||||
lastPointRef.current = null;
|
|
||||||
canvasRef.current?.releasePointerCapture(e.pointerId);
|
return () => {
|
||||||
if (onChange && canvasRef.current) {
|
pad.removeEventListener('endStroke', handleEnd);
|
||||||
onChange(canvasRef.current.toDataURL('image/png'));
|
pad.off();
|
||||||
}
|
ro.disconnect();
|
||||||
};
|
window.removeEventListener('orientationchange', resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
clear: () => {
|
clear: () => {
|
||||||
resetCanvas();
|
padRef.current?.clear();
|
||||||
onChange?.('');
|
onChangeRef.current?.('');
|
||||||
|
},
|
||||||
|
isEmpty: () => padRef.current?.isEmpty() ?? true,
|
||||||
|
getDataURL: () => {
|
||||||
|
const pad = padRef.current;
|
||||||
|
if (!pad || pad.isEmpty()) return '';
|
||||||
|
return pad.toDataURL('image/png');
|
||||||
},
|
},
|
||||||
isEmpty: () => emptyRef.current,
|
|
||||||
getDataURL: () => (canvasRef.current ? canvasRef.current.toDataURL('image/png') : ''),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<div ref={wrapRef} className={`signature-pad-wrap ${className ?? ''}`}>
|
||||||
ref={canvasRef}
|
<canvas ref={canvasRef} className="signature-pad-canvas" style={{ touchAction: 'none' }} />
|
||||||
className="signature-pad-canvas"
|
</div>
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onPointerLeave={handlePointerUp}
|
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Card, Button, Spin, Result, message, Modal, Input } from 'antd';
|
import { Card, Button, Spin, Result, message, Modal, Input } from 'antd';
|
||||||
import {
|
import {
|
||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
EditOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { aftersalesApi } from '@/services/api';
|
import { aftersalesApi } from '@/services/api';
|
||||||
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
|
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
|
||||||
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
|
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
|
||||||
import SignaturePad, { type SignaturePadHandle } from '@/components/SignaturePad';
|
import SignatureOverlay from '@/components/SignatureOverlay';
|
||||||
import './styles/PublicQuery.css';
|
import './styles/PublicQuery.css';
|
||||||
import './styles/AftersalesConfirm.css';
|
import './styles/AftersalesConfirm.css';
|
||||||
|
|
||||||
@@ -29,8 +30,7 @@ function AftersalesConfirmPage() {
|
|||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||||
const [signatureData, setSignatureData] = useState('');
|
const [signatureData, setSignatureData] = useState('');
|
||||||
|
const [showSignatureOverlay, setShowSignatureOverlay] = useState(false);
|
||||||
const signatureRef = useRef<SignaturePadHandle>(null);
|
|
||||||
|
|
||||||
const loadOrder = async () => {
|
const loadOrder = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -53,7 +53,7 @@ function AftersalesConfirmPage() {
|
|||||||
}, [serialNumber]);
|
}, [serialNumber]);
|
||||||
|
|
||||||
const handleAuthorize = async () => {
|
const handleAuthorize = async () => {
|
||||||
if (signatureRef.current?.isEmpty() || !signatureData) {
|
if (!signatureData) {
|
||||||
message.error('请先签名');
|
message.error('请先签名');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,6 @@ function AftersalesConfirmPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearSignature = () => {
|
const handleClearSignature = () => {
|
||||||
signatureRef.current?.clear();
|
|
||||||
setSignatureData('');
|
setSignatureData('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,14 +235,33 @@ function AftersalesConfirmPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="aftersales-signature-section">
|
<div className="aftersales-signature-section">
|
||||||
<div className="aftersales-signature-header">
|
<div className="aftersales-signature-header">
|
||||||
<p className="aftersales-signature-tip">请在下方签名确认维修结果</p>
|
<p className="aftersales-signature-tip">请签名确认维修结果</p>
|
||||||
<Button size="small" type="link" onClick={handleClearSignature}>
|
{signatureData && (
|
||||||
清除签名
|
<Button size="small" type="link" onClick={handleClearSignature}>
|
||||||
</Button>
|
清除签名
|
||||||
</div>
|
</Button>
|
||||||
<div className="aftersales-signature-canvas-wrap">
|
)}
|
||||||
<SignaturePad ref={signatureRef} onChange={setSignatureData} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{signatureData ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aftersales-signature-preview"
|
||||||
|
onClick={() => setShowSignatureOverlay(true)}
|
||||||
|
>
|
||||||
|
<img src={signatureData} alt="客户签名" />
|
||||||
|
<span className="aftersales-signature-preview-hint">点击可重新签名</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aftersales-signature-trigger"
|
||||||
|
onClick={() => setShowSignatureOverlay(true)}
|
||||||
|
>
|
||||||
|
<EditOutlined />
|
||||||
|
<span>点击此处签名</span>
|
||||||
|
<small>将进入横屏签名页</small>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="aftersales-actions">
|
<div className="aftersales-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -269,6 +287,15 @@ function AftersalesConfirmPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<SignatureOverlay
|
||||||
|
open={showSignatureOverlay}
|
||||||
|
onCancel={() => setShowSignatureOverlay(false)}
|
||||||
|
onConfirm={(url) => {
|
||||||
|
setSignatureData(url);
|
||||||
|
setShowSignatureOverlay(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="请说明退回原因"
|
title="请说明退回原因"
|
||||||
open={showRejectDialog}
|
open={showRejectDialog}
|
||||||
|
|||||||
@@ -51,23 +51,64 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aftersales-signature-canvas-wrap {
|
.aftersales-signature-trigger {
|
||||||
border: 1px dashed #94a3b8;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 22px 16px;
|
||||||
|
border: 1.5px dashed #94a3b8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftersales-signature-trigger:active {
|
||||||
|
background: #eef2f7;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftersales-signature-trigger .anticon {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftersales-signature-trigger small {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aftersales-signature-preview {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
padding: 8px;
|
align-items: center;
|
||||||
touch-action: none;
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-pad-canvas {
|
.aftersales-signature-preview img {
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
touch-action: none;
|
max-height: 160px;
|
||||||
background: #ffffff;
|
display: block;
|
||||||
border-radius: 6px;
|
}
|
||||||
cursor: crosshair;
|
|
||||||
|
.aftersales-signature-preview-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aftersales-signature-archived {
|
.aftersales-signature-archived {
|
||||||
|
|||||||
Reference in New Issue
Block a user