Files
WeKnora/frontend/src/views/settings/VectorStoreSettings.vue
ochan.kwon 40b74e2efa feat(retriever): activate OpenSearch k-NN driver (PR 3 of 3)
Phase 3 (#1440) gate flip. PR 1 (#1445) + PR 2a (#1481) + PR 2b (#1482)
laid the type prep + driver skeleton + read/write paths as gated dead
code; this PR wires every activation surface so opensearch becomes a
registerable VectorStore engine.

Activation wiring
- internal/types: validEngineTypes / GetVectorStoreTypes (with HNSW
  bounds + knn_engine enum + Immutable hints) / retrieverEngineMapping /
  buildEnvStoreForDriver — every gated surface now recognises
  "opensearch". IndexConfig grows four omitempty HNSW fields (HNSWM /
  HNSWEFConstruction / HNSWEFSearch / KNNEngine), keeping other engines'
  serialised config byte-identical.
- internal/container: createOpenSearchEngine + the switch case in
  createEngineServiceFromStore; the RETRIEVE_DRIVER=opensearch env path
  in initRetrieveEngineRegistry; NewEngineFactory now closes over the
  AuditLogService (the EngineFactory type itself is unchanged).
- internal/application/service/vectorstore_healthcheck.go: a
  testOpenSearchConnection case so CreateStore's connectivity probe
  accepts opensearch instead of returning 400.
- internal/application/repository/retriever/opensearch/transport.go:
  NewOpenSearchClient is exported so the factory and env path can build
  the TLS-hardened client; healthcheck.go reuses the unexported
  probeVersion / probeKNNPlugin for the service-layer probe.

Service-layer validation
- validateOpenSearchIndexConfig validates the HNSW caps (m 2-100,
  ef_construction 2-4096, ef_search 1-10000, knn_engine ∈ lucene|faiss).
  Shards/replicas continue to be enforced by the flat ValidateIndexConfig.
  Create-only: UpdateStore mutates the name only.
- validateConnectionConfig requires addr for opensearch.

Sync implementations (stubs.go shrinks)
- CopyIndices (copy.go) mirrors the Elasticsearch / Qdrant pattern —
  search → BatchSave with the source_id remap for generated questions —
  so dim/keyword routing and the source_id contract come from BatchSave
  for free. embeddingMap is keyed by the *target* SourceID because
  OpenSearch's BatchSave looks up embeddings by SourceID
  (lookupEmbedding), not by chunk_id (the ES driver's convention).
  Pagination is from/size; copies larger than max_result_window
  (default 10000) need the scroll-based async path that lands later.
- BatchUpdateChunkEnabledStatus / BatchUpdateChunkTagID (bulk_update.go)
  group the input by target value and issue one _update_by_query per
  group over the cross-dim <base>_* pattern. Caller values flow through
  bound script params only — never string-interpolated into the Painless
  source — closing the script-injection surface.
- inspectByQueryResponse (byquery.go) mirrors inspectBulkResponse: the
  full failure reason goes to the debug log only; the returned error
  carries the bounded id + type.
- UpdateByQueryParams.Refresh is *bool in opensearch-go v4.6.0 (the same
  shape as DeleteByQuery's quirk), so refresh=wait_for is not
  expressible; we use refresh=true.

Driver-owned audit (DIP)
- A new opensearch.AuditSink interface (with nopSink + WithAuditSink
  functional option) lets the driver emit opensearch.index_created and
  opensearch.reindex_executed events without importing any service
  package — the service layer implements the interface. NewRepository
  takes opts, so existing 4-arg test call sites keep compiling unchanged.
- internal/container/audit_sink.go bridges AuditSink to AuditLogService.
  When the context carries no tenant (the env-path registration ctx
  during boot, for example) the adapter skips the emit with a warning
  rather than silently writing tenant_id=0, which would collide with the
  system-scope sentinel.

Frontend + polish
- FieldSchema (frontend/src/api/vector-store.ts) gains min/max/enum/
  immutable. VectorStoreSettings.vue is now schema-driven: a closed
  `enum` renders a t-select; number inputs use the schema's `:min`/`:max`
  and fall back to the legacy replica-vs-shard heuristic only when the
  schema does not pin them; a danger-coloured warning fires when
  insecure_skip_verify is toggled on (the switch and warning are wrapped
  in a vertical stack so the warning sits on its own row below the switch).
- i18n: labels for hnsw_m / hnsw_ef_construction / hnsw_ef_search /
  knn_engine / insecure_skip_verify plus the warning copy in en-US,
  ko-KR, zh-CN, ru-RU.
- docker-compose.dev.yml: an opensearch profile (single-node 3.3.2 with
  security plugin disabled for dev only). OpenSearch Dashboards lives in a
  separate, opt-in opensearch-ui profile so the heavy UI container is not
  forced up alongside the cluster (the driver e2e is fully curl-verifiable
  against :9200). The new docs/dev/opensearch-integration-test.md covers the
  end-to-end exercise and the single-node guidance (set replicas=0 to keep
  the cluster Green).

Gating-guard tests flipped
- The "OpenSearch is NOT in validEngineTypes / mapping / types list /
  env builder / stubs" guard tests from PR 1 / PR 2 are replaced by
  their positive counterparts in this PR. The test suite was the
  activation checklist; the activation flip is its diff.

Backward compatibility
- Additive everywhere. IndexConfig's new HNSW fields are omitempty so
  other engines' serialised config is byte-identical. Existing
  Elasticsearch / Qdrant / Milvus / Weaviate / Doris / TencentVectorDB
  stores are untouched. No migrations.

Test plan
- go build ./... clean
- go vet ./... clean
- gofmt -l clean on touched files
- go test ./... — only TestOssEnsureBucket_CreateFails (Aliyun OSS
  endpoint), the docreader gRPC tests, and the doris SQL-shape tests
  fail; all three are pre-existing on upstream/main and untouched by
  this PR.
- New tests across internal/types, opensearch, service and container —
  including a full end-to-end env-path test that exercises
  initRetrieveEngineRegistry with RETRIEVE_DRIVER=opensearch against an
  httptest cluster.
2026-05-29 16:32:27 +08:00

971 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vectorstore-settings">
<div class="section-header">
<h2>{{ t('vectorStoreSettings.title') }}</h2>
<p class="section-description">{{ t('vectorStoreSettings.description') }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-container">
<t-loading size="small" />
</div>
<template v-else>
<div class="settings-group">
<div class="section-subheader">
<h3>{{ t('vectorStoreSettings.storesTitle') }}</h3>
<t-button v-if="authStore.hasRole('admin')" theme="primary" variant="outline" size="small" @click="openAddDialog">
<template #icon><add-icon /></template>
{{ t('vectorStoreSettings.addStore') }}
</t-button>
</div>
<!-- 与其它 settings 列表同形左侧 engine 徽章 + 标题 + env pill + 副标题 + 测试动作
env 来源是只读的 (engine_type / connection_config .env 写入所以没有更多菜单
user 来源沿用三点菜单的编辑 / 删除入口测试结果作为卡片底部的彩色条出现 -->
<div v-if="stores.length > 0" class="store-grid">
<div
v-for="store in [...envStores, ...userStores]"
:key="store.id"
class="store-card"
:class="[
`store-card--${store.engine_type}`,
{ 'store-card--env': store.source === 'env' }
]"
>
<div class="store-card__main">
<div
class="store-card__badge"
:class="badgeClass(store.engine_type)"
:style="badgeStyle(store.engine_type)"
:aria-label="store.engine_type"
>
<img
v-if="resolveLogo(store.engine_type)?.mode === 'color'"
:src="resolveLogo(store.engine_type)!.url"
:alt="store.engine_type"
class="store-card__badge-img"
/>
<template v-else-if="!resolveLogo(store.engine_type)">{{ engineInitial(store.engine_type) }}</template>
</div>
<div class="store-card__body">
<div class="store-card__header">
<h3 class="store-card__title" :title="store.name">{{ store.name }}</h3>
<span v-if="store.source === 'env'" class="store-card__pill">
{{ t('vectorStoreSettings.envTag') }}
</span>
<!-- 测试连接收进三点菜单结果走 MessagePlugin toast测试中卡片标题
右侧用一个小 spinner 给出进度反馈避免菜单已经关掉后没有可见状态 -->
<t-loading
v-if="testingId === store.id"
size="14px"
class="store-card__loading"
/>
<t-dropdown
v-if="authStore.hasRole('admin')"
:options="storeActionsFor(store)"
placement="bottom-right"
attach="body"
trigger="click"
@click="(action: any) => handleAction(action, store)"
>
<t-button variant="text" shape="square" size="small" class="store-card__more">
<t-icon name="ellipsis" />
</t-button>
</t-dropdown>
</div>
<div class="store-card__subtitle">
<span class="store-card__type">{{ store.engine_type }}</span>
<template v-if="getStoreEndpoint(store)">
<span class="store-card__sep">·</span>
<span class="store-card__endpoint" :title="getStoreEndpoint(store)">{{ getStoreEndpoint(store) }}</span>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-stores">
<p>{{ t('vectorStoreSettings.emptyDesc') }}</p>
</div>
</div>
</template>
<!-- Add/Edit Dialog -->
<t-dialog
v-model:visible="showDialog"
:header="editingStore ? t('vectorStoreSettings.editStore') : t('vectorStoreSettings.addStore')"
width="580px"
placement="center"
:footer="false"
destroy-on-close
>
<div class="dialog-form-container">
<!-- Edit Mode: immutable info banner + readonly fields -->
<template v-if="editingStore">
<div class="immutable-notice">
<t-icon name="info-circle" size="14px" />
<span>{{ t('vectorStoreSettings.immutableNotice') }}</span>
</div>
<div class="readonly-fields">
<div class="readonly-row">
<span class="readonly-label">{{ t('vectorStoreSettings.engineTypeLabel') }}</span>
<span class="readonly-value">{{ selectedType?.display_name || editingStore.engine_type }}</span>
</div>
<template v-if="selectedType">
<template v-for="field in selectedType.connection_fields" :key="field.name">
<div v-if="field.sensitive || form.connection_config[field.name]" class="readonly-row">
<span class="readonly-label">{{ fieldLabel(field.name) }}</span>
<span class="readonly-value">{{ field.sensitive ? '********' : form.connection_config[field.name] }}</span>
</div>
</template>
</template>
<template v-if="selectedType?.index_fields?.length">
<template v-for="field in selectedType.index_fields" :key="field.name">
<div v-if="form.index_config[field.name]" class="readonly-row">
<span class="readonly-label">{{ fieldLabel(field.name) }}</span>
<span class="readonly-value">{{ form.index_config[field.name] }}</span>
</div>
</template>
</template>
</div>
<div class="form-divider"></div>
</template>
<t-form :data="form" :rules="formRules" label-align="top" @submit="saveStore" class="store-form">
<div class="form-scroll-area">
<!-- Create Mode: engine type + connection fields -->
<template v-if="!editingStore">
<t-form-item :label="t('vectorStoreSettings.engineTypeLabel')" name="engine_type">
<t-select v-model="form.engine_type" @change="onEngineTypeChange">
<t-option
v-for="st in storeTypes"
:key="st.type"
:value="st.type"
:label="st.display_name"
/>
</t-select>
</t-form-item>
</template>
<!-- Name (always editable) -->
<t-form-item :label="t('vectorStoreSettings.nameLabel')" name="name">
<t-input v-model="form.name" :placeholder="t('vectorStoreSettings.namePlaceholder')" />
</t-form-item>
<!-- Create Mode: connection fields -->
<template v-if="!editingStore && selectedType">
<div class="form-divider"></div>
<div class="form-section-label">{{ t('vectorStoreSettings.connectionInfo') }}</div>
<template v-for="field in selectedType.connection_fields" :key="field.name">
<t-form-item
:label="fieldLabel(field.name)"
:name="`connection_config.${field.name}`"
>
<div v-if="field.type === 'boolean'" class="boolean-field">
<t-switch v-model="form.connection_config[field.name]" />
<div
v-if="field.name === 'insecure_skip_verify' && form.connection_config[field.name]"
class="field-warning"
>
{{ t('vectorStoreSettings.insecureSkipVerifyWarning') }}
</div>
</div>
<t-input
v-else-if="field.type === 'string' && field.sensitive"
v-model="form.connection_config[field.name]"
type="password"
placeholder="********"
/>
<t-input-number
v-else-if="field.type === 'number'"
v-model="form.connection_config[field.name]"
:placeholder="field.default != null ? String(field.default) : ' '"
theme="normal"
style="width: 100%;"
/>
<t-input
v-else
v-model="form.connection_config[field.name]"
:placeholder="field.default?.toString() || ''"
/>
</t-form-item>
</template>
<!-- Advanced: index fields -->
<template v-if="selectedType.index_fields?.length">
<div class="form-divider"></div>
<div class="advanced-toggle" @click="showAdvanced = !showAdvanced">
<t-icon :name="showAdvanced ? 'chevron-down' : 'chevron-right'" size="14px" />
<span>{{ t('vectorStoreSettings.advancedIndexConfig') }}</span>
</div>
<template v-if="showAdvanced">
<template v-for="field in selectedType.index_fields" :key="field.name">
<t-form-item :label="fieldLabel(field.name)" :name="`index_config.${field.name}`">
<!-- Closed value set dropdown (e.g. knn_engine) -->
<t-select
v-if="field.enum && field.enum.length"
v-model="form.index_config[field.name]"
:placeholder="field.default?.toString() || ''"
>
<t-option v-for="opt in field.enum" :key="opt" :value="opt" :label="opt" />
</t-select>
<t-input-number
v-else-if="field.type === 'number'"
v-model="form.index_config[field.name]"
:placeholder="field.default?.toString()"
:min="field.min ?? 1"
:max="field.max ?? (isReplicaField(field.name) ? 10 : 64)"
theme="normal"
style="width: 100%;"
/>
<t-input
v-else
v-model="form.index_config[field.name]"
:placeholder="field.default?.toString() || ''"
:maxlength="128"
/>
</t-form-item>
</template>
</template>
</template>
</template>
</div><!-- /.form-scroll-area -->
<!-- Dialog Footer (outside scroll area) -->
<div class="dialog-footer">
<div class="footer-left">
<t-button
v-if="!editingStore"
theme="default"
variant="outline"
:loading="testing"
@click="testFromDialog"
>
{{ testing ? t('vectorStoreSettings.testing') : t('vectorStoreSettings.testConnection') }}
</t-button>
</div>
<div class="footer-right">
<t-button theme="default" variant="base" @click="showDialog = false">{{ t('common.cancel') }}</t-button>
<t-button theme="primary" type="submit" :loading="saving">{{ t('common.save') }}</t-button>
</div>
</div>
</t-form>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import { AddIcon } from 'tdesign-icons-vue-next'
import {
listVectorStores,
listVectorStoreTypes,
createVectorStore,
updateVectorStore,
deleteVectorStore as deleteVectorStoreAPI,
testVectorStoreRaw,
testVectorStoreById,
type VectorStoreEntity,
type VectorStoreTypeInfo,
} from '@/api/vector-store'
import { useAuthStore } from '@/stores/auth'
import { providerLogo } from './providerLogos'
const { t } = useI18n()
const authStore = useAuthStore()
// ===== State =====
const stores = ref<VectorStoreEntity[]>([])
const storeTypes = ref<VectorStoreTypeInfo[]>([])
const loading = ref(false)
const showDialog = ref(false)
const editingStore = ref<VectorStoreEntity | null>(null)
const testing = ref(false)
const testingId = ref<string | null>(null)
const saving = ref(false)
const showAdvanced = ref(false)
const form = ref<{
name: string
engine_type: string
connection_config: Record<string, any>
index_config: Record<string, any>
}>({
name: '',
engine_type: '',
connection_config: {},
index_config: {},
})
// ===== Computed =====
const envStores = computed(() => stores.value.filter(s => s.source === 'env'))
const userStores = computed(() => stores.value.filter(s => s.source === 'user'))
const selectedType = computed(() => storeTypes.value.find(st => st.type === form.value.engine_type))
// Per-store dropdown options. env 来源是 .env 写入的UI 不允许 edit / delete
// 但仍然需要一个"测试连接"入口user 来源叠加 edit / delete。
const storeActionsFor = (store: VectorStoreEntity) => {
const actions: Array<{ content: string; value: string; theme?: 'error' }> = [
{ content: t('vectorStoreSettings.testConnection'), value: 'test' },
]
if (store.source !== 'env') {
actions.push({ content: t('common.edit'), value: 'edit' })
actions.push({ content: t('common.delete'), value: 'delete', theme: 'error' })
}
return actions
}
const formRules = computed(() => {
const rules: Record<string, any[]> = {
name: [{ required: true, message: t('vectorStoreSettings.validation.nameRequired') }],
}
if (!editingStore.value) {
rules.engine_type = [{ required: true, message: t('vectorStoreSettings.validation.engineTypeRequired') }]
if (selectedType.value) {
for (const field of selectedType.value.connection_fields) {
if (field.required) {
rules[`connection_config.${field.name}`] = [
{ required: true, message: t('vectorStoreSettings.validation.fieldRequired', { field: fieldLabel(field.name) }) },
]
}
}
// Index name/collection string fields: pattern validation (optional — empty is allowed)
for (const field of (selectedType.value.index_fields || [])) {
if (field.type === 'string') {
rules[`index_config.${field.name}`] = [
{
validator: (val: string) => !val || indexNamePattern.test(val),
message: t('vectorStoreSettings.validation.indexNamePattern'),
trigger: 'blur',
},
]
}
}
}
}
return rules
})
// Index/collection name pattern: must start with letter, alphanumeric + _ + - only, max 128
const indexNamePattern = /^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/
// ===== Methods =====
const fieldLabel = (name: string): string => {
const key = `vectorStoreSettings.fields.${name}`
const translated = t(key)
// If i18n key not found, vue-i18n returns the key itself — fall back to field name
return translated === key ? name : translated
}
// Distinguish replica fields (max 10) from shard fields (max 64) for input bounds
const replicaFieldNames = ['number_of_replicas', 'replication_factor', 'replica_number']
const isReplicaField = (name: string): boolean => replicaFieldNames.includes(name)
const getStoreEndpoint = (store: VectorStoreEntity): string => {
const cc = store.connection_config || {}
return cc.addr || cc.host || ''
}
// 卡片徽章首字母。engine_type 都是英文 ASCII直接 charAt。
const engineInitial = (engineType: string): string => {
return (engineType || '?').charAt(0).toUpperCase()
}
// 当 engine 有 logo 资源时,把 SVG URL 透传给 CSS::before 用 mask-image
// 渲染),并把卡片底色切回中性白;没有 logo 时返回空对象,沿用每个 engine
// 的品牌色 monogram 样式。color 模式不需要 mask 染色,所以 url 不上报。
const resolveLogo = (engineType: string) => providerLogo('vectorstore', engineType)
const badgeClass = (engineType: string) => {
const m = resolveLogo(engineType)?.mode
return {
'store-card__badge--logo': !!m,
'store-card__badge--color': m === 'color',
'store-card__badge--mono': m === 'mono',
}
}
const badgeStyle = (engineType: string): Record<string, string> => {
const logo = resolveLogo(engineType)
return logo?.mode === 'mono' ? { '--logo-url': `url("${logo.url}")` } : {}
}
const onEngineTypeChange = () => {
form.value.connection_config = {}
form.value.index_config = {}
showAdvanced.value = false
}
const loadStores = async () => {
try {
const response = await listVectorStores()
if (response.data && Array.isArray(response.data)) {
stores.value = response.data
}
} catch (error) {
console.error('Failed to load vector stores:', error)
}
}
const loadStoreTypes = async () => {
try {
storeTypes.value = await listVectorStoreTypes()
} catch (error) {
console.error('Failed to load vector store types:', error)
}
}
const openAddDialog = () => {
editingStore.value = null
showAdvanced.value = false
form.value = {
name: '',
engine_type: storeTypes.value[0]?.type || '',
connection_config: {},
index_config: {},
}
showDialog.value = true
}
const editStore = (store: VectorStoreEntity) => {
editingStore.value = store
showAdvanced.value = false
form.value = {
name: store.name,
engine_type: store.engine_type,
connection_config: { ...store.connection_config },
index_config: { ...store.index_config },
}
showDialog.value = true
}
const saveStore = async ({ validateResult, firstError }: any) => {
if (validateResult !== true && validateResult !== undefined) {
MessagePlugin.warning(firstError || t('vectorStoreSettings.toasts.errorGeneric'))
return
}
saving.value = true
try {
if (editingStore.value) {
await updateVectorStore(editingStore.value.id!, { name: form.value.name.trim() })
MessagePlugin.success(t('vectorStoreSettings.toasts.storeUpdated'))
} else {
const data: Partial<VectorStoreEntity> = {
name: form.value.name.trim(),
engine_type: form.value.engine_type,
connection_config: { ...form.value.connection_config },
index_config: showAdvanced.value ? { ...form.value.index_config } : {},
}
await createVectorStore(data)
MessagePlugin.success(t('vectorStoreSettings.toasts.storeCreated'))
}
showDialog.value = false
await loadStores()
} catch (error: any) {
const msg = error?.message || t('vectorStoreSettings.toasts.errorGeneric')
if (msg.toLowerCase().includes('already exists') || msg.toLowerCase().includes('duplicate')) {
MessagePlugin.error(t('vectorStoreSettings.toasts.duplicateName'))
} else {
MessagePlugin.error(msg)
}
} finally {
saving.value = false
}
}
const handleAction = (action: { value: string }, store: VectorStoreEntity) => {
if (action.value === 'test') {
testExisting(store)
} else if (action.value === 'edit') {
editStore(store)
} else if (action.value === 'delete') {
confirmDelete(store)
}
}
const confirmDelete = (store: VectorStoreEntity) => {
const dialog = DialogPlugin.confirm({
header: t('vectorStoreSettings.deleteConfirm'),
confirmBtn: t('common.delete'),
cancelBtn: t('common.cancel'),
theme: 'warning',
onConfirm: async () => {
try {
await deleteVectorStoreAPI(store.id!)
MessagePlugin.success(t('vectorStoreSettings.toasts.storeDeleted'))
await loadStores()
} catch (error: any) {
MessagePlugin.error(error?.message || t('vectorStoreSettings.toasts.errorGeneric'))
}
dialog.destroy()
},
})
}
const testExisting = async (store: VectorStoreEntity) => {
testingId.value = store.id!
try {
const res = await testVectorStoreById(store.id!)
if (res.success) {
MessagePlugin.success(t('vectorStoreSettings.toasts.testSuccess'))
} else {
MessagePlugin.error(res.error || t('vectorStoreSettings.toasts.testFailed'))
}
} catch (error: any) {
MessagePlugin.error(error?.message || t('vectorStoreSettings.toasts.testFailed'))
} finally {
testingId.value = null
}
}
const testFromDialog = async () => {
testing.value = true
try {
const data = {
engine_type: form.value.engine_type,
connection_config: { ...form.value.connection_config },
}
const res = await testVectorStoreRaw(data)
if (res.success) {
MessagePlugin.success(t('vectorStoreSettings.toasts.testSuccess'))
} else {
MessagePlugin.error(res.error || t('vectorStoreSettings.toasts.testFailed'))
}
} catch (error: any) {
MessagePlugin.error(error?.message || t('vectorStoreSettings.toasts.testFailed'))
} finally {
testing.value = false
}
}
// ===== Init =====
onMounted(async () => {
loading.value = true
try {
await Promise.all([loadStoreTypes(), loadStores()])
} finally {
loading.value = false
}
})
</script>
<style lang="less" scoped>
.vectorstore-settings {
width: 100%;
}
.section-header {
margin-bottom: 32px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
}
.section-description {
font-size: 14px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 48px 0;
}
.settings-group {
display: flex;
flex-direction: column;
}
.section-subheader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0;
}
}
.store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
// 与 Parser / Storage / Model 等同形:徽章 + 三段式。env 来源走 secondaryContainer
// 底色暗示只读test 按钮做成 text 模式,避免在标题行抢眼。
.store-card {
display: flex;
flex-direction: column;
padding: 14px 14px 14px 12px;
border: 1px solid var(--td-component-stroke);
border-radius: 10px;
background: var(--td-bg-color-container);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
min-width: 0;
&:hover {
border-color: var(--td-brand-color-3, var(--td-brand-color));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
&--env {
background: var(--td-bg-color-secondarycontainer);
&:hover {
border-color: var(--td-component-stroke);
box-shadow: none;
}
}
}
.store-card__main {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.store-card__badge {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
background: rgba(0, 82, 217, 0.1);
color: #0052D9;
}
// 真实品牌 logo 的渲染:保留每个 engine 类的 color 作为品牌色,
// 把背景换成中性白 + 细边框;用 ::before mask-image 把单色 SVG 染成 currentColor。
// 选择器叠了一层 .store-card 是为了胜过 `.store-card--<engine> .store-card__badge`
// 那条更具体的品牌底色规则。
.store-card .store-card__badge--logo {
background: var(--td-bg-color-container, #fff);
box-shadow: inset 0 0 0 1px var(--td-component-stroke);
}
.store-card .store-card__badge--mono::before {
content: '';
width: 22px;
height: 22px;
background-color: currentColor;
-webkit-mask-image: var(--logo-url);
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-image: var(--logo-url);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
.store-card__badge-img {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
// 各 vector engine 配色(覆盖 11 类常见后端,未列出的回落到默认蓝)
.store-card--qdrant .store-card__badge {
background: rgba(225, 38, 38, 0.12);
color: #E12626;
}
.store-card--milvus .store-card__badge {
background: rgba(0, 137, 255, 0.12);
color: #0089FF;
}
.store-card--weaviate .store-card__badge {
background: rgba(7, 192, 95, 0.12);
color: #07A050;
}
.store-card--elasticsearch .store-card__badge,
.store-card--elasticfaiss .store-card__badge {
background: rgba(255, 153, 0, 0.12);
color: #D97706;
}
.store-card--postgres .store-card__badge {
background: rgba(0, 82, 217, 0.1);
color: #0052D9;
}
.store-card--opensearch .store-card__badge {
background: rgba(98, 53, 187, 0.12);
color: #6235BB;
}
.store-card--infinity .store-card__badge {
background: rgba(98, 53, 187, 0.12);
color: #6235BB;
}
.store-card--tencent_vectordb .store-card__badge {
background: rgba(0, 82, 217, 0.1);
color: #0052D9;
}
.store-card--doris .store-card__badge {
background: rgba(255, 90, 0, 0.12);
color: #E55A00;
}
.store-card--sqlite .store-card__badge {
background: rgba(70, 70, 70, 0.1);
color: #464646;
}
.store-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.store-card__header {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.store-card__title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-card__pill {
flex-shrink: 0;
padding: 1px 6px;
font-size: 11px;
font-weight: 500;
line-height: 16px;
border-radius: 3px;
color: var(--td-warning-color-7, #B85C00);
background: var(--td-warning-color-1, #FEF3E6);
}
// 测试中 spinnertoast 弹出前的进度反馈)
.store-card__loading {
flex-shrink: 0;
color: var(--td-text-color-placeholder);
}
.store-card__more {
flex-shrink: 0;
color: var(--td-text-color-placeholder);
padding: 2px;
opacity: 0;
transition: opacity 0.15s ease;
&:hover,
&:focus-visible {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
}
.store-card:hover .store-card__more,
.store-card:focus-within .store-card__more {
opacity: 1;
}
.store-card__subtitle {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--td-text-color-secondary);
min-width: 0;
}
.store-card__type {
font-weight: 500;
}
.store-card__sep {
color: var(--td-text-color-placeholder);
}
.store-card__endpoint {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 11px;
color: var(--td-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.empty-stores {
padding: 48px 32px;
text-align: center;
color: var(--td-text-color-placeholder);
border: 1px dashed var(--td-component-stroke);
border-radius: 8px;
font-size: 14px;
}
// Dialog
.dialog-form-container {
margin-top: 12px;
display: flex;
flex-direction: column;
max-height: 70vh;
}
.store-form {
display: flex;
flex-direction: column;
min-height: 0;
}
.form-scroll-area {
flex: 1;
overflow-y: auto;
padding-right: 12px;
}
.immutable-notice {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
background: rgba(7, 192, 95, 0.1);
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
color: var(--td-brand-color);
white-space: pre-line;
}
/* Wrap switch + warning in a vertical stack so the warning sits on its own
line below the switch, independent of TDesign's form-item content flex
(which is a nowrap row — margin-top alone has no visible effect there). */
.boolean-field {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.field-warning {
margin-top: 8px;
font-size: 12px;
line-height: 1.4;
color: var(--td-error-color, #d54941);
}
.readonly-fields {
padding: 10px 14px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 6px;
margin-bottom: 16px;
}
.readonly-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 12px;
line-height: 1.4;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
border-bottom: none;
}
}
.readonly-label {
color: var(--td-text-color-placeholder);
font-size: 11px;
white-space: nowrap;
min-width: 60px;
}
.readonly-value {
color: var(--td-text-color-primary);
font-size: 12px;
font-family: var(--app-font-family-mono);
word-break: break-all;
}
.form-divider {
height: 1px;
background: var(--td-component-border);
margin: 20px 0;
}
.form-section-label {
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-secondary);
margin-bottom: 12px;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--td-text-color-secondary);
cursor: pointer;
user-select: none;
margin-bottom: 12px;
&:hover {
color: var(--td-brand-color);
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--td-component-border);
.footer-right {
display: flex;
gap: 12px;
}
}
</style>