初始提交:OCI 面板前端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:29 +08:00
commit db45a669e3
135 changed files with 25220 additions and 0 deletions
+123
View File
@@ -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/&lt;github|oidc&gt;/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>