优化支付宝支付流程与 Web 端体验

- 支付宝新窗口跳转,轮询与手动刷新增加加载反馈
- 支付回跳先校验订单状态,避免未付款误报成功
- 门户登录回调统一,云文件打开加载与版本冲突同步
- 精简本地 start.bat 与 README 说明

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-23 11:27:45 +08:00
parent 23b958753d
commit 4d19d7e814
13 changed files with 400 additions and 214 deletions
+3 -9
View File
@@ -13,19 +13,13 @@
## 本地开发 ## 本地开发
需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。 需 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。本地只需运行:
```bat ```bat
docker\sync-config.ps1 :: 同步 local.js 与 backend local.php(改端口后必跑) scripts\start.bat
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 日志
``` ```
生产环境编辑 `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`
| 服务 | 默认地址 | | 服务 | 默认地址 |
|------|----------| |------|----------|
+69 -1
View File
@@ -454,19 +454,87 @@
} }
.soon-pay-status { .soon-pay-status {
margin: 0 0 12px; margin: 0 0 8px;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: var(--soon-text-muted); color: var(--soon-text-muted);
transition: color 0.2s ease, opacity 0.2s ease;
} }
.soon-pay-status--ok { color: #4db6ac; } .soon-pay-status--ok { color: #4db6ac; }
.soon-pay-status--err { color: var(--soon-danger); } .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 { .soon-pay-sheet__cta {
width: 100%; width: 100%;
transition: opacity 0.2s ease;
} }
.soon-pay-sheet__cta--secondary { .soon-pay-sheet__cta--secondary {
margin-top: 8px; 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); }
}
+57 -10
View File
@@ -135,6 +135,7 @@
} }
function soonReportSaveError(err) { function soonReportSaveError(err) {
if (err && err.conflictSynced) return;
if (typeof window.soonShowApiError === 'function') { if (typeof window.soonShowApiError === 'function') {
window.soonShowApiError({ window.soonShowApiError({
status: err && err.status, status: err && err.status,
@@ -656,9 +657,22 @@
} }
function soonReportOpenLoadError(message) { function soonReportOpenLoadError(message) {
soonHideOpenFileLoading();
soonToast(message || '文件加载失败', 'error'); 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) { function soonToast(message, type) {
if (typeof window.soonToast === 'function') window.soonToast(message, type); if (typeof window.soonToast === 'function') window.soonToast(message, type);
} }
@@ -737,31 +751,62 @@
return String(pathOrKey).split(/[/\\]/).pop(); 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() { function soonHandlePayReturn() {
var paid = null; var orderNo = null;
var flag = false;
try { try {
paid = new URLSearchParams(location.search).get('paid'); orderNo = new URLSearchParams(location.search).get('paid');
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
try {
flag = !!sessionStorage.getItem('soon_pay_return');
} catch (e) { /* ignore */ }
if (!paid && !flag) return;
try { try {
sessionStorage.removeItem('soon_pay_return'); sessionStorage.removeItem('soon_pay_return');
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
if (paid) { if (!orderNo) return;
try { try {
var u = new URL(location.href); var u = new URL(location.href);
u.searchParams.delete('paid'); u.searchParams.delete('paid');
history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || '')); history.replaceState(null, '', u.pathname + (u.search || '') + (u.hash || ''));
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} if (!soonGetAccessToken()) return;
soonLoadMembership(true).then(function () { soonPollOrderPaid(orderNo, 10, 3000).then(function (data) {
if (data && data.status === 'paid') {
return soonLoadMembership(true).then(function () {
soonRefreshPortalIdentity(); soonRefreshPortalIdentity();
soonToast('支付成功,会员已激活', 'success'); soonToast('支付成功,会员已激活', 'success');
}); });
} }
if (data && data.status === 'cancelled') {
soonToast('订单已取消', 'warn');
return;
}
soonToast('支付尚未完成,如已付款请稍后在会员中心查看', 'warn');
});
}
window.soonIsWebPortal = soonIsWebPortal; window.soonIsWebPortal = soonIsWebPortal;
window.soonParseFileKey = soonParseFileKey; window.soonParseFileKey = soonParseFileKey;
@@ -783,6 +828,8 @@
window.soonTryConsumeCloudImport = soonTryConsumeCloudImport; window.soonTryConsumeCloudImport = soonTryConsumeCloudImport;
window.soonClearPendingImport = soonClearPendingImport; window.soonClearPendingImport = soonClearPendingImport;
window.soonReportOpenLoadError = soonReportOpenLoadError; window.soonReportOpenLoadError = soonReportOpenLoadError;
window.soonShowOpenFileLoading = soonShowOpenFileLoading;
window.soonHideOpenFileLoading = soonHideOpenFileLoading;
window.soonParseTemplateKey = soonParseTemplateKey; window.soonParseTemplateKey = soonParseTemplateKey;
window.soonMakeTemplateKey = soonMakeTemplateKey; window.soonMakeTemplateKey = soonMakeTemplateKey;
window.soonSoonTypeFromJson = soonSoonTypeFromJson; window.soonSoonTypeFromJson = soonSoonTypeFromJson;
+115 -15
View File
@@ -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 =
'<p style="font-family:sans-serif;text-align:center;padding:48px 24px;color:#333">正在跳转支付宝…</p>';
}
return w;
} catch (e) {
return null;
}
}
function submitAlipayForm(payForm, payWindow) {
if (!payForm || !payForm.gateway || !payForm.params) return false; if (!payForm || !payForm.gateway || !payForm.params) return false;
if (!isSafeAlipayGateway(payForm.gateway)) 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'); var form = document.createElement('form');
form.method = 'POST'; form.method = 'POST';
form.action = payForm.gateway; form.action = payForm.gateway;
form.target = ALIPAY_WINDOW_NAME;
form.acceptCharset = 'UTF-8'; form.acceptCharset = 'UTF-8';
form.style.display = 'none'; form.style.display = 'none';
Object.keys(payForm.params).forEach(function (key) { Object.keys(payForm.params).forEach(function (key) {
@@ -96,6 +118,11 @@
}); });
document.body.appendChild(form); document.body.appendChild(form);
form.submit(); form.submit();
setTimeout(function () {
try {
document.body.removeChild(form);
} catch (e) { /* ignore */ }
}, 0);
return true; return true;
} }
@@ -218,27 +245,34 @@
'<p class="soon-pay-checkout-empty__desc">点击下方按钮,将使用' + esc(ui.name) + '' + esc(ui.hint) + '</p></div>'; '<p class="soon-pay-checkout-empty__desc">点击下方按钮,将使用' + esc(ui.name) + '' + esc(ui.hint) + '</p></div>';
} }
function renderPayPayload(data, qrEl, orderNoEl) { function renderPayPayload(data, qrEl, orderNoEl, launchOpts) {
launchOpts = launchOpts || {};
if (orderNoEl && data.order_no) { if (orderNoEl && data.order_no) {
orderNoEl.textContent = '订单号 ' + data.order_no; orderNoEl.textContent = '订单号 ' + data.order_no;
orderNoEl.style.display = ''; orderNoEl.style.display = '';
} }
if (data.pay_form && data.pay_form.gateway) { if (data.pay_form && data.pay_form.gateway) {
if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效'; if (!isSafeAlipayGateway(data.pay_form.gateway)) return '支付网关无效';
sessionStorage.setItem('soon_pay_return', '1');
var payForm = data.pay_form; var payForm = data.pay_form;
var launched = launchOpts.launched === true;
qrEl.innerHTML = qrEl.innerHTML =
'<div class="soon-pay-checkout-ready soon-pay-checkout-ready--alipay">' + '<div class="soon-pay-checkout-ready soon-pay-checkout-ready--alipay">' +
'<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--alipay">支</div>' + '<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--alipay">支</div>' +
'<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' + '<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' +
'<p class="soon-pay-checkout-ready__desc">点击下方按钮跳转至支付宝收银台,支付完成后将自动返回</p>' + '<p class="soon-pay-checkout-ready__desc">' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">前往支付宝付款</button></div>'; (launched
? '支付窗口已打开,请在新窗口完成付款;本页将自动检测支付结果'
: '未能自动打开支付窗口,请允许弹窗后点击下方按钮') +
'</p>' +
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">' +
(launched ? '重新打开支付页面' : '打开支付宝付款') +
'</button></div>';
var alipayBtn = qrEl.querySelector('#goAlipay'); var alipayBtn = qrEl.querySelector('#goAlipay');
if (alipayBtn) { if (alipayBtn) {
alipayBtn.onclick = function () { alipayBtn.onclick = function () {
if (!submitAlipayForm(payForm)) { if (!submitAlipayForm(payForm)) {
if (typeof window.soonToast === 'function') { if (typeof window.soonToast === 'function') {
window.soonToast('无法跳转支付宝,请检查支付配置', 'warn'); window.soonToast('无法打开支付窗口,请允许浏览器弹窗', 'warn');
} }
} }
}; };
@@ -298,6 +332,9 @@
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml(channel) + '</div></section>' + '<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml(channel) + '</div></section>' +
'<div class="soon-pay-sheet__foot">' + '<div class="soon-pay-sheet__foot">' +
'<p class="soon-pay-status" id="payStatus"></p>' + '<p class="soon-pay-status" id="payStatus"></p>' +
'<div class="soon-pay-poll" id="payPollHint" aria-live="polite">' +
'<span class="soon-pay-poll__spin" aria-hidden="true"></span>' +
'<span class="soon-pay-poll__text">正在检测支付结果</span></div>' +
'<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' + '<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' +
(resumeOrderNo ? '继续支付' : '确认并支付') + '</button>' + (resumeOrderNo ? '继续支付' : '确认并支付') + '</button>' +
'<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__cta soon-pay-sheet__cta--secondary" id="payManualRefresh" style="display:none">已完成支付,刷新状态</button>' + '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__cta soon-pay-sheet__cta--secondary" id="payManualRefresh" style="display:none">已完成支付,刷新状态</button>' +
@@ -322,10 +359,14 @@
var manualBtn = sheetRoot.querySelector('#payManualRefresh'); var manualBtn = sheetRoot.querySelector('#payManualRefresh');
var statusEl = sheetRoot.querySelector('#payStatus'); var statusEl = sheetRoot.querySelector('#payStatus');
var pollHint = sheetRoot.querySelector('#payPollHint');
var confirmBtn = sheetRoot.querySelector('#payConfirm'); var confirmBtn = sheetRoot.querySelector('#payConfirm');
var qrEl = sheetRoot.querySelector('#payQr'); var qrEl = sheetRoot.querySelector('#payQr');
var orderNoEl = sheetRoot.querySelector('#payOrderNo'); var orderNoEl = sheetRoot.querySelector('#payOrderNo');
var backBtn = sheetRoot.querySelector('#payBack'); var backBtn = sheetRoot.querySelector('#payBack');
var checking = false;
var pollTickTimer = null;
var MANUAL_REFRESH_LABEL = '已完成支付,刷新状态';
function setStatus(text, type) { function setStatus(text, type) {
if (!statusEl) return; if (!statusEl) return;
@@ -334,6 +375,20 @@
statusEl.style.display = text ? 'block' : 'none'; 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) { function setStep(step) {
updatePayStep(sheetRoot, step, channel); updatePayStep(sheetRoot, step, channel);
sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2); sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2);
@@ -346,18 +401,21 @@
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
setPolling(false);
} }
function startPoll() { function startPoll() {
stopPoll(); stopPoll();
setPolling(true);
pollStartedAt = Date.now(); pollStartedAt = Date.now();
checkOrderStatus(false);
pollTimer = setInterval(function () { pollTimer = setInterval(function () {
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) { if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
stopPoll(); stopPoll();
setStatus('自动检测超时,如已支付请点击下方按钮', 'err'); setStatus('自动检测超时,如已支付请点击下方按钮', 'err');
return; return;
} }
checkOrderStatus(); checkOrderStatus(false);
}, 3000); }, 3000);
} }
@@ -368,14 +426,37 @@
setTimeout(onPaid, 600); setTimeout(onPaid, 600);
} }
function checkOrderStatus() { function checkOrderStatus(manual) {
if (!orderNo) return; 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) { 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() { function resetPaymentView() {
paymentReady = false; paymentReady = false;
@@ -391,9 +472,13 @@
setStatus(''); setStatus('');
} }
function applyCheckout(data) { function applyCheckout(data, payWindow) {
orderNo = data.order_no; 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) { if (err) {
setStatus(err, 'err'); setStatus(err, 'err');
return; return;
@@ -402,7 +487,14 @@
setStep(2); setStep(2);
if (confirmBtn) confirmBtn.style.display = 'none'; if (confirmBtn) confirmBtn.style.display = 'none';
if (manualBtn) manualBtn.style.display = ''; if (manualBtn) manualBtn.style.display = '';
setStatus('等待支付结果,完成后将自动刷新', 'ok'); if (channel === 'alipay') {
setStatus(
launched ? '支付窗口已打开,请在新窗口完成付款' : '未能自动打开支付窗口,请允许弹窗或点击下方按钮',
launched ? 'ok' : 'err'
);
} else {
setStatus('请扫码完成支付,本页将自动检测支付结果', 'ok');
}
startPoll(); startPoll();
} }
@@ -411,6 +503,7 @@
setStatus('正在生成付款信息…'); setStatus('正在生成付款信息…');
qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' + qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' +
'<p>正在连接支付渠道…</p></div>'; '<p>正在连接支付渠道…</p></div>';
var payWindow = channel === 'alipay' ? openAlipayPayWindow() : null;
var req; var req;
if (resumeOrderNo || orderNo) { if (resumeOrderNo || orderNo) {
var existingNo = orderNo || resumeOrderNo; var existingNo = orderNo || resumeOrderNo;
@@ -421,13 +514,15 @@
req.then(function (j) { req.then(function (j) {
if (confirmBtn) confirmBtn.disabled = false; if (confirmBtn) confirmBtn.disabled = false;
if (!j.ok) { if (!j.ok) {
if (payWindow && !payWindow.closed) payWindow.close();
setStatus(j.message || '下单失败', 'err'); setStatus(j.message || '下单失败', 'err');
qrEl.innerHTML = payEmptyStateHtml(channel); qrEl.innerHTML = payEmptyStateHtml(channel);
return; return;
} }
applyCheckout(j.data); applyCheckout(j.data, payWindow);
}).catch(function () { }).catch(function () {
if (confirmBtn) confirmBtn.disabled = false; if (confirmBtn) confirmBtn.disabled = false;
if (payWindow && !payWindow.closed) payWindow.close();
setStatus('网络错误,请重试', 'err'); setStatus('网络错误,请重试', 'err');
qrEl.innerHTML = payEmptyStateHtml(channel); qrEl.innerHTML = payEmptyStateHtml(channel);
}); });
@@ -448,6 +543,10 @@
function destroy() { function destroy() {
stopPoll(); stopPoll();
if (pollTickTimer) {
clearTimeout(pollTickTimer);
pollTickTimer = null;
}
paymentReady = false; paymentReady = false;
orderNo = resumeOrderNo; orderNo = resumeOrderNo;
} }
@@ -537,6 +636,7 @@
payEmptyStateHtml: payEmptyStateHtml, payEmptyStateHtml: payEmptyStateHtml,
renderPayPayload: renderPayPayload, renderPayPayload: renderPayPayload,
submitAlipayForm: submitAlipayForm, submitAlipayForm: submitAlipayForm,
openAlipayPayWindow: openAlipayPayWindow,
loadDisplayChannels: loadDisplayChannels, loadDisplayChannels: loadDisplayChannels,
displayChannels: displayChannels, displayChannels: displayChannels,
defaultChannel: defaultChannel, defaultChannel: defaultChannel,
+20 -6
View File
@@ -77,28 +77,42 @@
? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } }) ? window.soonAuthedFetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } })
: fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } }); : fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
meFetch 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) { .then(function (j) {
if (!j) return;
if (!j.ok || !j.data) { if (!j.ok || !j.data) {
clearSession(); clearSession();
location.reload(); location.reload();
return; return;
} }
if (typeof window.soonApplyMembership === 'function') {
window.soonApplyMembership(j.data);
}
if (j.data.role === 'admin' && admin) admin.style.display = 'inline-flex'; if (j.data.role === 'admin' && admin) admin.style.display = 'inline-flex';
if (avatar && j.data.email) { if (avatar && j.data.email) {
avatar.style.display = 'inline-flex'; avatar.style.display = 'inline-flex';
avatar.textContent = j.data.email.charAt(0).toUpperCase(); avatar.textContent = j.data.email.charAt(0).toUpperCase();
avatar.title = j.data.email; avatar.title = j.data.email;
} }
if (typeof window.soonLoadMembership === 'function') {
window.soonLoadMembership().then(function () {
if (typeof window.soonRefreshPortalIdentity === 'function') { if (typeof window.soonRefreshPortalIdentity === 'function') {
window.soonRefreshPortalIdentity(); 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) { if (logout) {
logout.addEventListener('click', function (e) { logout.addEventListener('click', function (e) {
+3
View File
@@ -1192,7 +1192,9 @@ window.openFile = function open(file, jAlready) {
return; return;
} }
if (window.platformBridge && window.platformBridge.readJsonFile) { if (window.platformBridge && window.platformBridge.readJsonFile) {
if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading();
return window.platformBridge.readJsonFile(file).then(function(j) { return window.platformBridge.readJsonFile(file).then(function(j) {
if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
if (j) { if (j) {
doOpenWithJson(j); doOpenWithJson(j);
openAs.name = file; openAs.name = file;
@@ -1201,6 +1203,7 @@ window.openFile = function open(file, jAlready) {
} }
}).catch(function () { }).catch(function () {
if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败');
else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
}); });
} }
try { try {
+3
View File
@@ -1517,7 +1517,9 @@ window.openFile = function open(file, jAlready) {
return; return;
} }
if (window.platformBridge && window.platformBridge.readJsonFile) { if (window.platformBridge && window.platformBridge.readJsonFile) {
if (typeof window.soonShowOpenFileLoading === 'function') window.soonShowOpenFileLoading();
return window.platformBridge.readJsonFile(file).then(function(j) { return window.platformBridge.readJsonFile(file).then(function(j) {
if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
if (j) { if (j) {
doOpenWithJson(j); doOpenWithJson(j);
openAs.name = file; openAs.name = file;
@@ -1526,6 +1528,7 @@ window.openFile = function open(file, jAlready) {
} }
}).catch(function () { }).catch(function () {
if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败'); if (typeof window.soonReportOpenLoadError === 'function') window.soonReportOpenLoadError('模板/文件加载失败');
else if (typeof window.soonHideOpenFileLoading === 'function') window.soonHideOpenFileLoading();
}); });
} }
try { try {
+12 -4
View File
@@ -351,14 +351,22 @@ layui.use(['layer', 'form', 'jquery'], function () {
var start = (fileListState.page - 1) * fileListState.size; var start = (fileListState.page - 1) * fileListState.size;
var slice = mapped.slice(start, start + 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 = ""; let h = "";
for (let item of slice) { for (let row of thumbRows) {
let item = row.item;
let filePath = item.filePath; let filePath = item.filePath;
let realType = item.type || 1; let realType = item.type || 1;
let src = await resolveRecentThumb(item); let src = row.src;
let fallbackSrc = recentFallbackThumb(realType); let fallbackSrc = row.fallbackSrc;
let displayName = item.name || displayNameFromPath(filePath); let displayName = item.name || displayNameFromPath(filePath);
let cardTitle = displayName; let cardTitle = displayName;
var cardClass = 'card'; var cardClass = 'card';
+47 -1
View File
@@ -161,6 +161,40 @@
.then(packCloudResult); .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) { function updateCloudFile(id, name, jsonStr, version) {
if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized')); if (!requireCloudAuth('保存')) return Promise.reject(new Error('unauthorized'));
var body = { name: normalizeSoonName(name), json: jsonStr }; var body = { name: normalizeSoonName(name), json: jsonStr };
@@ -170,7 +204,19 @@
headers: JSON_HEADERS, headers: JSON_HEADERS,
body: JSON.stringify(body) 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); .then(packCloudResult);
} }
-6
View File
@@ -873,7 +873,6 @@
<script src="../vendor/js/guideLines1.js"></script> <script src="../vendor/js/guideLines1.js"></script>
<script src="../vendor/js/print.min.js"></script> <script src="../vendor/js/print.min.js"></script>
<script src="../vendor/js/crypto-js.min.js"></script> <script src="../vendor/js/crypto-js.min.js"></script>
<script src="../vendor/js/jspdf.umd.min.js"></script>
<script src="../config/local.js"></script> <script src="../config/local.js"></script>
<script src="../js/common/auth-redirect.js"></script> <script src="../js/common/auth-redirect.js"></script>
<script src="../js/common/auth-session.js"></script> <script src="../js/common/auth-session.js"></script>
@@ -897,11 +896,6 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' });
if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false }); if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false });
if (window.soonLoadMembership) {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
});
}
</script> </script>
</body> </body>
-5
View File
@@ -689,11 +689,6 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { variant: 'design' });
if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false }); if (window.soonPortalAuth) soonPortalAuth.init({ logoutReload: false });
if (window.soonLoadMembership) {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
});
}
</script> </script>
</body> </body>
+3 -5
View File
@@ -157,13 +157,11 @@
<script> <script>
if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { active: 'home' }); if (window.SoonPortalTopbar) SoonPortalTopbar.mount('#portal-topbar', { active: 'home' });
soonPortalAuth.init(); soonPortalAuth.init({
if (window.soonLoadMembership) { onAuthed: function () {
window.soonLoadMembership().then(function () {
if (window.soonRefreshPortalIdentity) window.soonRefreshPortalIdentity();
if (window.soonReloadRecentFiles) window.soonReloadRecentFiles(); if (window.soonReloadRecentFiles) window.soonReloadRecentFiles();
});
} }
});
</script> </script>
</body> </body>
+58 -142
View File
@@ -1,57 +1,44 @@
@echo off @echo off
setlocal EnableDelayedExpansion setlocal EnableDelayedExpansion
rem keep window open when double-clicked
set "SELF=%~f0" set "SELF=%~f0"
if /i "%~1"=="__run__" ( if /i not "%~1"=="__run__" (
set "ACTION=%~2"
) else (
set "ACTION=%~1"
echo %CMDCMDLINE% | find /i " /c " >nul echo %CMDCMDLINE% | find /i " /c " >nul
if not errorlevel 1 ( if not errorlevel 1 (
cmd /k "%SELF%" __run__ %* cmd /k "%SELF%" __run__
exit /b exit /b
) )
) )
set "ROOT=%~dp0.." set "ROOT=%~dp0.."
for %%I in ("%ROOT%") do set "ROOT=%%~fI" 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 "COMPOSE=docker\docker-compose.yml"
set "ENV_FILE=docker\.env" set "ENV_FILE=docker\.env"
set "COMPOSE_PROJECT=soondesign" set "PROJECT=soondesign"
set "WEB_PORT=8100" set "WEB_PORT=8100"
set "ELECTRON_PORT=8101" set "ELECTRON_PORT=8101"
set "BACKEND_PORT=8102" set "BACKEND_PORT=8102"
if /i "%ACTION%"=="stop" goto :do_stop echo [SoonDesign] Local dev stack
if /i "%ACTION%"=="reset" goto :do_reset echo.
if /i "%ACTION%"=="rebuild" goto :do_rebuild
if /i "%ACTION%"=="logs" goto :do_logs
if /i "%ACTION%"=="status" goto :do_status
goto :do_start
:compose docker info >nul 2>&1
docker compose -p "%COMPOSE_PROJECT%" -f "%COMPOSE%" --env-file "%ENV_FILE%" %* if errorlevel 1 (
exit /b !ERRORLEVEL! echo [ERROR] Docker Desktop is not running.
: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%
exit /b 1 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 "%ROOT%\soonModels\" mkdir "%ROOT%\soonModels"
if not exist "%ENV_FILE%" ( if not exist "%ENV_FILE%" (
if not exist "docker\.env.example" ( if not exist "docker\.env.example" (
@@ -60,34 +47,54 @@ if not exist "%ENV_FILE%" (
) )
copy /Y "docker\.env.example" "%ENV_FILE%" >nul copy /Y "docker\.env.example" "%ENV_FILE%" >nul
) )
powershell -NoProfile -ExecutionPolicy Bypass -File "docker\sync-config.ps1" -EnvFile "%ENV_FILE%" powershell -NoProfile -ExecutionPolicy Bypass -File "docker\sync-config.ps1" -EnvFile "%ENV_FILE%"
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] Config sync failed. echo [ERROR] Config sync failed.
exit /b 1 exit /b 1
) )
call :load_ports
exit /b 0
:wait_api for /f "usebackq eol=# tokens=1,* delims==" %%A in ("%ENV_FILE%") do (
echo [INFO] Waiting for API http://localhost:%BACKEND_PORT%/health ... if /i "%%A"=="WEB_PORT" set "WEB_PORT=%%B"
set /a RETRY=0 if /i "%%A"=="ELECTRON_PORT" set "ELECTRON_PORT=%%B"
:wait_health if /i "%%A"=="BACKEND_PORT" set "BACKEND_PORT=%%B"
set /a RETRY+=1 )
curl.exe -sf "http://localhost:%BACKEND_PORT%/health" >nul 2>&1
if not errorlevel 1 exit /b 0 echo [INFO] Ports Web=%WEB_PORT% Electron=%ELECTRON_PORT% API=%BACKEND_PORT%
if !RETRY! GEQ 90 (
echo [WARN] API not ready after ~3 min. Run: scripts\start.bat logs 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 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 ======================================== echo ========================================
echo Project: %COMPOSE_PROJECT%
echo Web: http://localhost:%WEB_PORT%/pages/index.web.html echo Web: http://localhost:%WEB_PORT%/pages/index.web.html
echo Admin: http://localhost:%WEB_PORT%/pages/admin/index.html echo Admin: http://localhost:%WEB_PORT%/pages/admin/index.html
echo Electron: http://localhost:%ELECTRON_PORT%/pages/index.html echo Electron: http://localhost:%ELECTRON_PORT%/pages/index.html
@@ -95,98 +102,7 @@ echo API: http://localhost:%BACKEND_PORT%/health
echo ======================================== echo ========================================
echo Login: admin@local.test / admin123 echo Login: admin@local.test / admin123
echo Desktop: cd frontend-electron ^&^& npm start echo Desktop: cd frontend-electron ^&^& npm start
echo Stop: scripts\start.bat stop echo Stop: docker compose -p %PROJECT% -f docker\docker-compose.yml down
echo Logs: scripts\start.bat logs
exit /b 0
:do_stop
if exist "%ENV_FILE%" (call :compose down) else (call :compose_noenv down)
echo. echo.
echo [OK] Stopped project %COMPOSE_PROJECT%. DB volume kept. echo [DONE] Edit frontend-web/backend-web files and refresh browser to debug.
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%).
exit /b 0 exit /b 0