初始提交: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
+41
View File
@@ -0,0 +1,41 @@
/** 5 段 cron 单字段匹配:支持 *、n、*\/k、a-b、逗号组合;超出子集返回 false */
function fieldMatch(field: string, v: number): boolean {
return field.split(',').some((seg) => {
const step = /^\*\/(\d+)$/.exec(seg)
if (step) return Number(step[1]) > 0 && v % Number(step[1]) === 0
const range = /^(\d+)-(\d+)$/.exec(seg)
if (range) return v >= Number(range[1]) && v <= Number(range[2])
return seg === '*' || seg === String(v)
})
}
/** 计算 5 段 cron 的下次执行时间(本地时区):
* 从下一分钟起逐分钟扫描 8 天,表达式非法或窗口内无匹配返回 null。
* 日与周同时受限时按 AND 近似(面板生成的表达式不会同时限制)。 */
export function nextCronRun(expr: string): Date | null {
const p = expr.trim().split(/\s+/)
if (p.length !== 5) return null
const t = new Date()
t.setSeconds(0, 0)
for (let i = 0; i < 8 * 24 * 60; i++) {
t.setMinutes(t.getMinutes() + 1)
if (
fieldMatch(p[0], t.getMinutes()) &&
fieldMatch(p[1], t.getHours()) &&
fieldMatch(p[2], t.getDate()) &&
fieldMatch(p[3], t.getMonth() + 1) &&
fieldMatch(p[4], t.getDay())
)
return new Date(t)
}
return null
}
const pad2 = (n: number) => String(n).padStart(2, '0')
/** 下次执行的短文案(MM-DD HH:mm);无法计算返回空串 */
export function nextCronRunLabel(expr: string): string {
const d = nextCronRun(expr)
if (!d) return ''
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 系统日志「动作」中文化:路由模板 + 方法 → 人话描述。
* 精确匹配优先,未命中回退资源段兜底;新增路由不强求登记,兜底可读。
*/
const exact: Record<string, string> = {
'POST /api/v1/auth/login': '登录面板',
'POST /api/v1/auth/totp/setup': '发起两步验证绑定',
'POST /api/v1/auth/totp/activate': '启用两步验证',
'POST /api/v1/auth/totp/disable': '停用两步验证',
'DELETE /api/v1/auth/identities/:id': '解绑外部身份',
'PUT /api/v1/settings/telegram': '保存 Telegram 通知配置',
'POST /api/v1/settings/telegram/test': '发送通知测试消息',
'PUT /api/v1/settings/notify-events': '保存通知管理开关',
'PUT /api/v1/settings/task': '保存任务设置',
'PUT /api/v1/settings/security': '保存安全设置',
'PUT /api/v1/settings/oauth': '保存登录方式配置',
'POST /api/v1/oci-configs': '导入租户 API Key',
'PUT /api/v1/oci-configs/:id': '修改租户配置',
'DELETE /api/v1/oci-configs/:id': '删除租户配置',
'POST /api/v1/oci-configs/:id/verify': '租户测活',
'POST /api/v1/oci-configs/:id/region-subscriptions': '订阅区域',
'POST /api/v1/oci-configs/:id/instances': '创建实例',
'PUT /api/v1/oci-configs/:id/instances/:instanceId': '修改实例配置',
'DELETE /api/v1/oci-configs/:id/instances/:instanceId': '终止实例',
'POST /api/v1/oci-configs/:id/instances/:instanceId/action': '实例电源操作',
'POST /api/v1/oci-configs/:id/instances/:instanceId/change-public-ip': '更换公网 IP',
'POST /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '添加 IPv6 地址',
'DELETE /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '删除 IPv6 地址',
'POST /api/v1/oci-configs/:id/instances/:instanceId/replace-boot-volume': '更换引导卷',
'POST /api/v1/oci-configs/:id/users': '创建租户用户',
'PUT /api/v1/oci-configs/:id/users/:userId': '修改租户用户',
'DELETE /api/v1/oci-configs/:id/users/:userId': '删除租户用户',
'POST /api/v1/oci-configs/:id/users/:userId/reset-password': '重置用户密码',
'DELETE /api/v1/oci-configs/:id/users/:userId/mfa-devices': '移除用户 MFA',
'DELETE /api/v1/oci-configs/:id/users/:userId/api-keys': '清除用户 API Key',
'POST /api/v1/oci-configs/:id/log-webhook': '生成回传回调地址',
'DELETE /api/v1/oci-configs/:id/log-webhook': '撤销回传回调地址',
'POST /api/v1/oci-configs/:id/log-relay': '一键创建回传链路',
'DELETE /api/v1/oci-configs/:id/log-relay': '销毁回传链路',
'POST /api/v1/tasks': '创建后台任务',
'PUT /api/v1/tasks/:id': '修改后台任务',
'DELETE /api/v1/tasks/:id': '删除后台任务',
'POST /api/v1/tasks/:id/run': '手动执行任务',
'POST /api/v1/webhooks/oci-logs/:secret': '回传消息投递',
}
/** 资源段兜底词典:精确表未命中时以「动词 + 资源」组合 */
const nouns: Record<string, string> = {
vcns: 'VCN',
subnets: '子网',
'security-lists': '安全列表',
'boot-volumes': '引导卷',
'boot-volume-attachments': '引导卷挂载',
'volume-attachments': '块卷挂载',
'console-sessions': '控制台会话',
'console-connections': '控制台连接',
'notification-recipients': '通知收件人',
'password-policies': '密码策略',
'identity-settings': '身份设置',
'identity-providers': '身份提供方',
'sign-on-exemptions': 'MFA 豁免',
proxies: '代理',
}
const verbs: Record<string, string> = { POST: '创建', PUT: '修改', DELETE: '删除' }
/** 取路径中最后一个可翻译的资源段(跳过 :param 与动作尾段) */
function lastNoun(path: string): string {
const segs = path.split('/').filter((s) => s && !s.startsWith(':'))
for (let i = segs.length - 1; i >= 0; i--) {
const zh = nouns[segs[i]]
if (zh) return zh
}
return ''
}
export function actionLabel(method: string, path: string): string {
const hit = exact[`${method} ${path}`]
if (hit) return hit
const noun = lastNoun(path)
if (noun) return `${verbs[method] ?? method}${noun}`
return ''
}
+36
View File
@@ -0,0 +1,36 @@
import { ref, type Ref } from 'vue'
interface AsyncState<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string>
/** silent 静默刷新(不改 loading),用于过渡态轮询等后台刷新,避免闪加载态 */
run: (opts?: { silent?: boolean }) => Promise<void>
}
/** 加载一份异步数据:统一 loading / error / 重试语义;并发 run 时只保留最后一次的结果 */
export function useAsync<T>(fetcher: () => Promise<T>, immediate = true): AsyncState<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
let seq = 0
async function run(opts?: { silent?: boolean }) {
const cur = ++seq
if (!opts?.silent) loading.value = true
error.value = ''
try {
const result = await fetcher()
if (cur !== seq) return
data.value = result
} catch (e) {
if (cur !== seq) return
error.value = e instanceof Error ? e.message : String(e)
} finally {
if (cur === seq) loading.value = false
}
}
if (immediate) void run()
return { data, loading, error, run }
}
+20
View File
@@ -0,0 +1,20 @@
import { onMounted, onUnmounted } from 'vue'
/**
* 周期静默刷新:每 ms 毫秒调用一次 fn;页面隐藏(标签页切走)或上一轮
* 尚未完成时跳过本轮,组件卸载自动清理。fn 自行保证静默(不置 loading、不弹错)。
*/
export function useAutoRefresh(fn: () => Promise<void>, ms = 5000) {
let timer: ReturnType<typeof setInterval> | undefined
let busy = false
onMounted(() => {
timer = setInterval(() => {
if (busy || document.hidden) return
busy = true
void fn().finally(() => {
busy = false
})
}, ms)
})
onUnmounted(() => clearInterval(timer))
}
+37
View File
@@ -0,0 +1,37 @@
/** OCI 审计 / 回传事件名的中文标签。
* 审计事件 eventName 为尾名(LaunchInstance);回传 eventType 为全限定名
* (com.oraclecloud.ComputeApi.TerminateInstance),先取尾段再匹配,大小写不敏感。 */
const EVENT_LABELS: Record<string, string> = {
launchinstance: '创建实例',
terminateinstance: '终止实例',
instanceaction: '实例电源操作',
updateinstance: '修改实例',
changeinstancecompartment: '移动实例',
createuser: '创建用户',
deleteuser: '删除用户',
updateuser: '修改用户',
createapikey: '创建 API 密钥',
deleteapikey: '删除 API 密钥',
uploadapikey: '上传 API 密钥',
updateusercapabilities: '修改用户能力',
createregionsubscription: '订阅区域',
createpolicy: '创建策略',
updatepolicy: '修改策略',
deletepolicy: '删除策略',
interactivelogin: '控制台登录',
createvcn: '创建 VCN',
deletevcn: '删除 VCN',
createsubnet: '创建子网',
deletesubnet: '删除子网',
}
/** 全限定事件类型的尾段;已是尾名时原样返回 */
export function eventTail(nameOrType: string): string {
const i = nameOrType.lastIndexOf('.')
return i >= 0 ? nameOrType.slice(i + 1) : nameOrType
}
/** 事件中文名;未收录返回空串,调用方回退原名 */
export function eventLabel(nameOrType: string): string {
return EVENT_LABELS[eventTail(nameOrType).toLowerCase()] ?? ''
}
+62
View File
@@ -0,0 +1,62 @@
/** 展示格式化工具:时间、字节、OCID 截断 */
export function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function fmtRelative(iso: string | null | undefined): string {
if (!iso) return '—'
const diff = Date.now() - new Date(iso).getTime()
if (Number.isNaN(diff)) return '—'
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr} 小时前`
return `${Math.floor(hr / 24)} 天前`
}
export function fmtBytes(n: number): string {
if (n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log2(n) / 10), units.length - 1)
return `${(n / 2 ** (10 * i)).toFixed(1)} ${units[i]}`
}
/** OCID 中段省略:ocid1.instance.oc1..aaaa…mc25a */
export function truncOcid(ocid: string, head = 24, tail = 6): string {
if (!ocid || ocid.length <= head + tail + 1) return ocid
return `${ocid.slice(0, head)}${ocid.slice(-tail)}`
}
/** 可用域展示:RssO:EU-FRANKFURT-1-AD-1 → AD-1 */
export function shortAd(ad: string): string {
const m = ad.match(/AD-\d+$/)
return m ? m[0] : ad
}
/** 区域友好名中括号内的城市短名:Germany Central (Frankfurt) → Frankfurt */
export function regionCity(label: string): string {
const m = /\(([^)]+)\)/.exec(label)
return m ? m[1] : label
}
/** 块存储 VPU/GB 档位:0 低成本 / 10 均衡 / 20 高性能 / 30-120 超高性能 */
export function vpusLabel(vpus: number): string {
if (vpus >= 30) return `${vpus} · 超高性能`
if (vpus >= 20) return `${vpus} · 高性能`
if (vpus >= 10) return `${vpus} · 均衡`
return `${vpus} · 低成本`
}
/** Oracle 官方风格默认资源名:instance-20260703-2136 / vcn-20260509-0252 */
export function defaultResourceName(prefix: string): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
return `${prefix}-${date}-${pad(now.getHours())}${pad(now.getMinutes())}`
}
+51
View File
@@ -0,0 +1,51 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
interface ParticleOptions {
/** 粒子数;缺省按画布面积自适应(约每 8000px² 一粒) */
count?: number
}
const PALETTE = ['rgba(201,100,66,', 'rgba(135,134,127,', 'rgba(106,155,204,']
/** 背景飘浮粒子:缓慢上浮 + 透明度呼吸;遵循 prefers-reduced-motion,卸载即停 */
export function useParticles(canvasRef: Ref<HTMLCanvasElement | null>, opts: ParticleOptions = {}) {
let raf = 0
onMounted(() => {
const cv = canvasRef.value
if (!cv || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const ctx = cv.getContext('2d')
if (!ctx) return
cv.width = cv.offsetWidth
cv.height = cv.offsetHeight
const W = cv.width
const H = cv.height
const count = opts.count ?? Math.round((W * H) / 8000)
const ps = Array.from({ length: count }, (_, i) => ({
x: Math.random() * W,
y: Math.random() * H,
r: 0.7 + Math.random() * 1.9,
v: 0.08 + Math.random() * 0.34,
a: 0.12 + Math.random() * 0.38,
c: PALETTE[i % PALETTE.length],
p: Math.random() * 6.28,
}))
const tick = (t: number) => {
ctx.clearRect(0, 0, W, H)
for (const p of ps) {
p.y -= p.v
if (p.y < -4) {
p.y = H + 4
p.x = Math.random() * W
}
const alpha = p.a * (0.6 + 0.4 * Math.sin(t / 1300 + p.p))
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, 6.28318)
ctx.fillStyle = `${p.c}${alpha})`
ctx.fill()
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
})
onUnmounted(() => cancelAnimationFrame(raf))
}
+26
View File
@@ -0,0 +1,26 @@
import { computed } from 'vue'
import { listProxies } from '@/api/proxies'
import { useAsync } from '@/composables/useAsync'
import type { ProxyInfo } from '@/types/api'
/** 代理出口地区短文案:未探测=检测中、失败=未知 */
export function proxyGeoLabel(p: ProxyInfo): string {
if (!p.geoAt) return '检测中'
if (!p.country) return '未知'
return p.city ? `${p.country}·${p.city}` : p.country
}
/** 出站代理下拉数据源(租户导入 / 修改表单共用):
* 选项 label 为「名称 · 出口地区」,首项为直连哨兵 null。 */
export function useProxyOptions() {
const proxies = useAsync(listProxies)
const options = computed(() => [
{ label: '直连(不使用代理)', value: null as number | null },
...(proxies.data.value?.items ?? []).map((p) => ({
label: `${p.name} · ${proxyGeoLabel(p)}`,
value: p.id as number | null,
})),
])
return { proxies, options }
}
+33
View File
@@ -0,0 +1,33 @@
import { computed, ref, type Ref } from 'vue'
import { listRegions } from '@/api/configs'
import type { RegionInfo } from '@/types/api'
/** 模块级缓存:本地静态区域表全站只拉一次 */
const regions: Ref<RegionInfo[] | null> = ref(null)
let pending: Promise<void> | null = null
function ensureLoaded() {
if (regions.value || pending) return
pending = listRegions()
.then((list) => {
regions.value = list
})
.catch(() => {
pending = null
})
}
/** 区域友好名:eu-frankfurt-1 / iad(短名 key)→ 友好名,未收录时回退原名 */
export function useRegionAlias() {
ensureLoaded()
const byName = computed(() => new Map((regions.value ?? []).map((r) => [r.name, r.alias])))
const byKey = computed(
() => new Map((regions.value ?? []).map((r) => [r.key.toLowerCase(), r.alias])),
)
function alias(name: string | null | undefined): string {
if (!name) return '—'
return byName.value.get(name) ?? byKey.value.get(name.toLowerCase()) ?? name
}
return { alias, regions }
}
+82
View File
@@ -0,0 +1,82 @@
import { useMessage } from 'naive-ui'
import { h, ref, type Ref, type VNodeChild } from 'vue'
type ToastKind = 'error' | 'warning'
const AUTO_CLOSE_MS = 8000
function renderDetail(kind: ToastKind, detail: string, copied: Ref<boolean>): VNodeChild {
return h('div', { class: 'toast-code' }, [
h('div', { class: 'toast-code-head' }, [
h('span', { class: `toast-tag toast-tag-${kind}` }, kind === 'error' ? 'ERROR' : 'WARN'),
h(
'button',
{
class: 'toast-copy',
onClick: () => {
void navigator.clipboard.writeText(detail)
copied.value = true
},
},
copied.value ? '已复制' : '⎘ 复制',
),
]),
h('pre', { class: 'toast-code-pre' }, detail),
])
}
function renderToast(
kind: ToastKind,
text: string,
detail: string,
expanded: Ref<boolean>,
copied: Ref<boolean>,
toggle: () => void,
): VNodeChild {
const rows: VNodeChild[] = [
h('div', { class: 'toast-line' }, [
h('span', text),
h('a', { class: 'toast-toggle', onClick: toggle }, expanded.value ? '收起 ▴' : '展开详情 ▾'),
]),
]
if (expanded.value) rows.push(renderDetail(kind, detail, copied))
return h('div', { class: 'toast-body' }, rows)
}
/**
* 带可展开详情的消息封装:error/warning 传 detail 时,toast 内嵌
* 「展开详情」代码块(带复制);展开期间暂停 8s 自动关闭,收起后重新计时。
*/
export function useToast() {
const message = useMessage()
function show(kind: ToastKind, text: string, detail?: string) {
if (!detail) {
message[kind](text)
return
}
const expanded = ref(false)
const copied = ref(false)
let timer = 0
const inst = message[kind](
() => renderToast(kind, text, detail, expanded, copied, toggle),
{ duration: 0, closable: true },
)
const arm = () => {
timer = window.setTimeout(() => inst.destroy(), AUTO_CLOSE_MS)
}
function toggle() {
expanded.value = !expanded.value
window.clearTimeout(timer)
if (!expanded.value) arm()
}
arm()
}
return {
success: (text: string) => message.success(text),
info: (text: string) => message.info(text),
error: (text: string, detail?: string) => show('error', text, detail),
warning: (text: string, detail?: string) => show('warning', text, detail),
}
}
+33
View File
@@ -0,0 +1,33 @@
import { onUnmounted, watch } from 'vue'
/** OCI 实例的过渡状态:处于这些状态时数据会在短时间内继续变化 */
export const INSTANCE_TRANSIENT_STATES = [
'PROVISIONING',
'STARTING',
'STOPPING',
'TERMINATING',
'CREATING_IMAGE',
'MOVING',
]
/** 挂载关系的过渡状态 */
export const ATTACHMENT_TRANSIENT_STATES = ['ATTACHING', 'DETACHING']
/**
* 过渡态自动轮询:每次数据源更新后,若仍处于过渡态则延时触发一次 refresh,
* 进入稳态自动停止;组件卸载时清理定时器。
* 依赖 useAsync 每次 run 成功都会产生新的数据引用来驱动 watch。
*/
export function useTransientPoll<T>(
source: () => T,
isTransient: (v: T) => boolean,
refresh: () => void,
intervalMs = 5000,
) {
let timer: ReturnType<typeof setTimeout> | undefined
watch(source, (v) => {
clearTimeout(timer)
if (isTransient(v)) timer = setTimeout(refresh, intervalMs)
})
onUnmounted(() => clearTimeout(timer))
}