feat(frontend): UI P0 PageHeader + 身份三卡 + 专线抽屉密度

按设计纸落地全站页头规范、飞书 OIDC 状态/凭据/地址同步三卡,以及专线列表筛选独立行、行点击 480 抽屉与启停+更多操作列。
This commit is contained in:
Codex committed 2026-10-26 12:46:59 +08:00
1 parent a9bc68cd28
commit 35596cda9d
7 files changed
+263 -128

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
<template>
<header class="page-header">
<div class="page-header-text">
<h1 class="page-header-title">{{ title }}</h1>
<p v-if="description" class="page-header-desc">{{ description }}</p>
</div>
<div class="page-header-actions">
<slot name="secondary" />
<slot name="primary" />
</div>
</header>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 0 0 14px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-base, #e6ecf3);
}
.page-header-text { min-width: 0; flex: 1; }
.page-header-title {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
color: var(--text-primary, #1f2d3d);
}
.page-header-desc {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary, #8492a6);
}
.page-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 640px) {
.page-header { flex-direction: column; align-items: stretch; }
.page-header-actions { justify-content: flex-start; }
}
</style>
+16 -2
View File
@@ -230,7 +230,9 @@
"viewTasks": "View Tasks",
"noTasks": "No task history",
"trafficTrend": "Traffic Trend",
"noTrafficData": "No traffic data yet (waiting for samples…)"
"noTrafficData": "No traffic data yet (waiting for samples…)",
"pageDescription": "Manage VPN tunnels and runtime status",
"title": "Tunnels"
},
"oidcGuide": {
"title": "Feishu / OIDC browser authentication",
@@ -648,7 +650,19 @@
"loadFailed": "Could not load identity settings",
"saved": "Identity settings saved",
"saveFailed": "Could not save identity settings",
"pageDescription": "Configure Feishu or OIDC identity (app credentials, tenant, callbacks, and directory sync). Per-tunnel admission stays on the tunnel editor."
"pageDescription": "Configure Feishu app credentials, OIDC issuer, and directory sync.",
"credentialsTitle": "Credentials",
"addressSyncTitle": "Addresses & sync",
"tagEnabled": "Enabled",
"tagDisabled": "Disabled",
"tagTlsOk": "Entry ready",
"tagTlsPending": "Entry pending",
"tagGatewayOk": "Gateway synced",
"tagGatewayPending": "Gateway pending",
"tagDirSynced": "Directory synced",
"tagDirIdle": "Directory idle",
"retryGateway": "Retry gateway sync",
"goPublicEntry": "Open public entry"
},
"settings": {
"pageDescription": "Manage account security, the public gateway, and server setup.",
+16 -2
View File
@@ -230,7 +230,9 @@
"viewTasks": "查看任务",
"noTasks": "暂无任务记录",
"trafficTrend": "流量趋势",
"noTrafficData": "暂无流量数据(等待采样中…)"
"noTrafficData": "暂无流量数据(等待采样中…)",
"pageDescription": "管理 VPN 专线与运行状态",
"title": "专线"
},
"oidcGuide": {
"title": "飞书 / OIDC 浏览器认证配置",
@@ -648,7 +650,19 @@
"loadFailed": "读取身份配置失败",
"saved": "身份配置已保存",
"saveFailed": "保存身份配置失败",
"pageDescription": "配置飞书或 OIDC 身份源(App 凭据、租户、回调与通讯录同步)。专线准入名单仍在对应专线编辑页配置。"
"pageDescription": "配置飞书应用凭据,管理 OIDC 发行者与通讯录同步。",
"credentialsTitle": "应用凭据",
"addressSyncTitle": "地址与同步",
"tagEnabled": "已启用",
"tagDisabled": "未启用",
"tagTlsOk": "入口就绪",
"tagTlsPending": "入口待配",
"tagGatewayOk": "网关已同步",
"tagGatewayPending": "网关待同步",
"tagDirSynced": "通讯录已同步",
"tagDirIdle": "通讯录未同步",
"retryGateway": "重试同步网关",
"goPublicEntry": "去公网入口"
},
"settings": {
"pageDescription": "管理个人安全、公网认证入口与服务器环境。",
+17 -7
View File
@@ -1,18 +1,28 @@
<template>
<section class="identity-page">
<div class="identity-intro">
<p>{{ $t('identity.pageDescription') }}</p>
</div>
<IdentitySettings />
<PageHeader :title="$t('identity.title')" :description="$t('identity.pageDescription')">
<template #secondary>
<el-button :loading="refreshing" @click="onRefresh">{{ $t('tls.refresh') }}</el-button>
</template>
</PageHeader>
<IdentitySettings ref="settingsRef" />
</section>
</template>
<script setup>
import { ref } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import IdentitySettings from './settings/components/IdentitySettings.vue'
const settingsRef = ref(null)
const refreshing = ref(false)
async function onRefresh() {
refreshing.value = true
try { await settingsRef.value?.reload?.() }
finally { refreshing.value = false }
}
</script>
<style scoped>
.identity-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; }
.identity-intro { padding: 0 4px var(--space-4); }
.identity-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; }
.identity-page { max-width: 960px; margin: 0 auto; padding-bottom: 32px; }
</style>
+34 -29
View File
@@ -1,27 +1,27 @@
<template>
<el-card class="page-card" shadow="never">
<template #header>
<div class="tunnel-toolbar">
<div class="tunnel-filters">
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable class="tunnel-search" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable class="tunnel-filter" @change="load">
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
</el-select>
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable class="tunnel-filter" @change="load">
<el-option :label="$t('tunnels.stateRunning')" value="running" />
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
<el-option :label="$t('tunnels.stateError')" value="error" />
</el-select>
</div>
<div class="tunnel-actions">
<el-tooltip :content="$t('common.refresh')" placement="top">
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
</el-tooltip>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</div>
</div>
</template>
<div class="tunnels-page">
<PageHeader :title="$t('tunnels.title')" :description="$t('tunnels.pageDescription')">
<template #secondary>
<el-button :icon="Refresh" :loading="loading" @click="load">{{ $t('common.refresh') }}</el-button>
</template>
<template #primary>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</template>
</PageHeader>
<div class="tunnel-filters">
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable class="tunnel-search" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable class="tunnel-filter" @change="load">
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
</el-select>
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable class="tunnel-filter" @change="load">
<el-option :label="$t('tunnels.stateRunning')" value="running" />
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
<el-option :label="$t('tunnels.stateError')" value="error" />
</el-select>
</div>
<el-card class="page-card" shadow="never">
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="tunnel-error">
<el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button>
</el-alert>
@@ -34,7 +34,7 @@
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="default" @selection-change="handleSelectionChange">
<el-table :data="list" v-loading="loading" size="default" class="tunnel-table" @selection-change="handleSelectionChange" @row-click="openDetail">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
<template #default="{ row }">
@@ -79,17 +79,17 @@
<el-table-column :label="$t('tunnels.node')" width="110">
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
<el-table-column :label="$t('common.operation')" width="150" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<div class="row-actions" @click.stop>
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)">
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
<el-dropdown-item command="edit">{{ $t('common.edit') }}</el-dropdown-item>
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
@@ -110,6 +110,7 @@
<TunnelDetailDrawer ref="detailDrawer" @updated="load" />
</el-card>
</div>
<TunnelLogDialog ref="logDialog" />
@@ -127,6 +128,7 @@ import api from '../api'
import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import TaskProgress from '../components/TaskProgress.vue'
import PageHeader from '../components/PageHeader.vue'
import TunnelDetailDrawer from './tunnels/components/TunnelDetailDrawer.vue'
import TunnelLogDialog from './tunnels/components/TunnelLogDialog.vue'
import { protocolDisplay, protocolColor } from '../protocols'
@@ -219,8 +221,12 @@ async function load() {
}
// 更多操作下拉命令分发
function openDetail(row) {
detailDrawer.value?.open(row)
}
function onMoreCommand(cmd, row) {
if (cmd === 'detail') detailDrawer.value?.open(row)
else if (cmd === 'edit') router.push(`/tunnels/${row.id}/edit`)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') detailDrawer.value?.showQr(row)
@@ -296,11 +302,10 @@ onUnmounted(() => clearInterval(timer))
</script>
<style scoped>
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
.tunnel-filters { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin-bottom: 12px; }
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
.tunnel-table :deep(tbody tr) { cursor: pointer; }
.tunnel-search { width: 200px; }
.tunnel-filter { width: 150px; }
.tunnel-error { margin-bottom: var(--space-4); }
@@ -1,99 +1,115 @@
<template>
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<div class="identity-guide">
<b>{{ $t('identity.guideTitle') }}</b>
<p>{{ $t('identity.guideApp') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
<!-- 1. 状态条 -->
<el-card class="page-card status-card" shadow="never">
<div class="status-row">
<div class="status-tags">
<el-tag :type="identityForm.enabled ? 'success' : 'info'" size="small">{{ identityForm.enabled ? $t('identity.tagEnabled') : $t('identity.tagDisabled') }}</el-tag>
<el-tag :type="identityStatus?.tls_ready ? 'success' : 'warning'" size="small">{{ identityStatus?.tls_ready ? $t('identity.tagTlsOk') : $t('identity.tagTlsPending') }}</el-tag>
<el-tag :type="identityStatus?.gateway_ready ? 'success' : 'warning'" size="small">{{ identityStatus?.gateway_ready ? $t('identity.tagGatewayOk') : $t('identity.tagGatewayPending') }}</el-tag>
<el-tag :type="syncStatus?.last_success ? 'success' : 'info'" size="small">{{ syncStatus?.last_success ? $t('identity.tagDirSynced') : $t('identity.tagDirIdle') }}</el-tag>
</div>
<div class="status-actions">
<el-button v-if="statusAlert?.action === 'retry-gateway'" type="primary" size="small" :loading="identityGatewayLoading" @click="syncIdentityGateway">{{ $t('identity.retryGateway') }}</el-button>
<el-button v-else-if="statusAlert?.action === 'gateway' || statusAlert?.action === 'https'" type="primary" size="small" @click="goPublicEntry">{{ $t('identity.goPublicEntry') }}</el-button>
</div>
</div>
<el-alert type="error" :closable="false" :title="$t('feishuSync.publishAll')" show-icon style="margin-bottom:16px" />
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
<el-alert v-if="statusAlert" :type="statusAlert.type" :closable="false" :title="statusAlert.title" style="margin-top:12px" />
</el-card>
<!-- 2. 凭据卡 -->
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header>
<div class="card-head">
<b>{{ $t('identity.credentialsTitle') }}</b>
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
</div>
</template>
<el-form label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<el-row :gutter="12">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('identity.tenant')">
<div class="tenant-row">
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
</div>
<span v-if="tenantName" class="tls-hint">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</el-form-item>
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:12px" />
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" /></el-form-item>
<div class="identity-checks" aria-live="polite">
<div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div>
<div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
<div><span>{{ $t('identity.basicProfilePermission') }}</span><el-tag :type="basicProfileVerified ? 'success' : 'info'" size="small">{{ basicProfileVerified ? $t('identity.checked') : $t('identity.checkNamePending') }}</el-tag></div>
</div>
<p class="tls-hint">{{ $t('identity.permissionSteps') }} <a v-if="feishuPermissionUrl" :href="feishuPermissionUrl" target="_blank" rel="noopener noreferrer">{{ $t('identity.openFeishuPermission') }}</a></p>
<el-form-item :label="$t('identity.tenant')">
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</el-form-item>
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:16px" />
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" /></el-form-item>
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
</el-form>
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
</el-card>
<!-- 3. 地址与同步 -->
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('identity.addresses') }}</b></template>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
<template #header><b>{{ $t('identity.addressSyncTitle') }}</b></template>
<el-descriptions :column="1" border size="small" class="addr-desc">
<el-descriptions-item :label="$t('identity.issuer')">
<div class="copy-row"><span class="mono">{{ identityStatus?.issuer || '—' }}</span><el-button link type="primary" :disabled="!identityStatus?.issuer" @click="copyField(identityStatus?.issuer)">{{ $t('common.copy') }}</el-button></div>
</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">
<div class="copy-row"><span class="mono">{{ identityStatus?.feishu_callback || '—' }}</span><el-button link type="primary" :disabled="!identityStatus?.feishu_callback" @click="copyField(identityStatus?.feishu_callback)">{{ $t('common.copy') }}</el-button></div>
</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">
<div class="copy-row"><span class="mono">{{ identityStatus?.vpn_callback || '—' }}</span><el-button link type="primary" :disabled="!identityStatus?.vpn_callback" @click="copyField(identityStatus?.vpn_callback)">{{ $t('common.copy') }}</el-button></div>
</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">
<div class="copy-row"><span class="mono">{{ identityStatus?.client_id || '—' }}</span><el-button link type="primary" :disabled="!identityStatus?.client_id" @click="copyField(identityStatus?.client_id)">{{ $t('common.copy') }}</el-button></div>
</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
<el-button :loading="identityGatewayLoading" :disabled="!identityStatus?.enabled" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
</el-card>
<el-card v-if="identityStatus?.enabled" class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('feishuDirectory.title') }}</b></template>
<p class="tls-hint">{{ $t('feishuDirectory.hint') }}</p>
<div class="directory-check">
<el-select v-model="selectedProfile" filterable :placeholder="$t('feishuDirectory.choose')" :loading="profilesLoading" style="flex:1">
<el-option v-for="profile in identityProfiles" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
</el-select>
<el-button :disabled="!selectedProfile" :loading="directoryLoading" @click="checkDirectory">{{ $t('feishuDirectory.check') }}</el-button>
</div>
<el-alert v-if="directoryResult" :title="directoryResult" :type="directoryAvailable ? 'success' : 'warning'" :closable="false" style="margin-top:12px" />
</el-card>
<el-card v-if="identityStatus?.enabled" class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('feishuSync.title') }}</b></template>
<p class="tls-hint">{{ $t('feishuSync.hint') }}</p>
<p class="tls-hint">{{ $t('feishuSync.permissions') }}</p>
<div class="directory-check" style="align-items:center;margin:12px 0">
<b>{{ $t('feishuSync.liveCheck') }}</b>
<el-button :loading="scopeChecking" size="small" @click="checkScope">{{ $t('feishuSync.recheck') }}</el-button>
</div>
<el-alert v-if="scopeError" type="error" :closable="false" :title="scopeError" style="margin-bottom:12px" />
<el-alert v-else-if="scopeChecking" type="info" :closable="false" :title="$t('feishuSync.checking')" style="margin-bottom:12px" />
<template v-else-if="scopeResult">
<el-alert :type="scopeResult.visible_departments ? 'info' : 'error'" :closable="false"
:title="$t('feishuSync.liveVisibility', { departments: scopeResult.visible_departments, users: scopeResult.visible_users })" style="margin-bottom:12px" />
<el-alert v-if="!scopeResult.visible_departments" type="error" :closable="false" :title="$t('feishuSync.scopeMissing')" style="margin-bottom:12px" />
<el-alert v-else-if="scopeResult.department_access !== 'ok' || scopeResult.user_list_access !== 'ok'" type="error" :closable="false"
:title="$t('feishuSync.permissionMissing', { department: scopeResult.department_error || $t('feishuSync.ok'), users: scopeResult.user_list_error || $t('feishuSync.ok') })" style="margin-bottom:12px" />
<el-alert v-else type="success" :closable="false" :title="$t('feishuSync.apiReady')" style="margin-bottom:12px" />
<template v-if="identityStatus?.enabled">
<el-divider />
<div class="sync-block">
<div class="card-head" style="margin-bottom:8px">
<b>{{ $t('feishuSync.title') }}</b>
<div style="display:flex;gap:8px">
<el-button size="small" :loading="scopeChecking" @click="checkScope">{{ $t('feishuSync.recheck') }}</el-button>
<el-button type="primary" size="small" :loading="syncRunning" @click="runSync">{{ $t('feishuSync.run') }}</el-button>
</div>
</div>
<p class="tls-hint">{{ $t('feishuSync.hint') }}</p>
<el-alert v-if="scopeError" type="error" :closable="false" :title="scopeError" style="margin:8px 0" />
<el-alert v-else-if="scopeResult && !scopeResult.visible_departments" type="error" :closable="false" :title="$t('feishuSync.scopeMissing')" style="margin:8px 0" />
<el-alert v-else-if="scopeResult" type="success" :closable="false" :title="$t('feishuSync.apiReady')" style="margin:8px 0" />
<div class="directory-check" style="margin:12px 0">
<el-switch :model-value="syncStatus.enabled || false" :loading="syncSaving" :active-text="$t('feishuSync.auto')" @change="setSyncEnabled" />
<el-button :loading="syncLoading" @click="loadSync">{{ $t('tls.refresh') }}</el-button>
</div>
<p class="tls-hint">{{ $t('feishuSync.lastSuccess') }}:{{ syncStatus.last_success ? new Date(syncStatus.last_success).toLocaleString() : '—' }} · {{ $t('feishuSync.counts', { departments: syncStatus.departments || 0, members: syncStatus.members || 0 }) }}</p>
<el-alert v-if="syncStatus.last_error || syncError" type="warning" :closable="false" :title="syncError || syncStatus.last_error" style="margin-bottom:12px" />
<div class="directory-check" style="margin-bottom:12px">
<el-select v-model="selectedProfile" filterable :placeholder="$t('feishuDirectory.choose')" :loading="profilesLoading" style="flex:1">
<el-option v-for="profile in identityProfiles" :key="profile.subject" :label="`${profile.name || profile.subject} · ${profile.subject}`" :value="profile.subject" />
</el-select>
<el-button :disabled="!selectedProfile" :loading="directoryLoading" @click="checkDirectory">{{ $t('feishuDirectory.check') }}</el-button>
</div>
<el-alert v-if="directoryResult" :title="directoryResult" :type="directoryAvailable ? 'success' : 'warning'" :closable="false" style="margin-bottom:12px" />
<el-table :data="syncMembers" size="small" max-height="280" v-loading="syncLoading">
<el-table-column prop="name" :label="$t('feishuSync.name')" min-width="130" />
<el-table-column prop="open_id" label="open_id" min-width="220" />
<el-table-column :label="$t('feishuSync.departments')" min-width="160">
<template #default="{ row }">{{ memberDepartments(row.department_ids) }}</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
</div>
</template>
<div class="directory-check">
<el-switch :model-value="syncStatus.enabled || false" :loading="syncSaving" :active-text="$t('feishuSync.auto')" @change="setSyncEnabled" />
<el-button :loading="syncRunning" @click="runSync">{{ $t('feishuSync.run') }}</el-button>
<el-button :loading="syncLoading" @click="loadSync">{{ $t('tls.refresh') }}</el-button>
</div>
<p class="tls-hint">{{ $t('feishuSync.lastSuccess') }}:{{ syncStatus.last_success ? new Date(syncStatus.last_success).toLocaleString() : '—' }} · {{ $t('feishuSync.counts', { departments: syncStatus.departments || 0, members: syncStatus.members || 0 }) }}</p>
<p v-if="syncStatus.last_success" class="tls-hint">{{ $t('feishuSync.visibility', { departments: syncStatus.visible_departments || 0, users: syncStatus.visible_users || 0 }) }}</p>
<el-alert v-if="syncStatus.last_success && !syncStatus.departments && !scopeResult && !scopeChecking" type="warning" :closable="false" :title="$t('feishuSync.noDepartments')" style="margin-bottom:12px" />
<el-alert v-if="hasUpstreamNumberName" type="warning" :closable="false" :title="$t('feishuSync.numberName')" style="margin-bottom:12px" />
<el-alert v-if="syncStatus.last_error || syncError" type="warning" :closable="false" :title="syncError || syncStatus.last_error" style="margin-bottom:12px" />
<el-tree v-if="syncDepartments.length" :data="syncDepartmentTree" node-key="id" :props="{ label: 'name', children: 'children' }" style="margin:12px 0;max-height:260px;overflow:auto" />
<el-table :data="syncMembers" size="small" max-height="340" v-loading="syncLoading">
<el-table-column prop="name" :label="$t('feishuSync.name')" min-width="130" />
<el-table-column prop="open_id" label="open_id" min-width="260" />
<el-table-column :label="$t('feishuSync.departments')" min-width="170">
<template #default="{ row }">{{ memberDepartments(row.department_ids) }}</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<p v-if="syncStatus.members > syncMembers.length" class="tls-hint">{{ $t('feishuSync.firstMembers') }}</p>
</el-card>
</div>
</template>
@@ -296,20 +312,37 @@ async function syncIdentityGateway() {
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
finally { identityGatewayLoading.value = false }
}
const statusAlert = computed(() => {
if (!identitySecure) return { type: 'warning', title: t('identity.httpsOnly'), action: 'https' }
if (!identityStatus.value?.tls_ready) return { type: 'warning', title: t('identity.httpsRequired'), action: 'gateway' }
if (identityStatus.value?.enabled && !identityStatus.value?.gateway_ready) return { type: 'warning', title: t('identity.gatewayPending'), action: 'retry-gateway' }
return null
})
function goPublicEntry() { window.location.assign('/settings?tab=gateway') }
async function copyField(text) {
if (!text) return
try { await navigator.clipboard.writeText(text); ElMessage.success(t('common.copied')) }
catch { ElMessage.error(t('common.copyFailed')) }
}
onMounted(() => { if (!auth.mustChange) loadIdentity() })
defineExpose({ reload: loadIdentity })
</script>
<style scoped>
.settings-column { max-width: 850px; }
.settings-column { max-width: 920px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.identity-guide { padding: 16px 18px; margin-bottom: 16px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
.identity-guide code { display: inline-block; padding: 1px 5px; overflow-wrap: anywhere; color: var(--el-color-primary); background: var(--el-fill-color-light); border-radius: 4px; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.status-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.status-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 12px; }
.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
.directory-check { display: flex; gap: 8px; }
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--el-color-primary); font-size: 13px; }
.tenant-row { display: flex; gap: 8px; width: 100%; }
.tenant-row .el-input { flex: 1; }
.directory-check { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.copy-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
</style>
@@ -1,7 +1,7 @@
<template>
<div>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="480px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
@@ -43,6 +43,7 @@
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button size="small" @click="$router.push(`/tunnels/${detail.tunnel.id}/edit`)">{{ $t('common.edit') }}</el-button>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>