diff --git a/README.md b/README.md index 45c1ad3..8459ea6 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,13 @@ ## 本地开发 -需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。 +需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。本地只需运行: ```bat -docker\sync-config.ps1 :: 同步 local.js 与 backend local.php(改端口后必跑) -scripts\start.bat :: 启动(不销毁已有容器,适合日常开发) -scripts\start.bat stop :: 停止(保留数据库) -scripts\start.bat reset :: 停止并清空数据库卷 -scripts\start.bat rebuild :: 重建 API 镜像并重启 API 容器 -scripts\start.bat status :: 查看容器与 /health -scripts\start.bat logs :: 跟踪 API 日志 +scripts\start.bat ``` -生产环境编辑 `frontend-web/config/local.js`(从 `config/local.js.example` 复制);全量更新 frontend 时不要覆盖该文件。本地开发由 `docker/sync-config.ps1` 生成 `local.js`。 +脚本会自动:同步 `local.js` / `local.php`、启动 Docker 容器、等待 API。改 `docker/.env` 端口后重新运行即可。停止:`docker compose -p soondesign -f docker/docker-compose.yml down` | 服务 | 默认地址 | |------|----------| diff --git a/frontend-web/assets/css/pay-sheet.css b/frontend-web/assets/css/pay-sheet.css index 8ca462e..f1a73cd 100644 --- a/frontend-web/assets/css/pay-sheet.css +++ b/frontend-web/assets/css/pay-sheet.css @@ -454,19 +454,87 @@ } .soon-pay-status { - margin: 0 0 12px; + margin: 0 0 8px; text-align: center; font-size: 12px; color: var(--soon-text-muted); + transition: color 0.2s ease, opacity 0.2s ease; } .soon-pay-status--ok { color: #4db6ac; } .soon-pay-status--err { color: var(--soon-danger); } +.soon-pay-status--warn { color: #ffb74d; } + +.soon-pay-status.is-checking { + color: #4db6ac; + opacity: 0.85; +} + +.soon-pay-poll { + display: none; + align-items: center; + justify-content: center; + gap: 8px; + margin: 0 0 10px; + font-size: 11px; + color: var(--soon-text-muted); +} + +.soon-pay-sheet--polling .soon-pay-poll { + display: flex; +} + +.soon-pay-poll__spin { + width: 12px; + height: 12px; + border: 2px solid rgba(255, 255, 255, 0.12); + border-top-color: #4db6ac; + border-radius: 50%; + animation: soon-pay-spin 0.75s linear infinite; + flex-shrink: 0; +} + +.soon-pay-poll.is-tick .soon-pay-poll__spin { + animation-duration: 0.45s; +} + +.soon-pay-sheet--polling .soon-pay-checkout-ready { + border-color: rgba(77, 182, 172, 0.28); + animation: soon-pay-wait-pulse 2.4s ease-in-out infinite; +} .soon-pay-sheet__cta { width: 100%; + transition: opacity 0.2s ease; } .soon-pay-sheet__cta--secondary { margin-top: 8px; } + +.soon-pay-sheet__cta.is-loading { + opacity: 0.72; + pointer-events: none; +} + +.soon-pay-sheet__cta.is-loading::before { + content: ''; + display: inline-block; + width: 12px; + height: 12px; + margin-right: 6px; + vertical-align: -2px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: currentColor; + border-radius: 50%; + animation: soon-pay-spin 0.75s linear infinite; +} + +@keyframes soon-pay-spin { + to { transform: rotate(360deg); } +} + +@keyframes soon-pay-wait-pulse { + 0%, 100% { box-shadow: inset 0 0 0 0 rgba(77, 182, 172, 0); } + 50% { box-shadow: inset 0 0 0 1px rgba(77, 182, 172, 0.22); } +} diff --git a/frontend-web/js/common/cloud-files.js b/frontend-web/js/common/cloud-files.js index 29b5a2b..ac5089f 100644 --- a/frontend-web/js/common/cloud-files.js +++ b/frontend-web/js/common/cloud-files.js @@ -135,6 +135,7 @@ } function soonReportSaveError(err) { + if (err && err.conflictSynced) return; if (typeof window.soonShowApiError === 'function') { window.soonShowApiError({ status: err && err.status, @@ -656,9 +657,22 @@ } function soonReportOpenLoadError(message) { + soonHideOpenFileLoading(); soonToast(message || '文件加载失败', 'error'); } + function soonShowOpenFileLoading() { + if (typeof layer === 'undefined' || !layer.load) return; + soonHideOpenFileLoading(); + window._soonOpenLoadIdx = layer.load(1, { shade: [0.12, '#000'] }); + } + + function soonHideOpenFileLoading() { + if (window._soonOpenLoadIdx == null) return; + if (typeof layer !== 'undefined' && layer.close) layer.close(window._soonOpenLoadIdx); + window._soonOpenLoadIdx = null; + } + function soonToast(message, type) { if (typeof window.soonToast === 'function') window.soonToast(message, type); } @@ -737,29 +751,60 @@ return String(pathOrKey).split(/[/\\]/).pop(); } + function soonFetchOrderStatus(orderNo) { + var url = soonApiBase() + '/pay/orders/' + encodeURIComponent(orderNo); + var fetchFn = typeof window.soonAuthedFetch === 'function' + ? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } }) + : fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } }); + return fetchFn.then(function (r) { + if (!r.ok) return null; + return r.json(); + }).then(function (j) { + return j && j.ok && j.data ? j.data : null; + }).catch(function () { + return null; + }); + } + + function soonPollOrderPaid(orderNo, attemptsLeft, delayMs) { + return soonFetchOrderStatus(orderNo).then(function (data) { + if (data && data.status === 'paid') return data; + if (attemptsLeft <= 1) return data; + return new Promise(function (resolve) { + setTimeout(function () { + resolve(soonPollOrderPaid(orderNo, attemptsLeft - 1, delayMs)); + }, delayMs); + }); + }); + } + function soonHandlePayReturn() { - var paid = null; - var flag = false; + var orderNo = null; try { - paid = new URLSearchParams(location.search).get('paid'); + orderNo = new URLSearchParams(location.search).get('paid'); } catch (e) { /* ignore */ } - try { - flag = !!sessionStorage.getItem('soon_pay_return'); - } catch (e) { /* ignore */ } - if (!paid && !flag) return; try { sessionStorage.removeItem('soon_pay_return'); } catch (e) { /* ignore */ } - if (paid) { - try { - var u = new URL(location.href); - u.searchParams.delete('paid'); - history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || '')); - } catch (e) { /* ignore */ } - } - soonLoadMembership(true).then(function () { - soonRefreshPortalIdentity(); - soonToast('支付成功,会员已激活', 'success'); + if (!orderNo) return; + try { + var u = new URL(location.href); + u.searchParams.delete('paid'); + history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || '')); + } catch (e) { /* ignore */ } + if (!soonGetAccessToken()) return; + soonPollOrderPaid(orderNo, 10, 3000).then(function (data) { + if (data && data.status === 'paid') { + return soonLoadMembership(true).then(function () { + soonRefreshPortalIdentity(); + soonToast('支付成功,会员已激活', 'success'); + }); + } + if (data && data.status === 'cancelled') { + soonToast('订单已取消', 'warn'); + return; + } + soonToast('支付尚未完成,如已付款请稍后在会员中心查看', 'warn'); }); } @@ -783,6 +828,8 @@ window.soonTryConsumeCloudImport = soonTryConsumeCloudImport; window.soonClearPendingImport = soonClearPendingImport; window.soonReportOpenLoadError = soonReportOpenLoadError; + window.soonShowOpenFileLoading = soonShowOpenFileLoading; + window.soonHideOpenFileLoading = soonHideOpenFileLoading; window.soonParseTemplateKey = soonParseTemplateKey; window.soonMakeTemplateKey = soonMakeTemplateKey; window.soonSoonTypeFromJson = soonSoonTypeFromJson; diff --git a/frontend-web/js/common/member-pay-core.js b/frontend-web/js/common/member-pay-core.js index 383fd40..678ab01 100644 --- a/frontend-web/js/common/member-pay-core.js +++ b/frontend-web/js/common/member-pay-core.js @@ -77,12 +77,34 @@ } } - function submitAlipayForm(payForm) { + var ALIPAY_WINDOW_NAME = 'soon_alipay_pay'; + + function openAlipayPayWindow() { + try { + var w = window.open('about:blank', ALIPAY_WINDOW_NAME); + if (w) { + w.document.title = '支付宝收银台'; + w.document.body.innerHTML = + '
正在跳转支付宝…
'; + } + return w; + } catch (e) { + return null; + } + } + + function submitAlipayForm(payForm, payWindow) { if (!payForm || !payForm.gateway || !payForm.params) return false; if (!isSafeAlipayGateway(payForm.gateway)) return false; + var win = payWindow; + if (!win || win.closed) { + win = openAlipayPayWindow(); + } + if (!win) return false; var form = document.createElement('form'); form.method = 'POST'; form.action = payForm.gateway; + form.target = ALIPAY_WINDOW_NAME; form.acceptCharset = 'UTF-8'; form.style.display = 'none'; Object.keys(payForm.params).forEach(function (key) { @@ -96,6 +118,11 @@ }); document.body.appendChild(form); form.submit(); + setTimeout(function () { + try { + document.body.removeChild(form); + } catch (e) { /* ignore */ } + }, 0); return true; } @@ -218,27 +245,34 @@ '点击下方按钮,将使用' + esc(ui.name) + '(' + esc(ui.hint) + ')
'; } - function renderPayPayload(data, qrEl, orderNoEl) { + function renderPayPayload(data, qrEl, orderNoEl, launchOpts) { + launchOpts = launchOpts || {}; if (orderNoEl && data.order_no) { orderNoEl.textContent = '订单号 ' + data.order_no; orderNoEl.style.display = ''; } if (data.pay_form && data.pay_form.gateway) { if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效'; - sessionStorage.setItem('soon_pay_return', '1'); var payForm = data.pay_form; + var launched = launchOpts.launched === true; qrEl.innerHTML = '支付宝电脑网站支付
' + - '点击下方按钮跳转至支付宝收银台,支付完成后将自动返回
' + - '' + + (launched + ? '支付窗口已打开,请在新窗口完成付款;本页将自动检测支付结果' + : '未能自动打开支付窗口,请允许弹窗后点击下方按钮') + + '
' + + ''; var alipayBtn = qrEl.querySelector('#goAlipay'); if (alipayBtn) { alipayBtn.onclick = function () { if (!submitAlipayForm(payForm)) { if (typeof window.soonToast === 'function') { - window.soonToast('无法跳转支付宝,请检查支付配置', 'warn'); + window.soonToast('无法打开支付窗口,请允许浏览器弹窗', 'warn'); } } }; @@ -298,6 +332,9 @@ '正在连接支付渠道…