Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
556583270f | ||
|
|
343563bb79 |
@@ -4,6 +4,12 @@
|
||||
- `request` 的 `body` 传**对象**,封装内部统一 `JSON.stringify`;调用方自己 stringify 会双重编码,后端报 `cannot unmarshal string into …`(2026-07-14 ai-settings 开关踩坑)。
|
||||
- 跨页面共享状态才进 Pinia;页面内状态用 `ref`/`computed` 就地管理,不把一切塞 store。
|
||||
- 请求函数返回类型化数据(`Promise<Instance[]>`),错误向上抛由封装层兜底,不在每个调用点重复 try/catch。
|
||||
- 若列表页明确要把失败降级为空数组,必须先对需要用户处理的 `ApiError` 给出
|
||||
Toast;代理/网络 502 使用后端 `hint|error` 文案。批量从属请求(如每个 VCN 的
|
||||
subnet 计数)可继续静默降级,避免一次主请求产生 Toast 风暴。
|
||||
- 子组件写操作会改变父页面摘要字段时,成功后用语义事件上抛,由父组件重新拉取
|
||||
权威对象;不能只刷新子列表。API Key 激活至少同时刷新详情 config 与全局 scope,
|
||||
使 `keyActivatedAt` 传播窗口提示立即可见。
|
||||
- 消息提示统一走 `useToast`(`src/composables/useToast`),禁止组件直接 `useMessage`:超长错误(如 OCI 透传)自动折叠成「标题 + 展开详情」,显式传 detail 可自定义折叠内容。
|
||||
- 账单域(round14 起):发票/付款方式 DTO 在 `types/api.ts`,请求走 `api/billing.ts`;发票 PDF 接口带 JWT 不能裸 `<a href>`,统一经 `downloadInvoicePdf`(rawFetch→blob→触发保存)。发票 tab 的 403 不当普通错误弹 toast,转受限卡提示 osp-gateway policy;「未付余额」口径只计 OPEN/PAST_DUE,PAYMENT_SUBMITTED 已提交扣款不计入。
|
||||
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
|
||||
@@ -17,6 +23,9 @@
|
||||
域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。
|
||||
- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
|
||||
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
|
||||
- Auth Store 以 localStorage 为多标签页会话真值:监听 `storage` 同步 token/expiry;
|
||||
401 最终比较前还须主动 `syncFromStorage()`,兜住事件尚未送达的窗口,不得删除
|
||||
其他标签页刚换发的新会话。
|
||||
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
|
||||
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载)
|
||||
- 写请求(非 GET)在 `request.ts` 层按 method+url+body 做 **in-flight 去重**(2026-07-23 起):前一发未返回时同 key 复用同一 Promise 不再发第二发,完成即移除;GET 不去重(useAsync seq 后发优先)。这是防连点的系统级兜底,组件级 busy/disabled 仍必须做(见 typescript-vue.md);合法的「连续两次相同写」(极少)需改变 body 或等待前次完成。
|
||||
@@ -50,8 +59,11 @@ request<SessionRefresh>(path, {
|
||||
- 有旧 Token 的 `refreshesSession` 请求按“发出时 Token”登记在途换发。
|
||||
- 2xx 响应须在 Promise 返回、换发登记释放前由请求层先写入 Auth Store;组件可为
|
||||
mock 流程重复写同值。
|
||||
- 滑动续期头只在 2xx 后读取;应用前先同步 localStorage 再比较请求快照,
|
||||
非 2xx、网络错误或畸形 JSON 不得写 Store。
|
||||
- 2xx 同时含合法 `SessionRefresh` 响应体与续期头时,显式响应体优先且只写一次。
|
||||
- 其他请求的 401 若发现同一旧 Token 仍有换发在途,须等待全部结束后再比较 Store;
|
||||
Token 已变化则只抛业务错误,不登出。
|
||||
比较前先从 localStorage 同步;Token 已变化则只抛业务错误,不登出。
|
||||
- 换发请求自身收到 401 时先幂等退出登记再处理 401,避免两个失败换发互相等待。
|
||||
- 公开登录没有旧 Token,不由请求层提前建立会话;页面仍负责登录后导航。
|
||||
|
||||
@@ -60,7 +72,10 @@ request<SessionRefresh>(path, {
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| 2xx 且 `token` / `expiresAt` 合法 | 先 `setSession`,再释放等待者 |
|
||||
| 2xx 只有合法续期头 | 请求快照仍为当前 Token 时应用 |
|
||||
| 2xx 同时有 body refresh 与续期头 | 只应用 body refresh |
|
||||
| 网络错误、畸形 JSON、非 2xx | 不写 Store,必须释放登记 |
|
||||
| 401 前其他标签页已更新 localStorage | 同步新 Token,保留会话且不导航 |
|
||||
| 并发 401 等待后 Token 已变化 | 保留新会话,不跳登录页 |
|
||||
| 等待后仍是请求时 Token | 登出并带当前地址跳转登录页 |
|
||||
| 多个换发请求均返回 401 | 全部拒绝,不死锁,只执行一次有效登出 |
|
||||
@@ -78,6 +93,8 @@ request<SessionRefresh>(path, {
|
||||
- 换发 Promise 完成边界:断言返回调用方前 Store 已更新。
|
||||
- 两个换发请求同时 401:断言均结束、无死锁、仅一次导航。
|
||||
- 无旧 Token 的公开登录:断言请求层不提前写 Store。
|
||||
- 普通请求与 rawFetch 的非 2xx 带续期头:断言 Store 不变。
|
||||
- `storage` 事件、事件未送达的 401 与迟到续期头:断言采用其他标签页的新 Token。
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ oci-portal-dash/
|
||||
├── api/ request 封装(JWT 注入、401 跳登录、错误 hint 拼接)+ 按资源分模块;VITE_MOCK=1 时返回 mock.ts 数据
|
||||
├── stores/ Pinia:auth(令牌持久化)、app(侧栏收缩、暗色主题:默认跟随系统 + localStorage 记忆,驱动 html.dark 与 Naive darkTheme)、scope(全局作用域:租户/区域/区间,localStorage 按租户记忆)
|
||||
├── router/ 路由与登录守卫
|
||||
├── composables/ useAsync(异步状态)、useFormat(时间/字节/OCID 格式化)、useRegionAlias(区域友好名)、useTransientPoll(过渡态自动轮询:实例电源 / 挂载操作后每 5 秒刷新至稳态)
|
||||
├── composables/ useAsync(异步状态)、useFormat(时间/字节/OCID 格式化)、useRegionAlias(区域友好名)、useTransientPoll(过渡态自动轮询:实例电源 / 挂载操作后每 5 秒刷新至稳态)、usePersistedPageSize(表格每页数量 localStorage 记忆,默认 20)
|
||||
├── components/ StatusBadge、OcidText、CostChart 等复用组件;表单基座 FormModal / FormField(hint 问号 tooltip + error / feedback 下置提示)/ FormSection(分组标题)/ FilePicker(含 compact 单行态)/ AppInputNumber(数字框统一封装:右侧垂直上下箭头步进);tenant/ 下为租户详情各 tab 与失联占位 DeadPlaceholder(失联时整体替换 tab 内容),storage/ 下为引导卷编辑 / 挂载弹窗,task/ 下含 CronEditor 可视化编辑器与任务表单(tab 分类型),instance/ 下含 InstanceSpecForm(实例规格表单,创建实例弹窗与抢机任务共用),logs/ 下为日志页各 tab 面板(SystemLogPanel 系统日志、LogEventPanel 回传日志)
|
||||
├── layouts/ AppLayout(顶栏 + 内容区,顶栏右侧为全局作用域租户 / 区域选择器)、SidebarNav(分组导航、收缩态)
|
||||
└── views/ 页面:登录、总览、租户列表/详情、实例列表/详情、网络、VCN 详情、引导卷列表(编辑 / 挂载 / 删除以弹窗内联完成,无单独详情页)、任务、日志(tab 结构:「系统日志」操作留痕,预留回传日志)、设置(tab 结构:「通知」通知管理 + Telegram 配置,「任务」抢机熔断阈值)等
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.8.4]
|
||||
|
||||
### Added
|
||||
|
||||
- 租户详情在激活或替换 API Key 后 15 分钟内显示「新 Key 全球生效中」提示横幅,激活成功即时刷新,窗口结束自动消失
|
||||
- 代理列表支持按名称、类型、地址排序
|
||||
|
||||
### Changed
|
||||
|
||||
- 登录会话滑动续期:响应携带续期令牌时无感替换本地会话,多标签页经 localStorage 同步,活跃使用不再每 24 小时被登出
|
||||
- 租户列表默认每页 20 条,每页数量调节后本地记忆
|
||||
- 实例详情已有数据时刷新失败改为 Toast 提示并保留页面数据;实例、网络、引导卷列表遇代理或网络 502 时弹出错误提示,不再静默显示空列表
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复多标签页下旧标签令牌过期触发退出时,误清其他标签页刚换发的新会话的问题
|
||||
|
||||
## [0.8.3]
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "oci-portal-dash",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oci-portal-dash",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "oci-portal-dash",
|
||||
"private": true,
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+109
-3
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { request, ApiError } from './request'
|
||||
import { createPinia, disposePinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { request, rawFetch, ApiError } from './request'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
|
||||
@@ -43,14 +43,17 @@ function stubRefreshThen401() {
|
||||
}
|
||||
|
||||
let assignSpy: ReturnType<typeof vi.spyOn>
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
disposePinia(pinia)
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -159,6 +162,22 @@ describe('401 令牌快照', () => {
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('storage 事件尚未送达时,401 也不得删除其他标签页刚写入的新 token', async () => {
|
||||
setPageURL('http://localhost/settings')
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
localStorage.setItem('oci-portal.token', 'tokB')
|
||||
localStorage.setItem('oci-portal.expiresAt', '2099-06-01T00:00:00Z')
|
||||
return { ok: false, status: 401, json: async () => ({ error: '令牌已失效' }) } as Response
|
||||
})
|
||||
|
||||
await expect(request('/x')).rejects.toMatchObject({ status: 401 })
|
||||
expect(auth.token).toBe('tokB')
|
||||
expect(localStorage.getItem('oci-portal.token')).toBe('tokB')
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 先到时等待并发换新响应落入 Store,不得提前登出', async () => {
|
||||
setPageURL('http://localhost/settings')
|
||||
useAuthStore().setSession('tokA', '')
|
||||
@@ -228,3 +247,90 @@ describe('401 令牌快照', () => {
|
||||
expect(assignSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('滑动续期响应头', () => {
|
||||
function stubRenewHeaders(onFetch?: () => void, body = '{}') {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
onFetch?.()
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'X-Renewed-Token': 'tokB',
|
||||
'X-Renewed-Expires-At': '2099-06-01T00:00:00Z',
|
||||
}),
|
||||
text: async () => body,
|
||||
} as unknown as Response
|
||||
})
|
||||
}
|
||||
|
||||
it('携带续期头时更新本地会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders()
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokB')
|
||||
expect(auth.expiresAt).toBe('2099-06-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('发送时令牌已非当前会话则不应用续期头', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
// 响应返回前会话被并发换发(如 OAuth 绑定回跳),迟到的续期头不得覆盖
|
||||
stubRenewHeaders(() => auth.setSession('tokC', '2099-02-01T00:00:00Z'))
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokC')
|
||||
})
|
||||
|
||||
it('storage 事件尚未送达时也不让迟到续期头覆盖其他标签页的新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders(() => {
|
||||
localStorage.setItem('oci-portal.token', 'tokC')
|
||||
localStorage.setItem('oci-portal.expiresAt', '2099-07-01T00:00:00Z')
|
||||
})
|
||||
|
||||
await request('/instances')
|
||||
expect(auth.token).toBe('tokC')
|
||||
expect(localStorage.getItem('oci-portal.token')).toBe('tokC')
|
||||
})
|
||||
|
||||
it('显式 body 会话换发优先于续期头', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubRenewHeaders(undefined, '{"token":"tokC","expiresAt":"2099-07-01T00:00:00Z"}')
|
||||
await request('/refresh', { method: 'POST', refreshesSession: true })
|
||||
expect(auth.token).toBe('tokC')
|
||||
expect(auth.expiresAt).toBe('2099-07-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('普通失败响应携带续期头也不得更新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubFailedRenewHeaders()
|
||||
await expect(request('/instances')).rejects.toMatchObject({ status: 500 })
|
||||
expect(auth.token).toBe('tokA')
|
||||
})
|
||||
|
||||
it('rawFetch 失败响应携带续期头也不得更新会话', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
stubFailedRenewHeaders()
|
||||
await expect(rawFetch('/download')).rejects.toMatchObject({ status: 500 })
|
||||
expect(auth.token).toBe('tokA')
|
||||
})
|
||||
|
||||
function stubFailedRenewHeaders() {
|
||||
vi.stubGlobal('fetch', async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: new Headers({
|
||||
'X-Renewed-Token': 'tokB',
|
||||
'X-Renewed-Expires-At': '2099-06-01T00:00:00Z',
|
||||
}),
|
||||
json: async () => ({ error: '服务器内部错误' }),
|
||||
} as unknown as Response
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+21
-4
@@ -92,6 +92,7 @@ async function handleUnauthorized(sentToken: string) {
|
||||
const waited = await waitForSessionRefresh(sentToken)
|
||||
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const auth = useAuthStore()
|
||||
auth.syncFromStorage()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') {
|
||||
@@ -140,11 +141,25 @@ function trackSessionRefresh(token: string): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
/** 滑动续期:后端对临过期令牌在响应头中换发同会话新令牌;仅当发送时令牌仍是
|
||||
* 当前会话令牌才应用(与 401 处理同一快照原则,防旧会话迟到响应覆盖新会话)。 */
|
||||
function applyRenewedSession(resp: Response, sentToken: string) {
|
||||
// 测试环境的最小 Response stub 可能无 headers
|
||||
const token = resp.headers?.get('X-Renewed-Token')
|
||||
const expiresAt = resp.headers?.get('X-Renewed-Expires-At')
|
||||
if (!token || !expiresAt || !sentToken) return
|
||||
const auth = useAuthStore()
|
||||
auth.syncFromStorage()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.setSession(token, expiresAt)
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const refresh = value as { token?: unknown; expiresAt?: unknown }
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return false
|
||||
useAuthStore().setSession(refresh.token, refresh.expiresAt)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
|
||||
@@ -190,7 +205,8 @@ async function doRequest<T>(
|
||||
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
|
||||
const text = await resp.text()
|
||||
const result = (text ? JSON.parse(text) : undefined) as T
|
||||
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
|
||||
const bodyRefreshed = !!sentToken && !!opts.refreshesSession && applySessionRefresh(result)
|
||||
if (!bodyRefreshed) applyRenewedSession(resp, sentToken ?? '')
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -214,6 +230,7 @@ export async function rawFetch(
|
||||
body: opts.body,
|
||||
})
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
applyRenewedSession(resp, sentToken ?? '')
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -206,12 +206,14 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
key: 'name',
|
||||
minWidth: 140,
|
||||
ellipsis: { tooltip: true },
|
||||
sorter: (a, b) => a.name.localeCompare(b.name, 'zh-CN'),
|
||||
render: (row) => h('span', { class: 'text-[13px] font-medium' }, row.name),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 84,
|
||||
sorter: (a, b) => a.type.localeCompare(b.type, 'zh-CN'),
|
||||
render: (row) => h('span', { class: 'mono text-[12.5px] uppercase' }, row.type),
|
||||
},
|
||||
{
|
||||
@@ -219,6 +221,8 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
key: 'host',
|
||||
minWidth: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
// 同主机的多端口代理常见,主机相同再按端口数值排
|
||||
sorter: (a, b) => a.host.localeCompare(b.host) || a.port - b.port,
|
||||
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, `${row.host}:${row.port}`),
|
||||
},
|
||||
{ title: '地区', key: 'geo', minWidth: 120, render: renderGeo },
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import type { CreatedApiKey, IamUser, UserApiKey } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; cfgId: number; user: IamUser | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; switched: [] }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; activated: []; switched: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(760)
|
||||
@@ -93,6 +93,7 @@ async function doActivate() {
|
||||
await activateApiKey(props.cfgId, created.value.fingerprint, created.value.privateKey, props.user.id)
|
||||
activated.value = true
|
||||
toast.success(switched ? `面板签名已切换到用户 ${props.user.name}` : '面板签名凭据已切换到该 key')
|
||||
emit('activated')
|
||||
if (switched) emit('switched')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number; domainId?: string }>()
|
||||
const emit = defineEmits<{ 'key-activated': [] }>()
|
||||
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
@@ -184,6 +185,12 @@ const columns: DataTableColumns<IamUser> = [
|
||||
@created="users.run()"
|
||||
/>
|
||||
|
||||
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" @switched="users.run()" />
|
||||
<ApiKeysModal
|
||||
v-model:show="showKeys"
|
||||
:cfg-id="cfgId"
|
||||
:user="keysUser"
|
||||
@activated="emit('key-activated')"
|
||||
@switched="users.run()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, expect, it } from 'vitest'
|
||||
import { usePersistedPageSize } from './usePersistedPageSize'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('无记录时返回默认 20', () => {
|
||||
expect(usePersistedPageSize('tenants').load()).toBe(20)
|
||||
})
|
||||
|
||||
it('save 后 load 返回所存值', () => {
|
||||
const { load, save } = usePersistedPageSize('tenants')
|
||||
save(50)
|
||||
expect(load()).toBe(50)
|
||||
})
|
||||
|
||||
it('存量值不在候选列表内时回退默认', () => {
|
||||
localStorage.setItem('oci-portal:page-size:tenants', '999')
|
||||
expect(usePersistedPageSize('tenants').load()).toBe(20)
|
||||
})
|
||||
|
||||
it('不同表的记忆互不影响', () => {
|
||||
usePersistedPageSize('tenants').save(100)
|
||||
expect(usePersistedPageSize('proxies').load()).toBe(20)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
const PREFIX = 'oci-portal:page-size:'
|
||||
|
||||
/**
|
||||
* 表格每页数量的 localStorage 记忆:load 取上次选择(不在候选列表内的存量值
|
||||
* 回退默认),save 记录用户本次调节;key 按表名隔离,各表互不影响。
|
||||
*/
|
||||
export function usePersistedPageSize(
|
||||
table: string,
|
||||
fallback = 20,
|
||||
allowed: number[] = [10, 20, 50, 100],
|
||||
) {
|
||||
const key = PREFIX + table
|
||||
|
||||
function load(): number {
|
||||
const n = Number(localStorage.getItem(key))
|
||||
return allowed.includes(n) ? n : fallback
|
||||
}
|
||||
|
||||
function save(pageSize: number) {
|
||||
localStorage.setItem(key, String(pageSize))
|
||||
}
|
||||
|
||||
return { load, save }
|
||||
}
|
||||
+25
-4
@@ -1,12 +1,17 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, disposePinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
afterEach(() => disposePinia(pinia))
|
||||
|
||||
describe('isAuthed 实时过期判定', () => {
|
||||
const cases = [
|
||||
{ name: '无 token 未登录', token: '', expiresAt: '', want: false },
|
||||
@@ -44,6 +49,22 @@ it('logout 清空会话与本地存储', () => {
|
||||
|
||||
it('setSession 落地本地存储,新 store 实例可恢复', () => {
|
||||
useAuthStore().setSession('t2', '')
|
||||
setActivePinia(createPinia())
|
||||
disposePinia(pinia)
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
expect(useAuthStore().token).toBe('t2')
|
||||
})
|
||||
|
||||
it('storage 事件把其他标签页的新会话同步到当前 Store', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setSession('tokA', '2099-01-01T00:00:00Z')
|
||||
localStorage.setItem('oci-portal.token', 'tokB')
|
||||
localStorage.setItem('oci-portal.expiresAt', '2099-06-01T00:00:00Z')
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', { key: 'oci-portal.token', storageArea: localStorage }),
|
||||
)
|
||||
|
||||
expect(auth.token).toBe('tokB')
|
||||
expect(auth.expiresAt).toBe('2099-06-01T00:00:00Z')
|
||||
})
|
||||
|
||||
+17
-2
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
|
||||
const TOKEN_KEY = 'oci-portal.token'
|
||||
const EXPIRES_KEY = 'oci-portal.expiresAt'
|
||||
@@ -8,6 +8,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
|
||||
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
|
||||
|
||||
/** localStorage 是多标签页会话真值;401 判定前也主动同步,兜住 storage 事件尚未送达的窗口 */
|
||||
function syncFromStorage() {
|
||||
token.value = localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
expiresAt.value = localStorage.getItem(EXPIRES_KEY) ?? ''
|
||||
}
|
||||
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.storageArea && event.storageArea !== localStorage) return
|
||||
if (event.key !== null && event.key !== TOKEN_KEY && event.key !== EXPIRES_KEY) return
|
||||
syncFromStorage()
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
onScopeDispose(() => window.removeEventListener('storage', onStorage))
|
||||
|
||||
// 普通函数而非 computed:computed 只随 token/expiresAt 失效,时间流逝不会
|
||||
// 触发重算,令牌自然过期后路由守卫会长期误判仍已登录
|
||||
function isAuthed(): boolean {
|
||||
@@ -30,5 +45,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem(EXPIRES_KEY)
|
||||
}
|
||||
|
||||
return { token, expiresAt, isAuthed, setSession, logout }
|
||||
return { token, expiresAt, isAuthed, setSession, syncFromStorage, logout }
|
||||
})
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface OciConfigSummary {
|
||||
export interface OciConfig extends OciConfigSummary {
|
||||
userOcid: string
|
||||
fingerprint: string
|
||||
/** 最近一次启用新 API 签名 key 的时刻,传播窗口提示用;null 表示未记录 */
|
||||
keyActivatedAt?: string | null
|
||||
homeRegionKey: string
|
||||
subscriptionId: string
|
||||
paymentModel: string
|
||||
|
||||
@@ -11,6 +11,7 @@ import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { deleteBootVolume, listBootVolumes } from '@/api/bootVolumes'
|
||||
import { ApiError } from '@/api/request'
|
||||
import CompartmentSelect from '@/components/CompartmentSelect.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
@@ -33,6 +34,10 @@ interface BvRow extends BootVolume {
|
||||
const scope = useScopeStore()
|
||||
const message = useToast()
|
||||
|
||||
function showListError(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 502) message.error(error.message)
|
||||
}
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
@@ -47,7 +52,8 @@ const rows = useAsync<BvRow[]>(async () => {
|
||||
scope.region || undefined,
|
||||
)
|
||||
return (list ?? []).map((item) => ({ ...item, cfgId: cfg.id }))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
showListError(error)
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
@@ -82,6 +82,25 @@ const consoles = useAsync(() =>
|
||||
listConsoleConnections(cfgId.value, instanceId.value, region.value),
|
||||
)
|
||||
|
||||
/** 数据在手时的加载失败走 toast、页面继续展示旧数据;首载失败仍由页内占位卡承接。
|
||||
* 轮询期间同一错误反复出现只提示一次,成功一次后解除,避免 toast 刷屏 */
|
||||
let instanceErrorToasted = ''
|
||||
watch(
|
||||
() => instance.error.value,
|
||||
(err) => {
|
||||
if (err && instance.data.value && err !== instanceErrorToasted) {
|
||||
instanceErrorToasted = err
|
||||
message.error('实例刷新失败', err)
|
||||
}
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => instance.data.value,
|
||||
() => {
|
||||
instanceErrorToasted = ''
|
||||
},
|
||||
)
|
||||
|
||||
/** 按 imageId 补镜像名称,查询失败时回退 OCID 展示 */
|
||||
const image = useAsync(
|
||||
() => {
|
||||
@@ -432,7 +451,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else-if="instance.error.value" class="panel">
|
||||
<div v-else-if="instance.error.value && !instance.data.value" class="panel">
|
||||
<EmptyCard title="实例加载失败" :note="instance.error.value" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
terminatingInstances,
|
||||
terminatingKey,
|
||||
} from '@/api/instances'
|
||||
import { ApiError } from '@/api/request'
|
||||
import CompartmentSelect from '@/components/CompartmentSelect.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
@@ -39,6 +40,10 @@ interface InstanceRow extends Instance {
|
||||
|
||||
const message = useToast()
|
||||
|
||||
function showListError(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 502) message.error(error.message)
|
||||
}
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域,页内只做区间切换与搜索 */
|
||||
const scope = useScopeStore()
|
||||
/** ≤767px 表格换卡片列表(设计稿 B2) */
|
||||
@@ -54,7 +59,8 @@ const rows = useAsync<InstanceRow[]>(async () => {
|
||||
try {
|
||||
const list = await listInstances(cfg.id, scope.region || undefined, compartmentId)
|
||||
return list.map((item) => ({ ...item, cfgId: cfg.id, cfgAlias: cfg.alias }))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
showListError(error)
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { computed, h, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { listSubnets, listVcns } from '@/api/networks'
|
||||
import { ApiError } from '@/api/request'
|
||||
import CompartmentSelect from '@/components/CompartmentSelect.vue'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
@@ -18,6 +19,7 @@ import ReservedIpPanel from '@/components/network/ReservedIpPanel.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { Vcn } from '@/types/api'
|
||||
|
||||
@@ -30,8 +32,13 @@ interface VcnRow extends Vcn {
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域 */
|
||||
const scope = useScopeStore()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const message = useToast()
|
||||
const showCreate = ref(false)
|
||||
|
||||
function showListError(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 502) message.error(error.message)
|
||||
}
|
||||
|
||||
/** 当前作用域租户失联时整页替换占位,不发起资源请求 */
|
||||
const isDead = computed(() => scope.currentConfig?.aliveStatus === 'dead')
|
||||
|
||||
@@ -53,7 +60,8 @@ const rows = useAsync<VcnRow[]>(async () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
showListError(error)
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { deleteConfig, getConfig, verifyConfig } from '@/api/configs'
|
||||
@@ -43,6 +43,32 @@ const heroExpanded = ref(false)
|
||||
|
||||
const config = useAsync(() => getConfig(cfgId.value))
|
||||
|
||||
function refreshAfterKeyActivation() {
|
||||
void config.run()
|
||||
void scope.refreshConfigs()
|
||||
}
|
||||
|
||||
/** 新 key 激活后的 OCI 公钥全球传播窗口:窗口内显示提示横幅,到点自动消失 */
|
||||
const KEY_PROPAGATION_MS = 15 * 60 * 1000
|
||||
const propagationNow = ref(Date.now())
|
||||
let propagationTimer = 0
|
||||
const keyPropagating = computed(() => {
|
||||
const at = config.data.value?.keyActivatedAt
|
||||
return !!at && propagationNow.value - new Date(at).getTime() < KEY_PROPAGATION_MS
|
||||
})
|
||||
watch(
|
||||
() => config.data.value?.keyActivatedAt,
|
||||
(at) => {
|
||||
window.clearTimeout(propagationTimer)
|
||||
if (!at) return
|
||||
const left = new Date(at).getTime() + KEY_PROPAGATION_MS - Date.now()
|
||||
if (left > 0)
|
||||
propagationTimer = window.setTimeout(() => (propagationNow.value = Date.now()), left + 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => window.clearTimeout(propagationTimer))
|
||||
|
||||
/** 失联/暂停租户各 tab 拉不到云端数据,内容区整体换占位 */
|
||||
const isBlocked = computed(() =>
|
||||
['dead', 'suspended'].includes(config.data.value?.aliveStatus ?? ''),
|
||||
@@ -158,6 +184,26 @@ async function remove() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="keyPropagating"
|
||||
class="mt-3 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2.5 text-xs leading-relaxed"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-[14px] w-[14px] flex-none text-warn"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<span>
|
||||
新 API Key 正在全球生效中(约需数分钟):期间部分区域可能间歇报
|
||||
NotAuthenticated,属预期现象,请稍后重试
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@@ -231,7 +277,13 @@ async function remove() {
|
||||
<QuotaTab v-if="tab === 'quota'" :cfg-id="cfgId" />
|
||||
<!-- 域相关 tab 以 domainId 为 key:切域整组件重挂载,状态与数据全量刷新 -->
|
||||
<IdpTab v-else-if="tab === 'idp'" :key="`idp-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
||||
<UsersTab v-else-if="tab === 'users'" :key="`users-${domainId ?? ''}`" :cfg-id="cfgId" :domain-id="domainId" />
|
||||
<UsersTab
|
||||
v-else-if="tab === 'users'"
|
||||
:key="`users-${domainId ?? ''}`"
|
||||
:cfg-id="cfgId"
|
||||
:domain-id="domainId"
|
||||
@key-activated="refreshAfterKeyActivation"
|
||||
/>
|
||||
<CostTab v-else-if="tab === 'cost'" :cfg-id="cfgId" />
|
||||
<InvoiceTab v-else-if="tab === 'invoice'" :cfg-id="cfgId" />
|
||||
<SubsTab v-else-if="tab === 'subs'" :cfg-id="cfgId" :account-type="config.data.value.accountType" />
|
||||
|
||||
@@ -16,6 +16,7 @@ import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/
|
||||
import { aliveMeta } from '@/composables/useAliveStatus'
|
||||
import { fmtRelative, fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { usePersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
@@ -81,9 +82,10 @@ const filtered = computed(() => {
|
||||
})
|
||||
|
||||
/** 客户端分页:字面量对象会在重渲染时被重建导致状态丢失,须用 reactive 受控 */
|
||||
const tenantsPageSize = usePersistedPageSize('tenants')
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
pageSize: tenantsPageSize.load(),
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
onUpdatePage: (p: number) => {
|
||||
@@ -92,6 +94,7 @@ const pagination = reactive({
|
||||
onUpdatePageSize: (ps: number) => {
|
||||
pagination.pageSize = ps
|
||||
pagination.page = 1
|
||||
tenantsPageSize.save(ps)
|
||||
},
|
||||
})
|
||||
/** 搜索或分组过滤变化后回第 1 页,避免停留在越界页码 */
|
||||
|
||||
Reference in New Issue
Block a user