From 4d19d7e8149aa121b39da190c562d4a07c03ff38 Mon Sep 17 00:00:00 2001 From: 24kycj <1637269896@qq.com> Date: Tue, 23 Jun 2026 11:27:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=94=AF=E4=BB=98=E5=AE=9D?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=B5=81=E7=A8=8B=E4=B8=8E=20Web=20=E7=AB=AF?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支付宝新窗口跳转,轮询与手动刷新增加加载反馈 - 支付回跳先校验订单状态,避免未付款误报成功 - 门户登录回调统一,云文件打开加载与版本冲突同步 - 精简本地 start.bat 与 README 说明 Co-authored-by: Cursor --- README.md | 12 +- frontend-web/assets/css/pay-sheet.css | 70 +++++++- frontend-web/js/common/cloud-files.js | 81 +++++++-- frontend-web/js/common/member-pay-core.js | 130 ++++++++++++-- frontend-web/js/common/portal-auth.js | 30 +++- frontend-web/js/design1/output.js | 3 + frontend-web/js/design2/output.js | 3 + frontend-web/js/index.js | 16 +- frontend-web/js/platform/web.js | 48 +++++- frontend-web/pages/design1.web.html | 6 - frontend-web/pages/design2.web.html | 5 - frontend-web/pages/index.web.html | 10 +- scripts/start.bat | 200 +++++++--------------- 13 files changed, 400 insertions(+), 214 deletions(-) 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 @@ '
' + payEmptyStateHtml(channel) + '
' + '
' + '

' + + '
' + + '' + + '正在检测支付结果
' + '' + '' + @@ -322,10 +359,14 @@ var manualBtn = sheetRoot.querySelector('#payManualRefresh'); var statusEl = sheetRoot.querySelector('#payStatus'); + var pollHint = sheetRoot.querySelector('#payPollHint'); var confirmBtn = sheetRoot.querySelector('#payConfirm'); var qrEl = sheetRoot.querySelector('#payQr'); var orderNoEl = sheetRoot.querySelector('#payOrderNo'); var backBtn = sheetRoot.querySelector('#payBack'); + var checking = false; + var pollTickTimer = null; + var MANUAL_REFRESH_LABEL = '已完成支付,刷新状态'; function setStatus(text, type) { if (!statusEl) return; @@ -334,6 +375,20 @@ statusEl.style.display = text ? 'block' : 'none'; } + function setPolling(active) { + sheetRoot.classList.toggle('soon-pay-sheet--polling', !!active); + if (!active && pollHint) pollHint.classList.remove('is-tick'); + } + + function flashPollTick() { + if (!pollHint) return; + pollHint.classList.add('is-tick'); + if (pollTickTimer) clearTimeout(pollTickTimer); + pollTickTimer = setTimeout(function () { + pollHint.classList.remove('is-tick'); + }, 450); + } + function setStep(step) { updatePayStep(sheetRoot, step, channel); sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2); @@ -346,18 +401,21 @@ clearInterval(pollTimer); pollTimer = null; } + setPolling(false); } function startPoll() { stopPoll(); + setPolling(true); pollStartedAt = Date.now(); + checkOrderStatus(false); pollTimer = setInterval(function () { if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) { stopPoll(); setStatus('自动检测超时,如已支付请点击下方按钮', 'err'); return; } - checkOrderStatus(); + checkOrderStatus(false); }, 3000); } @@ -368,14 +426,37 @@ setTimeout(onPaid, 600); } - function checkOrderStatus() { - if (!orderNo) return; + function checkOrderStatus(manual) { + if (!orderNo || checking) return; + checking = true; + if (statusEl) statusEl.classList.add('is-checking'); + if (manual && manualBtn) { + manualBtn.disabled = true; + manualBtn.classList.add('is-loading'); + manualBtn.textContent = '查询中…'; + } else { + flashPollTick(); + } apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) { - if (st.ok && st.data && st.data.status === 'paid') handlePaid(); + if (st.ok && st.data && st.data.status === 'paid') { + handlePaid(); + return; + } + if (manual) { + setStatus('暂未收到支付结果,请完成付款后再试', 'warn'); + } + }).finally(function () { + checking = false; + if (statusEl) statusEl.classList.remove('is-checking'); + if (manual && manualBtn) { + manualBtn.disabled = false; + manualBtn.classList.remove('is-loading'); + manualBtn.textContent = MANUAL_REFRESH_LABEL; + } }); } - if (manualBtn) manualBtn.onclick = checkOrderStatus; + if (manualBtn) manualBtn.onclick = function () { checkOrderStatus(true); }; function resetPaymentView() { paymentReady = false; @@ -391,9 +472,13 @@ setStatus(''); } - function applyCheckout(data) { + function applyCheckout(data, payWindow) { orderNo = data.order_no; - var err = renderPayPayload(data, qrEl, orderNoEl); + var launched = false; + if (channel === 'alipay' && data.pay_form && data.pay_form.gateway) { + launched = submitAlipayForm(data.pay_form, payWindow); + } + var err = renderPayPayload(data, qrEl, orderNoEl, { launched: launched }); if (err) { setStatus(err, 'err'); return; @@ -402,7 +487,14 @@ setStep(2); if (confirmBtn) confirmBtn.style.display = 'none'; if (manualBtn) manualBtn.style.display = ''; - setStatus('等待支付结果,完成后将自动刷新', 'ok'); + if (channel === 'alipay') { + setStatus( + launched ? '支付窗口已打开,请在新窗口完成付款' : '未能自动打开支付窗口,请允许弹窗或点击下方按钮', + launched ? 'ok' : 'err' + ); + } else { + setStatus('请扫码完成支付,本页将自动检测支付结果', 'ok'); + } startPoll(); } @@ -411,6 +503,7 @@ setStatus('正在生成付款信息…'); qrEl.innerHTML = '
' + '

正在连接支付渠道…

'; + var payWindow = channel === 'alipay' ? openAlipayPayWindow() : null; var req; if (resumeOrderNo || orderNo) { var existingNo = orderNo || resumeOrderNo; @@ -421,13 +514,15 @@ req.then(function (j) { if (confirmBtn) confirmBtn.disabled = false; if (!j.ok) { + if (payWindow && !payWindow.closed) payWindow.close(); setStatus(j.message || '下单失败', 'err'); qrEl.innerHTML = payEmptyStateHtml(channel); return; } - applyCheckout(j.data); + applyCheckout(j.data, payWindow); }).catch(function () { if (confirmBtn) confirmBtn.disabled = false; + if (payWindow && !payWindow.closed) payWindow.close(); setStatus('网络错误,请重试', 'err'); qrEl.innerHTML = payEmptyStateHtml(channel); }); @@ -448,6 +543,10 @@ function destroy() { stopPoll(); + if (pollTickTimer) { + clearTimeout(pollTickTimer); + pollTickTimer = null; + } paymentReady = false; orderNo = resumeOrderNo; } @@ -537,6 +636,7 @@ payEmptyStateHtml: payEmptyStateHtml, renderPayPayload: renderPayPayload, submitAlipayForm: submitAlipayForm, + openAlipayPayWindow: openAlipayPayWindow, loadDisplayChannels: loadDisplayChannels, displayChannels: displayChannels, defaultChannel: defaultChannel, diff --git a/frontend-web/js/common/portal-auth.js b/frontend-web/js/common/portal-auth.js index dc1e35a..ed6acf3 100644 --- a/frontend-web/js/common/portal-auth.js +++ b/frontend-web/js/common/portal-auth.js @@ -77,28 +77,42 @@ ? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } }) : fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } }); meFetch - .then(function (r) { return r.json(); }) + .then(function (r) { + if (r.status === 401) { + clearSession(); + location.reload(); + return null; + } + if (!r.ok) { + if (typeof window.soonToast === 'function') window.soonToast('网络异常,部分功能可能不可用', 'warn'); + return null; + } + return r.json(); + }) .then(function (j) { + if (!j) return; if (!j.ok || !j.data) { clearSession(); location.reload(); return; } + if (typeof window.soonApplyMembership === 'function') { + window.soonApplyMembership(j.data); + } if (j.data.role === 'admin' && admin) admin.style.display = 'inline-flex'; if (avatar && j.data.email) { avatar.style.display = 'inline-flex'; avatar.textContent = j.data.email.charAt(0).toUpperCase(); avatar.title = j.data.email; } - if (typeof window.soonLoadMembership === 'function') { - window.soonLoadMembership().then(function () { - if (typeof window.soonRefreshPortalIdentity === 'function') { - window.soonRefreshPortalIdentity(); - } - }); + if (typeof window.soonRefreshPortalIdentity === 'function') { + window.soonRefreshPortalIdentity(); } + if (typeof options.onAuthed === 'function') options.onAuthed(j.data); }) - .catch(function () { /* keep authed shell */ }); + .catch(function () { + if (typeof window.soonToast === 'function') window.soonToast('网络异常,部分功能可能不可用', 'warn'); + }); if (logout) { logout.addEventListener('click', function (e) { diff --git a/frontend-web/js/design1/output.js b/frontend-web/js/design1/output.js index 4c05886..e86b32c 100644 --- a/frontend-web/js/design1/output.js +++ b/frontend-web/js/design1/output.js @@ -1192,7 +1192,9 @@ window.openFile = function open(file, jAlready) { return; } if (window.platformBridge && window.platformBridge.readJsonFile) { + if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading(); return window.platformBridge.readJsonFile(file).then(function(j) { + if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); if (j) { doOpenWithJson(j); openAs.name = file; @@ -1201,6 +1203,7 @@ window.openFile = function open(file, jAlready) { } }).catch(function () { if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); + else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); }); } try { diff --git a/frontend-web/js/design2/output.js b/frontend-web/js/design2/output.js index 844a7f2..48bde86 100644 --- a/frontend-web/js/design2/output.js +++ b/frontend-web/js/design2/output.js @@ -1517,7 +1517,9 @@ window.openFile = function open(file, jAlready) { return; } if (window.platformBridge && window.platformBridge.readJsonFile) { + if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading(); return window.platformBridge.readJsonFile(file).then(function(j) { + if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); if (j) { doOpenWithJson(j); openAs.name = file; @@ -1526,6 +1528,7 @@ window.openFile = function open(file, jAlready) { } }).catch(function () { if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); + else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading(); }); } try { diff --git a/frontend-web/js/index.js b/frontend-web/js/index.js index e37e160..6b6a31f 100644 --- a/frontend-web/js/index.js +++ b/frontend-web/js/index.js @@ -351,14 +351,22 @@ layui.use(['layer', 'form', 'jquery'], function () { var start = (fileListState.page - 1) * fileListState.size; var slice = mapped.slice(start, start + fileListState.size); + var thumbRows = await Promise.all(slice.map(async function (item) { + var realType = item.type || 1; + return { + item: item, + src: await resolveRecentThumb(item), + fallbackSrc: recentFallbackThumb(realType) + }; + })); let h = ""; - for (let item of slice) { - + for (let row of thumbRows) { + let item = row.item; let filePath = item.filePath; let realType = item.type || 1; - let src = await resolveRecentThumb(item); - let fallbackSrc = recentFallbackThumb(realType); + let src = row.src; + let fallbackSrc = row.fallbackSrc; let displayName = item.name || displayNameFromPath(filePath); let cardTitle = displayName; var cardClass = 'card'; diff --git a/frontend-web/js/platform/web.js b/frontend-web/js/platform/web.js index e01563b..618386e 100644 --- a/frontend-web/js/platform/web.js +++ b/frontend-web/js/platform/web.js @@ -161,6 +161,40 @@ .then(packCloudResult); } + function syncCloudFileAfterConflict(id) { + var oldKey = (typeof window !== 'undefined' && window.openAs && window.openAs.name) ? window.openAs.name : ''; + return authedFetch('files/' + id, { headers: { Accept: 'application/json' } }) + .then(function (response) { + if (!response.ok) return null; + return response.json(); + }) + .then(function (payload) { + if (!payload || !payload.ok || !payload.data) return null; + var d = payload.data; + var newKey = typeof window.soonApplyCloudMeta === 'function' + ? window.soonApplyCloudMeta(d) + : ('soondesign_file:' + d.id + ':v' + d.version); + if (oldKey && oldKey !== newKey && typeof window.soonLocalRemove === 'function') { + window.soonLocalRemove(oldKey); + } + if (typeof window.soonRecentOnCloudSave === 'function') { + window.soonRecentOnCloudSave({ + fileKey: newKey, + fileId: d.id, + name: d.name, + version: d.version + }, oldKey, null); + } + if (typeof window !== 'undefined' && window.openAs && newKey) { + window.openAs.name = newKey; + } + if (typeof window.soonToast === 'function') { + window.soonToast('文件已在其他端更新,已同步版本,请再次保存', 'warn'); + } + return newKey; + }); + } + function updateCloudFile(id, name, jsonStr, version) { if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); var body = { name: normalizeSoonName(name), json: jsonStr }; @@ -170,7 +204,19 @@ headers: JSON_HEADERS, body: JSON.stringify(body) }) - .then(function (r) { return r.text().then(function (t) { return handleCloudResponse(r, t); }); }) + .then(function (r) { + return r.text().then(function (t) { + if (r.status === 409) { + return syncCloudFileAfterConflict(id).then(function () { + var err = new Error('version_conflict'); + err.status = 409; + err.conflictSynced = true; + return Promise.reject(err); + }); + } + return handleCloudResponse(r, t); + }); + }) .then(packCloudResult); } diff --git a/frontend-web/pages/design1.web.html b/frontend-web/pages/design1.web.html index f7e6e02..cc3b2c9 100644 --- a/frontend-web/pages/design1.web.html +++ b/frontend-web/pages/design1.web.html @@ -873,7 +873,6 @@ - @@ -897,11 +896,6 @@ diff --git a/frontend-web/pages/design2.web.html b/frontend-web/pages/design2.web.html index d5072e0..2d6da6d 100644 --- a/frontend-web/pages/design2.web.html +++ b/frontend-web/pages/design2.web.html @@ -689,11 +689,6 @@ diff --git a/frontend-web/pages/index.web.html b/frontend-web/pages/index.web.html index 9943657..7baa4b8 100644 --- a/frontend-web/pages/index.web.html +++ b/frontend-web/pages/index.web.html @@ -157,13 +157,11 @@ diff --git a/scripts/start.bat b/scripts/start.bat index 9ac16b3..8fc9f45 100644 --- a/scripts/start.bat +++ b/scripts/start.bat @@ -1,57 +1,44 @@ @echo off setlocal EnableDelayedExpansion +rem keep window open when double-clicked set "SELF=%~f0" -if /i "%~1"=="__run__" ( - set "ACTION=%~2" -) else ( - set "ACTION=%~1" +if /i not "%~1"=="__run__" ( echo %CMDCMDLINE% | find /i " /c " >nul if not errorlevel 1 ( - cmd /k "%SELF%" __run__ %* + cmd /k "%SELF%" __run__ exit /b ) ) set "ROOT=%~dp0.." for %%I in ("%ROOT%") do set "ROOT=%%~fI" -cd /d "%ROOT%" || goto :fail_cd +cd /d "%ROOT%" || ( + echo [ERROR] Cannot cd to project root: %ROOT% + exit /b 1 +) set "COMPOSE=docker\docker-compose.yml" set "ENV_FILE=docker\.env" -set "COMPOSE_PROJECT=soondesign" +set "PROJECT=soondesign" set "WEB_PORT=8100" set "ELECTRON_PORT=8101" set "BACKEND_PORT=8102" -if /i "%ACTION%"=="stop" goto :do_stop -if /i "%ACTION%"=="reset" goto :do_reset -if /i "%ACTION%"=="rebuild" goto :do_rebuild -if /i "%ACTION%"=="logs" goto :do_logs -if /i "%ACTION%"=="status" goto :do_status -goto :do_start +echo [SoonDesign] Local dev stack +echo. -:compose -docker compose -p "%COMPOSE_PROJECT%" -f "%COMPOSE%" --env-file "%ENV_FILE%" %* -exit /b !ERRORLEVEL! - -:compose_noenv -docker compose -p "%COMPOSE_PROJECT%" -f "%COMPOSE%" %* -exit /b !ERRORLEVEL! - -:load_ports -for /f "usebackq eol=# tokens=1,* delims==" %%A in ("%ENV_FILE%") do ( - if /i "%%A"=="WEB_PORT" set "WEB_PORT=%%B" - if /i "%%A"=="ELECTRON_PORT" set "ELECTRON_PORT=%%B" - if /i "%%A"=="BACKEND_PORT" set "BACKEND_PORT=%%B" -) -exit /b 0 - -:ensure_env -if not exist "%ROOT%\docker\docker-compose.yml" ( - echo [ERROR] Missing docker\docker-compose.yml under %ROOT% +docker info >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Docker Desktop is not running. exit /b 1 ) + +if not exist "%ROOT%\docker\docker-compose.yml" ( + echo [ERROR] Missing docker\docker-compose.yml + exit /b 1 +) + if not exist "%ROOT%\soonModels\" mkdir "%ROOT%\soonModels" if not exist "%ENV_FILE%" ( if not exist "docker\.env.example" ( @@ -60,34 +47,54 @@ if not exist "%ENV_FILE%" ( ) copy /Y "docker\.env.example" "%ENV_FILE%" >nul ) + powershell -NoProfile -ExecutionPolicy Bypass -File "docker\sync-config.ps1" -EnvFile "%ENV_FILE%" if errorlevel 1 ( echo [ERROR] Config sync failed. exit /b 1 ) -call :load_ports -exit /b 0 -:wait_api -echo [INFO] Waiting for API http://localhost:%BACKEND_PORT%/health ... -set /a RETRY=0 -:wait_health -set /a RETRY+=1 -curl.exe -sf "http://localhost:%BACKEND_PORT%/health" >nul 2>&1 -if not errorlevel 1 exit /b 0 -if !RETRY! GEQ 90 ( - echo [WARN] API not ready after ~3 min. Run: scripts\start.bat logs +for /f "usebackq eol=# tokens=1,* delims==" %%A in ("%ENV_FILE%") do ( + if /i "%%A"=="WEB_PORT" set "WEB_PORT=%%B" + if /i "%%A"=="ELECTRON_PORT" set "ELECTRON_PORT=%%B" + if /i "%%A"=="BACKEND_PORT" set "BACKEND_PORT=%%B" +) + +echo [INFO] Ports Web=%WEB_PORT% Electron=%ELECTRON_PORT% API=%BACKEND_PORT% + +docker image inspect soondesign-api:dev >nul 2>&1 +if errorlevel 1 ( + echo [INFO] First run: building API image with pdo_mysql... + docker compose -p "%PROJECT%" -f "%COMPOSE%" --env-file "%ENV_FILE%" up -d --build +) else ( + docker compose -p "%PROJECT%" -f "%COMPOSE%" --env-file "%ENV_FILE%" up -d +) +if errorlevel 1 ( + echo [ERROR] docker compose up failed. exit /b 1 ) -set /a MOD=!RETRY! %% 5 -if !MOD!==0 echo ... still waiting (!RETRY!/90) -ping -n 3 127.0.0.1 >nul -goto :wait_health -:print_urls +echo [INFO] Waiting for API http://localhost:%BACKEND_PORT%/health ... +set /a RETRY=0 +:L_wait +set /a RETRY+=1 +curl.exe -sf "http://localhost:%BACKEND_PORT%/health" >nul 2>&1 +if not errorlevel 1 goto L_ok +if !RETRY! GEQ 60 ( + echo [WARN] API not ready. Check: docker logs soondesign-api + goto L_urls +) +set /a MOD=!RETRY! %% 5 +if !MOD!==0 echo ... waiting !RETRY!/60 +ping -n 3 127.0.0.1 >nul +goto L_wait + +:L_ok +echo [OK] API ready. + +:L_urls echo. echo ======================================== -echo Project: %COMPOSE_PROJECT% echo Web: http://localhost:%WEB_PORT%/pages/index.web.html echo Admin: http://localhost:%WEB_PORT%/pages/admin/index.html echo Electron: http://localhost:%ELECTRON_PORT%/pages/index.html @@ -95,98 +102,7 @@ echo API: http://localhost:%BACKEND_PORT%/health echo ======================================== echo Login: admin@local.test / admin123 echo Desktop: cd frontend-electron ^&^& npm start -echo Stop: scripts\start.bat stop -echo Logs: scripts\start.bat logs -exit /b 0 - -:do_stop -if exist "%ENV_FILE%" (call :compose down) else (call :compose_noenv down) +echo Stop: docker compose -p %PROJECT% -f docker\docker-compose.yml down echo. -echo [OK] Stopped project %COMPOSE_PROJECT%. DB volume kept. -exit /b 0 - -:do_reset -if exist "%ENV_FILE%" (call :compose down -v) else (call :compose_noenv down -v) -echo. -echo [OK] Project %COMPOSE_PROJECT% stopped. DB volume removed. -exit /b 0 - -:do_logs -docker logs -f --tail 80 soondesign-api -exit /b 0 - -:do_status -call :ensure_env -if errorlevel 1 goto :fail -docker ps --filter name=soondesign --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" -echo. -curl.exe -sf "http://localhost:%BACKEND_PORT%/health" >nul 2>&1 -if errorlevel 1 ( - echo [WARN] API health check failed. -) else ( - echo [OK] API health check passed. -) -exit /b 0 - -:do_rebuild -echo [SoonDesign] Rebuilding API image and restarting... -call :ensure_env -if errorlevel 1 goto :fail -if exist "%ENV_FILE%" (call :compose up -d --build --force-recreate api) else (call :compose_noenv up -d --build --force-recreate api) -if errorlevel 1 goto :fail -call :wait_api -call :print_urls -goto :done_ok - -:do_start -echo [SoonDesign] Starting local Docker environment... -echo [INFO] Compose project: %COMPOSE_PROJECT% -echo. - -docker info >nul 2>&1 -if errorlevel 1 ( - echo [ERROR] Docker is not running. Please start Docker Desktop first. - goto :fail -) - -call :ensure_env -if errorlevel 1 goto :fail - -echo [INFO] Ports: Web=%WEB_PORT% Electron=%ELECTRON_PORT% API=%BACKEND_PORT% -echo [INFO] docker compose up -d (keeps running containers; use rebuild for fresh API image) -if exist "%ENV_FILE%" (call :compose up -d) else (call :compose_noenv up -d) -if errorlevel 1 ( - echo [ERROR] docker compose failed. Is Docker Desktop running? - goto :fail -) - -call :wait_api -if errorlevel 1 goto :show_urls_warn - -echo [OK] Backend is ready. -curl.exe -sf "http://localhost:%BACKEND_PORT%/api/v1/settings" >nul 2>&1 -if errorlevel 1 ( - echo [WARN] API routes not responding. Run: scripts\start.bat logs -) else ( - echo [OK] API routes verified. -) -call :print_urls -goto :done_ok - -:show_urls_warn -call :print_urls -goto :done_ok - -:fail_cd -echo [ERROR] Cannot cd to project root: %ROOT% -goto :fail - -:fail -echo. -echo [FAILED] See messages above. -exit /b 1 - -:done_ok -echo. -echo [DONE] SoonDesign dev stack is up (project %COMPOSE_PROJECT%). +echo [DONE] Edit frontend-web/backend-web files and refresh browser to debug. exit /b 0