2 Commits
Author SHA1 Message Date
wangdefa 556583270f 发布 v0.8.4
CI / test (push) Successful in 51s
Release / release (push) Successful in 48s
2026-08-11 11:53:43 +08:00
wangdefa 343563bb79 滑动续期与多标签同步、列表报错提示、分页排序体验改进 2026-08-11 11:44:57 +08:00
21 changed files with 378 additions and 27 deletions
+18 -1
View File
@@ -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 配置,「任务」抢机熔断阈值)等
+17
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -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 },
+2 -1
View File
@@ -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) {
+8 -1
View File
@@ -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)
})
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
})
+2
View File
@@ -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
+7 -1
View File
@@ -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)
+20 -1
View File
@@ -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>
+7 -1
View File
@@ -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)
+9 -1
View File
@@ -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)
+54 -2
View File
@@ -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" />
+4 -1
View File
@@ -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 页,避免停留在越界页码 */