Procházet zdrojové kódy

增加登录后的服务器与客户端模式切换入口

Samien před 1 měsícem
rodič
revize
d00de24

+ 45 - 16
desktop/main.mjs

@@ -24,7 +24,7 @@ if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
   let updateWindow, updater, environmentHost, hostManager, loginWindow
-  let remoteMode = false, remoteURL = '', serverModeActive = false
+  let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   const serviceOrigins = () => {
     try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
@@ -283,6 +283,26 @@ else {
     })
     async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'桌面主机操作未完成',detail:error.message})}}
     const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
+    function requireModePage(event){
+      let trusted=false
+      try{trusted=event.sender===window?.webContents&&event.senderFrame===window.webContents.mainFrame
+        &&serviceOrigins().includes(new URL(event.senderFrame.url).origin)}catch{}
+      if(!trusted||closing||quitting)throw new Error('不允许此页面切换桌面运行模式')
+    }
+    ipcMain.handle('desktop:mode-state',event=>{
+      requireModePage(event)
+      return {mode:remoteMode?'client':'server',serverURL:serviceURL()||'',switching:switchingMode}
+    })
+    ipcMain.handle('desktop:mode-switch',async(event,mode)=>{
+      requireModePage(event)
+      if(mode!=='client'&&mode!=='server')throw new Error('运行模式无效')
+      return switchMode(mode==='client'?'remote':'local',false)
+    })
+    ipcMain.handle('desktop:remote-login',event=>{
+      requireModePage(event)
+      if(switchingMode)throw new Error('正在切换运行模式,请稍候')
+      openRemoteLogin()
+    })
     async function importDesktopHost(){
       const selection=window&&!window.isDestroyed()
         ?await dialog.showOpenDialog(window,{title:'导入桌面主机配置',properties:['openFile'],filters:[{name:'桌面主机配置',extensions:['json']}]})
@@ -330,29 +350,36 @@ else {
     function syncModeMenu(){
       const menu=Menu.getApplicationMenu()
       const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
-      if(useRemote)useRemote.enabled=!remoteMode
-      if(useLocal)useLocal.enabled=!serverModeActive
+      if(useRemote)useRemote.enabled=!switchingMode&&!remoteMode
+      if(useLocal)useLocal.enabled=!switchingMode&&!serverModeActive
+      const status=menu?.getMenuItemById('desktop-mode-status')
+      if(status)status.label=switchingMode?'正在切换运行模式…':remoteMode?'当前:客户端模式':serverModeActive?'当前:服务器模式':'请选择运行模式'
       if(ask)ask.checked=preferences.value.startupChoice==='ask'
     }
-    async function switchMode(mode){
-      if(closing||quitting)return
+    async function switchMode(mode,notify=true){
+      if(closing||quitting)return {ok:false,error:'App 正在退出'}
+      if(switchingMode)return {ok:false,error:'正在切换运行模式,请稍候'}
+      if(mode==='remote'&&remoteMode||mode==='local'&&serverModeActive)return {ok:true}
+      switchingMode=true;syncModeMenu()
       try{
         if(mode==='remote'){
           if(await enterRemoteMode()){
             if(window&&!window.isDestroyed())void window.loadURL(remoteURL)
-            await appDialog({type:'info',message:'已登录远程服务器',detail:`窗口已切换到 ${remoteURL},本 App 已断开本机服务;这台 Mac 的桌面可在远程服务器的电脑面板中授权使用。`})
-          }
+          }else return {ok:true,pendingLogin:true}
         }else{
+          loginWindow?.close()
           serverModeActive=true;remoteMode=false;remoteURL=''
           await hostManager?.stop().catch(error=>log(error.message))
           await preferences.setStartupChoice('local').catch(error=>log(error.message))
           if(window&&!window.isDestroyed())void window.loadFile(join(desktopRoot,'boot.html'))
           await manager?.start().catch(error=>log(error.message))
           if(!closing)environmentHost?.start()
-          await appDialog({type:'info',message:'已切换为服务器',detail:'本机夭夭服务正在启动,就绪后窗口会自动打开。'})
         }
-        syncModeMenu()
-      }catch(error){await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})}
+        return {ok:true}
+      }catch(error){
+        if(notify)await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})
+        return {ok:false,error:error.message}
+      }finally{switchingMode=false;syncModeMenu()}
     }
     ipcMain.handle('desktop-remote-login:submit',async(_event,value)=>{
       if(closing||quitting)return {ok:false,error:'App 正在退出'}
@@ -380,7 +407,7 @@ else {
     })
     ipcMain.handle('desktop-remote-login:use-local',async()=>{
       loginWindow?.close()
-      if(!remoteMode){
+      if(!serverModeActive){
         if(!window||window.isDestroyed())createWindow()
         await switchMode('local')
       }
@@ -428,14 +455,16 @@ else {
         {id:'runner-import',label:'导入节点配置…',click:importRunner},
         {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
         {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
+      {label:'运行模式',submenu:[{id:'desktop-mode-status',label:'请选择运行模式',enabled:false},{type:'separator'},
+        {id:'desktop-host-use-remote',label:'客户端模式(连接服务器)',enabled:!remoteMode,click:()=>void switchMode('remote')},
+        {id:'desktop-host-use-local',label:'服务器模式(本机运行)',enabled:!serverModeActive,click:()=>void switchMode('local')},
+        {label:'更换服务器…',click:openRemoteLogin},{type:'separator'},
+        {id:'desktop-host-ask',label:'启动时询问运行模式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
+          click:item=>{void preferences.setStartupChoice(item.checked?'ask':remoteMode?'remote':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
       {label:'桌面主机',submenu:[{id:'desktop-host-status',label:'未配置桌面主机',enabled:false},{type:'separator'},
-        {id:'desktop-host-use-remote',label:'登录远程服务器…',enabled:!remoteMode,click:()=>void switchMode('remote')},
-        {id:'desktop-host-use-local',label:'作为服务器运行',enabled:!serverModeActive,click:()=>void switchMode('local')},{type:'separator'},
         {id:'desktop-host-import',label:'导入主机配置…',click:importDesktopHost},
         {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
-        {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())},{type:'separator'},
-        {id:'desktop-host-ask',label:'启动时询问使用方式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
-          click:item=>{void preferences.setStartupChoice(item.checked?'ask':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
+        {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
       { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
     ]))
     const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })

+ 75 - 0
desktop/mode-switch.test.mjs

@@ -0,0 +1,75 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,readFile,writeFile,realpath,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:http'
+import {_electron as electron,expect} from '@playwright/test'
+import {dataKey} from './service-manager.mjs'
+
+test('signed-in desktop pages can switch both ways; other windows cannot control the runtime',{timeout:60000},async()=>{
+  const root=resolve(import.meta.dirname,'..'),home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-mode-switch-')))
+  const version=JSON.parse(await readFile(join(root,'.desktop-build/release.json'),'utf8')).webVersion
+  const identity={protocol:1,instanceId:'mode-switch',pid:process.pid,dataKey:dataKey(home),version}
+  let releaseIdentity
+  const identityReady=new Promise(resolve=>{releaseIdentity=resolve})
+  const local=createServer(async(req,res)=>{
+    if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
+    else {res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的本机页面</title>')}
+  })
+  const remote=createServer((_req,res)=>{res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')})
+  await new Promise(done=>local.listen(0,'127.0.0.1',done))
+  await new Promise(done=>remote.listen(0,'127.0.0.1',done))
+  const origin=`http://127.0.0.1:${local.address().port}`,remoteURL=`http://127.0.0.1:${remote.address().port}`
+  await writeFile(join(home,'service-instance.json'),JSON.stringify({...identity,url:origin,token:'fixture'}))
+  await writeFile(join(home,'desktop-preferences.json'),JSON.stringify({startupChoice:'local',remoteServer:remoteURL,backgroundAtLogin:false}))
+  let app
+  try{
+    app=await electron.launch({args:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(local.address().port),HERMES_YAOYAO_DESKTOP_TEST_SYNC:'0'}})
+    const page=await app.firstWindow();releaseIdentity();await page.waitForURL(origin+'/**')
+    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'server',serverURL:origin,switching:false})
+    assert.equal(await page.evaluate(()=>window.yaoyaoDesktop.switchMode('invalid').then(()=>false,()=>true)),true)
+    await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
+    await page.waitForURL(remoteURL+'/**')
+    assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'client',serverURL:remoteURL,switching:false})
+    await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
+    // Unchecking "ask at startup" must retain the active client role.
+    await app.evaluate(({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-host-ask');item.click(item)})
+    await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('ask')
+    await app.evaluate(({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-host-ask');item.click(item)})
+    await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
+    const denied=await app.evaluate(async({BrowserWindow},{origin,preload})=>{
+      const outsider=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,preload}})
+      try{
+        await outsider.loadURL(origin)
+        return await outsider.webContents.executeJavaScript('Promise.all([window.yaoyaoDesktop.modeState(),window.yaoyaoDesktop.switchMode("server"),window.yaoyaoDesktop.openRemoteLogin()].map(p=>p.then(()=>false,()=>true)))')
+      }finally{outsider.destroy()}
+    },{origin:remoteURL,preload:join(root,'desktop/preload.cjs')})
+    assert.deepEqual(denied,[true,true,true])
+    await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('server')})
+    await page.waitForURL(origin+'/**')
+    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false})
+    assert.equal(JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice,'local')
+    assert.equal(local.listening,true,'switching must leave an independent service running')
+    assert.equal(remote.listening,true)
+    // The server picker remains available after either login/mode selection.
+    const opened=app.waitForEvent('window');await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
+    const login=await opened;await login.waitForLoadState()
+    assert.equal(app.windows().length,2)
+    await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
+    assert.equal(app.windows().length,2)
+    await login.evaluate(()=>{void window.yaoyaoRemoteLogin.useLocal()})
+    await expect.poll(()=>app.windows().length).toBe(1)
+    await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
+    await page.waitForURL(remoteURL+'/**')
+    const pickerOpened=app.waitForEvent('window');await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
+    const picker=await pickerOpened;await picker.waitForLoadState()
+    await picker.evaluate(()=>{void window.yaoyaoRemoteLogin.useLocal()})
+    await page.waitForURL(origin+'/**')
+    await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false})
+  }finally{
+    releaseIdentity();await app?.close().catch(()=>{})
+    await Promise.all([local,remote].map(server=>new Promise(done=>{server.close(done);server.closeAllConnections()})))
+    await rm(home,{recursive:true,force:true})
+  }
+})

+ 3 - 0
desktop/preload.cjs

@@ -5,6 +5,9 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
   openUpdates: () => ipcRenderer.invoke('desktop:updates'),
+  modeState: () => ipcRenderer.invoke('desktop:mode-state'),
+  switchMode: mode => ipcRenderer.invoke('desktop:mode-switch', mode),
+  openRemoteLogin: () => ipcRenderer.invoke('desktop:remote-login'),
   /** 'local' on the YaoYao server Mac; paired host uuid in remote mode; null if unknown. */
   deviceHost: () => ipcRenderer.invoke('desktop:device-host'),
   openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),

+ 3 - 1
docs/desktop-lifecycle.md

@@ -1,6 +1,8 @@
 # macOS App 与后台服务
 
-同一个 App 支持两种角色。客户端模式只连接服务器并提供已授权的设备操作,无需本机 Hermes;服务器模式管理下表中的后台服务。切到客户端会关闭 App 的本机操作通道、停止其子进程并断开本机服务连接;独立后台服务继续遵循原生命周期,可通过停止后台服务菜单关闭。菜单显示「作为服务器运行」,首次选择默认是客户端。
+同一个 App 支持两种角色。客户端模式只连接服务器并提供已授权的设备操作,无需本机 Hermes;服务器模式管理下表中的后台服务。切到客户端会关闭 App 的本机操作通道、停止其子进程并断开本机服务连接;独立后台服务继续遵循原生命周期,可通过停止后台服务菜单关闭。首次选择默认是客户端。
+
+登录后可在「我的设置 → 运行模式」查看当前角色和服务器地址,选择「客户端模式」或「服务器模式」并切换,也可通过「更换服务器」重新登录其他服务器。此入口向桌面 App 的所有账号开放,管理的是当前电脑,不依赖远程账号的管理员权限;普通浏览器不显示本机运行模式控制。原生菜单栏另有常驻的「运行模式」菜单。切换成功后记住选择,账号与聊天数据保留在各自服务器。
 
 | 操作 | 结果 |
 | --- | --- |

+ 14 - 0
docs/verification/2026-09-19-desktop-mode/README.md

@@ -0,0 +1,14 @@
+# 登录后的桌面运行模式切换
+
+- 入口:登录后「我的设置 → 运行模式」,普通成员和管理员均可访问;原生菜单栏同时提供「运行模式」入口。
+- 页面显示当前模式和服务器地址。选择客户端或服务器后点击切换;「更换服务器」打开原生登录窗口。
+- 原生权限只允许当前服务的主窗口操作;普通浏览器隐藏此入口。切换保留独立服务和各服务器数据,成功后记住选择。
+
+## 验证
+
+- `npm run build` 与 `npm run typecheck` 通过。
+- 设置组件 15 项测试通过,覆盖普通成员双向操作入口、浏览器隐藏、失败提示及更换服务器。
+- `npm run desktop:test`:38 项通过。新增真实 Electron 验证双向切换、启动偏好、登录窗口复用和其他窗口的权限拒绝;补充验证远程登录窗口中返回服务器模式。
+- 浏览器隔离预览使用模拟原生接口,验证了浅色、深色、375px 窄窗口和横屏布局,以及选择、切换与状态更新。未操作用户服务。
+
+截图:[浅色桌面](desktop-light.png)、[深色桌面](desktop-dark.png)、[窄窗口](narrow.png)、[横屏](landscape.png)。

binární
docs/verification/2026-09-19-desktop-mode/desktop-dark.png


binární
docs/verification/2026-09-19-desktop-mode/desktop-light.png


binární
docs/verification/2026-09-19-desktop-mode/landscape.png


binární
docs/verification/2026-09-19-desktop-mode/narrow.png


+ 93 - 0
src/client/components/app/DesktopModePanel.vue

@@ -0,0 +1,93 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+type Mode = 'client' | 'server'
+const desktop = window.yaoyaoDesktop
+const state = ref<{ mode: Mode; serverURL: string; switching: boolean }>()
+const selected = ref<Mode>('client')
+const busy = ref(false)
+const error = ref('')
+const notice = ref('')
+const options = [
+  { mode: 'client', title: '客户端模式', description: '连接另一台电脑上的夭夭服务器。本机无需安装 Hermes。', icon: 'link' },
+  { mode: 'server', title: '服务器模式', description: '在这台电脑上运行夭夭服务,使用本机 Hermes,并供其他客户端连接。', icon: 'monitor' },
+] as const
+
+async function load() {
+  busy.value = true; error.value = ''
+  try {
+    state.value = await desktop!.modeState!()
+    selected.value = state.value.mode
+  } catch (e) { error.value = e instanceof Error ? e.message : '无法读取运行模式,请重试。' }
+  finally { busy.value = false }
+}
+async function switchMode() {
+  if (busy.value || !state.value || selected.value === state.value.mode) return
+  busy.value = true; error.value = ''; notice.value = ''
+  try {
+    const result = await desktop!.switchMode!(selected.value)
+    if (!result.ok) throw new Error(result.error || '切换运行模式未完成,请重试。')
+    if (result.pendingLogin) notice.value = '请在弹出的窗口中登录服务器。'
+    else await load()
+  } catch (e) { error.value = e instanceof Error ? e.message : '切换运行模式未完成,请重试。' }
+  finally { busy.value = false }
+}
+async function changeServer() {
+  busy.value = true; error.value = ''; notice.value = ''
+  try { await desktop!.openRemoteLogin!(); notice.value = '请在弹出的窗口中填写服务器地址并登录。' }
+  catch (e) { error.value = e instanceof Error ? e.message : '无法打开服务器登录窗口。' }
+  finally { busy.value = false }
+}
+onMounted(load)
+</script>
+
+<template>
+  <section class="desktop-mode-panel" aria-label="服务器与客户端模式" :aria-busy="busy">
+    <div v-if="state" class="mode-status">
+      <strong>当前:{{ state.mode === 'client' ? '客户端模式' : '服务器模式' }}</strong>
+      <span v-if="state.serverURL">{{ state.serverURL }}</span>
+    </div>
+    <p v-else-if="busy" role="status">正在读取运行模式…</p>
+    <fieldset :disabled="busy || !state || state.switching">
+      <legend>这台电脑如何使用夭夭?</legend>
+      <label v-for="option in options" :key="option.mode" class="mode-option" :class="{ selected: selected === option.mode }">
+        <input v-model="selected" type="radio" name="desktop-running-mode" :value="option.mode" />
+        <AppIcon :name="option.icon" :size="22" />
+        <span><strong>{{ option.title }}</strong><small>{{ option.description }}</small></span>
+      </label>
+    </fieldset>
+    <p class="mode-note">切换后会打开对应服务的页面,并记住选择。数据保留在原服务器;连接另一台服务器时,可能需要登录。</p>
+    <p v-if="error" class="mode-error" role="alert">{{ error }}</p>
+    <p v-if="notice" role="status">{{ notice }}</p>
+    <div class="mode-actions">
+      <button v-if="!state" type="button" :disabled="busy" @click="load">重新读取</button>
+      <button v-else class="mode-primary" type="button" :disabled="busy || state.switching || selected === state.mode" @click="switchMode">
+        {{ busy ? '正在切换…' : selected === state.mode ? '正在使用此模式' : selected === 'client' ? '切换为客户端' : '切换为服务器' }}
+      </button>
+      <button v-if="desktop?.openRemoteLogin" type="button" :disabled="busy || state?.switching" @click="changeServer">更换服务器…</button>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.desktop-mode-panel{display:grid;gap:20px;max-width:640px;color:var(--text-primary)}
+.mode-status{display:grid;gap:6px;padding:16px;border-radius:12px;background:var(--surface-soft)}
+.mode-status span{color:var(--text-secondary);font-size:13px;overflow-wrap:anywhere}
+fieldset{display:grid;gap:12px;min-width:0;margin:0;padding:0;border:0}
+legend{padding:0 0 12px;font-weight:600}
+.mode-option{display:flex;align-items:center;gap:12px;padding:18px;border:1px solid var(--line-strong);border-radius:12px;cursor:pointer}
+.mode-option.selected{border-color:var(--accent);background:var(--settings-selected)}
+.mode-option input{flex:none;margin:0;accent-color:var(--accent)}
+.mode-option span{display:grid;gap:6px;min-width:0}
+.mode-option small{font-size:13px;line-height:1.6;color:var(--text-secondary)}
+.mode-option:focus-within{outline:2px solid var(--accent);outline-offset:3px}
+fieldset:disabled .mode-option{cursor:default;opacity:.65}
+.mode-note{margin:0;color:var(--text-secondary);font-size:13px;line-height:1.7}
+.mode-error{margin:0;color:var(--danger)}
+.mode-actions{display:flex;flex-wrap:wrap;gap:12px}
+.mode-actions button{min-height:44px;padding:10px 18px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface-soft);color:var(--text-primary);font:inherit;cursor:pointer}
+.mode-actions .mode-primary{background:var(--accent);border-color:var(--accent);color:var(--text-on-solid)}
+.mode-actions button:disabled{opacity:.5;cursor:default}
+.mode-actions button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+</style>

+ 7 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, reactive, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
+import DesktopModePanel from '@/components/app/DesktopModePanel.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import DuplexVoicePanel from '@/components/app/DuplexVoicePanel.vue'
 import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
@@ -22,6 +23,7 @@ type SettingsPage =
   | 'account-security'
   | 'account-mobile'
   | 'appearance'
+  | 'desktop-mode'
   | 'system-overview'
   | 'system-users'
   | 'system-connection'
@@ -93,6 +95,7 @@ const updateLocked = ref(false)
 const accountCanSave = ref(false)
 const dirtyPages = reactive<Partial<Record<SettingsPage, boolean>>>({})
 const settingsQuery = ref('')
+const desktopModesAvailable = Boolean(window.yaoyaoDesktop?.modeState && window.yaoyaoDesktop?.switchMode)
 
 const agentItems = computed<NavigationItem[]>(() => !props.isAdmin || props.botMode ? [] : [
   { key: 'agent-identity', label: '身份与头像', icon: 'users' },
@@ -101,6 +104,7 @@ const agentItems = computed<NavigationItem[]>(() => !props.isAdmin || props.botM
 const accountItems = computed<NavigationItem[]>(() => [
   { key: 'account-profile', label: '账号资料', icon: 'users' },
   { key: 'account-security', label: '登录与安全', icon: 'settings' },
+  ...(desktopModesAvailable ? [{ key: 'desktop-mode', label: '运行模式', icon: 'monitor' } satisfies NavigationItem] : []),
   { key: 'appearance', label: '外观', icon: 'sun' },
   ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
 ])
@@ -131,6 +135,7 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   appearance: '外观',
+  'desktop-mode': '运行模式',
   'system-overview': '系统概览',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
@@ -145,6 +150,7 @@ const activeScope = computed(() => {
   if (activePage.value === 'account-profile') return '管理你的账号头像与服务器名称。'
   if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
   if (activePage.value === 'appearance') return '选择当前浏览器的显示方式。'
+  if (activePage.value === 'desktop-mode') return '选择这台电脑作为服务器或客户端。'
   return '全局设置 · 仅管理员'
 })
 
@@ -359,6 +365,7 @@ function requestModeSwitch() {
                   @logout="emit('logout')"
                 />
                 <NodePairingPanel v-else-if="activePage === 'account-mobile'" mode="account" :active="true" :insecure-transport="insecureTransport" :user-name="accountName" />
+                <DesktopModePanel v-else-if="activePage === 'desktop-mode' && desktopModesAvailable" />
                 <section v-else-if="activePage === 'appearance'" class="appearance-panel" aria-label="外观">
                   <h4>界面主题</h4>
                   <div class="theme-options" role="radiogroup" aria-label="界面主题">

+ 3 - 0
src/client/env.d.ts

@@ -12,6 +12,9 @@ interface ImportMeta {
 interface Window {
   yaoyaoDesktop?: {
     openUpdates?(): Promise<void>
+    modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean }>
+    switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
+    openRemoteLogin?(): Promise<void>
     deviceHost?(): Promise<{ deviceHost: string | null }>
     openComputer(id: string): Promise<boolean>
     computerClosed(): Promise<void>

+ 49 - 1
tests/client/settingsCenterDialog.test.ts

@@ -1,4 +1,4 @@
-import { mount, type VueWrapper } from '@vue/test-utils'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { defineComponent } from 'vue'
 import type { Profile } from '@shared/types'
@@ -75,12 +75,60 @@ function navigationButton(wrapper: VueWrapper, label: string) {
 }
 
 afterEach(() => {
+  delete window.yaoyaoDesktop
   document.body.innerHTML = ''
   vi.restoreAllMocks()
   Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
 })
 
 describe('Settings center dialog', () => {
+  it('keeps desktop runtime controls out of a browser', () => {
+    const wrapper = mountSettings({ initialPage: 'desktop-mode' })
+    expect(wrapper.findAll('.settings-sidebar nav button').map(button => button.text())).not.toContain('运行模式')
+    expect(wrapper.find('[aria-label="服务器与客户端模式"]').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('lets a signed-in member switch this desktop from client to server without logging out', async () => {
+    let mode: 'client' | 'server' = 'client'
+    const switchMode = vi.fn(async (next: 'client' | 'server') => { mode = next; return { ok: true } })
+    Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: {
+      modeState: vi.fn(async () => ({ mode, serverURL: 'http://fixture:15300', switching: false })),
+      switchMode,
+      openRemoteLogin: vi.fn(async () => {}),
+    } })
+    const wrapper = mountSettings({ isAdmin: false, botMode: true, initialPage: 'desktop-mode' })
+    await flushPromises()
+    expect(navigationButton(wrapper, '运行模式').attributes('aria-current')).toBe('page')
+    expect(wrapper.text()).toContain('当前:客户端模式')
+    await wrapper.get('input[value="server"]').setValue()
+    expect(switchMode).not.toHaveBeenCalled()
+    await wrapper.get('.mode-primary').trigger('click')
+    await flushPromises()
+    expect(switchMode).toHaveBeenCalledWith('server')
+    expect(wrapper.text()).toContain('当前:服务器模式')
+    expect(wrapper.emitted('logout')).toBeUndefined()
+    wrapper.unmount()
+  })
+
+  it('preserves the current mode on a failed switch and can reopen server login', async () => {
+    const openRemoteLogin = vi.fn(async () => {})
+    Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: {
+      modeState: vi.fn(async () => ({ mode: 'server', serverURL: 'http://127.0.0.1:15300', switching: false })),
+      switchMode: vi.fn(async () => ({ ok: false, error: '连接失败,请重试。' })), openRemoteLogin,
+    } })
+    const wrapper = mountSettings({ initialPage: 'desktop-mode' })
+    await flushPromises()
+    await wrapper.get('input[value="client"]').setValue()
+    await wrapper.get('.mode-primary').trigger('click'); await flushPromises()
+    expect(wrapper.get('[role="alert"]').text()).toContain('连接失败')
+    expect(wrapper.text()).toContain('当前:服务器模式')
+    await wrapper.findAll('.mode-actions button').find(button => button.text() === '更换服务器…')!.trigger('click')
+    await flushPromises()
+    expect(openRemoteLogin).toHaveBeenCalledOnce()
+    expect(wrapper.get('[role="status"]').text()).toContain('服务器地址并登录')
+    wrapper.unmount()
+  })
   it.each(['agent-identity', 'agent-models'])('keeps %s and the Profile selector out of Bot-mode personal settings', initialPage => {
     const wrapper = mountSettings({ botMode: true, initialPage })
     expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual(['个人', '管理'])

+ 10 - 2
tests/fixtures/settings-design/main.ts

@@ -9,6 +9,14 @@ import '@/styles/tokens.css'
 import '@/styles/global.css'
 
 const query=new URLSearchParams(location.search)
+if(query.get('view')==='desktop-mode'){
+  let mode:'client'|'server'='client'
+  window.yaoyaoDesktop={
+    modeState:async()=>({mode,serverURL:mode==='client'?'http://192.168.1.200:15300':'http://127.0.0.1:15300',switching:false}),
+    switchMode:async next=>{mode=next;return {ok:true}},openRemoteLogin:async()=>{},
+    openComputer:async()=>false,computerClosed:async()=>{},onComputerClose:()=>()=>{},
+  }
+}
 const names=['测试2','竹儿','测试','瑶儿']
 const agents=names.map((name,i)=>({id:`00000000-0000-4000-8000-00000000000${i+1}`,name,profile:'default',nodeId:'local',archived:false,avatar:'',instructions:'协助整理项目资料,检查问题并给出清晰、简洁的建议。',execution:'computer',canManageTeam:false,canCollaborate:true,memoryEnabled:true,createdAt:1,updatedAt:1}))
 const conversations=[{id:'group-a',name:'方案分析团队',kind:'group',memberIds:agents.map(a=>a.id),archived:false},{id:'group-b',name:'内容运营组',kind:'group',memberIds:agents.slice(0,3).map(a=>a.id),archived:false}]
@@ -52,14 +60,14 @@ const App=defineComponent({setup(){
   const auth=useAuthStore()
   auth.user={id:'fixture-user',username:'samien',role:query.has('member')?'member':'admin'} as never
   auth.status='authenticated';auth.serverIdentity=identity
-  const opened=ref<'bot'|'personal'|'editor'|null>(query.get('view')==='editor'?'editor':query.get('view')==='appearance'||query.get('view')==='profile'?'personal':'bot')
+  const opened=ref<'bot'|'personal'|'editor'|null>(query.get('view')==='editor'?'editor':['appearance','profile','desktop-mode'].includes(query.get('view')??'')?'personal':'bot')
   return()=>h('main',{style:'min-height:100dvh;background:var(--settings-panel);padding:18px;box-sizing:border-box'},[
     h('div',{style:'display:flex;gap:12px;align-items:center;font:13px var(--font-ui)'},[
       h('span','界面验证 · 示例数据'),h('button',{onClick:()=>opened.value='bot'},'Bot 设置'),h('button',{onClick:()=>opened.value='personal'},'我的设置'),h('button',{onClick:()=>opened.value='editor'},'编辑资料'),
     ]),
     opened.value==='bot'?h(BotSettingsDialog,{isAdmin:!query.has('member'),onClose:()=>opened.value=null}):null,
     opened.value==='editor'?h(BotProfileDialog,{agent:agents[0],draft:{...agents[0],source:'["local","default"]'},sources:[{nodeId:'local',profile:'default',name:'丫头'}],profiles:[{name:'default',agentName:'丫头',isDefault:true}],agents,conversations,conversationId:'fixture-chat',isAdmin:!query.has('member'),knowledgeEnabled:true,onClose:()=>opened.value=null,onSave:()=>opened.value=null} as never):null,
-    h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
+    h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='desktop-mode'?'desktop-mode':query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
   ])
 }})
 createApp(App).use(createPinia()).mount('#app')