5fb269cc0a
- 保存先写本地缓存,登录后可选云端同步;退出登录不再因文件操作跳转登录 - 修复 Layui 遮罩残留、design2 保存后线条、toast 被 finally 提前关闭 - 保存过程恢复 loading spinner;成功提示 2.5 秒 - 云端 payload 瘦身与体积超限提示;后端 schema 迁移与 FileService 容错 Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
var Api = function () { return window.AdminApi; };
|
|
var Ui = function () { return window.AdminUi; };
|
|
var I = function () { return window.AdminI18n; };
|
|
var Form = function () { return window.AdminForm; };
|
|
|
|
var MODAL_OPTS = { area: ['480px', 'auto'], skin: 'soon-layer soon-layer--tpl', maxWidth: '94vw' };
|
|
|
|
function activeBadge(v) {
|
|
var on = v === true || parseInt(v, 10) === 1;
|
|
return I().badgeStatus(on ? 'active' : 'disabled');
|
|
}
|
|
|
|
function typeLabel(type) {
|
|
return Number(type) === 2 ? '双面' : '单面';
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
n = parseInt(n, 10) || 0;
|
|
if (!n) return '—';
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
|
|
return (n / 1048576).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function getAdminBase() {
|
|
return ((window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.api_admin_base) || '').replace(/\/+$/, '');
|
|
}
|
|
|
|
function revokeBlob(state) {
|
|
if (state && state._blobUrl) {
|
|
try { URL.revokeObjectURL(state._blobUrl); } catch (e) { /* ignore */ }
|
|
state._blobUrl = '';
|
|
}
|
|
}
|
|
|
|
function nameFromFileName(fileName) {
|
|
return String(fileName || '').replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
|
|
}
|
|
|
|
function parseSoonMeta(text, fileName) {
|
|
var data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (e) {
|
|
throw new Error('invalid_json');
|
|
}
|
|
if (!data || typeof data !== 'object') throw new Error('invalid_json');
|
|
|
|
var type = 1;
|
|
if (Number(data.soonType) === 2 || data.backBlackPic) type = 2;
|
|
|
|
var thumbRaw = '';
|
|
if (data.frontDisplayPic && typeof data.frontDisplayPic === 'string') {
|
|
var candidate = data.frontDisplayPic.trim();
|
|
if (candidate.indexOf('data:image/') === 0) thumbRaw = candidate;
|
|
}
|
|
|
|
var name = '';
|
|
if (typeof data.name === 'string' && data.name.trim()) name = data.name.trim();
|
|
else if (typeof data.title === 'string' && data.title.trim()) name = data.title.trim();
|
|
else name = nameFromFileName(fileName);
|
|
if (!name) name = '未命名模板';
|
|
|
|
return { name: name, type: type, thumbRaw: thumbRaw, content: text };
|
|
}
|
|
|
|
function shrinkDataUrl(dataUrl, maxW, quality, done) {
|
|
if (!dataUrl) {
|
|
done('');
|
|
return;
|
|
}
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
var w = img.naturalWidth || img.width;
|
|
var h = img.naturalHeight || img.height;
|
|
if (!w || !h) {
|
|
done(dataUrl);
|
|
return;
|
|
}
|
|
if (w <= maxW) {
|
|
done(dataUrl);
|
|
return;
|
|
}
|
|
var nh = Math.max(1, Math.round(h * (maxW / w)));
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = maxW;
|
|
canvas.height = nh;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, maxW, nh);
|
|
try {
|
|
done(canvas.toDataURL('image/jpeg', quality));
|
|
} catch (e) {
|
|
done(dataUrl);
|
|
}
|
|
};
|
|
img.onerror = function () { done(''); };
|
|
img.src = dataUrl;
|
|
}
|
|
|
|
function setPreviewImage(modalState, src, renderPreview) {
|
|
modalState.thumbView = src || '';
|
|
renderPreview();
|
|
}
|
|
|
|
function applyThumbRaw(modalState, thumbRaw, renderPreview) {
|
|
if (!thumbRaw) {
|
|
setPreviewImage(modalState, '', renderPreview);
|
|
return;
|
|
}
|
|
if (thumbRaw.length < 700000) {
|
|
setPreviewImage(modalState, thumbRaw, renderPreview);
|
|
return;
|
|
}
|
|
shrinkDataUrl(thumbRaw, 360, 0.82, function (small) {
|
|
setPreviewImage(modalState, small || thumbRaw, renderPreview);
|
|
});
|
|
}
|
|
|
|
function readFileText(input) {
|
|
return new Promise(function (resolve, reject) {
|
|
var file = input && input.files && input.files[0];
|
|
if (!file) {
|
|
reject(new Error('no_file'));
|
|
return;
|
|
}
|
|
var reader = new FileReader();
|
|
reader.onload = function () { resolve({ text: String(reader.result || ''), fileName: file.name }); };
|
|
reader.onerror = function () { reject(new Error('read_failed')); };
|
|
reader.readAsText(file, 'UTF-8');
|
|
});
|
|
}
|
|
|
|
function fetchAdminThumb(plan, modalState, renderPreview) {
|
|
if (!plan || !plan.id || !Api().getToken()) {
|
|
renderPreview();
|
|
return;
|
|
}
|
|
revokeBlob(modalState);
|
|
window.fetch(getAdminBase() + '/templates/' + plan.id + '/thumb?_=' + Date.now(), {
|
|
headers: { Authorization: 'Bearer ' + Api().getToken() },
|
|
}).then(function (r) {
|
|
if (!r.ok) throw new Error('thumb');
|
|
return r.blob();
|
|
}).then(function (blob) {
|
|
modalState._blobUrl = URL.createObjectURL(blob);
|
|
setPreviewImage(modalState, modalState._blobUrl, renderPreview);
|
|
}).catch(function () {
|
|
renderPreview();
|
|
});
|
|
}
|
|
|
|
function openThumbPreview(src, title) {
|
|
if (!src || typeof layer === 'undefined') return;
|
|
layer.photos({
|
|
photos: {
|
|
title: title || '模板预览',
|
|
start: 0,
|
|
data: [{ alt: title || '模板预览', src: src }],
|
|
},
|
|
anim: 5,
|
|
shade: 0.88,
|
|
});
|
|
}
|
|
|
|
function modalHtml(opts) {
|
|
opts = opts || {};
|
|
var isEdit = opts.mode === 'edit';
|
|
var plan = opts.plan || {};
|
|
var pickLabel = isEdit ? '替换文件' : '选择文件';
|
|
|
|
return '<div class="soon-admin-tpl-modal">' +
|
|
'<button type="button" id="tplThumbBtn" class="soon-admin-tpl-card" disabled aria-label="预览缩略图">' +
|
|
'<span class="soon-admin-tpl-card__media">' +
|
|
'<img id="tplThumb" alt="" hidden>' +
|
|
'<span id="tplThumbEmpty" class="soon-admin-tpl-card__placeholder">选择 .soon 后显示预览</span>' +
|
|
'<span id="tplThumbHint" class="soon-admin-tpl-card__hint" hidden>点击放大</span></span>' +
|
|
'<span class="soon-admin-tpl-card__info">' +
|
|
'<span id="tplNameDisp" class="soon-admin-tpl-card__name">' + I().esc(isEdit ? (plan.name || '—') : '—') + '</span>' +
|
|
'<span id="tplTypeDisp" class="soon-admin-tpl-card__type">' +
|
|
(isEdit ? I().esc(typeLabel(plan.type)) : '—') + '</span></span></button>' +
|
|
'<label class="soon-admin-tpl-picker">' +
|
|
'<input type="file" id="tplFile" class="soon-admin-tpl-picker__input" accept=".soon,application/json">' +
|
|
'<span class="soon-btn soon-btn--sm soon-btn--ghost">' + I().esc(pickLabel) + '</span>' +
|
|
'<span id="tplFileLabel" class="soon-admin-tpl-picker__name">' +
|
|
(isEdit ? I().esc(plan.name + '.soon') : '未选择') + '</span></label>' +
|
|
'<div class="soon-admin-tpl-options">' +
|
|
'<label class="soon-admin-tpl-option"><span>排序</span>' +
|
|
Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 }) + '</label>' +
|
|
'<label class="soon-admin-tpl-option soon-admin-tpl-option--check">' +
|
|
'<input type="checkbox" id="tplActive"' + (plan.is_active !== false ? ' checked' : '') + '>' +
|
|
'<span>首页上架</span></label></div>' +
|
|
'<div class="soon-admin-tpl-modal__actions">' +
|
|
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">' +
|
|
(isEdit ? '保存' : '上传') + '</button></div></div>';
|
|
}
|
|
|
|
function bindTplModal(opts, modalState) {
|
|
var fileEl = Ui().el('tplFile');
|
|
var thumbBtn = Ui().el('tplThumbBtn');
|
|
var thumbEl = Ui().el('tplThumb');
|
|
var thumbEmptyEl = Ui().el('tplThumbEmpty');
|
|
var thumbHintEl = Ui().el('tplThumbHint');
|
|
var nameEl = Ui().el('tplNameDisp');
|
|
var typeEl = Ui().el('tplTypeDisp');
|
|
var labelEl = Ui().el('tplFileLabel');
|
|
var plan = opts.plan;
|
|
|
|
function renderPreview() {
|
|
var hasMeta = !!(modalState.name || modalState.type);
|
|
var src = modalState.thumbView || '';
|
|
nameEl.textContent = modalState.name || '—';
|
|
typeEl.textContent = hasMeta ? typeLabel(modalState.type) : '—';
|
|
|
|
if (src) {
|
|
thumbEl.src = src;
|
|
thumbEl.hidden = false;
|
|
thumbEmptyEl.hidden = true;
|
|
thumbHintEl.hidden = false;
|
|
thumbBtn.disabled = false;
|
|
thumbBtn.classList.add('is-ready');
|
|
} else {
|
|
thumbEl.hidden = true;
|
|
thumbEl.removeAttribute('src');
|
|
thumbEmptyEl.hidden = false;
|
|
thumbEmptyEl.textContent = hasMeta ? '无缩略图' : '选择 .soon 后显示预览';
|
|
thumbHintEl.hidden = true;
|
|
thumbBtn.disabled = true;
|
|
thumbBtn.classList.remove('is-ready');
|
|
}
|
|
}
|
|
|
|
if (thumbBtn) {
|
|
thumbBtn.onclick = function () {
|
|
if (!modalState.thumbView) return;
|
|
openThumbPreview(modalState.thumbView, modalState.name);
|
|
};
|
|
}
|
|
|
|
if (opts.mode === 'edit' && plan) {
|
|
modalState.name = plan.name;
|
|
modalState.type = plan.type;
|
|
modalState.thumbView = '';
|
|
modalState.content = null;
|
|
renderPreview();
|
|
fetchAdminThumb(plan, modalState, renderPreview);
|
|
}
|
|
|
|
if (!fileEl) return;
|
|
|
|
fileEl.onchange = function () {
|
|
var file = fileEl.files && fileEl.files[0];
|
|
if (!file) return;
|
|
labelEl.textContent = file.name;
|
|
readFileText(fileEl).then(function (payload) {
|
|
var meta = parseSoonMeta(payload.text, payload.fileName);
|
|
modalState.name = meta.name;
|
|
modalState.type = meta.type;
|
|
modalState.content = meta.content;
|
|
applyThumbRaw(modalState, meta.thumbRaw, renderPreview);
|
|
}).catch(function (e) {
|
|
fileEl.value = '';
|
|
labelEl.textContent = opts.mode === 'edit' ? (plan.name + '.soon') : '未选择';
|
|
if (e && e.message === 'invalid_json') soonToast('.soon 须为有效 JSON', 'warn');
|
|
else soonToast('文件读取失败', 'error');
|
|
});
|
|
};
|
|
}
|
|
|
|
function openCreateModal() {
|
|
var modalState = { content: null, name: '', type: 1, thumbView: '', _blobUrl: '' };
|
|
|
|
Ui().openFormModal('上传模板', modalHtml({ mode: 'create' }), function () {
|
|
if (!modalState.content) {
|
|
soonToast('请先选择 .soon 文件', 'warn');
|
|
return false;
|
|
}
|
|
return Api().postJson('templates', {
|
|
name: modalState.name,
|
|
content: modalState.content,
|
|
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
|
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
|
}).then(function (r) {
|
|
if (r) {
|
|
revokeBlob(modalState);
|
|
soonLayerDismiss();
|
|
soonToast('已上传', 'success');
|
|
render();
|
|
}
|
|
});
|
|
}, Object.assign({}, MODAL_OPTS, {
|
|
success: function () { bindTplModal({ mode: 'create' }, modalState); },
|
|
}));
|
|
}
|
|
|
|
function openEditModal(plan) {
|
|
var modalState = { content: null, name: plan.name, type: plan.type, thumbView: '', _blobUrl: '' };
|
|
|
|
Ui().openFormModal('编辑模板', modalHtml({ mode: 'edit', plan: plan }), function () {
|
|
var body = {
|
|
name: modalState.content ? modalState.name : plan.name,
|
|
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
|
|
is_active: Ui().el('tplActive').checked ? 1 : 0,
|
|
};
|
|
if (modalState.content) body.content = modalState.content;
|
|
return Api().putJson('templates/' + plan.id, body).then(function (r) {
|
|
if (r) {
|
|
revokeBlob(modalState);
|
|
soonLayerDismiss();
|
|
soonToast('已保存', 'success');
|
|
render();
|
|
}
|
|
});
|
|
}, Object.assign({}, MODAL_OPTS, {
|
|
success: function () { bindTplModal({ mode: 'edit', plan: plan }, modalState); },
|
|
}));
|
|
}
|
|
|
|
function deleteTemplate(plan) {
|
|
Ui().confirm('确定删除模板「' + plan.name + '」?', function () {
|
|
Api().del('templates/' + plan.id).then(function (r) {
|
|
if (r) {
|
|
soonToast('已删除', 'success');
|
|
render();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function render() {
|
|
var panel = Ui().el('panel-templates');
|
|
if (!panel) return;
|
|
panel.innerHTML = '<div class="soon-admin-page">' +
|
|
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
|
|
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
|
Ui().skeletonTable() + '</div>';
|
|
|
|
Api().get('templates').then(function (j) {
|
|
if (!j) return;
|
|
var items = j.data.items || [];
|
|
var rows = items.map(function (p) {
|
|
return '<tr data-id="' + p.id + '"><td>' + p.id + '</td><td>' + I().esc(p.name) +
|
|
'</td><td>' + typeLabel(p.type) + '</td><td>' + formatBytes(p.file_size) + '</td><td>' +
|
|
p.sort_order + '</td><td>' + (p.has_thumb ? '有' : '—') + '</td><td>' +
|
|
activeBadge(p.is_active) + '</td><td>' +
|
|
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-edit">编辑</button> ' +
|
|
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost tpl-del">删除</button></td></tr>';
|
|
}).join('');
|
|
|
|
panel.innerHTML = '<div class="soon-admin-page">' +
|
|
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
|
|
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
|
|
Ui().dataTable(
|
|
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],
|
|
rows
|
|
) + '</div>';
|
|
|
|
var planMap = {};
|
|
items.forEach(function (p) { planMap[p.id] = p; });
|
|
|
|
panel.querySelector('#tplCreate').onclick = openCreateModal;
|
|
panel.querySelectorAll('.tpl-edit').forEach(function (btn) {
|
|
btn.onclick = function () {
|
|
var id = parseInt(btn.closest('tr').dataset.id, 10);
|
|
if (planMap[id]) openEditModal(planMap[id]);
|
|
};
|
|
});
|
|
panel.querySelectorAll('.tpl-del').forEach(function (btn) {
|
|
btn.onclick = function () {
|
|
var id = parseInt(btn.closest('tr').dataset.id, 10);
|
|
if (planMap[id]) deleteTemplate(planMap[id]);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
window.AdminViews = window.AdminViews || {};
|
|
window.AdminViews.templates = { render: render };
|
|
})();
|