feat: support custom instance icons and refresh release

This commit is contained in:
shiyue
2026-08-25 22:11:07 +08:00
parent bd20a79d4b
commit 05480e511d
20 changed files with 1104 additions and 23 deletions

View File

@@ -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">