feat(ui): 自动回车开关 + 功能键排 + 修齿轮图标/子用户文案(issue #125)

1) 转发输入条「自动回车」开关(默认开,保持 issue #81 行为)
   关掉后只把文字填进应用输入框、不代发,按钮文案随之变「填入」。
   原先无条件补回车,导致想先编辑再发时文本一进框就被发出去,
   用户只能靠外部剪贴板工具绕开。开关记在 localStorage。

2) 功能键折叠排(Fn):Esc / Tab / 退格 / Del / 回车 / 方向键
   这些键在 VNC 里常被浏览器吞掉,手机端更是没有。经 xdotool 送出,
   键名须匹配服务端白名单 /^[A-Za-z_]{1,20}$/,故只放单键不放组合键。
   标签用「退格」而非 ⌫(U+232B)、用「Fn」而非 ⌨——实测这两个符号
   在容器字体下渲染成豆腐块方框。

3) 齿轮图标:旧路径是手改过的 feather 变体,H9/V9 这类绝对指令混在
   相对弧线里,有两颗齿明显走形(右下那颗尤其)。换成 lucide 官方
   settings 路径(MIT),12 齿对称。已渲染新旧对比确认。

4) 子用户文案:「重启实例」按钮本就按 isAdmin 隐藏,但两处提示文字没跟着
   变,子用户会照着找一个根本不存在的按钮。改为提示联系管理员。

样式复用既有牛奶布艺 token(--crease / --crease-press / --trough /
--surface / --muted),未新造阴影值。
This commit is contained in:
Gloridust
2026-07-28 22:33:08 +08:00
parent 01a00f3e0e
commit 0347405115
3 changed files with 169 additions and 28 deletions

View File

@@ -63,7 +63,9 @@ const Icon = {
), ),
gear: ( gear: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3.2" /><path d="M19.4 13a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-2.7-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.1-2.7l-.1-.1A2 2 0 1 1 6.9 4.5l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z" /> {/* 齿轮:原路径是手改过的 feather 变体H9/V9 这类绝对指令混在相对弧线里,
导致有两颗齿明显走形issue #125。换成 lucide 官方 settings 路径MIT12 齿对称。 */}
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />
</svg> </svg>
), ),
logout: ( logout: (

View File

@@ -133,6 +133,21 @@ function allowAutoRecover(iid: string): boolean {
return true; return true;
} }
// 转发输入条上的功能键issue #125。键名走 xdotool须匹配服务端白名单 /^[A-Za-z_]{1,20}$/
// 故只放单键、不放组合键ctrl+a 这类含 "+" 会被拒)。
const FUNC_KEYS: { key: string; label: string; title: string }[] = [
{ key: 'Escape', label: 'Esc', title: 'Escape关弹窗/退出全屏输入)' },
{ key: 'Tab', label: 'Tab', title: 'Tab切换焦点' },
// 用中文字面而非 ⌫U+232B容器/部分系统缺字形会渲染成豆腐块,实测就是方框
{ key: 'BackSpace', label: '退格', title: '退格(删除前一个字符)' },
{ key: 'Delete', label: 'Del', title: 'Delete删除后一个字符' },
{ key: 'Return', label: '↵', title: '回车(发送/换行)' },
{ key: 'Left', label: '←', title: '左方向键' },
{ key: 'Up', label: '↑', title: '上方向键' },
{ key: 'Down', label: '↓', title: '下方向键' },
{ key: 'Right', label: '→', title: '右方向键' },
];
const MenuIcon = ( const MenuIcon = (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 6h16M4 12h16M4 18h16" /> <path d="M4 6h16M4 12h16M4 18h16" />
@@ -189,6 +204,26 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
/* ignore */ /* ignore */
} }
}; };
const [showKeys, setShowKeys] = useState(false);
// 转发输入条「发送后自动回车」开关(默认开,保持 issue #81 起的既有行为)。
// 关掉的场景issue #125想先把长文/多行填进微信输入框里再自己斟酌、编辑后手动发;
// 自动回车会让文本一进框就被发出去,也让 Shift+回车换行之外的编辑很别扭。
const [autoEnter, setAutoEnter] = useState(() => {
try {
return window.localStorage.getItem('woc_ime_auto_enter') !== '0';
} catch {
return true;
}
});
const toggleAutoEnter = () => {
const v = !autoEnter;
setAutoEnter(v);
try {
window.localStorage.setItem('woc_ime_auto_enter', v ? '1' : '0');
} catch {
/* ignore */
}
};
// 麦克风开关(默认关):仅在「声音」开启时有意义;默认不抢占麦克风,避免把 AirPods 切到低质通话模式。 // 麦克风开关(默认关):仅在「声音」开启时有意义;默认不抢占麦克风,避免把 AirPods 切到低质通话模式。
const [micOn, setMicOn] = useState(() => { const [micOn, setMicOn] = useState(() => {
try { try {
@@ -775,16 +810,26 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
// 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME // 中文输入条发送:把本框文本经 xclip+xdotool 直接粘进微信当前聚焦的输入框(绕开 VNC IME
// 在面板的真实 textarea 里用原生输入法打字100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。 // 在面板的真实 textarea 里用原生输入法打字100% 可靠,不依赖 VNC 的 enable_ime / 合成事件。
const pressKey = async (key: string) => {
if (!id) return;
try {
await api.keyInInstance(id, key);
} catch (e: any) {
toast(e?.message || '按键失败:请确认实例已「升级实例」(镜像含 xdotool', 'error');
}
};
const sendImeText = async () => { const sendImeText = async () => {
const t = imeText; const t = imeText;
if (!t.trim() || !id) return; if (!t.trim() || !id) return;
setImeSending(true); setImeSending(true);
try { try {
await api.typeInInstance(id, t); await api.typeInInstance(id, t);
// 打完直接补一个回车把消息发出去issue #81,焦点【始终留在本输入条】。 // 打完补一个回车把消息发出去issue #81可由「自动回车」开关关掉issue #125
// 关掉时只把文字填进应用输入框,发不发由用户自己按。焦点【始终留在本输入条】。
// 切勿在转发模式把焦点切回虚拟机——那等于开了"无感输入",用户接着打的拼音会以原始 keysym 直灌微信 // 切勿在转发模式把焦点切回虚拟机——那等于开了"无感输入",用户接着打的拼音会以原始 keysym 直灌微信
// 输入框(出现 "nniih'h你好啊" 这种串码)。下一条仍在本条用本机输入法安全地打。 // 输入框(出现 "nniih'h你好啊" 这种串码)。下一条仍在本条用本机输入法安全地打。
await api.keyInInstance(id, 'Return'); if (autoEnter) await api.keyInInstance(id, 'Return');
setImeText(''); setImeText('');
} catch (e: any) { } catch (e: any) {
toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error'); toast(e?.message || '发送失败:请确认实例已「升级实例」(镜像含 xclip/xdotool', 'error');
@@ -1031,7 +1076,13 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
{!frameLoaded && loadStuck && ( {!frameLoaded && loadStuck && (
<div className="iv-loading"> <div className="iv-loading">
<div className="iv-loading-text"></div> <div className="iv-loading-text"></div>
<div className="iv-loading-sub"></div> {/* 子用户无「重启实例」权限(按钮下方已按 isAdmin 隐藏),文案也要跟着变,
否则会让人照着找一个根本不存在的按钮issue #125。 */}
<div className="iv-loading-sub">
{isAdmin
? '连接超时。可能是实例临时卡住,先「重新连接」;若仍无效请「重启实例」。'
: '连接超时。可能是实例临时卡住,请先「重新连接」;若反复无效,请联系管理员重启该实例。'}
</div>
<div className="iv-stuck-actions"> <div className="iv-stuck-actions">
<button <button
className="btn btn-primary" className="btn btn-primary"
@@ -1046,9 +1097,11 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</button> </button>
)} )}
</div> </div>
{isAdmin && (
<div className="iv-loading-sub" style={{ marginTop: 8 }}> <div className="iv-loading-sub" style={{ marginTop: 8 }}>
</div> </div>
)}
</div> </div>
)} )}
@@ -1216,7 +1269,26 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
</div> </div>
{inputMode === 'forward' && ( {inputMode === 'forward' && (
<>
{/* 功能键折叠集合issue #125VNC 里这些键要么被浏览器吃掉、要么手机端根本没有,
给一排直接点。经 xdotool 送出,键名须匹配服务端白名单 /^[A-Za-z_]{1,20}$/。 */}
{showKeys && (
<div className="iv-keybar">
{FUNC_KEYS.map((k) => (
<button key={k.key} className="iv-key" title={k.title} onClick={() => pressKey(k.key)}>
{k.label}
</button>
))}
</div>
)}
<div className="iv-imebar"> <div className="iv-imebar">
<button
className={'iv-imebar-tool' + (showKeys ? ' on' : '')}
title="功能键Esc / Tab / 退格 / 方向键…)"
onClick={() => setShowKeys((v) => !v)}
>
Fn
</button>
<textarea <textarea
className="iv-imebar-input" className="iv-imebar-input"
value={imeText} value={imeText}
@@ -1227,17 +1299,33 @@ export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void })
sendImeText(); sendImeText();
} }
}} }}
placeholder="中文输入这里 → 回车直接发送到应用先点好应用的输入框。Shift+回车换行。" placeholder={
autoEnter
? '中文输入这里 → 回车直接发送到应用先点好应用的输入框。Shift+回车换行。'
: '中文输入这里 → 回车只把文字填进应用输入框不自动发送发送由你按。Shift+回车换行。'
}
rows={1} rows={1}
/> />
<button
className={'iv-imebar-tool' + (autoEnter ? ' on' : '')}
title={
autoEnter
? '自动回车:开。文字送到应用后立刻回车发出。点击关闭(只填字不发送,便于先编辑再发)'
: '自动回车:关。只把文字填进应用输入框,发送由你自己按。点击开启'
}
onClick={toggleAutoEnter}
>
</button>
<button <button
className="btn btn-primary iv-imebar-send" className="btn btn-primary iv-imebar-send"
disabled={imeSending || !imeText.trim()} disabled={imeSending || !imeText.trim()}
onClick={sendImeText} onClick={sendImeText}
> >
{imeSending ? '发送中' : '发送'} {imeSending ? '发送中' : autoEnter ? '发送' : '填入'}
</button> </button>
</div> </div>
</>
)} )}
</div> </div>
)} )}

View File

@@ -1927,6 +1927,57 @@ button {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 18px; padding: 0 18px;
} }
/* 输入条上的小开关(功能键折叠 / 自动回车):牛奶布艺——默认凹槽,开启态顶起 */
.iv-imebar-tool {
height: 38px;
width: 38px;
flex: 0 0 auto;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
line-height: 1;
background: var(--trough);
color: var(--muted);
box-shadow: inset 0 1px 3px rgba(var(--shadow) / 0.16);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.iv-imebar-tool.on {
background: var(--surface);
color: var(--text);
box-shadow: var(--crease);
}
.iv-imebar-tool:active {
transform: scale(0.94);
}
/* 功能键排:贴在输入条上方,同一块「布」上顶起的一排小胶囊 */
.iv-keybar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px 0;
background: var(--base);
box-sizing: border-box;
}
.iv-key {
min-width: 40px;
height: 32px;
padding: 0 12px;
border: none;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
background: var(--surface);
color: var(--text);
box-shadow: var(--crease);
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.iv-key:active {
transform: scale(0.94);
box-shadow: var(--crease-press);
}
.iv-loading, .iv-loading,
.iv-drop { .iv-drop {
position: absolute; position: absolute;