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.
 
 
 
 
 
 

393 lines
19 KiB

<template>
<div>
<!-- 顶部 -->
<div class="flex items-center gap-2 mb-1">
<button v-if="view === 'edit'" class="btn btn-ghost btn-sm" @click="backToCards">← 返回</button>
<h2 class="text-lg font-semibold text-slate-800">系统配置<span v-if="view === 'edit'" class="text-slate-400 font-normal"> / {{ curCat.label }}</span></h2>
</div>
<p class="text-xs text-slate-400 mb-5">
平台自用服务配置(与「第三方服务配置」分库存储,不下发客户端):对象存储(固件 / 头像直传)、邮件服务(生产通知发信)、翻译服务(后台文案多语言)。
</p>
<!-- ========== 卡片网格(点击进入编辑) ========== -->
<div v-if="view === 'cards'" class="grid grid-cols-3 gap-4">
<div v-for="cat in CATALOG" :key="cat.id" class="cat-card" @click="openCat(cat.id)">
<div class="flex items-start justify-between">
<div class="cat-icon" :style="{ background: cat.color }">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="cat.icon" /></svg>
</div>
<span v-if="catEnabled(cat.id)" class="badge badge-success badge-sm text-white">已启用</span>
<span v-else class="badge badge-ghost badge-sm">未配置</span>
</div>
<div class="text-base font-semibold text-slate-800 mt-3">{{ cat.label }}</div>
<div class="text-xs text-slate-400 mt-1">{{ cat.desc }}</div>
<div class="text-[11px] text-slate-400 mt-3 pt-3 border-t border-slate-100">
<span v-if="catDefaultName(cat.id)">当前:<b class="text-slate-600">{{ catDefaultName(cat.id) }}</b></span>
<span v-else class="text-slate-300">尚未配置,点击设置</span>
</div>
</div>
</div>
<!-- ========== 编辑视图 ========== -->
<div v-else-if="view === 'edit'">
<!-- 多 provider 类目(对象存储 / 翻译):provider 选择卡片 + 表单 -->
<template v-if="curCat.multi">
<div class="text-xs font-semibold text-slate-500 mb-2">选择服务商 <span class="text-slate-400 font-normal">(勾选设为默认,平台调用走默认项)</span></div>
<div class="grid grid-cols-4 gap-3 mb-5">
<div
v-for="p in curProviders"
:key="p.id"
class="provider-card"
:class="{ active: curProviderId === p.id, 'is-default': p.is_default }"
@click="curProviderId = 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.is_default }" @click.stop="setDefault(p)">
<input type="radio" class="radio radio-xs radio-primary" :checked="p.is_default" @click.stop="setDefault(p)" />
<span>{{ p.is_default ? '默认使用' : '设为默认' }}</span>
</label>
</div>
</div>
</template>
<!-- 表单卡片 -->
<div v-if="curService" class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 max-w-3xl">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="provider-logo" :style="{ background: curService.color, width:'44px', height:'44px', fontSize:'14px' }">{{ curService.short }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ curService.name }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ curService.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="curService.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
</label>
<button class="btn btn-primary btn-sm text-white" :disabled="saving" @click="saveCur">
<span v-if="saving" class="loading loading-spinner loading-xs"></span>保存
</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 curService.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>
<select v-if="f.type === 'select'" v-model="f.value" class="select select-bordered select-sm">
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<div v-else 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>
<!-- 邮件:测试发信 -->
<template v-if="curCat.id === 'email'">
<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="testTo" class="input input-bordered input-sm font-mono" placeholder="填一个邮箱,发送测试邮件验证配置(需先保存)" />
</div>
<button class="btn btn-outline btn-sm" :disabled="testing" @click="testEmail">
<span v-if="testing" class="loading loading-spinner loading-xs"></span>发送测试
</button>
</div>
</template>
</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 FieldOption { value: string; label: string }
interface Field {
key: string; label: string; value: string
type?: 'text' | 'select'; options?: FieldOption[]
placeholder?: string; hint?: string
required?: boolean; encrypted?: boolean; mono?: boolean; wide?: boolean
_show?: boolean
}
interface ServiceDef {
id: string; provider: string; name: string; short: string; tag: string; color: string
enable: boolean; is_default: boolean; fields: Field[]
}
interface CatDef {
id: string; label: string; desc: string; color: string; icon: string
multi: boolean; services: ServiceDef[]
}
// ── 服务目录(前端定义 schema;值从后端 getsysconfigs 合并回来) ──
function mk(id: string, provider: string, name: string, short: string, tag: string, color: string, fields: Field[]): ServiceDef {
return { id, provider, name, short, tag, color, enable: false, is_default: false, fields }
}
const CATALOG = reactive<CatDef[]>([
{
id: 'storage', label: '对象存储', desc: '固件 / 头像等文件直传(默认阿里云 OSS)', color: '#6366f1',
icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8',
multi: true,
services: [
mk('storage_aliyun_oss', 'aliyun_oss', '阿里云 OSS', 'OSS', 'Aliyun · 固件/头像上传所用', '#ff6a00', [
{ 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 },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'cdn.yunyan.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
]),
mk('storage_tencent_cos', 'tencent_cos', '腾讯云 COS', 'COS', 'Tencent', '#00a4ff', [
{ key: 'region', label: 'Region', value: '', placeholder: 'ap-guangzhou', mono: true, required: true },
{ key: 'appid', label: 'AppId', value: '', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: '', mono: true, required: true },
{ key: 'secret_id', label: 'SecretId', value: '', mono: true, encrypted: true, required: true },
{ key: 'secret_key', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', wide: true, hint: '可选' },
]),
],
},
{
id: 'email', label: '邮件服务', desc: '生产通知 / 账号告警发信(支持 SMTP 与 Resend)', 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',
multi: false,
services: [
mk('email', 'auto', '邮件服务', 'MAIL', '生产通知 / 账号告警', '#f59e0b', [
{ key: 'EmailType', label: '邮箱类型', value: '1', type: 'select', required: true, options: [
{ value: '0', label: 'QQ 邮箱 (SMTP)' },
{ value: '1', label: '腾讯企业邮箱 (SMTP)' },
{ value: '2', label: 'Gmail (SMTP)' },
{ value: '3', label: 'Resend(HTTP API,海外送达率优)' },
] },
{ key: 'FromEmail', label: '发件人邮箱', value: '', mono: true, required: true, placeholder: 'noreply@yourdomain.com', hint: 'Resend 须为已在其后台验证域名下的地址' },
{ key: 'FromName', label: '发件人昵称', value: '', placeholder: '云岩科技' },
{ key: 'Password', label: 'SMTP 授权码 / Resend API Key', value: '', mono: true, encrypted: true, required: true, hint: 'SMTP 类型填邮箱授权码;Resend 填 re_ 开头的 API Key' },
]),
],
},
{
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',
multi: true,
services: [
mk('translate_volc', 'volc', '火山引擎翻译', '火山', 'Volcengine MT', '#1664ff', [
{ key: 'region', label: 'Region', value: 'cn-beijing', mono: true, required: true },
{ key: 'access_key', label: 'AccessKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'secret_key', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
]),
mk('translate_aliyun', 'aliyun_mt', '阿里云翻译', '阿里', 'Aliyun MT', '#ff6a00', [
{ key: 'region', label: 'Region', value: 'cn-hangzhou', 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 },
]),
mk('translate_google', 'google_mt', 'Google 翻译', 'G', 'Cloud Translation', '#ea4335', [
{ key: 'project', label: 'Project ID', value: '', mono: true, required: true },
{ key: 'api_key', label: 'API Key', value: '', mono: true, encrypted: true, required: true },
]),
mk('translate_deepl', 'deepl', 'DeepL', 'DL', 'DeepL API', '#0f2b46', [
{ key: 'auth_key', label: 'Auth Key', value: '', mono: true, encrypted: true, required: true },
]),
],
},
])
// ── 视图状态 ──
const view = ref<'cards' | 'edit'>('cards')
const editCatId = ref('')
const curProviderId = ref('')
const saving = ref(false)
const testing = ref(false)
const testTo = ref('')
const curCat = computed(() => CATALOG.find((c) => c.id === editCatId.value) || CATALOG[0])
const curProviders = computed(() => curCat.value.services)
const curService = computed(() => {
if (!curCat.value.multi) return curCat.value.services[0]
return curCat.value.services.find((s) => s.id === curProviderId.value) || curCat.value.services[0]
})
// ── 卡片状态计算 ──
function catServices(catId: string) {
return CATALOG.find((c) => c.id === catId)?.services || []
}
function catEnabled(catId: string) {
return catServices(catId).some((s) => s.enable)
}
function catDefaultName(catId: string) {
const cat = CATALOG.find((c) => c.id === catId)
if (!cat) return ''
if (!cat.multi) {
const s = cat.services[0]
return s.enable ? s.name : ''
}
const def = cat.services.find((s) => s.is_default && s.enable)
return def ? def.name : ''
}
function openCat(catId: string) {
editCatId.value = catId
const cat = CATALOG.find((c) => c.id === catId)!
// 进入时选中默认项,无默认则第一项
const def = cat.services.find((s) => s.is_default) || cat.services[0]
curProviderId.value = def.id
view.value = 'edit'
}
function backToCards() {
view.value = 'cards'
}
// ── 载入已落库配置 ──
async function loadConfigs() {
try {
const res = await webApi<{ items?: any[] }>('getsysconfigs', {})
const items = res?.items || []
const byId: Record<string, any> = {}
for (const it of items) byId[it.id] = it
for (const cat of CATALOG) {
for (const svc of cat.services) {
const row = byId[svc.id]
if (!row) continue
svc.enable = !!row.enable
svc.is_default = !!row.is_default
for (const rf of row.fields || []) {
const fld = svc.fields.find((x) => x.key === rf.key)
if (!fld) continue
if (rf.encrypted) {
fld.value = '' // 加密字段后端脱敏为 [encrypted],留空表示保留
if (rf.def_value === '[encrypted]') fld.hint = (fld.hint ? fld.hint + ';' : '') + '已配置(留空保留原密文)'
} else {
fld.value = rf.def_value || ''
}
}
}
}
} catch (e: any) {
info('系统配置读取失败:' + (e?.message ?? e))
}
}
onMounted(loadConfigs)
// ── 保存 ──
async function saveCur() {
const svc = curService.value
if (!svc) return
const cat = curCat.value
saving.value = true
const payload = {
id: svc.id,
category: cat.id,
name: svc.name,
provider: svc.provider,
enable: svc.enable,
is_default: cat.multi ? svc.is_default : true, // 单例类目(邮件)恒为默认
fields: svc.fields.map((f, i) => ({
key: f.key,
description: f.label,
type: f.type === 'select' ? 'select' : '',
encrypted: !!f.encrypted,
def_value: f.value || '',
sort: i,
})),
}
try {
await webApi('savesysconfig', payload)
success(`「${svc.name}」配置已保存${svc.enable ? '并启用' : ''}`)
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
// ── 设为默认(多 provider 类目):先切本地默认标记,落库后端保证唯一 ──
async function setDefault(p: ServiceDef) {
const cat = curCat.value
cat.services.forEach((s) => { s.is_default = s.id === p.id })
p.enable = true
curProviderId.value = p.id
try {
// 先确保该服务已落库(保存当前字段),再置默认
await saveCur2(p, cat)
await webApi('setsysdefault', { id: p.id, category: cat.id })
success(`已将「${p.name}」设为默认`)
} catch (e: any) {
error(e?.message ?? '设置失败')
}
}
// saveCur2:保存指定服务(setDefault 用,避免依赖 curService 计算态)
async function saveCur2(svc: ServiceDef, cat: CatDef) {
const payload = {
id: svc.id, category: cat.id, name: svc.name, provider: svc.provider,
enable: true, is_default: true,
fields: svc.fields.map((f, i) => ({
key: f.key, description: f.label, type: f.type === 'select' ? 'select' : '',
encrypted: !!f.encrypted, def_value: f.value || '', sort: i,
})),
}
await webApi('savesysconfig', payload)
}
// ── 邮件测试发信 ──
async function testEmail() {
if (!testTo.value.trim()) { info('请先填测试收件邮箱'); return }
testing.value = true
try {
await webApi('testsysemail', { to: testTo.value.trim() })
success(`测试邮件已发送至 ${testTo.value.trim()}`)
} catch (e: any) {
error(e?.message ?? '发送失败')
} finally {
testing.value = false
}
}
</script>
<style scoped>
.cat-card {
padding: 18px 20px; border-radius: 16px; border: 1px solid #eef2f7; background: #fff; cursor: pointer; transition: all .15s;
}
.cat-card:hover { border-color: #c7d2fe; box-shadow: 0 8px 24px rgba(99,102,241,.10); transform: translateY(-2px); }
.cat-icon { width: 46px; height: 46px; border-radius: 13px; display: flex; align-items: center; justify-content: center; color: #fff; 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-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; }
</style>