初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { getAbout } from '@/api/settings'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import { useParticles } from '@/composables/useParticles'
|
||||
import type { AboutInfo } from '@/types/api'
|
||||
|
||||
// GitHub 仓库地址:开源发布后填充;空串时按钮禁用并显示占位
|
||||
const REPO_URL = ''
|
||||
|
||||
const info = ref<AboutInfo | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const pcanvas = ref<HTMLCanvasElement | null>(null)
|
||||
useParticles(pcanvas, { count: 26 })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
info.value = await getAbout()
|
||||
} catch {
|
||||
/* 构建信息加载失败不阻塞页面,chips 显示占位 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function fmtBuild(t: string) {
|
||||
return t ? t.replace('T', ' ').replace('Z', ' UTC') : '—'
|
||||
}
|
||||
|
||||
function openRepo() {
|
||||
if (REPO_URL) window.open(REPO_URL, '_blank', 'noopener')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex max-w-[860px] flex-col gap-4">
|
||||
<!-- Hero:印章 + 名称 + 版本 chips,粒子与轨道环装饰 -->
|
||||
<div class="panel relative overflow-hidden px-6 py-12 text-center">
|
||||
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<div class="glow" style="width: 420px; height: 420px; left: 50%; top: -180px; transform: translateX(-50%)"></div>
|
||||
<div class="ring-dash spin" style="width: 380px; height: 380px; left: calc(50% - 190px); top: -60px"></div>
|
||||
<canvas ref="pcanvas" class="absolute inset-0 h-full w-full"></canvas>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="floaty mx-auto block w-fit"><AppLogo :size="84" /></span>
|
||||
<div class="mt-5 font-serif text-[34px] leading-tight font-semibold">OCI Portal</div>
|
||||
<div class="mt-1 text-[13px] text-ink-3">OCI 账号批量管理面板 · 自托管</div>
|
||||
<div v-if="loading" class="mt-6 flex justify-center"><NSpin size="small" /></div>
|
||||
<div v-else class="mt-6 flex flex-wrap justify-center gap-2">
|
||||
<span class="chip mono">{{ info?.version || 'dev' }}</span>
|
||||
<span class="chip mono">构建 {{ fmtBuild(info?.buildTime ?? '') }}</span>
|
||||
<span class="chip mono">{{ info?.platform || '—' }} · {{ info?.goVersion || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub 仓库 -->
|
||||
<div class="panel flex items-center gap-3.5 px-5 py-4">
|
||||
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
|
||||
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[13px] font-semibold">GitHub 仓库</span>
|
||||
<span class="mono mt-0.5 block truncate text-xs text-ink-3">{{ REPO_URL || 'github.com/…(待填充)' }}</span>
|
||||
</span>
|
||||
<NButton size="small" :disabled="!REPO_URL" @click="openRepo">前往仓库 ↗</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 999px;
|
||||
background: var(--color-card);
|
||||
padding: 6px 13px;
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-2);
|
||||
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.06);
|
||||
}
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(201, 100, 66, 0.13);
|
||||
filter: blur(80px);
|
||||
}
|
||||
.ring-dash {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.spin {
|
||||
animation: about-sp 52s linear infinite;
|
||||
}
|
||||
.floaty {
|
||||
animation: about-fl 7s ease-in-out infinite;
|
||||
}
|
||||
@keyframes about-sp {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes about-fl {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch, useMessage } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
activateTotp,
|
||||
disableTotp,
|
||||
getCredentials,
|
||||
getOAuthSettings,
|
||||
getOauthAuthorizeUrl,
|
||||
getTotpStatus,
|
||||
listIdentities,
|
||||
setupTotp,
|
||||
unbindIdentity,
|
||||
updateCredentials,
|
||||
updateOAuthSettings,
|
||||
updatePasswordLogin,
|
||||
} from '@/api/auth'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import type { TotpSetup, UpdateOAuthRequest } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
|
||||
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
|
||||
|
||||
/** provider 的展示名:配置显示名 → 默认名 → 原串 */
|
||||
function displayNameOf(p: string): string {
|
||||
const cfg = oauthCfg.data.value
|
||||
const custom = p === 'oidc' ? cfg?.oidcDisplayName : cfg?.githubDisplayName
|
||||
return custom || providerLabels[p] || p
|
||||
}
|
||||
|
||||
// ---- 两步验证(TOTP) ----
|
||||
const totp = useAsync(getTotpStatus)
|
||||
const showTotpSetup = ref(false)
|
||||
const showTotpDisable = ref(false)
|
||||
const totpPending = ref<TotpSetup | null>(null)
|
||||
const totpActivateCode = ref('')
|
||||
const totpBusy = ref(false)
|
||||
const totpDisableInput = ref('')
|
||||
|
||||
async function startTotpSetup() {
|
||||
totpBusy.value = true
|
||||
try {
|
||||
totpPending.value = await setupTotp()
|
||||
totpActivateCode.value = ''
|
||||
showTotpSetup.value = true
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发起失败')
|
||||
} finally {
|
||||
totpBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotpActivate() {
|
||||
if (totpActivateCode.value.length !== 6) {
|
||||
message.error('请输入 6 位验证码')
|
||||
return
|
||||
}
|
||||
totpBusy.value = true
|
||||
try {
|
||||
await activateTotp(totpActivateCode.value)
|
||||
message.success('两步验证已启用,下次登录需输入动态验证码')
|
||||
showTotpSetup.value = false
|
||||
totpPending.value = null
|
||||
void totp.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '激活失败')
|
||||
} finally {
|
||||
totpBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotpDisable() {
|
||||
const input = totpDisableInput.value.trim()
|
||||
totpBusy.value = true
|
||||
try {
|
||||
// 6 位纯数字按验证码提交,其余按密码
|
||||
await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input })
|
||||
message.success('两步验证已停用')
|
||||
totpDisableInput.value = ''
|
||||
showTotpDisable.value = false
|
||||
void totp.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '停用失败')
|
||||
} finally {
|
||||
totpBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTotpSecret() {
|
||||
if (!totpPending.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(totpPending.value.secret)
|
||||
message.success('已复制')
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外部身份绑定 ----
|
||||
const identities = useAsync(listIdentities)
|
||||
const oauthCfg = useAsync(getOAuthSettings)
|
||||
const binding = ref('')
|
||||
|
||||
const hasIdentity = computed(() => (identities.data.value?.items ?? []).length > 0)
|
||||
|
||||
// ---- 登录凭据:用户名 / 密码修改与密码登录开关 ----
|
||||
const creds = useAsync(getCredentials)
|
||||
const showCredModal = ref(false)
|
||||
const savingCred = ref(false)
|
||||
const togglingPwLogin = ref(false)
|
||||
const credForm = reactive({ newUsername: '', newPassword: '', confirm: '', currentPassword: '' })
|
||||
|
||||
function openCredModal() {
|
||||
Object.assign(credForm, {
|
||||
newUsername: creds.data.value?.username ?? '',
|
||||
newPassword: '',
|
||||
confirm: '',
|
||||
currentPassword: '',
|
||||
})
|
||||
showCredModal.value = true
|
||||
}
|
||||
|
||||
/** 是否有实际变更(改名或改密),提交按钮据此启用 */
|
||||
const credChanges = computed(() => ({
|
||||
nameChanged:
|
||||
!!credForm.newUsername.trim() && credForm.newUsername.trim() !== creds.data.value?.username,
|
||||
pwChanged: !!credForm.newPassword,
|
||||
}))
|
||||
|
||||
const credValid = computed(() => {
|
||||
const { nameChanged, pwChanged } = credChanges.value
|
||||
if (!credForm.currentPassword || (!nameChanged && !pwChanged)) return false
|
||||
if (pwChanged && (credForm.newPassword.length < 8 || credForm.newPassword !== credForm.confirm))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
async function saveCredentials() {
|
||||
const { nameChanged, pwChanged } = credChanges.value
|
||||
savingCred.value = true
|
||||
try {
|
||||
await updateCredentials({
|
||||
...(nameChanged ? { newUsername: credForm.newUsername.trim() } : {}),
|
||||
...(pwChanged ? { newPassword: credForm.newPassword } : {}),
|
||||
currentPassword: credForm.currentPassword,
|
||||
})
|
||||
message.success('登录凭据已更新,请使用新凭据重新登录')
|
||||
showCredModal.value = false
|
||||
auth.logout()
|
||||
void router.push('/login')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingCred.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */
|
||||
async function togglePasswordLogin(disabled: boolean) {
|
||||
togglingPwLogin.value = true
|
||||
try {
|
||||
await updatePasswordLogin(disabled)
|
||||
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
togglingPwLogin.value = false
|
||||
void creds.run({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** 已配置 clientID 的 provider 才显示绑定入口 */
|
||||
const bindableProviders = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const out: string[] = []
|
||||
if (cfg.oidcClientId) out.push('oidc')
|
||||
if (cfg.githubClientId) out.push('github')
|
||||
return out
|
||||
})
|
||||
|
||||
async function bindProvider(provider: string) {
|
||||
binding.value = provider
|
||||
try {
|
||||
const { url } = await getOauthAuthorizeUrl(provider, 'bind')
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发起绑定失败')
|
||||
binding.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeIdentity(id: number) {
|
||||
try {
|
||||
await unbindIdentity(id)
|
||||
message.success('已解绑;该身份后续无法登录面板')
|
||||
void identities.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
|
||||
type ProviderType = 'github' | 'oidc'
|
||||
|
||||
const PROVIDER_TYPES: { label: string; value: ProviderType }[] = [
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'OIDC(自动 discovery)', value: 'oidc' },
|
||||
]
|
||||
|
||||
const savingOauth = ref(false)
|
||||
const showProviderModal = ref(false)
|
||||
/** 编辑中的 provider;null 表示新增(类型可选) */
|
||||
const editingProvider = ref<ProviderType | null>(null)
|
||||
const pForm = reactive({
|
||||
provider: 'github' as ProviderType,
|
||||
displayName: '',
|
||||
issuer: '',
|
||||
clientId: '',
|
||||
secret: '',
|
||||
})
|
||||
|
||||
/** 已配置的登录方式表格行 */
|
||||
const providerRows = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const rows: { provider: ProviderType; name: string; clientId: string; disabled: boolean }[] = []
|
||||
if (cfg.githubClientId)
|
||||
rows.push({
|
||||
provider: 'github',
|
||||
name: displayNameOf('github'),
|
||||
clientId: cfg.githubClientId,
|
||||
disabled: cfg.githubDisabled,
|
||||
})
|
||||
if (cfg.oidcClientId)
|
||||
rows.push({
|
||||
provider: 'oidc',
|
||||
name: displayNameOf('oidc'),
|
||||
clientId: cfg.oidcClientId,
|
||||
disabled: cfg.oidcDisabled,
|
||||
})
|
||||
return rows
|
||||
})
|
||||
|
||||
const callbackBase = window.location.origin
|
||||
|
||||
function openCreateProvider() {
|
||||
editingProvider.value = null
|
||||
Object.assign(pForm, { provider: 'github', displayName: '', issuer: '', clientId: '', secret: '' })
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
function openEditProvider(p: ProviderType) {
|
||||
const cfg = oauthCfg.data.value
|
||||
editingProvider.value = p
|
||||
Object.assign(pForm, {
|
||||
provider: p,
|
||||
displayName: p === 'oidc' ? (cfg?.oidcDisplayName ?? '') : (cfg?.githubDisplayName ?? ''),
|
||||
issuer: p === 'oidc' ? (cfg?.oidcIssuer ?? '') : '',
|
||||
clientId: p === 'oidc' ? (cfg?.oidcClientId ?? '') : (cfg?.githubClientId ?? ''),
|
||||
secret: '',
|
||||
})
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
const pFormValid = computed(
|
||||
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
|
||||
)
|
||||
|
||||
const secretConfigured = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
|
||||
})
|
||||
|
||||
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
|
||||
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest {
|
||||
const cfg = oauthCfg.data.value
|
||||
return {
|
||||
oidcIssuer: cfg?.oidcIssuer ?? '',
|
||||
oidcClientId: cfg?.oidcClientId ?? '',
|
||||
oidcDisplayName: cfg?.oidcDisplayName ?? '',
|
||||
oidcDisabled: cfg?.oidcDisabled ?? false,
|
||||
githubClientId: cfg?.githubClientId ?? '',
|
||||
githubDisplayName: cfg?.githubDisplayName ?? '',
|
||||
githubDisabled: cfg?.githubDisabled ?? false,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
|
||||
function providerFormPatch(): Partial<UpdateOAuthRequest> {
|
||||
const name = pForm.displayName.trim()
|
||||
const id = pForm.clientId.trim()
|
||||
if (pForm.provider === 'oidc')
|
||||
return {
|
||||
oidcIssuer: pForm.issuer.trim(),
|
||||
oidcClientId: id,
|
||||
oidcDisplayName: name,
|
||||
...(pForm.secret ? { oidcClientSecret: pForm.secret } : {}),
|
||||
}
|
||||
return {
|
||||
githubClientId: id,
|
||||
githubDisplayName: name,
|
||||
...(pForm.secret ? { githubClientSecret: pForm.secret } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
savingOauth.value = true
|
||||
try {
|
||||
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
|
||||
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
|
||||
showProviderModal.value = false
|
||||
void oauthCfg.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
savingOauth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 禁用/启用:仅控制登录页展示与 login 授权,绑定入口与已绑身份不受影响 */
|
||||
async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
|
||||
savingOauth.value = true
|
||||
try {
|
||||
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
|
||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
||||
message.success(`「${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
|
||||
void oauthCfg.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
savingOauth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除 = 清空该 provider 全部配置(含 secret 与显示名称),不可恢复 */
|
||||
async function deleteProvider(p: ProviderType) {
|
||||
savingOauth.value = true
|
||||
const name = displayNameOf(p)
|
||||
try {
|
||||
const patch: Partial<UpdateOAuthRequest> =
|
||||
p === 'oidc'
|
||||
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
|
||||
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
|
||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
||||
message.success(`登录方式「${name}」已删除`)
|
||||
void oauthCfg.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
savingOauth.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 账号安全:两步验证 + 外部身份 -->
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">账号安全</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">两步验证与外部身份</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="totp.data.value?.enabled ? 'bg-ok' : 'bg-ink-3/50'"
|
||||
/>
|
||||
两步验证{{ totp.data.value?.enabled ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4.5 py-1">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password 等
|
||||
</div>
|
||||
</div>
|
||||
<NSpin v-if="totp.loading.value" size="small" />
|
||||
<NButton
|
||||
v-else-if="totp.data.value?.enabled"
|
||||
size="tiny"
|
||||
type="error"
|
||||
quaternary
|
||||
@click="showTotpDisable = true"
|
||||
>
|
||||
停用
|
||||
</NButton>
|
||||
<NButton v-else size="tiny" type="primary" :loading="totpBusy" @click="startTotpSetup">
|
||||
启用
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">登录凭据</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">禁用密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{{
|
||||
hasIdentity || creds.data.value?.passwordLoginDisabled
|
||||
? '开启后仅外部身份可登录;关闭随时恢复密码登录'
|
||||
: '至少绑定一个外部身份(OAuth2)后才能开启'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<NSwitch
|
||||
:value="creds.data.value?.passwordLoginDisabled ?? false"
|
||||
size="small"
|
||||
:loading="togglingPwLogin"
|
||||
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled"
|
||||
@update:value="togglePasswordLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="it in identities.data.value?.items ?? []"
|
||||
:key="it.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">
|
||||
{{ displayNameOf(it.provider) }} · {{ it.display }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
|
||||
</div>
|
||||
<NPopconfirm @positive-click="removeIdentity(it.id)">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>解绑</NButton>
|
||||
</template>
|
||||
解绑后该身份无法再登录面板,确认?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="bindableProviders.length"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<NButton
|
||||
v-for="p in bindableProviders"
|
||||
:key="p"
|
||||
size="small"
|
||||
:loading="binding === p"
|
||||
@click="bindProvider(p)"
|
||||
>
|
||||
绑定 {{ displayNameOf(p) }}
|
||||
</NButton>
|
||||
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
|
||||
</div>
|
||||
<div class="py-2.5 text-xs text-ink-3">
|
||||
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式配置:表格化管理,可新增 / 编辑 / 禁用 / 删除 -->
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">登录方式配置</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">配置后登录页与上方绑定入口即出现对应方式</div>
|
||||
</div>
|
||||
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
|
||||
</div>
|
||||
<div v-if="providerRows.length" class="overflow-x-auto">
|
||||
<table class="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="border-b border-line-soft px-4.5 py-2 font-medium">名称</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">Client ID</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">状态</th>
|
||||
<th class="w-40 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in providerRows" :key="row.provider" class="[&:last-child>td]:border-b-0">
|
||||
<td class="border-b border-line-soft px-4.5 py-2.5 font-medium">{{ row.name }}</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span class="rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2">
|
||||
{{ row.provider }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span class="mono block max-w-40 truncate text-ink-2">{{ row.clientId }}</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
|
||||
{{ row.disabled ? '已禁用' : '启用中' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap">
|
||||
<NButton size="tiny" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="toggleProviderDisabled(row.provider, !row.disabled)"
|
||||
>
|
||||
{{ row.disabled ? '启用' : '禁用' }}
|
||||
</NButton>
|
||||
<NPopconfirm @positive-click="deleteProvider(row.provider)">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>删除</NButton>
|
||||
</template>
|
||||
删除将清空该方式全部配置(含 Secret),不可恢复;确认?
|
||||
</NPopconfirm>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="px-4.5 py-6 text-center text-xs text-ink-3">
|
||||
尚未配置任何登录方式;点击右上「新增登录方式」配置 GitHub 或 OIDC
|
||||
</div>
|
||||
<FootNote>
|
||||
在 IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
|
||||
}}/api/v1/auth/oauth/<github|oidc>/callback;secret 加密存储不回显 ·
|
||||
需先在「网络与地址」保存面板地址
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑登录方式弹窗 -->
|
||||
<FormModal
|
||||
:show="showProviderModal"
|
||||
:title="editingProvider ? '编辑登录方式' : '新增登录方式'"
|
||||
:width="460"
|
||||
:submitting="savingOauth"
|
||||
:submit-disabled="!pFormValid"
|
||||
submit-text="保存"
|
||||
@update:show="showProviderModal = $event"
|
||||
@submit="saveProvider"
|
||||
>
|
||||
<FormField label="类型" required>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="t in PROVIDER_TYPES"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1.5 text-xs"
|
||||
:class="[
|
||||
pForm.provider === t.value
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2',
|
||||
editingProvider ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-ink-3',
|
||||
]"
|
||||
:disabled="!!editingProvider"
|
||||
@click="pForm.provider = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}」`">
|
||||
<NInput
|
||||
v-model:value="pForm.displayName"
|
||||
:maxlength="32"
|
||||
:placeholder="providerLabels[pForm.provider]"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-if="pForm.provider === 'oidc'"
|
||||
label="Issuer"
|
||||
required
|
||||
hint="IdP 的 issuer 地址,自动 discovery(如 https://accounts.google.com)"
|
||||
>
|
||||
<NInput v-model:value="pForm.issuer" placeholder="https://idp.example.com/realms/main" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Client ID"
|
||||
required
|
||||
:hint="pForm.provider === 'github' ? 'GitHub → Settings → Developer settings → OAuth Apps' : undefined"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="pForm.clientId"
|
||||
class="mono"
|
||||
:placeholder="pForm.provider === 'github' ? 'Iv1.xxxxxxxx' : 'oci-portal'"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Client Secret"
|
||||
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
|
||||
>
|
||||
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
|
||||
</FormField>
|
||||
<div class="rounded-md bg-wash px-3 py-2 text-xs leading-relaxed break-all text-ink-3">
|
||||
在平台侧填写回调地址:
|
||||
<span class="mono">{{ callbackBase }}/api/v1/auth/oauth/{{ pForm.provider }}/callback</span>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<!-- 修改登录凭据弹窗 -->
|
||||
<FormModal
|
||||
:show="showCredModal"
|
||||
title="修改登录凭据"
|
||||
:width="420"
|
||||
:submitting="savingCred"
|
||||
:submit-disabled="!credValid"
|
||||
submit-text="保存并重新登录"
|
||||
@update:show="showCredModal = $event"
|
||||
@submit="saveCredentials"
|
||||
>
|
||||
<FormField label="用户名" hint="最长 64 字符;保持原值表示不修改">
|
||||
<NInput v-model:value="credForm.newUsername" :maxlength="64" />
|
||||
</FormField>
|
||||
<FormField label="新密码" hint="8-72 字符;留空表示不修改密码">
|
||||
<NInput
|
||||
v-model:value="credForm.newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="留空不修改"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-if="credForm.newPassword"
|
||||
label="确认新密码"
|
||||
:feedback="
|
||||
credForm.confirm && credForm.confirm !== credForm.newPassword ? '两次输入不一致' : undefined
|
||||
"
|
||||
>
|
||||
<NInput v-model:value="credForm.confirm" type="password" show-password-on="click" />
|
||||
</FormField>
|
||||
<FormField label="当前密码" required hint="身份确认;保存成功后会退出登录">
|
||||
<NInput
|
||||
v-model:value="credForm.currentPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="输入当前登录密码"
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<!-- 停用两步验证弹窗 -->
|
||||
<FormModal
|
||||
:show="showTotpDisable"
|
||||
title="停用两步验证"
|
||||
:width="380"
|
||||
danger
|
||||
submit-text="停用"
|
||||
:submitting="totpBusy"
|
||||
:submit-disabled="!totpDisableInput.trim()"
|
||||
@update:show="showTotpDisable = $event"
|
||||
@submit="confirmTotpDisable"
|
||||
>
|
||||
<FormField label="身份确认" hint="输入验证器当前 6 位验证码,或输入登录密码">
|
||||
<NInput
|
||||
v-model:value="totpDisableInput"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="验证码或登录密码"
|
||||
@keyup.enter="confirmTotpDisable"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="text-xs text-ink-3">停用后登录不再要求动态验证码</div>
|
||||
</FormModal>
|
||||
|
||||
<!-- TOTP 启用引导弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showTotpSetup"
|
||||
preset="card"
|
||||
title="启用两步验证"
|
||||
closable
|
||||
:style="{ width: '360px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
>
|
||||
<div v-if="totpPending" class="flex flex-col items-center gap-3">
|
||||
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
|
||||
<!-- 暗色下反色渲染(浅模块+透明底),不再垫任何色块 -->
|
||||
<NQrCode
|
||||
:value="totpPending.otpauthUri"
|
||||
:size="160"
|
||||
:padding="0"
|
||||
:color="app.dark ? '#f5f4ed' : '#141413'"
|
||||
background-color="transparent"
|
||||
error-correction-level="M"
|
||||
/>
|
||||
<div class="text-center text-xs text-ink-3">
|
||||
用验证器 App 扫码添加;无法扫码时手动输入密钥
|
||||
</div>
|
||||
<div class="flex w-full items-center gap-2 rounded-md bg-wash px-2.5 py-2">
|
||||
<span class="mono min-w-0 flex-1 text-[12px] tracking-wide break-all select-all">
|
||||
{{ totpPending.secret }}
|
||||
</span>
|
||||
<NButton size="tiny" quaternary class="flex-none" @click="copyTotpSecret">复制</NButton>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="totpActivateCode"
|
||||
placeholder="输入 6 位验证码"
|
||||
:maxlength="6"
|
||||
class="totp-code !w-full"
|
||||
@keyup.enter="confirmTotpActivate"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="totpBusy" @click="confirmTotpActivate">
|
||||
验证并启用
|
||||
</NButton>
|
||||
<NButton size="small" quaternary @click="showTotpSetup = false">取消</NButton>
|
||||
</div>
|
||||
<div class="text-xs text-ink-3">密钥 10 分钟内有效,关闭弹窗后需重新发起</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.totp-code :deep(.n-input__input-el) {
|
||||
text-align: center;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal, useMessage } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { testNotifyTemplate, updateNotifyTemplate } from '@/api/settings'
|
||||
import type { NotifyTemplateItem } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
item: NotifyTemplateItem | null
|
||||
/** 事件行的说明文案,展示在弹窗副标题 */
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
/** 保存成功后携带最新自定义模板(空串=已恢复默认) */
|
||||
saved: [kind: string, template: string]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const draft = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const inputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show && props.item) draft.value = props.item.template || props.item.defaultTemplate
|
||||
},
|
||||
)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (v: boolean) => emit('update:show', v),
|
||||
})
|
||||
|
||||
/** 变量 chips:事件专属变量 + 公共变量 time */
|
||||
const varChips = computed(() => [...(props.item?.vars ?? []), 'time'])
|
||||
|
||||
/** 包装为占位符文本;直接写在模板插值里会被 Vue 编译器误判提前闭合 */
|
||||
function varToken(name: string) {
|
||||
return '{{' + name + '}}'
|
||||
}
|
||||
|
||||
/** 在光标处插入变量占位符;拿不到底层 textarea 时退化为追加 */
|
||||
function insertVar(name: string) {
|
||||
const token = `{{${name}}}`
|
||||
const el = inputRef.value?.textareaElRef
|
||||
if (!el) {
|
||||
draft.value += token
|
||||
return
|
||||
}
|
||||
const start = el.selectionStart ?? draft.value.length
|
||||
const end = el.selectionEnd ?? start
|
||||
draft.value = draft.value.slice(0, start) + token + draft.value.slice(end)
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.selectionStart = el.selectionEnd = start + token.length
|
||||
})
|
||||
}
|
||||
|
||||
/** 与默认模板一致或留空 → 按空串保存(恢复默认) */
|
||||
function normalized() {
|
||||
const text = draft.value.trim()
|
||||
if (!props.item || text === props.item.defaultTemplate.trim()) return ''
|
||||
return text
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.item) return
|
||||
saving.value = true
|
||||
try {
|
||||
const tpl = normalized()
|
||||
await updateNotifyTemplate(props.item.kind, tpl)
|
||||
message.success(tpl ? '模板已保存' : '已恢复默认模板')
|
||||
emit('saved', props.item.kind, tpl)
|
||||
visible.value = false
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
if (!props.item) return
|
||||
testing.value = true
|
||||
try {
|
||||
await testNotifyTemplate(props.item.kind, draft.value.trim())
|
||||
message.success('测试消息已发送,请在 Telegram 查收')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测试发送失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
closable
|
||||
:style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-[11px] tracking-wider text-ink-3 uppercase">
|
||||
通知模板 · {{ item?.label }}
|
||||
</div>
|
||||
<div class="text-[17px] font-semibold">编辑推送模板</div>
|
||||
<div class="text-[12.5px] font-normal text-ink-2">
|
||||
{{ hint ? hint + ' · ' : '' }}Markdown 语法(加粗 / 斜体 / 代码块)按 Telegram 语义渲染
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="item" class="flex flex-col gap-3.5">
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-baseline gap-2 text-[12.5px] font-medium text-ink-2">
|
||||
模板内容
|
||||
<span class="text-[11px] font-normal text-ink-3">留空保存 = 恢复默认模板</span>
|
||||
</div>
|
||||
<NInput
|
||||
ref="inputRef"
|
||||
v-model:value="draft"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
class="mono"
|
||||
:placeholder="item.defaultTemplate"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-baseline gap-2 text-[12.5px] font-medium text-ink-2">
|
||||
可用变量
|
||||
<span class="text-[11px] font-normal text-ink-3">点击插入光标处</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="v in varChips"
|
||||
:key="v"
|
||||
type="button"
|
||||
class="mono cursor-pointer rounded-md border border-line bg-white px-2 py-0.5 text-[11.5px] text-ink-2 hover:border-accent hover:text-accent dark:bg-transparent"
|
||||
@click="insertVar(v)"
|
||||
>
|
||||
{{ varToken(v) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11.5px] leading-relaxed text-ink-3">
|
||||
变量在发送时替换为实际值,未知变量原样保留 · 正文超长自动截断
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
|
||||
<div class="flex-1" />
|
||||
<NButton size="small" @click="visible = false">取消</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user