mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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('实例不存在');
|
||||
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,需容器运行)。整卷恢复会覆盖全部数据,强提示并建议恢复后重启实例。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user