fix(frontend): support HTTP plugin pages and consolidate shared helpers

Reuse secure UUID generation for plugin frame channels and sessions on HTTP.
Consolidate utility modules and shorten the plugin views store name.
Declare route roles and default entries in metadata instead of guard mappings.
Keep source integration build output under node_modules/.vite.

Validated frontend lint, production and source builds, browser plugin flows,
and 20 route guard scenarios with simulated sessions and plugin resources.

Fixes #288

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-26 19:33:17 +08:00
co-authored by Codex
parent eb23a0fb3b
commit d1bf90d5fd
80 changed files with 270 additions and 255 deletions
-1
View File
@@ -6,7 +6,6 @@
# Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/frontend/.pnpm-store/
/frontend/.pnpm-cache/
*.tsbuildinfo
+1 -1
View File
@@ -48,7 +48,7 @@ pnpm --dir frontend build:source
pnpm --dir modules/plugins/examples/workbench/frontend build:source
```
`build:source` 用各项目的 `tsconfig.source.json` 检查源码类型,再构建到各自 `.vite/source-dist`,不改写正式依赖、锁文件或 `dist`。正常 `dev`、`build`、宿主发行 CI 和 Docker 仍消费锁定的 UI 包,无需初始化子模块。CI 另有源码联调检查,显式检出子模块、核对 UI 提交锁定并验证这两个命令。
`build:source` 用各项目的 `tsconfig.source.json` 检查源码类型,管理端构建到 `frontend/node_modules/.vite/source-dist`,示例构建到其前端项目的 `.vite/source-dist`,不改写正式依赖、锁文件或 `dist`。正常 `dev`、`build`、宿主发行 CI 和 Docker 仍消费锁定的 UI 包,无需初始化子模块。CI 另有源码联调检查,显式检出子模块、核对 UI 提交锁定并验证这两个命令。
### 本地 SDK 验证
@@ -3,11 +3,7 @@ import { BaseButton, BaseIconButton, BaseModal, BaseScrollbar, BaseSegmented, Ba
import { Apple, Copy, Monitor, Upload } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import { buildCodexCcSwitchImportDeeplink } from '@/utils/ccswitchImport'
import {
buildCodexConfigFiles,
CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
} from '@/utils/codexConfig'
import { buildCodexCcSwitchImportDeeplink, buildCodexConfigFiles, CODEX_WEBSOCKET_ENABLED_BY_DEFAULT } from '@/utils/client'
const props = withDefaults(defineProps<{
title?: string
+1 -1
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import { formatDateTime, formatRelativeTime } from '@/utils/date'
import { formatDateTime, formatRelativeTime } from '@/utils/format'
const props = defineProps<{
value: string | null
+1 -1
View File
@@ -3,7 +3,7 @@ import type { InputField } from './schema'
import { BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { Braces, Info, List } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef } from 'vue'
import { jsonObjectError, parseJsonObject } from '@/utils/jsonObject'
import { jsonObjectError, parseJsonObject } from '@/utils/data'
import { inputFields } from './schema'
const props = defineProps<{
+1 -1
View File
@@ -1,4 +1,4 @@
import { isRecord } from '@/utils/object'
import { isRecord } from '@/utils/data'
export interface InputField {
key: string
+1 -1
View File
@@ -1,4 +1,4 @@
import { isRecord } from '@/utils/object'
import { isRecord } from '@/utils/data'
interface ChartTooltipTheme {
surface: string
@@ -7,7 +7,7 @@ import type {
import { BaseButton, BaseSegmented } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getClientProfileOptions, previewClientProfile } from '@/api/modules/client-profiles'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
import ClientProfilePresetFields from './ClientProfilePresetFields.vue'
import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ClientProfilePreview } from '@/api/modules/client-profiles'
import { BaseSkeleton } from '@codex-proxy/ui'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
defineProps<{
preview?: Pick<ClientProfilePreview, 'userAgent' | 'versionSource' | 'checkedAt' | 'error'>
@@ -3,7 +3,7 @@ import type { XaiClientProfilePreview, XaiClientProfileSelection } from '@/api/m
import { BaseButton, BaseFormItem, BaseInput, BaseSegmented, BaseSelect } from '@codex-proxy/ui'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getXaiClientProfileOptions, previewXaiClientProfile } from '@/api/modules/client-profiles'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue'
const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), {
+1 -1
View File
@@ -2,7 +2,7 @@ import { toast } from '@codex-proxy/ui'
import { shallowRef } from 'vue'
import { ApiError } from '@/api/request'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { errorMessage, withMinimumDuration } from '@/utils/operation'
type MaybePromise<T> = T | Promise<T>
interface AsyncActionRunOptions {
+1 -1
View File
@@ -1,7 +1,7 @@
import { toast } from '@codex-proxy/ui'
import { useClipboard } from '@vueuse/core'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
interface CopyTextOptions {
successText: string
+1 -1
View File
@@ -1,7 +1,7 @@
import { onScopeDispose, shallowRef } from 'vue'
import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
export function useRequestState(onError?: (error: unknown) => void) {
const loading = shallowRef(false)
@@ -31,7 +31,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId,
import { useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { usePluginManagementViewsStore } from '@/stores/modules/plugin-management-views'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useThemeStore } from '@/stores/modules/theme'
import { pluginPageLocation, shortPluginInstanceId } from '@/views/plugins/utils/navigation'
@@ -56,7 +56,7 @@ const emit = defineEmits<{
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const pluginViewsStore = usePluginManagementViewsStore()
const pluginViewsStore = usePluginViewsStore()
const systemUpdateStore = useSystemUpdateStore()
const themeStore = useThemeStore()
const { pageViews: pluginPageViews } = storeToRefs(pluginViewsStore)
@@ -15,8 +15,8 @@ import {
import { storeToRefs } from 'pinia'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
import { errorMessage } from '@/utils/async'
import { formatTime } from '@/utils/date'
import { formatTime } from '@/utils/format'
import { errorMessage } from '@/utils/operation'
import {
resolveSystemUpdateLogClasses,
resolveSystemUpdatePresentation,
+24 -10
View File
@@ -1,3 +1,4 @@
import type { AuthSession } from '@/api'
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/modules/auth'
@@ -8,17 +9,32 @@ export const router = createRouter({
routes,
})
function resolveRoleEntry(role: AuthSession['role']) {
const entry = router.getRoutes().find(route => route.meta.role === role && route.meta.defaultEntry)
if (!entry)
throw new Error(`未配置 ${role} 身份的默认入口`)
return { path: entry.path }
}
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// 登录页不依赖会话恢复;只使用当前已知身份决定是否跳转。
if (to.name === 'login') {
if (authStore.isAuthenticated)
return { name: authStore.isAdmin ? 'dashboard' : 'key-usage' }
if (to.meta.guestOnly) {
if (authStore.session)
return resolveRoleEntry(authStore.session.role)
return
}
const login = { name: 'login', query: { redirect: to.fullPath } }
const requiredRole = to.meta.role
if (!requiredRole)
return
const login = {
name: 'login',
query: { redirect: to.fullPath },
state: { loginMode: requiredRole },
}
if (!authStore.sessionChecked) {
try {
@@ -30,12 +46,10 @@ router.beforeEach(async (to) => {
}
}
if (!authStore.isAuthenticated)
return { ...login, state: { loginMode: to.name === 'key-usage' ? 'key' : 'admin' } }
if (!authStore.session)
return login
// 两种身份各自进入独立页面,Key 不挂载会请求管理接口的布局。
if (!authStore.isAdmin && to.name !== 'key-usage')
return { name: 'key-usage' }
if (authStore.isAdmin && to.name === 'key-usage')
return { name: 'dashboard' }
if (authStore.session.role !== requiredRole)
return resolveRoleEntry(authStore.session.role)
})
+12
View File
@@ -1,18 +1,30 @@
import type { RouteRecordRaw } from 'vue-router'
import type { AuthSession } from '@/api'
declare module 'vue-router' {
interface RouteMeta {
role?: AuthSession['role']
defaultEntry?: boolean
guestOnly?: boolean
}
}
export const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
meta: { guestOnly: true },
component: () => import('@/views/login/index.vue'),
},
{
path: '/key-usage',
name: 'key-usage',
meta: { role: 'key', defaultEntry: true },
component: () => import('@/views/key-usage/index.vue'),
},
{
path: '/',
meta: { role: 'admin', defaultEntry: true },
component: () => import('@/layout/index.vue'),
children: [
{
@@ -5,9 +5,9 @@ import { computed, shallowRef, watch } from 'vue'
import { getPluginManagementViews } from '@/api'
import { ApiError } from '@/api/request'
import { useAuthStore } from '@/stores/modules/auth'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
export const usePluginManagementViewsStore = defineStore('plugin-management-views', () => {
export const usePluginViewsStore = defineStore('plugin-views', () => {
const authStore = useAuthStore()
const views = shallowRef<PluginManagementView[]>([])
const loading = shallowRef(false)
@@ -88,4 +88,4 @@ export const usePluginManagementViewsStore = defineStore('plugin-management-view
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(usePluginManagementViewsStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(usePluginViewsStore, import.meta.hot))
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
const maxUpdateLogs = 200
const updateEventReadyTimeoutMs = 3_000
@@ -1,8 +1,49 @@
import {
buildCodexConfigFiles,
CODEX_DEFAULT_MODEL,
CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
} from './codexConfig.ts'
import { API_BASE_URL } from '@/api/constants'
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
export const CODEX_WEBSOCKET_ENABLED_BY_DEFAULT = false
export interface CodexConfigInput {
apiKey: string
baseUrl: string
websocketEnabled?: boolean
}
export function buildCodexConfigFiles(input: CodexConfigInput) {
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const websocketEnabled = input.websocketEnabled ?? CODEX_WEBSOCKET_ENABLED_BY_DEFAULT
// 保留 auth.json 载荷,兼容仍依赖该字段的 CCSwitch 导入器。
const auth = { OPENAI_API_KEY: input.apiKey }
const configToml = `model_provider = "OpenAI"
model = "${CODEX_DEFAULT_MODEL}"
review_model = "${CODEX_DEFAULT_MODEL}"
model_reasoning_effort = "max"
service_tier = "default"
[model_providers.OpenAI]
name = "OpenAI"
base_url = ${JSON.stringify(baseUrl)}
wire_api = "responses"
supports_websockets = ${websocketEnabled}
requires_openai_auth = false
# 代理密钥仅用于网关鉴权,真实账号登录状态由服务端管理。
experimental_bearer_token = ${JSON.stringify(input.apiKey)}
[model_providers.OpenAI.http_headers]
# 声明服务端托管认证,让官方客户端启用原生生图;该标记不是密钥。
X-OpenAI-Actor-Authorization = "proxy-managed"
[features]
image_generation = true
goals = true`
return {
auth,
authJson: JSON.stringify(auth, null, 2),
baseUrl,
configToml,
}
}
export interface CodexCcSwitchImportInput {
apiKey: string
@@ -80,3 +121,16 @@ function encodeBase64(value: string) {
binary += String.fromCharCode(byte)
return btoa(binary)
}
export function resolveServiceRootUrl() {
const normalizedApiBase = API_BASE_URL.trim().replace(/\/+$/, '')
if (/^https?:\/\//i.test(normalizedApiBase))
return normalizedApiBase
if (typeof window === 'undefined')
return normalizedApiBase
const origin = window.location.origin.replace(/\/+$/, '')
if (!normalizedApiBase)
return origin
return `${origin}${normalizedApiBase.startsWith('/') ? normalizedApiBase : `/${normalizedApiBase}`}`
}
-44
View File
@@ -1,44 +0,0 @@
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
export const CODEX_WEBSOCKET_ENABLED_BY_DEFAULT = false
export interface CodexConfigInput {
apiKey: string
baseUrl: string
websocketEnabled?: boolean
}
export function buildCodexConfigFiles(input: CodexConfigInput) {
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const websocketEnabled = input.websocketEnabled ?? CODEX_WEBSOCKET_ENABLED_BY_DEFAULT
// 保留 auth.json 载荷,兼容仍依赖该字段的 CCSwitch 导入器。
const auth = { OPENAI_API_KEY: input.apiKey }
const configToml = `model_provider = "OpenAI"
model = "${CODEX_DEFAULT_MODEL}"
review_model = "${CODEX_DEFAULT_MODEL}"
model_reasoning_effort = "max"
service_tier = "default"
[model_providers.OpenAI]
name = "OpenAI"
base_url = ${JSON.stringify(baseUrl)}
wire_api = "responses"
supports_websockets = ${websocketEnabled}
requires_openai_auth = false
# 代理密钥仅用于网关鉴权,真实账号登录状态由服务端管理。
experimental_bearer_token = ${JSON.stringify(input.apiKey)}
[model_providers.OpenAI.http_headers]
# 声明服务端托管认证,让官方客户端启用原生生图;该标记不是密钥。
X-OpenAI-Actor-Authorization = "proxy-managed"
[features]
image_generation = true
goals = true`
return {
auth,
authJson: JSON.stringify(auth, null, 2),
baseUrl,
configToml,
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { RequestLocation } from '@/api'
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseJsonObject(text: string, maximumBytes?: number): Record<string, unknown> {
let value: unknown
try {
value = JSON.parse(text)
}
catch {
throw new Error('请输入有效的 JSON 对象')
}
if (!isRecord(value))
throw new Error('输入必须是 JSON 对象')
if (maximumBytes !== undefined && new TextEncoder().encode(JSON.stringify(value)).byteLength > maximumBytes)
throw new Error(`输入不能超过 ${maximumBytes / 1024} KiB`)
return value
}
export function jsonObjectError(text: string, maximumBytes?: number) {
try {
parseJsonObject(text, maximumBytes)
return undefined
}
catch (error) {
return (error as Error).message
}
}
export function normalizeRequestLocation(location: RequestLocation): RequestLocation {
return {
country: location.country.trim().toUpperCase(),
region: location.region.trim(),
city: location.city.trim(),
timezone: location.timezone.trim(),
}
}
export function requestLocationError(location: RequestLocation): string {
if (!/^[A-Z]{2}$/.test(location.country) || !location.region || !location.city || !location.timezone)
return '请填写两位国家代码、地区、城市和 IANA 时区'
return ''
}
@@ -53,3 +53,43 @@ export function formatRelativeTime(
function normalizedDate(value: ConfigType) {
return dayjs(typeof value === 'string' ? value.replace(' ', 'T') : value)
}
const integerFormatter = new Intl.NumberFormat('zh-CN')
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const metricUnits = [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const
export function formatInteger(value: number) {
return integerFormatter.format(value)
}
export function formatLocalizedCompactNumber(value: number) {
return localizedCompactFormatter.format(Number.isFinite(value) ? value : 0)
}
export function formatCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return formatInteger(normalized)
for (const [unit, threshold] of metricUnits) {
if (normalized < threshold)
continue
const scaled = normalized / threshold
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
return `${rounded.replace(/\.?0+$/, '')}${unit}`
}
return formatInteger(normalized)
}
-26
View File
@@ -1,26 +0,0 @@
import { isRecord } from '@/utils/object'
export function parseJsonObject(text: string, maximumBytes?: number): Record<string, unknown> {
let value: unknown
try {
value = JSON.parse(text)
}
catch {
throw new Error('请输入有效的 JSON 对象')
}
if (!isRecord(value))
throw new Error('输入必须是 JSON 对象')
if (maximumBytes !== undefined && new TextEncoder().encode(JSON.stringify(value)).byteLength > maximumBytes)
throw new Error(`输入不能超过 ${maximumBytes / 1024} KiB`)
return value
}
export function jsonObjectError(text: string, maximumBytes?: number) {
try {
parseJsonObject(text, maximumBytes)
return undefined
}
catch (error) {
return (error as Error).message
}
}
-39
View File
@@ -1,39 +0,0 @@
const integerFormatter = new Intl.NumberFormat('zh-CN')
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const metricUnits = [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const
export function formatInteger(value: number) {
return integerFormatter.format(value)
}
export function formatLocalizedCompactNumber(value: number) {
return localizedCompactFormatter.format(Number.isFinite(value) ? value : 0)
}
export function formatCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return formatInteger(normalized)
for (const [unit, threshold] of metricUnits) {
if (normalized < threshold)
continue
const scaled = normalized / threshold
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
return `${rounded.replace(/\.?0+$/, '')}${unit}`
}
return formatInteger(normalized)
}
-3
View File
@@ -1,3 +0,0 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -27,3 +27,12 @@ export async function withMinimumDuration<T>(
}
}
}
export function generateRequestId() {
// 普通 HTTP 管理端没有 randomUUID;getRandomValues 仍可生成密码学安全的 UUIDv4。
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
-16
View File
@@ -1,16 +0,0 @@
import type { RequestLocation } from '@/api'
export function normalizeRequestLocation(location: RequestLocation): RequestLocation {
return {
country: location.country.trim().toUpperCase(),
region: location.region.trim(),
city: location.city.trim(),
timezone: location.timezone.trim(),
}
}
export function requestLocationError(location: RequestLocation): string {
if (!/^[A-Z]{2}$/.test(location.country) || !location.region || !location.city || !location.timezone)
return '请填写两位国家代码、地区、城市和 IANA 时区'
return ''
}
-14
View File
@@ -1,14 +0,0 @@
import { API_BASE_URL } from '@/api/constants'
export function resolveServiceRootUrl() {
const normalizedApiBase = API_BASE_URL.trim().replace(/\/+$/, '')
if (/^https?:\/\//i.test(normalizedApiBase))
return normalizedApiBase
if (typeof window === 'undefined')
return normalizedApiBase
const origin = window.location.origin.replace(/\/+$/, '')
if (!normalizedApiBase)
return origin
return `${origin}${normalizedApiBase.startsWith('/') ? normalizedApiBase : `/${normalizedApiBase}`}`
}
-8
View File
@@ -1,8 +0,0 @@
export function generateRequestId() {
// 普通 HTTP 管理端没有 randomUUID;getRandomValues 仍可生成密码学安全的 UUIDv4。
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
@@ -4,7 +4,7 @@ import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import { computed } from 'vue'
import { formatInteger } from '@/utils/number'
import { formatInteger } from '@/utils/format'
const props = defineProps<{
summary: Awaited<ReturnType<typeof getAccounts>>['summary']
@@ -3,7 +3,7 @@ import type { AccountProfileActivityInsights, AccountProfileInvocation } from '@
import { Package } from '@lucide/vue'
import { computed } from 'vue'
import { formatInteger } from '@/utils/number'
import { formatInteger } from '@/utils/format'
const props = defineProps<{
insights: AccountProfileActivityInsights
@@ -2,7 +2,7 @@
import type { AccountProfileStatisticsResponse } from '@/api'
import { BaseSkeleton } from '@codex-proxy/ui'
import { computed } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatCompactNumber, formatInteger } from '@/utils/format'
const props = defineProps<{
profile: AccountProfileStatisticsResponse | null
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { AccountSubscription } from '@/api'
import { computed } from 'vue'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
const props = defineProps<{
subscription: AccountSubscription
@@ -2,7 +2,7 @@ import type { Component } from 'vue'
import type { AccountErrorReason, AccountStatus } from '@/api'
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
import { formatDateTime, parseTimestamp } from '@/utils/date'
import { formatDateTime, parseTimestamp } from '@/utils/format'
import { errorReasonLabels, statusLabels, statusTones } from '../../constants'
export type AccountStatusDisplayMode = AccountStatus | 'refresh_backoff'
@@ -1,7 +1,7 @@
import type { AccountQuotaWindow } from '../../constants'
import { clamp } from 'es-toolkit'
import { formatInteger } from '@/utils/number'
import { isRecord } from '@/utils/object'
import { isRecord } from '@/utils/data'
import { formatInteger } from '@/utils/format'
export type AccountUsageWindowVariant = 'compact' | 'detail' | 'metric'
@@ -1,7 +1,7 @@
import type { AccountImportSettings, AccountOAuthStartResponse } from '@/api'
import { computed, onScopeDispose, shallowRef } from 'vue'
import { completeAccountOAuth, startAccountOAuth } from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
type Status = 'idle' | 'starting' | 'waiting' | 'paused' | 'expired' | 'completing'
@@ -9,8 +9,8 @@ import { getAccountModels, refreshAccountModels } from '@/api'
import { API_BASE_URL } from '@/api/constants'
import { useIdSet } from '@/composables/useIdSet'
import { useRequestState } from '@/composables/useRequestState'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { formatDateTime, formatTime } from '@/utils/date'
import { formatDateTime, formatTime } from '@/utils/format'
import { errorMessage, withMinimumDuration } from '@/utils/operation'
interface ConnectionTestRun {
accountId: string
@@ -2,7 +2,7 @@ import type { AccountImportTask, AccountImportTaskDetail } from '@/api'
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountImportTask, getAccountImportTasks, stopAccountImportTask } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
export function useAccountImportTasks(options: { reload: () => Promise<unknown> }) {
const open = shallowRef(false)
@@ -16,7 +16,7 @@ import {
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
import { useIdSet } from '@/composables/useIdSet'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { errorMessage, withMinimumDuration } from '@/utils/operation'
import { isSupportedProvider } from '@/utils/providers'
import { useAccountOnboarding } from './useAccountOnboarding'
@@ -3,9 +3,8 @@ import { toast } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { createAccountImportTask, importAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { errorMessage, generateRequestId } from '@/utils/operation'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { generateRequestId } from '@/utils/uuid'
import { accountCreateProvider, accountCreateSourceKey, accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../components/AccountCreateModal/model'
import { accountImportModes } from '../components/AccountCreateModal/presenter'
import { accountImportDocuments, MAX_ACCOUNT_IMPORT_COUNT, mixedImportDocuments } from '../utils/accountImport'
@@ -7,8 +7,7 @@ import {
getAccountResetCredits,
} from '@/api'
import { ApiError } from '@/api/request'
import { errorMessage } from '@/utils/async'
import { generateRequestId } from '@/utils/uuid'
import { errorMessage, generateRequestId } from '@/utils/operation'
interface PendingResetCreditOperation {
accountId: string
@@ -1,4 +1,4 @@
import { isRecord } from '@/utils/object'
import { isRecord } from '@/utils/data'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
export const MAX_ACCOUNT_IMPORT_COUNT = 200
@@ -1,5 +1,5 @@
import type { AccountProfileDailyUsage } from '@/api'
import { formatCompactNumber } from '@/utils/number'
import { formatCompactNumber } from '@/utils/format'
export type ProfileActivityMode = 'daily' | 'weekly' | 'cumulative'
export type ProfileActivityLevel = 0 | 1 | 2 | 3 | 4
@@ -4,7 +4,7 @@ import { BaseCard, BaseEmpty } from '@codex-proxy/ui'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import { formatCompactNumber } from '@/utils/number'
import { formatCompactNumber } from '@/utils/format'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow/index.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
@@ -3,7 +3,7 @@ import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { Box, CheckCircle2, Monitor, RefreshCw, ShieldCheck, Terminal, TriangleAlert } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
import { formatProviderLabel, isSupportedProvider, providerIcon } from '@/utils/providers'
interface WireProfile {
@@ -4,9 +4,8 @@ import { useIntervalFn } from '@vueuse/core'
import { computed, onMounted, onScopeDispose, shallowRef } from 'vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatCompactNumber, formatDateTime, formatInteger } from '@/utils/format'
import { errorMessage, withMinimumDuration } from '@/utils/operation'
export function useDashboard() {
const activeTrendKind = shallowRef(normalizeDashboardTrendKind('usage'))
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MetricTone } from './composables/useDashboard'
import { formatInteger } from '@/utils/number'
import { formatInteger } from '@/utils/format'
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
export const metricToneIconClasses: Record<MetricTone, string> = {
@@ -15,8 +15,8 @@ import {
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useIdSet } from '@/composables/useIdSet'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
import { errorMessage } from '@/utils/operation'
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../constants'
export interface AccountGroupFormValue {
@@ -3,7 +3,7 @@ import type { KeyUsageMetrics } from '@/api/modules/key-usage'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { Gauge, Zap } from '@lucide/vue'
import { computed } from 'vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatCompactNumber, formatInteger } from '@/utils/format'
import AnimatedMetricValue from '@/views/dashboard/components/AnimatedMetricValue.vue'
import { money } from '../utils/format'
import { keyUsageTokenMetrics, keyUsageTokenValue } from '../utils/metrics'
@@ -6,7 +6,7 @@ import { computed } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { chartTooltipStyle } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatCompactNumber, formatInteger } from '@/utils/number'
import { formatCompactNumber, formatInteger } from '@/utils/format'
import { keyUsageTime, money } from '../utils/format'
import { keyUsageTokenMetrics, keyUsageTokenValue } from '../utils/metrics'
@@ -2,7 +2,7 @@ import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getKeyUsageConfig } from '@/api/modules/key-usage'
import { useCopyText } from '@/composables/useCopyText'
import { useRequestState } from '@/composables/useRequestState'
import { resolveServiceRootUrl } from '@/utils/serviceUrl'
import { resolveServiceRootUrl } from '@/utils/client'
export function useKeyConfig() {
const showConfig = shallowRef(false)
+1 -1
View File
@@ -1,4 +1,4 @@
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
export const KEY_USAGE_TIME_ZONE = 'Asia/Shanghai'
@@ -2,7 +2,7 @@
import type { ApiKey } from '@/api'
import { BasePopover } from '@codex-proxy/ui'
import { computed } from 'vue'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
const props = defineProps<{ apiKey: ApiKey }>()
const windows = computed(() => [
@@ -2,8 +2,7 @@ import type { Ref, ShallowRef } from 'vue'
import type { getApiKeys } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { buildCodexCcSwitchImportDeeplink } from '@/utils/ccswitchImport'
import { resolveServiceRootUrl } from '@/utils/serviceUrl'
import { buildCodexCcSwitchImportDeeplink, resolveServiceRootUrl } from '@/utils/client'
// “使用密钥”弹窗展示明文时,在列表行上补挂 reveal 得到的完整 key。
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number] & { key?: string }
@@ -4,7 +4,7 @@ import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, shallowRef } from 'vue'
import { getApiKeys } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
export function useApiKeysQuery() {
const searchQuery = shallowRef('')
@@ -5,7 +5,7 @@ import { BaseEmpty, BaseFormItem, BaseInput, BaseTag } from '@codex-proxy/ui'
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import SchemaForm from '@/components/SchemaForm/index.vue'
import { jsonObjectError, parseJsonObject } from '@/utils/jsonObject'
import { jsonObjectError, parseJsonObject } from '@/utils/data'
import PluginHelpPopover from './PluginHelpPopover.vue'
const props = withDefaults(defineProps<{
@@ -12,7 +12,7 @@ import { BaseButton, BaseFormItem, BaseIconButton, BaseInput, BaseSegmented, Bas
import { Plus, RefreshCw, Trash2 } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { getApiKeys } from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
import PluginHelpPopover from './PluginHelpPopover.vue'
const props = withDefaults(defineProps<{
@@ -15,7 +15,7 @@ import {
import { API_BASE_URL } from '@/api/constants'
import { ApiError } from '@/api/request'
import { useThemeStore } from '@/stores/modules/theme'
import { errorMessage } from '@/utils/async'
import { errorMessage, generateRequestId } from '@/utils/operation'
import {
assemblePluginManagementPage,
MAXIMUM_MANAGEMENT_BODY_BYTES,
@@ -131,8 +131,8 @@ async function loadPage() {
loading.value = true
loadError.value = ''
const controller = new AbortController()
const channel = crypto.randomUUID()
const sessionId = crypto.randomUUID()
const channel = generateRequestId()
const sessionId = generateRequestId()
try {
await nextTick()
if (sequence !== loadSequence)
@@ -4,14 +4,14 @@ import { Blocks, CircleAlert, RefreshCw } from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed, onMounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePluginManagementViewsStore } from '@/stores/modules/plugin-management-views'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { pluginPageLocation } from '../utils/navigation'
import PluginManagementFrame from './PluginManagementFrame.vue'
import PluginPageLoading from './PluginPageLoading.vue'
const route = useRoute()
const router = useRouter()
const directory = usePluginManagementViewsStore()
const directory = usePluginViewsStore()
const { pageViews, loading, loaded, loadError } = storeToRefs(directory)
const frameReload = shallowRef(0)
@@ -3,7 +3,7 @@ import { BaseButton, BaseEmpty, BaseSelect, toast } from '@codex-proxy/ui'
import { CircleAlert, LoaderCircle } from '@lucide/vue'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountGroups, getApiKeys } from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
const props = withDefaults(defineProps<{ kind: 'keys' | 'groups', disabled?: boolean, maxCollapseTags?: number }>(), { maxCollapseTags: 1 })
const selected = defineModel<string[]>({ required: true })
@@ -32,8 +32,8 @@ import {
updatePluginInstance,
} from '@/api'
import { ApiError } from '@/api/request'
import { usePluginManagementViewsStore } from '@/stores/modules/plugin-management-views'
import { errorMessage } from '@/utils/async'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { errorMessage } from '@/utils/operation'
import { configurationStatus, currentPluginInstance, groupInstalledPlugins } from '../utils/catalog'
import { usePluginInstallation } from './usePluginInstallation'
import { usePluginUninstall } from './usePluginUninstall'
@@ -44,7 +44,7 @@ export function usePluginManagement() {
const instances = shallowRef<PluginInstance[]>([])
const sources = shallowRef<PluginUpdateSourceBinding[]>([])
const credentials = shallowRef<PluginSourceCredential[]>([])
const extensionDirectory = usePluginManagementViewsStore()
const extensionDirectory = usePluginViewsStore()
const { views: extensions } = storeToRefs(extensionDirectory)
const loading = shallowRef(false)
const catalog = computed(() => groupInstalledPlugins(artifacts.value, instances.value, sources.value))
+2 -2
View File
@@ -7,8 +7,8 @@ import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { createProxy, deleteProxy, getProxies, probeProxy, testProxy, updateProxy } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { formatDateTime } from '@/utils/date'
import { normalizeRequestLocation, requestLocationError } from '@/utils/request-location'
import { normalizeRequestLocation, requestLocationError } from '@/utils/data'
import { formatDateTime } from '@/utils/format'
import ProxyAccountsModal from './components/ProxyAccountsModal.vue'
import ProxyFormModal from './components/ProxyFormModal.vue'
import { effectiveProxyLocation } from './utils/location'
@@ -3,7 +3,7 @@ import type { BackupRecord } from '@/api'
import { BaseButton, BaseCard, BaseConfirmModal, BaseIconButton, BaseTable, BaseTablePagination, defineTableColumns } from '@codex-proxy/ui'
import { Download, Play, RefreshCw, Trash2 } from '@lucide/vue'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
import BackupStatusBadge from './BackupStatusBadge.vue'
@@ -3,7 +3,7 @@ import type { ClientDownloadPackage, CodexDesktopWindowsDownloads } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseSkeleton } from '@codex-proxy/ui'
import { ArrowDownToLine, PackageOpen, RefreshCw } from '@lucide/vue'
import { formatDateTime } from '@/utils/date'
import { formatDateTime } from '@/utils/format'
import ClientInstallCommandList from './ClientInstallCommandList.vue'
@@ -2,7 +2,7 @@ import type { CodexDesktopWindowsDownloads } from '@/api'
import { shallowRef } from 'vue'
import { getCodexDesktopWindowsDownloads } from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
export function useClientDownloads() {
const open = shallowRef(false)
@@ -10,7 +10,7 @@ import {
} from '@/api'
import { ApiError } from '@/api/request'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
const ACTIVE_STATUSES: BackupStatus[] = ['queued', 'dumping', 'uploading']
const POLL_INTERVAL_MS = 2000
@@ -8,7 +8,7 @@ import {
updateBackupSchedule,
updateBackupStorage,
} from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
/** 存储与计划配置共用的加载/保存/测试 composable。 */
export function useBackupSettings() {
@@ -8,8 +8,8 @@ import { computed, reactive, ref, shallowRef } from 'vue'
import { getSettings, updateSettings } from '@/api'
import { ApiError } from '@/api/request'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { normalizeRequestLocation, requestLocationError } from '@/utils/request-location'
import { normalizeRequestLocation, requestLocationError } from '@/utils/data'
import { errorMessage } from '@/utils/operation'
type RotationStrategy = (typeof rotationOptions)[number]['value']
@@ -3,7 +3,7 @@ import { toast } from '@codex-proxy/ui'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import { getPricing, previewPricingSync, syncPricing, updatePricing } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
import { pricingProviders, pricingRows } from './model'
export function usePricing() {
@@ -10,7 +10,7 @@ import {
zeroInactiveValues,
} from '@/components/charts/timeSeriesGap'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import {
tooltipIndex,
@@ -4,7 +4,7 @@ import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } fro
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
@@ -8,7 +8,7 @@ import { use } from 'echarts/core'
import { computed } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import {
tooltipIndex,
@@ -7,7 +7,7 @@ import { computed, shallowRef } from 'vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
import {
tooltipIndex,
@@ -5,7 +5,7 @@ import { watchDebounced } from '@vueuse/core'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { withMinimumDuration } from '@/utils/async'
import { withMinimumDuration } from '@/utils/operation'
interface UseOpsErrorsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
@@ -1,7 +1,7 @@
import type { UsageRecordDetail } from '@/api'
import { shallowRef, watch } from 'vue'
import { getUsageRecordDetail } from '@/api'
import { errorMessage } from '@/utils/async'
import { errorMessage } from '@/utils/operation'
/** Requests can be switched while a lookup is pending; stale results never replace the active trace. */
export function useRequestDiagnostics(requestId: () => string) {
@@ -10,7 +10,7 @@ import {
getUsageRecords,
getUsageRecordSummary,
} from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { withMinimumDuration } from '@/utils/operation'
interface UseUsageRecordsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
+1 -1
View File
@@ -1,4 +1,4 @@
import { formatLocalizedCompactNumber } from '@/utils/number'
import { formatLocalizedCompactNumber } from '@/utils/format'
import { formatProviderLabel } from '@/utils/providers'
const percentFormatter = new Intl.NumberFormat('zh-CN', {
+1 -1
View File
@@ -11,7 +11,7 @@ import type {
UsageTokenDetails,
} from '@/api'
import { isRecord } from '@/utils/object'
import { isRecord } from '@/utils/data'
import { formatDuration } from './format'
// Usage 记录的规范化 view model:组件只消费这个形状。
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
},
},
build: {
outDir: sourceUi ? '.vite/source-dist' : 'dist',
outDir: sourceUi ? 'node_modules/.vite/source-dist' : 'dist',
assetsDir: 'assets',
chunkSizeWarningLimit: 600,
rolldownOptions: {