feat: support custom instance icons and refresh release
This commit is contained in:
194
public/app.js
194
public/app.js
@@ -232,6 +232,7 @@
|
||||
let currentAgent = DEFAULT_AGENT;
|
||||
let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi');
|
||||
let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0';
|
||||
let instanceIconConfig = { custom: false, version: 'default', updatedAt: null, url: '/api/instance-icon?v=default' };
|
||||
let codexConfigCache = null;
|
||||
let loadedHistorySessionId = null;
|
||||
let activeSessionLoad = null;
|
||||
@@ -1265,9 +1266,197 @@
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="instance-icon-settings">
|
||||
<img class="instance-icon-preview" data-instance-icon data-instance-icon-preview src="${escapeHtml(instanceIconUrl())}" alt="当前实例图标">
|
||||
<div class="instance-icon-settings-main">
|
||||
<div class="settings-nav-card-title">实例图标</div>
|
||||
<div class="settings-nav-card-meta" data-instance-icon-summary>正在读取当前图标…</div>
|
||||
<input type="file" data-instance-icon-file accept="image/png,image/jpeg,image/webp" hidden>
|
||||
<div class="settings-actions instance-icon-actions">
|
||||
<button class="btn-save" type="button" data-instance-icon-select>选择图标</button>
|
||||
<button class="btn-test" type="button" data-instance-icon-reset>恢复默认</button>
|
||||
</div>
|
||||
<div class="settings-status instance-icon-status" data-instance-icon-status></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function instanceIconUrl(config = instanceIconConfig) {
|
||||
const version = String(config?.version || 'default').trim();
|
||||
return `/api/instance-icon?v=${encodeURIComponent(version || 'default')}`;
|
||||
}
|
||||
|
||||
function applyInstanceIconConfig(config) {
|
||||
const next = {
|
||||
custom: config?.custom === true,
|
||||
version: String(config?.version || 'default'),
|
||||
updatedAt: config?.updatedAt || null,
|
||||
};
|
||||
next.url = instanceIconUrl(next);
|
||||
instanceIconConfig = next;
|
||||
document.querySelectorAll('[data-instance-icon]').forEach((node) => {
|
||||
if (node instanceof HTMLImageElement) node.src = next.url;
|
||||
});
|
||||
document.querySelectorAll('[data-instance-icon-link]').forEach((node) => {
|
||||
if (node instanceof HTMLLinkElement) node.href = next.url;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async function loadInstanceIconConfig() {
|
||||
const response = await fetch('/api/instance-icon/config', { cache: 'no-store' });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(payload?.message || `读取实例图标失败 (${response.status})`);
|
||||
}
|
||||
return applyInstanceIconConfig(payload);
|
||||
}
|
||||
|
||||
async function cropImageFileToInstancePng(file) {
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
if (!file || !allowedTypes.includes(file.type)) {
|
||||
throw new Error('请选择 PNG、JPG 或 WebP 图片');
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = () => reject(new Error('无法读取所选图片'));
|
||||
image.src = objectUrl;
|
||||
});
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!width || !height) throw new Error('所选图片没有有效尺寸');
|
||||
const side = Math.min(width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('当前浏览器无法处理图标图片');
|
||||
context.clearRect(0, 0, 512, 512);
|
||||
context.drawImage(
|
||||
image,
|
||||
(width - side) / 2,
|
||||
(height - side) / 2,
|
||||
side,
|
||||
side,
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512,
|
||||
);
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) resolve(result);
|
||||
else reject(new Error('生成 PNG 图标失败'));
|
||||
}, 'image/png');
|
||||
});
|
||||
if (blob.size > 4 * 1024 * 1024) throw new Error('处理后的图标超过 4MB');
|
||||
return blob;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadInstanceIcon(blob) {
|
||||
await ensureAuthenticatedWs();
|
||||
const response = await fetch('/api/instance-icon', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(payload?.message || `保存实例图标失败 (${response.status})`);
|
||||
}
|
||||
return applyInstanceIconConfig(payload);
|
||||
}
|
||||
|
||||
async function resetInstanceIconToDefault() {
|
||||
await ensureAuthenticatedWs();
|
||||
const response = await fetch('/api/instance-icon', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(payload?.message || `恢复默认图标失败 (${response.status})`);
|
||||
}
|
||||
return applyInstanceIconConfig(payload);
|
||||
}
|
||||
|
||||
function mountInstanceIconSettings(panel) {
|
||||
const fileInput = panel.querySelector('[data-instance-icon-file]');
|
||||
const selectBtn = panel.querySelector('[data-instance-icon-select]');
|
||||
const resetBtn = panel.querySelector('[data-instance-icon-reset]');
|
||||
const summary = panel.querySelector('[data-instance-icon-summary]');
|
||||
const status = panel.querySelector('[data-instance-icon-status]');
|
||||
if (!fileInput || !selectBtn || !resetBtn || !summary || !status) return;
|
||||
|
||||
const showState = (config) => {
|
||||
summary.textContent = config.custom
|
||||
? `已自定义${config.updatedAt ? ` · ${new Date(config.updatedAt).toLocaleString()}` : ''}`
|
||||
: '使用 CC-Web 默认图标';
|
||||
resetBtn.disabled = !config.custom;
|
||||
};
|
||||
const showStatus = (message = '', type = '') => {
|
||||
status.textContent = message;
|
||||
status.className = `settings-status instance-icon-status ${type}`.trim();
|
||||
};
|
||||
const setBusy = (busy) => {
|
||||
selectBtn.disabled = busy;
|
||||
resetBtn.disabled = busy || !instanceIconConfig.custom;
|
||||
};
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
fileInput.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
showStatus('正在处理并保存图标…');
|
||||
try {
|
||||
const blob = await cropImageFileToInstancePng(file);
|
||||
const config = await uploadInstanceIcon(blob);
|
||||
showState(config);
|
||||
showStatus('实例图标已保存', 'success');
|
||||
} catch (err) {
|
||||
showStatus(err?.message || '保存实例图标失败', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
if (!instanceIconConfig.custom || !confirm('确认恢复 CC-Web 默认图标?')) return;
|
||||
setBusy(true);
|
||||
showStatus('正在恢复默认图标…');
|
||||
try {
|
||||
const config = await resetInstanceIconToDefault();
|
||||
showState(config);
|
||||
showStatus('已恢复默认图标', 'success');
|
||||
} catch (err) {
|
||||
showStatus(err?.message || '恢复默认图标失败', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
loadInstanceIconConfig().then(showState).catch((err) => {
|
||||
showState(instanceIconConfig);
|
||||
showStatus(err?.message || '读取实例图标失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
loadInstanceIconConfig().catch(() => {
|
||||
// 登录页仍可通过服务端默认回退显示图标,配置读取失败不阻断主界面。
|
||||
});
|
||||
|
||||
function mountAppearanceSettings(panel) {
|
||||
const themePageBtn = panel.querySelector('[data-open-theme-page]');
|
||||
if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
|
||||
@@ -1279,6 +1468,7 @@
|
||||
});
|
||||
}
|
||||
refreshDividerTimeControls(panel);
|
||||
mountInstanceIconSettings(panel);
|
||||
}
|
||||
|
||||
function buildNotifyEntryHtml(config) {
|
||||
@@ -11920,7 +12110,7 @@
|
||||
navigator.serviceWorker.ready.then((reg) => {
|
||||
reg.showNotification('CC-Web', {
|
||||
body: `「${title}」任务完成`,
|
||||
icon: '/icon-192.png',
|
||||
icon: instanceIconUrl(),
|
||||
tag: 'cc-web-task',
|
||||
renotify: true,
|
||||
});
|
||||
@@ -12973,7 +13163,7 @@
|
||||
panel.className = 'force-change-panel';
|
||||
|
||||
panel.innerHTML = `
|
||||
<img class="login-logo" src="icon-192.png" alt="CC-Web">
|
||||
<img class="login-logo" data-instance-icon src="${escapeHtml(instanceIconUrl())}" alt="CC-Web">
|
||||
<h2>修改初始密码</h2>
|
||||
<p>首次登录需要设置新密码</p>
|
||||
<div class="force-change-form">
|
||||
|
||||
Reference in New Issue
Block a user