You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

607 lines
33 KiB

<template>
<div>
<!-- 状态横幅 -->
<div class="mb-4 rounded-lg px-4 py-2.5 text-xs flex items-center gap-2" style="background:#ecfdf5;border:1px solid #a7f3d0;color:#047857">
<span class="font-semibold">对象存储已接后端</span>
<span class="text-emerald-400">·</span>
<span>对象存储配置已落库(存 svc_config,与「第三方服务配置 → 存储」共用同一份),启用的「阿里云 OSS」即固件 / 头像上传所用存储。邮件 / 翻译两项仍为演示。</span>
</div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">系统配置</h2>
<p class="text-xs text-slate-400 mt-1">平台级服务配置:① 对象存储(固件 / 头像等文件,默认阿里云 OSS);② 邮件服务(生产环节给厂家发通知邮件);③ 翻译服务(后台配置多语言同步)。</p>
</div>
</div>
<div class="flex gap-4" style="align-items:flex-start">
<!-- 左侧分类 -->
<div style="width:208px;flex-shrink:0">
<div class="flex flex-col gap-1.5">
<div
v-for="c in categories"
:key="c.id"
class="cat-item"
:class="{ active: curCat === c.id }"
@click="curCat = c.id"
>
<div class="cat-icon" :style="{ background: c.color }">
<svg width="17" height="17" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="c.icon" /></svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700">{{ c.label }}</div>
<div class="text-[11px] text-slate-400 truncate">{{ c.desc }}</div>
</div>
<span v-if="svcEnabled(c.id)" class="dot-on" title="已启用"></span>
</div>
</div>
</div>
<!-- 右侧详情 -->
<div class="flex-1 min-w-0">
<!-- ========== 对象存储 ========== -->
<div v-if="curCat === 'storage'">
<!-- 固件存储指向提示 -->
<div class="rounded-xl px-4 py-3 mb-4 flex items-center gap-3" style="background:linear-gradient(135deg,#ecfdf5,#f0fdfa);border:1px solid #a7f3d0">
<div style="width:36px;height:36px;border-radius:10px;background:#10b981;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:19px;height:19px;color:#fff" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-emerald-800">设备固件上传当前指向:{{ activeProvider.name }}</div>
<div class="text-[11px] text-emerald-600 mt-0.5">固件包、用户头像等文件均上传至此存储桶。切换默认存储不影响历史文件 URL。</div>
</div>
<span class="badge badge-success badge-sm text-white">运行中</span>
</div>
<!-- 厂商选择卡片 -->
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-500">选择默认对象存储 <span class="text-slate-400 font-normal">(固件 / 头像等文件上传指向此服务)</span></span>
</div>
<div class="grid grid-cols-4 gap-3 mb-4">
<div
v-for="p in providers"
:key="p.id"
class="provider-card"
:class="{ active: curProvider === p.id, 'is-default': p.id === activeId }"
@click="curProvider = p.id"
>
<div class="flex items-center justify-between">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<span v-if="p.enable" class="text-[10px] text-emerald-500 font-semibold">已配置</span>
<span v-else class="text-[10px] text-slate-300 font-semibold">未配置</span>
</div>
<div class="text-sm font-semibold text-slate-700 mt-2">{{ p.name }}</div>
<div class="text-[11px] text-slate-400 mb-2.5">{{ p.tag }}</div>
<label class="default-pick" :class="{ on: p.id === activeId }" @click.stop="setDefaultById(p.id)">
<input type="radio" class="radio radio-xs radio-primary" :checked="p.id === activeId" @click.stop="setDefaultById(p.id)" />
<span>{{ p.id === activeId ? '默认使用' : '设为默认' }}</span>
</label>
</div>
</div>
<!-- 选中厂商表单 -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="provider-logo" :style="{ background: editProvider.color, width:'44px', height:'44px', fontSize:'15px' }">{{ editProvider.short }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ editProvider.name }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ editProvider.tag }} · 对象存储</p>
</div>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 mr-1">
<span class="text-xs text-slate-500">启用</span>
<input v-model="editProvider.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
</label>
<button class="btn btn-outline btn-sm" @click="testConn">测试连接</button>
<button v-if="curProvider !== activeId" class="btn btn-ghost btn-sm text-indigo-600" @click="setDefault">设为默认存储</button>
<button class="btn btn-primary btn-sm text-white" @click="save">保存</button>
</div>
</div>
<div class="divider text-xs text-slate-400 my-3">连接配置</div>
<div class="grid grid-cols-2 gap-x-5 gap-y-4">
<div v-for="f in editProvider.fields" :key="f.key" class="form-control" :class="{ 'col-span-2': f.wide }">
<label class="label py-1">
<span class="label-text text-sm">
{{ f.label }}<span v-if="f.required" class="text-rose-500 ml-0.5">*</span>
<span v-if="f.encrypted" class="lock-tag ml-1.5">🔒 加密存储</span>
</span>
</label>
<div class="relative">
<input
v-model="f.value"
:type="f.encrypted && !f._show ? 'password' : 'text'"
class="input input-bordered input-sm w-full"
:class="{ 'font-mono': f.mono, 'pr-14': f.encrypted }"
:placeholder="f.placeholder"
/>
<button v-if="f.encrypted" type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
</div>
<span v-if="f.hint" class="text-[11px] text-slate-400 mt-1">{{ f.hint }}</span>
</div>
</div>
<div class="divider text-xs text-slate-400 my-4">上传规则</div>
<div class="grid grid-cols-3 gap-4">
<div class="rule-box">
<div class="text-[11px] text-slate-400">固件包路径前缀</div>
<div class="font-mono text-sm text-slate-700 mt-1">/firmware/</div>
</div>
<div class="rule-box">
<div class="text-[11px] text-slate-400">头像路径前缀</div>
<div class="font-mono text-sm text-slate-700 mt-1">/userAvatar/</div>
</div>
<div class="rule-box">
<div class="text-[11px] text-slate-400">签名 URL 有效期</div>
<div class="font-mono text-sm text-slate-700 mt-1">3600 s</div>
</div>
</div>
</div>
</div>
<!-- ========== 邮件服务 ========== -->
<div v-else-if="curCat === 'mail'">
<!-- 用途说明 -->
<div class="rounded-xl px-4 py-3 mb-4 flex items-center gap-3" style="background:#fffbeb;border:1px solid #fde68a">
<div style="width:36px;height:36px;border-radius:10px;background:#f59e0b;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:19px;height:19px;color:#fff" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-amber-800">生产环节邮件通知</div>
<div class="text-[11px] text-amber-600 mt-0.5">用于给目标厂家发送授权信息、公码批次、设备出货等通知邮件;账号告警也走此通道。</div>
</div>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="provider-logo" :style="{ background: '#f59e0b', width:'44px', height:'44px', fontSize:'13px' }">SMTP</div>
<div>
<h3 class="text-base font-semibold text-slate-800">邮件服务</h3>
<p class="text-xs text-slate-400 mt-0.5">SMTP 发信</p>
</div>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 mr-1">
<span class="text-xs text-slate-500">启用</span>
<input v-model="mail.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
</label>
<button class="btn btn-primary btn-sm text-white" @click="save">保存</button>
</div>
</div>
<div class="divider text-xs text-slate-400 my-3">发信配置</div>
<div class="grid grid-cols-2 gap-x-5 gap-y-4">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">SMTP 主机 <span class="text-rose-500">*</span></span></label>
<input v-model="mail.host" class="input input-bordered input-sm font-mono" placeholder="smtp.exmail.qq.com" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">端口 <span class="text-rose-500">*</span></span></label>
<input v-model="mail.port" class="input input-bordered input-sm font-mono" placeholder="465" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">加密方式</span></label>
<select v-model="mail.secure" class="select select-bordered select-sm">
<option v-for="o in secureOptions" :key="o" :value="o">{{ o }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">发件人显示名</span></label>
<input v-model="mail.fromName" class="input input-bordered input-sm" placeholder="云岩科技" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">发信账号 <span class="text-rose-500">*</span></span></label>
<input v-model="mail.user" class="input input-bordered input-sm font-mono" placeholder="noreply@yunyan.com" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">授权码 / 密码 <span class="lock-tag ml-1.5">🔒 加密存储</span></span></label>
<div class="relative">
<input v-model="mail.pass" :type="mail._showPass ? 'text' : 'password'" class="input input-bordered input-sm w-full font-mono pr-14" placeholder="留空保留原密文" />
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="mail._showPass = !mail._showPass">{{ mail._showPass ? '隐藏' : '显示' }}</button>
</div>
</div>
<div class="form-control col-span-2">
<label class="label py-1"><span class="label-text text-sm">默认抄送 (CC)</span></label>
<input v-model="mail.cc" class="input input-bordered input-sm font-mono" placeholder="多个邮箱用逗号分隔,可留空" />
</div>
</div>
<div class="divider text-xs text-slate-400 my-4">测试发信</div>
<div class="flex items-end gap-3">
<div class="form-control flex-1">
<label class="label py-1"><span class="label-text text-sm">测试收件邮箱</span></label>
<input v-model="mail.testTo" class="input input-bordered input-sm font-mono" placeholder="填一个邮箱,发送一封测试邮件验证配置" />
</div>
<button class="btn btn-outline btn-sm" @click="testMail">发送测试</button>
</div>
</div>
</div>
<!-- ========== 翻译服务 ========== -->
<div v-else-if="curCat === 'translate'">
<!-- 用途说明 -->
<div class="rounded-xl px-4 py-3 mb-4 flex items-center gap-3" style="background:#f0fdfa;border:1px solid #99f6e4">
<div style="width:36px;height:36px;border-radius:10px;background:#14b8a6;display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:19px;height:19px;color:#fff" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" /></svg>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-teal-800">后台配置多语言同步</div>
<div class="text-[11px] text-teal-600 mt-0.5">应用文案、通知 / 邮件模板、公告等后台配置内容,按目标语种自动翻译,保证多语言版本一致。</div>
</div>
<span class="badge badge-sm text-white" style="background:#14b8a6;border:none">默认 {{ tActiveProvider.name }}</span>
</div>
<!-- 厂商选择卡片 -->
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-500">选择默认翻译服务 <span class="text-slate-400 font-normal">(后台多语言同步走此服务)</span></span>
</div>
<div class="grid grid-cols-4 gap-3 mb-4">
<div
v-for="p in tProviders"
:key="p.id"
class="provider-card"
:class="{ active: tCurProvider === p.id, 'is-default': p.id === tActiveId }"
@click="tCurProvider = p.id"
>
<div class="flex items-center justify-between">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<span v-if="p.enable" class="text-[10px] text-emerald-500 font-semibold">已配置</span>
<span v-else class="text-[10px] text-slate-300 font-semibold">未配置</span>
</div>
<div class="text-sm font-semibold text-slate-700 mt-2">{{ p.name }}</div>
<div class="text-[11px] text-slate-400 mb-2.5">{{ p.tag }}</div>
<label class="default-pick" :class="{ on: p.id === tActiveId }" @click.stop="setTDefaultById(p.id)">
<input type="radio" class="radio radio-xs radio-primary" :checked="p.id === tActiveId" @click.stop="setTDefaultById(p.id)" />
<span>{{ p.id === tActiveId ? '默认使用' : '设为默认' }}</span>
</label>
</div>
</div>
<!-- 选中厂商表单 -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="provider-logo" :style="{ background: tEditProvider.color, width:'44px', height:'44px', fontSize:'13px' }">{{ tEditProvider.short }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ tEditProvider.name }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ tEditProvider.tag }} · 机器翻译</p>
</div>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 mr-1">
<span class="text-xs text-slate-500">启用</span>
<input v-model="tEditProvider.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
</label>
<button class="btn btn-outline btn-sm" @click="testTranslate">测试连接</button>
<button v-if="tCurProvider !== tActiveId" class="btn btn-ghost btn-sm text-indigo-600" @click="setTDefaultById(tCurProvider)">设为默认</button>
<button class="btn btn-primary btn-sm text-white" @click="save">保存</button>
</div>
</div>
<div class="divider text-xs text-slate-400 my-3">连接配置</div>
<div class="grid grid-cols-2 gap-x-5 gap-y-4">
<div v-for="f in tEditProvider.fields" :key="f.key" class="form-control" :class="{ 'col-span-2': f.wide }">
<label class="label py-1">
<span class="label-text text-sm">
{{ f.label }}<span v-if="f.required" class="text-rose-500 ml-0.5">*</span>
<span v-if="f.encrypted" class="lock-tag ml-1.5">🔒 加密存储</span>
</span>
</label>
<div class="relative">
<input
v-model="f.value"
:type="f.encrypted && !f._show ? 'password' : 'text'"
class="input input-bordered input-sm w-full"
:class="{ 'font-mono': f.mono, 'pr-14': f.encrypted }"
:placeholder="f.placeholder"
/>
<button v-if="f.encrypted" type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="f._show = !f._show">{{ f._show ? '隐藏' : '显示' }}</button>
</div>
<span v-if="f.hint" class="text-[11px] text-slate-400 mt-1">{{ f.hint }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useToast } from '~/composables/useToast'
import { useApi } from '~/composables/useApi'
definePageMeta({ title: '系统配置' })
const { success, info, error } = useToast() as any
const { webApi } = useApi()
interface Field {
key: string; label: string; value: string
placeholder?: string; hint?: string
required?: boolean; encrypted?: boolean; mono?: boolean; wide?: boolean
_show?: boolean
}
interface Provider {
id: string; name: string; short: string; tag: string; color: string
enable: boolean; fields: Field[]
}
const categories = [
{ id: 'storage', label: '对象存储', desc: '固件 / 头像 / 文件', color: '#6366f1', icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8' },
{ id: 'mail', label: '邮件服务', desc: '生产通知 / 厂家发信', color: '#f59e0b', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ id: 'translate', label: '翻译服务', desc: '后台文案多语言同步', color: '#14b8a6', icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129' },
]
const curCat = ref('storage')
// 邮件服务(生产环节给厂家发送通知邮件)
const mail = reactive({
enable: true,
host: 'smtp.exmail.qq.com',
port: '465',
secure: 'SSL',
user: 'noreply@yunyan.com',
pass: '',
_showPass: false,
fromName: '云岩科技',
cc: 'ops@yunyan.com',
testTo: '',
})
const secureOptions = ['SSL', 'STARTTLS', '不加密']
function testMail() {
if (!mail.testTo) { info('请先填测试收件邮箱'); return }
info(`正在向 ${mail.testTo} 发送测试邮件…(演示)`)
setTimeout(() => success(`测试邮件已发送至 ${mail.testTo}`), 600)
}
function svcEnabled(id: string) {
if (id === 'storage') return true
if (id === 'mail') return mail.enable
if (id === 'translate') return tActiveProvider.value?.enable ?? false
return false
}
// 翻译服务(后台配置内容多语言同步)
const tProviders = reactive<Provider[]>([
{
id: 'volc', name: '火山引擎翻译', short: '火山', tag: 'Volcengine MT', color: '#1664ff', enable: true,
fields: [
{ key: 'region', label: 'Region', value: 'cn-beijing', mono: true, required: true },
{ key: 'accessKey', label: 'AccessKey', value: 'AKLTZjJmODdk****', mono: true, encrypted: true, required: true },
{ key: 'secretKey', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
],
},
{
id: 'aliyun', name: '阿里云翻译', short: '阿里', tag: 'Aliyun MT', color: '#ff6a00', enable: true,
fields: [
{ key: 'region', label: 'Region', value: 'cn-hangzhou', mono: true, required: true },
{ key: 'accessKeyId', label: 'AccessKey ID', value: 'LTAI5txxxxxxxx', mono: true, encrypted: true, required: true },
{ key: 'accessKeySecret', label: 'AccessKey Secret', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
{ key: 'scene', label: '翻译场景', value: 'general', mono: true, hint: 'general / social / ecommerce / tech' },
],
},
{
id: 'google', name: 'Google 翻译', short: 'G', tag: 'Cloud Translation', color: '#ea4335', enable: false,
fields: [
{ key: 'project', label: 'Project ID', value: '', mono: true, required: true },
{ key: 'apiKey', label: 'API Key', value: '', mono: true, encrypted: true, required: true },
],
},
{
id: 'deepl', name: 'DeepL', short: 'DL', tag: 'DeepL API', color: '#0f2b46', enable: false,
fields: [
{ key: 'authKey', label: 'Auth Key', value: '', mono: true, encrypted: true, required: true },
{ key: 'plan', label: '套餐', value: 'free', mono: true, hint: 'free / pro' },
],
},
])
const tActiveId = ref('volc')
const tCurProvider = ref('volc')
const tEditProvider = computed(() => tProviders.find((p) => p.id === tCurProvider.value)!)
const tActiveProvider = computed(() => tProviders.find((p) => p.id === tActiveId.value)!)
function setTDefaultById(id: string) {
const p = tProviders.find((x) => x.id === id)
if (!p) return
tActiveId.value = id
tCurProvider.value = id
p.enable = true
success(`已将「${p.name}」设为默认翻译服务,后台多语言同步将走该服务`)
}
function testTranslate() {
info(`正在测试 ${tEditProvider.value.name} …(演示)`)
setTimeout(() => success(`${tEditProvider.value.name} 连接正常,可调用翻译`), 600)
}
// 对象存储厂商
const providers = reactive<Provider[]>([
{
id: 'aliyun', name: '阿里云 OSS', short: 'OSS', tag: 'Aliyun', color: '#ff6a00', enable: false,
fields: [
{ key: 'endpoint', label: 'Endpoint', value: '', placeholder: 'oss-cn-shanghai.aliyuncs.com', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: '', placeholder: 'yunyan-firmware', mono: true, required: true },
{ key: 'access_key_id', label: 'AccessKey ID', value: '', mono: true, encrypted: true, required: true },
{ key: 'access_key_secret', label: 'AccessKey Secret', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'cdn.yunyan.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
],
},
{
id: 'tencent', name: '腾讯云 COS', short: 'COS', tag: 'Tencent', color: '#00a4ff', enable: true,
fields: [
{ key: 'region', label: 'Region', value: 'ap-guangzhou', mono: true, required: true },
{ key: 'appid', label: 'AppId', value: '1253517901', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: 'yunyan-1253517901', mono: true, required: true },
{ key: 'secretId', label: 'SecretId', value: 'AKIDxxxxxxxx', mono: true, encrypted: true, required: true },
{ key: 'secretKey', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', wide: true, hint: '可选' },
],
},
{
id: 'qiniu', name: '七牛云 Kodo', short: 'KODO', tag: 'Qiniu', color: '#00aaff', enable: false,
fields: [
{ key: 'zone', label: '存储区域', value: '', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: '', mono: true, required: true },
{ key: 'accessKey', label: 'AccessKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'secretKey', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '加速域名', value: '', wide: true },
],
},
{
id: 'minio', name: 'MinIO', short: 'S3', tag: 'S3 兼容', color: '#c72e49', enable: false,
fields: [
{ key: 'endpoint', label: 'Endpoint', value: '', mono: true, required: true, placeholder: 'minio.内网:9000' },
{ key: 'bucket', label: 'Bucket', value: '', mono: true, required: true },
{ key: 'accessKey', label: 'AccessKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'secretKey', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'useSSL', label: '使用 HTTPS', value: 'false', mono: true },
],
},
])
const activeId = ref('aliyun') // 当前固件/默认存储
const curProvider = ref('aliyun')
const editProvider = computed(() => providers.find((p) => p.id === curProvider.value)!)
const activeProvider = computed(() => providers.find((p) => p.id === activeId.value)!)
// ===== 对象存储:真实落库(存 svc_config,storage 类目=6;与「第三方服务配置→存储」共用同一份)=====
// 每个厂商卡片 ↔ 一条 svc_config 行:svcId 固定、provider 为后端识别用的值。
// 后端 api_upload 目前用「启用的 aliyun_oss」那份配置签发固件/头像的直传地址。
interface SvcMeta { svcId: string; provider: string }
const STORAGE_SVC: Record<string, SvcMeta> = {
aliyun: { svcId: 'storage_aliyun_oss', provider: 'aliyun_oss' },
tencent: { svcId: 'storage_tencent_cos', provider: 'tencent_cos' },
qiniu: { svcId: 'storage_qiniu_kodo', provider: 'qiniu_kodo' },
minio: { svcId: 'storage_minio', provider: 'minio' },
}
const STORAGE_CAT = '6' // ServiceCategory: 对象存储
const svcProviderToCard: Record<string, string> = {}
// cardSvcId:各卡片实际要写库的 svc_config.id。默认用 STORAGE_SVC 里的固定 id;
// 若库里已存在同 provider 的行(可能由「第三方服务配置」用别的 id 建的),载入时改用那条行的 id,
// 保存即更新同一行、不产生重复。
const cardSvcId: Record<string, string> = {}
for (const [card, m] of Object.entries(STORAGE_SVC)) {
svcProviderToCard[m.provider] = card
cardSvcId[card] = m.svcId
}
const existingSvcIds = ref<Set<string>>(new Set()) // 已在库里的 svcId → 决定 add / update
// 载入已落库的对象存储配置,回显到各厂商卡片(加密字段回显为空,占位提示"留空不改")。
async function loadStorageConfigs() {
try {
const res = await webApi<{ items?: any[] }>('getsvcconfigs', { app_name: '' })
const items = res?.items || []
let firstEnabled = ''
for (const it of items) {
const cats = String(it.categories || '').split(',').map((s: string) => s.trim())
if (!cats.includes(STORAGE_CAT)) continue
const card = svcProviderToCard[it.provider]
const p = providers.find((x) => x.id === card)
if (!p) continue
existingSvcIds.value.add(it.id)
cardSvcId[card] = it.id // 复用库中该行 id,保存即更新同一行
p.enable = !!it.enable
if (p.enable && !firstEnabled) firstEnabled = card
for (const f of it.fields || []) {
const fld = p.fields.find((x) => x.key === f.key)
if (!fld) continue
// 加密字段后端回传占位 [encrypted],此处留空并提示"留空保留"
fld.value = f.encrypted ? '' : (f.def_value || '')
if (f.encrypted && f.def_value === '[encrypted]') fld.hint = '已配置(留空保留原密文)'
}
}
if (firstEnabled) { activeId.value = firstEnabled }
} catch (e: any) {
info('对象存储配置读取失败(console 接口不可用?):' + (e?.message ?? e))
}
}
onMounted(loadStorageConfigs)
// 保存一个厂商卡片到 svc_config(不存在则新增,存在则更新;加密字段留空=保留原密文)。
async function saveProvider(p: Provider): Promise<boolean> {
const meta = STORAGE_SVC[p.id]
if (!meta) { error('未知的存储厂商: ' + p.id); return false }
const svcId = cardSvcId[p.id] || meta.svcId
const payload = {
id: svcId,
name: p.name,
provider: meta.provider,
categories: STORAGE_CAT,
description: `${p.tag} · 对象存储`,
enable: p.enable,
app_name: '', // 全局默认作用域
fields: p.fields.map((f, i) => ({
key: f.key,
description: f.label,
type: '',
encrypted: !!f.encrypted,
def_value: f.value || '',
sort: i,
})),
}
const method = existingSvcIds.value.has(svcId) ? 'updatesvcconfig' : 'addsvcconfig'
try {
await webApi(method, payload)
existingSvcIds.value.add(svcId)
cardSvcId[p.id] = svcId
return true
} catch (e: any) {
error(e?.message ?? '保存失败')
return false
}
}
function setDefault() {
setDefaultById(curProvider.value)
}
// 设为默认:把该厂商设为当前编辑项并启用,落库后即成为固件/头像上传所用存储。
async function setDefaultById(id: string) {
const p = providers.find((x) => x.id === id)
if (!p) return
activeId.value = id
curProvider.value = id
p.enable = true
if (await saveProvider(p)) {
success(`已将「${p.name}」设为默认对象存储并保存,固件 / 头像上传将指向该存储桶`)
}
}
function testConn() {
info(`连接测试暂未接后端:保存后到「产品 → 版本管理」上传一个固件即可验证 ${editProvider.value.name} 是否可写`)
}
async function save() {
const p = editProvider.value
if (await saveProvider(p)) {
success(`「${p.name}」配置已保存${p.enable ? '并启用' : ''}`)
}
}
</script>
<style scoped>
.cat-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 11px;
border: 1px solid #eef2f7; background: #fff; cursor: pointer; transition: all .15s;
}
.cat-item:hover { border-color: #c7d2fe; background: #f5f3ff; }
.cat-item.active { border-color: #6366f1; background: linear-gradient(135deg,#eef2ff,#f5f3ff); box-shadow: 0 4px 12px rgba(99,102,241,.12); }
.cat-icon { width: 32px; height: 32px; border-radius: 9px; display: flex; align-items: center; justify-content: center; color: #fff; flex-shrink: 0; }
.dot-on { width: 7px; height: 7px; border-radius: 50%; background: #10b981; box-shadow: 0 0 0 3px rgba(16,185,129,.15); flex-shrink: 0; }
.provider-card {
padding: 12px 14px; border-radius: 13px; border: 1.5px solid #eef2f7; background: #fff; cursor: pointer; transition: all .15s;
}
.provider-card:hover { border-color: #c7d2fe; }
.provider-card.active { border-color: #6366f1; background: linear-gradient(135deg,#eef2ff,#faf5ff); box-shadow: 0 4px 14px rgba(99,102,241,.12); }
.provider-card.is-default { box-shadow: 0 0 0 1.5px #10b981 inset; }
.provider-logo { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; font-size: 12px; letter-spacing: -.02em; }
.default-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; background: #dcfce7; color: #15803d; }
.default-pick { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #94a3b8; cursor: pointer; padding: 3px 8px; border-radius: 7px; border: 1px solid #eef2f7; transition: all .15s; }
.default-pick:hover { border-color: #c7d2fe; color: #6366f1; }
.default-pick.on { color: #15803d; background: #dcfce7; border-color: #bbf7d0; }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 6px; border-radius: 5px; }
.rule-box { background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 14px; }
</style>