重构 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,265 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TOKEN_KEY = 'soon_access';
|
||||
var REFRESH_KEY = 'soon_refresh';
|
||||
var refreshPromise = null;
|
||||
var scopeControllers = {};
|
||||
|
||||
function getAdminBase() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return (cfg.api_admin_base || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getV1Base() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return (cfg.api_v1_base || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function loginRedirectUrl() {
|
||||
var hash = location.hash || '';
|
||||
var redirect = 'index.html' + hash;
|
||||
return 'login.html?redirect=' + encodeURIComponent(redirect);
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
window.location.href = loginRedirectUrl();
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
var p = String(path || '').replace(/^\//, '');
|
||||
return getAdminBase() + '/' + p;
|
||||
}
|
||||
|
||||
function defaultScope() {
|
||||
if (window.AdminRouter && typeof AdminRouter.currentTab === 'function') {
|
||||
return AdminRouter.currentTab() || 'app';
|
||||
}
|
||||
return 'app';
|
||||
}
|
||||
|
||||
function abortScope(scope) {
|
||||
if (!scope) return;
|
||||
var ctrl = scopeControllers[scope];
|
||||
if (!ctrl) return;
|
||||
ctrl.abort();
|
||||
delete scopeControllers[scope];
|
||||
}
|
||||
|
||||
function attachScope(scope) {
|
||||
abortScope(scope);
|
||||
var ctrl = new AbortController();
|
||||
scopeControllers[scope] = ctrl;
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
function isAbortError(err) {
|
||||
return err && (err.name === 'AbortError' || err.code === 20);
|
||||
}
|
||||
|
||||
function refreshAccessToken() {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
var refresh = getRefreshToken();
|
||||
if (!refresh) {
|
||||
return Promise.reject(new Error('no_refresh_token'));
|
||||
}
|
||||
refreshPromise = window.fetch(getV1Base() + '/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(TOKEN_KEY, j.data.access_token);
|
||||
if (j.data.refresh_token) {
|
||||
localStorage.setItem(REFRESH_KEY, j.data.refresh_token);
|
||||
}
|
||||
return j.data.access_token;
|
||||
});
|
||||
}).finally(function () {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function sessionExpired() {
|
||||
clearToken();
|
||||
if (typeof soonToast === 'function') soonToast('登录已过期,请重新登录', 'warn');
|
||||
setTimeout(goLogin, 600);
|
||||
}
|
||||
|
||||
async function readJsonResponse(r, signal) {
|
||||
if (signal && signal.aborted) return null;
|
||||
var j;
|
||||
try {
|
||||
j = await r.json();
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
if (typeof soonToast === 'function') soonToast('响应解析失败', 'error');
|
||||
return null;
|
||||
}
|
||||
if (signal && signal.aborted) return null;
|
||||
return j;
|
||||
}
|
||||
|
||||
async function httpRequest(url, opts) {
|
||||
opts = opts || {};
|
||||
var signal = opts.signal;
|
||||
var headers = Object.assign({
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
'Content-Type': 'application/json',
|
||||
}, opts.headers || {});
|
||||
var reqOpts = Object.assign({}, opts, { headers: headers, signal: signal });
|
||||
delete reqOpts.scope;
|
||||
delete reqOpts.retry401;
|
||||
|
||||
try {
|
||||
return await window.fetch(url, reqOpts);
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithAuth(url, opts) {
|
||||
opts = opts || {};
|
||||
var signal = opts.signal;
|
||||
var r = await httpRequest(url, opts);
|
||||
if (!r) return null;
|
||||
|
||||
if (r.status === 401 && !opts.retry401) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch (e) {
|
||||
sessionExpired();
|
||||
return null;
|
||||
}
|
||||
var retryOpts = Object.assign({}, opts, { retry401: true });
|
||||
retryOpts.headers = Object.assign({}, opts.headers || {}, {
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
});
|
||||
r = await httpRequest(url, retryOpts);
|
||||
if (!r) return null;
|
||||
if (r.status === 401) {
|
||||
sessionExpired();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status === 403) {
|
||||
var forbidden = await readJsonResponse(r, signal);
|
||||
if (forbidden && typeof soonToast === 'function') {
|
||||
soonToast(forbidden.message || '无权限执行此操作', 'warn');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
async function fetch(path, opts) {
|
||||
opts = opts || {};
|
||||
var scope = opts.scope || defaultScope();
|
||||
var signal = opts.signal || attachScope(scope);
|
||||
var r;
|
||||
try {
|
||||
r = await requestWithAuth(apiUrl(path), Object.assign({}, opts, { signal: signal }));
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
if (typeof soonToast === 'function') soonToast('网络错误,请稍后重试', 'error');
|
||||
return null;
|
||||
}
|
||||
if (!r || (signal && signal.aborted)) return null;
|
||||
|
||||
var j = await readJsonResponse(r, signal);
|
||||
if (!j || (signal && signal.aborted)) return null;
|
||||
if (!j.ok) {
|
||||
if (typeof soonToast === 'function') soonToast(j.message || '操作失败', 'error');
|
||||
return null;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function get(path, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, { method: 'GET' }));
|
||||
}
|
||||
|
||||
async function postJson(path, body, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function putJson(path, body, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function del(path, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!getToken()) return null;
|
||||
var signal = attachScope('auth');
|
||||
var r;
|
||||
try {
|
||||
r = await requestWithAuth(getV1Base() + '/auth/me', {
|
||||
method: 'GET',
|
||||
signal: signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
return null;
|
||||
}
|
||||
if (!r || signal.aborted) return null;
|
||||
var j = await readJsonResponse(r, signal);
|
||||
if (!j || signal.aborted) return null;
|
||||
if (!j.ok || !j.data) return null;
|
||||
return j.data;
|
||||
}
|
||||
|
||||
window.AdminApi = {
|
||||
getToken: getToken,
|
||||
clearToken: clearToken,
|
||||
goLogin: goLogin,
|
||||
getV1Base: getV1Base,
|
||||
fetch: fetch,
|
||||
get: get,
|
||||
postJson: postJson,
|
||||
putJson: putJson,
|
||||
del: del,
|
||||
fetchMe: fetchMe,
|
||||
abortScope: abortScope,
|
||||
refreshAccessToken: refreshAccessToken,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STATUS = {
|
||||
active: '正常',
|
||||
disabled: '已停用',
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
expired: '已过期',
|
||||
};
|
||||
|
||||
var REFUND_STATUS = {
|
||||
none: '无申请',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
var ROLE = {
|
||||
user: '普通用户',
|
||||
admin: '管理员',
|
||||
};
|
||||
|
||||
var CHANNEL = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
};
|
||||
|
||||
var ACTION = {
|
||||
'stats.summary': '查看概览',
|
||||
'users.list': '浏览用户列表',
|
||||
'users.create': '创建用户',
|
||||
'users.update': '编辑用户',
|
||||
'users.delete': '删除用户',
|
||||
'users.detail': '查看用户详情',
|
||||
'users.setPassword': '重置用户密码',
|
||||
'users.setSubscription': '设置用户会员',
|
||||
'users.setStatus': '变更用户状态',
|
||||
'orders.list': '浏览订单列表',
|
||||
'orders.detail': '查看订单详情',
|
||||
'orders.cancel': '取消订单',
|
||||
'orders.refundApprove': '通过退款',
|
||||
'orders.refundReject': '拒绝退款',
|
||||
'plans.list': '浏览套餐列表',
|
||||
'plans.upsert': '编辑套餐',
|
||||
'settings.list': '浏览系统设置',
|
||||
'settings.set': '修改系统设置',
|
||||
'settings.set_batch': '批量修改系统设置',
|
||||
'payment.key.upload': '上传支付密钥',
|
||||
'payment.key.status': '查看支付密钥',
|
||||
};
|
||||
|
||||
var STATUS_BADGE = {
|
||||
active: 'soon-badge--active',
|
||||
disabled: 'soon-badge--disabled',
|
||||
pending: 'soon-badge--pending',
|
||||
paid: 'soon-badge--paid',
|
||||
cancelled: 'soon-badge--disabled',
|
||||
refunded: 'soon-badge--pending',
|
||||
expired: 'soon-badge--disabled',
|
||||
};
|
||||
|
||||
var REFUND_BADGE = {
|
||||
none: 'soon-badge--disabled',
|
||||
pending: 'soon-badge--pending',
|
||||
approved: 'soon-badge--paid',
|
||||
rejected: 'soon-badge--disabled',
|
||||
};
|
||||
|
||||
function t(map, v) {
|
||||
if (v == null || v === '') return '-';
|
||||
return map[v] || String(v);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (typeof soonEscapeHtml === 'function') return soonEscapeHtml(s);
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function badgeStatus(v) {
|
||||
var cls = STATUS_BADGE[v] || 'soon-badge--pending';
|
||||
return '<span class="soon-badge ' + cls + '">' + esc(t(STATUS, v)) + '</span>';
|
||||
}
|
||||
|
||||
function badgeRefundStatus(v) {
|
||||
var key = v || 'none';
|
||||
var cls = REFUND_BADGE[key] || 'soon-badge--disabled';
|
||||
return '<span class="soon-badge ' + cls + '">' + esc(t(REFUND_STATUS, key)) + '</span>';
|
||||
}
|
||||
|
||||
function formatCents(n) {
|
||||
var c = parseInt(n, 10) || 0;
|
||||
return '¥' + (c / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
var b = parseInt(n, 10) || 0;
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function formatDateTime(s) {
|
||||
if (!s) return '-';
|
||||
return String(s).replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
window.AdminI18n = {
|
||||
tStatus: function (v) { return t(STATUS, v); },
|
||||
tRole: function (v) { return t(ROLE, v); },
|
||||
tChannel: function (v) { return t(CHANNEL, v); },
|
||||
tAction: function (v) { return t(ACTION, v); },
|
||||
badgeStatus: badgeStatus,
|
||||
badgeRefundStatus: badgeRefundStatus,
|
||||
tRefundStatus: function (v) { return t(REFUND_STATUS, v || 'none'); },
|
||||
formatCents: formatCents,
|
||||
formatBytes: formatBytes,
|
||||
formatDateTime: formatDateTime,
|
||||
esc: esc,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FULL_ONLY = { settings: true, audits: true, payment: true };
|
||||
|
||||
function normalizeLevel(level) {
|
||||
return level === 'ops' ? 'ops' : 'full';
|
||||
}
|
||||
|
||||
function canAccessTab(tab, level) {
|
||||
level = normalizeLevel(level);
|
||||
if (level === 'full') return true;
|
||||
return !FULL_ONLY[tab];
|
||||
}
|
||||
|
||||
function defaultTab(level) {
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
function applyNav(level) {
|
||||
level = normalizeLevel(level);
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (el) {
|
||||
var req = el.getAttribute('data-require') || 'ops';
|
||||
var show = req !== 'full' || level === 'full';
|
||||
el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function filterQuickLinks(links, level) {
|
||||
level = normalizeLevel(level);
|
||||
return links.filter(function (l) {
|
||||
var tab = (l.href || '').replace(/^#\//, '').split(/[/?]/)[0];
|
||||
return canAccessTab(tab, level);
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminPermissions = {
|
||||
canAccessTab: canAccessTab,
|
||||
defaultTab: defaultTab,
|
||||
applyNav: applyNav,
|
||||
filterQuickLinks: filterQuickLinks,
|
||||
normalizeLevel: normalizeLevel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,297 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var State = function () { return window.AdminState; };
|
||||
var Perms = function () { return window.AdminPermissions; };
|
||||
|
||||
var ROUTES = {
|
||||
'': 'dashboard',
|
||||
'/': 'dashboard',
|
||||
'/dashboard': 'dashboard',
|
||||
'/users': 'users',
|
||||
'/orders': 'orders',
|
||||
'/plans': 'plans',
|
||||
'/settings': 'settings',
|
||||
'/audits': 'audits',
|
||||
'/payment': 'payment',
|
||||
};
|
||||
|
||||
var TAB_TO_PATH = {
|
||||
dashboard: '/dashboard',
|
||||
users: '/users',
|
||||
orders: '/orders',
|
||||
plans: '/plans',
|
||||
settings: '/settings',
|
||||
audits: '/audits',
|
||||
payment: '/payment',
|
||||
};
|
||||
|
||||
var DETAIL_TABS = { users: true, orders: true };
|
||||
|
||||
var FILTER_DEFS = {
|
||||
users: {
|
||||
stateKey: 'users',
|
||||
defaults: { page: 1, size: 20, q: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
},
|
||||
},
|
||||
orders: {
|
||||
stateKey: 'orders',
|
||||
defaults: { page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
status: 'string',
|
||||
refund_status: 'string',
|
||||
channel: 'string',
|
||||
from: 'string',
|
||||
to: 'string',
|
||||
},
|
||||
},
|
||||
audits: {
|
||||
stateKey: 'audits',
|
||||
defaults: { page: 1, size: 50, q: '', from: '', to: '', action: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
from: 'string',
|
||||
to: 'string',
|
||||
action: 'string',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
stateKey: 'settings',
|
||||
defaults: { tab: 'site', q: '' },
|
||||
fields: {
|
||||
tab: 'string',
|
||||
q: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var handlers = {};
|
||||
var currentTab = null;
|
||||
var pendingDetailId = null;
|
||||
var suppressHash = false;
|
||||
|
||||
function parseQuery(qs) {
|
||||
var out = {};
|
||||
if (!qs) return out;
|
||||
qs.split('&').forEach(function (pair) {
|
||||
if (!pair) return;
|
||||
var idx = pair.indexOf('=');
|
||||
var k = idx >= 0 ? pair.slice(0, idx) : pair;
|
||||
var v = idx >= 0 ? pair.slice(idx + 1) : '';
|
||||
try {
|
||||
k = decodeURIComponent(k.replace(/\+/g, ' '));
|
||||
v = decodeURIComponent(v.replace(/\+/g, ' '));
|
||||
} catch (e) { /* keep raw */ }
|
||||
if (k) out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
var raw = (location.hash || '#/dashboard').replace(/^#/, '');
|
||||
if (!raw.startsWith('/')) raw = '/' + raw;
|
||||
|
||||
var qIdx = raw.indexOf('?');
|
||||
var pathStr = qIdx >= 0 ? raw.substring(0, qIdx) : raw;
|
||||
var query = parseQuery(qIdx >= 0 ? raw.substring(qIdx + 1) : '');
|
||||
|
||||
var segments = pathStr.split('/').filter(function (s) { return s; });
|
||||
var head = segments[0] || 'dashboard';
|
||||
var tab = ROUTES['/' + head] || 'dashboard';
|
||||
var id = null;
|
||||
if (segments[1] && DETAIL_TABS[tab]) {
|
||||
var n = parseInt(segments[1], 10);
|
||||
if (!isNaN(n) && n > 0) id = n;
|
||||
}
|
||||
|
||||
return { tab: tab, id: id, query: query };
|
||||
}
|
||||
|
||||
function coerceField(type, val) {
|
||||
if (type === 'int') {
|
||||
var n = parseInt(val, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
return val == null ? '' : String(val);
|
||||
}
|
||||
|
||||
function applyUrlToState(loc) {
|
||||
loc = loc || parseHash();
|
||||
Object.keys(FILTER_DEFS).forEach(function (tab) {
|
||||
if (loc.tab !== tab) return;
|
||||
var def = FILTER_DEFS[tab];
|
||||
var st = State()[def.stateKey];
|
||||
if (!st) return;
|
||||
Object.keys(def.defaults).forEach(function (key) {
|
||||
st[key] = def.defaults[key];
|
||||
});
|
||||
Object.keys(def.fields).forEach(function (key) {
|
||||
if (!(key in loc.query)) return;
|
||||
var val = coerceField(def.fields[key], loc.query[key]);
|
||||
if (val == null) return;
|
||||
if (def.fields[key] === 'int' && val <= 0) return;
|
||||
st[key] = val;
|
||||
});
|
||||
if (tab === 'settings' && st.tab !== 'site' && st.tab !== 'all') {
|
||||
st.tab = 'site';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIncludeParam(key, val, defVal) {
|
||||
if (val == null || val === '') return false;
|
||||
if (typeof defVal === 'number') return val !== defVal;
|
||||
return String(val) !== String(defVal);
|
||||
}
|
||||
|
||||
function buildHash(tab, opts) {
|
||||
opts = opts || {};
|
||||
var path = TAB_TO_PATH[tab] || '/dashboard';
|
||||
if (opts.id && DETAIL_TABS[tab]) {
|
||||
path += '/' + opts.id;
|
||||
}
|
||||
|
||||
var def = FILTER_DEFS[tab];
|
||||
var qs = '';
|
||||
if (def) {
|
||||
var st = State()[def.stateKey] || {};
|
||||
var parts = [];
|
||||
Object.keys(def.fields).forEach(function (key) {
|
||||
var val = st[key];
|
||||
if (shouldIncludeParam(key, val, def.defaults[key])) {
|
||||
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(val)));
|
||||
}
|
||||
});
|
||||
if (parts.length) qs = '?' + parts.join('&');
|
||||
}
|
||||
|
||||
return '#' + path + qs;
|
||||
}
|
||||
|
||||
function setHash(hash) {
|
||||
if (location.hash === hash) return;
|
||||
suppressHash = true;
|
||||
location.hash = hash;
|
||||
suppressHash = false;
|
||||
}
|
||||
|
||||
function showTab(name, loc) {
|
||||
var level = State().adminLevel || 'full';
|
||||
if (Perms() && !Perms().canAccessTab(name, level)) {
|
||||
name = Perms().defaultTab(level);
|
||||
var safeHash = buildHash(name);
|
||||
if (location.hash !== safeHash) {
|
||||
setHash(safeHash);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!handlers[name]) name = 'dashboard';
|
||||
var prevTab = currentTab;
|
||||
if (prevTab && prevTab !== name && window.AdminApi && typeof AdminApi.abortScope === 'function') {
|
||||
AdminApi.abortScope(prevTab);
|
||||
}
|
||||
currentTab = name;
|
||||
pendingDetailId = loc && loc.id ? loc.id : null;
|
||||
|
||||
document.querySelectorAll('.panel').forEach(function (p) {
|
||||
p.classList.remove('is-visible');
|
||||
});
|
||||
var panel = document.getElementById('panel-' + name);
|
||||
if (panel) panel.classList.add('is-visible');
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (li) {
|
||||
li.classList.toggle('is-active', li.dataset.tab === name);
|
||||
});
|
||||
if (handlers[name] && typeof handlers[name].render === 'function') {
|
||||
handlers[name].render();
|
||||
}
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
var loc = parseHash();
|
||||
applyUrlToState(loc);
|
||||
showTab(loc.tab, loc);
|
||||
}
|
||||
|
||||
function navigate(tab) {
|
||||
pendingDetailId = null;
|
||||
var hash = buildHash(tab);
|
||||
if (location.hash === hash) {
|
||||
showTab(tab, parseHash());
|
||||
} else {
|
||||
setHash(hash);
|
||||
}
|
||||
}
|
||||
|
||||
function syncQuery(opts) {
|
||||
if (!currentTab) return;
|
||||
var hash = buildHash(currentTab, opts || {});
|
||||
setHash(hash);
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
if (!currentTab || !DETAIL_TABS[currentTab]) return;
|
||||
var n = parseInt(id, 10);
|
||||
if (isNaN(n) || n <= 0) return;
|
||||
setHash(buildHash(currentTab, { id: n }));
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
if (!currentTab || !DETAIL_TABS[currentTab]) return;
|
||||
var loc = parseHash();
|
||||
if (!loc.id) return;
|
||||
pendingDetailId = null;
|
||||
setHash(buildHash(currentTab));
|
||||
}
|
||||
|
||||
function hasPendingDetail() {
|
||||
return pendingDetailId != null;
|
||||
}
|
||||
|
||||
function consumeDetailId() {
|
||||
var id = pendingDetailId;
|
||||
pendingDetailId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
function onDrawerClosed() {
|
||||
closeDetail();
|
||||
}
|
||||
|
||||
function init(viewHandlers) {
|
||||
handlers = viewHandlers || {};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (li) {
|
||||
li.onclick = function () {
|
||||
navigate(li.dataset.tab);
|
||||
};
|
||||
});
|
||||
if (!location.hash) {
|
||||
setHash('#/dashboard');
|
||||
}
|
||||
var loc = parseHash();
|
||||
applyUrlToState(loc);
|
||||
showTab(loc.tab, loc);
|
||||
}
|
||||
|
||||
window.AdminRouter = {
|
||||
init: init,
|
||||
navigate: navigate,
|
||||
syncQuery: syncQuery,
|
||||
openDetail: openDetail,
|
||||
closeDetail: closeDetail,
|
||||
hasPendingDetail: hasPendingDetail,
|
||||
consumeDetailId: consumeDetailId,
|
||||
onDrawerClosed: onDrawerClosed,
|
||||
currentTab: function () { return currentTab; },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 分页与 URL 状态:docs/API-PAGINATION.md、AdminRouter
|
||||
|
||||
window.AdminState = {
|
||||
currentAdminId: null,
|
||||
adminLevel: 'full',
|
||||
users: { page: 1, size: 20, q: '' },
|
||||
orders: { page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '' },
|
||||
audits: { page: 1, size: 50, q: '', from: '', to: '', action: '' },
|
||||
settings: { tab: 'site', q: '' },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,246 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function pageHeader(title, desc, actionsHtml) {
|
||||
return '<div class="soon-admin-page-header">' +
|
||||
'<div class="soon-admin-page-header__main">' +
|
||||
'<h1>' + I().esc(title) + '</h1>' +
|
||||
(desc ? '<p class="soon-admin-page-header__desc">' + I().esc(desc) + '</p>' : '') +
|
||||
'</div>' +
|
||||
(actionsHtml ? '<div class="soon-admin-page-header__actions">' + actionsHtml + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rowsHtml, colSpan) {
|
||||
var th = headers.map(function (h) { return '<th>' + h + '</th>'; }).join('');
|
||||
var empty = '<tr><td colspan="' + (colSpan || headers.length) + '" class="soon-admin-empty-row">' +
|
||||
emptyBlock('暂无数据', '', '') + '</td></tr>';
|
||||
return '<div class="soon-admin-table-wrap"><table class="soon-admin-table"><thead><tr>' + th +
|
||||
'</tr></thead><tbody>' + (rowsHtml || empty) + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function emptyBlock(title, hint, actionHtml) {
|
||||
var html = '<div class="soon-empty soon-empty--inline">' +
|
||||
'<img src="../../assets/images/web/admin/empty.svg" alt="" class="soon-admin-empty-icon">' +
|
||||
'<p class="soon-empty__title">' + I().esc(title) + '</p>';
|
||||
if (hint) html += '<p class="soon-empty__hint">' + I().esc(hint) + '</p>';
|
||||
if (actionHtml) html += actionHtml;
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function skeletonStats(n) {
|
||||
var c = n || 4;
|
||||
var html = '<div class="soon-admin-stat-grid">';
|
||||
for (var i = 0; i < c; i++) {
|
||||
html += '<div class="soon-skeleton soon-skeleton--h80"></div>';
|
||||
}
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function skeletonTable() {
|
||||
return '<div class="soon-skeleton soon-skeleton--h120"></div>';
|
||||
}
|
||||
|
||||
function pagerHtml(page, size, total) {
|
||||
if (window.AdminPager) {
|
||||
return AdminPager.html({ page: page, size: size, total: total });
|
||||
}
|
||||
page = page || 1;
|
||||
size = size || 20;
|
||||
total = total || 0;
|
||||
var pages = Math.max(1, Math.ceil(total / size));
|
||||
return '<div class="soon-admin-pager">' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-prev"' +
|
||||
(page <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
||||
'<span class="soon-admin-pager__info">第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)</span>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-next"' +
|
||||
(page >= pages ? ' disabled' : '') + '>下一页</button></div>';
|
||||
}
|
||||
|
||||
function bindPager(container, page, size, total, onPageChange) {
|
||||
if (!container) return;
|
||||
if (window.AdminPager) {
|
||||
AdminPager.bind(container, { page: page, size: size, total: total }, function (chg) {
|
||||
if (chg.size && chg.size !== size) {
|
||||
onPageChange(1, chg.size);
|
||||
} else if (chg.page) {
|
||||
onPageChange(chg.page, size);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
var prev = container.querySelector('.soon-pager-prev');
|
||||
var next = container.querySelector('.soon-pager-next');
|
||||
if (prev && !prev.disabled) {
|
||||
prev.onclick = function () { onPageChange(Math.max(1, page - 1)); };
|
||||
}
|
||||
if (next && !next.disabled) {
|
||||
next.onclick = function () {
|
||||
var pages = Math.max(1, Math.ceil(total / size));
|
||||
onPageChange(Math.min(pages, page + 1));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(msg, onOk) {
|
||||
if (typeof layer === 'undefined') {
|
||||
if (window.confirm(msg)) onOk();
|
||||
return;
|
||||
}
|
||||
layer.confirm(msg, { skin: 'soon-layer', title: '确认' }, function (idx) {
|
||||
layer.close(idx);
|
||||
onOk();
|
||||
});
|
||||
}
|
||||
|
||||
function openDrawer(title, bodyHtml) {
|
||||
closeDrawer();
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'soon-admin-drawer-overlay';
|
||||
overlay.id = 'adminDrawerOverlay';
|
||||
overlay.onclick = closeDrawer;
|
||||
|
||||
var drawer = document.createElement('div');
|
||||
drawer.className = 'soon-admin-drawer';
|
||||
drawer.id = 'adminDrawer';
|
||||
drawer.innerHTML =
|
||||
'<div class="soon-admin-drawer__header">' +
|
||||
'<h3>' + I().esc(title) + '</h3>' +
|
||||
'<button type="button" class="soon-admin-drawer__close" aria-label="关闭">×</button>' +
|
||||
'</div><div class="soon-admin-drawer__body">' + bodyHtml + '</div>';
|
||||
|
||||
drawer.querySelector('.soon-admin-drawer__close').onclick = closeDrawer;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(drawer);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
var o = document.getElementById('adminDrawerOverlay');
|
||||
var d = document.getElementById('adminDrawer');
|
||||
if (o) o.remove();
|
||||
if (d) d.remove();
|
||||
if (window.AdminRouter && typeof AdminRouter.onDrawerClosed === 'function') {
|
||||
AdminRouter.onDrawerClosed();
|
||||
}
|
||||
}
|
||||
|
||||
function runFormSubmit(btn, handler, opts) {
|
||||
opts = opts || {};
|
||||
if (!btn || btn.disabled) return;
|
||||
var ret;
|
||||
try {
|
||||
ret = handler();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (ret === false) return;
|
||||
if (ret && typeof ret.then === 'function') {
|
||||
setBtnLoading(btn, true, opts.loadingLabel);
|
||||
ret.finally(function () {
|
||||
setBtnLoading(btn, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openFormModal(title, formHtml, onSave, opts) {
|
||||
if (typeof layer === 'undefined') return;
|
||||
opts = opts || {};
|
||||
layer.open({
|
||||
type: 1,
|
||||
skin: 'soon-layer',
|
||||
title: title,
|
||||
area: opts.area || ['420px', 'auto'],
|
||||
maxWidth: opts.maxWidth || '96vw',
|
||||
content: formHtml,
|
||||
success: function (layero) {
|
||||
var area = opts.area || ['420px', 'auto'];
|
||||
if (area[1] && area[1] !== 'auto') {
|
||||
var contentEl = null;
|
||||
if (layero && typeof layero.find === 'function') {
|
||||
contentEl = layero.find('.layui-layer-content')[0];
|
||||
} else if (layero && layero.querySelector) {
|
||||
contentEl = layero.querySelector('.layui-layer-content');
|
||||
}
|
||||
if (contentEl) contentEl.classList.add('soon-layer-content--scroll');
|
||||
}
|
||||
var btn = document.getElementById('adminModalSave');
|
||||
if (btn) {
|
||||
btn.onclick = function () {
|
||||
runFormSubmit(btn, onSave, opts);
|
||||
};
|
||||
}
|
||||
if (typeof opts.success === 'function') opts.success();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bindFormSubmit(btnOrId, handler, loadingLabel) {
|
||||
var btn = typeof btnOrId === 'string' ? document.getElementById(btnOrId) : btnOrId;
|
||||
if (!btn) return;
|
||||
btn.onclick = function () {
|
||||
runFormSubmit(btn, handler, { loadingLabel: loadingLabel });
|
||||
};
|
||||
}
|
||||
|
||||
function detailSection(title, rowsHtml) {
|
||||
return '<div class="soon-admin-detail-section"><h4>' + I().esc(title) + '</h4>' + rowsHtml + '</div>';
|
||||
}
|
||||
|
||||
function detailRow(label, value) {
|
||||
return '<div class="soon-admin-detail-row"><span class="soon-admin-detail-row__label">' +
|
||||
I().esc(label) + '</span><span class="soon-admin-detail-row__value">' + value + '</span></div>';
|
||||
}
|
||||
|
||||
function filterDateField(id, label, value) {
|
||||
return '<div class="soon-admin-filter-field">' +
|
||||
'<span class="soon-admin-filter-field__label">' + I().esc(label) + '</span>' +
|
||||
'<span class="soon-admin-date-wrap">' +
|
||||
'<input type="date" id="' + id + '" class="soon-input soon-input--sm soon-input--date" value="' + I().esc(value || '') + '">' +
|
||||
'</span></div>';
|
||||
}
|
||||
|
||||
function listError(message, retryBtnId) {
|
||||
return '<div class="soon-admin-list-error">' +
|
||||
'<p>' + I().esc(message || '加载失败') + '</p>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="' + (retryBtnId || 'listRetry') + '">重试</button></div>';
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, loading, label) {
|
||||
if (!btn) return;
|
||||
if (loading) {
|
||||
if (!btn.dataset.soonLabel) btn.dataset.soonLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = label || '处理中…';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
if (btn.dataset.soonLabel) btn.textContent = btn.dataset.soonLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
window.AdminUi = {
|
||||
pageHeader: pageHeader,
|
||||
dataTable: dataTable,
|
||||
emptyBlock: emptyBlock,
|
||||
skeletonStats: skeletonStats,
|
||||
skeletonTable: skeletonTable,
|
||||
pagerHtml: pagerHtml,
|
||||
bindPager: bindPager,
|
||||
confirm: confirm,
|
||||
openDrawer: openDrawer,
|
||||
closeDrawer: closeDrawer,
|
||||
openFormModal: openFormModal,
|
||||
runFormSubmit: runFormSubmit,
|
||||
bindFormSubmit: bindFormSubmit,
|
||||
detailSection: detailSection,
|
||||
detailRow: detailRow,
|
||||
filterDateField: filterDateField,
|
||||
listError: listError,
|
||||
setBtnLoading: setBtnLoading,
|
||||
el: el,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user