|
|
@@ -1,6 +1,7 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { onMounted, ref } from 'vue'
|
|
|
+import { computed, onMounted, ref } from 'vue'
|
|
|
import { apiRequest } from '@/api/client'
|
|
|
+import type { DesktopHostSummary } from '@shared/desktopHost'
|
|
|
|
|
|
const emit = defineEmits<{ 'dirty-change': [value: boolean] }>()
|
|
|
const approvalPolicy = ref<'ask'|'allow'|'deny'>('ask')
|
|
|
@@ -9,28 +10,60 @@ const serverComputer = ref(true)
|
|
|
const vm = ref(true)
|
|
|
const cloud = ref(true)
|
|
|
const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
|
|
|
-const hosts = ref<{id:string; label:string; name:string}[]>([])
|
|
|
+type ComputerRow = { id: string; label: string; name: string; online?: boolean; createdAt?: number }
|
|
|
+const hosts = ref<ComputerRow[]>([])
|
|
|
const drafts = ref<Record<string, string>>({})
|
|
|
-const nameError = ref('')
|
|
|
+const nameError = ref(''), nameStatus = ref(''), namesLoading = ref(false), nameBusy = ref('')
|
|
|
+const pendingRemoval = ref('')
|
|
|
+const namesDisabled = computed(() => namesLoading.value || !!nameBusy.value)
|
|
|
+const hasSameNames = computed(() => {
|
|
|
+ const labels = hosts.value.filter(host => host.id !== 'local').map(host => host.label)
|
|
|
+ return new Set(labels).size !== labels.length
|
|
|
+})
|
|
|
+function pairedAt(value: number) {
|
|
|
+ return new Date(value).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
|
+}
|
|
|
async function loadNames() {
|
|
|
+ if (namesLoading.value) return
|
|
|
+ namesLoading.value = true
|
|
|
nameError.value = ''
|
|
|
try {
|
|
|
- const value = await apiRequest<{localName?: string; hosts: {id:string; name:string; displayName?: string; enabled:boolean; hostName?: string}[]}>('/api/app/admin/desktop-hosts')
|
|
|
- const rows = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
|
|
|
+ const value = await apiRequest<{localName?: string; hosts: DesktopHostSummary[]}>('/api/app/admin/desktop-hosts')
|
|
|
+ const rows: ComputerRow[] = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
|
|
|
for (const host of value.hosts) {
|
|
|
if (!host.enabled) continue
|
|
|
- rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '' })
|
|
|
+ rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '', online: host.online, createdAt: host.createdAt })
|
|
|
}
|
|
|
+ const edited = new Set(hosts.value.filter(host => drafts.value[host.id] !== host.name).map(host => host.id))
|
|
|
+ drafts.value = Object.fromEntries(rows.map(row => [row.id, edited.has(row.id) ? drafts.value[row.id] : row.name]))
|
|
|
hosts.value = rows
|
|
|
- drafts.value = Object.fromEntries(rows.map(row => [row.id, row.name]))
|
|
|
} catch (cause) { nameError.value = cause instanceof Error ? cause.message : '无法读取电脑名单' }
|
|
|
+ finally { namesLoading.value = false }
|
|
|
}
|
|
|
async function saveName(id: string) {
|
|
|
- nameError.value = ''
|
|
|
+ if (namesDisabled.value) return
|
|
|
+ nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
|
|
|
+ const name = drafts.value[id] || ''
|
|
|
try {
|
|
|
- await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name: drafts.value[id] || '' } })
|
|
|
+ await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name } })
|
|
|
+ const host = hosts.value.find(row => row.id === id)
|
|
|
+ if (host) host.name = name
|
|
|
+ nameStatus.value = '名字已保存'
|
|
|
await loadNames()
|
|
|
} catch (cause) { nameError.value = cause instanceof Error ? cause.message : '改名失败' }
|
|
|
+ finally { nameBusy.value = '' }
|
|
|
+}
|
|
|
+async function removeHost(id: string) {
|
|
|
+ if (namesDisabled.value || id === 'local' || pendingRemoval.value !== id) return
|
|
|
+ nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
|
|
|
+ try {
|
|
|
+ await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
|
+ hosts.value = hosts.value.filter(host => host.id !== id)
|
|
|
+ delete drafts.value[id]
|
|
|
+ pendingRemoval.value = ''
|
|
|
+ nameStatus.value = '这条配对记录已停用'
|
|
|
+ } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '停用失败,请重试' }
|
|
|
+ finally { nameBusy.value = '' }
|
|
|
}
|
|
|
|
|
|
type Settings = { approvalPolicy?: 'ask'|'allow'|'deny'; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
|
|
|
@@ -78,15 +111,30 @@ onMounted(() => { void load(); void loadNames() })
|
|
|
<div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
|
|
|
</form>
|
|
|
<div class="names">
|
|
|
+ <div class="names-heading"><strong>电脑名称与配对记录</strong><button type="button" :disabled="namesDisabled" @click="loadNames">{{ namesLoading ? '刷新中…' : '刷新电脑列表' }}</button></div>
|
|
|
<p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,脚本机留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
|
|
|
+ <p v-if="hasSameNames" class="same-names">有同名的配对记录。请结合连接状态、编号和配对时间区分,确认不再使用后可停用旧记录。</p>
|
|
|
<p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
|
|
|
- <label v-for="host in hosts" :key="host.id"><span>{{ host.label }}</span><input v-model="drafts[host.id]" maxlength="64" :placeholder="host.id === 'local' ? '服务器' : host.label" :aria-label="`名字 ${host.label}`" /><button type="button" @click="saveName(host.id)">保存名字</button></label>
|
|
|
- <p v-if="!hosts.length && !nameError">还没有可改名的电脑。</p>
|
|
|
+ <p v-if="nameStatus" role="status">{{ nameStatus }}</p>
|
|
|
+ <div v-for="host in hosts" :key="host.id" class="computer" :data-host-id="host.id">
|
|
|
+ <div class="computer-heading"><label :for="`computer-name-${host.id}`">{{ host.label }}</label><span class="connection">{{ host.id === 'local' ? '当前服务器' : host.online ? '已连接' : '未连接' }}</span></div>
|
|
|
+ <div v-if="host.id !== 'local'" class="computer-details">
|
|
|
+ <small :title="host.id">编号 {{ host.id.slice(0, 8) }}</small>
|
|
|
+ <small v-if="host.createdAt">配对于 {{ pairedAt(host.createdAt) }}</small>
|
|
|
+ </div>
|
|
|
+ <div class="name-editor"><input :id="`computer-name-${host.id}`" v-model="drafts[host.id]" :disabled="namesDisabled" maxlength="64" :placeholder="host.id === 'local' ? '服务器' : host.label" :aria-label="`名字 ${host.label}${host.id === 'local' ? '' : `,编号 ${host.id.slice(0, 8)}`}`" /><button type="button" :disabled="namesDisabled" @click="saveName(host.id)">保存名字</button><button v-if="host.id !== 'local'" type="button" class="remove-host" :disabled="namesDisabled" @click="pendingRemoval = host.id">停用</button></div>
|
|
|
+ <div v-if="pendingRemoval === host.id" class="remove-confirmation" role="group" :aria-label="`停用编号 ${host.id.slice(0, 8)} 的配对记录`">
|
|
|
+ <p>停用后,机器人将无法连接这条配对记录。需要在这台电脑重新登录或配对才能恢复。</p>
|
|
|
+ <div class="actions"><button type="button" :disabled="namesDisabled" @click="pendingRemoval = ''">取消</button><button type="button" class="remove-host" :disabled="namesDisabled" @click="removeHost(host.id)">{{ nameBusy === host.id ? '停用中…' : '确认停用' }}</button></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <p v-if="!hosts.length && !nameError && !namesLoading">还没有可改名的电脑。</p>
|
|
|
</div>
|
|
|
</section>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
-.host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.names{display:grid;gap:10px}.names label{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) auto;gap:8px;align-items:center}.names input{min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit}.names button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.error{color:var(--danger)}select:focus-visible,input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
|
+.host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.names{display:grid;gap:10px}.names input{min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit}.names button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.error{color:var(--danger)}select:focus-visible,input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
|
|
select{display:block;width:100%;min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:inherit;font:inherit}
|
|
|
+.names-heading,.computer-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.names .computer-heading label{min-width:0;overflow-wrap:anywhere;font-weight:600}.connection{flex-shrink:0;font-size:12px;color:var(--text-secondary)}.computer{display:grid;gap:8px;padding:12px;border:1px solid var(--line);border-radius:10px}.computer-details{display:flex;flex-wrap:wrap;gap:4px 16px}.name-editor{display:flex;flex-wrap:wrap;gap:8px}.name-editor input{min-width:0;flex:1 1 150px}.same-names,.remove-confirmation{padding:10px 12px;border-radius:8px;background:var(--surface-soft)}.remove-confirmation{display:grid;gap:8px}.names .remove-host{color:var(--danger)}.names button:hover:not(:disabled){background:var(--surface-soft)}.names button:active:not(:disabled){background:var(--settings-selected)}
|
|
|
</style>
|