Commit 07f352eb authored by Nicholas's avatar Nicholas

增强用户体验

parent 422e6426
{
"name": "Streams River Refunds UI",
"version": "1.7",
"version": "1.9",
"build": "abc",
"description": "river refunds",
"date": "2026-08-19 00:04:11",
"date": "2026-09-08 15:14:19",
"author": "Nicholas",
"spa": false
}
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import {
createDeviationService,
fetchDeviationPageData,
......@@ -80,19 +80,36 @@ function buildPassengers(order, terms) {
}, []);
}
function PassengerAgreement({ checkedIds, onToggle, passenger }) {
function PassengerAgreement({ checkedIds, disabled, onToggle, passenger, showErrors }) {
const agreementId = useId();
const checked = checkedIds.includes(passenger.id);
const showError = showErrors && !checked;
return (
<div className="agreement-block">
<label className="agreement-check">
<label
className={`agreement-check${checked ? ' is-checked' : ''}${showError ? ' has-error' : ''}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
aria-invalid={showError || undefined}
aria-describedby={showError ? `${agreementId}-error` : undefined}
onChange={(event) => onToggle(passenger.id, event.target.checked)}
/>
<span className="term-desc">{passenger.term.desc}</span>
<span className="agreement-copy">
<span className="agreement-prompt">
{checked ? '已确认本方案及条款' : '请勾选确认本方案及条款'}
</span>
<span className="term-desc">{passenger.term.desc}</span>
</span>
</label>
{showError && (
<p className="agreement-error" id={`${agreementId}-error`}>
请先勾选确认此项。
</p>
)}
<p className="term-heading">补偿条款与细则:</p>
<div
className="term-content"
......@@ -111,8 +128,10 @@ export default function App() {
const [code, setCode] = useState('');
const [mobileToken, setMobileToken] = useState('');
const [checkedIds, setCheckedIds] = useState([]);
const [showAgreementErrors, setShowAgreementErrors] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [countdown, setCountdown] = useState(0);
const agreementsRef = useRef(null);
const passengers = useMemo(() => buildPassengers(order, terms), [order, terms]);
// 开头文案(含河流名)从中台条款的 intro 字段取,同一订单各乘客一致,取第一位即可;没有则回退到写死文案。
......@@ -201,6 +220,20 @@ export default function App() {
});
}
function goToUncheckedAgreement() {
setShowAgreementErrors(true);
const checkbox = agreementsRef.current?.querySelector('input[type="checkbox"]:not(:checked)');
if (!checkbox) {
return;
}
checkbox.focus({ preventScroll: true });
checkbox.closest('.agreement-check')?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
});
}
async function handleSendSmsCode() {
if (!phone) {
window.alert('请输入手机号码');
......@@ -219,23 +252,22 @@ export default function App() {
}
async function handleSubmit() {
if (!phone) {
window.alert('请输入手机号码');
if (submitting || passengers.length === 0) {
return;
}
if (!code) {
window.alert('请输入验证码');
if (!allChecked) {
goToUncheckedAgreement();
return;
}
if (checkedIds.length < 1) {
window.alert('请选择方案');
if (!phone) {
window.alert('请输入手机号码');
return;
}
if (checkedIds.length !== passengers.length) {
window.alert('请勾选所有用户');
if (!code) {
window.alert('请输入验证码');
return;
}
......@@ -322,13 +354,15 @@ export default function App() {
)}
</section>
<section className="agreements-section">
<section className="agreements-section" ref={agreementsRef}>
{passengers.map((passenger) => (
<PassengerAgreement
checkedIds={checkedIds}
disabled={submitting}
key={passenger.id}
onToggle={togglePassenger}
passenger={passenger}
showErrors={showAgreementErrors}
/>
))}
</section>
......@@ -341,9 +375,23 @@ export default function App() {
</section>
<section className="form-section">
{passengers.length > 0 && (
<div className={`agreement-progress${allChecked ? ' is-complete' : ''}`}>
<div role="status" aria-live="polite" aria-atomic="true">
<strong>已确认 {checkedIds.length} / {passengers.length} 项</strong>
<p>{allChecked ? '全部方案已确认,请完成手机验证后提交。' : '请确认全部方案后提交。'}</p>
</div>
{!allChecked && (
<button className="agreement-jump" type="button" onClick={goToUncheckedAgreement}>
前往确认
</button>
)}
</div>
)}
<input
type="text"
value={phone}
disabled={submitting}
placeholder="手机号码"
onChange={(event) => setPhone(event.target.value)}
/>
......@@ -351,15 +399,21 @@ export default function App() {
<input
type="text"
value={code}
disabled={submitting}
placeholder="验证码"
onChange={(event) => setCode(event.target.value)}
/>
<button type="button" disabled={countdown > 0} onClick={handleSendSmsCode}>
<button type="button" disabled={submitting || countdown > 0} onClick={handleSendSmsCode}>
{countdown > 0 ? `已发送(${countdown})` : '获取验证码'}
</button>
</div>
<button className="submit-button" type="button" disabled={!allChecked} onClick={handleSubmit}>
提交
<button
className="submit-button"
type="button"
disabled={submitting || passengers.length === 0}
onClick={handleSubmit}
>
{submitting ? '提交中…' : '提交'}
</button>
</section>
......
......@@ -89,25 +89,92 @@ button:disabled {
}
.agreement-check {
display: block;
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 48px;
margin: 0;
padding: 0;
padding: 16px;
background: #f4f6f9;
border: 2px solid #738199;
border-radius: 6px;
cursor: pointer;
scroll-margin-block: 24px;
}
.agreement-check.is-checked {
background: #edf2f8;
border-color: #0d1c34;
}
.agreement-check.has-error {
background: #fff4f1;
border-color: #b42318;
}
.agreement-check input {
width: 13px;
height: 13px;
margin: 3px 6px 0 0;
vertical-align: text-top;
appearance: none;
-webkit-appearance: none;
display: grid;
place-content: center;
flex: 0 0 26px;
width: 26px;
height: 26px;
margin: 0;
background: #ffffff;
border: 2px solid #44536a;
border-radius: 4px;
accent-color: #0d1c34;
cursor: pointer;
}
.agreement-check input:checked {
background: #0d1c34;
border-color: #0d1c34;
}
.agreement-check input:checked::after {
content: '';
width: 7px;
height: 13px;
margin-top: -3px;
border: solid #ffffff;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
}
.agreement-check input:focus-visible,
.agreement-jump:focus-visible,
.submit-button:focus-visible {
outline: 3px solid #3265a4;
outline-offset: 3px;
}
.agreement-copy {
min-width: 0;
overflow-wrap: anywhere;
}
.agreement-prompt {
display: block;
color: #0d1c34;
font-weight: 700;
line-height: 26px;
}
.term-desc {
display: inline;
display: block;
margin-top: 8px;
}
.agreement-error {
margin: 8px 0 0;
color: #b42318;
font-weight: 600;
}
.term-heading {
margin: 0.25rem 1rem 1rem;
margin: 1rem 0;
font-weight: 700;
}
......@@ -127,6 +194,39 @@ button:disabled {
padding: 3rem 0;
}
.agreement-progress {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
padding: 16px;
color: #0d1c34;
background: #f4f6f9;
border: 1px solid #738199;
border-radius: 6px;
}
.agreement-progress.is-complete {
border-color: #0d1c34;
}
.agreement-progress p {
margin: 4px 0 0;
}
.agreement-jump {
flex-shrink: 0;
min-height: 44px;
padding: 8px 12px;
color: #0d1c34;
background: #ffffff;
border: 1px solid #738199;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
}
.form-section input {
width: 100%;
height: 40px;
......@@ -168,11 +268,12 @@ button:disabled {
.submit-button {
width: 100%;
height: 40px;
height: 48px;
color: #ffffff;
background: #0d1c34;
border: 0;
border-radius: 2px;
cursor: pointer;
}
.loading-mask {
......@@ -241,9 +342,24 @@ button:disabled {
width: 100% !important;
}
.form-section {
width: 100%;
}
.term-desc,
.term-heading,
.term-content {
margin-left: 0;
}
}
@media (forced-colors: active) {
.agreement-check input {
appearance: auto;
-webkit-appearance: auto;
}
.agreement-check input:checked::after {
content: none;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment