From 343563bb79b46e83b35cf2511e45236e5d9b5a7e Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Tue, 11 Aug 2026 11:44:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BB=91=E5=8A=A8=E7=BB=AD=E6=9C=9F=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E6=A0=87=E7=AD=BE=E5=90=8C=E6=AD=A5=E3=80=81=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8A=A5=E9=94=99=E6=8F=90=E7=A4=BA=E3=80=81=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E6=8E=92=E5=BA=8F=E4=BD=93=E9=AA=8C=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/frontend/api-and-state.md | 19 ++- .trellis/spec/frontend/directory-structure.md | 2 +- src/api/request.test.ts | 112 +++++++++++++++++- src/api/request.ts | 25 +++- src/components/proxy/ProxyPanel.vue | 4 + src/components/tenant/ApiKeysModal.vue | 3 +- src/components/tenant/UsersTab.vue | 9 +- src/composables/usePersistedPageSize.test.ts | 26 ++++ src/composables/usePersistedPageSize.ts | 24 ++++ src/stores/auth.test.ts | 29 ++++- src/stores/auth.ts | 19 ++- src/types/api.ts | 2 + src/views/BootVolumeListView.vue | 8 +- src/views/InstanceDetailView.vue | 21 +++- src/views/InstanceListView.vue | 8 +- src/views/NetworkListView.vue | 10 +- src/views/TenantDetailView.vue | 56 ++++++++- src/views/TenantListView.vue | 5 +- 18 files changed, 358 insertions(+), 24 deletions(-) create mode 100644 src/composables/usePersistedPageSize.test.ts create mode 100644 src/composables/usePersistedPageSize.ts diff --git a/.trellis/spec/frontend/api-and-state.md b/.trellis/spec/frontend/api-and-state.md index 665426d..b7d8f38 100644 --- a/.trellis/spec/frontend/api-and-state.md +++ b/.trellis/spec/frontend/api-and-state.md @@ -4,6 +4,12 @@ - `request` 的 `body` 传**对象**,封装内部统一 `JSON.stringify`;调用方自己 stringify 会双重编码,后端报 `cannot unmarshal string into …`(2026-07-14 ai-settings 开关踩坑)。 - 跨页面共享状态才进 Pinia;页面内状态用 `ref`/`computed` 就地管理,不把一切塞 store。 - 请求函数返回类型化数据(`Promise`),错误向上抛由封装层兜底,不在每个调用点重复 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 不能裸 ``,统一经 `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(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(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(path, { - 换发 Promise 完成边界:断言返回调用方前 Store 已更新。 - 两个换发请求同时 401:断言均结束、无死锁、仅一次导航。 - 无旧 Token 的公开登录:断言请求层不提前写 Store。 +- 普通请求与 rawFetch 的非 2xx 带续期头:断言 Store 不变。 +- `storage` 事件、事件未送达的 401 与迟到续期头:断言采用其他标签页的新 Token。 ### 7. Wrong vs Correct diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index 276b7f3..95c4ed1 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -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 配置,「任务」抢机熔断阈值)等 diff --git a/src/api/request.test.ts b/src/api/request.test.ts index 8e1618c..1c64489 100644 --- a/src/api/request.test.ts +++ b/src/api/request.test.ts @@ -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 +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 + }) + } +}) diff --git a/src/api/request.ts b/src/api/request.ts index 48b1e4f..9559b54 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -92,6 +92,7 @@ async function handleUnauthorized(sentToken: string) { const waited = await waitForSessionRefresh(sentToken) if (waited) await new Promise((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( // 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 } diff --git a/src/components/proxy/ProxyPanel.vue b/src/components/proxy/ProxyPanel.vue index d804c2d..d84cad5 100644 --- a/src/components/proxy/ProxyPanel.vue +++ b/src/components/proxy/ProxyPanel.vue @@ -206,12 +206,14 @@ const columns: DataTableColumns = [ 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 = [ 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 }, diff --git a/src/components/tenant/ApiKeysModal.vue b/src/components/tenant/ApiKeysModal.vue index be9e96a..5bb7d51 100644 --- a/src/components/tenant/ApiKeysModal.vue +++ b/src/components/tenant/ApiKeysModal.vue @@ -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) { diff --git a/src/components/tenant/UsersTab.vue b/src/components/tenant/UsersTab.vue index 79f1d99..18f8d72 100644 --- a/src/components/tenant/UsersTab.vue +++ b/src/components/tenant/UsersTab.vue @@ -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 = [ @created="users.run()" /> - + diff --git a/src/composables/usePersistedPageSize.test.ts b/src/composables/usePersistedPageSize.test.ts new file mode 100644 index 0000000..89b9d46 --- /dev/null +++ b/src/composables/usePersistedPageSize.test.ts @@ -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) +}) diff --git a/src/composables/usePersistedPageSize.ts b/src/composables/usePersistedPageSize.ts new file mode 100644 index 0000000..1113c68 --- /dev/null +++ b/src/composables/usePersistedPageSize.ts @@ -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 } +} diff --git a/src/stores/auth.test.ts b/src/stores/auth.test.ts index 6fe474b..3b1a001 100644 --- a/src/stores/auth.test.ts +++ b/src/stores/auth.test.ts @@ -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') +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts index afec378..6939cf6 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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 } }) diff --git a/src/types/api.ts b/src/types/api.ts index 7e0d763..4f16241 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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 diff --git a/src/views/BootVolumeListView.vue b/src/views/BootVolumeListView.vue index 5acc09c..71910c4 100644 --- a/src/views/BootVolumeListView.vue +++ b/src/views/BootVolumeListView.vue @@ -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(async () => { scope.region || undefined, ) return (list ?? []).map((item) => ({ ...item, cfgId: cfg.id })) - } catch { + } catch (error) { + showListError(error) return [] } }, false) diff --git a/src/views/InstanceDetailView.vue b/src/views/InstanceDetailView.vue index 82c51d5..2854a16 100644 --- a/src/views/InstanceDetailView.vue +++ b/src/views/InstanceDetailView.vue @@ -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 = [ > -
+
diff --git a/src/views/InstanceListView.vue b/src/views/InstanceListView.vue index d4f72c0..356daa3 100644 --- a/src/views/InstanceListView.vue +++ b/src/views/InstanceListView.vue @@ -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(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) diff --git a/src/views/NetworkListView.vue b/src/views/NetworkListView.vue index 8381f80..7109de7 100644 --- a/src/views/NetworkListView.vue +++ b/src/views/NetworkListView.vue @@ -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(async () => { } }), ) - } catch { + } catch (error) { + showListError(error) return [] } }, false) diff --git a/src/views/TenantDetailView.vue b/src/views/TenantDetailView.vue index 4f88bca..a454b8f 100644 --- a/src/views/TenantDetailView.vue +++ b/src/views/TenantDetailView.vue @@ -1,6 +1,6 @@