Compare commits

...
5 Commits
Author SHA1 Message Date
zyycnandCodex 0879515ee8 chore: bump version to 3.0.3
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:59:19 +08:00
zyycnandCodex be40783454 fix(ui): restore dashboard and mobile interactions
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 16:58:08 +08:00
zyycnandCodex 946b9d005d chore: bump version to 3.0.2
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:58:47 +08:00
zyycn f181d54ac3 feat(accounts): support mixed CPR batch import
按导出文档中的 provider 分流批量导入 OpenAI 与 xAI 账号。\n\nCo-authored-by: Codex <noreply@openai.com>
2026-07-25 03:56:57 +08:00
zyycnandCodex d1a875c19a fix(update): allow GitHub release asset redirects
Co-authored-by: Codex <noreply@openai.com>
2026-07-25 03:43:19 +08:00
13 changed files with 377 additions and 48 deletions
@@ -39,6 +39,8 @@ use self::state::{
};
use self::swap::{replace_release_files, rollback_release};
pub use self::release::validate_download_url;
const APP_BINARY_NAME: &str = "codex-proxy-rs";
const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist";
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos";
@@ -177,7 +177,8 @@ pub(crate) fn select_archive<'a>(
})
}
pub(crate) fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
/// 校验 GitHub Release 资产 URL 及其重定向目标是否位于受信任边界内。
pub fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> {
let url = reqwest::Url::parse(raw)
.map_err(|error| invalid(format!("invalid download URL: {error}")))?;
if local_download_allowed(&url, api_base) {
@@ -294,6 +295,7 @@ fn github_download_host_allowed(host: &str) -> bool {
|| host.ends_with(".github.com")
|| host == "objects.githubusercontent.com"
|| host.ends_with(".objects.githubusercontent.com")
|| host == "release-assets.githubusercontent.com"
}
fn local_download_allowed(url: &reqwest::Url, api_base: &str) -> bool {
@@ -10,7 +10,9 @@ use gateway_admin::model::system::{
};
use gateway_admin::ports::system::{SystemOperationErrorKind, SystemOperations};
use gateway_core::engine::CancellationToken;
use gateway_host::system_update::{ProcessSystemOperations, SystemUpdateConfig};
use gateway_host::system_update::{
ProcessSystemOperations, SystemUpdateConfig, validate_download_url,
};
use sha2::{Digest as _, Sha256};
use tar::{Builder, EntryType, Header};
use wiremock::matchers::{method, path};
@@ -280,6 +282,17 @@ async fn update_should_reject_release_archive_from_untrusted_host() {
);
}
#[test]
fn update_should_trust_github_release_asset_redirect_host() {
assert!(
validate_download_url(
"https://release-assets.githubusercontent.com/github-production-release-asset/archive",
"https://api.github.com/repos",
)
.is_ok()
);
}
#[tokio::test]
async fn update_should_reject_release_archive_with_unsafe_path() {
let server = MockServer::start().await;
@@ -23,7 +23,8 @@ const MAX_PAGE_SIZE: u16 = 100;
const MAX_FILTER_BYTES: usize = 256;
const MAX_SEARCH_BYTES: usize = 512;
const MAX_ACCOUNT_IDS: usize = 200;
const DASHBOARD_ACCOUNT_LIMIT: u16 = 50;
/// 概览卡只展示最近使用的四个账号;完整账号用量由账号管理页单独查询。
const DASHBOARD_ACCOUNT_LIMIT: u16 = 4;
const DIAGNOSTIC_LIMIT: i64 = 100;
const ACCOUNT_USAGE_TIMELINE_HOURS: i64 = 24;
@@ -6,7 +6,7 @@ defineProps<{
</script>
<template>
<header class="flex min-h-17 shrink-0 items-start justify-between gap-4">
<header class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
{{ title }}
@@ -20,7 +20,10 @@ defineProps<{
</slot>
</p>
</div>
<div v-if="$slots.actions" class="mt-0.5 flex shrink-0 items-center gap-2">
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center gap-2 max-sm:w-full max-sm:justify-end"
>
<slot name="actions" />
</div>
</header>
@@ -0,0 +1,240 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { Menu } from '@lucide/vue'
import {
useDraggable,
useElementSize,
useScreenSafeArea,
useStorage,
useWindowSize,
} from '@vueuse/core'
import {
computed,
nextTick,
onMounted,
onScopeDispose,
shallowRef,
useTemplateRef,
watch,
} from 'vue'
interface StoredPosition {
initialized: boolean
side: 'left' | 'right'
x: number
y: number
}
const emit = defineEmits<{
open: []
}>()
const edgeGap = 12
const defaultButtonSize = 44
const dragClickThreshold = 6
const storageKey = 'codex-proxy:mobile-sidebar-toggle-position'
const toggleEl = useTemplateRef<HTMLElement>('toggleEl')
const { width: viewportWidth, height: viewportHeight } = useWindowSize({
initialWidth: 390,
initialHeight: 760,
type: 'visual',
})
const { width: toggleWidth, height: toggleHeight } = useElementSize(toggleEl, {
width: defaultButtonSize,
height: defaultButtonSize,
})
const safeArea = useScreenSafeArea()
const storedPosition = useStorage<StoredPosition>(
storageKey,
{
initialized: false,
side: 'left',
x: edgeGap,
y: 136,
},
undefined,
{ mergeDefaults: true, shallow: true },
)
const pointerStart = shallowRef({ x: edgeGap, y: 136 })
const ignoreNextClick = shallowRef(false)
const positionReady = shallowRef(false)
let ignoreClickTimer: number | undefined
const bounds = computed(() => {
const buttonWidth = positiveNumber(toggleWidth.value, defaultButtonSize)
const buttonHeight = positiveNumber(toggleHeight.value, defaultButtonSize)
const windowWidth = positiveNumber(viewportWidth.value, 390)
const windowHeight = positiveNumber(viewportHeight.value, 760)
const minX = parseCssPixel(safeArea.left.value) + edgeGap
const maxX = Math.max(
minX,
windowWidth - parseCssPixel(safeArea.right.value) - edgeGap - buttonWidth,
)
const minY = parseCssPixel(safeArea.top.value) + edgeGap
const maxY = Math.max(
minY,
windowHeight - parseCssPixel(safeArea.bottom.value) - edgeGap - buttonHeight,
)
return { minX, maxX, minY, maxY }
})
const { x, y, isDragging } = useDraggable(toggleEl, {
initialValue: {
x: storedPosition.value.x,
y: storedPosition.value.y,
},
preventDefault: false,
stopPropagation: false,
onEnd(position) {
applyPosition(snapPosition(position.x, position.y))
},
})
const toggleStyle = computed<CSSProperties>(() => ({
transform: `translate3d(${x.value}px, ${y.value}px, 0)`,
touchAction: 'none',
}))
function handlePointerDown(event: PointerEvent) {
pointerStart.value = { x: event.clientX, y: event.clientY }
const target = event.currentTarget
if (target instanceof HTMLElement)
target.setPointerCapture?.(event.pointerId)
}
function handlePointerUp(event: PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement)
target.releasePointerCapture?.(event.pointerId)
const moved = Math.hypot(
event.clientX - pointerStart.value.x,
event.clientY - pointerStart.value.y,
)
ignoreNextClick.value = true
window.clearTimeout(ignoreClickTimer)
ignoreClickTimer = window.setTimeout(() => {
ignoreNextClick.value = false
}, 320)
if (moved <= dragClickThreshold) {
emit('open')
}
}
function handleClick() {
if (isDragging.value || ignoreNextClick.value) {
ignoreNextClick.value = false
return
}
emit('open')
}
function defaultPosition() {
const targetY = Math.round(positiveNumber(viewportHeight.value, 760) * 0.36)
return snapPosition(bounds.value.minX, targetY)
}
function restorePosition() {
if (!storedPosition.value.initialized) {
applyPosition(defaultPosition())
return
}
const sideX = storedPosition.value.side === 'right' ? bounds.value.maxX : bounds.value.minX
applyPosition({
initialized: true,
side: storedPosition.value.side,
x: sideX,
y: storedPosition.value.y,
})
}
function snapPosition(rawX: number, rawY: number) {
const { minX, maxX, minY, maxY } = bounds.value
const side = rawX <= (minX + maxX) / 2 ? 'left' : 'right'
return {
initialized: true,
side,
x: side === 'left' ? minX : maxX,
y: clamp(rawY, minY, maxY),
} satisfies StoredPosition
}
function applyPosition(position: StoredPosition) {
const { minX, maxX, minY, maxY } = bounds.value
const next = {
initialized: true,
side: position.side,
x: clamp(position.x, minX, maxX),
y: clamp(position.y, minY, maxY),
} satisfies StoredPosition
x.value = next.x
y.value = next.y
storedPosition.value = next
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function positiveNumber(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback
}
function parseCssPixel(value: string) {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
}
onMounted(async () => {
await nextTick()
safeArea.update()
restorePosition()
positionReady.value = true
})
watch(
bounds,
() => {
if (!positionReady.value)
return
restorePosition()
},
{ flush: 'post' },
)
onScopeDispose(() => {
window.clearTimeout(ignoreClickTimer)
})
</script>
<template>
<div
ref="toggleEl"
class="fixed top-0 left-0 z-40 min-[961px]:hidden"
:class="positionReady ? 'opacity-100' : 'opacity-0'"
:style="toggleStyle"
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-(--cp-icon-button-radius) border-0 bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:scale-95 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-page)"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
: 'cursor-grab'
"
aria-label="打开侧边栏"
@pointerdown="handlePointerDown"
@pointerup="handlePointerUp"
@click="handleClick"
>
<Menu class="size-5" />
</button>
</div>
</template>
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { Menu } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
const emit = defineEmits<{
open: []
}>()
</script>
<template>
<div
class="sticky top-0 z-40 -mx-4 -mt-4 mb-4 flex h-15 items-center bg-(--cp-bg-page)/96 px-4 backdrop-blur-sm min-[961px]:hidden"
>
<BaseButton
icon-only
size="md"
variant="default"
label="打开侧边栏"
class="shadow-[0_12px_28px_-20px_var(--cp-shadow-sticky)]"
@click="emit('open')"
>
<Menu class="size-5" />
</BaseButton>
</div>
</template>
+2 -2
View File
@@ -9,7 +9,7 @@ import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import MobileSidebarToolbar from './components/MobileSidebarToolbar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal.vue'
const uiStore = useUiStore()
@@ -77,12 +77,12 @@ watch(
@open-about="aboutOpen = true"
@open-system-update="openSystemUpdate"
/>
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<main class="h-dvh min-w-0 flex-1 overflow-hidden">
<BaseScrollbar
ref="pageScrollbarRef"
view-class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6"
>
<MobileSidebarToolbar v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
@@ -2,7 +2,7 @@
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
import type { AccountRow } from '../constants'
import { Openai, Xai } from '@boxicons/vue'
import { Copy, KeyRound, Upload } from '@lucide/vue'
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
import { useClipboard, useFileDialog } from '@vueuse/core'
import { computed, ref } from 'vue'
@@ -51,7 +51,7 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
})
const modeOptions = computed(() => accountProviderModeOptions(form.value.provider))
const isProviderSelected = computed(() => form.value.provider === 'openai' || form.value.provider === 'xai')
const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider))
const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value)
const provider = computed({
@@ -91,10 +91,17 @@ const oauthCallback = computed({
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
const isXai = computed(() => form.value.provider === 'xai')
const importFileLabel = computed(() => mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件')
const isBatch = computed(() => form.value.provider === 'batch')
const importFileLabel = computed(() => {
if (isBatch.value)
return '批量账号文件'
return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件'
})
const importFilePlaceholder = computed(() => mode.value === 'agent_identity'
? '粘贴 Agent 身份文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
: isBatch.value
? '粘贴 CPR 多平台导出文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
const accountName = computed(() => {
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
@@ -140,6 +147,8 @@ const description = computed<string | undefined>(() => {
return undefined
if (props.reauthorizing)
return '完成授权后粘贴回调地址,系统会更新账号凭据'
if (isBatch.value)
return '导入 CPR 多平台账号包,系统会按文档中的平台自动分流'
if (isXai.value) {
return mode.value === 'oauth'
? '复制 xAI 授权链接,完成后粘贴回调地址,不使用 xAI API Key'
@@ -152,7 +161,7 @@ const description = computed<string | undefined>(() => {
return '导入 CPR、Sub2API 或 CPA 账号文件,已存在账号会更新'
})
function selectProvider(value: 'openai' | 'xai') {
function selectProvider(value: 'openai' | 'xai' | 'batch') {
provider.value = value
}
@@ -198,7 +207,8 @@ async function copyText(value: string, successText: string) {
:hide-footer="isChoosingProvider"
>
<template #icon>
<Xai v-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<LayoutGrid v-if="isBatch" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="isXai" class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
<Openai v-else class="text-(--cp-text-primary)" aria-hidden="true" :width="20" :height="20" />
</template>
@@ -209,7 +219,12 @@ async function copyText(value: string, successText: string) {
/>
<div v-else class="flex flex-col gap-4">
<BaseSegmented v-if="!reauthorizing" v-model="mode" :options="modeOptions" class="w-full" />
<BaseSegmented
v-if="!reauthorizing && !isBatch"
v-model="mode"
:options="modeOptions"
class="w-full"
/>
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
@@ -316,7 +331,7 @@ async function copyText(value: string, successText: string) {
:disabled="!canSubmit"
@click="emit('create')"
>
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : '导入' }}
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }}
</BaseButton>
</template>
</BaseModal>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
withDefaults(
defineProps<{
@@ -11,10 +12,15 @@ withDefaults(
)
const emit = defineEmits<{
select: [provider: 'openai' | 'xai']
select: [provider: 'openai' | 'xai' | 'batch']
}>()
const providers = [
{
value: 'batch' as const,
label: '批量导入',
icon: LayoutGrid,
},
{
value: 'openai' as const,
label: 'OpenAI',
@@ -136,6 +136,7 @@ const requestBars = computed(() => {
}
})
})
const hasDailyRequests = computed(() => requestBars.value.some(bar => bar.requestCount > 0))
</script>
<template>
@@ -197,6 +198,7 @@ const requestBars = computed(() => {
</strong>
</div>
<div
v-if="hasDailyRequests"
class="flex items-stretch gap-px"
:class="[trackShapeClass, !isCompact ? 'mt-2' : 'mt-auto']"
role="img"
@@ -215,6 +217,11 @@ const requestBars = computed(() => {
/>
</span>
</div>
<span
v-else
class="block font-mono tabular-nums text-(--cp-text-muted)"
:class="[valueClass, !isCompact ? 'mt-2' : 'mt-auto']"
>—</span>
</template>
<div v-else :class="headerClass">
@@ -11,6 +11,12 @@ import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type ImportProvider = 'openai' | 'xai'
interface MixedImportDocument {
provider: ImportProvider
document: Record<string, unknown>
}
export function useAccountOnboarding(options: {
reload: () => Promise<unknown>
@@ -46,10 +52,10 @@ export function useAccountOnboarding(options: {
await creatingAccountAction.run(
async () => {
await importAccountDocument()
await finishCreate(
createForm.value.provider === 'xai' ? 'xAI OAuth 账号已导入' : 'OpenAI 账号已导入',
)
const message = createForm.value.provider === 'batch'
? await importMixedAccountDocument()
: await importAccountDocument()
await finishCreate(message)
},
{ errorText: '导入失败' },
)
@@ -170,6 +176,41 @@ export function useAccountOnboarding(options: {
data,
}))
commitConfigRevision(result.configRevision)
return createForm.value.provider === 'xai' ? 'xAI 账号已导入' : 'OpenAI 账号已导入'
}
async function importMixedAccountDocument() {
const documents = parseMixedImportDocuments(parseImportJson(createForm.value.importText))
let importedCount = 0
const failures: string[] = []
for (const entry of documents) {
try {
const expectedConfigRevision = await requireConfigRevision()
const result = await withConflictRefresh(() => importAccounts({
provider: entry.provider,
expectedConfigRevision,
data: entry.document,
}))
commitConfigRevision(result.configRevision)
importedCount += result.importedCount
}
catch (error) {
const message = error instanceof Error && error.message
? error.message
: '导入失败'
failures.push(`${entry.provider === 'xai' ? 'xAI' : 'OpenAI'}:${message}`)
}
}
if (importedCount === 0) {
throw new Error(failures.length > 0 ? `批量导入失败:${failures.join(';')}` : '批量文件没有可导入的账号')
}
if (failures.length > 0) {
return `已导入 ${importedCount} 个账号,${failures.length} 个文档失败:${failures.join(';')}`
}
return `已导入 ${importedCount} 个账号`
}
async function finishCreate(message: string) {
@@ -195,7 +236,7 @@ export function useAccountOnboarding(options: {
() => {
createForm.value = {
...createForm.value,
mode: 'oauth',
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
@@ -255,3 +296,28 @@ function parseImportJson(value: string) {
throw new Error('JSON 格式不正确')
}
}
function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
if (!isRecord(value) || !Array.isArray(value.documents))
throw new Error('批量导入文件必须是 CPR 多平台导出文件')
const documents: MixedImportDocument[] = []
for (const entry of value.documents) {
if (!isRecord(entry))
throw new Error('批量导入文件包含无效的 Provider 文档')
const provider = entry.provider
if (provider !== 'openai' && provider !== 'xai')
throw new Error('批量导入文件包含无效的 Provider 文档')
if (!isRecord(entry.document))
throw new Error('批量导入文件包含无效的 Provider 文档')
documents.push({ provider, document: entry.document })
}
if (documents.length === 0)
throw new Error('批量文件没有可导入的账号文档')
return documents
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+1 -1
View File
@@ -1 +1 @@
version: "3.0.1"
version: "3.0.3"