feat(interconnect): sync environment variables with original structure and IDs, and translate UI badges

This commit is contained in:
duorameng
2026-06-26 09:23:20 +08:00
parent 898cb64085
commit bdeda51b22
4 changed files with 205 additions and 66 deletions
+87
View File
@@ -2,6 +2,8 @@ package controllers
import ( import (
"github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/models/vo" "github.com/engigu/baihu-panel/internal/models/vo"
"github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/services"
"github.com/engigu/baihu-panel/internal/services/relation" "github.com/engigu/baihu-panel/internal/services/relation"
@@ -300,3 +302,88 @@ func (ec *EnvController) GetTags(c *gin.Context) {
} }
utils.Success(c, tags) utils.Success(c, tags)
} }
// BulkSaveEnv 批量保存环境变量
func (ec *EnvController) BulkSaveEnv(c *gin.Context) {
var reqs []struct {
ID string `json:"id"`
Name string `json:"name" binding:"required"`
Value string `json:"value" binding:"required"`
Remark string `json:"remark"`
Type string `json:"type"`
Hidden *bool `json:"hidden"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&reqs); err != nil {
utils.BadRequest(c, err.Error())
return
}
userID := c.GetString("userID")
for _, req := range reqs {
if req.Type == "secret" {
continue // 二次严苛拦截,机密变量不应下发/保存
}
hidden := true
if req.Hidden != nil {
hidden = *req.Hidden
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
var existingEnv *models.EnvironmentVariable
// 优先按 ID 匹配
if req.ID != "" {
var e models.EnvironmentVariable
if err := database.DB.Where("id = ?", req.ID).First(&e).Error; err == nil {
existingEnv = &e
}
}
// 如果 ID 没找到,按 Name 匹配
if existingEnv == nil {
var e models.EnvironmentVariable
if err := database.DB.Where("name = ?", req.Name).First(&e).Error; err == nil {
existingEnv = &e
}
}
if existingEnv != nil {
existingEnv.Name = req.Name
existingEnv.Value = models.BigText(req.Value)
existingEnv.Remark = req.Remark
existingEnv.Type = req.Type
existingEnv.Hidden = &hidden
existingEnv.Enabled = &enabled
database.DB.Save(existingEnv)
if req.ID != "" && existingEnv.ID != req.ID {
database.DB.Model(existingEnv).Update("id", req.ID)
}
} else {
envVar := &models.EnvironmentVariable{
ID: req.ID,
Name: req.Name,
Value: models.BigText(req.Value),
Remark: req.Remark,
Type: req.Type,
Hidden: &hidden,
Enabled: &enabled,
UserID: userID,
CreatedAt: models.Now(),
UpdatedAt: models.Now(),
}
if envVar.ID == "" {
envVar.ID = utils.GenerateID()
}
database.DB.Create(envVar)
}
}
services.GetAgentWSManager().BroadcastTasksToAll()
utils.Success(c, nil)
}
+46 -33
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/constant"
"github.com/engigu/baihu-panel/internal/database"
"github.com/engigu/baihu-panel/internal/models" "github.com/engigu/baihu-panel/internal/models"
"github.com/engigu/baihu-panel/internal/models/vo" "github.com/engigu/baihu-panel/internal/models/vo"
"github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/services"
@@ -271,9 +272,13 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
var req struct { var req struct {
NodeIDs []string `json:"node_ids" binding:"required"` NodeIDs []string `json:"node_ids" binding:"required"`
Envs []struct { Envs []struct {
Name string `json:"name"` ID string `json:"id"`
Value string `json:"value"` Name string `json:"name"`
Remark string `json:"remark"` Value string `json:"value"`
Remark string `json:"remark"`
Type string `json:"type"`
Hidden *bool `json:"hidden"`
Enabled *bool `json:"enabled"`
} `json:"envs" binding:"required"` } `json:"envs" binding:"required"`
} }
@@ -282,6 +287,27 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
return return
} }
// Filter out secret variables securely using the database
var safeEnvs []interface{}
for _, envReq := range req.Envs {
var env models.EnvironmentVariable
query := database.DB
if envReq.ID != "" {
query = query.Where("id = ? OR name = ?", envReq.ID, envReq.Name)
} else {
query = query.Where("name = ?", envReq.Name)
}
if err := query.First(&env).Error; err == nil {
if env.Type == "secret" {
continue // 坚决阻断下发机密数据
}
}
if envReq.Type == "secret" {
continue // 坚决阻断下发机密数据
}
safeEnvs = append(safeEnvs, envReq)
}
results := make([]map[string]interface{}, 0) results := make([]map[string]interface{}, 0)
for _, nodeID := range req.NodeIDs { for _, nodeID := range req.NodeIDs {
@@ -291,44 +317,31 @@ func (ic *InterconnectController) SyncEnv(c *gin.Context) {
continue continue
} }
client, apiURL, err := ic.getClientAndURL(node, "/api/v1/env") client, apiURL, err := ic.getClientAndURL(node, "/api/v1/env/bulk_save")
if err != nil { if err != nil {
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
continue continue
} }
successCount := 0 payloadBytes, _ := json.Marshal(safeEnvs)
for _, env := range req.Envs {
payload := map[string]interface{}{
"name": env.Name,
"value": env.Value,
"remark": env.Remark,
"type": "normal",
}
payloadBytes, _ := json.Marshal(payload)
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil { if err != nil {
continue results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
} continue
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err == nil && resp.StatusCode == 200 {
successCount++
}
if resp != nil {
resp.Body.Close()
}
} }
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
httpReq.Header.Set("Content-Type", "application/json")
results = append(results, map[string]interface{}{ resp, err := client.Do(httpReq)
"node_id": nodeID, if err != nil || resp.StatusCode != 200 {
"success": true, results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
"msg": "同步完成", } else {
"count": successCount, results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
}) }
if resp != nil {
resp.Body.Close()
}
} }
utils.Success(c, results) utils.Success(c, results)
+1
View File
@@ -116,6 +116,7 @@ func registerEnvRoutes(g *gin.RouterGroup, c *Controllers) {
env.GET("/secret-status", c.Env.GetSecretStatus) env.GET("/secret-status", c.Env.GetSecretStatus)
env.GET("/tags", c.Env.GetTags) env.GET("/tags", c.Env.GetTags)
env.POST("", c.Env.CreateEnvVar) env.POST("", c.Env.CreateEnvVar)
env.POST("/bulk_save", c.Env.BulkSaveEnv)
env.GET("", c.Env.GetEnvVars) env.GET("", c.Env.GetEnvVars)
env.GET("/all", c.Env.GetAllEnvVars) env.GET("/all", c.Env.GetAllEnvVars)
env.GET("/:id", c.Env.GetEnvVar) env.GET("/:id", c.Env.GetEnvVar)
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect' import * as interconnectApi from '@/api/interconnect'
import { api, type EnvVar, type Task } from '@/api' import { api, type EnvVar, type Task } from '@/api'
import { Network, Search, HardDrive } from 'lucide-vue-next' import { Network, Search, HardDrive, Check } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
nodes: interconnectApi.InterconnectNode[] nodes: interconnectApi.InterconnectNode[]
@@ -62,12 +61,13 @@ const filteredEnvs = computed(() => {
return localEnvs.value.filter((e: EnvVar) => e.name.toLowerCase().includes(kw) || e.value.toLowerCase().includes(kw)) return localEnvs.value.filter((e: EnvVar) => e.name.toLowerCase().includes(kw) || e.value.toLowerCase().includes(kw))
}) })
function toggleNode(nodeId: string) { function toggleNode(nodeId: any) {
const index = selectedNodes.value.indexOf(nodeId) const idStr = String(nodeId)
const index = selectedNodes.value.indexOf(idStr)
if (index > -1) { if (index > -1) {
selectedNodes.value.splice(index, 1) selectedNodes.value.splice(index, 1)
} else { } else {
selectedNodes.value.push(nodeId) selectedNodes.value.push(idStr)
} }
} }
@@ -75,16 +75,17 @@ function toggleAllNodes() {
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) { if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
selectedNodes.value = [] selectedNodes.value = []
} else { } else {
selectedNodes.value = props.nodes.map(n => n.id) selectedNodes.value = props.nodes.map(n => String(n.id))
} }
} }
function toggleTask(id: string) { function toggleTask(id: any) {
const index = selectedTasks.value.indexOf(id) const idStr = String(id)
const index = selectedTasks.value.indexOf(idStr)
if (index > -1) { if (index > -1) {
selectedTasks.value.splice(index, 1) selectedTasks.value.splice(index, 1)
} else { } else {
selectedTasks.value.push(id) selectedTasks.value.push(idStr)
} }
} }
@@ -92,24 +93,25 @@ function toggleAllTasks() {
if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) { if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) {
selectedTasks.value = [] selectedTasks.value = []
} else { } else {
selectedTasks.value = filteredTasks.value.map((t: Task) => t.id) selectedTasks.value = filteredTasks.value.map((t: Task) => String(t.id))
} }
} }
function toggleEnv(id: string) { function toggleEnv(id: any) {
const index = selectedEnvsIds.value.indexOf(id) const idStr = String(id)
const index = selectedEnvsIds.value.indexOf(idStr)
if (index > -1) { if (index > -1) {
selectedEnvsIds.value.splice(index, 1) selectedEnvsIds.value.splice(index, 1)
} else { } else {
selectedEnvsIds.value.push(id) selectedEnvsIds.value.push(idStr)
} }
} }
function toggleAllEnvs() { function toggleAllEnvs() {
if (selectedEnvsIds.value.length === filteredEnvs.value.length && filteredEnvs.value.length > 0) { if (selectedEnvsIds.value.length === filteredEnvs.value.filter((e: EnvVar) => e.type !== 'secret').length && filteredEnvs.value.length > 0) {
selectedEnvsIds.value = [] selectedEnvsIds.value = []
} else { } else {
selectedEnvsIds.value = filteredEnvs.value.map((e: EnvVar) => e.id) selectedEnvsIds.value = filteredEnvs.value.filter((e: EnvVar) => e.type !== 'secret').map((e: EnvVar) => String(e.id))
} }
} }
@@ -136,7 +138,19 @@ async function handleSync() {
toast.warning('请先选择要同步的变量') toast.warning('请先选择要同步的变量')
return return
} }
const envsToSync = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
const allSelectedEnvs = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
const envsToSync = allSelectedEnvs.filter((e: EnvVar) => e.type !== 'secret')
if (allSelectedEnvs.length > envsToSync.length) {
toast.info('已自动过滤机密变量,因下发无意义')
}
if (envsToSync.length === 0) {
syncing.value = false
return
}
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync) const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
showSyncResult(res) showSyncResult(res)
} }
@@ -161,7 +175,7 @@ function showSyncResult(res: any[]) {
<template> <template>
<div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-16rem)] min-h-[500px]"> <div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-16rem)] min-h-[500px]">
<!-- 左侧节点选择 --> <!-- 左侧节点选择 -->
<div class="w-full lg:w-1/3 flex flex-col border rounded-lg bg-card overflow-hidden"> <div class="w-full lg:w-1/3 flex flex-col border rounded-lg bg-card overflow-hidden h-[180px] lg:h-full shrink-0">
<div class="p-3 border-b bg-muted/20 flex items-center justify-between"> <div class="p-3 border-b bg-muted/20 flex items-center justify-between">
<h3 class="font-medium text-sm flex items-center gap-2"> <h3 class="font-medium text-sm flex items-center gap-2">
<HardDrive class="w-4 h-4 text-muted-foreground" /> <HardDrive class="w-4 h-4 text-muted-foreground" />
@@ -176,8 +190,13 @@ function showSyncResult(res: any[]) {
暂无子节点 暂无子节点
</div> </div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleNode(node.id)"> <div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/node" @click="toggleNode(node.id)">
<Checkbox :checked="selectedNodes.includes(node.id)" @update:checked="toggleNode(node.id)" @click.stop /> <div
class="size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedNodes.includes(String(node.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/node:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedNodes.includes(String(node.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-medium truncate">{{ node.name }}</span> <span class="text-sm font-medium truncate">{{ node.name }}</span>
<span class="text-xs text-muted-foreground truncate">{{ node.url }}</span> <span class="text-xs text-muted-foreground truncate">{{ node.url }}</span>
@@ -186,9 +205,9 @@ function showSyncResult(res: any[]) {
</div> </div>
</div> </div>
</div> </div>
<!-- 右侧数据选择 --> <!-- 右侧数据选择 -->
<div class="w-full lg:w-2/3 flex flex-col border rounded-lg bg-card overflow-hidden"> <div class="w-full lg:w-2/3 flex flex-col border rounded-lg bg-card overflow-hidden flex-1 lg:h-full min-h-[250px] lg:min-h-0">
<div class="p-3 border-b bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div class="p-3 border-b bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex bg-muted p-1 rounded-md"> <div class="flex bg-muted p-1 rounded-md">
<button <button
@@ -207,22 +226,22 @@ function showSyncResult(res: any[]) {
</button> </button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 w-full justify-end sm:w-auto">
<div class="relative w-48 hidden sm:block"> <div class="relative flex-grow sm:flex-grow-0 sm:w-48">
<Search class="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Search class="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input v-model="taskSearch" v-if="activeSyncType === 'task'" placeholder="搜索任务..." class="h-8 pl-7 text-xs" /> <Input v-model="taskSearch" v-if="activeSyncType === 'task'" placeholder="搜索任务..." class="h-8 pl-7 text-xs" />
<Input v-model="envSearch" v-if="activeSyncType === 'env'" placeholder="搜索变量..." class="h-8 pl-7 text-xs" /> <Input v-model="envSearch" v-if="activeSyncType === 'env'" placeholder="搜索变量..." class="h-8 pl-7 text-xs" />
</div> </div>
<Button class="h-8 gap-1.5" size="sm" @click="handleSync" :disabled="syncing"> <Button class="h-8 gap-1.5" size="sm" @click="handleSync" :disabled="syncing">
<Network class="w-3.5 h-3.5" :class="{'animate-pulse': syncing}" /> <Network class="w-3.5 h-3.5" :class="{'animate-pulse': syncing}" />
{{ syncing ? '正在下发...' : '执行下发' }} {{ syncing ? '正在下发...' : '下发' }}
</Button> </Button>
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto p-2"> <div class="flex-1 overflow-y-auto p-2">
<!-- 任务列表 --> <!-- 任务列表 -->
<div v-if="activeSyncType === 'task'" class="space-y-1"> <div v-if="activeSyncType === 'task'" key="task-list" class="space-y-1">
<div class="flex items-center justify-between px-2 py-1 mb-2"> <div class="flex items-center justify-between px-2 py-1 mb-2">
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} </span> <span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} </span>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks"> <Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
@@ -231,12 +250,17 @@ function showSyncResult(res: any[]) {
</div> </div>
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div> <div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div> <div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
<div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleTask(task.id)"> <div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/task" @click="toggleTask(task.id)">
<Checkbox class="mt-1" :checked="selectedTasks.includes(task.id)" @update:checked="toggleTask(task.id)" @click.stop /> <div
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedTasks.includes(String(task.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/task:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedTasks.includes(String(task.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">{{ task.name }}</span> <span class="text-sm font-medium truncate">{{ task.name }}</span>
<span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type }}</span> <span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type === 'task' ? '任务' : (task.type === 'repo' ? '仓库' : task.type) }}</span>
</div> </div>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span> <span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span>
</div> </div>
@@ -244,7 +268,7 @@ function showSyncResult(res: any[]) {
</div> </div>
<!-- 变量列表 --> <!-- 变量列表 -->
<div v-if="activeSyncType === 'env'" class="space-y-1"> <div v-if="activeSyncType === 'env'" key="env-list" class="space-y-1">
<div class="flex items-center justify-between px-2 py-1 mb-2"> <div class="flex items-center justify-between px-2 py-1 mb-2">
<span class="text-xs text-muted-foreground">已选 {{ selectedEnvsIds.length }} </span> <span class="text-xs text-muted-foreground">已选 {{ selectedEnvsIds.length }} </span>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllEnvs"> <Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllEnvs">
@@ -253,11 +277,25 @@ function showSyncResult(res: any[]) {
</div> </div>
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div> <div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div> <div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
<div v-for="env in filteredEnvs" :key="env.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleEnv(env.id)"> <div v-for="env in filteredEnvs" :key="env.id"
<Checkbox class="mt-1" :checked="selectedEnvsIds.includes(env.id)" @update:checked="toggleEnv(env.id)" @click.stop /> class="flex items-start gap-3 p-2 rounded-md select-none group/env"
:class="env.type === 'secret' ? 'opacity-50 cursor-not-allowed bg-muted/20' : 'hover:bg-muted/50 cursor-pointer'"
@click="env.type === 'secret' ? null : toggleEnv(env.id)">
<div
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="[
selectedEnvsIds.includes(String(env.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/env:border-muted-foreground/50 scale-95',
env.type === 'secret' ? 'opacity-50 bg-muted border-muted-foreground/30' : ''
]"
>
<Check v-if="selectedEnvsIds.includes(String(env.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-medium truncate font-mono">{{ env.name }}</span> <div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.value }}</span> <span class="text-sm font-medium truncate">{{ env.name }}</span>
<span v-if="env.type" class="text-[10px] bg-muted px-1.5 py-0.5 rounded" :class="env.type === 'secret' ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground'">{{ env.type === 'secret' ? '机密' : (env.type === 'normal' ? '常量' : env.type) }}</span>
</div>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.type === 'secret' ? '********' : env.value }}</span>
</div> </div>
</div> </div>
</div> </div>