feat(v1.2.0): 自定义实例图标——内置精选 + 上传裁剪

- AppIcon:内置精选平台图标(微信/Chromium/Telegram/小红书/抖音/B站/微博/知乎/YouTube/通用)+ ICON_CHOICES。
- 编辑器(管理菜单「图标」):选内置图标 / 上传图片用 react-easy-crop 方形裁剪→128px PNG / 恢复默认。
- 后端 setInstanceIcon + /api/admin/instances/:id/icon(仅 admin;icon=builtin:<key>/data:图片/空,限 ~225KB)。
- 新增依赖 react-easy-crop。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 19:41:40 +08:00
Unverified
parent 1ffc6d7e1d
commit 1c230316ed
8 changed files with 270 additions and 0 deletions
+12
View File
@@ -26,6 +26,7 @@ import {
createInstance,
removeInstance as removeInstanceRecord,
renameInstance,
setInstanceIcon,
setInstanceUsers,
publicInstance,
APP_TYPES,
@@ -449,6 +450,17 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
}
});
// 设置实例自定义图标(仅管理员):icon = builtin:<key> / data:image 图片 / 空串(恢复默认)。
app.post('/api/admin/instances/:id/icon', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
const { icon } = (req.body as any) ?? {};
try {
return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) };
} catch (e: any) {
return reply.code(400).send({ error: e?.message || '设置图标失败' });
}
});
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
app.post('/api/admin/instances/:id/start', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
+17
View File
@@ -330,6 +330,23 @@ export function renameInstance(id: string, name: string) {
return publicInstance(inst);
}
// 设置/清除实例自定义图标。传空 → 恢复按 appType 的默认图标。
// 仅允许 builtin:<key> 或 data:image/...(裁剪后约 128px,限 ~225KB,防滥用撑大 accounts.json)。
export function setInstanceIcon(id: string, icon: string | null) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
const v = (icon ?? '').trim();
if (!v) {
delete inst.icon;
} else if (/^builtin:[a-z0-9_-]{1,32}$/.test(v) || (v.startsWith('data:image/') && v.length <= 300000)) {
inst.icon = v;
} else {
throw new Error('图标格式不合法或过大');
}
persist();
return publicInstance(inst);
}
export function removeInstance(id: string) {
const inst = findInstance(id);
if (!inst) throw new Error('实例不存在');
+20
View File
@@ -8,6 +8,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
@@ -4515,6 +4516,12 @@
"node": ">=18"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4735,6 +4742,19 @@
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-easy-crop": "^6.0.2",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
+27
View File
@@ -32,14 +32,41 @@ const dots = (
</g>
);
// 文字字形(品牌色块 + 白字),用于没有简单标志的平台
const txt = (s: string, fs = 22) => (
<text x="24" y="25" fill="#fff" fontSize={fs} fontWeight="700" textAnchor="middle" dominantBaseline="central" fontFamily="-apple-system, system-ui, sans-serif">
{s}
</text>
);
const play = <path fill="#fff" d="M20 17l12 7-12 7z" />;
// key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。
export const BUILTIN_ICONS: Record<string, Glyph> = {
wechat: G('#07c160', chat),
chromium: G('#4285f4', globe),
telegram: G('#2aabee', plane),
xiaohongshu: G('#ff2442', txt('书')),
douyin: G('#111111', txt('抖')),
bilibili: G('#fb7299', txt('B', 26)),
weibo: G('#e6162d', txt('微')),
zhihu: G('#0084ff', txt('知')),
youtube: G('#ff0000', play),
globe: G('#5b8def', globe),
app: G('#8a9099', dots),
};
// 「内置图标」选择器里展示的可选项(顺序即展示顺序)
export const ICON_CHOICES: { key: string; label: string }[] = [
{ key: 'wechat', label: '微信' },
{ key: 'chromium', label: 'Chromium' },
{ key: 'telegram', label: 'Telegram' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'douyin', label: '抖音' },
{ key: 'bilibili', label: 'B站' },
{ key: 'weibo', label: '微博' },
{ key: 'zhihu', label: '知乎' },
{ key: 'youtube', label: 'YouTube' },
{ key: 'globe', label: '通用' },
];
const DEFAULT_BY_APP: Record<AppType, string> = {
wechat: 'wechat',
chromium: 'chromium',
+2
View File
@@ -155,6 +155,8 @@ export const api = {
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
setInstanceIcon: (id: string, icon: string | null) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
+145
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Cropper from 'react-easy-crop';
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
@@ -95,6 +97,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
const [securityInst, setSecurityInst] = useState<InstanceWithStatus | null>(null); // 安全(内存阈值)弹窗
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
const [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 未使用的旧数据卷(来自之前删实例时未勾选"彻底清除"):允许复用以继承聊天记录,或显式删除。
const [orphanVols, setOrphanVols] = useState<{ name: string; createdAt?: string; sizeBytes?: number }[]>([]);
@@ -306,6 +309,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
onDelete={() => setDeleteInst(inst)}
onSecurity={() => setSecurityInst(inst)}
onVolume={() => setVolumeInst(inst)}
onIcon={() => setIconInst(inst)}
/>
))}
</div>
@@ -531,6 +535,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{volumeInst && (
<VolumeManager inst={volumeInst} onClose={() => setVolumeInst(null)} onChanged={load} />
)}
{iconInst && (
<InstanceIconEditor
inst={iconInst}
onClose={() => setIconInst(null)}
onDone={() => {
toast('已更新图标', 'ok');
load();
}}
/>
)}
</div>
);
}
@@ -866,6 +880,7 @@ function InstanceAdminCard({
onDelete,
onSecurity,
onVolume,
onIcon,
}: {
inst: InstanceWithStatus;
userCount: number;
@@ -881,6 +896,7 @@ function InstanceAdminCard({
onDelete: () => void;
onSecurity: () => void;
onVolume: () => void;
onIcon: () => void;
}) {
const wx = inst.wechat;
const busy = BUSY_PHASES.includes(wx.phase);
@@ -997,6 +1013,9 @@ function InstanceAdminCard({
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
</button>
<button className="btn-text" onClick={onIcon} title="设置实例图标:内置图标 / 上传图片裁剪">
</button>
<button className="btn-text" onClick={onVolume} title="数据卷:备份/恢复、上传 PC 微信数据、文件管理">
</button>
@@ -1018,6 +1037,132 @@ function InstanceAdminCard({
);
}
// 把裁剪区域画到 128px 画布并导出 PNG dataURL(存进 inst.icon
async function cropToDataUrl(src: string, area: { x: number; y: number; width: number; height: number }): Promise<string> {
const img = await new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = rej;
i.src = src;
});
const SIZE = 128;
const c = document.createElement('canvas');
c.width = SIZE;
c.height = SIZE;
c.getContext('2d')!.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, SIZE, SIZE);
return c.toDataURL('image/png');
}
// 实例图标编辑:选内置图标 / 上传图片裁剪 / 恢复默认。
function InstanceIconEditor({ inst, onClose, onDone }: { inst: InstanceWithStatus; onClose: () => void; onDone: () => void }) {
const { toast } = useUI();
const [sel, setSel] = useState<string>(inst.icon || ''); // '' = 按应用默认
const [busy, setBusy] = useState(false);
const [cropSrc, setCropSrc] = useState(''); // 非空 = 裁剪态
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [area, setArea] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
e.target.value = '';
if (!f) return;
if (!f.type.startsWith('image/')) return toast('请选择图片文件', 'error');
if (f.size > 8 * 1024 * 1024) return toast('图片过大(>8MB', 'error');
const r = new FileReader();
r.onload = () => {
setCropSrc(String(r.result));
setCrop({ x: 0, y: 0 });
setZoom(1);
};
r.readAsDataURL(f);
};
const confirmCrop = async () => {
if (!cropSrc || !area) return;
try {
setSel(await cropToDataUrl(cropSrc, area));
setCropSrc('');
} catch {
toast('裁剪失败', 'error');
}
};
const save = async () => {
setBusy(true);
try {
await api.setInstanceIcon(inst.id, sel || null);
onDone();
onClose();
} catch (e: any) {
toast(e?.message || '保存失败', 'error');
} finally {
setBusy(false);
}
};
return (
<div className="modal-mask" onClick={onClose}>
<div className="card modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 460 }}>
<h2> · {inst.name}</h2>
{cropSrc ? (
<>
<div className="icon-crop">
<Cropper
image={cropSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, a) => setArea(a)}
/>
</div>
<input type="range" min={1} max={3} step={0.01} value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
<div className="modal-actions">
<button type="button" className="btn" onClick={() => setCropSrc('')}></button>
<button type="button" className="btn btn-primary" onClick={confirmCrop}>使</button>
</div>
</>
) : (
<>
<div className="icon-edit-top">
<InstanceIcon icon={sel || undefined} appType={inst.appType} size={56} radius={14} />
<div className="muted small">{sel.startsWith('data:') ? '自定义图片' : sel.startsWith('builtin:') ? '内置图标' : '按应用默认'}</div>
</div>
<div className="field-label"></div>
<div className="icon-grid">
<button type="button" className={'icon-pick' + (sel === '' ? ' sel' : '')} onClick={() => setSel('')}>
<InstanceIcon appType={inst.appType} size={38} radius={11} />
<span></span>
</button>
{ICON_CHOICES.map((c) => (
<button
type="button"
key={c.key}
className={'icon-pick' + (sel === `builtin:${c.key}` ? ' sel' : '')}
onClick={() => setSel(`builtin:${c.key}`)}
>
<InstanceIcon icon={`builtin:${c.key}`} size={38} radius={11} />
<span>{c.label}</span>
</button>
))}
</div>
<button type="button" className="btn" onClick={() => fileRef.current?.click()}></button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPickFile} />
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} disabled={busy}></button>
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}></button>
</div>
</>
)}
</div>
</div>
);
}
// 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。
// 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作
// (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
+46
View File
@@ -2066,3 +2066,49 @@ button {
font-size: 11px;
color: var(--muted);
}
/* ── 实例图标编辑 ─────────────────────────────────────── */
.icon-edit-top {
display: flex;
align-items: center;
gap: 12px;
}
.icon-crop {
position: relative;
height: 240px;
border-radius: var(--r-small);
overflow: hidden;
background: #1a1d24;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
gap: 8px;
}
.icon-pick {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border: none;
border-radius: var(--r-small);
background: var(--trough);
cursor: pointer;
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
transition: background 0.15s, box-shadow 0.15s, transform 0.12s;
}
.icon-pick span {
font-size: 11px;
color: var(--muted);
}
.icon-pick.sel {
background: var(--surface);
box-shadow: var(--crease-accent);
}
.icon-pick.sel span {
color: var(--wx-green-dark);
}
.icon-pick:active {
transform: scale(0.96);
}