发布 0.1.0:通知渠道、告警规则与多项体验修复
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s

This commit is contained in:
Wang Defa
2026-07-10 17:31:19 +08:00
parent 9d0c116ce3
commit 5464d2dfea
68 changed files with 1604 additions and 359 deletions
+28 -1
View File
@@ -5,8 +5,12 @@ import { onMounted, ref } from 'vue'
import { getAbout } from '@/api/settings'
import AppLogo from '@/components/AppLogo.vue'
import { useParticles } from '@/composables/useParticles'
import { useAppStore } from '@/stores/app'
import type { AboutInfo } from '@/types/api'
// 点击 logo 在星爆 / 波浪封印两款间切换(全局生效并记忆)
const app = useAppStore()
// GitHub 仓库地址;空串时按钮禁用并显示占位
const REPO_URL = 'https://github.com/wangdefaa/oci-portal'
@@ -45,7 +49,15 @@ function openRepo() {
<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>
<button
type="button"
class="floaty logo-btn mx-auto block w-fit"
title="切换 logo 款式"
aria-label="切换 logo 款式"
@click="app.toggleLogoVariant()"
>
<AppLogo :size="84" />
</button>
<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>
@@ -98,6 +110,21 @@ function openRepo() {
border-radius: 50%;
border: 1.5px dashed color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
}
/* logo 即款式开关:去按钮底色,悬停轻微放大提示可点 */
.logo-btn {
cursor: pointer;
border: none;
background: none;
padding: 0;
line-height: 0;
transition: scale 0.25s ease;
}
.logo-btn:hover {
scale: 1.06;
}
.logo-btn:active {
scale: 0.96;
}
@media (prefers-reduced-motion: no-preference) {
.spin {
animation: about-sp 52s linear infinite;
+51 -11
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch, useMessage } from 'naive-ui'
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
activateTotp,
disableTotp,
revokeSessions,
getCredentials,
getOAuthSettings,
getOauthAuthorizeUrl,
@@ -24,12 +25,17 @@ 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'
import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useMessage()
const message = useToast()
const router = useRouter()
const auth = useAuthStore()
const app = useAppStore()
/** 敏感操作响应携带新会话时无感替换;204 降级(undefined)交由 401 流程重登 */
function applySession(r: SessionRefresh) {
if (r?.token) auth.setSession(r.token, r.expiresAt)
}
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
@@ -69,7 +75,7 @@ async function confirmTotpActivate() {
}
totpBusy.value = true
try {
await activateTotp(totpActivateCode.value)
applySession(await activateTotp(totpActivateCode.value))
message.success('两步验证已启用,下次登录需输入动态验证码')
showTotpSetup.value = false
totpPending.value = null
@@ -86,7 +92,7 @@ async function confirmTotpDisable() {
totpBusy.value = true
try {
// 6 位纯数字按验证码提交,其余按密码
await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input })
applySession(await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input }))
message.success('两步验证已停用')
totpDisableInput.value = ''
showTotpDisable.value = false
@@ -151,15 +157,22 @@ async function saveCredentials() {
const { nameChanged, pwChanged } = credChanges.value
savingCred.value = true
try {
await updateCredentials({
const refresh = 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')
if (refresh?.token) {
// 版本递增使其余会话全部失效;当前会话经新 token 无感延续
applySession(refresh)
message.success('登录凭据已更新,其他已登录会话已全部失效')
void creds.run({ silent: true })
} else {
message.success('登录凭据已更新,请使用新凭据重新登录')
auth.logout()
void router.push('/login')
}
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
@@ -171,7 +184,7 @@ async function saveCredentials() {
async function togglePasswordLogin(disabled: boolean) {
togglingPwLogin.value = true
try {
await updatePasswordLogin(disabled)
applySession(await updatePasswordLogin(disabled))
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
@@ -204,7 +217,7 @@ async function bindProvider(provider: string) {
async function removeIdentity(id: number) {
try {
await unbindIdentity(id)
applySession(await unbindIdentity(id))
message.success('已解绑;该身份后续无法登录面板')
void identities.run()
} catch (e) {
@@ -212,6 +225,21 @@ async function removeIdentity(id: number) {
}
}
// ---- 撤销全部会话:令牌版本递增,除本会话(换新)外全部立即失效 ----
const revoking = ref(false)
async function revokeAllSessions() {
revoking.value = true
try {
applySession(await revokeSessions())
message.success('已撤销全部会话;其他设备须重新登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
revoking.value = false
}
}
// ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
type ProviderType = 'github' | 'oidc'
@@ -419,6 +447,18 @@ async function deleteProvider(p: ProviderType) {
<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">
怀疑令牌泄露时使用;其他设备与旧令牌立即失效,本会话自动换新
</div>
</div>
<NButton size="tiny" type="error" quaternary :loading="revoking" @click="revokeAllSessions">
撤销
</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>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { NButton, NCollapseTransition, NInput, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { testNotifyChannel, updateNotifyChannel } from '@/api/settings'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import { useToast } from '@/composables/useToast'
import type { NotifyChannelItem, NotifyChannelType } from '@/types/api'
const props = defineProps<{ item: NotifyChannelItem }>()
const emit = defineEmits<{ saved: [NotifyChannelItem[]] }>()
const message = useToast()
/** 渠道展示元数据:图标字符 + 名称 + 一句话说明 + 密文字段文案 */
const CHANNEL_META: Record<
NotifyChannelType,
{ icon: string; name: string; hint: string; secretLabel?: string; secretHint?: string }
> = {
webhook: {
icon: 'W',
name: 'Webhook',
hint: '通用 HTTP POST,可对接飞书 / 钉钉 / Slack / 企业微信机器人',
},
ntfy: {
icon: 'N',
name: 'ntfy',
hint: '开源推送服务,官方 ntfy.sh 或自建服务端',
secretLabel: 'Access Token',
secretHint: '服务端开启鉴权时填写;留空表示沿用已保存值',
},
bark: {
icon: 'B',
name: 'Bark',
hint: 'iOS 推送,官方 api.day.app 或自建服务端',
secretLabel: 'Device Key',
secretHint: 'Bark App 内的设备 key;留空表示沿用已保存值',
},
smtp: {
icon: 'M',
name: 'SMTP 邮件',
hint: '465 端口走隐式 TLS,其余端口自动 STARTTLS',
secretLabel: '密码',
secretHint: 'SMTP 授权码或密码;留空表示沿用已保存值',
},
}
/** Webhook body 模板预设(占位符 {{title}} / {{text}} 发送时替换并做 JSON 转义) */
const WEBHOOK_PRESETS = [
{ label: 'Slack', tpl: '{"text": "{{title}}\\n{{text}}"}' },
{ label: '飞书', tpl: '{"msg_type": "text", "content": {"text": "{{title}}\\n{{text}}"}}' },
{ label: '钉钉', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
{ label: '企业微信', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
]
const meta = computed(() => CHANNEL_META[props.item.type])
const expanded = ref(false)
const saving = ref(false)
const testing = ref(false)
const form = reactive({
enabled: false,
url: '',
bodyTemplate: '',
server: '',
topic: '',
host: '',
port: 587 as number | null,
username: '',
from: '',
to: '',
secret: '',
})
// 服务端数据到达/保存后回填;密文字段不回明文,输入框留空表示沿用
watch(
() => props.item,
(it) => {
form.enabled = it.enabled
form.url = it.url ?? ''
form.bodyTemplate = it.bodyTemplate ?? ''
form.server = it.server ?? ''
form.topic = it.topic ?? ''
form.host = it.host ?? ''
form.port = it.port || 587
form.username = it.username ?? ''
form.from = it.from ?? ''
form.to = it.to ?? ''
form.secret = ''
},
{ immediate: true },
)
const secretPlaceholder = computed(() =>
props.item.secretSet
? `已配置${props.item.secretTail ? `(尾号 ${props.item.secretTail}` : ''},留空沿用`
: '',
)
async function save() {
saving.value = true
try {
const secret = form.secret.trim()
const items = await updateNotifyChannel(props.item.type, {
enabled: form.enabled,
url: form.url.trim(),
bodyTemplate: form.bodyTemplate,
server: form.server.trim(),
topic: form.topic.trim(),
host: form.host.trim(),
port: form.port ?? 0,
username: form.username.trim(),
from: form.from.trim(),
to: form.to.trim(),
// 留空表示沿用已保存的密文字段
...(secret ? { secret } : {}),
})
message.success(`${meta.value.name} 配置已保存`)
form.secret = ''
emit('saved', items)
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function sendTest() {
testing.value = true
try {
await testNotifyChannel(props.item.type)
message.success(`测试消息已发送,请在 ${meta.value.name} 侧查收`)
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
testing.value = false
}
}
</script>
<template>
<div class="border-b border-line-soft last:border-b-0">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-3 px-4.5 py-3.5 text-left"
@click="expanded = !expanded"
>
<span
class="flex h-8.5 w-8.5 flex-none items-center justify-center rounded-full bg-wash text-[15px] font-bold text-ink-2"
>
{{ meta.icon }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-[13px] font-medium">{{ meta.name }}</span>
<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="item.enabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
{{ item.enabled ? '已启用' : '未启用' }}
</span>
</div>
<div class="mt-0.5 text-xs text-ink-3">{{ meta.hint }}</div>
</div>
<span class="text-xs text-ink-3">{{ expanded ? '收起' : '配置' }}</span>
</button>
<NCollapseTransition :show="expanded">
<div class="px-4.5 pb-3.5">
<div class="mb-3 flex items-center justify-between rounded-lg bg-wash px-3 py-2">
<span class="text-[12.5px]">启用该渠道</span>
<NSwitch v-model:value="form.enabled" size="small" />
</div>
<!-- Webhook -->
<template v-if="item.type === 'webhook'">
<FormField label="Webhook URL" hint="机器人/集成的完整回调地址,含鉴权参数">
<NInput v-model:value="form.url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…" />
</FormField>
<FormField
label="Body 模板"
hint="发送时 {{title}} / {{text}} 按 JSON 字符串转义后替换;留空按 Slack 形状发送"
>
<div class="mb-1.5 flex flex-wrap gap-1.5">
<NButton
v-for="p in WEBHOOK_PRESETS"
:key="p.label"
size="tiny"
quaternary
type="primary"
@click="form.bodyTemplate = p.tpl"
>
{{ p.label }}
</NButton>
</div>
<NInput
v-model:value="form.bodyTemplate"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
class="mono"
:placeholder="WEBHOOK_PRESETS[0].tpl"
/>
</FormField>
</template>
<!-- ntfy -->
<template v-else-if="item.type === 'ntfy'">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="服务端" hint="留空用官方 ntfy.sh">
<NInput v-model:value="form.server" placeholder="https://ntfy.sh" />
</FormField>
<FormField label="Topic" hint="订阅的主题名">
<NInput v-model:value="form.topic" placeholder="oci-portal" />
</FormField>
</div>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'tk_…(可选)'"
/>
</FormField>
</template>
<!-- Bark -->
<template v-else-if="item.type === 'bark'">
<FormField label="服务端" hint="留空用官方 api.day.app">
<NInput v-model:value="form.server" placeholder="https://api.day.app" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'Bark App 内复制'"
/>
</FormField>
</template>
<!-- SMTP -->
<template v-else>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="主机">
<NInput v-model:value="form.host" placeholder="smtp.example.com" />
</FormField>
<FormField label="端口" hint="465 隐式 TLS,587 STARTTLS">
<AppInputNumber v-model:value="form.port" :min="1" :max="65535" class="!w-full" />
</FormField>
<FormField label="用户名" hint="留空表示免认证">
<NInput v-model:value="form.username" placeholder="user@example.com" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'SMTP 授权码'"
/>
</FormField>
<FormField label="发件人">
<NInput v-model:value="form.from" placeholder="noreply@example.com" />
</FormField>
<FormField label="收件人">
<NInput v-model:value="form.to" placeholder="me@example.com" />
</FormField>
</div>
</template>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
<span class="text-xs text-ink-3">测试使用已保存的配置,修改后请先保存</span>
</div>
</div>
</NCollapseTransition>
</div>
</template>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { NButton, NInput, NModal, useMessage } from 'naive-ui'
import { NButton, NInput, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { testNotifyTemplate, updateNotifyTemplate } from '@/api/settings'
import type { NotifyTemplateItem } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -18,7 +19,7 @@ const emit = defineEmits<{
saved: [kind: string, template: string]
}>()
const message = useMessage()
const message = useToast()
const draft = ref('')
const saving = ref(false)
const testing = ref(false)