fix(web): expose plugin activation after upload

This commit is contained in:
CaIon
2026-09-21 21:01:15 +08:00
parent 00e8a00cb4
commit e537dc380b
9 changed files with 314 additions and 36 deletions
@@ -23,14 +23,18 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import { UploadDialog } from '../components/upload-dialog'
import { MAX_PLUGIN_SOURCE_BYTES } from '../lib/plugin-url'
import type { TaskPluginDetail } from '../types'
const uploadTaskPlugin = vi.hoisted(() => vi.fn())
const { uploadTaskPlugin, activateTaskPlugin } = vi.hoisted(() => ({
uploadTaskPlugin: vi.fn(),
activateTaskPlugin: vi.fn(),
}))
vi.mock('../api', () => ({ uploadTaskPlugin }))
vi.mock('../api', () => ({ uploadTaskPlugin, activateTaskPlugin }))
const queryClients: QueryClient[] = []
function renderDialog(open = true) {
function renderDialog(open = true, initialKey?: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
@@ -41,7 +45,11 @@ function renderDialog(open = true) {
const onOpenChange = vi.fn()
const view = render(
<QueryClientProvider client={queryClient}>
<UploadDialog open={open} onOpenChange={onOpenChange} />
<UploadDialog
open={open}
onOpenChange={onOpenChange}
initialKey={initialKey}
/>
</QueryClientProvider>
)
return { onOpenChange, queryClient, view }
@@ -61,6 +69,43 @@ function footerButton(name: string | RegExp) {
return within(footer as HTMLElement).getByRole('button', { name })
}
function savedPlugin(active: boolean): TaskPluginDetail {
return {
meta: {
key: 'demo',
name: 'Demo',
version: '2.0.0',
apiVersion: 1,
author: { name: 'Demo' },
models: [],
fetchMode: 'per_task',
},
source: 'const a = 1',
layer: 'override',
plugin: {
id: 2,
key: 'demo',
api_version: 1,
version: '2.0.0',
source: 'const a = 1',
source_hash: 'hash',
enabled: true,
active,
created_at: 1,
remark: '',
},
}
}
async function uploadSource(user: ReturnType<typeof userEvent.setup>) {
await user.upload(
fileInput(),
new File(['const a = 1'], 'plugin.js', { type: 'text/javascript' })
)
await waitFor(() => expect(footerButton('Upload')).toBeEnabled())
await user.click(footerButton('Upload'))
}
afterEach(() => {
for (const queryClient of queryClients) queryClient.clear()
queryClients.length = 0
@@ -187,6 +232,111 @@ describe('UploadDialog URL import', () => {
})
describe('UploadDialog upload lifecycle', () => {
test.each([undefined, 'demo'])(
'offers activation after saving an inactive version from %s',
async (initialKey) => {
const user = userEvent.setup()
uploadTaskPlugin.mockResolvedValue(savedPlugin(false))
let resolveActivation!: () => void
activateTaskPlugin.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveActivation = resolve
})
)
const { queryClient, onOpenChange } = renderDialog(true, initialKey)
queryClient.setQueryData(['task-plugin', 'demo'], { cached: true })
queryClient.setQueryData(['task-plugin-versions', 'demo'], [])
queryClient.setQueryData(['task-plugins'], [])
await uploadSource(user)
expect(
await screen.findByText('Plugin saved, activation required')
).toBeVisible()
expect(
screen.getByText(
'This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.'
)
).toBeVisible()
expect(
screen.queryByRole('textbox', { name: 'Plugin source' })
).not.toBeInTheDocument()
expect(activateTaskPlugin).not.toHaveBeenCalled()
expect(footerButton('Activate now')).toHaveFocus()
// Reset the cached queries so activation must invalidate them again.
queryClient.setQueryData(['task-plugin', 'demo'], { cached: true })
queryClient.setQueryData(['task-plugin-versions', 'demo'], [])
queryClient.setQueryData(['task-plugins'], [])
await user.keyboard('{Enter}')
expect(activateTaskPlugin).toHaveBeenCalledWith('demo', '2.0.0')
expect(footerButton('Activating...')).toBeDisabled()
expect(footerButton('Later')).toBeDisabled()
await user.keyboard('{Escape}')
expect(onOpenChange).not.toHaveBeenCalled()
resolveActivation()
expect(await screen.findByText('Plugin version activated')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Activate now' })
).not.toBeInTheDocument()
expect(footerButton('Close')).toHaveFocus()
for (const queryKey of [
['task-plugins'],
['task-plugin', 'demo'],
['task-plugin-versions', 'demo'],
]) {
expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true)
}
}
)
test('keeps the saved version available to retry when activation fails', async () => {
const user = userEvent.setup()
uploadTaskPlugin.mockResolvedValue(savedPlugin(false))
activateTaskPlugin
.mockRejectedValueOnce(new Error('activation failed'))
.mockResolvedValueOnce(undefined)
renderDialog()
await uploadSource(user)
await user.click(
await screen.findByRole('button', { name: 'Activate now' })
)
expect(await screen.findByText('activation failed')).toBeVisible()
expect(screen.getByText('Plugin saved, activation required')).toBeVisible()
expect(footerButton('Activate now')).toBeEnabled()
await user.click(footerButton('Activate now'))
expect(await screen.findByText('Plugin version activated')).toBeVisible()
expect(uploadTaskPlugin).toHaveBeenCalledTimes(1)
})
test('allows postponing activation without changing the active version', async () => {
const user = userEvent.setup()
uploadTaskPlugin.mockResolvedValue(savedPlugin(false))
const { onOpenChange } = renderDialog()
await uploadSource(user)
await user.click(await screen.findByRole('button', { name: 'Later' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(activateTaskPlugin).not.toHaveBeenCalled()
})
test('shows an already active upload as activated without another activation request', async () => {
const user = userEvent.setup()
uploadTaskPlugin.mockResolvedValue(savedPlugin(true))
renderDialog()
await uploadSource(user)
expect(await screen.findByText('Plugin version activated')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Activate now' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Upload$/ })
).not.toBeInTheDocument()
expect(activateTaskPlugin).not.toHaveBeenCalled()
})
test('disables Upload while the source is empty and shows a pending label', async () => {
const user = userEvent.setup()
let resolveUpload: (value: unknown) => void = () => undefined
@@ -210,12 +360,9 @@ describe('UploadDialog upload lifecycle', () => {
await user.click(uploadButton)
await waitFor(() => expect(footerButton(/Uploading/)).toBeDisabled())
resolveUpload({
source: 'const a = 1',
meta: { key: 'demo', name: 'Demo', version: '1.0.0', apiVersion: 1 },
})
resolveUpload(savedPlugin(true))
expect(
await screen.findByText('Parsed plugin metadata')
await screen.findByText('Plugin version activated')
).toBeInTheDocument()
})
@@ -17,8 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact [email protected]
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, CircleCheck, Upload } from 'lucide-react'
import { useState } from 'react'
import { AlertTriangle, CircleCheck, Power, Upload } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -36,7 +36,7 @@ import {
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner'
import { uploadTaskPlugin } from '../api'
import { activateTaskPlugin, uploadTaskPlugin } from '../api'
import {
encodePluginIconFile,
PluginIconFileError,
@@ -68,22 +68,43 @@ export function UploadDialog(props: UploadDialogProps) {
const [result, setResult] = useState<TaskPluginDetail | null>(null)
const [importUrl, setImportUrl] = useState('')
const [importError, setImportError] = useState('')
const primaryActionRef = useRef<HTMLButtonElement>(null)
const mutation = useMutation({
mutationFn: () => uploadTaskPlugin(source, remark, icon),
onSuccess: (data) => {
setResult(data)
queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
if (props.initialKey) {
queryClient.invalidateQueries({
queryKey: ['task-plugin', props.initialKey],
})
queryClient.invalidateQueries({
queryKey: ['task-plugin-versions', props.initialKey],
})
}
toast.success(t('Plugin uploaded successfully'))
queryClient.invalidateQueries({
queryKey: ['task-plugin', data.meta.key],
})
queryClient.invalidateQueries({
queryKey: ['task-plugin-versions', data.meta.key],
})
toast.success(
data.plugin?.active === false
? t('Plugin saved, activation required')
: t('Plugin uploaded successfully')
)
},
})
const activateMutation = useMutation({
mutationFn: (detail: TaskPluginDetail) =>
activateTaskPlugin(detail.meta.key, detail.meta.version),
onSuccess: (_, detail) => {
queryClient.invalidateQueries({ queryKey: ['task-plugins'] })
queryClient.invalidateQueries({
queryKey: ['task-plugin', detail.meta.key],
})
queryClient.invalidateQueries({
queryKey: ['task-plugin-versions', detail.meta.key],
})
toast.success(t('Plugin version activated'))
},
})
useEffect(() => {
if (result) primaryActionRef.current?.focus()
}, [result, activateMutation.isSuccess])
const handleFile = async (file: File) => {
if (file.size > MAX_PLUGIN_SOURCE_BYTES) {
@@ -118,6 +139,7 @@ export function UploadDialog(props: UploadDialogProps) {
}
const close = (open: boolean) => {
if (!open && (mutation.isPending || activateMutation.isPending)) return
props.onOpenChange(open)
if (!open) {
setSource('')
@@ -130,13 +152,89 @@ export function UploadDialog(props: UploadDialogProps) {
setImportUrl('')
setImportError('')
mutation.reset()
activateMutation.reset()
}
}
if (result) {
const needsActivation =
result.plugin?.active === false && !activateMutation.isSuccess
return (
<Dialog
open={props.open}
onOpenChange={close}
showCloseButton={!activateMutation.isPending}
bodyClassName='space-y-4'
title={t('Plugin uploaded successfully')}
description={`${result.meta.name} (${result.meta.key}) · v${result.meta.version}`}
descriptionClassName='break-all'
footer={
<>
<Button
ref={needsActivation ? undefined : primaryActionRef}
variant={needsActivation ? 'outline' : 'default'}
disabled={activateMutation.isPending}
onClick={() => close(false)}
>
{needsActivation ? t('Later') : t('Close')}
</Button>
{needsActivation && (
<Button
ref={primaryActionRef}
disabled={activateMutation.isPending}
onClick={() => activateMutation.mutate(result)}
>
{activateMutation.isPending ? (
<Spinner aria-hidden='true' />
) : (
<Power aria-hidden='true' />
)}
{activateMutation.isPending
? t('Activating...')
: t('Activate now')}
</Button>
)}
</>
}
>
<Alert role='status'>
{needsActivation ? (
<AlertTriangle aria-hidden='true' />
) : (
<CircleCheck aria-hidden='true' />
)}
<AlertTitle>
{needsActivation
? t('Plugin saved, activation required')
: t('Plugin version activated')}
</AlertTitle>
{needsActivation && (
<AlertDescription>
{t(
'This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.'
)}
</AlertDescription>
)}
</Alert>
{activateMutation.error && (
<Alert variant='destructive'>
<AlertTriangle aria-hidden='true' />
<AlertTitle>{t('Plugin activation failed')}</AlertTitle>
<AlertDescription className='break-words whitespace-pre-wrap'>
{activateMutation.error.message}
</AlertDescription>
</Alert>
)}
</Dialog>
)
}
return (
<Dialog
open={props.open}
onOpenChange={close}
showCloseButton={!mutation.isPending}
contentClassName='sm:max-w-3xl'
bodyClassName='space-y-4'
title={
@@ -151,7 +249,11 @@ export function UploadDialog(props: UploadDialogProps) {
}
footer={
<>
<Button variant='outline' onClick={() => close(false)}>
<Button
variant='outline'
disabled={mutation.isPending}
onClick={() => close(false)}
>
{t('Close')}
</Button>
<Button
@@ -219,10 +321,7 @@ export function UploadDialog(props: UploadDialogProps) {
<FieldLabel htmlFor='task-plugin-icon'>{t('Plugin icon')}</FieldLabel>
<div className='flex items-center gap-3'>
{icon ? (
<PluginIcon
plugin={{ key: result?.meta.key ?? 'upload', iconSrc: icon }}
size={32}
/>
<PluginIcon plugin={{ key: 'upload', iconSrc: icon }} size={32} />
) : null}
<Input
id='task-plugin-icon'
@@ -279,16 +378,6 @@ export function UploadDialog(props: UploadDialogProps) {
</AlertDescription>
</Alert>
) : null}
{result ? (
<Alert>
<CircleCheck className='text-primary' />
<AlertTitle>{t('Parsed plugin metadata')}</AlertTitle>
<AlertDescription className='font-mono'>
{result.meta.key} · {result.meta.name} · v{result.meta.version}
</AlertDescription>
</Alert>
) : null}
</Dialog>
)
}
+6
View File
@@ -217,6 +217,8 @@
"Actions": "Actions",
"Actions for {{name}}": "Actions for {{name}}",
"Activate / Roll back": "Activate / Roll back",
"Activate now": "Activate now",
"Activating...": "Activating...",
"active": "active",
"Active": "Active",
"Active apps": "Active apps",
@@ -3141,6 +3143,7 @@
"Latency check": "Latency check",
"Latency short": "Lat.",
"Latency trend (last 24h)": "Latency trend (last 24h)",
"Later": "Later",
"Later requests follow the new channel.": "Later requests follow the new channel.",
"Latest platform updates and notices": "Latest platform updates and notices",
"Latest version": "Latest version",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "Please wait before requesting another verification code.",
"Please wait for the current generation to complete": "Please wait for the current generation to complete",
"Plugin": "Plugin",
"Plugin activation failed": "Plugin activation failed",
"Plugin author": "Plugin author",
"Plugin default": "Plugin default",
"Plugin details": "Plugin details",
@@ -4365,6 +4369,7 @@
"Plugin key": "Plugin key",
"Plugin metadata": "Plugin metadata",
"Plugin sandbox": "Plugin sandbox",
"Plugin saved, activation required": "Plugin saved, activation required",
"Plugin source": "Plugin source",
"Plugin source exceeds the 1 MiB limit.": "Plugin source exceeds the 1 MiB limit.",
"Plugin uploaded successfully": "Plugin uploaded successfully",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "This verification method is not allowed for this action.",
"This verification method requires its dedicated verification flow.": "This verification method requires its dedicated verification flow.",
"This version is ignored": "This version is ignored",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.",
"This week": "This week",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.",
"This will clear custom pricing ratios and revert to upstream defaults.": "This will clear custom pricing ratios and revert to upstream defaults.",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "Actions",
"Actions for {{name}}": "Actions pour {{name}}",
"Activate / Roll back": "Activer / Restaurer",
"Activate now": "Activer maintenant",
"Activating...": "Activation...",
"active": "actif",
"Active": "Actif",
"Active apps": "Applications actives",
@@ -3141,6 +3143,7 @@
"Latency check": "Contrôle de latence",
"Latency short": "Lat.",
"Latency trend (last 24h)": "Tendance de latence (24 dernières heures)",
"Later": "Plus tard",
"Later requests follow the new channel.": "Les requêtes suivantes suivent le nouveau canal.",
"Latest platform updates and notices": "Dernières mises à jour et annonces de la plateforme",
"Latest version": "Dernière version",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "Veuillez patienter avant de demander un nouveau code.",
"Please wait for the current generation to complete": "Veuillez attendre la fin de la génération en cours",
"Plugin": "Plugin",
"Plugin activation failed": "Échec de l’activation du plugin",
"Plugin author": "Auteur du plugin",
"Plugin default": "Valeur par défaut du plugin",
"Plugin details": "Détails du plugin",
@@ -4365,6 +4369,7 @@
"Plugin key": "Clé du plugin",
"Plugin metadata": "Métadonnées du plugin",
"Plugin sandbox": "Bac à sable du plugin",
"Plugin saved, activation required": "Plugin enregistré, activation requise",
"Plugin source": "Source du plugin",
"Plugin source exceeds the 1 MiB limit.": "Le code du plugin dépasse la limite de 1 Mio.",
"Plugin uploaded successfully": "Plugin importé avec succès",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "Cette méthode de vérification n’est pas autorisée pour cette action.",
"This verification method requires its dedicated verification flow.": "Cette méthode nécessite son propre parcours de vérification.",
"This version is ignored": "Cette version est ignorée",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "Cette version est enregistrée mais inactive. Activez-la pour remplacer la version actuelle, ou activez-la plus tard dans l’onglet Versions.",
"This week": "Cette semaine",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Cela ajoutera 2 règles modèles (Codex CLI et Claude CLI) à la liste de règles existante.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Cela effacera les ratios de tarification personnalisés et rétablira les valeurs par défaut du fournisseur.",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "操作",
"Actions for {{name}}": "{{name}} の操作",
"Activate / Roll back": "有効化 / ロールバック",
"Activate now": "今すぐ有効化",
"Activating...": "有効化中...",
"active": "有効",
"Active": "有効",
"Active apps": "アクティブなアプリ",
@@ -3141,6 +3143,7 @@
"Latency check": "レイテンシ確認",
"Latency short": "遅延",
"Latency trend (last 24h)": "レイテンシ推移(直近 24 時間)",
"Later": "後で",
"Later requests follow the new channel.": "以降のリクエストは新しいチャネルを使用します。",
"Latest platform updates and notices": "最新のプラットフォーム更新と通知",
"Latest version": "最新バージョン",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "しばらく待ってから確認コードを再送信してください。",
"Please wait for the current generation to complete": "現在の生成が完了するまでお待ちください",
"Plugin": "プラグイン",
"Plugin activation failed": "プラグインの有効化に失敗しました",
"Plugin author": "プラグイン作成者",
"Plugin default": "プラグインの既定値",
"Plugin details": "プラグインの詳細",
@@ -4365,6 +4369,7 @@
"Plugin key": "プラグインキー",
"Plugin metadata": "プラグインメタデータ",
"Plugin sandbox": "プラグインサンドボックス",
"Plugin saved, activation required": "プラグインを保存しました。有効化が必要です",
"Plugin source": "プラグインソース",
"Plugin source exceeds the 1 MiB limit.": "プラグインのソースが 1 MiB の上限を超えています。",
"Plugin uploaded successfully": "プラグインをアップロードしました",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "この操作ではこの確認方法を使用できません。",
"This verification method requires its dedicated verification flow.": "この確認方法では専用の確認フローを使用してください。",
"This version is ignored": "このバージョンの通知は停止中です",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "このバージョンは保存済みですが、まだ有効ではありません。有効化すると現在のバージョンを置き換えます。後で「バージョン」タブから有効化することもできます。",
"This week": "今週",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "既存のルールリストに2つのテンプレートルール(Codex CLIとClaude CLI)を追加します。",
"This will clear custom pricing ratios and revert to upstream defaults.": "これにより、カスタム料金比率がクリアされ、上位のデフォルトに戻ります。",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "Операции",
"Actions for {{name}}": "Действия для {{name}}",
"Activate / Roll back": "Активировать / Откатить",
"Activate now": "Активировать сейчас",
"Activating...": "Активация...",
"active": "активный",
"Active": "Активна",
"Active apps": "Активные приложения",
@@ -3141,6 +3143,7 @@
"Latency check": "Проверка задержки",
"Latency short": "Зад.",
"Latency trend (last 24h)": "Тренд задержки (за 24 часа)",
"Later": "Позже",
"Later requests follow the new channel.": "Последующие запросы используют новый канал.",
"Latest platform updates and notices": "Последние обновления и уведомления платформы",
"Latest version": "Последняя версия",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "Подождите перед повторным запросом кода подтверждения.",
"Please wait for the current generation to complete": "Дождитесь завершения текущей генерации",
"Plugin": "Плагин",
"Plugin activation failed": "Не удалось активировать плагин",
"Plugin author": "Автор плагина",
"Plugin default": "Значение плагина по умолчанию",
"Plugin details": "Сведения о плагине",
@@ -4365,6 +4369,7 @@
"Plugin key": "Ключ плагина",
"Plugin metadata": "Метаданные плагина",
"Plugin sandbox": "Песочница плагина",
"Plugin saved, activation required": "Плагин сохранён, требуется активация",
"Plugin source": "Исходный код плагина",
"Plugin source exceeds the 1 MiB limit.": "Код плагина превышает лимит 1 МиБ.",
"Plugin uploaded successfully": "Плагин успешно загружен",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "Этот способ подтверждения не разрешён для данной операции.",
"This verification method requires its dedicated verification flow.": "Для этого способа необходимо пройти отдельную процедуру подтверждения.",
"This version is ignored": "Уведомления об этой версии отключены",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "Эта версия сохранена, но не активна. Активируйте её, чтобы заменить текущую версию, или сделайте это позже на вкладке «Версии».",
"This week": "На этой неделе",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Это добавит 2 шаблонных правила (Codex CLI и Claude CLI) к существующему списку правил.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Это очистит пользовательские ценовые коэффициенты и вернет к стандартным значениям поставщика.",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "Hành động",
"Actions for {{name}}": "Thao tác cho {{name}}",
"Activate / Roll back": "Kích hoạt / Khôi phục",
"Activate now": "Kích hoạt ngay",
"Activating...": "Đang kích hoạt...",
"active": "hoạt động",
"Active": "Hoạt động",
"Active apps": "Ứng dụng đang hoạt động",
@@ -3141,6 +3143,7 @@
"Latency check": "Kiểm tra độ trễ",
"Latency short": "Trễ",
"Latency trend (last 24h)": "Xu hướng độ trễ (24 giờ qua)",
"Later": "Để sau",
"Later requests follow the new channel.": "Các yêu cầu sau sẽ dùng kênh mới.",
"Latest platform updates and notices": "Cập nhật và thông báo nền tảng mới nhất",
"Latest version": "Phiên bản mới nhất",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "Vui lòng đợi trước khi yêu cầu mã xác minh khác.",
"Please wait for the current generation to complete": "Vui lòng đợi lượt tạo hiện tại hoàn tất",
"Plugin": "Plugin",
"Plugin activation failed": "Kích hoạt plugin thất bại",
"Plugin author": "Tác giả plugin",
"Plugin default": "Mặc định của plugin",
"Plugin details": "Chi tiết plugin",
@@ -4365,6 +4369,7 @@
"Plugin key": "Khóa plugin",
"Plugin metadata": "Siêu dữ liệu plugin",
"Plugin sandbox": "Môi trường thử nghiệm plugin",
"Plugin saved, activation required": "Đã lưu plugin, cần kích hoạt",
"Plugin source": "Mã nguồn plugin",
"Plugin source exceeds the 1 MiB limit.": "Mã nguồn plugin vượt giới hạn 1 MiB.",
"Plugin uploaded successfully": "Đã tải plugin lên",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "Thao tác này không cho phép sử dụng phương thức xác minh này.",
"This verification method requires its dedicated verification flow.": "Phương thức này cần được thực hiện qua quy trình xác minh riêng.",
"This version is ignored": "Đã bỏ qua phiên bản này",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "Phiên bản này đã được lưu nhưng chưa kích hoạt. Kích hoạt để thay thế phiên bản hiện tại, hoặc kích hoạt sau trong thẻ Phiên bản.",
"This week": "Tuần này",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "Thao tác này sẽ thêm 2 quy tắc mẫu (Codex CLI và Claude CLI) vào danh sách quy tắc hiện có.",
"This will clear custom pricing ratios and revert to upstream defaults.": "Điều này sẽ xóa các tỷ lệ giá tùy chỉnh và trở về mặc định ban đầu.",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "操作",
"Actions for {{name}}": "{{name}} 的操作",
"Activate / Roll back": "激活 / 回滚",
"Activate now": "立即啟用",
"Activating...": "啟用中...",
"active": "活躍",
"Active": "生效",
"Active apps": "活躍套用程式",
@@ -3141,6 +3143,7 @@
"Latency check": "延遲檢測",
"Latency short": "延遲",
"Latency trend (last 24h)": "延遲趨勢(最近 24 小時)",
"Later": "稍後",
"Later requests follow the new channel.": "後續請求改用新渠道。",
"Latest platform updates and notices": "最新平台更新和通知",
"Latest version": "最新版本",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "請稍後再要求驗證碼。",
"Please wait for the current generation to complete": "請等待目前生成完成",
"Plugin": "外掛",
"Plugin activation failed": "外掛啟用失敗",
"Plugin author": "外掛作者",
"Plugin default": "外掛預設值",
"Plugin details": "外掛詳情",
@@ -4365,6 +4369,7 @@
"Plugin key": "外掛键",
"Plugin metadata": "外掛元数据",
"Plugin sandbox": "外掛沙盒",
"Plugin saved, activation required": "外掛已儲存,待啟用",
"Plugin source": "外掛原始碼",
"Plugin source exceeds the 1 MiB limit.": "外掛原始碼超過 1 MiB 上限。",
"Plugin uploaded successfully": "外掛上傳成功",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "此操作不允許使用該驗證方式。",
"This verification method requires its dedicated verification flow.": "此驗證方式需要透過專用驗證流程完成。",
"This version is ignored": "已忽略此版本",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "此版本已儲存但尚未啟用。啟用後將取代目前版本,也可以稍後在外掛詳情的「版本」頁中啟用。",
"This week": "本週",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "這將在現有規則列表中追加 2 條模板規則(Codex CLI 和 Claude CLI)。",
"This will clear custom pricing ratios and revert to upstream defaults.": "這將清除自訂定價比例並恢復到上游預設值。",
+6
View File
@@ -217,6 +217,8 @@
"Actions": "操作",
"Actions for {{name}}": "{{name}} 的操作",
"Activate / Roll back": "激活 / 回滚",
"Activate now": "立即激活",
"Activating...": "激活中...",
"active": "活跃",
"Active": "生效",
"Active apps": "活跃应用",
@@ -3141,6 +3143,7 @@
"Latency check": "延迟检测",
"Latency short": "延迟",
"Latency trend (last 24h)": "延迟趋势(最近 24 小时)",
"Later": "稍后",
"Later requests follow the new channel.": "后续请求改用新渠道。",
"Latest platform updates and notices": "最新平台更新和通知",
"Latest version": "最新版本",
@@ -4351,6 +4354,7 @@
"Please wait before requesting another verification code.": "请稍后再请求验证码。",
"Please wait for the current generation to complete": "请等待当前生成完成",
"Plugin": "插件",
"Plugin activation failed": "插件激活失败",
"Plugin author": "插件作者",
"Plugin default": "插件默认值",
"Plugin details": "插件详情",
@@ -4365,6 +4369,7 @@
"Plugin key": "插件键",
"Plugin metadata": "插件元数据",
"Plugin sandbox": "插件沙盒",
"Plugin saved, activation required": "插件已保存,待激活",
"Plugin source": "插件源码",
"Plugin source exceeds the 1 MiB limit.": "插件源码超过 1 MiB 上限。",
"Plugin uploaded successfully": "插件上传成功",
@@ -5947,6 +5952,7 @@
"This verification method is not allowed for this action.": "此操作不允许使用该验证方式。",
"This verification method requires its dedicated verification flow.": "此验证方式需要通过专用验证流程完成。",
"This version is ignored": "已忽略此版本",
"This version is saved but not active. Activate it to replace the current version, or activate it later from the Versions tab.": "此版本已保存但尚未激活。激活后将替换当前版本,也可以稍后在插件详情的“版本”页中激活。",
"This week": "本周",
"This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.": "这将在现有规则列表中追加 2 条模板规则(Codex CLI 和 Claude CLI)。",
"This will clear custom pricing ratios and revert to upstream defaults.": "这将清除自定义定价比例并恢复到上游默认值。",