mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
Compare commits
1 Commits
+7
-2
@@ -14,7 +14,8 @@ RUN set -eux; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates locales dpkg \
|
||||
fonts-wqy-zenhei fonts-wqy-microhei fonts-noto-cjk fonts-noto-color-emoji \
|
||||
libnss3 libgbm1 libasound2 libxss1; \
|
||||
libnss3 libgbm1 libasound2 libxss1 \
|
||||
xdotool; \
|
||||
sed -i 's/# zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
apt-get clean; \
|
||||
@@ -72,9 +73,13 @@ RUN set -eux; \
|
||||
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
RUN chmod +x /woc/wechat-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
RUN chmod +x /defaults/autostart
|
||||
|
||||
# 启动钩子:每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod +x /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
|
||||
@@ -10,6 +10,19 @@ WECHAT_BIN=/config/wechat/opt/wechat/wechat
|
||||
# 容器内无 GPU,强制软件渲染
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
|
||||
# 防“最小化后丢失”:本桌面(openbox)无任务栏,微信被最小化就无处恢复 → 黑屏。
|
||||
# 看守进程:每 2s 把“被最小化(不可见)”的顶层窗口重新激活,相当于禁用最小化。
|
||||
(
|
||||
export DISPLAY="${DISPLAY:-:1}"
|
||||
while sleep 2; do
|
||||
all=$(xdotool search --name '.+' 2>/dev/null) || continue
|
||||
vis=$(xdotool search --onlyvisible --name '.+' 2>/dev/null)
|
||||
for w in ${all}; do
|
||||
printf '%s\n' "${vis}" | grep -qx "${w}" || xdotool windowactivate "${w}" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
) &
|
||||
|
||||
# 1) 等待微信安装就绪(首次需在面板点「下载并安装」)
|
||||
notified=0
|
||||
while [ ! -x "${WECHAT_BIN}" ]; do
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# linuxserver 启动钩子(/custom-cont-init.d,root 身份,每次启动执行)。
|
||||
# 作用:始终用镜像内最新的 /defaults/autostart 覆盖数据卷里的副本。
|
||||
# 原因:baseimage 的 init-kasmvnc-config 只在 /config/.config/openbox/autostart "缺失时"才拷贝,
|
||||
# 导致旧实例(卷里已有旧 autostart)升级镜像后仍跑旧逻辑(如缺少"最小化自动复原"看守)。
|
||||
mkdir -p /config/.config/openbox
|
||||
cp /defaults/autostart /config/.config/openbox/autostart
|
||||
chmod +x /config/.config/openbox/autostart
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /config/.config/openbox/autostart 2>/dev/null || true
|
||||
@@ -125,6 +125,26 @@ export async function ensureRunning(inst: Instance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 升级实例:拉取最新微信镜像后重建容器(保留数据卷 → 登录态不丢)。
|
||||
// 拉取失败(本地自构建 / 离线 / 仓库不可达)则用本地现有镜像重建,不阻断。
|
||||
export async function upgradeInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await pullImage();
|
||||
} catch (e: any) {
|
||||
console.warn('[docker] 升级时拉取镜像失败,改用本地镜像重建:', e?.message || e);
|
||||
}
|
||||
await runInstance(inst);
|
||||
}
|
||||
|
||||
// 停止实例容器(保留容器与数据卷,可再启动)。
|
||||
export async function stopInstance(inst: Instance): Promise<void> {
|
||||
try {
|
||||
await docker.getContainer(inst.containerName).stop({ t: 5 } as any);
|
||||
} catch {
|
||||
/* 已停止或不存在 */
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeInstance(inst: Instance, purgeVolume: boolean): Promise<void> {
|
||||
try {
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
ensureNetwork,
|
||||
ensureRunning,
|
||||
runInstance,
|
||||
stopInstance,
|
||||
upgradeInstance,
|
||||
removeInstance as removeInstanceContainer,
|
||||
instanceRuntime,
|
||||
triggerWechat,
|
||||
@@ -264,7 +266,7 @@ app.post('/api/admin/instances/:id/rename', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 启动/重启实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。
|
||||
app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
@@ -277,6 +279,46 @@ app.post('/api/admin/instances/:id/start', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 停止实例容器(仅管理员):保留容器与数据卷。
|
||||
app.post('/api/admin/instances/:id/stop', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await stopInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '停止失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。
|
||||
app.post('/api/admin/instances/:id/restart', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await runInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '重启失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像
|
||||
// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。
|
||||
app.post('/api/admin/instances/:id/upgrade', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
const inst = findInstance((req.params as any).id);
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
try {
|
||||
await upgradeInstance(inst);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: '升级失败:' + (e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// 实例侧:设置该实例可被哪些账户访问
|
||||
app.post('/api/admin/instances/:id/users', async (req, reply) => {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
|
||||
@@ -90,6 +90,9 @@ export const api = {
|
||||
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
|
||||
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
|
||||
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
|
||||
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
|
||||
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
|
||||
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
|
||||
|
||||
// 文件中转
|
||||
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
|
||||
|
||||
@@ -27,7 +27,14 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
|
||||
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [starting, setStarting] = useState<Set<string>>(new Set());
|
||||
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
|
||||
const setAct = (id: string, label: string | null) =>
|
||||
setActing((a) => {
|
||||
const n = { ...a };
|
||||
if (label) n[id] = label;
|
||||
else delete n[id];
|
||||
return n;
|
||||
});
|
||||
|
||||
const subs = users.filter((u) => u.role !== 'admin');
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -72,7 +79,7 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
};
|
||||
|
||||
const start = async (inst: InstanceWithStatus) => {
|
||||
setStarting((s) => new Set(s).add(inst.id));
|
||||
setAct(inst.id, '启动中…');
|
||||
try {
|
||||
await api.instanceStart(inst.id);
|
||||
toast('实例已启动', 'ok');
|
||||
@@ -80,11 +87,22 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(inst.id);
|
||||
return n;
|
||||
});
|
||||
setAct(inst.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
const lifecycle = async (inst: InstanceWithStatus, kind: 'stop' | 'restart' | 'upgrade') => {
|
||||
const label = kind === 'stop' ? '停止中…' : kind === 'upgrade' ? '升级中…' : '重启中…';
|
||||
setAct(inst.id, label);
|
||||
if (kind === 'upgrade') toast('正在升级实例:拉取最新镜像并重建,可能需要几分钟,请勿离开…', 'info');
|
||||
try {
|
||||
await (kind === 'stop' ? api.instanceStop(inst.id) : kind === 'upgrade' ? api.instanceUpgrade(inst.id) : api.instanceRestart(inst.id));
|
||||
toast(kind === 'stop' ? '已停止' : kind === 'upgrade' ? '已升级到最新镜像并重启' : '已重启', 'ok');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '操作失败', 'error');
|
||||
} finally {
|
||||
setAct(inst.id, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,10 +161,13 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
userCount={usersForInstance(inst.id).length}
|
||||
starting={starting.has(inst.id)}
|
||||
acting={acting[inst.id]}
|
||||
onEnter={() => nav(`/i/${inst.id}`)}
|
||||
onTrigger={trigger}
|
||||
onStart={() => start(inst)}
|
||||
onStop={() => lifecycle(inst, 'stop')}
|
||||
onRestart={() => lifecycle(inst, 'restart')}
|
||||
onUpgrade={() => lifecycle(inst, 'upgrade')}
|
||||
onRename={() => setRenameInst(inst)}
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
@@ -428,20 +449,26 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
|
||||
function InstanceAdminCard({
|
||||
inst,
|
||||
userCount,
|
||||
starting,
|
||||
acting,
|
||||
onEnter,
|
||||
onTrigger,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onUpgrade,
|
||||
onRename,
|
||||
onAssign,
|
||||
onDelete,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
starting?: boolean;
|
||||
acting?: string;
|
||||
onEnter: () => void;
|
||||
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onRestart: () => void;
|
||||
onUpgrade: () => void;
|
||||
onRename: () => void;
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -450,15 +477,18 @@ function InstanceAdminCard({
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (installed) badge = { text: '在线', cls: 'tag-on' };
|
||||
else badge = { text: '待安装', cls: 'tag-warn' };
|
||||
|
||||
let sub: string;
|
||||
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
if (acting) sub = acting;
|
||||
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}` : '微信已安装';
|
||||
@@ -471,48 +501,65 @@ function InstanceAdminCard({
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
</div>
|
||||
<div className="inst-sub">
|
||||
{sub} · 可访问 {userCount} 人
|
||||
{sub}
|
||||
{!acting && ` · 可访问 ${userCount} 人`}
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
{working && (
|
||||
<div className="wx-progress">
|
||||
<div
|
||||
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
className={'wx-progress-bar' + (acting || wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={!acting && wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!busy && (
|
||||
<div className="inst-actions">
|
||||
{offline ? (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={starting} onClick={onStart}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 进行中(升级/重启/停止/下载)时隐藏所有操作,避免重复点击 */}
|
||||
{!working && (
|
||||
<>
|
||||
<div className="inst-actions">
|
||||
{offline ? (
|
||||
<button className="btn btn-primary inst-act-wide" onClick={onStart}>
|
||||
{inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inst-admin-links">
|
||||
{!busy && !offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<div className="inst-admin-links">
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
|
||||
升级实例
|
||||
</button>
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onRestart}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={onStop}>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
|
||||
const [vncNonce, setVncNonce] = useState(0);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
@@ -217,21 +218,39 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
}
|
||||
};
|
||||
|
||||
// 打开/收起 KasmVNC 原生控制条(音频/麦克风/摄像头/剪贴板/全屏/键盘设置都在里面)。
|
||||
// noVNC 的 openControlbar 是给 #noVNC_control_bar 加 .noVNC_open(CSS 据此展开;手柄绑 mousedown,
|
||||
// 合成 click 触发不了),所以直接切换 bar 上的该 class 最可靠。iframe 与面板同源,可直接访问其 DOM。
|
||||
const openVncControls = () => {
|
||||
// 桌面加载后给 noVNC 原生控制条注入"实心可见"样式:原生背景近纯黑半透明,叠在深色/黑屏上看不见。
|
||||
// 注入后,用 KasmVNC 自带的左侧边缘手柄拉出控制条(音频/剪贴板/键盘/全屏等)时即可见。iframe 同源可直接访问。
|
||||
const injectVncStyle = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const bar = doc?.getElementById('noVNC_control_bar');
|
||||
if (bar) {
|
||||
bar.classList.toggle('noVNC_open');
|
||||
focusFrame();
|
||||
} else {
|
||||
toast('控制条尚未就绪,请等画面出现后重试', 'error');
|
||||
}
|
||||
if (!doc || doc.getElementById('woc-vnc-style')) return;
|
||||
const st = doc.createElement('style');
|
||||
st.id = 'woc-vnc-style';
|
||||
st.textContent =
|
||||
'#noVNC_control_bar_anchor{z-index:2147483647!important;}' +
|
||||
'#noVNC_control_bar{background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.55)!important;box-shadow:0 0 24px rgba(0,0,0,.55)!important;}' +
|
||||
'#noVNC_control_bar_handle{opacity:1!important;background:rgba(18,22,30,.96)!important;border:1px solid rgba(255,255,255,.5)!important;}';
|
||||
(doc.head || doc.documentElement).appendChild(st);
|
||||
} catch {
|
||||
toast('无法打开 VNC 控制条', 'error');
|
||||
/* 同源正常不会到这 */
|
||||
}
|
||||
};
|
||||
|
||||
const restartInstance = async () => {
|
||||
const ok = await confirm({
|
||||
title: '重启该实例?',
|
||||
body: '会重建容器(聊天记录保留),微信重新启动,约十几秒;用于修复卡死/最小化丢失等。',
|
||||
confirmText: '重启',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.instanceRestart(id);
|
||||
toast('已重启,正在重连…', 'ok');
|
||||
setFrameLoaded(false);
|
||||
setVncNonce((n) => n + 1); // 强制 iframe 重挂、重连
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '重启失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -270,9 +289,6 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<span className="ws-title">{title}</span>
|
||||
{showVnc && (
|
||||
<>
|
||||
<button className="ws-action" title="VNC 控制:音频 / 麦克风 / 摄像头 / 剪贴板 / 全屏 / 键盘" onClick={openVncControls}>
|
||||
控制
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="文件传输"
|
||||
@@ -283,6 +299,11 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="ws-action" title="重启实例(修复卡死/最小化丢失)" onClick={restartInstance}>
|
||||
重启
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
@@ -330,7 +351,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
) : (
|
||||
<div className="iv-stage">
|
||||
<iframe
|
||||
key={id}
|
||||
key={`${id}:${vncNonce}`}
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
@@ -338,7 +359,10 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(focusFrame, 400); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
setTimeout(() => {
|
||||
focusFrame(); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
injectVncStyle(); // 让原生控制条在深色背景下可见
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -347,7 +371,7 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;语音/视频/剪贴板点右上角「控制」开启</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;音频/剪贴板等在画面左侧边缘的工具条里</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
|
||||
@@ -1382,9 +1382,9 @@ button {
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
color: var(--wx-green-dark);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 7px 14px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--crease);
|
||||
|
||||
@@ -29,6 +29,10 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// 桌面反代与 API 不能被 SW 拦截
|
||||
navigateFallbackDenylist: [/^\/desktop/, /^\/api/],
|
||||
// 新版本立即接管 + 清理旧缓存,避免更新后仍跑旧代码(硬刷新绕不过 SW)
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user