初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -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())}`
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()] ?? ''
|
||||
}
|
||||
@@ -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())}`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user