mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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,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 }
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user