Web 端数据交互与 design2 布局修复

统一门户 JSON 契约:新增 GET /templates/{id},模板/云文件/保存/打开走虚拟 key;
layer 保存弹层替代 prompt,本地 .soon 已登录后台 POST 登记;修复保存静默失败与 design2 全宽布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-09 01:20:34 +08:00
parent ebe191b06d
commit 877fd278c2
27 changed files with 1232 additions and 326 deletions
@@ -6,6 +6,8 @@
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');
@@ -23,6 +25,99 @@
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];
@@ -31,106 +126,195 @@
return;
}
var reader = new FileReader();
reader.onload = function () { resolve(String(reader.result || '')); };
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 defaultNameFromFile(input) {
var file = input && input.files && input.files[0];
if (!file) return '';
return file.name.replace(/\.soon$/i, '').replace(/[_-]+/g, ' ').trim();
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 uploadFields(required) {
return Form().panel('模板文件',
Form().field('选择 .soon 文件',
'<input type="file" id="tplFile" class="soon-input soon-input--sm" accept=".soon,application/json">' +
(required ? '' : '<p class="soon-admin-form-hint soon-admin-form-hint--tight">留空则不替换已有文件</p>'),
required ? '须为有效的 .soon JSON 文件' : '')
);
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 metaFields(plan) {
plan = plan || {};
return Form().panel('展示设置',
Form().field('名称', Form().input('tplName', { value: plan.name || '' })) +
'<div class="soon-admin-form-grid">' +
Form().field('排序', Form().input('tplSort', { type: 'number', value: plan.sort_order != null ? plan.sort_order : 0 })) +
Form().field('类型', '<input class="soon-input soon-input--sm" value="' + I().esc(typeLabel(plan.type)) + '" disabled>') +
'</div>' +
'<label class="soon-admin-check"><input type="checkbox" id="tplActive"' +
(plan.is_active !== false ? ' checked' : '') + '> 上架展示</label>'
);
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 form = '<div class="soon-admin-form">' + uploadFields(true) + metaFields() +
Form().actions('上传', 'adminModalSave') + '</div>';
var modalState = { content: null, name: '', type: 1, thumbView: '', _blobUrl: '' };
Ui().openFormModal('上传模板', form, function () {
var nameEl = Ui().el('tplName');
var fileEl = Ui().el('tplFile');
var name = nameEl.value.trim() || defaultNameFromFile(fileEl);
if (!name) {
soonToast('请填写名称或选择文件', 'warn');
Ui().openFormModal('上传模板', modalHtml({ mode: 'create' }), function () {
if (!modalState.content) {
soonToast('请先选择 .soon 文件', 'warn');
return false;
}
return readFileText(fileEl).then(function (content) {
return Api().postJson('templates', {
name: name,
content: content,
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
is_active: Ui().el('tplActive').checked ? 1 : 0,
}).then(function (r) {
if (r) {
layer.closeAll();
soonToast('已上传', 'success');
render();
}
});
}).catch(function (e) {
if (e && e.message === 'no_file') soonToast('请选择 .soon 文件', 'warn');
else soonToast('文件读取失败', 'error');
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);
layer.closeAll();
soonToast('已上传', 'success');
render();
}
});
}, { area: ['480px', 'auto'] });
}, Object.assign({}, MODAL_OPTS, {
success: function () { bindTplModal({ mode: 'create' }, modalState); },
}));
}
function openEditModal(plan) {
var form = '<div class="soon-admin-form">' + metaFields(plan) + uploadFields(false) +
Form().actions('保存', 'adminModalSave') + '</div>';
var modalState = { content: null, name: plan.name, type: plan.type, thumbView: '', _blobUrl: '' };
Ui().openFormModal('编辑模板 · ' + plan.name, form, function () {
var name = Ui().el('tplName').value.trim();
if (!name) {
soonToast('名称不能为空', 'warn');
return false;
}
Ui().openFormModal('编辑模板', modalHtml({ mode: 'edit', plan: plan }), function () {
var body = {
name: name,
name: modalState.content ? modalState.name : plan.name,
sort_order: parseInt(Ui().el('tplSort').value, 10) || 0,
is_active: Ui().el('tplActive').checked ? 1 : 0,
};
var fileEl = Ui().el('tplFile');
var hasFile = fileEl && fileEl.files && fileEl.files[0];
var save = function (content) {
if (content) body.content = content;
return Api().putJson('templates/' + plan.id, body).then(function (r) {
if (r) {
layer.closeAll();
soonToast('已保存', 'success');
render();
}
});
};
if (hasFile) {
return readFileText(fileEl).then(save).catch(function () {
soonToast('文件读取失败', 'error');
});
}
return save(null);
}, { area: ['480px', 'auto'] });
if (modalState.content) body.content = modalState.content;
return Api().putJson('templates/' + plan.id, body).then(function (r) {
if (r) {
revokeBlob(modalState);
layer.closeAll();
soonToast('已保存', 'success');
render();
}
});
}, Object.assign({}, MODAL_OPTS, {
success: function () { bindTplModal({ mode: 'edit', plan: plan }, modalState); },
}));
}
function deleteTemplate(plan) {
@@ -148,7 +332,7 @@
var panel = Ui().el('panel-templates');
if (!panel) return;
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().skeletonTable() + '</div>';
@@ -165,7 +349,7 @@
}).join('');
panel.innerHTML = '<div class="soon-admin-page">' +
Ui().pageHeader('模板库', '管理首页展示的 .soon 模板(缩略图与名称轻量加载)',
Ui().pageHeader('模板库', '上传 .soon 后自动识别名称、类型与缩略图',
'<button type="button" class="soon-btn soon-btn--sm" id="tplCreate">上传模板</button>') +
Ui().dataTable(
['ID', '名称', '类型', '大小', '排序', '缩略图', '状态', '操作'],