Commit 07f352eb authored by Nicholas's avatar Nicholas

增强用户体验

parent 422e6426
{ {
"name": "Streams River Refunds UI", "name": "Streams River Refunds UI",
"version": "1.7", "version": "1.9",
"build": "abc", "build": "abc",
"description": "river refunds", "description": "river refunds",
"date": "2026-08-19 00:04:11", "date": "2026-09-08 15:14:19",
"author": "Nicholas", "author": "Nicholas",
"spa": false "spa": false
} }
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { import {
createDeviationService, createDeviationService,
fetchDeviationPageData, fetchDeviationPageData,
...@@ -80,19 +80,36 @@ function buildPassengers(order, terms) { ...@@ -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 checked = checkedIds.includes(passenger.id);
const showError = showErrors && !checked;
return ( return (
<div className="agreement-block"> <div className="agreement-block">
<label className="agreement-check"> <label
className={`agreement-check${checked ? ' is-checked' : ''}${showError ? ' has-error' : ''}`}
>
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={disabled}
aria-invalid={showError || undefined}
aria-describedby={showError ? `${agreementId}-error` : undefined}
onChange={(event) => onToggle(passenger.id, event.target.checked)} onChange={(event) => onToggle(passenger.id, event.target.checked)}
/> />
<span className="agreement-copy">
<span className="agreement-prompt">
{checked ? '已确认本方案及条款' : '请勾选确认本方案及条款'}
</span>
<span className="term-desc">{passenger.term.desc}</span> <span className="term-desc">{passenger.term.desc}</span>
</span>
</label> </label>
{showError && (
<p className="agreement-error" id={`${agreementId}-error`}>
请先勾选确认此项。
</p>
)}
<p className="term-heading">补偿条款与细则:</p> <p className="term-heading">补偿条款与细则:</p>
<div <div
className="term-content" className="term-content"
...@@ -111,8 +128,10 @@ export default function App() { ...@@ -111,8 +128,10 @@ export default function App() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [mobileToken, setMobileToken] = useState(''); const [mobileToken, setMobileToken] = useState('');
const [checkedIds, setCheckedIds] = useState([]); const [checkedIds, setCheckedIds] = useState([]);
const [showAgreementErrors, setShowAgreementErrors] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
const agreementsRef = useRef(null);
const passengers = useMemo(() => buildPassengers(order, terms), [order, terms]); const passengers = useMemo(() => buildPassengers(order, terms), [order, terms]);
// 开头文案(含河流名)从中台条款的 intro 字段取,同一订单各乘客一致,取第一位即可;没有则回退到写死文案。 // 开头文案(含河流名)从中台条款的 intro 字段取,同一订单各乘客一致,取第一位即可;没有则回退到写死文案。
...@@ -201,6 +220,20 @@ export default function App() { ...@@ -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() { async function handleSendSmsCode() {
if (!phone) { if (!phone) {
window.alert('请输入手机号码'); window.alert('请输入手机号码');
...@@ -219,23 +252,22 @@ export default function App() { ...@@ -219,23 +252,22 @@ export default function App() {
} }
async function handleSubmit() { async function handleSubmit() {
if (!phone) { if (submitting || passengers.length === 0) {
window.alert('请输入手机号码');
return; return;
} }
if (!code) { if (!allChecked) {
window.alert('请输入验证码'); goToUncheckedAgreement();
return; return;
} }
if (checkedIds.length < 1) { if (!phone) {
window.alert('请选择方案'); window.alert('请输入手机号码');
return; return;
} }
if (checkedIds.length !== passengers.length) { if (!code) {
window.alert('请勾选所有用户'); window.alert('请输入验证码');
return; return;
} }
...@@ -322,13 +354,15 @@ export default function App() { ...@@ -322,13 +354,15 @@ export default function App() {
)} )}
</section> </section>
<section className="agreements-section"> <section className="agreements-section" ref={agreementsRef}>
{passengers.map((passenger) => ( {passengers.map((passenger) => (
<PassengerAgreement <PassengerAgreement
checkedIds={checkedIds} checkedIds={checkedIds}
disabled={submitting}
key={passenger.id} key={passenger.id}
onToggle={togglePassenger} onToggle={togglePassenger}
passenger={passenger} passenger={passenger}
showErrors={showAgreementErrors}
/> />
))} ))}
</section> </section>
...@@ -341,9 +375,23 @@ export default function App() { ...@@ -341,9 +375,23 @@ export default function App() {
</section> </section>
<section className="form-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 <input
type="text" type="text"
value={phone} value={phone}
disabled={submitting}
placeholder="手机号码" placeholder="手机号码"
onChange={(event) => setPhone(event.target.value)} onChange={(event) => setPhone(event.target.value)}
/> />
...@@ -351,15 +399,21 @@ export default function App() { ...@@ -351,15 +399,21 @@ export default function App() {
<input <input
type="text" type="text"
value={code} value={code}
disabled={submitting}
placeholder="验证码" placeholder="验证码"
onChange={(event) => setCode(event.target.value)} 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})` : '获取验证码'} {countdown > 0 ? `已发送(${countdown})` : '获取验证码'}
</button> </button>
</div> </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> </button>
</section> </section>
......
...@@ -89,25 +89,92 @@ button:disabled { ...@@ -89,25 +89,92 @@ button:disabled {
} }
.agreement-check { .agreement-check {
display: block; display: flex;
align-items: flex-start;
gap: 12px;
min-height: 48px;
margin: 0; 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 { .agreement-check input {
width: 13px; appearance: none;
height: 13px; -webkit-appearance: none;
margin: 3px 6px 0 0; display: grid;
vertical-align: text-top; 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; 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 { .term-desc {
display: inline; display: block;
margin-top: 8px;
}
.agreement-error {
margin: 8px 0 0;
color: #b42318;
font-weight: 600;
} }
.term-heading { .term-heading {
margin: 0.25rem 1rem 1rem; margin: 1rem 0;
font-weight: 700; font-weight: 700;
} }
...@@ -127,6 +194,39 @@ button:disabled { ...@@ -127,6 +194,39 @@ button:disabled {
padding: 3rem 0; 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 { .form-section input {
width: 100%; width: 100%;
height: 40px; height: 40px;
...@@ -168,11 +268,12 @@ button:disabled { ...@@ -168,11 +268,12 @@ button:disabled {
.submit-button { .submit-button {
width: 100%; width: 100%;
height: 40px; height: 48px;
color: #ffffff; color: #ffffff;
background: #0d1c34; background: #0d1c34;
border: 0; border: 0;
border-radius: 2px; border-radius: 2px;
cursor: pointer;
} }
.loading-mask { .loading-mask {
...@@ -241,9 +342,24 @@ button:disabled { ...@@ -241,9 +342,24 @@ button:disabled {
width: 100% !important; width: 100% !important;
} }
.form-section {
width: 100%;
}
.term-desc, .term-desc,
.term-heading, .term-heading,
.term-content { .term-content {
margin-left: 0; 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