feat: 添加 Provider Ops 扩展操作系统,支持余额监控

主要更改:
- 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作
- 后端:添加 provider_ops 服务层和 API 路由
- 前端:添加 ProviderAuthDialog 组件配置认证信息
- 前端:添加 providerOps API 和认证模板系统

UI/组件优化:
- Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器
- Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框
- KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑
- ProviderManagement:重新设计表格布局,显示余额监控数据
This commit is contained in:
fawney19
2026-01-17 19:50:35 +08:00
parent 3cdf471473
commit c71027c466
29 changed files with 4653 additions and 144 deletions

View File

@@ -1,35 +1,151 @@
<template>
<div v-if="masked" class="group relative">
<input
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
:name="shouldDisableAutofill ? randomName : undefined"
v-bind="filteredAttrs"
@input="handleInput"
>
<button
v-if="hasValue"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/20 hover:text-muted-foreground/50 transition-colors"
tabindex="-1"
:aria-label="isVisible ? '隐藏内容' : '显示内容'"
@click="toggleVisibility"
>
<EyeOff v-if="isVisible" class="h-4 w-4" />
<Eye v-else class="h-4 w-4" />
</button>
</div>
<input
v-else
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
:data-lpignore="disableAutofill ? 'true' : undefined"
:data-1p-ignore="disableAutofill ? 'true' : undefined"
:data-form-type="disableAutofill ? 'other' : undefined"
v-bind="$attrs"
:data-lpignore="shouldDisableAutofill ? 'true' : undefined"
:data-1p-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-form-type="shouldDisableAutofill ? 'other' : undefined"
:data-protonpass-ignore="shouldDisableAutofill ? 'true' : undefined"
:data-bwignore="shouldDisableAutofill ? 'true' : undefined"
:data-bitwarden-watching="shouldDisableAutofill ? 'false' : undefined"
:name="shouldDisableAutofill ? randomName : undefined"
v-bind="filteredAttrs"
@input="handleInput"
>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, useAttrs, ref } from 'vue'
import { Eye, EyeOff } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
// 开发环境警告type="password" 已被弃用
const warnPasswordType = import.meta.env.DEV
? (() => {
let warned = false
return () => {
if (!warned) {
warned = true
console.warn(
'[Input] type="password" 已被弃用,请使用 masked 属性代替。\n' +
'示例:<Input v-model="apiKey" masked />\n' +
'masked 属性使用 CSS 遮蔽而非 password 类型,不会触发浏览器密码管理器。'
)
}
}
})()
: () => {}
interface Props {
modelValue?: string | number
class?: string
autocomplete?: string
/**
* 遮蔽显示内容(用于 API Key 等敏感信息)
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
* 注意Firefox 不支持 -webkit-text-security会显示明文但仍可通过按钮切换
*/
masked?: boolean
/**
* 禁用浏览器自动填充
* - true: 禁用自动填充
* - false: 允许自动填充(默认)
*/
disableAutofill?: boolean
}
const props = defineProps<Props>()
const attrs = useAttrs()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const isVisible = ref(false)
// 判断是否有值
const hasValue = computed(() => {
return props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
})
function toggleVisibility() {
isVisible.value = !isVisible.value
}
// 计算是否应该禁用自动填充
const shouldDisableAutofill = computed(() => {
// masked 模式默认禁用自动填充
if (props.masked && props.disableAutofill === undefined) {
return true
}
return props.disableAutofill ?? false
})
// 始终使用 text 类型,永远不用 password
const effectiveType = computed(() => {
const attrType = attrs.type as string | undefined
// 如果传入 password强制转为 text配合 masked 使用)
if (attrType === 'password') {
warnPasswordType()
return 'text'
}
return attrType
})
// 过滤掉 type 和 class 属性,因为我们会单独处理
const filteredAttrs = computed(() => {
const { type, class: _, ...rest } = attrs
return rest
})
// 生成一个稳定的随机值(组件实例级别)
const randomSuffix = Math.random().toString(36).substring(2, 8)
const randomName = `field_${randomSuffix}`
const autocompleteAttr = computed(() => {
if (props.disableAutofill) {
return 'one-time-code'
// 如果显式设置了 autocomplete 且不禁用自动填充,使用该值
if (props.autocomplete && !shouldDisableAutofill.value) {
return props.autocomplete
}
// 禁用自动填充时,使用浏览器无法识别的随机值
if (shouldDisableAutofill.value) {
return `off-${randomSuffix}`
}
return props.autocomplete ?? 'off'
})
@@ -37,12 +153,25 @@ const autocompleteAttr = computed(() => {
const inputClass = computed(() =>
cn(
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
props.masked && 'pr-10',
props.class
)
)
// 当 masked 为 true 且未显示时,用 CSS 遮蔽文字
const inputStyle = computed(() => {
if (props.masked && !isVisible.value) {
// 使用 -webkit-text-securityChrome, Safari, Edge 支持)
// Firefox 不支持此属性,会显示明文,但仍可通过小眼睛按钮切换
return { '-webkit-text-security': 'disc' }
}
return undefined
})
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
defineExpose({ inputRef })
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 border-t border-border/60 px-4 sm:px-6 py-3 sm:py-4 bg-muted/20">
<!-- 左侧记录范围和每页数量 -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
<span class="font-medium">
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
<span class="font-medium whitespace-nowrap">
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> <span class="text-foreground font-semibold">{{ total }}</span>
</span>
<Select
@@ -11,8 +11,10 @@
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-36 h-9 border-border/60">
<SelectValue />
<SelectTrigger class="w-[120px] h-8 sm:h-9 border-border/60 text-xs sm:text-sm">
<span class="flex-1 text-center">
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem
@@ -27,26 +29,7 @@
</div>
<!-- 右侧分页按钮 -->
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(1)"
>
首页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(current - 1)"
>
上一页
</Button>
<div class="flex flex-wrap items-center justify-center gap-1.5 sm:gap-2 sm:ml-auto">
<!-- 页码按钮智能省略 -->
<template
v-for="page in pageNumbers"
@@ -68,24 +51,24 @@
>{{ page }}</span>
</template>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(current + 1)"
<!-- 页码跳转 -->
<div
v-if="totalPages > 7"
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
>
下一页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</Button>
<span class="hidden sm:inline">跳至</span>
<input
v-model="jumpPageInput"
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="w-12 h-9 px-2 text-center text-sm border border-border/60 rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60"
@keydown.enter="handleJumpPage"
@blur="handleJumpPage"
@input="filterNumericInput"
>
<span class="hidden sm:inline"></span>
</div>
</div>
</div>
</template>
@@ -116,6 +99,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const jumpPageInput = ref('')
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
@@ -175,4 +159,18 @@ function handlePageSizeChange(value: string) {
emit('update:current', 1)
}
}
function handleJumpPage() {
const page = parseInt(jumpPageInput.value)
if (!isNaN(page) && page >= 1 && page <= totalPages.value && page !== props.current) {
emit('update:current', page)
}
jumpPageInput.value = ''
}
function filterNumericInput(event: Event) {
const input = event.target as HTMLInputElement
input.value = input.value.replace(/[^0-9]/g, '')
jumpPageInput.value = input.value
}
</script>