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">
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
<meta name="application-name" content="CC-Web">
|
||||
<meta name="theme-color" content="#020c16">
|
||||
<title>CC-Web</title>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link data-instance-icon-link rel="icon" type="image/png" href="/api/instance-icon?v=default">
|
||||
<link data-instance-icon-link rel="apple-touch-icon" href="/api/instance-icon?v=default">
|
||||
<link rel="manifest" href="/api/site.webmanifest">
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('cc-web-theme') || 'washi';
|
||||
@@ -33,7 +32,7 @@
|
||||
<!-- Login -->
|
||||
<div id="login-overlay" class="login-overlay">
|
||||
<div class="login-box">
|
||||
<img class="login-logo" src="icon-192.png" alt="CC-Web">
|
||||
<img class="login-logo" data-instance-icon src="/api/instance-icon?v=default" alt="CC-Web">
|
||||
<h2>CC-Web</h2>
|
||||
<p>Claude / Codex Web Chat</p>
|
||||
<form id="login-form">
|
||||
|
||||
@@ -5255,6 +5255,49 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.instance-icon-settings {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-card-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.instance-icon-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex: 0 0 64px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 4px 12px rgba(45, 31, 20, 0.12);
|
||||
}
|
||||
.instance-icon-settings-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.instance-icon-actions {
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.instance-icon-actions button {
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.instance-icon-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.instance-icon-status {
|
||||
min-height: 18px;
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings-toggle-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5443,6 +5486,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
||||
.settings-nav-card { padding: 13px 14px; }
|
||||
.settings-back { width: 32px; height: 32px; }
|
||||
.settings-retry-grid { grid-template-columns: 1fr; }
|
||||
.instance-icon-settings { align-items: flex-start; }
|
||||
.instance-icon-preview { width: 56px; height: 56px; flex-basis: 56px; }
|
||||
}
|
||||
|
||||
/* === Force Change Password Overlay === */
|
||||
|
||||
@@ -4,7 +4,7 @@ self.addEventListener('message', (event) => {
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(event.data.title || 'CC-Web', {
|
||||
body: event.data.body || '',
|
||||
icon: event.data.icon || '/icon-192.png',
|
||||
icon: event.data.icon || '/api/instance-icon?v=default',
|
||||
tag: 'cc-web-task',
|
||||
renotify: true,
|
||||
data: event.data.data || {},
|
||||
|
||||
Reference in New Issue
Block a user