重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function computeAssetBase() {
|
||||
if (typeof window === 'undefined' || !window.location) {
|
||||
return 'assets/images/';
|
||||
}
|
||||
var p = window.location.pathname || '/';
|
||||
if (p.indexOf('/pages/') !== -1 || /\.web\.html$/i.test(p) || /\/pages\/[^/]+\.html$/i.test(p)) {
|
||||
return '../assets/images/';
|
||||
}
|
||||
if (p.endsWith('/pages/') || p.endsWith('/pages')) {
|
||||
return '../assets/images/';
|
||||
}
|
||||
return 'assets/images/';
|
||||
}
|
||||
|
||||
function computeJsBase() {
|
||||
if (typeof window === 'undefined' || !window.location) {
|
||||
return 'js/';
|
||||
}
|
||||
var p = window.location.pathname || '/';
|
||||
if (p.indexOf('/pages/') !== -1 || /\.web\.html$/i.test(p) || /\/pages\/[^/]+\.html$/i.test(p)) {
|
||||
return '../js/';
|
||||
}
|
||||
return 'js/';
|
||||
}
|
||||
|
||||
window.SOON_ASSET_BASE = computeAssetBase();
|
||||
window.SOON_JS_BASE = computeJsBase();
|
||||
|
||||
window.soonAsset = function (name) {
|
||||
var n = String(name || '').replace(/^\/+/, '');
|
||||
return window.SOON_ASSET_BASE + n;
|
||||
};
|
||||
|
||||
window.soonJs = function (rel) {
|
||||
var n = String(rel || '').replace(/^\/+/, '');
|
||||
return window.SOON_JS_BASE + n;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ALLOWED = /^(index\.web\.html|member\.web\.html|login\.web\.html|register\.web\.html|admin\/(?:index|login)\.html|index\.html|login\.html|design[12]\.web\.html)(\?.*)?$/i;
|
||||
|
||||
function soonEscapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function soonSafeRedirect(urlParam, fallback) {
|
||||
fallback = fallback || 'index.web.html';
|
||||
if (!urlParam || typeof urlParam !== 'string') return fallback;
|
||||
var raw = urlParam.trim();
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
raw = decodeURIComponent(raw);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
var lower = raw.toLowerCase();
|
||||
if (lower.indexOf('//') === 0 || lower.indexOf('http:') === 0 || lower.indexOf('https:') === 0 ||
|
||||
lower.indexOf('javascript:') === 0 || raw.indexOf('..') !== -1 || raw.indexOf('\\') !== -1) {
|
||||
return fallback;
|
||||
}
|
||||
var pathOnly = raw.split(/[#?]/)[0];
|
||||
if (ALLOWED.test(pathOnly)) return raw;
|
||||
try {
|
||||
var resolved = new URL(raw, window.location.href);
|
||||
if (resolved.origin !== window.location.origin) return fallback;
|
||||
var path = resolved.pathname.replace(/^.*\/pages\//, '').replace(/^\//, '');
|
||||
if (!ALLOWED.test(path.split(/[#?]/)[0])) return fallback;
|
||||
return path + resolved.search + resolved.hash;
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function soonSafeImageUrl(url, fallback) {
|
||||
if (!url || typeof url !== 'string') return fallback || '';
|
||||
var raw = url.trim();
|
||||
if (!raw) return fallback || '';
|
||||
var lower = raw.toLowerCase();
|
||||
if (lower.indexOf('javascript:') === 0 || lower.indexOf('data:text/html') === 0 ||
|
||||
lower.indexOf('vbscript:') === 0 || lower.indexOf('//') === 0) {
|
||||
return fallback || '';
|
||||
}
|
||||
if (lower.indexOf('data:image/') === 0) return raw;
|
||||
if (lower.indexOf('blob:') === 0) return raw;
|
||||
if (lower.indexOf('https:') === 0) return raw;
|
||||
if (lower.indexOf('http:') === 0) {
|
||||
try {
|
||||
var apiBase = (typeof window !== 'undefined' && window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base);
|
||||
if (apiBase && new URL(raw).origin === new URL(apiBase).origin) return raw;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
if (raw.indexOf('../') === 0 || raw.indexOf('./') === 0 || raw.indexOf('/') === 0) {
|
||||
try {
|
||||
var resolved = new URL(raw, window.location.href);
|
||||
if (resolved.origin === window.location.origin) return raw;
|
||||
} catch (e) { /* fall through */ }
|
||||
}
|
||||
return fallback || '';
|
||||
}
|
||||
|
||||
window.soonEscapeHtml = soonEscapeHtml;
|
||||
window.soonSafeRedirect = soonSafeRedirect;
|
||||
window.soonSafeImageUrl = soonSafeImageUrl;
|
||||
})();
|
||||
@@ -0,0 +1,107 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var refreshPromise = null;
|
||||
|
||||
function getAccessToken() {
|
||||
try { return localStorage.getItem('soon_access') || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
try { return localStorage.getItem('soon_refresh') || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try {
|
||||
localStorage.removeItem('soon_access');
|
||||
localStorage.removeItem('soon_refresh');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
var cfg = (typeof window !== 'undefined' && window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base);
|
||||
if (cfg) return String(cfg).replace(/\/+$/, '');
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.origin + '/api/v1';
|
||||
}
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
var path = 'login.web.html';
|
||||
try {
|
||||
var current = window.location.pathname.replace(/^.*\/pages\//, '').replace(/^\//, '');
|
||||
if (current && current.indexOf('login.web.html') !== 0) {
|
||||
var redirect = typeof soonSafeRedirect === 'function'
|
||||
? soonSafeRedirect(current + window.location.search, 'index.web.html')
|
||||
: 'index.web.html';
|
||||
path = 'login.web.html?redirect=' + encodeURIComponent(redirect);
|
||||
}
|
||||
} catch (e) { /* use default */ }
|
||||
window.location.href = path;
|
||||
}
|
||||
|
||||
function refreshAccessToken() {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
var refresh = getRefreshToken();
|
||||
if (!refresh) {
|
||||
return Promise.reject(new Error('no_refresh_token'));
|
||||
}
|
||||
refreshPromise = fetch(getApiBase() + '/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
if (!r.ok || !j.ok || !j.data || !j.data.access_token) {
|
||||
throw new Error(j.message || 'refresh_failed');
|
||||
}
|
||||
localStorage.setItem('soon_access', j.data.access_token);
|
||||
if (j.data.refresh_token) {
|
||||
localStorage.setItem('soon_refresh', j.data.refresh_token);
|
||||
}
|
||||
return j.data.access_token;
|
||||
});
|
||||
}).finally(function () {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function soonAuthedFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
var headers = Object.assign({}, opts.headers || {});
|
||||
var token = getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
} else {
|
||||
delete headers['Authorization'];
|
||||
delete headers['authorization'];
|
||||
}
|
||||
opts.headers = headers;
|
||||
|
||||
var fullUrl = url;
|
||||
if (url.indexOf('http') !== 0) {
|
||||
fullUrl = getApiBase() + '/' + String(url).replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
return fetch(fullUrl, opts).then(function (response) {
|
||||
if (response.status !== 401) return response;
|
||||
return refreshAccessToken().then(function (newToken) {
|
||||
var retryHeaders = Object.assign({}, opts.headers || {});
|
||||
retryHeaders['Authorization'] = 'Bearer ' + newToken;
|
||||
var retryOpts = Object.assign({}, opts, { headers: retryHeaders });
|
||||
return fetch(fullUrl, retryOpts);
|
||||
}).catch(function () {
|
||||
clearSession();
|
||||
redirectToLogin();
|
||||
return response;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.soonAuthedFetch = soonAuthedFetch;
|
||||
window.soonClearAuthSession = clearSession;
|
||||
window.soonGetAccessToken = getAccessToken;
|
||||
window.soonRedirectToLogin = redirectToLogin;
|
||||
})();
|
||||
@@ -0,0 +1,312 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FILE_PREFIX = 'soondesign_file:';
|
||||
|
||||
function soonIsWebPortal() {
|
||||
return typeof window !== 'undefined' && window.platformBridge && !window.fs;
|
||||
}
|
||||
|
||||
function soonGetAccessToken() {
|
||||
if (typeof window.soonGetAccessToken === 'function') {
|
||||
return window.soonGetAccessToken();
|
||||
}
|
||||
try { return localStorage.getItem('soon_access') || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function soonParseFileKey(key) {
|
||||
if (!key || typeof key !== 'string') return null;
|
||||
var m = key.match(/^soondesign_file:(\d+)(?::v(\d+))?$/);
|
||||
if (!m) return null;
|
||||
return { id: parseInt(m[1], 10), version: m[2] ? parseInt(m[2], 10) : null };
|
||||
}
|
||||
|
||||
function soonMakeFileKey(id, version) {
|
||||
return FILE_PREFIX + id + ':v' + version;
|
||||
}
|
||||
|
||||
function soonNormalizeSoonName(pathOrName) {
|
||||
var n = String(pathOrName || 'design.soon');
|
||||
if (n.indexOf(FILE_PREFIX) === 0) {
|
||||
var meta = window._soonFileMeta;
|
||||
return (meta && meta.name) ? meta.name : 'design.soon';
|
||||
}
|
||||
if (n.indexOf('soondesign_session:') === 0) {
|
||||
n = n.replace(/^soondesign_session:/, '');
|
||||
}
|
||||
n = n.split(/[/\\]/).pop();
|
||||
if (!/\.soon$/i.test(n)) n = (n.replace(/\.soon$/i, '') || 'design') + '.soon';
|
||||
return n;
|
||||
}
|
||||
|
||||
function soonApplyCloudMeta(data) {
|
||||
if (!data || !data.id) return null;
|
||||
window._soonFileMeta = { id: data.id, version: data.version, name: data.name };
|
||||
return soonMakeFileKey(data.id, data.version);
|
||||
}
|
||||
|
||||
function soonBindFileMeta(fileKey, opts) {
|
||||
if (!fileKey || fileKey.indexOf(FILE_PREFIX) !== 0) return;
|
||||
var parsed = soonParseFileKey(fileKey);
|
||||
if (!parsed) return;
|
||||
var prev = window._soonFileMeta || {};
|
||||
window._soonFileMeta = {
|
||||
id: parsed.id,
|
||||
version: parsed.version != null ? parsed.version : prev.version,
|
||||
name: (opts && opts.name) || (prev.id === parsed.id ? prev.name : '') || ''
|
||||
};
|
||||
}
|
||||
|
||||
function soonFormatOpenTitle(name) {
|
||||
if (!name) return '';
|
||||
if (typeof window.soonDisplayFileName === 'function') return window.soonDisplayFileName(name);
|
||||
return String(name).split(/[/\\]/).pop();
|
||||
}
|
||||
|
||||
var _membership = {
|
||||
loaded: false,
|
||||
isMember: false,
|
||||
name: '免费版',
|
||||
tier: 'free',
|
||||
expiresAt: null,
|
||||
loading: null,
|
||||
};
|
||||
|
||||
function soonApiBase() {
|
||||
var cfg = (typeof window !== 'undefined' && window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base);
|
||||
if (cfg) return String(cfg).replace(/\/+$/, '');
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.origin + '/api/v1';
|
||||
}
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
function formatExpireDate(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(String(iso).replace(' ', 'T'));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
var y = d.getFullYear();
|
||||
var mo = String(d.getMonth() + 1);
|
||||
var day = String(d.getDate());
|
||||
if (mo.length < 2) mo = '0' + mo;
|
||||
if (day.length < 2) day = '0' + day;
|
||||
return y + '-' + mo + '-' + day;
|
||||
}
|
||||
|
||||
function soonApplyMembership(data) {
|
||||
var m = data || {};
|
||||
var sub = m.subscription || {};
|
||||
var isMember = m.is_member === true
|
||||
|| m.tier === 'member'
|
||||
|| m.tier === 'pro'
|
||||
|| (sub.status && sub.status !== 'free' && sub.status !== 'expired');
|
||||
_membership = {
|
||||
loaded: true,
|
||||
isMember: !!isMember,
|
||||
name: m.name || (isMember ? '订阅版' : '免费版'),
|
||||
tier: m.tier || (isMember ? 'member' : 'free'),
|
||||
expiresAt: sub.expires_at || null,
|
||||
loading: null,
|
||||
};
|
||||
if (typeof window.soonRefreshPortalIdentity === 'function') {
|
||||
window.soonRefreshPortalIdentity();
|
||||
}
|
||||
return _membership;
|
||||
}
|
||||
|
||||
function soonRefreshPortalIdentity() {
|
||||
var el = document.getElementById('auth_identity');
|
||||
if (!el) return;
|
||||
if (!soonGetAccessToken()) {
|
||||
el.style.display = 'none';
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!_membership.loaded) {
|
||||
soonLoadMembership(false).then(function () {
|
||||
soonRefreshPortalIdentity();
|
||||
});
|
||||
return;
|
||||
}
|
||||
var text;
|
||||
var cls = 'soon-portal-identity';
|
||||
if (_membership.isMember) {
|
||||
cls += ' soon-portal-identity--member';
|
||||
text = _membership.name || '订阅版';
|
||||
if (_membership.expiresAt) {
|
||||
text += ' · 至 ' + formatExpireDate(_membership.expiresAt);
|
||||
}
|
||||
} else {
|
||||
cls += ' soon-portal-identity--free';
|
||||
text = _membership.name || '免费版';
|
||||
}
|
||||
el.className = cls;
|
||||
el.textContent = text;
|
||||
el.style.display = 'inline-flex';
|
||||
var account = document.getElementById('auth_account');
|
||||
if (account) account.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function soonLoadMembership(force) {
|
||||
if (_membership.loaded && !force) return Promise.resolve(_membership);
|
||||
if (_membership.loading && !force) return _membership.loading;
|
||||
if (!soonGetAccessToken()) {
|
||||
soonApplyMembership(null);
|
||||
return Promise.resolve(_membership);
|
||||
}
|
||||
var url = soonApiBase() + '/plans/me';
|
||||
var fetchFn = typeof window.soonAuthedFetch === 'function'
|
||||
? window.soonAuthedFetch(url, { headers: { Accept: 'application/json' } })
|
||||
: fetch(url, { headers: { Authorization: 'Bearer ' + soonGetAccessToken(), Accept: 'application/json' } });
|
||||
_membership.loading = fetchFn
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (j && j.ok && j.data) {
|
||||
soonApplyMembership(j.data.membership || j.data);
|
||||
} else {
|
||||
soonApplyMembership(null);
|
||||
}
|
||||
return _membership;
|
||||
})
|
||||
.catch(function () {
|
||||
soonApplyMembership(null);
|
||||
return _membership;
|
||||
})
|
||||
.finally(function () {
|
||||
_membership.loading = null;
|
||||
});
|
||||
return _membership.loading;
|
||||
}
|
||||
|
||||
function soonIsMember() {
|
||||
return !!_membership.isMember;
|
||||
}
|
||||
|
||||
function soonMemberPageUrl() {
|
||||
return 'member.web.html';
|
||||
}
|
||||
|
||||
function soonRequireMember(actionLabel) {
|
||||
if (!soonGetAccessToken()) return soonRequireLogin(actionLabel);
|
||||
if (soonIsMember()) return true;
|
||||
if (typeof window.soonShowSubscribeGate === 'function') {
|
||||
window.soonShowSubscribeGate({ action: actionLabel || '使用' });
|
||||
} else {
|
||||
soonToast('订阅后可' + (actionLabel || '使用'), 'warn');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function soonGuardCloudSave() {
|
||||
if (!soonIsWebPortal()) return true;
|
||||
return soonRequireMember('保存');
|
||||
}
|
||||
|
||||
function soonGuardMemberPreview() {
|
||||
if (!soonIsWebPortal()) return true;
|
||||
return soonRequireMember('预览效果');
|
||||
}
|
||||
|
||||
function soonGuardMemberExport() {
|
||||
if (!soonIsWebPortal()) return true;
|
||||
return soonRequireMember('导出');
|
||||
}
|
||||
|
||||
function soonToast(message, type) {
|
||||
if (typeof window.soonToast === 'function') window.soonToast(message, type);
|
||||
}
|
||||
|
||||
function soonRequireLogin(actionLabel) {
|
||||
if (soonGetAccessToken()) return true;
|
||||
var label = actionLabel || '继续';
|
||||
soonToast('请先登录后再' + label, 'warn');
|
||||
if (typeof window.soonRedirectToLogin === 'function') {
|
||||
setTimeout(function () { window.soonRedirectToLogin(); }, 600);
|
||||
} else if (typeof window.redirectToLogin === 'function') {
|
||||
setTimeout(function () { window.redirectToLogin(); }, 600);
|
||||
} else {
|
||||
window.location.href = 'login.web.html';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function soonParseApiError(response, bodyText) {
|
||||
var msg = '操作失败,请稍后重试';
|
||||
var code = '';
|
||||
try {
|
||||
var j = bodyText ? JSON.parse(bodyText) : null;
|
||||
if (j && j.message) msg = j.message;
|
||||
if (j && j.error) code = j.error;
|
||||
} catch (e) { /* ignore */ }
|
||||
if (response && response.status === 401) {
|
||||
return { status: 401, message: '请先登录', code: code || 'unauthorized' };
|
||||
}
|
||||
if (response && response.status === 409) {
|
||||
return { status: 409, message: '文件已被其他端修改,请刷新后重试', code: code || 'conflict' };
|
||||
}
|
||||
if (response && response.status === 403 && (code === 'membership_required' || msg.indexOf('订阅') >= 0 || msg.indexOf('会员') >= 0)) {
|
||||
return { status: 403, message: msg || '此功能需要订阅后使用', code: code || 'membership_required' };
|
||||
}
|
||||
if (response && response.status === 413) {
|
||||
if (code === 'file_limit_exceeded') {
|
||||
return { status: 413, message: msg || '文件数量已达上限,请清理文件或续订', code: code };
|
||||
}
|
||||
return { status: 413, message: msg || '存储空间不足,请清理文件或续订', code: code || 'quota_exceeded' };
|
||||
}
|
||||
if (response && response.status === 404) {
|
||||
return { status: 404, message: '文件不存在', code: code || 'not_found' };
|
||||
}
|
||||
return { status: response ? response.status : 0, message: msg, code: code };
|
||||
}
|
||||
|
||||
function soonShowApiError(info) {
|
||||
if (!info) return;
|
||||
var kind = 'error';
|
||||
if (info.status === 401) kind = 'warn';
|
||||
else if (info.status === 409) kind = 'warn';
|
||||
else if (info.status === 413) kind = 'warn';
|
||||
else if (info.status === 403 && info.code === 'membership_required') kind = 'warn';
|
||||
soonToast(info.message, kind);
|
||||
if (info.status === 401) {
|
||||
soonRequireLogin('操作');
|
||||
} else if (info.status === 403 && info.code === 'membership_required') {
|
||||
if (typeof window.soonShowSubscribeGate === 'function') {
|
||||
window.soonShowSubscribeGate({ action: '使用' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function soonDisplayFileName(pathOrKey) {
|
||||
if (!pathOrKey) return '';
|
||||
if (pathOrKey.indexOf(FILE_PREFIX) === 0) {
|
||||
var meta = window._soonFileMeta;
|
||||
if (meta && meta.name) return meta.name;
|
||||
var parsed = soonParseFileKey(pathOrKey);
|
||||
return parsed ? ('文件 #' + parsed.id) : pathOrKey;
|
||||
}
|
||||
if (pathOrKey.indexOf('soondesign_session:') === 0) {
|
||||
return pathOrKey.replace(/^soondesign_session:/, '');
|
||||
}
|
||||
return String(pathOrKey).split(/[/\\]/).pop();
|
||||
}
|
||||
|
||||
window.soonIsWebPortal = soonIsWebPortal;
|
||||
window.soonParseFileKey = soonParseFileKey;
|
||||
window.soonMakeFileKey = soonMakeFileKey;
|
||||
window.soonNormalizeSoonName = soonNormalizeSoonName;
|
||||
window.soonApplyCloudMeta = soonApplyCloudMeta;
|
||||
window.soonBindFileMeta = soonBindFileMeta;
|
||||
window.soonFormatOpenTitle = soonFormatOpenTitle;
|
||||
window.soonGuardCloudSave = soonGuardCloudSave;
|
||||
window.soonGuardMemberPreview = soonGuardMemberPreview;
|
||||
window.soonGuardMemberExport = soonGuardMemberExport;
|
||||
window.soonLoadMembership = soonLoadMembership;
|
||||
window.soonApplyMembership = soonApplyMembership;
|
||||
window.soonRefreshPortalIdentity = soonRefreshPortalIdentity;
|
||||
window.soonIsMember = soonIsMember;
|
||||
window.soonRequireMember = soonRequireMember;
|
||||
window.soonRequireLogin = soonRequireLogin;
|
||||
window.soonParseApiError = soonParseApiError;
|
||||
window.soonShowApiError = soonShowApiError;
|
||||
window.soonDisplayFileName = soonDisplayFileName;
|
||||
})();
|
||||
@@ -0,0 +1,164 @@
|
||||
// Fabric.js 扩展类
|
||||
|
||||
(function (fabric) {
|
||||
fabric.CurvedText = fabric.util.createClass(fabric.Object, {
|
||||
type: 'curved-text',
|
||||
diameter: 0,
|
||||
kerning: 0,
|
||||
text: '',
|
||||
flipped: false,
|
||||
fill: '#000',
|
||||
fontFamily: 'Times New Roman',
|
||||
fontSize: 24, // in px
|
||||
fontWeight: 'normal',
|
||||
fontStyle: '', // "normal", "italic" or "oblique".
|
||||
cacheProperties: fabric.Object.prototype.cacheProperties.concat('diameter', 'textBackgroundColor', 'kerning', 'flipped', 'fill', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'strokeStyle', 'strokeWidth'),
|
||||
strokeStyle: null,
|
||||
strokeWidth: 0,
|
||||
initialize: function (text, options) {
|
||||
options || (options = {});
|
||||
if (typeof text === 'object' && text) {
|
||||
for (let key in text) {
|
||||
this[key] = text[key]
|
||||
}
|
||||
} else {
|
||||
this.text = text;
|
||||
}
|
||||
this.callSuper('initialize', options);
|
||||
this.set('lockUniScaling', true);
|
||||
var canvas = this.getCircularText();
|
||||
this.cropCanvas(canvas);
|
||||
this.set('width', canvas.width);
|
||||
this.set('height', canvas.height);
|
||||
},
|
||||
_getFontDeclaration: function () {
|
||||
return [
|
||||
(fabric.isLikelyNode ? this.fontWeight : this.fontStyle),
|
||||
(fabric.isLikelyNode ? this.fontStyle : this.fontWeight),
|
||||
this.fontSize + 'px',
|
||||
(fabric.isLikelyNode ? ('"' + this.fontFamily + '"') : this.fontFamily)
|
||||
].join(' ');
|
||||
},
|
||||
cropCanvas: function (canvas) {
|
||||
var ctx = canvas.getContext('2d'),
|
||||
w = canvas.width,
|
||||
h = canvas.height,
|
||||
pix = { x: [], y: [] }, n,
|
||||
imageData = ctx.getImageData(0, 0, w, h),
|
||||
fn = function (a, b) { return a - b };
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
if (imageData.data[((y * w + x) * 4) + 3] > 0) {
|
||||
pix.x.push(x);
|
||||
pix.y.push(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果没有找到任何非透明像素,直接返回,不进行裁剪
|
||||
if (pix.x.length === 0 || pix.y.length === 0) {
|
||||
return;
|
||||
}
|
||||
pix.x.sort(fn);
|
||||
pix.y.sort(fn);
|
||||
n = pix.x.length - 1;
|
||||
w = pix.x[n] - pix.x[0];
|
||||
h = pix.y[n] - pix.y[0];
|
||||
// 确保宽度和高度是有效的正数
|
||||
if (w <= 0 || h <= 0 || isNaN(w) || isNaN(h)) {
|
||||
return;
|
||||
}
|
||||
var cut = ctx.getImageData(pix.x[0], pix.y[0], w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.putImageData(cut, 0, 0);
|
||||
},
|
||||
getCircularText: function () {
|
||||
var text = this.text,
|
||||
diameter = this.diameter,
|
||||
flipped = this.flipped,
|
||||
kerning = this.kerning,
|
||||
fill = this.fill,
|
||||
inwardFacing = true,
|
||||
startAngle = 0,
|
||||
canvas = fabric.util.createCanvasElement(),
|
||||
ctx = canvas.getContext('2d'),
|
||||
cw, // character-width
|
||||
x, // iterator
|
||||
clockwise = -1; // draw clockwise for aligned right. Else Anticlockwise
|
||||
if (flipped) {
|
||||
// startAngle = 180;
|
||||
inwardFacing = false;
|
||||
}
|
||||
startAngle *= Math.PI / 180; // convert to radians
|
||||
var d = document.createElement('div');
|
||||
d.style.fontFamily = this.fontFamily;
|
||||
d.style.fontSize = this.fontSize + 'px';
|
||||
d.style.fontWeight = this.fontWeight;
|
||||
d.style.fontStyle = this.fontStyle;
|
||||
d.textContent = text;
|
||||
document.body.appendChild(d);
|
||||
var textHeight = d.offsetHeight;
|
||||
document.body.removeChild(d);
|
||||
canvas.width = canvas.height = diameter;
|
||||
ctx.font = this._getFontDeclaration();
|
||||
if (inwardFacing) { text = text.split('').reverse().join('') };
|
||||
ctx.translate(diameter / 2, diameter / 2); // Move to center
|
||||
startAngle += (Math.PI * !inwardFacing); // Rotate 180 if outward
|
||||
ctx.textBaseline = 'middle'; // Ensure we draw in exact center
|
||||
ctx.textAlign = 'center'; // Ensure we draw in exact center
|
||||
for (x = 0; x < text.length; x++) {
|
||||
cw = ctx.measureText(text[x]).width;
|
||||
startAngle += ((cw + (x == text.length - 1 ? 0 : kerning)) / (diameter / 2 - textHeight)) / 2 * -clockwise;
|
||||
}
|
||||
ctx.rotate(startAngle);
|
||||
for (x = 0; x < text.length; x++) {
|
||||
cw = ctx.measureText(text[x]).width; // half letter
|
||||
ctx.rotate((cw / 2) / (diameter / 2 - textHeight) * clockwise);
|
||||
if (this.strokeStyle && this.strokeWidth) {
|
||||
ctx.strokeStyle = this.strokeStyle;
|
||||
ctx.lineWidth = this.strokeWidth;
|
||||
ctx.miterLimit = 2;
|
||||
ctx.strokeText(text[x], 0, (inwardFacing ? 1 : -1) * (0 - diameter / 2 + textHeight / 2));
|
||||
}
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fillText(text[x], 0, (inwardFacing ? 1 : -1) * (0 - diameter / 2 + textHeight / 2));
|
||||
ctx.rotate((cw / 2 + kerning) / (diameter / 2 - textHeight) * clockwise); // rotate half letter
|
||||
}
|
||||
return canvas;
|
||||
},
|
||||
_set: function (key, value) {
|
||||
switch (key) {
|
||||
case 'scaleX':
|
||||
this.fontSize *= value;
|
||||
this.diameter *= value;
|
||||
this.width *= value;
|
||||
this.scaleX = 1;
|
||||
if (this.width < 1) { this.width = 1; }
|
||||
break;
|
||||
case 'scaleY':
|
||||
this.height *= value;
|
||||
this.scaleY = 1;
|
||||
if (this.height < 1) { this.height = 1; }
|
||||
break;
|
||||
default:
|
||||
this.callSuper('_set', key, value);
|
||||
break;
|
||||
}
|
||||
},
|
||||
_render: function (ctx) {
|
||||
var canvas = this.getCircularText();
|
||||
this.cropCanvas(canvas);
|
||||
this.set('width', canvas.width);
|
||||
this.set('height', canvas.height);
|
||||
ctx.drawImage(canvas, -this.width / 2, -this.height / 2, this.width, this.height);
|
||||
this.setCoords();
|
||||
},
|
||||
toObject: function (propertiesToInclude) {
|
||||
return this.callSuper('toObject', ['text', 'diameter', 'textBackgroundColor', 'kerning', 'flipped', 'fill', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'strokeStyle', 'strokeWidth'].concat(propertiesToInclude));
|
||||
}
|
||||
});
|
||||
fabric.CurvedText.fromObject = function (object, callback, forceAsync) {
|
||||
return fabric.Object._fromObject('CurvedText', object, callback, forceAsync, 'curved-text');
|
||||
};
|
||||
})(typeof fabric !== 'undefined' ? fabric : require('fabric').fabric);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 多语言支持模块
|
||||
|
||||
/**
|
||||
* 多语言切换
|
||||
*/
|
||||
function langua_ge(lan = 'zh') {
|
||||
$("[language='m']").each(function (i) {
|
||||
$(this).html($(this).attr(lan));
|
||||
});
|
||||
|
||||
$("[language='t']").each(function (i) {
|
||||
$(this).attr("title", $(this).attr(lan));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 多语言字符串获取
|
||||
* @param {string} str - 字符串键
|
||||
* @param {string} s_lan - 当前语言 ('zh', 'ozh', 'en')
|
||||
*/
|
||||
function language_str(str, s_lan) {
|
||||
let t = {
|
||||
"whetherSave": { zh: "是否保存当前文件?", ozh: "是否保存當前文件?", en: "Do you want to save the current file?" },
|
||||
"save": { zh: "保存", ozh: "保存", en: "Save" },
|
||||
"noSave": { zh: "不保存", ozh: "不保存", en: "No saving" },
|
||||
"cancel": { zh: "取消", ozh: "取消", en: "Cancel" },
|
||||
"saveSucc": { zh: "保存成功至", ozh: "保存成功至", en: "Save successfully to" },
|
||||
"saveFile": { zh: "保存文件", ozh: "保存文件", en: "Save file" },
|
||||
"display": { zh: "预览", ozh: "預覽", en: "Display" },
|
||||
"output": { zh: "导出", ozh: "導出", en: "Export" },
|
||||
"bg": { zh: "背景", ozh: "背景", en: "Background" },
|
||||
"selectPic": { zh: "请选择图片", ozh: "請選擇圖片", en: "Please select a picture" },
|
||||
"img": { zh: "圖片", ozh: "圖片", en: "Image" },
|
||||
"simg": { zh: "合成圖片", ozh: "合成圖片", en: "Synthesized Image" },
|
||||
"text": { zh: "文本", ozh: "文本", en: "Text" },
|
||||
"ctext": { zh: "圆形文本", ozh: "圆形文本", en: "Circle Text" },
|
||||
"stext": { zh: "合成文本", ozh: "合成文本", en: "Synthesized Text" },
|
||||
"rect": { zh: "矩形", ozh: "矩形", en: "Rectangle" },
|
||||
"circ": { zh: "圓形", ozh: "圓形", en: "Circle" },
|
||||
"line": { zh: "直線", ozh: "直線", en: "Line" },
|
||||
"qrc": { zh: "二維碼", ozh: "二維碼", en: "QR code" },
|
||||
"barc": { zh: "條形碼", ozh: "條形碼", en: "Barcode" },
|
||||
"counter": { zh: "計數器", ozh: "计数器", en: "Counter" },
|
||||
"about": { zh: "关于Soon Design", ozh: "關於Soon Design", en: "About Soon Design" },
|
||||
"vers": { zh: "版本", ozh: "版本", en: "Version" },
|
||||
"comf": { zh: "确认", ozh: "確認", en: "Select" },
|
||||
"copyright": { zh: "© 2022 cardsoon Co., Ltd. All rights reserved.", ozh: "© 2022 cardsoon Co., Ltd. All rights reserved.", en: "© 2022 cardsoon Co., Ltd. All rights reserved." }
|
||||
};
|
||||
return t[str] ? t[str][s_lan] : str;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
langua_ge,
|
||||
language_str
|
||||
};
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var PAY_STEP_LABELS = ['选择支付', '扫码付款', '订阅生效'];
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
var cfg = (typeof window !== 'undefined' && window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base);
|
||||
if (cfg) return String(cfg).replace(/\/+$/, '');
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.origin + '/api/v1';
|
||||
}
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
function apiFetch(url, opts) {
|
||||
if (typeof window.soonAuthedFetch === 'function') return window.soonAuthedFetch(url, opts || {});
|
||||
opts = opts || {};
|
||||
var token = localStorage.getItem('soon_access') || '';
|
||||
opts.headers = Object.assign({}, opts.headers || {});
|
||||
if (token) opts.headers.Authorization = 'Bearer ' + token;
|
||||
return fetch(url, opts);
|
||||
}
|
||||
|
||||
function parseApiJson(r) {
|
||||
return r.text().then(function (text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
return { ok: false, message: '服务暂时不可用,请稍后重试' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function apiGet(p) {
|
||||
return apiFetch(apiBase() + p).then(function (r) {
|
||||
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
|
||||
return parseApiJson(r);
|
||||
});
|
||||
}
|
||||
|
||||
function apiPost(p, body) {
|
||||
return apiFetch(apiBase() + p, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
}).then(function (r) {
|
||||
if (!r.ok && r.status === 401) return { ok: false, message: '未登录或会话已过期' };
|
||||
return parseApiJson(r);
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeAlipayGateway(url) {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
try {
|
||||
var u = new URL(url);
|
||||
return u.protocol === 'https:'
|
||||
&& (u.hostname === 'openapi.alipay.com' || u.hostname === 'openapi.alipaydev.com');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitAlipayForm(payForm) {
|
||||
if (!payForm || !payForm.gateway || !payForm.params) return false;
|
||||
if (!isSafeAlipayGateway(payForm.gateway)) return false;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = payForm.gateway;
|
||||
form.acceptCharset = 'UTF-8';
|
||||
form.style.display = 'none';
|
||||
Object.keys(payForm.params).forEach(function (key) {
|
||||
var val = payForm.params[key];
|
||||
if (val == null || val === '') return;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = String(val);
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
|
||||
function payStepLabel(step, channel) {
|
||||
if (step === 2 && channel === 'alipay') return '网页支付';
|
||||
if (step === 2 && channel === 'wechat') return '扫码付款';
|
||||
return PAY_STEP_LABELS[step - 1];
|
||||
}
|
||||
|
||||
function payStepperHtml(activeStep, channel) {
|
||||
var parts = [];
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
if (i > 1) {
|
||||
var bridgeCls = 'soon-pay-stepper__bridge';
|
||||
if (i - 1 < activeStep) bridgeCls += ' is-done';
|
||||
parts.push('<li class="' + bridgeCls + '" aria-hidden="true"></li>');
|
||||
}
|
||||
var cls = 'soon-pay-stepper__item';
|
||||
if (i < activeStep) cls += ' is-done';
|
||||
else if (i === activeStep) cls += ' is-active';
|
||||
parts.push('<li class="' + cls + '">' +
|
||||
'<span class="soon-pay-stepper__dot">' + (i < activeStep ? '✓' : String(i)) + '</span>' +
|
||||
'<span class="soon-pay-stepper__label">' + payStepLabel(i, channel) + '</span></li>');
|
||||
}
|
||||
return '<ol class="soon-pay-stepper" id="payStepper">' + parts.join('') + '</ol>';
|
||||
}
|
||||
|
||||
function updatePayStep(root, step, channel) {
|
||||
if (!root) return;
|
||||
var html = payStepperHtml(step, channel || 'wechat');
|
||||
var old = root.querySelector('#payStepper');
|
||||
if (old) old.outerHTML = html;
|
||||
}
|
||||
|
||||
function payEmptyStateHtml() {
|
||||
return '<div class="soon-pay-checkout-empty">' +
|
||||
'<div class="soon-pay-checkout-empty__icon" aria-hidden="true">' +
|
||||
'<svg viewBox="0 0 48 48" fill="none"><rect x="8" y="12" width="32" height="24" rx="4" stroke="currentColor" stroke-width="1.5"/>' +
|
||||
'<path d="M8 20h32" stroke="currentColor" stroke-width="1.5"/>' +
|
||||
'<circle cx="32" cy="30" r="3" fill="currentColor" opacity="0.5"/></svg></div>' +
|
||||
'<p class="soon-pay-checkout-empty__title">等待生成付款码</p>' +
|
||||
'<p class="soon-pay-checkout-empty__desc">确认支付方式后,将在此显示二维码或跳转链接</p></div>';
|
||||
}
|
||||
|
||||
function renderPayPayload(data, qrEl, orderNoEl) {
|
||||
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;
|
||||
qrEl.innerHTML =
|
||||
'<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>' +
|
||||
'<p class="soon-pay-checkout-ready__title">支付宝电脑网站支付</p>' +
|
||||
'<p class="soon-pay-checkout-ready__desc">点击下方按钮跳转至支付宝收银台,支付完成后将自动返回</p>' +
|
||||
'<button type="button" class="soon-btn soon-pay-checkout-ready__btn" id="goAlipay">前往支付宝付款</button></div>';
|
||||
var alipayBtn = qrEl.querySelector('#goAlipay');
|
||||
if (alipayBtn) {
|
||||
alipayBtn.onclick = function () {
|
||||
if (!submitAlipayForm(payForm)) {
|
||||
if (typeof window.soonToast === 'function') {
|
||||
window.soonToast('无法跳转支付宝,请检查支付配置', 'warn');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (data.qrcode) {
|
||||
qrEl.innerHTML =
|
||||
'<div class="soon-pay-checkout-ready">' +
|
||||
'<div class="soon-pay-checkout-ready__brand soon-pay-checkout-ready__brand--wechat">微</div>' +
|
||||
'<p class="soon-pay-checkout-ready__title">微信扫码支付</p>' +
|
||||
'<div class="soon-pay-checkout-ready__qr" id="payQrCanvas"></div>' +
|
||||
'<p class="soon-pay-checkout-ready__desc">请使用微信扫一扫,有效期约 2 小时</p></div>';
|
||||
if (typeof QRCode !== 'undefined') {
|
||||
new QRCode(qrEl.querySelector('#payQrCanvas'), { text: data.qrcode, width: 188, height: 188 });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return '未获取到支付信息';
|
||||
}
|
||||
|
||||
function monthlyPlan(items) {
|
||||
var found = null;
|
||||
(items || []).forEach(function (p) {
|
||||
if (p.code === 'member_monthly' || p.code === 'pro_monthly') found = p;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function billingSavePercent(items, plan) {
|
||||
var monthly = monthlyPlan(items);
|
||||
if (!monthly || monthly.price_cents <= 0 || !plan || plan.duration_days <= 30) return null;
|
||||
var months = plan.duration_days / 30;
|
||||
var full = monthly.price_cents * months;
|
||||
if (full <= plan.price_cents) return null;
|
||||
return Math.round((1 - plan.price_cents / full) * 100);
|
||||
}
|
||||
|
||||
function paySheetHtml(opts) {
|
||||
opts = opts || {};
|
||||
var resumeOrderNo = opts.orderNo || null;
|
||||
var channel = opts.channel || 'wechat';
|
||||
var embedded = opts.variant === 'embedded';
|
||||
var backBtn = !embedded && opts.showBack
|
||||
? '<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-pay-sheet__back" id="payBack">返回选择方案</button>'
|
||||
: '';
|
||||
var headBlock = embedded ? '' :
|
||||
'<header class="soon-pay-sheet__head">' +
|
||||
'<div class="soon-pay-sheet__head-main">' +
|
||||
'<span class="soon-pay-sheet__eyebrow">' + (resumeOrderNo ? '继续支付' : '确认订阅') + '</span>' +
|
||||
'<h3 class="soon-pay-sheet__title">' + esc(opts.planName || '') + '</h3>' +
|
||||
'<p class="soon-pay-sheet__order" id="payOrderNo"' +
|
||||
(resumeOrderNo ? '' : ' style="display:none"') + '>' +
|
||||
(resumeOrderNo ? '订单号 ' + esc(resumeOrderNo) : '') + '</p></div>' +
|
||||
'<div class="soon-pay-sheet__head-price">' +
|
||||
'<span class="soon-pay-sheet__currency">¥</span>' +
|
||||
'<span class="soon-pay-sheet__amount">' + esc(opts.priceDisplay || '') + '</span></div></header>';
|
||||
var orderInBody = embedded
|
||||
? '<p class="soon-pay-sheet__order soon-pay-sheet__order--inline" id="payOrderNo"' +
|
||||
(resumeOrderNo ? '' : ' style="display:none"') + '>' +
|
||||
(resumeOrderNo ? '订单号 ' + esc(resumeOrderNo) : '') + '</p>'
|
||||
: '';
|
||||
return '<div class="soon-pay-sheet' + (embedded ? ' soon-pay-sheet--embedded' : '') + '">' +
|
||||
(backBtn ? '<div class="soon-pay-sheet__toolbar">' + backBtn + '</div>' : '') +
|
||||
headBlock +
|
||||
'<div class="soon-pay-sheet__body">' +
|
||||
orderInBody +
|
||||
payStepperHtml(1, channel) +
|
||||
'<section class="soon-pay-section soon-pay-section--channels" id="payChannels">' +
|
||||
'<h4 class="soon-pay-section__title">支付方式</h4>' +
|
||||
'<div class="soon-pay-channels">' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'wechat' ? ' is-active' : '') + '" data-ch="wechat">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--wechat">微</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">微信支付</span>' +
|
||||
'<span class="soon-pay-channel__hint">扫码付款</span></span></button>' +
|
||||
'<button type="button" class="soon-pay-channel' + (channel === 'alipay' ? ' is-active' : '') + '" data-ch="alipay">' +
|
||||
'<span class="soon-pay-channel__radio" aria-hidden="true"></span>' +
|
||||
'<span class="soon-pay-channel__icon soon-pay-channel__icon--alipay">支</span>' +
|
||||
'<span class="soon-pay-channel__text"><span class="soon-pay-channel__name">支付宝</span>' +
|
||||
'<span class="soon-pay-channel__hint">网页支付</span></span></button></div></section>' +
|
||||
'<section class="soon-pay-section soon-pay-section--checkout">' +
|
||||
'<div class="soon-pay-qr-panel" id="payQr">' + payEmptyStateHtml() + '</div></section>' +
|
||||
'<div class="soon-pay-sheet__foot">' +
|
||||
'<p class="soon-pay-status" id="payStatus"></p>' +
|
||||
'<button type="button" class="soon-btn soon-pay-sheet__cta" id="payConfirm">' +
|
||||
(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>' +
|
||||
'</div></div></div>';
|
||||
}
|
||||
|
||||
function bindPaySheet(sheetRoot, opts) {
|
||||
opts = opts || {};
|
||||
var planId = opts.planId;
|
||||
var resumeOrderNo = opts.orderNo || null;
|
||||
var channel = opts.channel || 'wechat';
|
||||
var onPaid = typeof opts.onPaid === 'function' ? opts.onPaid : function () {};
|
||||
var pollTimer = null;
|
||||
var pollStartedAt = 0;
|
||||
var orderNo = resumeOrderNo;
|
||||
var paymentReady = false;
|
||||
|
||||
if (!sheetRoot) return { destroy: function () {} };
|
||||
|
||||
var manualBtn = sheetRoot.querySelector('#payManualRefresh');
|
||||
var statusEl = sheetRoot.querySelector('#payStatus');
|
||||
var confirmBtn = sheetRoot.querySelector('#payConfirm');
|
||||
var qrEl = sheetRoot.querySelector('#payQr');
|
||||
var orderNoEl = sheetRoot.querySelector('#payOrderNo');
|
||||
var backBtn = sheetRoot.querySelector('#payBack');
|
||||
|
||||
function setStatus(text, type) {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = text || '';
|
||||
statusEl.className = 'soon-pay-status' + (type ? ' soon-pay-status--' + type : '');
|
||||
statusEl.style.display = text ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
updatePayStep(sheetRoot, step, channel);
|
||||
sheetRoot.classList.toggle('soon-pay-sheet--checkout', step >= 2);
|
||||
var chSec = sheetRoot.querySelector('#payChannels');
|
||||
if (chSec) chSec.style.display = step >= 2 ? 'none' : '';
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollStartedAt = Date.now();
|
||||
pollTimer = setInterval(function () {
|
||||
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
|
||||
stopPoll();
|
||||
setStatus('自动检测超时,如已支付请点击下方按钮', 'err');
|
||||
return;
|
||||
}
|
||||
checkOrderStatus();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handlePaid() {
|
||||
stopPoll();
|
||||
setStep(3);
|
||||
setStatus('支付成功,正在更新订阅…', 'ok');
|
||||
setTimeout(onPaid, 600);
|
||||
}
|
||||
|
||||
function checkOrderStatus() {
|
||||
if (!orderNo) return;
|
||||
apiGet('/pay/orders/' + encodeURIComponent(orderNo)).then(function (st) {
|
||||
if (st.ok && st.data && st.data.status === 'paid') handlePaid();
|
||||
});
|
||||
}
|
||||
|
||||
if (manualBtn) manualBtn.onclick = checkOrderStatus;
|
||||
|
||||
function resetPaymentView() {
|
||||
paymentReady = false;
|
||||
stopPoll();
|
||||
setStep(1);
|
||||
if (manualBtn) manualBtn.style.display = 'none';
|
||||
if (confirmBtn) confirmBtn.style.display = '';
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
if (orderNoEl && !resumeOrderNo) {
|
||||
orderNoEl.textContent = '';
|
||||
orderNoEl.style.display = 'none';
|
||||
}
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function applyCheckout(data) {
|
||||
orderNo = data.order_no;
|
||||
var err = renderPayPayload(data, qrEl, orderNoEl);
|
||||
if (err) {
|
||||
setStatus(err, 'err');
|
||||
return;
|
||||
}
|
||||
paymentReady = true;
|
||||
setStep(2);
|
||||
if (confirmBtn) confirmBtn.style.display = 'none';
|
||||
if (manualBtn) manualBtn.style.display = '';
|
||||
setStatus('等待支付结果,完成后将自动刷新', 'ok');
|
||||
startPoll();
|
||||
}
|
||||
|
||||
function submitCheckout() {
|
||||
if (confirmBtn) confirmBtn.disabled = true;
|
||||
setStatus('正在生成付款信息…');
|
||||
qrEl.innerHTML = '<div class="soon-pay-checkout-loading"><div class="soon-skeleton soon-skeleton--h200"></div>' +
|
||||
'<p>正在连接支付渠道…</p></div>';
|
||||
var req;
|
||||
if (resumeOrderNo || orderNo) {
|
||||
var existingNo = orderNo || resumeOrderNo;
|
||||
req = apiPost('/pay/orders/' + encodeURIComponent(existingNo) + '/checkout', { channel: channel });
|
||||
} else {
|
||||
req = apiPost('/pay/orders', { plan_id: planId, channel: channel });
|
||||
}
|
||||
req.then(function (j) {
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
if (!j.ok) {
|
||||
setStatus(j.message || '下单失败', 'err');
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
return;
|
||||
}
|
||||
applyCheckout(j.data);
|
||||
}).catch(function () {
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
setStatus('网络错误,请重试', 'err');
|
||||
qrEl.innerHTML = payEmptyStateHtml();
|
||||
});
|
||||
}
|
||||
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (el) {
|
||||
el.onclick = function () {
|
||||
if (el.classList.contains('is-active')) return;
|
||||
sheetRoot.querySelectorAll('.soon-pay-channel').forEach(function (x) { x.classList.remove('is-active'); });
|
||||
el.classList.add('is-active');
|
||||
channel = el.dataset.ch;
|
||||
if (paymentReady) resetPaymentView();
|
||||
};
|
||||
});
|
||||
|
||||
if (confirmBtn) confirmBtn.onclick = submitCheckout;
|
||||
if (backBtn && typeof opts.onBack === 'function') backBtn.onclick = opts.onBack;
|
||||
|
||||
function destroy() {
|
||||
stopPoll();
|
||||
paymentReady = false;
|
||||
orderNo = resumeOrderNo;
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: destroy,
|
||||
reset: resetPaymentView,
|
||||
};
|
||||
}
|
||||
|
||||
function openPayModal(opts) {
|
||||
opts = opts || {};
|
||||
if (typeof layer === 'undefined' || !layer.open) return null;
|
||||
var html = paySheetHtml({
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
});
|
||||
var payCtrl = null;
|
||||
var idx = layer.open({
|
||||
type: 1,
|
||||
skin: 'soon-layer',
|
||||
title: false,
|
||||
closeBtn: 1,
|
||||
shadeClose: !!opts.shadeClose,
|
||||
area: ['480px'],
|
||||
content: html,
|
||||
success: function (layero) {
|
||||
var layerEl = layero && layero[0] ? layero[0] : layero;
|
||||
if (layerEl && layerEl.classList) {
|
||||
layerEl.classList.add('soon-layer--pay');
|
||||
var layerContent = layerEl.querySelector('.layui-layer-content');
|
||||
if (layerContent) {
|
||||
layerContent.style.overflow = 'visible';
|
||||
layerContent.style.maxHeight = 'none';
|
||||
}
|
||||
}
|
||||
var sheet = layerEl.querySelector('.soon-pay-sheet');
|
||||
payCtrl = bindPaySheet(sheet, {
|
||||
planId: opts.planId,
|
||||
planName: opts.planName,
|
||||
priceDisplay: opts.priceDisplay,
|
||||
orderNo: opts.orderNo,
|
||||
channel: opts.channel || 'wechat',
|
||||
onPaid: function () {
|
||||
layer.close(idx);
|
||||
if (typeof opts.onPaid === 'function') opts.onPaid();
|
||||
},
|
||||
onBack: opts.onBack,
|
||||
});
|
||||
},
|
||||
end: function () {
|
||||
if (payCtrl) payCtrl.destroy();
|
||||
if (typeof opts.onClose === 'function') opts.onClose();
|
||||
},
|
||||
});
|
||||
return idx;
|
||||
}
|
||||
|
||||
window.SoonMemberPay = {
|
||||
apiBase: apiBase,
|
||||
apiGet: apiGet,
|
||||
apiPost: apiPost,
|
||||
esc: esc,
|
||||
monthlyPlan: monthlyPlan,
|
||||
billingSavePercent: billingSavePercent,
|
||||
paySheetHtml: paySheetHtml,
|
||||
bindPaySheet: bindPaySheet,
|
||||
openPayModal: openPayModal,
|
||||
payEmptyStateHtml: payEmptyStateHtml,
|
||||
renderPayPayload: renderPayPayload,
|
||||
submitAlipayForm: submitAlipayForm,
|
||||
POLL_TIMEOUT_MS: POLL_TIMEOUT_MS,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,104 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CHECK_SVG = '<svg viewBox="0 0 12 12" fill="none" stroke-width="2" stroke="currentColor">' +
|
||||
'<path d="M2 6l3 3 5-6"/></svg>';
|
||||
|
||||
function esc(s) {
|
||||
if (window.SoonMemberPay && window.SoonMemberPay.esc) return window.SoonMemberPay.esc(s);
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function isPlanFeatureLine(line) {
|
||||
if (!line) return false;
|
||||
return line.indexOf('到期') < 0
|
||||
&& line.indexOf('订阅有效期') < 0
|
||||
&& line.indexOf('订阅周期') < 0
|
||||
&& line.indexOf('权益一致') < 0;
|
||||
}
|
||||
|
||||
function featureItem(text) {
|
||||
return '<li><span class="soon-plan-card__check">' + CHECK_SVG + '</span><span>' + esc(text) + '</span></li>';
|
||||
}
|
||||
|
||||
function billingSavePercent(items, plan) {
|
||||
if (window.SoonMemberPay && window.SoonMemberPay.billingSavePercent) {
|
||||
return window.SoonMemberPay.billingSavePercent(items, plan);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function planCardHtml(plan, ctx) {
|
||||
ctx = ctx || {};
|
||||
var items = ctx.allPlans || [];
|
||||
var mode = ctx.mode || 'page';
|
||||
var isMember = !!ctx.isMember;
|
||||
var currentCode = ctx.currentCode || '';
|
||||
var selectedId = ctx.selectedId;
|
||||
|
||||
var feat = plan.is_recommended ? ' soon-plan-card--featured' : '';
|
||||
var cur = plan.code === currentCode ? ' soon-plan-card--current' : '';
|
||||
var isGate = mode === 'gate';
|
||||
var isPicker = mode === 'picker' || isGate;
|
||||
var picker = isPicker ? ' soon-plan-card--picker' : '';
|
||||
if (isGate) picker += ' soon-plan-card--gate';
|
||||
var selected = isPicker && plan.id === selectedId ? ' soon-plan-card--selected' : '';
|
||||
|
||||
var ribbon = plan.is_recommended
|
||||
? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--rec">推荐</span>'
|
||||
: (plan.code === currentCode ? '<span class="soon-plan-card__ribbon soon-plan-card__ribbon--current">当前</span>' : '');
|
||||
var savePct = billingSavePercent(items, plan);
|
||||
if (savePct) ribbon += '<span class="soon-plan-card__save">省 ' + savePct + '%</span>';
|
||||
var badgeCls = ribbon ? ' soon-plan-card--badged' : '';
|
||||
if (savePct) badgeCls += ' soon-plan-card--save';
|
||||
|
||||
var period = plan.duration_days > 0
|
||||
? '<span class="soon-plan-card__period">/' + esc(plan.period_label || plan.duration_days + '天') + '</span>'
|
||||
: '';
|
||||
var features = (plan.features || []).filter(isPlanFeatureLine).map(featureItem).join('');
|
||||
var desc = plan.description || '';
|
||||
var price = esc(plan.price_display || (plan.price_cents / 100).toFixed(2));
|
||||
|
||||
var inner =
|
||||
ribbon +
|
||||
'<div class="soon-plan-card__head">' +
|
||||
'<div class="soon-plan-card__name">' + esc(plan.name) + '</div>' +
|
||||
(desc ? '<p class="soon-plan-card__desc">' + esc(desc) + '</p>' : '') +
|
||||
'<div class="soon-plan-card__price-row">' +
|
||||
'<span class="soon-plan-card__currency">¥</span>' +
|
||||
'<span class="soon-plan-card__price">' + price + '</span>' + period + '</div></div>';
|
||||
if (!isGate) {
|
||||
inner += '<div class="soon-plan-card__body">' +
|
||||
'<ul class="soon-plan-card__features">' + features + '</ul></div>';
|
||||
}
|
||||
|
||||
if (isPicker) {
|
||||
return '<button type="button" class="soon-plan-card' + feat + cur + badgeCls + picker + selected + '" ' +
|
||||
'data-action="pick-plan" data-id="' + plan.id + '" aria-pressed="' + (plan.id === selectedId ? 'true' : 'false') + '">' +
|
||||
inner + '</button>';
|
||||
}
|
||||
|
||||
var cta = '<button type="button" class="soon-btn soon-plan-card__cta" data-id="' + plan.id +
|
||||
'" data-name="' + esc(plan.name) + '" data-price="' + price + '">' +
|
||||
(isMember ? '续订' : '立即订阅') + '</button>';
|
||||
return '<article class="soon-plan-card' + feat + cur + badgeCls + '">' + inner +
|
||||
'<div class="soon-plan-card__foot">' + cta + '</div></article>';
|
||||
}
|
||||
|
||||
function plansGridHtml(plans, ctx) {
|
||||
return (plans || []).map(function (p) {
|
||||
return planCardHtml(p, ctx);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.SoonMemberPlan = {
|
||||
isPlanFeatureLine: isPlanFeatureLine,
|
||||
planCardHtml: planCardHtml,
|
||||
plansGridHtml: plansGridHtml,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,306 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var GATE_FEATURES = ['高清预览', '成品打印', '云端保存', '导出设计文件'];
|
||||
var _gate = { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null };
|
||||
|
||||
var pay = window.SoonMemberPay;
|
||||
var plansUi = window.SoonMemberPlan;
|
||||
|
||||
function esc(s) {
|
||||
if (pay && pay.esc) return pay.esc(s);
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function toast(msg, type) {
|
||||
if (typeof window.soonToast === 'function') window.soonToast(msg, type);
|
||||
}
|
||||
|
||||
function emptyGate() {
|
||||
return { index: null, payIndex: null, plans: [], selectedId: null, gateOpts: null };
|
||||
}
|
||||
|
||||
function ensureLayer(cb) {
|
||||
if (typeof layer !== 'undefined' && layer.open) {
|
||||
if (layer.config) layer.config({ skin: 'soon-layer' });
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
if (typeof layui !== 'undefined') {
|
||||
layui.use(['layer'], function () {
|
||||
window.layer = layui.layer;
|
||||
layer.config({ skin: 'soon-layer' });
|
||||
cb();
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast('订阅功能暂不可用,请刷新页面后重试', 'warn');
|
||||
}
|
||||
|
||||
function actionCopy(actionLabel) {
|
||||
var label = (actionLabel || '使用').trim();
|
||||
if (label.indexOf('预览') >= 0 || label.indexOf('导出') >= 0) return '预览、打印与导出成品';
|
||||
if (label.indexOf('保存') >= 0 || label.indexOf('另存') >= 0) return '保存或另存到云端';
|
||||
if (label.indexOf('下载') >= 0) return '下载设计文件';
|
||||
return label;
|
||||
}
|
||||
|
||||
function shellHtml(actionLabel) {
|
||||
var feat = GATE_FEATURES.map(function (t) {
|
||||
return '<li>' + esc(t) + '</li>';
|
||||
}).join('');
|
||||
return '<div class="soon-subscribe-modal">' +
|
||||
'<header class="soon-subscribe-modal__head">' +
|
||||
'<span class="soon-subscribe-modal__eyebrow">订阅功能</span>' +
|
||||
'<h3 class="soon-subscribe-modal__title">开通订阅,解锁完整能力</h3>' +
|
||||
'<p class="soon-subscribe-modal__desc">免费版可使用设计工具;订阅后可' + esc(actionCopy(actionLabel)) + '。</p>' +
|
||||
'<ul class="soon-subscribe-modal__features">' + feat + '</ul>' +
|
||||
'</header>' +
|
||||
'<div class="soon-subscribe-modal__body">' +
|
||||
'<div class="soon-subscribe-modal__section-head">' +
|
||||
'<h4 class="soon-subscribe-modal__section-title"><span class="soon-subscribe-modal__section-mark"></span>订阅方案</h4>' +
|
||||
'<p class="soon-subscribe-modal__section-sub">选择订阅周期,功能相同,随时续订</p></div>' +
|
||||
'<section class="soon-plans-grid soon-subscribe-plans-grid" data-role="plan-picker">' +
|
||||
'<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p></section>' +
|
||||
'</div>' +
|
||||
'<footer class="soon-subscribe-modal__foot">' +
|
||||
'<button type="button" class="soon-subscribe-modal__stay" data-action="stay">继续设计</button>' +
|
||||
'<button type="button" class="soon-btn soon-subscribe-modal__pay" data-action="go-pay" disabled>立即支付</button>' +
|
||||
'</footer></div>';
|
||||
}
|
||||
|
||||
function findPlan(id) {
|
||||
var pid = Number(id);
|
||||
for (var i = 0; i < _gate.plans.length; i++) {
|
||||
if (_gate.plans[i].id === pid) return _gate.plans[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function planPriceDisplay(plan) {
|
||||
if (!plan) return '';
|
||||
return plan.price_display || (plan.price_cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function renderPlanPicker(modal) {
|
||||
var picker = modal.querySelector('[data-role="plan-picker"]');
|
||||
if (!picker || !plansUi) return;
|
||||
if (!_gate.plans.length) {
|
||||
picker.innerHTML = '<p class="soon-subscribe-plans__empty">暂无可用订阅方案</p>';
|
||||
return;
|
||||
}
|
||||
picker.innerHTML = plansUi.plansGridHtml(_gate.plans, {
|
||||
allPlans: _gate.plans,
|
||||
selectedId: _gate.selectedId,
|
||||
mode: 'gate',
|
||||
});
|
||||
}
|
||||
|
||||
function updatePayButton(modal) {
|
||||
var btn = modal.querySelector('[data-action="go-pay"]');
|
||||
var plan = findPlan(_gate.selectedId);
|
||||
if (!btn) return;
|
||||
if (!plan) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '立即支付';
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '立即支付 ¥' + planPriceDisplay(plan);
|
||||
}
|
||||
|
||||
function selectPlan(modal, planId) {
|
||||
var plan = findPlan(planId);
|
||||
if (!plan) return;
|
||||
_gate.selectedId = plan.id;
|
||||
renderPlanPicker(modal);
|
||||
updatePayButton(modal);
|
||||
}
|
||||
|
||||
function closeGate(fireStay) {
|
||||
var idx = _gate.index;
|
||||
var payIdx = _gate.payIndex;
|
||||
var opts = _gate.gateOpts;
|
||||
_gate = emptyGate();
|
||||
if (typeof layer !== 'undefined') {
|
||||
if (idx != null) layer.close(idx);
|
||||
if (payIdx != null) layer.close(payIdx);
|
||||
}
|
||||
if (fireStay && opts && typeof opts.onStay === 'function') opts.onStay();
|
||||
}
|
||||
|
||||
function onPaySuccess() {
|
||||
var gateIdx = _gate.index;
|
||||
var opts = _gate.gateOpts;
|
||||
_gate = emptyGate();
|
||||
if (typeof layer !== 'undefined') {
|
||||
if (gateIdx != null) layer.close(gateIdx);
|
||||
}
|
||||
toast('恭喜您,订阅已成功生效!', 'success');
|
||||
if (typeof window.soonLoadMembership === 'function') {
|
||||
window.soonLoadMembership(true).then(function () {
|
||||
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
|
||||
});
|
||||
}
|
||||
if (opts && typeof opts.onSuccess === 'function') opts.onSuccess();
|
||||
}
|
||||
|
||||
function openPayStep(modal) {
|
||||
var plan = findPlan(_gate.selectedId);
|
||||
if (!plan || !pay || !pay.openPayModal) {
|
||||
toast('请先选择订阅方案', 'warn');
|
||||
return;
|
||||
}
|
||||
if (_gate.payIndex != null) {
|
||||
try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ }
|
||||
_gate.payIndex = null;
|
||||
}
|
||||
var priceDisplay = planPriceDisplay(plan);
|
||||
_gate.payIndex = pay.openPayModal({
|
||||
planId: plan.id,
|
||||
planName: plan.name,
|
||||
priceDisplay: priceDisplay,
|
||||
shadeClose: true,
|
||||
onPaid: onPaySuccess,
|
||||
onClose: function () {
|
||||
_gate.payIndex = null;
|
||||
updatePayButton(modal);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlansError(modal, message) {
|
||||
var picker = modal.querySelector('[data-role="plan-picker"]');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = '<p class="soon-subscribe-plans__empty">' + esc(message) +
|
||||
'<br><button type="button" class="soon-btn soon-btn--sm" data-action="retry-plans">重试</button></p>';
|
||||
updatePayButton(modal);
|
||||
}
|
||||
|
||||
function loadPlans(modal) {
|
||||
var picker = modal.querySelector('[data-role="plan-picker"]');
|
||||
if (!picker) return;
|
||||
if (!pay || !plansUi) {
|
||||
renderPlansError(modal, '订阅模块未加载,请刷新页面');
|
||||
return;
|
||||
}
|
||||
picker.innerHTML = '<p class="soon-subscribe-plans__loading">正在加载订阅方案…</p>';
|
||||
updatePayButton(modal);
|
||||
pay.apiGet('/plans').then(function (ps) {
|
||||
if (!ps.ok) {
|
||||
renderPlansError(modal, ps.message || '方案加载失败');
|
||||
return;
|
||||
}
|
||||
var paid = ((ps.data && ps.data.items) || []).filter(function (p) {
|
||||
return p.code !== 'free' && p.price_cents > 0;
|
||||
});
|
||||
if (!paid.length) {
|
||||
renderPlansError(modal, '暂无可用订阅方案');
|
||||
return;
|
||||
}
|
||||
_gate.plans = paid;
|
||||
var def = paid[0];
|
||||
for (var i = 0; i < paid.length; i++) {
|
||||
if (paid[i].is_recommended) { def = paid[i]; break; }
|
||||
}
|
||||
selectPlan(modal, def.id);
|
||||
}).catch(function () {
|
||||
renderPlansError(modal, '网络错误,请稍后重试');
|
||||
});
|
||||
}
|
||||
|
||||
function bindModal(modal) {
|
||||
modal.addEventListener('click', function (e) {
|
||||
var stay = e.target.closest('[data-action="stay"]');
|
||||
if (stay) {
|
||||
e.preventDefault();
|
||||
closeGate(true);
|
||||
return;
|
||||
}
|
||||
var retry = e.target.closest('[data-action="retry-plans"]');
|
||||
if (retry) {
|
||||
e.preventDefault();
|
||||
loadPlans(modal);
|
||||
return;
|
||||
}
|
||||
var goPay = e.target.closest('[data-action="go-pay"]');
|
||||
if (goPay) {
|
||||
e.preventDefault();
|
||||
if (!goPay.disabled) openPayStep(modal);
|
||||
return;
|
||||
}
|
||||
var pick = e.target.closest('[data-action="pick-plan"]');
|
||||
if (pick) {
|
||||
e.preventDefault();
|
||||
var id = Number(pick.dataset.id);
|
||||
if (id) selectPlan(modal, id);
|
||||
}
|
||||
});
|
||||
loadPlans(modal);
|
||||
}
|
||||
|
||||
function openGate(opts) {
|
||||
opts = opts || {};
|
||||
if (_gate.index != null) {
|
||||
try { layer.close(_gate.index); } catch (e) { /* ignore */ }
|
||||
}
|
||||
if (_gate.payIndex != null) {
|
||||
try { layer.close(_gate.payIndex); } catch (e) { /* ignore */ }
|
||||
}
|
||||
_gate = emptyGate();
|
||||
_gate.gateOpts = opts;
|
||||
|
||||
var width = Math.min(1000, window.innerWidth - 24);
|
||||
layer.open({
|
||||
type: 1,
|
||||
skin: 'soon-layer',
|
||||
title: false,
|
||||
closeBtn: 1,
|
||||
shadeClose: true,
|
||||
area: [width + 'px', 'auto'],
|
||||
content: shellHtml(opts.action),
|
||||
success: function (layero, index) {
|
||||
var layerEl = layero && layero[0] ? layero[0] : layero;
|
||||
if (layerEl && layerEl.classList) layerEl.classList.add('soon-layer--subscribe');
|
||||
var content = layerEl && layerEl.querySelector ? layerEl.querySelector('.layui-layer-content') : null;
|
||||
if (content) content.style.padding = '0';
|
||||
var modal = layerEl.querySelector('.soon-subscribe-modal');
|
||||
if (!modal) return;
|
||||
_gate.index = index;
|
||||
_gate.gateOpts = opts;
|
||||
bindModal(modal);
|
||||
},
|
||||
end: function () {
|
||||
var payIdx = _gate.payIndex;
|
||||
var closedOpts = _gate.gateOpts;
|
||||
if (payIdx != null) {
|
||||
try { layer.close(payIdx); } catch (e) { /* ignore */ }
|
||||
}
|
||||
_gate = emptyGate();
|
||||
if (closedOpts && closedOpts.onClose) closedOpts.onClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function soonShowSubscribeGate(opts) {
|
||||
if (!pay || !plansUi) {
|
||||
toast('订阅功能暂不可用,请刷新页面后重试', 'warn');
|
||||
return false;
|
||||
}
|
||||
ensureLayer(function () { openGate(opts || {}); });
|
||||
return false;
|
||||
}
|
||||
|
||||
window.soonShowSubscribeGate = soonShowSubscribeGate;
|
||||
|
||||
if (!window._soonSubscribeFocusBound) {
|
||||
window._soonSubscribeFocusBound = true;
|
||||
window.addEventListener('focus', function () {
|
||||
if (typeof window.soonLoadMembership !== 'function') return;
|
||||
window.soonLoadMembership(true).then(function () {
|
||||
if (typeof window.soonRefreshPortalIdentity === 'function') window.soonRefreshPortalIdentity();
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function showAuthedNav() {
|
||||
var guest = document.getElementById('auth_guest');
|
||||
var account = document.getElementById('auth_account');
|
||||
var session = document.getElementById('auth_session');
|
||||
if (guest) guest.style.display = 'none';
|
||||
if (account) account.style.display = 'inline-flex';
|
||||
if (session) session.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (typeof window.soonClearAuthSession === 'function') {
|
||||
window.soonClearAuthSession();
|
||||
} else {
|
||||
localStorage.removeItem('soon_access');
|
||||
localStorage.removeItem('soon_refresh');
|
||||
}
|
||||
}
|
||||
|
||||
function initTopbar(options) {
|
||||
options = options || {};
|
||||
var tok = localStorage.getItem('soon_access') || '';
|
||||
var login = document.getElementById('auth_login');
|
||||
var reg = document.getElementById('auth_register');
|
||||
var member = document.getElementById('auth_member');
|
||||
var admin = document.getElementById('auth_admin');
|
||||
var logout = document.getElementById('auth_logout');
|
||||
var avatar = document.getElementById('auth_avatar');
|
||||
|
||||
if (!tok) return;
|
||||
|
||||
showAuthedNav();
|
||||
if (login) login.style.display = 'none';
|
||||
if (reg) reg.style.display = 'none';
|
||||
if (member) member.style.display = 'inline-flex';
|
||||
if (logout) logout.style.display = 'inline-flex';
|
||||
|
||||
var base = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_v1_base) || '';
|
||||
if (!base) return;
|
||||
|
||||
var meFetch = typeof window.soonAuthedFetch === 'function'
|
||||
? 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 (j) {
|
||||
if (!j.ok || !j.data) {
|
||||
clearSession();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () { /* keep authed shell */ });
|
||||
|
||||
if (logout) {
|
||||
logout.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
clearSession();
|
||||
if (options.logoutReload !== false) location.reload();
|
||||
else location.href = 'index.web.html';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.soonPortalAuth = { init: initTopbar };
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STANDARD_LINKS = [
|
||||
{ id: 'home', href: 'index.web.html', label: '设计首页', show: 'always' },
|
||||
{ id: 'member', href: 'member.web.html', label: '订阅', show: 'authed', elId: 'auth_member' },
|
||||
{ id: 'admin', href: 'admin/index.html', label: '管理', show: 'admin', elId: 'auth_admin' },
|
||||
];
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function authBlock() {
|
||||
return '<div class="soon-portal-topbar__guest" id="auth_guest">' +
|
||||
'<a href="login.web.html" id="auth_login" class="soon-btn soon-btn--sm">登录</a>' +
|
||||
'<a href="register.web.html" id="auth_register" class="soon-btn soon-btn--sm soon-btn--ghost">注册</a>' +
|
||||
'</div>' +
|
||||
'<div class="soon-portal-topbar__account" id="auth_account" style="display:none">' +
|
||||
'<span id="auth_avatar" class="soon-portal-avatar" style="display:none"></span>' +
|
||||
'<span id="auth_identity" class="soon-portal-identity" style="display:none"></span>' +
|
||||
'</div>' +
|
||||
'<nav class="soon-portal-topbar__session" id="auth_session" style="display:none" aria-label="账户">' +
|
||||
'<a href="member.web.html" id="auth_member" class="soon-portal-topbar__link" style="display:none">订阅</a>' +
|
||||
'<a href="admin/index.html" id="auth_admin" class="soon-portal-topbar__link" style="display:none">管理</a>' +
|
||||
'<a href="#" id="auth_logout" class="soon-portal-topbar__link" style="display:none">退出</a>' +
|
||||
'</nav>';
|
||||
}
|
||||
|
||||
function renderLinks(links, active) {
|
||||
return links.map(function (lnk) {
|
||||
if (lnk.id === 'member' || lnk.id === 'admin') return '';
|
||||
var cls = 'soon-portal-topbar__link' + (active === lnk.id ? ' is-active' : '');
|
||||
return '<a href="' + esc(lnk.href) + '" class="' + cls + '">' + esc(lnk.label) + '</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toolsBlock(lang, linksHtml) {
|
||||
return '<div class="soon-portal-topbar__tools">' +
|
||||
lang +
|
||||
(linksHtml ? '<nav class="soon-portal-topbar__links" aria-label="导航">' + linksHtml + '</nav>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.variant] standard | auth | design
|
||||
* @param {string} [opts.active] home | member
|
||||
* @param {string} [opts.extraClass]
|
||||
*/
|
||||
function html(opts) {
|
||||
opts = opts || {};
|
||||
var base = opts.assetBase || '../';
|
||||
var variant = opts.variant || 'standard';
|
||||
var extraCls = opts.extraClass ? ' ' + opts.extraClass : '';
|
||||
|
||||
if (variant === 'auth') {
|
||||
return '<header class="soon-portal-topbar soon-auth-topbar' + extraCls + '">' +
|
||||
'<div class="soon-portal-topbar__brand"><a href="index.web.html">' +
|
||||
'<img src="' + esc(base) + 'assets/images/logo.png" alt=""><span>SoonDesign</span></a></div>' +
|
||||
'<div class="soon-portal-topbar__actions">' +
|
||||
toolsBlock('', '<a href="index.web.html" class="soon-portal-topbar__link">返回首页</a>') +
|
||||
'</div></header>';
|
||||
}
|
||||
|
||||
if (variant === 'design') {
|
||||
return '<header class="soon-portal-topbar soon-design-portal-bar' + extraCls + '">' +
|
||||
'<div class="soon-portal-topbar__brand"><a href="index.web.html">' +
|
||||
'<img src="' + esc(base) + 'assets/images/logo.png" alt=""><span>SoonDesign</span></a></div>' +
|
||||
'<div class="soon-portal-topbar__actions">' +
|
||||
toolsBlock('', '<a href="index.web.html" class="soon-portal-topbar__link">返回首页</a>') +
|
||||
authBlock() + '</div></header>';
|
||||
}
|
||||
|
||||
var active = opts.active || '';
|
||||
var showLang = opts.showLanguage !== false;
|
||||
var links = opts.links || STANDARD_LINKS;
|
||||
var lang = showLang
|
||||
? '<select id="language_select" class="soon-portal-topbar__lang" aria-label="语言">' +
|
||||
'<option value="zh">中文-简体</option><option value="ozh">中文-繁體</option><option value="en">English</option></select>'
|
||||
: '';
|
||||
|
||||
return '<header class="soon-portal-topbar' + extraCls + '">' +
|
||||
'<div class="soon-portal-topbar__brand"><a href="' + esc(opts.brandHref || 'index.web.html') + '">' +
|
||||
'<img src="' + esc(base) + 'assets/images/logo.png" alt=""><span>SoonDesign</span></a></div>' +
|
||||
'<div class="soon-portal-topbar__actions">' +
|
||||
toolsBlock(lang, renderLinks(links, active)) +
|
||||
authBlock() + '</div></header>';
|
||||
}
|
||||
|
||||
function mount(selector, opts) {
|
||||
var el = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
||||
if (!el) return;
|
||||
el.innerHTML = html(opts);
|
||||
if (opts && opts.initLang !== false) {
|
||||
var sel = document.getElementById('language_select');
|
||||
if (sel) {
|
||||
sel.value = localStorage.getItem('lang') || 'zh';
|
||||
sel.onchange = function () { localStorage.setItem('lang', sel.value); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.SoonPortalTopbar = { html: html, mount: mount };
|
||||
})();
|
||||
@@ -0,0 +1,20 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function soonToast(message, type) {
|
||||
if (typeof layer === 'undefined' || !layer.msg) return;
|
||||
var skin = 'soon-toast';
|
||||
if (type === 'success') skin += ' soon-toast--success';
|
||||
else if (type === 'error') skin += ' soon-toast--error';
|
||||
else if (type === 'warn') skin += ' soon-toast--warn';
|
||||
layer.msg(message, {
|
||||
time: 2400,
|
||||
shade: 0,
|
||||
offset: '72px',
|
||||
icon: -1,
|
||||
skin: skin
|
||||
});
|
||||
}
|
||||
|
||||
window.soonToast = soonToast;
|
||||
})();
|
||||
@@ -0,0 +1,212 @@
|
||||
// 公共工具函数模块
|
||||
|
||||
/**
|
||||
* DES加密函数
|
||||
*/
|
||||
function desEncrypt(str, key = "df6a551ca43181fc485f3043bcdd2fbc") {
|
||||
var APIFMS;
|
||||
try {
|
||||
var keyHex_encrypt = CryptoJS.enc.Utf8.parse(key);
|
||||
var encrypted = CryptoJS.DES.encrypt(str, keyHex_encrypt, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
APIFMS = CryptoJS.enc.Base64.stringify(encrypted.ciphertext);
|
||||
} catch (err) {
|
||||
console.log('des 加密 -------------------------');
|
||||
console.log(err);
|
||||
}
|
||||
return APIFMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取URL参数
|
||||
*/
|
||||
function GetFile() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期格式化
|
||||
*/
|
||||
function getDate() {
|
||||
let date = new Date();
|
||||
return date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象的绝对坐标
|
||||
*/
|
||||
function getAbsoluteXY(_obj) {
|
||||
let coord = _obj.get("lineCoords");
|
||||
return [_obj.get("left"), _obj.get("top")];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两点之间的距离
|
||||
*/
|
||||
function getDisdance(x1, y1, x2, y2) {
|
||||
var dx = Math.abs(x2 - x1); // 计算x轴上的距离差,并取绝对值
|
||||
var dy = Math.abs(y2 - y1); // 计算y轴上的距离差,并取绝对值
|
||||
var distance = Math.sqrt(dx * dx + dy * dy); // 应用勾股定理计算距离
|
||||
return distance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算角度(相对于画布中心)
|
||||
*/
|
||||
function getDeg(pointer, canvas) {
|
||||
// 计算点击位置与画布中心的坐标差
|
||||
var centerX = canvas.width / 2;
|
||||
var centerY = canvas.height / 2;
|
||||
var mouseX = pointer.x;
|
||||
var mouseY = pointer.y;
|
||||
var dx = mouseX - centerX;
|
||||
var dy = mouseY - centerY;
|
||||
// 计算旋转角度(弧度)
|
||||
var angleRad = Math.atan2(dy, dx);
|
||||
// 将角度转换为度数
|
||||
var angleDeg = angleRad * (180 / Math.PI);
|
||||
if (angleDeg < 0) {
|
||||
angleDeg += 360;
|
||||
}
|
||||
angleDeg += 90;
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字段名是否重复
|
||||
*/
|
||||
function checkName(name, objs1, objs2) {
|
||||
//检查字段名是否重复了
|
||||
for (let item of objs1) {
|
||||
if (item.name == name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let item of objs2) {
|
||||
if (item.name == name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不重复的字段名
|
||||
*/
|
||||
function resName(pre_name, objs1, objs2) {
|
||||
//返回的是不重复的字段名
|
||||
let num = (objs1.length + objs2.length) - 1;
|
||||
do {
|
||||
num++;
|
||||
}
|
||||
while (!checkName(pre_name + num, objs1, objs2));//重复了就再加1
|
||||
return pre_name + num;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象在画布中的索引
|
||||
*/
|
||||
function getIndex(target, canvas, _canvas = null) {
|
||||
let temp_objs;
|
||||
if (_canvas == null) {
|
||||
temp_objs = canvas.getObjects();
|
||||
} else {
|
||||
temp_objs = _canvas.getObjects();
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (let res of temp_objs) {
|
||||
if (target == res) {
|
||||
return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象类型
|
||||
*/
|
||||
function getType(target, canvas, objs) {
|
||||
let temp_objs = canvas.getObjects();
|
||||
let i = 0;
|
||||
for (let res of temp_objs) {
|
||||
if (target == res) {
|
||||
return objs[i].type;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取坐标的最小X值
|
||||
*/
|
||||
function getCoordsMinX(acoords) {
|
||||
let x = acoords[0].x;
|
||||
for (let item of acoords) {
|
||||
if (item.x < x) {
|
||||
x = item.x;
|
||||
}
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取坐标的最大X值
|
||||
*/
|
||||
function getCoordsMaxX(acoords) {
|
||||
let x = acoords[0].x;
|
||||
for (let item of acoords) {
|
||||
if (item.x > x) {
|
||||
x = item.x;
|
||||
}
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取坐标的最小Y值
|
||||
*/
|
||||
function getCoordsMinY(acoords) {
|
||||
let y = acoords[0].y;
|
||||
for (let item of acoords) {
|
||||
if (item.y < y) {
|
||||
y = item.y;
|
||||
}
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取坐标的最大Y值
|
||||
*/
|
||||
function getCoordsMaxY(acoords) {
|
||||
let y = acoords[0].y;
|
||||
for (let item of acoords) {
|
||||
if (item.y > y) {
|
||||
y = item.y;
|
||||
}
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
desEncrypt,
|
||||
GetFile,
|
||||
getDate,
|
||||
getAbsoluteXY,
|
||||
getDisdance,
|
||||
getDeg,
|
||||
checkName,
|
||||
resName,
|
||||
getIndex,
|
||||
getType,
|
||||
getCoordsMinX,
|
||||
getCoordsMaxX,
|
||||
getCoordsMinY,
|
||||
getCoordsMaxY
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user