feat(proxies): test account egress and assign accounts in bulk (#607)

* feat(proxies): test account egress and assign accounts in bulk

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine proxy controls and quota display

Keep egress results with their proxy options and refresh stale proxy revisions before retrying. Compact account assignment and settings controls, and hide missing quota refresh timestamps.

Co-authored-by: Codex <noreply@openai.com>

* fix(proxies): reset account selection without closing dialog

Co-authored-by: Codex <noreply@openai.com>

* fix(proxies): clarify assignment reset and unavailable accounts

Use a red reset button and disabled checkboxes for accounts already using the target proxy.

Co-authored-by: Codex <noreply@openai.com>

* feat(proxies): show account details in assignment dialog

Add platform, subscription, and group columns, remove inline provider icons, and use the existing subtle danger style for reset.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
This commit is contained in:
Husan API
2026-11-04 22:42:16 +08:00
committed by GitHub
co-authored by Codex Zyy
parent bdfc201795
commit f5c5a6e5b7
23 changed files with 611 additions and 46 deletions
@@ -537,14 +537,10 @@ async fn probe<S>(
where
S: SessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.probe(Some(&proxy), request.detect_location)
.probe(request.proxy_url.0.as_ref(), request.detect_location)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
@@ -354,9 +354,12 @@ impl ProxyProbe for SuccessfulProbe {
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
} else {
assert!(detect_location);
}
let exit_ip = if proxy.is_some() {
"203.0.113.2"
} else {
"203.0.113.3"
};
ProxyTestResult {
location: if detect_location {
ProxyLocationDetection::Detected { location: serde_json::from_value(json!({"country":"JP","region":"Tokyo","city":"Tokyo","timezone":"Asia/Tokyo"})).unwrap() }
@@ -365,8 +368,8 @@ impl ProxyProbe for SuccessfulProbe {
},
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
exit_ipv4: Some("203.0.113.2".parse().unwrap()),
exit_ip: Some(exit_ip.parse().unwrap()),
exit_ipv4: Some(exit_ip.parse().unwrap()),
exit_ipv6: None,
message: "Connected".to_owned(),
}
@@ -567,11 +570,50 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records
}
#[tokio::test]
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
async fn proxy_probe_checks_direct_egress_without_changing_saved_proxies() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (_, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!([{"name": "Saved", "proxyUrl": "http://saved.example:8080"}])),
true,
)
.await;
for detect_location in [false, true] {
let (status, probed) = request(
&fixture,
"/api/admin/proxies/probe",
Some(json!({"proxyUrl": "", "detectLocation": detect_location})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(probed["data"]["success"], true);
assert_eq!(probed["data"]["exitIpv4"], "203.0.113.3");
assert_eq!(
probed["data"]["location"]["status"],
if detect_location {
"detected"
} else {
"notRequested"
}
);
}
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 1);
assert_eq!(
listed["data"]["items"][0],
created["data"][0]["data"]["record"]
);
}
#[tokio::test]
async fn proxy_probe_rejects_missing_or_invalid_addresses() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
(json!({"proxyUrl": " "}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
(
+7
View File
@@ -430,6 +430,13 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
“修改未添加项”只带回明确未添加的条目;连接测试失败的代理已经保存,可在列表中重新测试。
请求中断或结果无法确认时暂停后续提交,添加结果待确认的条目可能已保存,应先刷新列表核对,不能直接重复添加
创建或编辑账号时,在“出站隧道”选择直连或已保存的代理,点击右侧的“测试出口”图标。
耗时显示在对应选项右侧,悬浮可查看 IPv4 / IPv6,测试失败通过通知提示。
禁用代理的账号使用服务器直连出口。出口测试不保存账号设置,也不验证账号权限或额度;账号可用性使用账号连接测试
在代理列表点击关联账号数量,再点击“分配账号”,搜索并跨页勾选账号后点击右上角的“分配”,一次最多 1000 个。
已使用此代理的账号不可重复选择;所选账号原有代理会被替换,凭据、分组与调度设置保留。禁用代理须先启用
### 插件命令行
使用与网关相同的工作目录和 `deploy/config.yaml`,读取制品已接受且当前已启用的插件实例:
+4 -1
View File
@@ -1257,10 +1257,13 @@ Search 的选号限制不约束插件在选号后主动改写正文。
| `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` |
| `POST` | `/api/admin/proxies/create` | `[{ name, proxyUrl, location?, autoLocation?, maxWebsocketConnections? }]` | 逐项结果数组,成功项的 `data` 为 `{ record, configRevision }` |
| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, enabled?, proxyUrl?, location?, autoLocation?, maxWebsocketConnections? }` | `{ record, configRevision }` |
| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址,返回连通性及可选位置结果,不创建代理 |
| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址或服务器直连出口,返回连通性及可选位置结果,不创建代理 |
| `POST` | `/api/admin/proxies/test` | `[{ id, revision, detectLocation? }]` | 逐项结果数组,成功项的 `data` 为最新代理记录 |
| `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` |
`probe` 的 `proxyUrl` 必填,空字符串表示服务器直连;非空值须为合法代理 URL,`null`、省略或非法地址均拒绝。
探测不创建代理或修改账号绑定
添加与测试统一接收 1–100 项数组,单条操作传一个元素,响应 `data` 按输入顺序返回等长数组。
每项为 `{ status: "succeeded", data }`、`{ status: "failed", httpStatus, code, message, data: null }`
或 `{ status: "skipped" }`。整个请求的 JSON、数组形状、数量或重复测试 ID 校验失败时返回 4xx,不执行任何项;
+2 -1
View File
@@ -187,11 +187,12 @@ export function testProxy(data: ProxyTestParam[], options: RequestOptions = {})
})
}
export function probeProxy(data: ProxyProbeParam) {
export function probeProxy(data: ProxyProbeParam, options: RequestOptions = {}) {
return request<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
+6 -1
View File
@@ -232,7 +232,12 @@
"disabledSuffix": "(disabled)",
"testFailed": "(test failed)",
"notTested": "(not tested)",
"outboundTunnel": "Outbound tunnel"
"outboundTunnel": "Outbound tunnel",
"testExit": "Test egress",
"selectToTest": "Select a direct connection or managed proxy to test egress",
"disabledUsesDirect": "The proxy is disabled, so the test uses the server’s direct connection",
"connected": "Egress connected · {latencyMs} ms",
"testIncomplete": "No test result received, run the test again"
},
"scheduling": {
"preserve": "Keep unchanged",
+13
View File
@@ -161,6 +161,19 @@
"disableDescription": "New requests from linked accounts will use direct connections, proxy settings and bindings are kept and resume when reenabled",
"delete": "Delete “{name}”?",
"deleteDescription": "Linked accounts will be unlinked and new requests will use direct connections"
},
"assignment": {
"title": "Assign accounts",
"currentProxy": "Current proxy",
"noAccounts": "No accounts available",
"searchAccounts": "Search accounts to assign",
"selectPage": "Select eligible accounts on this page",
"selectAccount": "Select account {name}",
"alreadyAssigned": "Already using this proxy",
"selectionLimit": "Assign up to {count} accounts at a time",
"enableFirst": "Enable this proxy before assigning accounts",
"assignAction": "Assign ({count})",
"assigned": "Assigned {count} account to “{name}” | Assigned {count} accounts to “{name}”"
}
}
}
@@ -232,7 +232,12 @@
"disabledSuffix": "(已禁用)",
"testFailed": "(测试失败)",
"notTested": "(未测试)",
"outboundTunnel": "出站隧道"
"outboundTunnel": "出站隧道",
"testExit": "测试出口",
"selectToTest": "选择直连或受管理的代理后可测试出口",
"disabledUsesDirect": "代理已禁用,将测试服务器直连出口",
"connected": "出口连通 · {latencyMs} ms",
"testIncomplete": "未取得测试结果,请重新测试"
},
"scheduling": {
"scheduling": "调度",
@@ -161,6 +161,19 @@
"disableDescription": "关联账号的新请求将改为直连,代理配置和绑定会保留,重新启用后恢复使用",
"delete": "确定删除“{name}”吗?",
"deleteDescription": "将自动解除关联账号,关联账号的新请求将改为直连"
},
"assignment": {
"title": "分配账号",
"currentProxy": "当前代理",
"noAccounts": "暂无账号",
"searchAccounts": "搜索待分配账号",
"selectPage": "选择当前页可分配账号",
"selectAccount": "选择账号 {name}",
"alreadyAssigned": "已使用此代理",
"selectionLimit": "一次最多分配 {count} 个账号",
"enableFirst": "启用此代理后可分配账号",
"assignAction": "分配 ({count})",
"assigned": "已将 {count} 个账号分配至“{name}”"
}
}
}
@@ -232,7 +232,12 @@
"disabledSuffix": "(已禁用)",
"testFailed": "(測試失敗)",
"notTested": "(未測試)",
"outboundTunnel": "出站隧道"
"outboundTunnel": "出站隧道",
"testExit": "測試出口",
"selectToTest": "選擇直連或受管理的代理後可測試出口",
"disabledUsesDirect": "代理已禁用,將測試服務器直連出口",
"connected": "出口連通 · {latencyMs} ms",
"testIncomplete": "未取得測試結果,請重新測試"
},
"scheduling": {
"preserve": "保留",
@@ -161,6 +161,19 @@
"disableDescription": "關聯賬號的新請求將改爲直連,代理配置和綁定會保留,重新啓用後恢復使用",
"delete": "確定刪除“{name}”嗎?",
"deleteDescription": "將自動解除關聯賬號,關聯賬號的新請求將改爲直連"
},
"assignment": {
"title": "分配賬號",
"currentProxy": "當前代理",
"noAccounts": "暫無賬號",
"searchAccounts": "搜索待分配賬號",
"selectPage": "選擇當前頁可分配賬號",
"selectAccount": "選擇賬號 {name}",
"alreadyAssigned": "已使用此代理",
"selectionLimit": "一次最多分配 {count} 個賬號",
"enableFirst": "啟用此代理後可分配賬號",
"assignAction": "分配 ({count})",
"assigned": "已將 {count} 個賬號分配至“{name}”"
}
}
}
@@ -95,7 +95,7 @@ const schedulingMode = computed({
v-model:proxy-id="proxyId"
preserve-model-access
:account-id="catalogAccountId"
:model-account-id="catalogAccountId"
:groups="groups"
:groups-loading="groupsLoading"
:disabled="saving"
@@ -105,10 +105,6 @@ async function requestSave() {
</div>
</div>
<ZFormItem v-if="isOpenAiOAuthAccount(account)" :label="t('accounts.editor.transportLabel')">
<ZSegmented v-model="oauthTransport" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
</ZFormItem>
<AccountApiKeyFields v-if="isOpenAiApiKeyAccount(account)" v-model="apiKey" editing :disabled="saving || !configurationReady" />
<AccountSettingsFields
@@ -128,7 +124,13 @@ async function requestSave() {
:current-proxy-id="account.outboundProxyId"
:endpoint="account.outboundProxyEndpoint"
:account-id="account.id"
/>
>
<template v-if="isOpenAiOAuthAccount(account)" #name-extra>
<ZFormItem :label="t('accounts.editor.transportLabel')">
<ZSegmented v-model="oauthTransport" class="w-full" :aria-label="t('accounts.editor.transportLabel')" :options="transportOptions" :disabled="saving || !configurationReady" />
</ZFormItem>
</template>
</AccountSettingsFields>
</ZForm>
<template #footer>
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { ZFormItem, ZSelect } from '@codex-proxy/ui'
import type { OutboundProxyTest } from '@/api'
import { ZFormItem, ZIconButton, ZSelect } from '@codex-proxy/ui'
import { Wifi } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProxyCatalog } from '@/composables/useProxyCatalog'
import { useAccountProxyTest } from '../composables/useAccountProxyTest'
import AccountProxyTestBadge from './AccountProxyTestBadge.vue'
const props = withDefaults(defineProps<{ accountId?: string, currentProxyId?: string | null, endpoint?: string | null, disabled?: boolean, preserve?: boolean, formProp: string }>(), { preserve: true })
@@ -11,7 +15,7 @@ const { t } = useI18n({ useScope: 'global' })
const mode = defineModel<string>('mode', { required: true })
const proxyId = defineModel<string>('proxyId', { required: true })
const { proxies, loading } = useProxyCatalog()
const { proxies, loading, loadProxies } = useProxyCatalog()
// 按绑定 ID 识别代理,避免相同脱敏地址下的不同认证配置被误认
const currentSelection = computed(() => props.currentProxyId || (props.endpoint ? 'preserve' : 'direct'))
const options = computed(() => [
@@ -68,10 +72,68 @@ const selection = computed({
: ''
},
})
const selectedProxy = computed(() => proxies.value.find(proxy => proxy.id === selection.value))
// 目录刷新只禁用测试入口,不改变目标,避免清除版本冲突提示
const testTarget = computed(() => {
if (props.disabled)
return null
// 禁用代理的账号实际走直连,不把该代理自身的测试结果当成账号出口
if (selection.value === 'direct' || selectedProxy.value?.enabled === false)
return 'direct' as const
return selectedProxy.value ?? null
})
const { result, directResult, testing, test } = useAccountProxyTest({
target: () => testTarget.value,
accountId: () => props.accountId,
onConflict: loadProxies,
onTested: (record) => {
proxies.value = proxies.value.map(proxy => proxy.id === record.id
? record
: proxy)
},
})
// 代理复用目录中的最近测试,直连结果只保留在当前账号的编辑过程
const optionTests = computed<Record<string, OutboundProxyTest | null>>(() => ({
...Object.fromEntries(proxies.value.map(proxy => [proxy.id, proxy.enabled ? proxy.lastTest : null])),
direct: directResult.value,
}))
const selectedTest = computed(() => result.value ?? (testTarget.value === 'direct'
? directResult.value
: optionTests.value[selection.value]))
const testHint = computed(() => selectedProxy.value?.enabled === false
? t('accounts.proxy.disabledUsesDirect')
: selection.value !== 'direct' && !selectedProxy.value
? t('accounts.proxy.selectToTest')
: null)
</script>
<template>
<ZFormItem :prop="formProp" :show-message="false" :label="t('accounts.proxy.outboundTunnel')">
<ZSelect v-model="selection" class="w-full" :options="options" :disabled="disabled || loading" :aria-label="t('accounts.proxy.outboundTunnel')" />
<div class="grid w-full min-w-0 gap-1.5">
<div class="flex min-w-0 gap-2">
<div class="relative min-w-0 flex-1">
<!-- 为框内测试结果预留空间,避免遮挡代理文本和下拉箭头 -->
<ZSelect v-model="selection" class="w-full" :class="{ '[&_[role=combobox]]:pr-28': selectedTest?.success }" :options="options" :disabled="disabled || loading" :aria-label="t('accounts.proxy.outboundTunnel')">
<template #option="{ option, selected }">
<span class="min-w-0 truncate" :class="option.description ? 'max-w-1/2 shrink-0' : 'flex-1'">{{ option.label }}</span>
<span v-if="option.description" class="min-w-0 flex-1 truncate font-normal" :class="option.disabled ? 'text-z-text-disabled' : 'text-z-text-tertiary'" :title="option.description">
{{ option.description }}
</span>
<AccountProxyTestBadge class="ml-auto" :result="optionTests[option.value]" in-option />
<span v-if="!selected" class="size-[15px] shrink-0" aria-hidden="true" />
</template>
</ZSelect>
<div v-if="selectedTest?.success" class="absolute inset-y-0 right-9 flex items-center">
<AccountProxyTestBadge :result="selectedTest" />
</div>
</div>
<ZIconButton class="shrink-0" variant="solid" :disabled="testing || loading || !testTarget" :aria-label="t('accounts.proxy.testExit')" @click="test">
<Wifi class="size-4" />
</ZIconButton>
</div>
<p v-if="testHint" class="m-0 text-z-xs text-z-text-secondary">
{{ testHint }}
</p>
</div>
</ZFormItem>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { OutboundProxyTest } from '@/api'
import { ZButton, ZPopover } from '@codex-proxy/ui'
import { Wifi } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
defineProps<{ result?: OutboundProxyTest | null, inOption?: boolean }>()
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<ZPopover v-if="result?.success" class="shrink-0" :trigger="inOption ? 'hover' : 'hover-click'" placement="top-end">
<template #reference="{ open, id }">
<!-- 下拉项本身是按钮,内部仅展示状态,避免嵌套按钮干扰选中操作 -->
<span v-if="inOption" class="inline-flex items-center gap-1 text-z-xs whitespace-nowrap text-z-success-text tabular-nums" :aria-describedby="open ? id : undefined">
<Wifi class="size-3.5" aria-hidden="true" />
{{ result.latencyMs }} ms
</span>
<ZButton v-else type="success" variant="link" size="small" class="gap-1! px-0! no-underline! tabular-nums" :aria-label="t('accounts.proxy.connected', { latencyMs: result.latencyMs })" :aria-expanded="open" :aria-controls="open ? id : undefined">
<template #icon>
<Wifi class="size-3.5" aria-hidden="true" />
</template>
{{ result.latencyMs }} ms
</ZButton>
</template>
<div class="grid max-w-80 gap-1 px-3 py-2 font-mono text-z-xs break-all">
<span v-if="result.exitIpv4">IPv4 · {{ result.exitIpv4 }}</span>
<span v-if="result.exitIpv6">IPv6 · {{ result.exitIpv6 }}</span>
<span v-if="!result.exitIpv4 && !result.exitIpv6">IP · {{ result.exitIp || t('common.values.unknown') }}</span>
</div>
</ZPopover>
</template>
@@ -15,6 +15,7 @@ withDefaults(defineProps<{
currentProxyId?: string | null
endpoint?: string | null
accountId?: string
modelAccountId?: string
preserveProxy?: boolean
preserveModelAccess?: boolean
showScheduling?: boolean
@@ -37,7 +38,11 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
<template>
<div class="grid gap-4.5">
<AccountModelAccessField v-model="modelAccess" form-prop="modelAccess" :account-id="accountId" :disabled="disabled" :allow-preserve="preserveModelAccess" />
<div v-if="name !== undefined" class="grid min-w-0 gap-4.5" :class="{ 'sm:grid-cols-2': $slots['name-extra'] }">
<AccountNameField v-model="name" :disabled="disabled" :placeholder="namePlaceholder" />
<slot name="name-extra" />
</div>
<AccountModelAccessField v-model="modelAccess" form-prop="modelAccess" :account-id="modelAccountId ?? accountId" :disabled="disabled" :allow-preserve="preserveModelAccess" />
<slot name="scheduling">
<div v-if="showScheduling" class="flex min-h-6 items-center justify-between gap-3">
<span class="text-z leading-none font-medium text-z-text-secondary">{{ t('accounts.scheduling.scheduling') }}</span>
@@ -83,7 +88,6 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
/>
</ZFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" form-prop="proxyId" :preserve="preserveProxy" :current-proxy-id="currentProxyId" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
<AccountNameField v-if="name !== undefined" v-model="name" :disabled="disabled" :placeholder="namePlaceholder" />
<ZFormItem v-if="notes !== undefined" :label="t('common.fields.notes')">
<ZTextarea v-model="notes" :rows="3" :maxlength="500" :placeholder="notesPlaceholder ?? t('accounts.editor.notesHelp')" :disabled="disabled" />
</ZFormItem>
@@ -47,13 +47,15 @@ const hasForecast = computed(() => props.account.authenticationKind !== 'api_key
const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription)
const canRefresh = computed(() => props.account.capabilities.quotaRefresh || props.account.upstream != null)
const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || canRefresh.value || props.account.capabilities.resetCredits)
const lastRefreshed = computed(() => props.account.upstream?.discovered && !props.account.upstream.observedAt
? undefined
: t('accounts.quota.lastRefreshed', {
refreshedAtDisplay: formatRelativeTime(props.account.upstream?.discovered
? props.account.upstream.observedAtRelative
: props.account.quota.refreshedAtRelative),
}))
const lastRefreshed = computed(() => {
const { upstream, quota } = props.account
if (upstream?.discovered && !upstream.observedAt)
return undefined
const refreshedAt = upstream?.discovered ? upstream.observedAtRelative : quota.refreshedAtRelative
return refreshedAt
? t('accounts.quota.lastRefreshed', { refreshedAtDisplay: formatRelativeTime(refreshedAt) })
: undefined
})
const refreshTitle = computed(() => props.account.upstream?.stale && props.account.upstream.observedAt
? `${props.account.upstream.observedAtDisplay} · ${t('accounts.upstream.stale')}`
: lastRefreshed.value)
@@ -70,7 +72,7 @@ watch(hasPersonalInfo, (available) => {
{{ account.upstream?.discovered ? t('accounts.upstream.quota') : t('accounts.quota.accountLimit') }}
</h3>
<p
v-if="account.capabilities.quota || quotaEntries.length > 0 || account.upstream?.discovered"
v-if="(account.capabilities.quota || quotaEntries.length > 0 || account.upstream?.discovered) && (hasPlanBadge || lastRefreshed)"
class="col-span-2 row-start-2 m-0 flex min-w-0 items-center gap-1.5 text-z-xs font-emphasis text-z-text-secondary"
>
<AccountPlanBadge v-if="hasPlanBadge" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" class="shrink-0" />
@@ -0,0 +1,81 @@
import type { OutboundProxyRecord, OutboundProxyTest } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { probeProxy, testProxy } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
import { t } from '@/i18n'
import { notification as ZNotification } from '@/utils/notification'
import { errorMessage } from '@/utils/operation'
export function useAccountProxyTest(options: {
target: () => OutboundProxyRecord | 'direct' | null
accountId: () => string | undefined
onTested: (proxy: OutboundProxyRecord) => void
onConflict: () => Promise<void>
}) {
const request = useRequestState(cause => ZNotification.error({ message: errorMessage(cause) }))
const result = shallowRef<OutboundProxyTest | null>(null)
const directResult = shallowRef<OutboundProxyTest | null>(null)
const targetId = computed(() => {
const target = options.target()
return typeof target === 'object'
? target?.id
: target
})
watch([targetId, options.accountId], ([, accountId], [, previousAccountId]) => {
request.invalidate()
result.value = null
if (accountId !== previousAccountId)
directResult.value = null
request.error.value = ''
}, { flush: 'sync' })
async function test() {
const target = options.target()
if (!target || request.loading.value)
return
const requestId = request.start()
result.value = null
const requestOptions = { signal: request.signal, silent: true }
try {
if (target === 'direct') {
const response = await probeProxy({ proxyUrl: '', detectLocation: false }, requestOptions)
if (request.isCurrent(requestId)) {
result.value = response
directResult.value = response
}
}
else {
const [response] = await testProxy([
{ id: target.id, revision: target.revision, detectLocation: false },
], requestOptions)
if (!request.isCurrent(requestId))
return
if (response?.status !== 'succeeded') {
if (response?.status === 'failed' && response.httpStatus === 409)
await options.onConflict()
throw new Error(response?.status === 'failed'
? response.message
: t('accounts.proxy.testIncomplete'))
}
options.onTested(response.data)
result.value = response.data.lastTest
}
if (result.value && !result.value.success)
request.fail(requestId, new Error(result.value.message || t('proxies.actions.testFailed')))
}
catch (error) {
request.fail(requestId, error)
}
finally {
request.finish(requestId)
}
}
return {
result,
directResult,
testing: request.loading,
test,
}
}
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { OutboundProxyAccount, OutboundProxyRecord } from '@/api'
import { defineTableColumns, ZConfirmDialog, ZDialog, ZIconButton, ZInput, ZPagination, ZTable } from '@codex-proxy/ui'
import { Search, Unlink } from '@lucide/vue'
import { defineTableColumns, ZButton, ZConfirmDialog, ZDialog, ZIconButton, ZInput, ZPagination, ZTable } from '@codex-proxy/ui'
import { Plus, Search, Unlink } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
@@ -10,23 +10,25 @@ import AccountGroupMarks from '@/components/AccountGroupMarks.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { stablePresetVisualToneClass } from '@/utils/color'
import { useProxyAccounts } from '../composables/useProxyAccounts'
import ProxyAssignAccountsModal from './ProxyAssignAccountsModal.vue'
const props = defineProps<{
proxy: OutboundProxyRecord | null
}>()
const emit = defineEmits<{ removed: [] }>()
const emit = defineEmits<{ changed: [] }>()
const { t } = useI18n({ useScope: 'global' })
const open = defineModel<boolean>({ required: true })
const pendingRemove = shallowRef<OutboundProxyAccount | null>(null)
const showRemove = shallowRef(false)
const showAssign = shallowRef(false)
const removeProxyName = shallowRef('')
const { accounts, loading, error, pagination, search, setPage, setPageSize, removing, removeAccount } = useProxyAccounts({
const { accounts, loading, error, pagination, search, setPage, setPageSize, removing, removeAccount, reloadAccounts } = useProxyAccounts({
isOpen: () => open.value,
proxyId: () => props.proxy?.id,
onRemoved: () => emit('removed'),
onRemoved: () => emit('changed'),
})
const columns = computed(() => (defineTableColumns<OutboundProxyAccount>([
{
@@ -99,22 +101,39 @@ function requestRemove(account: OutboundProxyAccount) {
showRemove.value = true
}
function onAssigned() {
emit('changed')
void reloadAccounts()
}
watch([
open,
() => props.proxy?.id,
], () => {
showRemove.value = false
showAssign.value = false
})
</script>
<template>
<ZDialog v-model="open" :title="t('proxies.accounts.linkAccount')" :description="description" width="48rem" :show-close="!removing" :close-on-click-modal="!removing" :close-on-press-escape="!removing">
<div class="flex min-h-0 flex-col">
<ZInput v-model="search" class="mb-4 shrink-0 sm:w-80" :disabled="removing" :aria-label="t('proxies.accounts.searchAssociatedAccounts')" :placeholder="t('proxies.accounts.searchAccountNameOrEmail')">
<template #prefix>
<Search class="size-4.5 text-z-text-tertiary" />
</template>
</ZInput>
<div class="mb-4 flex shrink-0 flex-wrap items-center gap-3">
<ZInput v-model="search" class="min-w-0 flex-1 sm:max-w-80" :disabled="removing" :aria-label="t('proxies.accounts.searchAssociatedAccounts')" :placeholder="t('proxies.accounts.searchAccountNameOrEmail')">
<template #prefix>
<Search class="size-4.5 text-z-text-tertiary" />
</template>
</ZInput>
<ZButton type="primary" class="ml-auto shrink-0" :disabled="removing || !proxy?.enabled" @click="showAssign = true">
<template #icon>
<Plus class="size-4" />
</template>
{{ t('proxies.assignment.title') }}
</ZButton>
</div>
<p v-if="proxy && !proxy.enabled" class="mt-0 mb-3 text-z-xs text-z-text-secondary">
{{ t('proxies.assignment.enableFirst') }}
</p>
<ZTable :key="proxy?.id" class="min-h-0 shrink-0 [--z-table-row-height:64px]" :style="{ height: tableHeight }" :columns="columns" :data="rows" :loading="loading" :empty-text="emptyText">
<template #identity="{ row }">
<div class="flex min-w-0 items-center gap-3">
@@ -162,4 +181,5 @@ watch([
{{ t('proxies.accounts.removeDescription', { name: pendingRemove?.name, removeProxyName }) }}
</p>
</ZConfirmDialog>
<ProxyAssignAccountsModal v-model="showAssign" :proxy="proxy" @assigned="onAssigned" />
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import type { Account, OutboundProxyRecord } from '@/api'
import { defineTableColumns, ZButton, ZCheckbox, ZDialog, ZInput, ZPagination, ZTable } from '@codex-proxy/ui'
import { Search } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import AccountGroupMarks from '@/components/AccountGroupMarks.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useProxyAccountAssignment } from '../composables/useProxyAccountAssignment'
const props = defineProps<{ proxy: OutboundProxyRecord | null }>()
const emit = defineEmits<{ assigned: [] }>()
const open = defineModel<boolean>({ required: true })
const { t } = useI18n({ useScope: 'global' })
const {
accounts,
loading,
error,
search,
pagination,
setPage,
setPageSize,
selectedIds,
selectedRowKeys,
allSelected,
indeterminate,
toggleSelection,
toggleAll,
tooManySelected,
maximumAccounts,
assigning,
assign,
} = useProxyAccountAssignment({
isOpen: () => open.value,
proxy: () => props.proxy,
onAssigned: () => emit('assigned'),
})
const columns = computed(() => defineTableColumns<Account>([
{
key: 'selection',
kind: 'selection',
size: 'xs',
align: 'center',
},
{
key: 'identity',
label: t('accounts.account'),
kind: 'identity',
size: '2xl',
},
{
key: 'provider',
label: t('proxies.accounts.platformType'),
kind: 'custom',
size: 'md',
align: 'center',
},
{
key: 'plan',
label: t('proxies.accounts.subscription'),
kind: 'custom',
size: 'sm',
align: 'center',
},
{
key: 'groups',
label: t('groups.group'),
kind: 'custom',
size: 'lg',
align: 'center',
},
{
key: 'outboundProxy',
label: t('proxies.assignment.currentProxy'),
kind: 'custom',
size: '2xl',
},
]))
const emptyText = computed(() => error.value || (search.value.trim()
? t('proxies.accounts.noMatches')
: t('proxies.assignment.noAccounts')))
async function confirmAssignment() {
if (await assign())
open.value = false
}
</script>
<template>
<ZDialog v-model="open" :title="t('proxies.assignment.title')" :description="proxy?.name" width="64rem" :show-close="!assigning" :close-on-click-modal="!assigning" :close-on-press-escape="!assigning">
<div class="flex min-h-0 flex-col gap-3">
<div class="flex min-w-0 items-center gap-2">
<ZInput v-model="search" class="min-w-0 flex-1 sm:w-80 sm:flex-none" :disabled="assigning" :aria-label="t('proxies.assignment.searchAccounts')" :placeholder="t('proxies.accounts.searchAccountNameOrEmail')">
<template #prefix>
<Search class="size-4.5 text-z-text-tertiary" />
</template>
</ZInput>
<div v-if="selectedIds.size" class="ml-auto flex shrink-0 gap-2">
<ZButton type="danger" variant="plain" :disabled="assigning" @click="selectedIds = new Set()">
{{ t('common.actions.reset') }}
</ZButton>
<ZButton type="primary" :loading="assigning" :disabled="!proxy?.enabled || tooManySelected" @click="confirmAssignment">
{{ t('proxies.assignment.assignAction', { count: selectedIds.size }) }}
</ZButton>
</div>
</div>
<ZTable class="min-h-0 shrink-0 [--z-table-row-height:56px]" :style="{ height: 'min(50dvh, 28rem)' }" :columns="columns" :data="accounts" :loading="loading" :empty-text="emptyText" :selected-row-keys="selectedRowKeys">
<template #header-selection>
<ZCheckbox :model-value="allSelected" :indeterminate="indeterminate" :disabled="loading || assigning" :aria-label="t('proxies.assignment.selectPage')" @update:model-value="toggleAll" />
</template>
<template #selection="{ row }">
<ZCheckbox :model-value="selectedIds.has(row.id)" :disabled="loading || assigning || row.outboundProxyId === proxy?.id" :aria-label="t('proxies.assignment.selectAccount', { name: row.name })" @update:model-value="toggleSelection(row.id)" />
</template>
<template #identity="{ row }">
<div class="min-w-0">
<div class="truncate text-z font-heavy text-z-text" :title="row.name">
{{ row.name }}
</div>
<div v-if="row.email && row.email !== row.name" class="mt-0.5 truncate font-mono text-z-xs text-z-text-secondary" :title="row.email">
{{ row.email }}
</div>
</div>
</template>
<template #provider="{ row }">
<ProviderIconGroup :provider="row.provider" :authentication-kind="row.authenticationKind" />
</template>
<template #plan="{ row }">
<AccountPlanBadge :upstream="row.upstream" :authentication-kind="row.authenticationKind" :plan-type="row.planType" :plan-type-display="row.planTypeDisplay || '—'" />
</template>
<template #groups="{ row }">
<div class="flex justify-center">
<AccountGroupMarks :groups="row.groups" />
</div>
</template>
<template #outboundProxy="{ row }">
<span v-if="row.outboundProxyId === proxy?.id" class="text-z-success-text">{{ t('proxies.assignment.alreadyAssigned') }}</span>
<span v-else class="block truncate text-z-xs text-z-text-secondary" :title="row.outboundProxyEndpoint ?? undefined">
{{ row.outboundProxyEndpoint || t('accounts.proxy.directConnection') }}
</span>
</template>
</ZTable>
<p v-if="tooManySelected" class="m-0 text-z-sm text-z-error-text" role="alert">
{{ t('proxies.assignment.selectionLimit', { count: maximumAccounts }) }}
</p>
<p v-if="proxy && !proxy.enabled" class="m-0 text-z-sm text-z-warning-text" role="alert">
{{ t('proxies.assignment.enableFirst') }}
</p>
</div>
<template #footer>
<ZPagination v-bind="pagination" class="mt-0! w-full" :disabled="loading || assigning" @current-change="setPage" @size-change="setPageSize" />
</template>
</ZDialog>
</template>
@@ -0,0 +1,98 @@
import type { OutboundProxyRecord } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { batchUpdateAccounts, getAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { usePageSelection } from '@/composables/usePageSelection'
import { t } from '@/i18n'
import { notification as ZNotification } from '@/utils/notification'
const MAX_ASSIGNMENT_ACCOUNTS = 1000
export function useProxyAccountAssignment(options: {
isOpen: () => boolean
proxy: () => OutboundProxyRecord | null
onAssigned: () => void
}) {
const search = shallowRef('')
const action = useAsyncAction()
const query = usePagedQuery({
initialPageSize: 20,
load: (pagination, requestOptions) => getAccounts({
...pagination,
search: search.value.trim() || undefined,
}, requestOptions),
})
const selectableAccounts = computed(() => query.items.value.filter(account => account.outboundProxyId !== options.proxy()?.id))
const selection = usePageSelection(selectableAccounts)
const tooManySelected = computed(() => selection.selectedIds.value.size > MAX_ASSIGNMENT_ACCOUNTS)
const pagination = computed(() => ({
currentPage: query.page.value,
pageSize: query.pageSize.value,
total: query.total.value,
}))
function setPage(page: number) {
if (options.isOpen() && !action.loading.value)
void query.execute({ page })
}
function setPageSize(pageSize: number) {
if (options.isOpen() && !action.loading.value)
void query.execute({ page: 1, pageSize })
}
async function assign() {
const proxy = options.proxy()
const accountIds = [...selection.selectedIds.value]
if (!options.isOpen() || !proxy?.enabled || !accountIds.length || tooManySelected.value)
return false
return await action.run(async () => {
// 只写代理字段,保留账号的凭据、分组与调度设置
const result = await batchUpdateAccounts({ accountIds, outboundProxyId: proxy.id })
options.onAssigned()
ZNotification.success({
message: t('proxies.assignment.assigned', { count: result.accountIds.length, name: proxy.name }),
})
return options.isOpen() && options.proxy()?.id === proxy.id
}) ?? false
}
watch([
options.isOpen,
() => options.proxy()?.id,
], () => {
query.invalidate({
resetLoading: options.isOpen(),
})
selection.selectedIds.value = new Set()
if (!options.isOpen())
return
search.value = ''
if (options.proxy())
void query.reloadFromStart()
}, { immediate: true, flush: 'sync' })
watch(search, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
if (options.isOpen() && options.proxy())
void query.reloadFromStart()
}, 300)
onCleanup(() => clearTimeout(timer))
})
return {
accounts: query.items,
loading: query.loading,
error: query.error,
search,
pagination,
setPage,
setPageSize,
...selection,
tooManySelected,
maximumAccounts: MAX_ASSIGNMENT_ACCOUNTS,
assigning: action.loading,
assign,
}
}
@@ -37,6 +37,11 @@ export function useProxyAccounts(options: {
void query.execute({ page: 1, pageSize: size })
}
function reloadAccounts() {
if (options.isOpen() && options.proxyId())
return query.execute()
}
async function removeAccount(accountId: string) {
const proxyId = options.proxyId()
if (!proxyId)
@@ -92,5 +97,6 @@ export function useProxyAccounts(options: {
setPageSize,
removing: removeAction.loading,
removeAccount,
reloadAccounts,
}
}
+1 -1
View File
@@ -198,6 +198,6 @@ watch(search, () => {
{{ t('proxies.page.deleteDescription') }}
</p>
</ZConfirmDialog>
<ProxyAccountsModal v-model="showAccounts" :proxy="inspected" @removed="loadProxies({ silent: true })" />
<ProxyAccountsModal v-model="showAccounts" :proxy="inspected" @changed="loadProxies({ silent: true })" />
</div>
</template>