301 lines
14 KiB
Vue
301 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { SyncApi, type Device, type ServiceStatus, type Vault } from './api'
|
||
|
||
const api = new SyncApi()
|
||
const service = reactive<ServiceStatus>({ health: false, ready: false, protocol: 1, maxObjectSize: 100 * 1024 * 1024 })
|
||
const checking = ref(true)
|
||
const signedIn = ref(false)
|
||
const busy = ref(false)
|
||
const username = ref('')
|
||
const password = ref('')
|
||
const deviceName = ref('OpenNexus Web Console')
|
||
const sessionLabel = ref('')
|
||
const credentialsRequired = ref(false)
|
||
const currentPassword = ref('')
|
||
const newUsername = ref('')
|
||
const newPassword = ref('')
|
||
const confirmPassword = ref('')
|
||
const newVaultName = ref('')
|
||
const vaults = ref<Vault[]>([])
|
||
const devices = ref<Device[]>([])
|
||
const toast = ref('')
|
||
const toastError = ref(false)
|
||
let toastTimer: number | undefined
|
||
let statusTimer: number | undefined
|
||
|
||
const used = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.used || 0), 0))
|
||
const quota = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.quota || 0), 0))
|
||
const activeDevices = computed(() => devices.value.filter(device => !device.revoked).length)
|
||
|
||
function formatBytes(value: number) {
|
||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||
let size = Number(value) || 0
|
||
let unit = 0
|
||
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1 }
|
||
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
|
||
}
|
||
|
||
function shortId(value: string) { return `${value.slice(0, 12)}…` }
|
||
function usagePercent(vault: Vault) { return Math.min(100, vault.quota ? vault.used / vault.quota * 100 : 0) }
|
||
function firstCharacter(value: string, fallback: string) { return value.trim().slice(0, 1).toUpperCase() || fallback }
|
||
|
||
function notify(text: string, error = false) {
|
||
toast.value = text
|
||
toastError.value = error
|
||
window.clearTimeout(toastTimer)
|
||
toastTimer = window.setTimeout(() => { toast.value = '' }, 4800)
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
checking.value = true
|
||
try { Object.assign(service, await api.status()) }
|
||
finally { checking.value = false }
|
||
}
|
||
|
||
async function loadAccount() {
|
||
const [vaultPayload, devicePayload] = await Promise.all([api.vaults(), api.devices()])
|
||
vaults.value = vaultPayload.items
|
||
devices.value = devicePayload.items
|
||
}
|
||
|
||
async function refreshAccount() {
|
||
if (busy.value) return
|
||
busy.value = true
|
||
try {
|
||
await loadAccount()
|
||
notify('账户数据已刷新')
|
||
} catch (error) {
|
||
if (!api.signedIn) leaveConsole()
|
||
notify(error instanceof Error ? error.message : 'REFRESH_FAILED', true)
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
function leaveConsole() {
|
||
api.clear()
|
||
signedIn.value = false
|
||
password.value = ''
|
||
vaults.value = []
|
||
devices.value = []
|
||
credentialsRequired.value = false
|
||
currentPassword.value = ''
|
||
newPassword.value = ''
|
||
confirmPassword.value = ''
|
||
}
|
||
|
||
async function signIn() {
|
||
if (busy.value) return
|
||
busy.value = true
|
||
const secret = password.value
|
||
const account = username.value.trim()
|
||
const device = deviceName.value.trim()
|
||
password.value = ''
|
||
try {
|
||
credentialsRequired.value = await api.login(account, secret, device)
|
||
sessionLabel.value = `${account} · ${device}`
|
||
newUsername.value = account
|
||
signedIn.value = true
|
||
if (!credentialsRequired.value) await loadAccount()
|
||
notify(credentialsRequired.value ? '请立即固定账户与密码' : '设备会话已建立')
|
||
} catch (error) {
|
||
leaveConsole()
|
||
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
async function fixCredentials() {
|
||
if (busy.value) return
|
||
if (newPassword.value !== confirmPassword.value) {
|
||
notify('两次输入的新密码不一致', true)
|
||
return
|
||
}
|
||
busy.value = true
|
||
const oldSecret = currentPassword.value
|
||
const nextSecret = newPassword.value
|
||
currentPassword.value = ''
|
||
newPassword.value = ''
|
||
confirmPassword.value = ''
|
||
try {
|
||
const result = await api.changeCredentials(oldSecret, newUsername.value.trim(), nextSecret)
|
||
credentialsRequired.value = false
|
||
sessionLabel.value = `${result.username} · ${deviceName.value.trim()}`
|
||
await loadAccount()
|
||
notify('账户与密码已固定;以后重启不会再随机更换')
|
||
} catch (error) {
|
||
notify(error instanceof Error ? error.message : 'CREDENTIAL_CHANGE_FAILED', true)
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
async function createVault() {
|
||
const name = newVaultName.value.trim()
|
||
if (!name || busy.value) return
|
||
busy.value = true
|
||
try {
|
||
await api.createVault(name)
|
||
newVaultName.value = ''
|
||
await loadAccount()
|
||
notify(`已创建 Vault:${name}`)
|
||
} catch (error) {
|
||
notify(error instanceof Error ? error.message : 'CREATE_VAULT_FAILED', true)
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
async function revokeDevice(device: Device) {
|
||
if (!window.confirm(`撤销设备“${device.name}”?该设备的下一次请求会被拒绝。`)) return
|
||
busy.value = true
|
||
try {
|
||
await api.revokeDevice(device.id)
|
||
if (device.id === api.deviceId) {
|
||
leaveConsole()
|
||
notify('当前设备已撤销')
|
||
} else {
|
||
await loadAccount()
|
||
notify(`已撤销设备:${device.name}`)
|
||
}
|
||
} catch (error) {
|
||
notify(error instanceof Error ? error.message : 'REVOKE_FAILED', true)
|
||
} finally { busy.value = false }
|
||
}
|
||
|
||
async function logout() {
|
||
if (busy.value) return
|
||
busy.value = true
|
||
try { await api.logout() }
|
||
finally {
|
||
leaveConsole()
|
||
busy.value = false
|
||
notify('会话已退出')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void refreshStatus()
|
||
statusTimer = window.setInterval(() => { void refreshStatus() }, 15_000)
|
||
})
|
||
onBeforeUnmount(() => {
|
||
window.clearInterval(statusTimer)
|
||
window.clearTimeout(toastTimer)
|
||
leaveConsole()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="ambient ambient-one" aria-hidden="true" />
|
||
<div class="ambient ambient-two" aria-hidden="true" />
|
||
<header class="topbar">
|
||
<a class="brand" href="/console" aria-label="OpenNexus Sync Console">
|
||
<span class="brand-mark" aria-hidden="true"><i /><i /><i /></span>
|
||
<span><strong>OpenNexus</strong><small>Sync Console</small></span>
|
||
</a>
|
||
<div class="service-strip" aria-label="服务状态">
|
||
<span class="status-chip" :class="{ ok: service.health, bad: !checking && !service.health }"><i /><b>服务</b><em>{{ checking ? '检测中' : service.health ? '在线' : '离线' }}</em></span>
|
||
<span class="status-chip" :class="{ ok: service.ready, bad: !checking && !service.ready }"><i /><b>依赖</b><em>{{ checking ? '检测中' : service.ready ? '就绪' : '不可用' }}</em></span>
|
||
<button class="icon-button" type="button" :disabled="checking" aria-label="刷新服务状态" title="刷新服务状态" @click="refreshStatus">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section v-if="!signedIn" class="hero">
|
||
<div class="hero-copy">
|
||
<div class="eyebrow"><span /> Self-hosted workspace sync</div>
|
||
<h1>让知识在设备之间<br><em>安静地抵达。</em></h1>
|
||
<p>OpenNexus Sync 为每个 Vault 保留完整修订历史、附件完整性校验和设备级撤销。此控制台只连接当前服务器,不在浏览器持久保存密码或令牌。</p>
|
||
<div class="protocol-grid" aria-label="协议能力">
|
||
<article><strong>v{{ service.protocol }}</strong><span>同步协议</span></article>
|
||
<article><strong>{{ formatBytes(service.maxObjectSize) }}</strong><span>单对象上限</span></article>
|
||
<article><strong>SHA-256</strong><span>内容完整性</span></article>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="login-card" aria-labelledby="login-title">
|
||
<div class="card-glow" aria-hidden="true" />
|
||
<div class="card-heading">
|
||
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
|
||
<div><p>设备会话</p><h2 id="login-title">登录 Sync Server</h2></div>
|
||
</div>
|
||
<form @submit.prevent="signIn">
|
||
<label>账户<input v-model="username" autocomplete="username" maxlength="80" required placeholder="输入 Sync 账户"></label>
|
||
<label>密码<input v-model="password" type="password" autocomplete="current-password" minlength="12" maxlength="256" required placeholder="至少 12 个字符"></label>
|
||
<label>设备名称<input v-model="deviceName" autocomplete="off" maxlength="120" required></label>
|
||
<button class="primary-button" type="submit" :disabled="busy || !password"><span>{{ busy ? '正在建立会话…' : '建立安全会话' }}</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg></button>
|
||
</form>
|
||
<p class="privacy-note"><span>●</span>密码在发出请求前即从输入框清除;会话令牌只保存在当前页面内存。</p>
|
||
</section>
|
||
</section>
|
||
|
||
<section v-else class="console-view">
|
||
<div class="console-heading">
|
||
<div><div class="eyebrow"><span /> Connected workspace</div><h1>同步空间</h1><p>{{ sessionLabel }}</p></div>
|
||
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
|
||
</div>
|
||
|
||
<section v-if="credentialsRequired" class="login-card credential-card" aria-labelledby="credential-title">
|
||
<div class="card-heading">
|
||
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
|
||
<div><p>首次登录</p><h2 id="credential-title">固定账户凭据</h2></div>
|
||
</div>
|
||
<p class="surface-intro">当前密码只对本次服务启动有效。修改账户和密码后,凭据将写入数据库并在后续重启中保持不变。</p>
|
||
<form @submit.prevent="fixCredentials">
|
||
<label>当前随机密码<input v-model="currentPassword" type="password" autocomplete="current-password" minlength="12" maxlength="256" required></label>
|
||
<label>新账户<input v-model="newUsername" autocomplete="username" maxlength="80" required></label>
|
||
<label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||
<label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
|
||
<button class="primary-button" type="submit" :disabled="busy || !currentPassword || !newPassword || !confirmPassword">{{ busy ? '正在保存…' : '保存并固定凭据' }}</button>
|
||
</form>
|
||
</section>
|
||
|
||
<template v-else>
|
||
<div class="metric-grid">
|
||
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
|
||
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
|
||
<article><span>设备</span><strong>{{ devices.length }}</strong><small>{{ activeDevices }} 台可访问</small></article>
|
||
<article><span>协议</span><strong>v{{ service.protocol }}</strong><small>历史长期保留</small></article>
|
||
</div>
|
||
|
||
<div class="content-grid">
|
||
<section class="surface">
|
||
<div class="surface-heading">
|
||
<div><p>Vaults</p><h2>远端知识库</h2></div>
|
||
<button class="icon-button" type="button" :disabled="busy" aria-label="刷新账户数据" title="刷新账户数据" @click="refreshAccount">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
|
||
</button>
|
||
</div>
|
||
<form class="create-form" @submit.prevent="createVault">
|
||
<label>创建新 Vault</label>
|
||
<div><input v-model="newVaultName" maxlength="120" required placeholder="例如:产品知识库"><button type="submit" :disabled="busy || !newVaultName.trim()">创建</button></div>
|
||
</form>
|
||
<div class="vault-list">
|
||
<div v-if="!vaults.length" class="empty-state">还没有远端 Vault</div>
|
||
<article v-for="vault in vaults" :key="vault.id" class="vault-item">
|
||
<div class="vault-symbol">N</div>
|
||
<div class="item-copy">
|
||
<strong>{{ vault.name }}</strong><small>{{ shortId(vault.id) }} · revision {{ vault.sequence }}</small>
|
||
<progress :value="usagePercent(vault)" max="100" />
|
||
</div>
|
||
<div class="item-side"><strong>{{ formatBytes(vault.used) }}</strong><small>of {{ formatBytes(vault.quota) }}</small></div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="surface">
|
||
<div class="surface-heading"><div><p>Devices</p><h2>设备会话</h2></div><span class="secure-badge">逐次鉴权</span></div>
|
||
<p class="surface-intro">撤销会让该设备的下一次请求立即失败。Vault 内容和本地文件不会因此删除。</p>
|
||
<div class="device-list">
|
||
<article v-for="device in devices" :key="device.id" class="device-item" :class="{ revoked: device.revoked }">
|
||
<div class="device-symbol">{{ firstCharacter(device.name, 'D') }}</div>
|
||
<div class="item-copy"><strong>{{ device.name }}</strong><small>{{ device.revoked ? '已撤销' : '可访问' }}<template v-if="device.id === api.deviceId"> · 当前设备</template></small></div>
|
||
<button v-if="!device.revoked" class="danger-button" type="button" :disabled="busy" @click="revokeDevice(device)">撤销</button>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</main>
|
||
|
||
<footer><span>OpenNexus Sync v1</span><span>Transport encryption · Device revocation · Immutable history</span></footer>
|
||
<div v-if="toast" class="toast" :class="{ error: toastError }" role="status" aria-live="polite">{{ toast }}</div>
|
||
</template>
|