fix(frontend): stabilize async state and session lifecycles

Preserve plugin pages during session renewal, restore pagination after failed requests, and cancel plugin resources on reload or disposal. Reuse request state for quota forecasts and disable logout while authentication changes.

Validated with frontend lint and build, 23 temporary behavioral checks, and browser checks using simulated pagination and authentication responses.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-18 13:32:46 +08:00
co-authored by Codex
parent 5f61309941
commit 598d56baed
12 changed files with 780 additions and 757 deletions
+42 -8
View File
@@ -14,6 +14,13 @@ interface PageResult {
}
}
export interface PagedQueryExecution {
page?: number
pageSize?: number
silent?: boolean
background?: boolean
}
export function usePagedQuery<Result extends PageResult>(options: {
initialPageSize: number
load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise<Result>
@@ -25,34 +32,52 @@ export function usePagedQuery<Result extends PageResult>(options: {
const total = shallowRef(0)
const items = shallowRef<Result['items'][number][]>([])
const request = useRequestState(options.onError)
const { loading, error, invalidate } = request
const { loading, error } = request
let confirmedPagination = { page: page.value, pageSize: pageSize.value }
async function execute(execution: { silent?: boolean, background?: boolean } = {}) {
function restorePagination() {
page.value = confirmedPagination.page
pageSize.value = confirmedPagination.pageSize
}
function invalidate(options?: Parameters<typeof request.invalidate>[0]) {
request.invalidate(options)
restorePagination()
}
async function execute(execution: PagedQueryExecution = {}): Promise<boolean> {
// 保留当前列表的刷新不一定静默;手动刷新仍需由请求层提示失败。
const background = execution.background || execution.silent
const requestId = request.start(background)
const pagination = {
page: execution.page ?? page.value,
pageSize: execution.pageSize ?? pageSize.value,
}
// 同步控件的目标值,失败时再回退;只在成功时写入会让控件内部模型无法恢复。
// 轮询或操作后的回读也沿用当前目标页,避免把尚未完成的翻页拉回旧页。
page.value = pagination.page
pageSize.value = pagination.pageSize
try {
const result = await options.load({
page: page.value,
pageSize: pageSize.value,
}, { silent: execution.silent, signal: request.signal })
const result = await options.load(pagination, { silent: execution.silent, signal: request.signal })
if (!request.isCurrent(requestId))
return false
if (result.items.length === 0 && result.page.total > 0 && result.page.page > 1) {
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
return execute(execution)
return execute({ ...execution, page: clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY) })
}
items.value = result.items
page.value = result.page.page
pageSize.value = result.page.pageSize
total.value = result.page.total
confirmedPagination = { page: page.value, pageSize: pageSize.value }
options.onSuccess?.(result)
return true
}
catch (cause: unknown) {
if (request.isCurrent(requestId))
restorePagination()
request.fail(requestId, cause, background)
return false
}
@@ -61,6 +86,14 @@ export function usePagedQuery<Result extends PageResult>(options: {
}
}
function reloadFromStart() {
// 筛选条件已改变,旧结果不能继续对应当前查询,也不能在失败时恢复旧页。
items.value = []
total.value = 0
confirmedPagination = { page: 1, pageSize: confirmedPagination.pageSize }
return execute(confirmedPagination)
}
return {
page,
pageSize,
@@ -69,6 +102,7 @@ export function usePagedQuery<Result extends PageResult>(options: {
loading,
error,
execute,
reloadFromStart,
invalidate,
}
}
@@ -577,6 +577,7 @@ onBeforeUnmount(() => {
:size="isCollapsed ? 'default' : 'small'"
aria-label="退出登录"
type="danger"
:disabled="authStore.loading || !authStore.isAuthenticated"
@click="handleLogout"
>
<LogOut :size="isCollapsed ? 19 : 18" />
+8 -1
View File
@@ -8,6 +8,8 @@ import { resetUnauthorizedHandling } from '@/api/request'
export const useAuthStore = defineStore('auth', () => {
const session = shallowRef<AuthSession | null>(null)
// 登录身份变化使关联数据失效,正常续期只更新会话有效期。
const sessionGeneration = shallowRef(0)
const isAuthenticated = computed(() => session.value !== null)
const isAdmin = computed(() => session.value?.role === 'admin')
const sessionChecked = shallowRef(false)
@@ -41,7 +43,10 @@ export const useAuthStore = defineStore('auth', () => {
// 登录或退出之后到达的旧状态响应,不覆盖新会话。
if (currentRevision === revision) {
const wasAuthenticated = isAuthenticated.value
const previousRole = session.value?.role
session.value = status.session
if (previousRole !== status.session?.role)
sessionGeneration.value += 1
sessionChecked.value = true
if (status.authenticated && !wasAuthenticated)
resetUnauthorizedHandling()
@@ -61,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => {
revision += 1
pendingCheck.value = undefined
session.value = result
sessionGeneration.value += 1
sessionChecked.value = true
resetUnauthorizedHandling()
return result
@@ -97,9 +103,10 @@ export const useAuthStore = defineStore('auth', () => {
revision += 1
pendingCheck.value = undefined
session.value = null
sessionGeneration.value += 1
sessionChecked.value = true
resetUnauthorizedHandling()
}
return { session, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
return { session, sessionGeneration, isAuthenticated, isAdmin, sessionChecked, loading, checking, checkAuth, refreshSession, login, logout, invalidateSession }
})
+1 -4
View File
@@ -70,10 +70,7 @@ export const usePluginViewsStore = defineStore('plugin-views', () => {
return pending ?? refresh()
}
watch(() => authStore.session, (session, previous) => {
if (session !== previous)
reset()
})
watch(() => authStore.sessionGeneration, reset, { flush: 'sync' })
return {
views,
@@ -3,6 +3,8 @@ import type { Account, AccountQuotaForecastResponse } from '@/api'
import { ZNotification } from '@codex-proxy/ui'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountQuotaForecast, refreshAccountQuota } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
import { errorMessage } from '@/utils/operation'
export function useAccountQuotaForecast(
accountId: Ref<string>,
@@ -10,44 +12,27 @@ export function useAccountQuotaForecast(
onAccountUpdated: (account: Account) => void,
) {
const report = shallowRef<AccountQuotaForecastResponse | null>(null)
const loading = shallowRef(false)
const request = useRequestState()
const { loading, error } = request
const refreshing = shallowRef(false)
const error = shallowRef(false)
let requestVersion = 0
let controller: AbortController | undefined
let disposed = false
function cancelLoad(resetLoading = true) {
requestVersion += 1
controller?.abort()
controller = undefined
if (resetLoading)
loading.value = false
}
async function load() {
if (!open.value || !accountId.value || disposed)
return false
cancelLoad()
const version = requestVersion
controller = new AbortController()
loading.value = true
error.value = false
const requestId = request.start()
try {
const result = await getAccountQuotaForecast({ accountId: accountId.value }, { signal: controller.signal })
if (version === requestVersion) {
const result = await getAccountQuotaForecast({ accountId: accountId.value }, { signal: request.signal })
if (request.isCurrent(requestId)) {
report.value = result
return true
}
}
catch {
if (version === requestVersion) {
error.value = true
}
catch (cause) {
request.fail(requestId, cause)
}
finally {
if (version === requestVersion)
loading.value = false
request.finish(requestId)
}
return false
}
@@ -56,9 +41,9 @@ export function useAccountQuotaForecast(
if (refreshing.value || !open.value)
return
const targetAccountId = accountId.value
cancelLoad()
request.invalidate()
refreshing.value = true
error.value = false
error.value = ''
try {
// 刷新属于现有额度动作;预测查询本身始终只读。
const result = await refreshAccountQuota({ accountId: targetAccountId })
@@ -68,10 +53,10 @@ export function useAccountQuotaForecast(
if (accountId.value === targetAccountId && await load())
ZNotification.success({ message: '额度已刷新' })
}
catch {
catch (cause) {
if (!disposed && accountId.value === targetAccountId) {
if (!report.value)
error.value = true
error.value = errorMessage(cause)
}
}
finally {
@@ -80,17 +65,16 @@ export function useAccountQuotaForecast(
}
watch([open, accountId], ([isOpen]) => {
cancelLoad(isOpen)
request.invalidate({ resetLoading: isOpen })
if (!isOpen)
return
report.value = null
error.value = false
error.value = ''
void load()
}, { immediate: true })
onScopeDispose(() => {
disposed = true
cancelLoad()
})
return { report, loading, refreshing, error, load, refresh }
@@ -45,20 +45,16 @@ export function useAccountsQuery() {
}))
function handlePageChange(page: number) {
query.page.value = page
void query.execute()
void query.execute({ page })
}
function handlePageSizeChange(pageSize: number) {
query.pageSize.value = pageSize
query.page.value = 1
void query.execute()
void query.execute({ page: 1, pageSize })
}
function handleSortChange(nextSort: TableSort | undefined) {
sort.value = nextSort
query.page.value = 1
void query.execute()
void query.reloadFromStart()
}
async function replaceAccount(updated: Account) {
@@ -74,15 +70,13 @@ export function useAccountsQuery() {
watch(searchQuery, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
query.page.value = 1
void query.execute()
void query.reloadFromStart()
}, 250)
onCleanup(() => clearTimeout(timer))
})
watch([providerQuery, statusQuery, groupQuery], () => {
query.page.value = 1
void query.execute()
void query.reloadFromStart()
})
onMounted(() => {
@@ -256,26 +256,21 @@ export function useAccountGroups() {
}
function handlePageChange(page: number) {
query.page.value = page
void query.execute()
void query.execute({ page })
}
function handlePageSizeChange(pageSize: number) {
query.pageSize.value = pageSize
query.page.value = 1
void query.execute()
void query.execute({ page: 1, pageSize })
}
watch(searchQuery, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
query.page.value = 1
void query.execute()
void query.reloadFromStart()
}, 250)
onCleanup(() => clearTimeout(timer))
})
watch(statusQuery, () => {
query.page.value = 1
void query.execute()
void query.reloadFromStart()
})
onMounted(() => {
void Promise.all([query.execute(), loadReferenceKeys()])
@@ -1,74 +1,13 @@
<script setup lang="ts">
import type { PluginManagementPage, PluginManagementRoute, PluginManagementView } from '@/api'
import type { PluginManagementPage, PluginManagementView } from '@/api'
import { ZButton } from '@codex-proxy/ui'
import { RotateCw } from '@lucide/vue'
import { useEventListener, useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onMounted, onScopeDispose, shallowRef, useTemplateRef, watch } from 'vue'
import {
callPluginManagementRoute,
callPluginModelResponses,
createPluginManagementCallbackTicket,
pluginManagementCallbackPath,
} from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { ApiError } from '@/api/request'
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
import { errorMessage, generateRequestId } from '@/utils/operation'
import {
assemblePluginManagementPage,
MAXIMUM_MANAGEMENT_BODY_BYTES,
MAXIMUM_MANAGEMENT_IN_FLIGHT,
MAXIMUM_MANAGEMENT_QUERY_BYTES,
MAXIMUM_MODEL_BODY_BYTES,
MAXIMUM_MODEL_CHUNK_BYTES,
MAXIMUM_MODEL_IN_FLIGHT,
MODEL_REQUEST_DEADLINE_MS,
MODEL_RESPONSE_IDLE_MS,
PLUGIN_MANAGEMENT_BRIDGE,
PLUGIN_MANAGEMENT_BRIDGE_VERSION,
readPluginFrameTheme,
} from '../management/frame'
import { usePluginFrameSession } from '../composables/usePluginFrameSession'
import PluginPageLoading from './PluginPageLoading.vue'
interface BridgeMessage {
target: string
version: number
channel: string
session: string
type: string
id: number
payload?: unknown
}
interface FrameSession {
channel: string
session: string
controller: AbortController
view: PluginManagementView
page: PluginManagementPage
revoke: () => void
inFlight: number
requestIds: Set<number>
modelRequests: Map<number, ModelRequest>
loaded: boolean
}
interface ModelRequest {
id: number
source: Window
controller: AbortController
reader?: ReadableStreamDefaultReader<Uint8Array>
buffered?: Uint8Array
bufferedOffset: number
reading: boolean
nextPullId: number
activePullId?: number
totalTimer?: ReturnType<typeof setTimeout>
idleTimer?: ReturnType<typeof setTimeout>
}
const props = defineProps<{
view: PluginManagementView
page: PluginManagementPage
@@ -81,554 +20,20 @@ const emit = defineEmits<{
const themeStore = useThemeStore()
const containerRef = useTemplateRef<HTMLDivElement>('container')
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
const srcdoc = shallowRef('')
const loading = shallowRef(false)
const loadError = shallowRef('')
const contentHeight = shallowRef(0)
const viewportHeight = shallowRef(0)
const { srcdoc, loading, loadError, contentHeight, frameKey, loadPage, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
view: () => props.view,
page: () => props.page,
iframe: iframeRef,
viewportHeight: readViewportHeight,
onStale: () => emit('stale'),
})
const frameReady = computed(() => contentHeight.value > 0)
let session: FrameSession | undefined
let loadSequence = 0
let previousViewportHeight = -1
function disposeSession() {
const current = session
session = undefined
srcdoc.value = ''
contentHeight.value = 0
previousViewportHeight = -1
if (!current)
return
current.controller.abort()
for (const request of current.modelRequests.values())
terminateModelRequest(current, request)
current.revoke()
}
async function loadPage() {
const sequence = ++loadSequence
disposeSession()
loading.value = true
loadError.value = ''
const controller = new AbortController()
const channel = generateRequestId()
const sessionId = generateRequestId()
try {
await nextTick()
if (sequence !== loadSequence)
return
pushLayout()
const assembled = await assemblePluginManagementPage(
props.view,
props.page,
channel,
sessionId,
window.location.origin,
readPluginFrameTheme(),
viewportHeight.value,
controller.signal,
)
if (sequence !== loadSequence || controller.signal.aborted) {
assembled.revoke()
return
}
session = {
channel,
session: sessionId,
controller,
view: props.view,
page: props.page,
revoke: assembled.revoke,
inFlight: 0,
requestIds: new Set(),
modelRequests: new Map(),
loaded: false,
}
srcdoc.value = assembled.srcdoc
}
catch (error) {
if (sequence === loadSequence && !controller.signal.aborted) {
if (error instanceof ApiError && error.status === 409) {
loadError.value = '插件页面版本已变更,正在刷新'
emit('stale')
}
else {
loadError.value = errorMessage(error)
}
}
}
finally {
if (sequence === loadSequence)
loading.value = false
}
}
function handleFrameLoad() {
const current = session
if (!current)
return
if (!current.loaded) {
current.loaded = true
pushLayout()
return
}
loadError.value = '插件页面尝试离开已隔离的管理文档,已停止加载'
disposeSession()
}
function isBridgeMessage(value: unknown): value is BridgeMessage {
if (!value || typeof value !== 'object')
return false
const message = value as Record<string, unknown>
return message.target === PLUGIN_MANAGEMENT_BRIDGE
&& message.version === PLUGIN_MANAGEMENT_BRIDGE_VERSION
&& typeof message.channel === 'string'
&& typeof message.session === 'string'
&& typeof message.type === 'string'
&& Number.isSafeInteger(message.id)
&& Number(message.id) > 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function postResult(
current: FrameSession,
source: Window,
id: number,
ok: boolean,
payload?: unknown,
error?: string,
transfer: Transferable[] = [],
) {
if (session !== current || iframeRef.value?.contentWindow !== source)
return
source.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'result',
id,
ok,
payload,
error,
}, '*', transfer)
}
function routeForMessage(view: PluginManagementView, payload: Record<string, unknown>) {
const method = typeof payload.method === 'string' ? payload.method.toUpperCase() : ''
const path = typeof payload.path === 'string' ? payload.path : ''
return view.routes.find(route => route.method === method && route.path === path)
}
function validateRoutePayload(route: PluginManagementRoute, payload: Record<string, unknown>) {
const query = payload.query
const contentType = payload.contentType
const body = payload.body
if (typeof query !== 'string' || query.startsWith('?') || query.includes('#') || hasControlCharacter(query))
throw new Error('查询参数无效')
if (new TextEncoder().encode(query).byteLength > MAXIMUM_MANAGEMENT_QUERY_BYTES)
throw new Error('查询参数超过 8 KiB')
if (!(body instanceof ArrayBuffer) || body.byteLength > MAXIMUM_MANAGEMENT_BODY_BYTES)
throw new Error('请求正文无效或超过 1 MiB')
if ((route.method === 'GET' || route.method === 'HEAD') && body.byteLength > 0)
throw new Error(`${route.method} 路由不接受请求正文`)
if (contentType !== undefined && typeof contentType !== 'string')
throw new Error('Content-Type 无效')
if (typeof contentType === 'string' && !route.requestContentTypes.includes(contentType))
throw new Error('Content-Type 未在插件路由中注册')
if (body.byteLength > 0 && typeof contentType !== 'string')
throw new Error('包含正文的请求必须声明 Content-Type')
return {
query,
contentType: typeof contentType === 'string' ? contentType : undefined,
body,
}
}
function hasControlCharacter(value: string) {
return [...value].some((character) => {
const code = character.codePointAt(0) ?? 0
return code < 0x20 || code === 0x7F
})
}
async function handleRouteRequest(
current: FrameSession,
source: Window,
message: BridgeMessage,
) {
if (!isRecord(message.payload))
throw new Error('请求参数无效')
const route = routeForMessage(current.view, message.payload)
if (!route)
throw new Error('请求未在当前插件版本中注册')
const payload = validateRoutePayload(route, message.payload)
const response = await callPluginManagementRoute(current.view.target, {
method: route.method,
path: route.path,
query: payload.query,
contentType: payload.contentType,
body: payload.body,
}, { signal: current.controller.signal, silent: true })
const body = response.body
postResult(current, source, message.id, true, {
status: response.status,
contentType: response.contentType,
body,
}, undefined, [body])
}
async function handleCallbackTicket(
current: FrameSession,
source: Window,
message: BridgeMessage,
) {
if (!isRecord(message.payload))
throw new Error('回调票据参数无效')
const path = typeof message.payload.path === 'string' ? message.payload.path : ''
const ttlSeconds = message.payload.ttlSeconds
if (!current.view.callbacks.some(callback => callback.path === path))
throw new Error('回调路径未在当前插件版本中注册')
if (!Number.isSafeInteger(ttlSeconds) || Number(ttlSeconds) < 1 || Number(ttlSeconds) > 600)
throw new Error('回调票据有效期必须为 1 到 600 秒')
const ticket = await createPluginManagementCallbackTicket(current.view.target, {
path,
ttlSeconds: Number(ttlSeconds),
}, { signal: current.controller.signal, silent: true })
const callbackPath = `${API_BASE_URL}${pluginManagementCallbackPath(current.view.target, path, ticket.state)}`
postResult(current, source, message.id, true, {
state: ticket.state,
expiresAtMs: ticket.expiresAtMs,
callbackUrl: new URL(callbackPath, window.location.origin).toString(),
})
}
function postModelMessage(
current: FrameSession,
source: Window,
type: 'models-response' | 'models-chunk' | 'models-complete' | 'models-error',
id: number,
payload?: unknown,
error?: string,
transfer: Transferable[] = [],
) {
if (session !== current || iframeRef.value?.contentWindow !== source)
return
source.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type,
id,
payload,
error,
}, '*', transfer)
}
function validateModelRequestPayload(payload: unknown) {
if (!isRecord(payload))
throw new Error('模型请求参数无效')
const clientKeyId = payload.clientKeyId
const body = payload.body
if (
typeof clientKeyId !== 'string'
|| !clientKeyId
|| clientKeyId !== clientKeyId.trim()
|| clientKeyId.length > 128
|| hasControlCharacter(clientKeyId)
) {
throw new Error('clientKeyId 无效')
}
if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > MAXIMUM_MODEL_BODY_BYTES)
throw new Error('Responses 请求正文无效或超过 8 MiB')
let parsed: unknown
try {
parsed = JSON.parse(body)
}
catch {
throw new Error('Responses 请求正文不是有效 JSON')
}
if (!isRecord(parsed))
throw new Error('Responses 请求正文必须是 JSON 对象')
return { clientKeyId, body }
}
function modelResponseHeaders(response: Response) {
// 完整转交浏览器可读取的响应头;浏览器自身的 Headers 合同仍然适用。
return Array.from(response.headers.entries())
}
function supportedModelResponse(response: Response) {
if (!response.body)
return true
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
return contentType === 'application/json'
|| contentType === 'text/event-stream'
|| Boolean(contentType?.endsWith('+json'))
}
function detachModelRequest(current: FrameSession, request: ModelRequest) {
if (current.modelRequests.get(request.id) !== request)
return false
current.modelRequests.delete(request.id)
clearTimeout(request.totalTimer)
clearTimeout(request.idleTimer)
request.totalTimer = undefined
request.idleTimer = undefined
return true
}
function cancelModelReader(request: ModelRequest) {
const reader = request.reader
if (!reader)
return
void reader.cancel()
.finally(() => {
try {
reader.releaseLock()
}
catch {
// 取消中的 read 会自行释放;会话清理不能因此产生未处理拒绝。
}
})
.catch(() => undefined)
}
function terminateModelRequest(current: FrameSession, request: ModelRequest, error?: string) {
if (!detachModelRequest(current, request))
return
request.controller.abort()
cancelModelReader(request)
if (error)
postModelMessage(current, request.source, 'models-error', request.id, undefined, error)
}
function completeModelRequest(current: FrameSession, request: ModelRequest, pullId: number) {
if (!detachModelRequest(current, request))
return
try {
request.reader?.releaseLock()
}
catch {
// EOF 已确定,锁清理失败不应阻断结束标记。
}
postModelMessage(current, request.source, 'models-complete', request.id, { pullId })
}
function armModelIdleTimeout(current: FrameSession, request: ModelRequest) {
clearTimeout(request.idleTimer)
request.idleTimer = setTimeout(() => {
terminateModelRequest(current, request, '页面 30 秒未继续读取模型响应')
}, MODEL_RESPONSE_IDLE_MS)
}
async function handleModelRequest(current: FrameSession, source: Window, message: BridgeMessage) {
if (current.modelRequests.size >= MAXIMUM_MODEL_IN_FLIGHT) {
postModelMessage(current, source, 'models-error', message.id, undefined, '模型请求已达并发上限')
return
}
if (current.requestIds.has(message.id) || current.modelRequests.has(message.id)) {
postModelMessage(current, source, 'models-error', message.id, undefined, '模型请求序号重复')
return
}
let payload: ReturnType<typeof validateModelRequestPayload>
try {
payload = validateModelRequestPayload(message.payload)
}
catch (error) {
postModelMessage(current, source, 'models-error', message.id, undefined, errorMessage(error))
return
}
const request: ModelRequest = {
id: message.id,
source,
controller: new AbortController(),
bufferedOffset: 0,
reading: false,
nextPullId: 1,
}
current.modelRequests.set(message.id, request)
request.totalTimer = setTimeout(() => {
terminateModelRequest(current, request, '模型请求超过 10 分钟')
}, MODEL_REQUEST_DEADLINE_MS)
try {
const response = await callPluginModelResponses(
current.view.target,
payload.clientKeyId,
payload.body,
request.controller.signal,
)
if (current.modelRequests.get(request.id) !== request)
return
if (!supportedModelResponse(response))
throw new Error('模型接口返回了不受支持的内容类型')
const hasBody = response.body !== null
if (response.body)
request.reader = response.body.getReader()
const statusText = response.statusText.length <= 256 && !hasControlCharacter(response.statusText)
? response.statusText
: ''
postModelMessage(current, source, 'models-response', message.id, {
status: response.status,
statusText,
headers: modelResponseHeaders(response),
hasBody,
})
if (!hasBody) {
detachModelRequest(current, request)
return
}
armModelIdleTimeout(current, request)
}
catch (error) {
if (current.modelRequests.get(request.id) === request)
terminateModelRequest(current, request, errorMessage(error, '模型请求失败'))
}
}
async function handleModelPull(current: FrameSession, source: Window, message: BridgeMessage) {
const request = current.modelRequests.get(message.id)
if (!request || request.source !== source)
return
const pullId = isRecord(message.payload) ? message.payload.pullId : undefined
if (!Number.isSafeInteger(pullId) || Number(pullId) < 1) {
terminateModelRequest(current, request, '模型响应拉取序号无效')
return
}
if (request.reading) {
if (request.activePullId !== pullId)
terminateModelRequest(current, request, '模型响应拉取发生重入')
return
}
if (pullId !== request.nextPullId) {
terminateModelRequest(current, request, '模型响应拉取序号已失效')
return
}
clearTimeout(request.idleTimer)
request.idleTimer = undefined
request.reading = true
request.activePullId = Number(pullId)
try {
while (current.modelRequests.get(message.id) === request) {
if (!request.buffered || request.bufferedOffset >= request.buffered.byteLength) {
request.buffered = undefined
request.bufferedOffset = 0
const result = await request.reader?.read()
if (current.modelRequests.get(message.id) !== request)
return
if (!result || result.done) {
completeModelRequest(current, request, Number(pullId))
return
}
if (result.value.byteLength === 0)
continue
request.buffered = result.value
}
const end = Math.min(request.bufferedOffset + MAXIMUM_MODEL_CHUNK_BYTES, request.buffered.byteLength)
const chunk = request.buffered.slice(request.bufferedOffset, end)
request.bufferedOffset = end
if (request.bufferedOffset >= request.buffered.byteLength) {
request.buffered = undefined
request.bufferedOffset = 0
}
const body = chunk.buffer
request.nextPullId += 1
postModelMessage(current, source, 'models-chunk', message.id, { pullId, body }, undefined, [body])
armModelIdleTimeout(current, request)
return
}
}
catch (error) {
if (current.modelRequests.get(message.id) === request)
terminateModelRequest(current, request, errorMessage(error, '模型响应读取失败'))
}
finally {
request.reading = false
request.activePullId = undefined
}
}
async function handleBridgeMessage(event: MessageEvent) {
const current = session
const source = event.source
if (
!current
|| source !== iframeRef.value?.contentWindow
|| event.origin !== 'null'
|| !isBridgeMessage(event.data)
|| event.data.channel !== current.channel
|| event.data.session !== current.session
) {
return
}
const frame = source as Window
if (event.data.type === 'resize') {
const height = isRecord(event.data.payload) ? event.data.payload.height : undefined
if (typeof height === 'number' && Number.isSafeInteger(height) && height > 0 && height <= 1_000_000)
contentHeight.value = height
return
}
if (event.data.type === 'models-request') {
void handleModelRequest(current, frame, event.data)
return
}
if (event.data.type === 'models-pull') {
void handleModelPull(current, frame, event.data)
return
}
if (event.data.type === 'models-cancel') {
const request = current.modelRequests.get(event.data.id)
if (request?.source === frame)
terminateModelRequest(current, request)
return
}
if (!['request', 'callback-ticket'].includes(event.data.type))
return
if (current.requestIds.has(event.data.id) || current.modelRequests.has(event.data.id)) {
postResult(current, frame, event.data.id, false, undefined, '插件管理请求序号重复')
return
}
if (current.inFlight >= MAXIMUM_MANAGEMENT_IN_FLIGHT) {
postResult(current, frame, event.data.id, false, undefined, '插件管理请求已达并发上限')
return
}
current.inFlight += 1
current.requestIds.add(event.data.id)
try {
if (event.data.type === 'request')
await handleRouteRequest(current, frame, event.data)
else await handleCallbackTicket(current, frame, event.data)
}
catch (error) {
if (session !== current || current.controller.signal.aborted)
return
if (error instanceof ApiError && error.status === 409) {
loadError.value = '插件页面版本已变更,正在刷新'
emit('stale')
disposeSession()
return
}
postResult(current, frame, event.data.id, false, undefined, errorMessage(error))
}
finally {
current.requestIds.delete(event.data.id)
current.inFlight = Math.max(0, current.inFlight - 1)
}
}
function pushLayout() {
function readViewportHeight() {
const container = containerRef.value
if (!container)
return
return 0
// 在 iframe 创建前预留可用空间,计算不依赖插件内容高度。
let top = container.getBoundingClientRect().top
@@ -637,69 +42,17 @@ function pushLayout() {
top += parent.scrollTop
bottomPadding += Number.parseFloat(getComputedStyle(parent).paddingBottom) || 0
}
const height = Math.max(0, Math.floor(document.documentElement.clientHeight - top - bottomPadding))
viewportHeight.value = height
const current = session
const frame = iframeRef.value
if (!current?.loaded || !frame?.contentWindow)
return
if (height === previousViewportHeight)
return
previousViewportHeight = height
frame.contentWindow.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'layout',
payload: { viewportHeight: height },
}, '*')
}
async function pushTheme() {
await nextTick()
const current = session
const target = iframeRef.value?.contentWindow
if (!current || !target)
return
target.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'theme',
payload: readPluginFrameTheme(),
}, '*')
pushLayout()
viewportHeight.value = Math.max(0, Math.floor(document.documentElement.clientHeight - top - bottomPadding))
return viewportHeight.value
}
useResizeObserver(containerRef, pushLayout)
useEventListener('resize', pushLayout)
watch(
[
() => props.view.target.instanceId,
() => props.view.target.artifactSha256,
() => props.view.target.revision,
() => props.page.id,
],
() => void loadPage(),
{ immediate: true },
)
watch(
() => [themeStore.effectiveTheme, themeStore.themeRevision],
() => void pushTheme(),
)
onMounted(() => {
window.addEventListener('message', handleBridgeMessage)
pushLayout()
})
onScopeDispose(() => {
loadSequence += 1
window.removeEventListener('message', handleBridgeMessage)
disposeSession()
})
onMounted(pushLayout)
</script>
<template>
@@ -726,7 +79,7 @@ onScopeDispose(() => {
<iframe
v-else-if="srcdoc"
ref="iframe"
:key="session?.channel"
:key="frameKey"
:srcdoc="srcdoc"
:title="`${view.name}:${page.title}`"
class="block w-full border-0 bg-transparent"
@@ -0,0 +1,668 @@
import type { ShallowRef } from 'vue'
import type { PluginManagementPage, PluginManagementRoute, PluginManagementView } from '@/api'
import { useEventListener } from '@vueuse/core'
import { computed, nextTick, onScopeDispose, shallowRef, watch } from 'vue'
import {
callPluginManagementRoute,
callPluginModelResponses,
createPluginManagementCallbackTicket,
pluginManagementCallbackPath,
} from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { ApiError } from '@/api/request'
import { errorMessage, generateRequestId } from '@/utils/operation'
import {
assemblePluginManagementPage,
hasControlCharacter,
MAXIMUM_MANAGEMENT_BODY_BYTES,
MAXIMUM_MANAGEMENT_IN_FLIGHT,
MAXIMUM_MANAGEMENT_QUERY_BYTES,
MAXIMUM_MODEL_BODY_BYTES,
MAXIMUM_MODEL_CHUNK_BYTES,
MAXIMUM_MODEL_IN_FLIGHT,
MODEL_REQUEST_DEADLINE_MS,
MODEL_RESPONSE_IDLE_MS,
PLUGIN_MANAGEMENT_BRIDGE,
PLUGIN_MANAGEMENT_BRIDGE_VERSION,
readPluginFrameTheme,
} from '../management/frame'
interface BridgeMessage {
target: string
version: number
channel: string
session: string
type: string
id: number
payload?: unknown
}
interface FrameSession {
channel: string
session: string
controller: AbortController
view: PluginManagementView
page: PluginManagementPage
revoke: () => void
inFlight: number
requestIds: Set<number>
modelRequests: Map<number, ModelRequest>
loaded: boolean
}
interface ModelRequest {
id: number
source: Window
controller: AbortController
reader?: ReadableStreamDefaultReader<Uint8Array>
buffered?: Uint8Array
bufferedOffset: number
reading: boolean
nextPullId: number
activePullId?: number
totalTimer?: ReturnType<typeof setTimeout>
idleTimer?: ReturnType<typeof setTimeout>
}
export function usePluginFrameSession(options: {
view: () => PluginManagementView
page: () => PluginManagementPage
iframe: Readonly<ShallowRef<HTMLIFrameElement | null>>
viewportHeight: () => number
onStale: () => void
}) {
const srcdoc = shallowRef('')
const loading = shallowRef(false)
const loadError = shallowRef('')
const contentHeight = shallowRef(0)
const session = shallowRef<FrameSession>()
let loadController: AbortController | undefined
let previousViewportHeight = -1
function disposeSession() {
loadController?.abort()
loadController = undefined
const current = session.value
session.value = undefined
srcdoc.value = ''
contentHeight.value = 0
previousViewportHeight = -1
if (!current)
return
current.controller.abort()
for (const request of current.modelRequests.values())
terminateModelRequest(current, request)
current.revoke()
}
async function loadPage() {
disposeSession()
loading.value = true
loadError.value = ''
const controller = new AbortController()
loadController = controller
const view = options.view()
const page = options.page()
const channel = generateRequestId()
const sessionId = generateRequestId()
try {
await nextTick()
if (controller.signal.aborted)
return
const assembled = await assemblePluginManagementPage(
view,
page,
channel,
sessionId,
window.location.origin,
readPluginFrameTheme(),
options.viewportHeight(),
controller.signal,
)
if (controller.signal.aborted) {
assembled.revoke()
return
}
session.value = {
channel,
session: sessionId,
controller,
view,
page,
revoke: assembled.revoke,
inFlight: 0,
requestIds: new Set(),
modelRequests: new Map(),
loaded: false,
}
srcdoc.value = assembled.srcdoc
}
catch (error) {
if (!controller.signal.aborted) {
// 一个资源加载失败时,同时停止仍在运行的其他资源请求。
controller.abort()
if (error instanceof ApiError && error.status === 409) {
loadError.value = '插件页面版本已变更,正在刷新'
options.onStale()
}
else {
loadError.value = errorMessage(error)
}
}
}
finally {
if (loadController === controller) {
loadController = undefined
loading.value = false
}
}
}
function handleFrameLoad() {
const current = session.value
if (!current)
return
if (!current.loaded) {
current.loaded = true
pushLayout()
return
}
loadError.value = '插件页面尝试离开已隔离的管理文档,已停止加载'
disposeSession()
}
function isBridgeMessage(value: unknown): value is BridgeMessage {
if (!value || typeof value !== 'object')
return false
const message = value as Record<string, unknown>
return message.target === PLUGIN_MANAGEMENT_BRIDGE
&& message.version === PLUGIN_MANAGEMENT_BRIDGE_VERSION
&& typeof message.channel === 'string'
&& typeof message.session === 'string'
&& typeof message.type === 'string'
&& Number.isSafeInteger(message.id)
&& Number(message.id) > 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function postResult(
current: FrameSession,
source: Window,
id: number,
ok: boolean,
payload?: unknown,
error?: string,
transfer: Transferable[] = [],
) {
if (session.value !== current || options.iframe.value?.contentWindow !== source)
return
source.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'result',
id,
ok,
payload,
error,
}, '*', transfer)
}
function routeForMessage(view: PluginManagementView, payload: Record<string, unknown>) {
const method = typeof payload.method === 'string' ? payload.method.toUpperCase() : ''
const path = typeof payload.path === 'string' ? payload.path : ''
return view.routes.find(route => route.method === method && route.path === path)
}
function validateRoutePayload(route: PluginManagementRoute, payload: Record<string, unknown>) {
const query = payload.query
const contentType = payload.contentType
const body = payload.body
if (typeof query !== 'string' || query.startsWith('?') || query.includes('#') || hasControlCharacter(query))
throw new Error('查询参数无效')
if (new TextEncoder().encode(query).byteLength > MAXIMUM_MANAGEMENT_QUERY_BYTES)
throw new Error('查询参数超过 8 KiB')
if (!(body instanceof ArrayBuffer) || body.byteLength > MAXIMUM_MANAGEMENT_BODY_BYTES)
throw new Error('请求正文无效或超过 1 MiB')
if ((route.method === 'GET' || route.method === 'HEAD') && body.byteLength > 0)
throw new Error(`${route.method} 路由不接受请求正文`)
if (contentType !== undefined && typeof contentType !== 'string')
throw new Error('Content-Type 无效')
if (typeof contentType === 'string' && !route.requestContentTypes.includes(contentType))
throw new Error('Content-Type 未在插件路由中注册')
if (body.byteLength > 0 && typeof contentType !== 'string')
throw new Error('包含正文的请求必须声明 Content-Type')
return {
query,
contentType: typeof contentType === 'string' ? contentType : undefined,
body,
}
}
async function handleRouteRequest(
current: FrameSession,
source: Window,
message: BridgeMessage,
) {
if (!isRecord(message.payload))
throw new Error('请求参数无效')
const route = routeForMessage(current.view, message.payload)
if (!route)
throw new Error('请求未在当前插件版本中注册')
const payload = validateRoutePayload(route, message.payload)
const response = await callPluginManagementRoute(current.view.target, {
method: route.method,
path: route.path,
query: payload.query,
contentType: payload.contentType,
body: payload.body,
}, { signal: current.controller.signal, silent: true })
const body = response.body
postResult(current, source, message.id, true, {
status: response.status,
contentType: response.contentType,
body,
}, undefined, [body])
}
async function handleCallbackTicket(
current: FrameSession,
source: Window,
message: BridgeMessage,
) {
if (!isRecord(message.payload))
throw new Error('回调票据参数无效')
const path = typeof message.payload.path === 'string' ? message.payload.path : ''
const ttlSeconds = message.payload.ttlSeconds
if (!current.view.callbacks.some(callback => callback.path === path))
throw new Error('回调路径未在当前插件版本中注册')
if (!Number.isSafeInteger(ttlSeconds) || Number(ttlSeconds) < 1 || Number(ttlSeconds) > 600)
throw new Error('回调票据有效期必须为 1 到 600 秒')
const ticket = await createPluginManagementCallbackTicket(current.view.target, {
path,
ttlSeconds: Number(ttlSeconds),
}, { signal: current.controller.signal, silent: true })
const callbackPath = `${API_BASE_URL}${pluginManagementCallbackPath(current.view.target, path, ticket.state)}`
postResult(current, source, message.id, true, {
state: ticket.state,
expiresAtMs: ticket.expiresAtMs,
callbackUrl: new URL(callbackPath, window.location.origin).toString(),
})
}
function postModelMessage(
current: FrameSession,
source: Window,
type: 'models-response' | 'models-chunk' | 'models-complete' | 'models-error',
id: number,
payload?: unknown,
error?: string,
transfer: Transferable[] = [],
) {
if (session.value !== current || options.iframe.value?.contentWindow !== source)
return
source.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type,
id,
payload,
error,
}, '*', transfer)
}
function validateModelRequestPayload(payload: unknown) {
if (!isRecord(payload))
throw new Error('模型请求参数无效')
const clientKeyId = payload.clientKeyId
const body = payload.body
if (
typeof clientKeyId !== 'string'
|| !clientKeyId
|| clientKeyId !== clientKeyId.trim()
|| clientKeyId.length > 128
|| hasControlCharacter(clientKeyId)
) {
throw new Error('clientKeyId 无效')
}
if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > MAXIMUM_MODEL_BODY_BYTES)
throw new Error('Responses 请求正文无效或超过 8 MiB')
let parsed: unknown
try {
parsed = JSON.parse(body)
}
catch {
throw new Error('Responses 请求正文不是有效 JSON')
}
if (!isRecord(parsed))
throw new Error('Responses 请求正文必须是 JSON 对象')
return { clientKeyId, body }
}
function modelResponseHeaders(response: Response) {
// 完整转交浏览器可读取的响应头;浏览器自身的 Headers 合同仍然适用。
return Array.from(response.headers.entries())
}
function supportedModelResponse(response: Response) {
if (!response.body)
return true
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
return contentType === 'application/json'
|| contentType === 'text/event-stream'
|| Boolean(contentType?.endsWith('+json'))
}
function detachModelRequest(current: FrameSession, request: ModelRequest) {
if (current.modelRequests.get(request.id) !== request)
return false
current.modelRequests.delete(request.id)
clearTimeout(request.totalTimer)
clearTimeout(request.idleTimer)
request.totalTimer = undefined
request.idleTimer = undefined
return true
}
function cancelModelReader(request: ModelRequest) {
const reader = request.reader
if (!reader)
return
void reader.cancel()
.finally(() => {
try {
reader.releaseLock()
}
catch {
// 取消中的 read 会自行释放;会话清理不能因此产生未处理拒绝。
}
})
.catch(() => undefined)
}
function terminateModelRequest(current: FrameSession, request: ModelRequest, error?: string) {
if (!detachModelRequest(current, request))
return
request.controller.abort()
cancelModelReader(request)
if (error)
postModelMessage(current, request.source, 'models-error', request.id, undefined, error)
}
function completeModelRequest(current: FrameSession, request: ModelRequest, pullId: number) {
if (!detachModelRequest(current, request))
return
try {
request.reader?.releaseLock()
}
catch {
// EOF 已确定,锁清理失败不应阻断结束标记。
}
postModelMessage(current, request.source, 'models-complete', request.id, { pullId })
}
function armModelIdleTimeout(current: FrameSession, request: ModelRequest) {
clearTimeout(request.idleTimer)
request.idleTimer = setTimeout(() => {
terminateModelRequest(current, request, '页面 30 秒未继续读取模型响应')
}, MODEL_RESPONSE_IDLE_MS)
}
async function handleModelRequest(current: FrameSession, source: Window, message: BridgeMessage) {
if (current.modelRequests.size >= MAXIMUM_MODEL_IN_FLIGHT) {
postModelMessage(current, source, 'models-error', message.id, undefined, '模型请求已达并发上限')
return
}
if (current.requestIds.has(message.id) || current.modelRequests.has(message.id)) {
postModelMessage(current, source, 'models-error', message.id, undefined, '模型请求序号重复')
return
}
let payload: ReturnType<typeof validateModelRequestPayload>
try {
payload = validateModelRequestPayload(message.payload)
}
catch (error) {
postModelMessage(current, source, 'models-error', message.id, undefined, errorMessage(error))
return
}
const request: ModelRequest = {
id: message.id,
source,
controller: new AbortController(),
bufferedOffset: 0,
reading: false,
nextPullId: 1,
}
current.modelRequests.set(message.id, request)
request.totalTimer = setTimeout(() => {
terminateModelRequest(current, request, '模型请求超过 10 分钟')
}, MODEL_REQUEST_DEADLINE_MS)
try {
const response = await callPluginModelResponses(
current.view.target,
payload.clientKeyId,
payload.body,
request.controller.signal,
)
if (current.modelRequests.get(request.id) !== request)
return
if (!supportedModelResponse(response))
throw new Error('模型接口返回了不受支持的内容类型')
const hasBody = response.body !== null
if (response.body)
request.reader = response.body.getReader()
const statusText = response.statusText.length <= 256 && !hasControlCharacter(response.statusText)
? response.statusText
: ''
postModelMessage(current, source, 'models-response', message.id, {
status: response.status,
statusText,
headers: modelResponseHeaders(response),
hasBody,
})
if (!hasBody) {
detachModelRequest(current, request)
return
}
armModelIdleTimeout(current, request)
}
catch (error) {
if (current.modelRequests.get(request.id) === request)
terminateModelRequest(current, request, errorMessage(error, '模型请求失败'))
}
}
async function handleModelPull(current: FrameSession, source: Window, message: BridgeMessage) {
const request = current.modelRequests.get(message.id)
if (!request || request.source !== source)
return
const pullId = isRecord(message.payload) ? message.payload.pullId : undefined
if (!Number.isSafeInteger(pullId) || Number(pullId) < 1) {
terminateModelRequest(current, request, '模型响应拉取序号无效')
return
}
if (request.reading) {
if (request.activePullId !== pullId)
terminateModelRequest(current, request, '模型响应拉取发生重入')
return
}
if (pullId !== request.nextPullId) {
terminateModelRequest(current, request, '模型响应拉取序号已失效')
return
}
clearTimeout(request.idleTimer)
request.idleTimer = undefined
request.reading = true
request.activePullId = Number(pullId)
try {
while (current.modelRequests.get(message.id) === request) {
if (!request.buffered || request.bufferedOffset >= request.buffered.byteLength) {
request.buffered = undefined
request.bufferedOffset = 0
const result = await request.reader?.read()
if (current.modelRequests.get(message.id) !== request)
return
if (!result || result.done) {
completeModelRequest(current, request, Number(pullId))
return
}
if (result.value.byteLength === 0)
continue
request.buffered = result.value
}
const end = Math.min(request.bufferedOffset + MAXIMUM_MODEL_CHUNK_BYTES, request.buffered.byteLength)
const chunk = request.buffered.slice(request.bufferedOffset, end)
request.bufferedOffset = end
if (request.bufferedOffset >= request.buffered.byteLength) {
request.buffered = undefined
request.bufferedOffset = 0
}
const body = chunk.buffer
request.nextPullId += 1
postModelMessage(current, source, 'models-chunk', message.id, { pullId, body }, undefined, [body])
armModelIdleTimeout(current, request)
return
}
}
catch (error) {
if (current.modelRequests.get(message.id) === request)
terminateModelRequest(current, request, errorMessage(error, '模型响应读取失败'))
}
finally {
request.reading = false
request.activePullId = undefined
}
}
async function handleBridgeMessage(event: MessageEvent) {
const current = session.value
const source = event.source
if (
!current
|| source !== options.iframe.value?.contentWindow
|| event.origin !== 'null'
|| !isBridgeMessage(event.data)
|| event.data.channel !== current.channel
|| event.data.session !== current.session
) {
return
}
const frame = source as Window
if (event.data.type === 'resize') {
const height = isRecord(event.data.payload) ? event.data.payload.height : undefined
if (typeof height === 'number' && Number.isSafeInteger(height) && height > 0 && height <= 1_000_000)
contentHeight.value = height
return
}
if (event.data.type === 'models-request') {
void handleModelRequest(current, frame, event.data)
return
}
if (event.data.type === 'models-pull') {
void handleModelPull(current, frame, event.data)
return
}
if (event.data.type === 'models-cancel') {
const request = current.modelRequests.get(event.data.id)
if (request?.source === frame)
terminateModelRequest(current, request)
return
}
if (!['request', 'callback-ticket'].includes(event.data.type))
return
if (current.requestIds.has(event.data.id) || current.modelRequests.has(event.data.id)) {
postResult(current, frame, event.data.id, false, undefined, '插件管理请求序号重复')
return
}
if (current.inFlight >= MAXIMUM_MANAGEMENT_IN_FLIGHT) {
postResult(current, frame, event.data.id, false, undefined, '插件管理请求已达并发上限')
return
}
current.inFlight += 1
current.requestIds.add(event.data.id)
try {
if (event.data.type === 'request')
await handleRouteRequest(current, frame, event.data)
else await handleCallbackTicket(current, frame, event.data)
}
catch (error) {
if (session.value !== current || current.controller.signal.aborted)
return
if (error instanceof ApiError && error.status === 409) {
loadError.value = '插件页面版本已变更,正在刷新'
options.onStale()
disposeSession()
return
}
postResult(current, frame, event.data.id, false, undefined, errorMessage(error))
}
finally {
current.requestIds.delete(event.data.id)
current.inFlight = Math.max(0, current.inFlight - 1)
}
}
function pushLayout() {
const height = options.viewportHeight()
const current = session.value
const frame = options.iframe.value
if (!current?.loaded || !frame?.contentWindow)
return
if (height === previousViewportHeight)
return
previousViewportHeight = height
frame.contentWindow.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'layout',
payload: { viewportHeight: height },
}, '*')
}
async function pushTheme() {
await nextTick()
const current = session.value
const target = options.iframe.value?.contentWindow
if (!current || !target)
return
target.postMessage({
target: PLUGIN_MANAGEMENT_BRIDGE,
version: PLUGIN_MANAGEMENT_BRIDGE_VERSION,
channel: current.channel,
session: current.session,
type: 'theme',
payload: readPluginFrameTheme(),
}, '*')
pushLayout()
}
useEventListener('message', handleBridgeMessage)
onScopeDispose(disposeSession)
watch(
[
() => options.view().target.instanceId,
() => options.view().target.artifactSha256,
() => options.view().target.revision,
() => options.page().id,
],
() => void loadPage(),
{ immediate: true },
)
const frameKey = computed(() => session.value?.channel)
return { srcdoc, loading, loadError, contentHeight, frameKey, loadPage, handleFrameLoad, pushLayout, pushTheme }
}
@@ -226,8 +226,7 @@ export function useProxies() {
async function reloadAfterCreate() {
search.value = ''
query.page.value = 1
await query.execute()
await query.reloadFromStart()
}
function requestDelete(proxy: OutboundProxyRecord) {
@@ -279,13 +278,11 @@ export function useProxies() {
}
function setPage(page: number) {
query.page.value = page
void query.execute()
void query.execute({ page })
}
function setPageSize(size: number) {
query.pageSize.value = size
setPage(1)
void query.execute({ page: 1, pageSize: size })
}
watch(() => form.proxyUrl, () => {
@@ -298,7 +295,7 @@ export function useProxies() {
watch(search, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
void setPage(1)
void query.reloadFromStart()
}, 300)
onCleanup(() => clearTimeout(timer))
})
@@ -25,22 +25,15 @@ export function useProxyAccounts(options: {
total: query.total.value,
}))
function load() {
if (options.isOpen() && options.proxyId())
void query.execute()
}
function setPage(page: number) {
if (!options.isOpen())
if (!options.isOpen() || !options.proxyId())
return
query.page.value = page
query.items.value = []
load()
void query.execute({ page })
}
function setPageSize(size: number) {
query.pageSize.value = size
setPage(1)
if (options.isOpen() && options.proxyId())
void query.execute({ page: 1, pageSize: size })
}
async function removeAccount(accountId: string) {
@@ -67,16 +60,19 @@ export function useProxyAccounts(options: {
query.invalidate({ resetLoading: options.isOpen() })
if (!options.isOpen())
return
query.items.value = []
query.total.value = 0
query.error.value = ''
query.page.value = 1
search.value = ''
load()
if (options.proxyId()) {
void query.reloadFromStart()
}
else {
query.items.value = []
query.total.value = 0
}
}, { immediate: true, flush: 'sync' })
watch(search, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
void setPage(1)
if (options.isOpen() && options.proxyId())
void query.reloadFromStart()
}, 300)
onCleanup(() => clearTimeout(timer))
})
@@ -101,14 +101,11 @@ export function useBackupRecords() {
}
async function changePage(page: number): Promise<void> {
paged.page.value = page
await paged.execute()
await paged.execute({ page })
}
async function changePageSize(pageSize: number): Promise<void> {
paged.pageSize.value = pageSize
paged.page.value = 1
await paged.execute()
await paged.execute({ page: 1, pageSize })
}
async function create(): Promise<void> {