feat(v1.2.0): 实例头像由首字母改为按应用类型的图标(自定义图标铺底)

新增 AppIcon.tsx(InstanceIcon + 内置 SVG 图标:微信/Chromium/Telegram/通用),侧栏与主页
卡片头像改用它(按 appType 出默认图标;data: 图片 / builtin:<key> 优先)。
新增 Instance.icon 字段 + publicInstance 下发,为「自定义图标(内置选择 + 上传裁剪)」铺底。
侧栏/主页标题「微信实例」→「实例」,主页副文案按应用名泛化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 19:01:03 +08:00
Unverified
parent 7c9eb82a6f
commit 1ffc6d7e1d
5 changed files with 91 additions and 28 deletions
+2
View File
@@ -43,6 +43,7 @@ export interface Instance {
id: string; // 短 id,用于容器/卷命名
name: string; // 显示名
appType?: AppType; // 承载的应用类型;缺省(老实例)= wechat(见 instanceAppType
icon?: string; // 自定义图标:data: 图片(base64) 或 builtin:<key>;缺省按 appType 取默认图标
containerName: string; // woc-wx-<id>
volumeName: string; // woc-data-<id>
kasmUser: string; // 随机生成,服务端注入反代,永不下发前端
@@ -216,6 +217,7 @@ export function publicInstance(i: Instance) {
id: i.id,
name: i.name,
appType: instanceAppType(i), // 老实例无字段时回退 wechat
icon: i.icon,
createdAt: i.createdAt,
createdBy: i.createdBy,
memSoftLimitMB: i.memSoftLimitMB,
+74
View File
@@ -0,0 +1,74 @@
import type { AppType } from './api';
// 实例图标。支持三种来源(优先级从高到低):
// 1) 自定义上传/裁剪的图片 → inst.icon = "data:image/...;base64,..."
// 2) 内置图标 → inst.icon = "builtin:<key>"(如 builtin:xiaohongshu
// 3) 缺省:按 appType 给默认图标(微信 / Chromium / Telegram / 通用)
// 内置图标用简洁 SVG(彩色圆角块 + 白色字形),风格统一、无需联网抓取。后续可往 BUILTIN 里加更多平台。
type Glyph = { bg: string; el: JSX.Element };
const G = (bg: string, el: JSX.Element): Glyph => ({ bg, el });
// 白色字形(viewBox 0 0 48 48,置于彩色圆角块上)
const chat = (
<path
fill="#fff"
d="M19 12c-6.6 0-12 4.2-12 9.5 0 3 1.8 5.7 4.6 7.4l-1.1 3.9 4.4-2.3c1.3.3 2.7.5 4.1.5 6.6 0 12-4.2 12-9.5S25.6 12 19 12zm-4 8.2a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2zm8 0a1.6 1.6 0 110-3.2 1.6 1.6 0 010 3.2z"
/>
);
const globe = (
<g fill="none" stroke="#fff" strokeWidth="2.4">
<circle cx="24" cy="24" r="13" />
<ellipse cx="24" cy="24" rx="5.5" ry="13" />
<path d="M11.5 20h25M11.5 28h25" />
</g>
);
const plane = <path fill="#fff" d="M35 14L13 23.2l6.1 2.2 2.3 7.2 3.3-3.9 5.6 4.1L35 14zm-12.4 12.6l9-7.2-6.7 8.1-.1 3.6-2.2-4.5z" />;
const dots = (
<g fill="#fff">
<circle cx="16" cy="24" r="2.6" />
<circle cx="24" cy="24" r="2.6" />
<circle cx="32" cy="24" r="2.6" />
</g>
);
// key → 字形。default-by-appType 与「内置图标选择器」共用同一张表。
export const BUILTIN_ICONS: Record<string, Glyph> = {
wechat: G('#07c160', chat),
chromium: G('#4285f4', globe),
telegram: G('#2aabee', plane),
globe: G('#5b8def', globe),
app: G('#8a9099', dots),
};
const DEFAULT_BY_APP: Record<AppType, string> = {
wechat: 'wechat',
chromium: 'chromium',
telegram: 'telegram',
custom: 'app',
};
export function InstanceIcon({
icon,
appType,
size = 36,
radius = 12,
}: {
icon?: string;
appType?: AppType;
size?: number;
radius?: number;
}) {
// 1) 自定义图片
if (icon && icon.startsWith('data:')) {
return <img src={icon} width={size} height={size} alt="" style={{ borderRadius: radius, objectFit: 'cover', display: 'block' }} />;
}
// 2) 内置 / 3) 默认
const key = icon && icon.startsWith('builtin:') ? icon.slice(8) : DEFAULT_BY_APP[appType ?? 'wechat'] ?? 'app';
const g = BUILTIN_ICONS[key] ?? BUILTIN_ICONS.app;
return (
<svg width={size} height={size} viewBox="0 0 48 48" style={{ display: 'block' }} aria-hidden="true">
<rect width="48" height="48" rx={(radius / size) * 48} fill={g.bg} />
{g.el}
</svg>
);
}
+14 -10
View File
@@ -2,7 +2,8 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './auth';
import { useUI, PasswordInput } from './ui';
import { api, type InstanceWithStatus } from './api';
import { api, appProfile, type InstanceWithStatus } from './api';
import { InstanceIcon } from './AppIcon';
import InstanceView from './pages/Desktop';
import Admin from './pages/Admin';
@@ -175,7 +176,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
</button>
</nav>
{!collapsed && <div className="sb-section"></div>}
{!collapsed && <div className="sb-section"></div>}
<div className="sb-list">
{instances.length === 0 && !collapsed && <div className="sb-empty"></div>}
{instances.map((inst) => {
@@ -184,7 +185,7 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
return (
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
<span className="sb-avatar">
{inst.name.slice(0, 1)}
<InstanceIcon icon={inst.icon} appType={inst.appType} size={34} radius={10} />
<span className={'sb-dot ' + st.cls} />
</span>
{!collapsed && <span className="sb-label">{inst.name}</span>}
@@ -252,7 +253,7 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
)}
<div className="section-row">
<span className="section-title"></span>
<span className="section-title"></span>
{isAdmin && (
<button className="btn-text" onClick={() => nav('/admin')}>
@@ -265,21 +266,24 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
<div className="empty-blob">
<img src="/favicon.svg" alt="" />
</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
<div className="empty-title"></div>
<div className="empty-sub">{isAdmin ? '去「管理」新建一个实例' : '请联系管理员为你分配实例'}</div>
</div>
) : (
<div className="inst-grid">
{instances.map((inst) => {
const st = statusOf(inst);
const prof = appProfile(inst.appType);
const meta = inst.wechat.installed
? `微信 ${inst.wechat.version || ''}`.trim()
: inst.runtime === 'running'
? '待下载安装微信'
? `${prof.label} ${inst.wechat.version || ''}`.trim()
: inst.runtime === 'running' && prof.needsInstall
? `待下载安装${prof.label}`
: '';
return (
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
<span className="home-card-av">
<InstanceIcon icon={inst.icon} appType={inst.appType} size={42} radius={12} />
</span>
<span className="home-card-main">
<span className="home-card-name">{inst.name}</span>
<span className="home-card-meta">
+1
View File
@@ -48,6 +48,7 @@ export interface PanelInstance {
id: string;
name: string;
appType?: AppType; // 缺省(老实例)= wechat
icon?: string; // 自定义图标:data: 图片 / builtin:<key>;缺省按 appType 取默认图标
createdAt: string;
createdBy: string;
memSoftLimitMB?: number;
-18
View File
@@ -1397,15 +1397,6 @@ button {
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.sb-dot {
position: absolute;
@@ -1562,15 +1553,6 @@ button {
flex: none;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
color: #fff;
font-weight: 700;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.home-card-main {
position: relative;