mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-13 15:54:34 +00:00
feat(web): factory task plugins update only with the system
Marketplace install/upgrade on a factory-served plugin actually created a permanent override shadowing every future built-in release. The card now shows an informational "Updates with the system" badge instead of the action, while keeping the built-in vs marketplace version line and the upgradable state badge visible. Deliberate overrides are untouched: upload and marketplace actions on overridden or third-party plugins behave as before, and the plugins table now hints when an override lags behind the shipped built-in version so operators know deleting it restores the newer factory plugin.
This commit is contained in:
@@ -26,7 +26,10 @@ import {
|
|||||||
GITHUB_MARKETPLACE_INDEX_URL,
|
GITHUB_MARKETPLACE_INDEX_URL,
|
||||||
indexHasIntegrityHashes,
|
indexHasIntegrityHashes,
|
||||||
isDefaultMarketplaceSource,
|
isDefaultMarketplaceSource,
|
||||||
|
isStaleFactoryOverride,
|
||||||
|
marketplaceBuiltInVersion,
|
||||||
parseMarketplaceIndex,
|
parseMarketplaceIndex,
|
||||||
|
resolveMarketplaceActionPolicy,
|
||||||
resolvePluginSourceUrl,
|
resolvePluginSourceUrl,
|
||||||
} from '../lib/marketplace'
|
} from '../lib/marketplace'
|
||||||
import type {
|
import type {
|
||||||
@@ -52,7 +55,23 @@ function marketplacePlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installedPlugin(key: string, version: string): TaskPluginListItem {
|
function factoryMeta(key: string, version: string) {
|
||||||
|
return {
|
||||||
|
apiVersion: 1,
|
||||||
|
key,
|
||||||
|
name: key,
|
||||||
|
version,
|
||||||
|
author: { name: 'test' as const },
|
||||||
|
models: null,
|
||||||
|
fetchMode: 'poll',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installedPlugin(
|
||||||
|
key: string,
|
||||||
|
version: string,
|
||||||
|
overrides: Partial<TaskPluginListItem> = {}
|
||||||
|
): TaskPluginListItem {
|
||||||
return {
|
return {
|
||||||
meta: {
|
meta: {
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
@@ -71,6 +90,7 @@ function installedPlugin(key: string, version: string): TaskPluginListItem {
|
|||||||
runtime_status: 'registered',
|
runtime_status: 'registered',
|
||||||
channel_count: 0,
|
channel_count: 0,
|
||||||
in_flight_count: 0,
|
in_flight_count: 0,
|
||||||
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +419,102 @@ describe('install state derivation', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('marketplace action policy', () => {
|
||||||
|
test('factory-served plugin returns the informational system-update state', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveMarketplaceActionPolicy(
|
||||||
|
installedPlugin('doubao', '1.0.0', { source: 'factory' })
|
||||||
|
),
|
||||||
|
{ kind: 'system_update' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overridden factory plugin still allows marketplace install', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveMarketplaceActionPolicy(
|
||||||
|
installedPlugin('doubao', '1.2.0', {
|
||||||
|
source: 'override_over_factory',
|
||||||
|
factory_meta: factoryMeta('doubao', '1.0.0'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ kind: 'install' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('third-party plugin still allows marketplace install', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveMarketplaceActionPolicy(installedPlugin('doubao', '1.0.0')),
|
||||||
|
{ kind: 'install' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uninstalled plugin still allows marketplace install', () => {
|
||||||
|
assert.deepEqual(resolveMarketplaceActionPolicy(undefined), {
|
||||||
|
kind: 'install',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('factory-served built-in version is the installed meta version', () => {
|
||||||
|
assert.equal(
|
||||||
|
marketplaceBuiltInVersion(
|
||||||
|
installedPlugin('doubao', '1.0.0', { source: 'factory' })
|
||||||
|
),
|
||||||
|
'1.0.0'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overridden factory built-in version comes from factory_meta', () => {
|
||||||
|
assert.equal(
|
||||||
|
marketplaceBuiltInVersion(
|
||||||
|
installedPlugin('doubao', '1.2.0', {
|
||||||
|
source: 'override_over_factory',
|
||||||
|
factory_meta: factoryMeta('doubao', '1.0.0'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
'1.0.0'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stale factory override', () => {
|
||||||
|
test('is stale when override version differs from built-in', () => {
|
||||||
|
assert.equal(
|
||||||
|
isStaleFactoryOverride(
|
||||||
|
installedPlugin('doubao', '1.2.0', {
|
||||||
|
source: 'override_over_factory',
|
||||||
|
factory_meta: factoryMeta('doubao', '1.0.0'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is not stale when override version matches built-in', () => {
|
||||||
|
assert.equal(
|
||||||
|
isStaleFactoryOverride(
|
||||||
|
installedPlugin('doubao', '1.0.0', {
|
||||||
|
source: 'override_over_factory',
|
||||||
|
factory_meta: factoryMeta('doubao', '1.0.0'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is not stale for factory-served or third-party plugins', () => {
|
||||||
|
assert.equal(
|
||||||
|
isStaleFactoryOverride(
|
||||||
|
installedPlugin('doubao', '1.0.0', { source: 'factory' })
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
isStaleFactoryOverride(installedPlugin('doubao', '1.0.0')),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('marketplace version lookup', () => {
|
describe('marketplace version lookup', () => {
|
||||||
test('finds the entry matching a version', () => {
|
test('finds the entry matching a version', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { getChannelTypeLabel } from '@/features/channels/lib'
|
import { getChannelTypeLabel } from '@/features/channels/lib'
|
||||||
import { resolveLocalizedText } from '@/lib/localized-text'
|
import { resolveLocalizedText } from '@/lib/localized-text'
|
||||||
|
|
||||||
import { findMarketplaceVersion, type InstallState } from '../lib/marketplace'
|
import {
|
||||||
|
findMarketplaceVersion,
|
||||||
|
marketplaceBuiltInVersion,
|
||||||
|
resolveMarketplaceActionPolicy,
|
||||||
|
type InstallState,
|
||||||
|
} from '../lib/marketplace'
|
||||||
import type { MarketplacePlugin, TaskPluginListItem } from '../types'
|
import type { MarketplacePlugin, TaskPluginListItem } from '../types'
|
||||||
import { PluginIcon } from './plugin-icon'
|
import { PluginIcon } from './plugin-icon'
|
||||||
|
|
||||||
@@ -47,6 +52,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
|
|||||||
const channelTypes = plugin.channelTypes ?? []
|
const channelTypes = plugin.channelTypes ?? []
|
||||||
const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
|
const latestEntry = findMarketplaceVersion(plugin, plugin.latest)
|
||||||
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
|
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
|
||||||
|
const actionPolicy = resolveMarketplaceActionPolicy(props.installed)
|
||||||
|
const builtInVersion = marketplaceBuiltInVersion(props.installed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full flex-col gap-2.5 rounded-xl border p-3'>
|
<div className='flex h-full flex-col gap-2.5 rounded-xl border p-3'>
|
||||||
@@ -90,12 +97,12 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.installed?.factory_meta && (
|
{builtInVersion && (
|
||||||
<div className='text-xs'>
|
<div className='text-xs'>
|
||||||
<span className={labelClass}>{t('Versions')}</span>{' '}
|
<span className={labelClass}>{t('Versions')}</span>{' '}
|
||||||
<span className='font-mono'>
|
<span className='font-mono'>
|
||||||
{t('Built-in v{{factory}} / marketplace v{{market}}', {
|
{t('Built-in v{{factory}} / marketplace v{{market}}', {
|
||||||
factory: props.installed.factory_meta.version,
|
factory: builtInVersion,
|
||||||
market: plugin.latest,
|
market: plugin.latest,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
@@ -110,17 +117,21 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='mt-auto border-t pt-2'>
|
<div className='mt-auto border-t pt-2'>
|
||||||
<Button
|
{actionPolicy.kind === 'system_update' ? (
|
||||||
size='sm'
|
<Badge variant='secondary'>{t('Updates with the system')}</Badge>
|
||||||
variant={
|
) : (
|
||||||
props.installState.status === 'up_to_date' ? 'outline' : 'default'
|
<Button
|
||||||
}
|
size='sm'
|
||||||
className='w-full'
|
variant={
|
||||||
onClick={props.onInstall}
|
props.installState.status === 'up_to_date' ? 'outline' : 'default'
|
||||||
>
|
}
|
||||||
<Download />
|
className='w-full'
|
||||||
{getActionLabel(props.installState, t)}
|
onClick={props.onInstall}
|
||||||
</Button>
|
>
|
||||||
|
<Download />
|
||||||
|
{getActionLabel(props.installState, t)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
setTaskPluginStatus,
|
setTaskPluginStatus,
|
||||||
TaskPluginUsageError,
|
TaskPluginUsageError,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { isStaleFactoryOverride } from '../lib/marketplace'
|
||||||
import type { TaskPluginListItem, TaskPluginUsage } from '../types'
|
import type { TaskPluginListItem, TaskPluginUsage } from '../types'
|
||||||
import { PluginCard } from './plugin-card'
|
import { PluginCard } from './plugin-card'
|
||||||
import { PluginIcon } from './plugin-icon'
|
import { PluginIcon } from './plugin-icon'
|
||||||
@@ -158,12 +159,26 @@ export function PluginsTable(props: PluginsTableProps) {
|
|||||||
return <Badge variant='secondary'>{t('Factory')}</Badge>
|
return <Badge variant='secondary'>{t('Factory')}</Badge>
|
||||||
}
|
}
|
||||||
if (row.original.source === 'override_over_factory') {
|
if (row.original.source === 'override_over_factory') {
|
||||||
|
const factoryVersion = row.original.factory_meta?.version
|
||||||
|
const staleHint = isStaleFactoryOverride(row.original)
|
||||||
|
? t(
|
||||||
|
'Built-in is v{{factory}}; delete the custom version to return to it',
|
||||||
|
{ factory: factoryVersion }
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
return (
|
return (
|
||||||
<Badge>
|
<div className='flex min-w-0 flex-col gap-0.5' title={staleHint}>
|
||||||
{t('Custom (overrides factory {{version}})', {
|
<Badge>
|
||||||
version: row.original.factory_meta?.version,
|
{t('Custom (overrides factory {{version}})', {
|
||||||
})}
|
version: factoryVersion,
|
||||||
</Badge>
|
})}
|
||||||
|
</Badge>
|
||||||
|
{staleHint ? (
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{staleHint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <Badge>{t('Third-party')}</Badge>
|
return <Badge>{t('Third-party')}</Badge>
|
||||||
|
|||||||
@@ -234,6 +234,46 @@ export function deriveInstallState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarketplaceActionPolicy =
|
||||||
|
| { kind: 'install' }
|
||||||
|
| { kind: 'system_update' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory-served plugins are compiled into the binary and must only update
|
||||||
|
* with a system release. Marketplace install would create a permanent override
|
||||||
|
* that shadows every future built-in update — that action is suppressed.
|
||||||
|
* Overrides and third-party plugins still install/upgrade normally.
|
||||||
|
*/
|
||||||
|
export function resolveMarketplaceActionPolicy(
|
||||||
|
installed?: TaskPluginListItem
|
||||||
|
): MarketplaceActionPolicy {
|
||||||
|
if (installed?.source === 'factory') {
|
||||||
|
return { kind: 'system_update' }
|
||||||
|
}
|
||||||
|
return { kind: 'install' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in version shown next to the marketplace latest. Factory-served items
|
||||||
|
* do not carry `factory_meta` (their `meta` *is* the factory meta); overridden
|
||||||
|
* factory plugins expose the shadowed built-in on `factory_meta`.
|
||||||
|
*/
|
||||||
|
export function marketplaceBuiltInVersion(
|
||||||
|
installed?: TaskPluginListItem
|
||||||
|
): string | undefined {
|
||||||
|
if (!installed) return undefined
|
||||||
|
if (installed.source === 'factory') return installed.meta.version
|
||||||
|
return installed.factory_meta?.version
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStaleFactoryOverride(item: TaskPluginListItem): boolean {
|
||||||
|
return (
|
||||||
|
item.source === 'override_over_factory' &&
|
||||||
|
item.factory_meta != null &&
|
||||||
|
item.factory_meta.version !== item.meta.version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A source is only integrity-checked when every listed version carries a
|
* A source is only integrity-checked when every listed version carries a
|
||||||
* sha256. Anything less and installs from it cannot be pinned, so the UI warns.
|
* sha256. Anything less and installs from it cannot be pinned, so the UI warns.
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "Built-in",
|
"Built-in": "Built-in",
|
||||||
"Built-in Device": "Built-in Device",
|
"Built-in Device": "Built-in Device",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}",
|
||||||
|
"Updates with the system": "Updates with the system",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "Built-in is v{{factory}}; delete the custom version to return to it",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key",
|
||||||
"by": "by",
|
"by": "by",
|
||||||
"By category": "By category",
|
"By category": "By category",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "Intégré",
|
"Built-in": "Intégré",
|
||||||
"Built-in Device": "Appareil intégré",
|
"Built-in Device": "Appareil intégré",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}",
|
||||||
|
"Updates with the system": "Mise à jour système",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "La version intégrée est v{{factory}} ; supprimez la version personnalisée pour y revenir",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB",
|
||||||
"by": "par",
|
"by": "par",
|
||||||
"By category": "Par catégorie",
|
"By category": "Par catégorie",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "組み込み",
|
"Built-in": "組み込み",
|
||||||
"Built-in Device": "内蔵デバイス",
|
"Built-in Device": "内蔵デバイス",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}",
|
||||||
|
"Updates with the system": "システムとともに更新",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "組み込みは v{{factory}} です。カスタム版を削除すると戻ります",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー",
|
||||||
"by": "によって",
|
"by": "によって",
|
||||||
"By category": "カテゴリ別",
|
"By category": "カテゴリ別",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "Встроенный",
|
"Built-in": "Встроенный",
|
||||||
"Built-in Device": "Встроенное устройство",
|
"Built-in Device": "Встроенное устройство",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}",
|
||||||
|
"Updates with the system": "Обновляется с системой",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "Встроенная версия — v{{factory}}; удалите свою, чтобы вернуться к ней",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности",
|
||||||
"by": "от",
|
"by": "от",
|
||||||
"By category": "По категориям",
|
"By category": "По категориям",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "Tích hợp sẵn",
|
"Built-in": "Tích hợp sẵn",
|
||||||
"Built-in Device": "Thiết bị tích hợp",
|
"Built-in Device": "Thiết bị tích hợp",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}",
|
||||||
|
"Updates with the system": "Cập nhật cùng hệ thống",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "Bản tích hợp là v{{factory}}; xóa bản tùy chỉnh để trở lại",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB",
|
||||||
"by": "by",
|
"by": "by",
|
||||||
"By category": "Theo danh mục",
|
"By category": "Theo danh mục",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "內置",
|
"Built-in": "內置",
|
||||||
"Built-in Device": "內置設備",
|
"Built-in Device": "內置設備",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}",
|
||||||
|
"Updates with the system": "隨系統更新",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "內建版本為 v{{factory}};刪除自訂版本即可恢復",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰",
|
||||||
"by": "由",
|
"by": "由",
|
||||||
"By category": "按行業",
|
"By category": "按行業",
|
||||||
|
|||||||
@@ -714,6 +714,8 @@
|
|||||||
"Built-in": "内置",
|
"Built-in": "内置",
|
||||||
"Built-in Device": "内置设备",
|
"Built-in Device": "内置设备",
|
||||||
"Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
|
"Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}",
|
||||||
|
"Updates with the system": "随系统更新",
|
||||||
|
"Built-in is v{{factory}}; delete the custom version to return to it": "内置版本为 v{{factory}};删除自定义版本即可恢复",
|
||||||
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
|
"Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥",
|
||||||
"by": "由",
|
"by": "由",
|
||||||
"By category": "按行业",
|
"By category": "按行业",
|
||||||
|
|||||||
Reference in New Issue
Block a user