feat(v1.2.0): UI 按应用类型泛化(不再到处写死「微信」)

新增 APP_PROFILES(label/needsInstall/enterHint/updateLabel)+ appProfile()。
- 实例卡片:状态副文案、"进入实例"提示、安装/更新按钮均按 appType 显示;Chromium 已烤进镜像
  (needsInstall=false)故不显示"下载安装/更新",状态直接"已就绪"。
- 桌面页:连接提示语用 enterHint(微信=扫码登录,Chromium=直接使用);安装中/未安装提示、iframe
  标题、文件传输/剪贴板/输入条文案改为应用名或通用"应用/桌面"。
- 列表区"微信实例"→"实例"、空状态泛化。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 18:19:03 +08:00
Unverified
parent 179f5cf7a1
commit 1da52bfb96
3 changed files with 53 additions and 31 deletions
+18
View File
@@ -26,6 +26,24 @@ export const APP_LABELS: Record<AppType, string> = {
chromium: 'Chromium',
custom: '自定义应用',
};
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。
// enterHint: 首次进入实例的提示。
// updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。
export interface AppProfile {
label: string;
needsInstall: boolean;
enterHint: string;
updateLabel: string;
}
export const APP_PROFILES: Record<AppType, AppProfile> = {
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
};
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
export interface PanelInstance {
id: string;
name: string;
+12 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, APP_LABELS, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
@@ -271,7 +271,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{isAdmin && (
<>
<div className="section-row">
<span className="section-title"></span>
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingInst(true)}>
+
</button>
@@ -279,11 +279,11 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{instances.length === 0 ? (
<EmptyState
icon="🖥️"
title="还没有微信实例"
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
title="还没有实例"
sub="新建一个实例(微信 / Chromium 浏览器),进入后即可在浏览器里使用"
action={
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
</button>
}
/>
@@ -899,6 +899,8 @@ function InstanceAdminCard({
return () => document.removeEventListener('mousedown', onDocDown);
}, [menuOpen]);
const profile = appProfile(inst.appType);
let badge: { text: string; cls: string };
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
@@ -911,8 +913,8 @@ function InstanceAdminCard({
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
else sub = '微信尚未安装';
else if (installed) sub = wx.version ? `${profile.label} ${wx.version}` : `${profile.label}已就绪`;
else sub = `${profile.label}尚未安装`;
return (
<div className={'inst-card' + (menuOpen ? ' open-menu' : '')}>
@@ -943,7 +945,7 @@ function InstanceAdminCard({
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
</button>
) : (
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装' + profile.label}>
</button>
)}
@@ -960,9 +962,9 @@ function InstanceAdminCard({
<div className="inst-menu-group">
<div className="inst-menu-label"></div>
<div className="inst-menu-items">
{!offline && (
{!offline && profile.needsInstall && (
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
{installed ? '更新微信' : '下载安装'}
{installed ? profile.updateLabel : '下载安装'}
</button>
)}
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
+23 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../api';
import { api, appProfile } from '../api';
import { useUI } from '../ui';
import { useAuth } from '../auth';
import { useInstances } from '../AppShell';
@@ -150,6 +150,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const audioRef = useRef<VncAudio | null>(null);
const inst = instances.find((i) => i.id === id);
const profile = appProfile(inst?.appType); // 按应用类型显示正确文案(微信/Chromium…)
const appLabel = profile.label;
// 进入实例时,共享列表可能尚未同步(管理页新建/安装后),先按"探测中"显示加载态,
// 等列表刷新到该实例或超时后再判定是否真的不存在,避免从管理页跳转时误报"实例不存在"。
const [probing, setProbing] = useState(true);
@@ -366,7 +368,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
setUploading(false);
if (ok) {
toast(`已上传 ${ok} 个文件到桌面,微信里可直接`, 'ok');
toast(`已上传 ${ok} 个文件到桌面,应用里可直接取`, 'ok');
refreshFiles();
}
};
@@ -379,7 +381,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
};
const delFile = async (name: string) => {
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
if (!(await confirm({ title: `删除「${name}」?`, body: '将从桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
try {
await api.deleteFile(id, name);
toast('已删除', 'ok');
@@ -445,7 +447,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
return;
}
if (pushClipboardToRemote(t)) {
toast('已发送到容器剪贴板,请在微信输入框按 Ctrl+V 粘贴', 'ok');
toast('已发送到容器剪贴板,请在应用输入框按 Ctrl+V 粘贴', 'ok');
} else {
toast('发送失败:桌面尚未连接', 'error');
}
@@ -486,7 +488,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
const restartInstance = async () => {
const ok = await confirm({
title: '重启该实例?',
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
body: `会重建容器(数据保留),${appLabel}重新启动,约十几秒;用于修复卡死/最小化丢失等。`,
confirmText: '重启',
});
if (!ok) return;
@@ -525,7 +527,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
}
};
const title = inst?.name || '微信实例';
const title = inst?.name || '实例';
return (
<div className="ws-page">
@@ -550,8 +552,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
className={'ws-action' + (inputMode === 'seamless' ? ' on' : '')}
title={
inputMode === 'seamless'
? '无感输入:直接在微信输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在微信里打)'
? '无感输入:直接在应用输入框里打中文(提交后转发,已修复混数字丢字)。点击切回「转发输入条」'
: '转发输入:用底部输入条打中文,最稳。点击切到「无感输入」(直接在应用里打)'
}
onClick={() => setMode(inputMode === 'seamless' ? 'forward' : 'seamless')}
>
@@ -609,7 +611,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="spinner" />
<div className="iv-notice-title"></div>
<div className="iv-notice-title">{appLabel}</div>
<div className="iv-notice-sub">
{inst.wechat.message || '请稍候'}
{inst.wechat.percent >= 0 ? ` · ${inst.wechat.percent}%` : ''}
@@ -619,18 +621,18 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
) : !installed ? (
<div className="iv-stage iv-center">
<div className="iv-notice">
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? '微信安装出错' : '微信尚未安装'}</div>
<div className="iv-notice-title">{inst.wechat.phase === 'error' ? `${appLabel}安装出错` : `${appLabel}尚未安装`}</div>
<div className="iv-notice-sub">
{inst.wechat.phase === 'error'
? inst.wechat.message || '安装失败,可在「管理」重试'
: '该实例容器已就绪,但尚未安装微信'}
: `该实例容器已就绪,但尚未安装${appLabel}`}
</div>
{isAdmin ? (
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
{inst.wechat.phase === 'error' ? '重试 / 更新' : '下载安装'}
</button>
) : (
<div className="iv-notice-sub"></div>
<div className="iv-notice-sub">{appLabel}</div>
)}
{isAdmin && (
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(id), '_blank')}>
@@ -647,7 +649,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
ref={frameRef}
className="iv-frame"
src={desktopUrl(id)}
title="电脑版微信"
title={`${appLabel} · 实例桌面`}
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
onLoad={() => {
setFrameLoaded(true);
@@ -664,7 +666,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-loading">
<div className="spinner" />
<div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div>
<div className="iv-loading-sub">{profile.enterHint}</div>
<div className="iv-loading-sub"></div>
{!window.isSecureContext && (
<div className="iv-loading-warn"> HTTPS 访</div>
@@ -703,8 +705,8 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
<div className="drop-card">
<div className="drop-icon"></div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
<div className="drop-title"></div>
<div className="drop-sub">+ / </div>
</div>
</div>
)}
@@ -742,7 +744,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
{uploading ? '上传中…' : ' 选择文件上传'}
</button>
<div className="files-hint">~/Desktop</div>
<div className="files-hint">~/Desktop</div>
<div className="files-list">
{files.length === 0 && (
<div className="muted small" style={{ padding: '10px 2px' }}>
@@ -776,17 +778,17 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
className="clip-area"
value={clipText}
onChange={(e) => setClipText(e.target.value)}
placeholder="在此输入或粘贴文本,点「发送到微信」后到微信输入框按 Ctrl+V 粘贴"
placeholder="在此输入或粘贴文本,点「发送到剪贴板」后到应用输入框按 Ctrl+V 粘贴"
rows={5}
/>
<button className="btn btn-primary files-upload" onClick={sendClip}>
</button>
<button className="btn-text" style={{ alignSelf: 'flex-start', marginTop: 6 }} onClick={pullClipboardFromRemote}>
</button>
<div className="files-hint">
http 访 Ctrl+V
http 访 Ctrl+V
</div>
</div>
)}
@@ -804,7 +806,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
sendImeText();
}
}}
placeholder="中文输入这里 → 回车送进微信(先点好微信的输入框)。Shift+回车换行。"
placeholder="中文输入这里 → 回车送进应用(先点好应用的输入框)。Shift+回车换行。"
rows={1}
/>
<button