From 1c230316ed36d377c1875622e7da7f5fffb6250d Mon Sep 17 00:00:00 2001 From: Gloridust Date: Sun, 14 Jun 2026 19:41:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(v1.2.0):=20=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=9B=BE=E6=A0=87=E2=80=94=E2=80=94=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E7=B2=BE=E9=80=89=20+=20=E4=B8=8A=E4=BC=A0=E8=A3=81?= =?UTF-8?q?=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppIcon:内置精选平台图标(微信/Chromium/Telegram/小红书/抖音/B站/微博/知乎/YouTube/通用)+ ICON_CHOICES。 - 编辑器(管理菜单「图标」):选内置图标 / 上传图片用 react-easy-crop 方形裁剪→128px PNG / 恢复默认。 - 后端 setInstanceIcon + /api/admin/instances/:id/icon(仅 admin;icon=builtin:/data:图片/空,限 ~225KB)。 - 新增依赖 react-easy-crop。 Co-Authored-By: Claude Opus 4.8 --- panel/server/src/index.ts | 12 +++ panel/server/src/store.ts | 17 ++++ panel/web/package-lock.json | 20 +++++ panel/web/package.json | 1 + panel/web/src/AppIcon.tsx | 27 +++++++ panel/web/src/api.ts | 2 + panel/web/src/pages/Admin.tsx | 145 ++++++++++++++++++++++++++++++++++ panel/web/src/styles.css | 46 +++++++++++ 8 files changed, 270 insertions(+) diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 131e386..33040b7 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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: / 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; diff --git a/panel/server/src/store.ts b/panel/server/src/store.ts index d69d809..5a5c7a7 100644 --- a/panel/server/src/store.ts +++ b/panel/server/src/store.ts @@ -330,6 +330,23 @@ export function renameInstance(id: string, name: string) { return publicInstance(inst); } +// 设置/清除实例自定义图标。传空 → 恢复按 appType 的默认图标。 +// 仅允许 builtin: 或 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('实例不存在'); diff --git a/panel/web/package-lock.json b/panel/web/package-lock.json index bf1684b..58e31b4 100644 --- a/panel/web/package-lock.json +++ b/panel/web/package-lock.json @@ -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", diff --git a/panel/web/package.json b/panel/web/package.json index 176f08e..c9e950c 100644 --- a/panel/web/package.json +++ b/panel/web/package.json @@ -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": { diff --git a/panel/web/src/AppIcon.tsx b/panel/web/src/AppIcon.tsx index f84d667..3c58310 100644 --- a/panel/web/src/AppIcon.tsx +++ b/panel/web/src/AppIcon.tsx @@ -32,14 +32,41 @@ const dots = ( ); +// 文字字形(品牌色块 + 白字),用于没有简单标志的平台 +const txt = (s: string, fs = 22) => ( + + {s} + +); +const play = ; + // key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。 export const BUILTIN_ICONS: Record = { 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 = { wechat: 'wechat', chromium: 'chromium', diff --git a/panel/web/src/api.ts b/panel/web/src/api.ts index 554129f..00d64a3 100644 --- a/panel/web/src/api.ts +++ b/panel/web/src/api.ts @@ -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) => diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 5d21c1b..83708d0 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -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(null); // 重命名实例弹窗 const [securityInst, setSecurityInst] = useState(null); // 安全(内存阈值)弹窗 const [volumeInst, setVolumeInst] = useState(null); // 数据卷管理弹窗 + const [iconInst, setIconInst] = useState(null); // 图标编辑弹窗 const [acting, setActing] = useState>({}); // 实例 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)} /> ))} @@ -531,6 +535,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () {volumeInst && ( setVolumeInst(null)} onChanged={load} /> )} + {iconInst && ( + setIconInst(null)} + onDone={() => { + toast('已更新图标', 'ok'); + load(); + }} + /> + )} ); } @@ -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({ + @@ -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 { + const img = await new Promise((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(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(null); + + const onPickFile = (e: React.ChangeEvent) => { + 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 ( +
+
e.stopPropagation()} style={{ maxWidth: 460 }}> +

图标 · {inst.name}

+ {cropSrc ? ( + <> +
+ setArea(a)} + /> +
+ setZoom(Number(e.target.value))} /> +
+ + +
+ + ) : ( + <> +
+ +
预览({sel.startsWith('data:') ? '自定义图片' : sel.startsWith('builtin:') ? '内置图标' : '按应用默认'})
+
+
内置图标
+
+ + {ICON_CHOICES.map((c) => ( + + ))} +
+ + +
+ + +
+ + )} +
+
+ ); +} + // 数据卷管理(仅管理员):整卷备份/恢复 + 文件浏览器(浏览/上传/解压/下载/改名/移动/删除)。 // 主要场景:把 PC 微信数据迁移上来、跨实例迁移、离线备份。全程在「运行中」的实例上操作 // (浏览/改名/删除靠 docker exec,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。 diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index 903a8ab..4eca4bf 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -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); +}