Browse Source

上传部署代码

main
liwei1dao 3 months ago
parent
commit
b493d5b4b1
  1. 2
      apps/admin/.nuxt/manifest/latest.json
  2. 2
      apps/admin/.nuxt/manifest/meta/dev.json
  3. 6
      apps/admin/.nuxt/nitro.json
  4. 2
      apps/admin/.nuxt/nuxt.d.ts
  5. 9
      apps/admin/.nuxt/nuxt.lock
  6. 2
      apps/admin/.nuxt/nuxt.node.d.ts
  7. 2
      apps/admin/.nuxt/tailwind/postcss.mjs
  8. 1
      apps/admin/app/layouts/default.vue
  9. 256
      apps/admin/app/pages/appconfig.vue
  10. 6
      apps/admin/app/pages/apps.vue
  11. 514
      apps/admin/app/pages/systemconfig.vue
  12. BIN
      apps/services/dump.rdb
  13. 9
      apps/services/log/console.log
  14. 10
      apps/services/sys/bytedance/audomodel/sys_test.go
  15. 2
      deploy/voitrans/env/env.example
  16. 9
      deploy/voitrans/prod-build.sh
  17. 226
      deploy/voitrans/prod-deploy.sh

2
apps/admin/.nuxt/manifest/latest.json

@ -1 +1 @@
{"id":"dev","timestamp":1782613791473} {"id":"dev","timestamp":1782719244117}

2
apps/admin/.nuxt/manifest/meta/dev.json

@ -1 +1 @@
{"id":"dev","timestamp":1782613791473,"prerendered":[]} {"id":"dev","timestamp":1782719244117,"prerendered":[]}

6
apps/admin/.nuxt/nitro.json

@ -1,5 +1,5 @@
{ {
"date": "2026-06-28T02:29:52.480Z", "date": "2026-06-29T07:47:25.178Z",
"preset": "nitro-dev", "preset": "nitro-dev",
"framework": { "framework": {
"name": "nuxt", "name": "nuxt",
@ -9,9 +9,9 @@
"nitro": "2.13.4" "nitro": "2.13.4"
}, },
"dev": { "dev": {
"pid": 21165, "pid": 9287,
"workerAddress": { "workerAddress": {
"socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-21165-1-1-132.sock" "socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-9287-1-1-5267.sock"
} }
} }
} }

2
apps/admin/.nuxt/nuxt.d.ts

@ -1,5 +1,5 @@
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@pinia/nuxt" /> /// <reference types="@pinia/nuxt" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxt/devtools" /> /// <reference types="@nuxt/devtools" />
/// <reference types="@nuxt/telemetry" /> /// <reference types="@nuxt/telemetry" />
/// <reference path="types/nitro-layouts.d.ts" /> /// <reference path="types/nitro-layouts.d.ts" />

9
apps/admin/.nuxt/nuxt.lock

@ -0,0 +1,9 @@
{
"pid": 9287,
"startedAt": 1782719243662,
"command": "dev",
"cwd": "/Users/liwei/work/go/yunyan/yunyan-sas/apps/admin",
"port": 3000,
"hostname": "::1",
"url": "http://[::1]:3000"
}

2
apps/admin/.nuxt/nuxt.node.d.ts

@ -1,5 +1,5 @@
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@pinia/nuxt" /> /// <reference types="@pinia/nuxt" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxt/devtools" /> /// <reference types="@nuxt/devtools" />
/// <reference types="@nuxt/telemetry" /> /// <reference types="@nuxt/telemetry" />
/// <reference path="types/nitro-layouts.d.ts" /> /// <reference path="types/nitro-layouts.d.ts" />

2
apps/admin/.nuxt/tailwind/postcss.mjs

@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/28/2026, 10:29:51 AM // generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/29/2026, 5:27:44 PM
import "@nuxtjs/tailwindcss/config-ctx" import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger"; import configMerger from "@nuxtjs/tailwindcss/merger";

1
apps/admin/app/layouts/default.vue

@ -178,6 +178,7 @@ const menuGroups = [
{ {
label: '系统管理', label: '系统管理',
items: [ items: [
{ id: 'systemconfig', label: '系统配置', to: '/systemconfig', icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4' },
{ id: 'apps', label: '应用注册', to: '/apps', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, { id: 'apps', label: '应用注册', to: '/apps', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'statlog', label: '统计日志', to: '/statlog', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' }, { id: 'statlog', label: '统计日志', to: '/statlog', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
{ id: 'admin', label: '后台账号', to: '/accounts', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, { id: 'admin', label: '后台账号', to: '/accounts', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },

256
apps/admin/app/pages/appconfig.vue

@ -0,0 +1,256 @@
<template>
<div>
<!-- 演示横幅 -->
<div class="mb-4 rounded-lg px-4 py-2.5 text-xs flex items-center gap-2" style="background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca">
<span class="font-semibold">演示效果</span>
<span class="text-indigo-400">·</span>
<span>本页为 UI 演示,数据为本地 mock。由「应用注册」列表点击「服务配置」进入,配置随应用隔离。</span>
</div>
<!-- 返回 + 标题 -->
<div class="flex items-center gap-2 mb-4">
<button class="btn btn-ghost btn-sm" @click="goBack">&larr; 返回应用列表</button>
<span class="text-slate-300">/</span>
<h2 class="text-lg font-semibold text-slate-800">应用服务配置</h2>
</div>
<!-- 应用信息条 -->
<div class="rounded-xl px-5 py-4 mb-5 flex items-center gap-4" style="background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #e0e7ff">
<div class="app-avatar">{{ appInitial }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-base font-bold text-slate-800">{{ appName }}</span>
<span class="badge badge-sm" style="background:#e0e7ff;color:#4338ca;border:none">#{{ appId }}</span>
<span v-if="appRegion" class="badge badge-ghost badge-sm">{{ appRegion }}</span>
</div>
<div class="text-[11px] text-slate-500 mt-0.5">正在配置该应用的第三方登录、支付、推送与基础信息;仅对本应用生效,与其他应用隔离。</div>
</div>
<span class="badge badge-success badge-sm text-white">SaaS 独立配置</span>
</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="c.count" class="cnt-badge">{{ c.count }}</span>
</div>
</div>
</div>
<!-- 右侧详情 -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<!-- ===== 登录认证 ===== -->
<template v-if="curCat === 'auth'">
<div v-for="p in authProviders" :key="p.id" class="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer" @click="toggleOpen(p)">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800">{{ p.name }}</span>
<span class="badge badge-xs" :class="p.enable ? 'badge-success text-white' : 'badge-ghost'">{{ p.enable ? '已启用' : '未启用' }}</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">{{ p.desc }}</div>
</div>
<input v-model="p.enable" type="checkbox" class="toggle toggle-primary toggle-sm" @click.stop />
<svg class="w-4 h-4 text-slate-300 transition-transform" :class="{ 'rotate-180': p._open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<div v-show="p._open" class="px-5 pb-5 pt-1 border-t border-slate-50">
<p v-if="p.note" class="text-[11px] text-slate-500 bg-slate-50 rounded-lg px-3 py-2 mb-3">{{ p.note }}</p>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div v-for="f in p.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" :readonly="f.readonly" />
<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="flex justify-end mt-4">
<button class="btn btn-primary btn-sm text-white" @click="save">保存</button>
</div>
</div>
</div>
</template>
<!-- ===== 支付配置 ===== -->
<template v-else-if="curCat === 'pay'">
<div v-for="p in payProviders" :key="p.id" class="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer" @click="toggleOpen(p)">
<div class="provider-logo" :style="{ background: p.color }">{{ p.short }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-slate-800">{{ p.name }}</span>
<span class="badge badge-xs" :class="p.enable ? 'badge-success text-white' : 'badge-ghost'">{{ p.enable ? '已启用' : '未启用' }}</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">{{ p.desc }}</div>
</div>
<input v-model="p.enable" type="checkbox" class="toggle toggle-primary toggle-sm" @click.stop />
<svg class="w-4 h-4 text-slate-300 transition-transform" :class="{ 'rotate-180': p._open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<div v-show="p._open" class="px-5 pb-5 pt-1 border-t border-slate-50">
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div v-for="f in p.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.encrypted" class="lock-tag ml-1.5">🔒</span></span></label>
<input v-model="f.value" :type="f.encrypted ? 'password' : 'text'" class="input input-bordered input-sm w-full" :class="{ 'font-mono': f.mono }" :placeholder="f.placeholder" />
</div>
</div>
<div class="flex justify-end mt-4"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</div>
</template>
<!-- ===== 消息推送 ===== -->
<template v-else-if="curCat === 'push'">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-base font-semibold text-slate-800 mb-1">消息推送</h3>
<p class="text-xs text-slate-400 mb-4">推送通道(极光 / FCM / APNs)在「系统配置」统一接入,此处为各应用的开关与包名映射。</p>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">Android 包名</span></label><input v-model="push.androidPkg" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">iOS Bundle ID</span></label><input v-model="push.iosBundle" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">极光 AppKey(应用级)</span></label><input v-model="push.jpushKey" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">推送总开关</span></label><input v-model="push.enable" type="checkbox" class="toggle toggle-primary mt-2" /></div>
</div>
<div class="flex justify-end mt-5"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</template>
<!-- ===== 基础信息 ===== -->
<template v-else-if="curCat === 'basic'">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-base font-semibold text-slate-800 mb-4">应用基础信息</h3>
<div class="grid grid-cols-2 gap-x-5 gap-y-3">
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">应用名称</span></label><input v-model="basic.name" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">应用简称</span></label><input v-model="basic.shortName" class="input input-bordered input-sm" /></div>
<div class="form-control col-span-2"><label class="label py-1"><span class="label-text text-sm">应用 Logo URL</span></label><input v-model="basic.logo" class="input input-bordered input-sm font-mono" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">用户协议链接</span></label><input v-model="basic.terms" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">隐私政策链接</span></label><input v-model="basic.privacy" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">客服邮箱</span></label><input v-model="basic.support" class="input input-bordered input-sm" /></div>
<div class="form-control"><label class="label py-1"><span class="label-text text-sm">默认语言</span></label><input v-model="basic.lang" class="input input-bordered input-sm" /></div>
</div>
<div class="flex justify-end mt-5"><button class="btn btn-primary btn-sm text-white" @click="save">保存</button></div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '应用服务配置' })
const { success } = useToast()
const route = useRoute()
const router = useRouter()
interface Field { key: string; label: string; value: string; placeholder?: string; hint?: string; required?: boolean; encrypted?: boolean; mono?: boolean; wide?: boolean; readonly?: boolean; _show?: boolean }
interface ProviderCard { id: string; name: string; short: string; desc: string; color: string; enable: boolean; note?: string; fields: Field[]; _open?: boolean }
// 应用上下文(由「应用注册」列表跳转携带 query)
const appId = computed(() => Number(route.query.id) || 0)
const appName = computed(() => (route.query.name as string) || '未指定应用')
const appRegion = computed(() => (route.query.region as string) || '')
const appInitial = computed(() => (appName.value || 'A').charAt(0).toUpperCase())
function goBack() { router.push('/apps') }
const categories = computed(() => [
{ id: 'auth', label: '登录认证', desc: '微信 / Apple / Google', color: '#6366f1', count: authProviders.filter((p) => p.enable).length, icon: 'M12 11c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM12 11v6m0-6V5m6 6a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'pay', label: '支付配置', desc: '微信支付 / 支付宝', color: '#10b981', count: payProviders.filter((p) => p.enable).length, icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
{ id: 'push', label: '消息推送', desc: '极光 / FCM / APNs', color: '#ec4899', count: 0, icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ id: 'basic', label: '基础信息', desc: '名称 / Logo / 协议', color: '#f59e0b', count: 0, icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
])
const curCat = ref('auth')
// 登录认证(重点:微信登录)
const authProviders = reactive<ProviderCard[]>([
{
id: 'wechat', name: '微信登录', short: '微', desc: '微信开放平台 App / 公众号授权登录', color: '#07c160', enable: true, _open: true,
note: '微信开放平台 → 创建移动应用,审核通过后获取 AppID 与 AppSecret。App 登录用「移动应用」,公众号网页授权用「公众号」的 AppID。',
fields: [
{ key: 'appid', label: 'AppID', value: 'wx9f8e7d6c5b4a3210', mono: true, required: true },
{ key: 'secret', label: 'AppSecret', value: '', mono: true, encrypted: true, required: true, hint: '留空保留原密文' },
{ key: 'mch', label: '关联商户号', value: '', mono: true, hint: '可选,用于微信支付打通' },
{ key: 'scope', label: '授权 scope', value: 'snsapi_userinfo', mono: true },
],
},
{
id: 'apple', name: 'Apple 登录', short: '', desc: 'Sign in with Apple(iOS 上架必备)', color: '#111827', enable: true, _open: false,
fields: [
{ key: 'clientId', label: 'Client ID (Bundle)', value: 'com.yunyan.voitrans', mono: true, required: true },
{ key: 'teamId', label: 'Team ID', value: 'A1B2C3D4E5', mono: true, required: true },
{ key: 'keyId', label: 'Key ID', value: 'XYZ1234567', mono: true, required: true },
{ key: 'privateKey', label: 'AuthKey (.p8)', value: '', encrypted: true, wide: true, required: true, placeholder: '粘贴 .p8 私钥内容' },
],
},
{
id: 'google', name: 'Google 登录', short: 'G', desc: 'Google OAuth 2.0(海外应用)', color: '#ea4335', enable: false, _open: false,
fields: [
{ key: 'clientId', label: 'Client ID', value: '', mono: true, wide: true, required: true, placeholder: 'xxx.apps.googleusercontent.com' },
{ key: 'clientSecret', label: 'Client Secret', value: '', mono: true, encrypted: true, required: true },
],
},
{
id: 'phone', name: '手机短信登录', short: '号', desc: '手机号 + 验证码登录', color: '#0ea5e9', enable: true, _open: false,
note: '短信通道复用「系统配置 → 短信服务」,此处仅控制本应用是否开放手机号登录及验证码有效期。',
fields: [
{ key: 'channel', label: '短信通道', value: '系统配置 · 腾讯云 SMS', readonly: true, wide: true },
{ key: 'expire', label: '验证码有效期(秒)', value: '300', mono: true },
],
},
])
// 支付配置
const payProviders = reactive<ProviderCard[]>([
{
id: 'wxpay', name: '微信支付', short: '微', desc: 'App / JSAPI 支付', color: '#07c160', enable: true, _open: false,
fields: [
{ key: 'mchid', label: '商户号 MchId', value: '1612345678', mono: true },
{ key: 'appid', label: '关联 AppID', value: 'wx9f8e7d6c5b4a3210', mono: true },
{ key: 'apiv3', label: 'APIv3 密钥', value: '', mono: true, encrypted: true },
{ key: 'serialNo', label: '证书序列号', value: '', mono: true, wide: true },
],
},
{
id: 'alipay', name: '支付宝', short: '支', desc: 'App 支付', color: '#1677ff', enable: false, _open: false,
fields: [
{ key: 'appid', label: 'AppID', value: '', mono: true },
{ key: 'privateKey', label: '应用私钥', value: '', encrypted: true, wide: true },
{ key: 'publicKey', label: '支付宝公钥', value: '', wide: true },
],
},
])
const push = reactive({ androidPkg: 'com.yunyan.voitrans', iosBundle: 'com.yunyan.voitrans', jpushKey: 'a1b2c3d4e5f6', enable: true })
const basic = reactive({ name: 'Voitrans 同声传译', shortName: 'Voitrans', logo: 'https://cdn.yunyan.com/app/voitrans/logo.png', terms: 'https://yunyan.com/terms', privacy: 'https://yunyan.com/privacy', support: 'support@yunyan.com', lang: 'zh-CN' })
function toggleOpen(p: ProviderCard) { p._open = !p._open }
function save() { success(`「${appName.value}」的配置已保存(演示,未落库)`) }
</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; }
.cnt-badge { font-size: 11px; font-weight: 700; color: #4338ca; background: #e0e7ff; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.provider-logo { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; font-size: 14px; flex-shrink: 0; }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 6px; border-radius: 5px; }
.app-avatar { width: 48px; height: 48px; border-radius: 13px; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 20px; flex-shrink: 0; box-shadow: 0 6px 18px rgba(79,70,229,.3); }
</style>

6
apps/admin/app/pages/apps.vue

@ -36,6 +36,7 @@
</td> </td>
<td> <td>
<div class="flex gap-1"> <div class="flex gap-1">
<button class="btn btn-ghost btn-xs text-indigo-600" @click="openAppConfig(a)">服务配置</button>
<button class="btn btn-ghost btn-xs" @click="showForm(a)">编辑</button> <button class="btn btn-ghost btn-xs" @click="showForm(a)">编辑</button>
<button class="btn btn-ghost btn-xs text-emerald-600" @click="openAnalyze(a.id, a.name || ('应用#' + a.id))">分析历史</button> <button class="btn btn-ghost btn-xs text-emerald-600" @click="openAnalyze(a.id, a.name || ('应用#' + a.id))">分析历史</button>
<button class="btn btn-ghost btn-xs text-indigo-500" @click="notifyApp(a.id)">发事件</button> <button class="btn btn-ghost btn-xs text-indigo-500" @click="notifyApp(a.id)">发事件</button>
@ -367,6 +368,11 @@ async function saveApp() {
} }
} }
function openAppConfig(a: AppItem) {
// 跳转到该应用的服务配置页(微信登录 / 支付 / 推送 / 基础信息)
router.push({ path: '/appconfig', query: { id: a.id, name: a.name || ('应用#' + a.id), region: a.region || '' } })
}
function delApp(id: number) { function delApp(id: number) {
if (!window.confirm(`删除注册应用 #${id} ?(不影响应用自身的库)`)) return if (!window.confirm(`删除注册应用 #${id} ?(不影响应用自身的库)`)) return
consoleApi('apps/del', { id }) consoleApi('apps/del', { id })

514
apps/admin/app/pages/systemconfig.vue

@ -0,0 +1,514 @@
<template>
<div>
<!-- 演示横幅 -->
<div class="mb-4 rounded-lg px-4 py-2.5 text-xs flex items-center gap-2" style="background:#eef2ff;border:1px solid #c7d2fe;color:#4338ca">
<span class="font-semibold">演示效果</span>
<span class="text-indigo-400">·</span>
<span>本页为 UI 演示,数据为本地 mock,未连接后端。确认方向后再接 console 接口。</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 } from 'vue'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '系统配置' })
const { success, info } = useToast() as any
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: true,
fields: [
{ key: 'region', label: 'Region', value: 'oss-cn-shanghai', placeholder: 'oss-cn-shanghai', mono: true, required: true },
{ key: 'endpoint', label: 'Endpoint', value: 'oss-cn-shanghai.aliyuncs.com', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: 'yunyan-firmware', 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: 'domain', label: '自定义域名 (CDN)', value: 'https://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)!)
function setDefault() {
setDefaultById(curProvider.value)
}
function setDefaultById(id: string) {
const p = providers.find((x) => x.id === id)
if (!p) return
activeId.value = id
curProvider.value = id
p.enable = true
success(`已将「${p.name}」设为默认对象存储,固件 / 头像等文件上传将指向该存储桶`)
}
function testConn() {
info ? info(`正在测试 ${editProvider.value.name} 连接…(演示)`) : success('连接测试通过(演示)')
setTimeout(() => success(`${editProvider.value.name} 连接正常,Bucket 可写`), 600)
}
function save() {
success('配置已保存(演示,未落库)')
}
</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>

BIN
apps/services/dump.rdb

Binary file not shown.

9
apps/services/log/console.log

@ -10,3 +10,12 @@
2026/06/29 10:01:40.888 INFO console.stat: 统计快照消费已启动 subject=stats.event stream=STATS durable=console-stats 2026/06/29 10:01:40.888 INFO console.stat: 统计快照消费已启动 subject=stats.event stream=STATS durable=console-stats
2026/06/29 10:01:40.888 INFO console: HTTP 服务监听 :8080(静态目录 ./consoleweb) 2026/06/29 10:01:40.888 INFO console: HTTP 服务监听 :8080(静态目录 ./consoleweb)
2026/06/29 10:01:40.888 DEBUG 启动模块【console】 2026/06/29 10:01:40.888 DEBUG 启动模块【console】
2026/06/29 23:27:46.394 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/29 23:44:06.400 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/29 23:56:46.411 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 03:18:09.111 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 03:18:39.119 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 03:19:09.123 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 07:05:56.071 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 07:06:26.079 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host
2026/06/30 07:39:03.753 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host

10
apps/services/sys/bytedance/audomodel/sys_test.go

@ -2,10 +2,10 @@ package audomodel_test
import ( import (
"context" "context"
"yunyan/sys/bytedance/audomodel"
"yunyan/sys/bytedance/translate"
"fmt" "fmt"
"testing" "testing"
"yunyan/sys/bytedance/audomodel"
"yunyan/sys/bytedance/translate"
) )
func TestRecognizeFlashByURL(t *testing.T) { func TestRecognizeFlashByURL(t *testing.T) {
@ -20,7 +20,7 @@ func TestRecognizeFlashByURL(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("初始化失败: %v", err) t.Fatalf("初始化失败: %v", err)
} }
statusCode, logID, contexts, err := sys.RecognizeFlash("liwei1da0", "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com/User/2019585175758831616/ExternalAudio/2026/03/24/stCRmukfdIcs.mp3", false, "ja-JP") statusCode, logID, contexts, err := sys.RecognizeFlash("liwei1da0", "https://yunyan-deepsound.oss-cn-shanghai.aliyuncs.com/LocalAudio/2026/06/23/PHDXtMXhhNqZ.wav", false, "es-ES")
if err != nil { if err != nil {
fmt.Printf("识别失败: %v (code=%s, logid=%s)\n", err, statusCode, logID) fmt.Printf("识别失败: %v (code=%s, logid=%s)\n", err, statusCode, logID)
return return
@ -42,8 +42,8 @@ func TestRecognizeFlashByURL(t *testing.T) {
tests = append(tests, item.Content) tests = append(tests, item.Content)
} }
// 映射ASR语言到火山翻译SDK的标准语言代码 // 映射ASR语言到火山翻译SDK的标准语言代码
formLang := mapASRLangToTranslateLang("ja-JP") formLang := mapASRLangToTranslateLang("es-ES")
tolLang := mapASRLangToTranslateLang("en-US") tolLang := mapASRLangToTranslateLang("") // 目标语言为中文
results, err := sys.Translate(context.Background(), formLang, tolLang, tests) results, err := sys.Translate(context.Background(), formLang, tolLang, tests)
fmt.Printf("翻译成功: results=%v, err=%v\n", results, err) fmt.Printf("翻译成功: results=%v, err=%v\n", results, err)
} }

2
deploy/voitrans/env/env.example

@ -3,6 +3,8 @@
# 依赖的 etcd/mysql/redis/nats 在 DOCKER_NETWORK 网络内,用容器名连接。 # 依赖的 etcd/mysql/redis/nats 在 DOCKER_NETWORK 网络内,用容器名连接。
# ── 镜像 / 容器 ── # ── 镜像 / 容器 ──
# TAG = 当前运行的镜像版本。dev 固定 dev-latest;prod 部署成功后由 prod-deploy.sh 自动写回为本次版本号(如 0.2.1),
# 所以服务器上 cat .env 的这一行就是当前实际运行版本,便于排查是否环境/版本问题。
TAG=dev-latest TAG=dev-latest
PLATFORM=linux/amd64 PLATFORM=linux/amd64
DOCKER_NETWORK=1panel-network DOCKER_NETWORK=1panel-network

9
deploy/voitrans/prod-build.sh

@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# voitrans 生产环境:仅「构建带版本号的镜像并推送」,不部署。 # voitrans 生产环境:仅「构建带版本号的镜像并推送」,不部署。
# #
# 业务五服务(gateway/home/api/mcp/timer)打成单镜像 voitrans-a11。 # 业务五服务(gateway/home/api/mcp/timer)打成单镜像 voitrans-a11(所有应用共用此镜像)。
# 同时推送 :<version> 与 :latest 两个 tag。部署用 ./prod-deploy.sh <区域> <version>。 # 同时推送 :<version> 与 :latest 两个 tag。部署用 ./prod-deploy.sh <应用> <区域> <version>。
# #
# 用法(在 deploy/voitrans/ 下): # 用法(在 deploy/voitrans/ 下):
# ./prod-build.sh <version> # version 必须形如 X.Y.Z,如 0.1.1 # ./prod-build.sh <version> # 只要版本号(应用共用同一镜像,构建不分应用);version 必须形如 X.Y.Z,如 0.1.1
set -euo pipefail set -euo pipefail
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -41,4 +41,5 @@ docker push "$IMG_VER"
docker push "$IMG_LATEST" docker push "$IMG_LATEST"
green "✓ 已推送 $IMG_VER 与 $IMG_LATEST" green "✓ 已推送 $IMG_VER 与 $IMG_LATEST"
echo " 部署:./prod-deploy.sh <shanghai|japan|singapore> $VERSION" echo " 部署:./prod-deploy.sh <应用> <shanghai|japan|singapore> $VERSION"
echo " 例:./prod-deploy.sh voitrans shanghai $VERSION"

226
deploy/voitrans/prod-deploy.sh

@ -1,15 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# voitrans 生产环境:部署到指定区域(选择区域 + 镜像版本 tag),支持回滚。 # 生产环境部署:选择「应用 + 区域 + 镜像版本 tag」部署,支持回滚。
# #
# 镜像由 ./prod-build.sh 预先构建推送;本脚本只做「远端拉取 + 重启」。 # 镜像由 ./prod-build.sh 预先构建推送;本脚本只做「远端拉取 + 重启」。
# 仅做镜像部署:服务器上的 docker-compose.yml / .env / confs/ 由你自行维护 # 所有应用共用同一镜像 voitrans-a11 与本目录的模板,仅「远端运行目录」按应用区分(见下方 APPS)。
# (照着仓库里的 env/env.example、confs/*.yaml.example 在服务器上放好真实文件),本脚本不上传任何配置。 # 各区域一套服务器(见下方 region_profile)。
# 各区域一套服务器(见下方 region_profile),各区域共用同一镜像 voitrans-a11。 # 仅做镜像部署:服务器上的 docker-compose.yml / .env / confs/ 全部由你自行维护,本脚本只下发 *.example 模板、绝不覆盖真实文件
# (首次照同步来的 docker-compose.yml.example / env.example / confs/*.yaml.example 在各应用远端目录复制成真实文件并填值)。
# #
# 用法(在 deploy/voitrans/ 下): # 用法(在 deploy/voitrans/ 下):
# ./prod-deploy.sh <区域> <tag> # 部署指定版本,如 ./prod-deploy.sh shanghai 0.1.1 # ./prod-deploy.sh # 不带参数 → 交互式引导:逐步选「应用 / 区域 / 版本」,部署前确认
# ./prod-deploy.sh <区域> rollback # 回滚到上一次部署版本(与当前版本来回切换) # ./prod-deploy.sh <应用> <区域> <tag> # 直接部署,如 ./prod-deploy.sh voitrans shanghai 0.1.1
# 区域: shanghai | japan | singapore # ./prod-deploy.sh <应用> <区域> rollback # 回滚到上一次部署版本(与当前版本来回切换)
# ./prod-deploy.sh <区域> <tag> # 省略应用 → 默认首个应用(兼容旧写法)
# ./prod-deploy.sh -h # 查看用法
# 应用: 见 APPS(默认 voitrans) 区域: shanghai | japan | singapore
set -euo pipefail set -euo pipefail
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -19,59 +23,209 @@ green() { printf "\033[32m%s\033[0m\n" "$*"; }
red() { printf "\033[31m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*"; }
# ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)── # ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)──
IMAGE_NAME="voitrans-a11" IMAGE_NAME="voitrans-a11" # 共用镜像:所有应用都用这一个镜像
REGISTRY="docker-registry.ideapsound.com" REGISTRY="docker-registry.ideapsound.com"
REGISTRY_USER="admin" REGISTRY_USER="admin"
REGISTRY_PASS="li13451234" REGISTRY_PASS="li13451234"
DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务器一致) DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务器一致)
# ╔══════════════════════════════════════════════════════════════════════╗ # ╔══════════════════════════════════════════════════════════════════════╗
# ║ 各区域部署目标 —— 写死在此(本文件纳入 git)。公开仓库前清空 HOST。 ║ # ║ 应用列表 —— 共用镜像 voitrans-a11 与本目录模板,仅「远端部署目录」按应用区分。 ║
# ║ 新增区域:在此加一段 case 填 DEPLOY_HOST ║ # ║ 格式: "key|显示名|远端部署目录" ║
# ║ 新增应用:在此加一行(远端目录在目标服务器上自行备好 .env/confs/compose)。 ║
# ╚══════════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════════╝
region_profile() { APPS=(
DEPLOY_PORT=22 "voitrans|Voitrans|/home/work/voitrans-a11"
DEPLOY_USER=root "lumi|Lumi|/home/work/lumi-a11"
DEPLOY_KEY=~/.ssh/id_rsa "echomeet|EchoMeet|/home/work/echomeet-a11"
DEPLOY_DIR="/opt/yunyan/voitrans" # 远端部署目录(其中 docker-compose.yml/.env/confs 由你自己维护) "deapvoice|DeapVoice|/home/work/deapvoice-a11"
"deapsound|DeapSound|/home/work/deapsound-a11"
"allhelp|AllHelp|/home/work/allhelp-a11"
"deepglass|DeepGlass|/home/work/deepglass-a11"
)
app_keys() { local e k; for e in "${APPS[@]}"; do IFS='|' read -r k _ <<<"$e"; printf '%s\n' "$k"; done; }
app_field() { # $1=key $2=字段序号(2=显示名 3=远端目录)
local e k n r
for e in "${APPS[@]}"; do
IFS='|' read -r k n r <<<"$e"
[ "$k" = "$1" ] || continue
case "$2" in 2) printf '%s' "$n" ;; 3) printf '%s' "$r" ;; esac
return 0
done
return 1
}
is_app() { app_field "$1" 2 >/dev/null 2>&1; }
DEFAULT_APP="${APPS[0]%%|*}" # 省略应用时的默认(首个);用参数展开取首行 key,避开 head 截断管道触发 SIGPIPE
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ 各区域部署目标 —— 每区域一整套 HOST/PORT/USER/KEY(IP 与 SSH 私钥各不相同)。║
# ║ 写死在此(本文件纳入 git)。公开仓库前清空 HOST/KEY。 ║
# ║ 新增区域:在 region_profile 加一段 case,并把区域名加进 REGIONS 数组。 ║
# ╚══════════════════════════════════════════════════════════════════════╝
REGIONS=(shanghai japan singapore) # 可选环境/区域(交互菜单按此顺序展示)
region_profile() { # 远端部署目录由「应用」决定(见 APPS),此处只管各区域服务器连接信息
case "$REGION" in case "$REGION" in
shanghai) DEPLOY_HOST="" ;; # ← 上海生产服务器 IP(必填) shanghai)
japan) DEPLOY_HOST="" ;; # ← 日本生产服务器 IP(必填) DEPLOY_HOST="139.224.212.237" # ← 上海生产服务器 IP(必填)
singapore) DEPLOY_HOST="" ;; # ← 新加坡生产服务器 IP(必填) DEPLOY_PORT=22
*) red "✗ 未知区域: $REGION(应为 shanghai|japan|singapore)"; exit 1 ;; DEPLOY_USER=root
DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem # ← 上海服务器 SSH 私钥(各区域不同)
;;
japan)
DEPLOY_HOST="8.216.34.101" # ← 日本生产服务器 IP(必填)
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_japan.pem # ← 日本服务器 SSH 私钥(各区域不同)
;;
singapore)
DEPLOY_HOST="47.84.198.24" # ← 新加坡生产服务器 IP(必填)
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_KEY=/Users/liwei/Documents/key/alibaba_singapore.pem # ← 新加坡服务器 SSH 私钥(各区域不同)
;;
*) red "✗ 未知区域: $REGION(应为 ${REGIONS[*]// /|})"; exit 1 ;;
esac esac
} }
# ── 参数 ── # 取某区域的目标主机(供交互菜单展示),不污染当前环境变量
REGION="${1:-}"; ARG2="${2:-}" region_host_of() { ( REGION="$1" region_profile >/dev/null 2>&1; printf '%s' "${DEPLOY_HOST:-}" ); }
[ -n "$REGION" ] && [ -n "$ARG2" ] || { red "用法: ./prod-deploy.sh <shanghai|japan|singapore> <tag|rollback>"; exit 1; }
# ── 用法 ──
usage() {
blue "生产部署(共用镜像 ${IMAGE_NAME},仅远端目录按应用区分)"
cat >&2 <<EOF
用法: ./prod-deploy.sh <应用> <环境/区域> <tag|rollback>
应用 : $(app_keys | paste -sd '/' -)
环境/区域 : ${REGIONS[*]// /, }
tag : 由 ./prod-build.sh <tag> 预先构建推送的版本号,如 0.1.1
rollback : 回滚到上一次部署版本(与当前版本来回切换)
示例:
./prod-deploy.sh voitrans shanghai 0.1.1 # 部署 voitrans 0.1.1 到上海
./prod-deploy.sh voitrans japan rollback # voitrans 日本回滚到上一版本
./prod-deploy.sh shanghai 0.1.1 # 省略应用 → 默认首个应用(${DEFAULT_APP:-?})
提示: 不带参数(或参数不全)运行将进入交互式引导,逐步选择应用 / 环境 / 版本。
EOF
}
# ── 参数解析:<应用> <区域> <tag|rollback>;省略应用兼容旧式 <区域> <tag>(默认首个应用)──
case "${1:-}" in -h|--help|help) usage; exit 0 ;; esac
ARGS=("$@")
APP_KEY=""; REGION=""; ARG2=""; INTERACTIVE=0
if [ "${#ARGS[@]}" -gt 0 ] && is_app "${ARGS[0]:-}"; then
APP_KEY="${ARGS[0]}"; REGION="${ARGS[1]:-}"; ARG2="${ARGS[2]:-}"
else
REGION="${ARGS[0]:-}"; ARG2="${ARGS[1]:-}" # 旧式:省略应用
fi
# 缺任一项就进交互(仅当不是「旧式两参数」这种完整非交互调用)
enter_interactive() { [ "$INTERACTIVE" = "1" ] && return 0; INTERACTIVE=1; usage; echo >&2; }
# ① 选择应用
if [ -z "$APP_KEY" ]; then
if [ -n "$REGION" ] && [ -n "$ARG2" ]; then
APP_KEY="$DEFAULT_APP" # 旧式两参数:默认首个应用,不打断
else
enter_interactive
blue "① 选择应用:"
i=1
while IFS= read -r k; do
printf " %d) %-12s → 远端目录 %s\n" "$i" "$k" "$(app_field "$k" 3)" >&2
i=$((i+1))
done < <(app_keys)
napp="$(app_keys | wc -l | tr -d ' ')"
printf " 请输入序号 [1-%s]: " "$napp" >&2
read -r sel || { red "✗ 已取消"; exit 1; }
case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac
{ [ "$sel" -ge 1 ] && [ "$sel" -le "$napp" ]; } || { red "✗ 序号超出范围"; exit 1; }
APP_KEY="$(app_keys | sed -n "${sel}p")"
green " ✓ 应用: $APP_KEY"
fi
fi
is_app "$APP_KEY" || { red "✗ 未知应用: $APP_KEY(应为 $(app_keys | paste -sd '/' -))"; exit 1; }
# 解析应用 → 远端部署目录(镜像与模板共用 voitrans 这套)
APP_DISPLAY="$(app_field "$APP_KEY" 2)"
DEPLOY_DIR="$(app_field "$APP_KEY" 3)"
# ② 选择环境 / 区域
if [ -z "$REGION" ]; then
enter_interactive
blue "② 选择部署环境 / 区域:"
i=1
for r in "${REGIONS[@]}"; do
h="$(region_host_of "$r")"
printf " %d) %-10s → %s\n" "$i" "$r" "${h:-(未配置 IP,需先在脚本 region_profile 填)}" >&2
i=$((i+1))
done
printf " 请输入序号 [1-%d]: " "${#REGIONS[@]}" >&2
read -r sel || { red "✗ 已取消"; exit 1; }
case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac
{ [ "$sel" -ge 1 ] && [ "$sel" -le "${#REGIONS[@]}" ]; } || { red "✗ 序号超出范围"; exit 1; }
REGION="${REGIONS[$((sel-1))]}"
green " ✓ 环境: $REGION"
fi
# ③ 选择版本 / 动作
if [ -z "$ARG2" ]; then
enter_interactive
echo >&2
blue "③ 选择版本(输入由 prod-build.sh 推送的 tag,或输入 rollback 回滚):"
printf " tag 或 rollback: " >&2
read -r ARG2 || { red "✗ 已取消"; exit 1; }
[ -n "$ARG2" ] || { red "✗ 未输入版本"; exit 1; }
fi
if [ "$ARG2" = "rollback" ]; then MODE="rollback"; NEW_TAG=""; else MODE="deploy"; NEW_TAG="$ARG2"; fi if [ "$ARG2" = "rollback" ]; then MODE="rollback"; NEW_TAG=""; else MODE="deploy"; NEW_TAG="$ARG2"; fi
# deploy 模式校验 tag:去空白 + 非空 + 合法字符(避免空/空格 tag 让远端 docker 报 invalid reference format)
if [ "$MODE" = "deploy" ]; then
NEW_TAG="$(printf '%s' "$NEW_TAG" | tr -d '[:space:]')"
[ -n "$NEW_TAG" ] || { red "✗ 未指定版本 tag —— deploy 必须带版本号(如 0.1.1,需先 ./prod-build.sh 0.1.1 构建推送);回滚请用 rollback"; exit 1; }
case "$NEW_TAG" in
*[!A-Za-z0-9._-]*) red "✗ 非法版本 tag: '$NEW_TAG'(只能含字母数字与 . _ -)"; exit 1 ;;
esac
fi
region_profile region_profile
: "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填 DEPLOY_HOST}" : "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 IP}"
: "${DEPLOY_KEY:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 SSH 私钥}"
# 交互模式下,部署前二次确认(带全参数的非交互/CI 调用不打断)
if [ "$INTERACTIVE" = "1" ]; then
echo >&2
blue "即将部署 → 应用: $APP_DISPLAY($APP_KEY) 镜像: $IMAGE_NAME 环境: $REGION 目标: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 动作: $MODE${NEW_TAG:+ $NEW_TAG}"
printf "确认执行?[y/N]: " >&2
read -r yn || true
case "$yn" in [yY]|[yY][eE][sS]) ;; *) red "已取消"; exit 0 ;; esac
fi
_ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; } _ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
_scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; } _scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; }
_dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; } _dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; }
# 同步「模板 + compose」(只传模板,绝不覆盖你维护的真实 .env / confs/*.yaml) # 同步「模板」(只传 *.example,绝不覆盖你在服务器维护的真实 docker-compose.yml / .env / confs/*.yaml)
blue ">>> [$REGION] 同步模板与 compose → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" blue ">>> [$APP_KEY/$REGION] 同步模板 → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR"
_ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'" _ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'"
_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml")" _scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml.example")"
_scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEPLOY_DIR/confs/")" _scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEPLOY_DIR/confs/")"
_scp "$SVC_DIR/env/env.example" "$(_dest "$DEPLOY_DIR/env.example")" _scp "$SVC_DIR/env/env.example" "$(_dest "$DEPLOY_DIR/env.example")"
# 远端拉取并重启(不上传真实配置):tag 历史存服务器上独立的 .deploy_tag,不动你维护的 .env。 # 远端拉取并重启(不上传真实配置):版本历史存独立的 .deploy_tag;部署成功后会把当前 TAG 写回 .env 的 TAG 行(仅这一行)。
blue ">>> [$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" blue ">>> [$APP_KEY/$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR"
_ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" <<'REMOTE' _ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" <<'REMOTE'
set -e set -e
DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8" DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8"
[ -f "$DIR/.env" ] || { echo "✗ 缺少 $DIR/.env(请照 env.example 在服务器填好真实 .env)"; exit 1; } [ -f "$DIR/.env" ] || { echo "✗ 缺少 $DIR/.env(请照 env.example 在服务器填好真实 .env)"; exit 1; }
[ -f "$DIR/confs/gateway.yaml" ] || { echo "✗ 缺少 $DIR/confs/*.yaml(请照 confs/*.yaml.example 在服务器填好)"; exit 1; } [ -f "$DIR/confs/gateway.yaml" ] || { echo "✗ 缺少 $DIR/confs/*.yaml(请照 confs/*.yaml.example 在服务器填好)"; exit 1; }
[ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml"; exit 1; } [ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml(请照 docker-compose.yml.example 复制为 docker-compose.yml)"; exit 1; }
STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,不动用户的 .env) STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,记录版本历史)
PREVF="$DIR/.deploy_tag.prev" # 上一次部署 tag PREVF="$DIR/.deploy_tag.prev" # 上一次部署 tag
CUR_TAG="$([ -f "$STATE" ] && cat "$STATE" || true)" CUR_TAG="$([ -f "$STATE" ] && cat "$STATE" || true)"
CUR_PREV="$([ -f "$PREVF" ] && cat "$PREVF" || true)" CUR_PREV="$([ -f "$PREVF" ] && cat "$PREVF" || true)"
@ -87,13 +241,19 @@ fi
echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null
docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET"
docker pull "$IMG_PREFIX:$FINAL_TAG" docker pull "$IMG_PREFIX:$FINAL_TAG"
# REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),不改写用户的 .env # REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),确保拉到本次版本
cd "$DIR" && REGISTRY="$REG" TAG="$FINAL_TAG" docker compose --env-file .env up -d cd "$DIR" && REGISTRY="$REG" TAG="$FINAL_TAG" docker compose --env-file .env up -d
docker image prune -f docker image prune -f
# 记录当前运行版本:① 写回 .env 的 TAG 行(只改这一行,便于 cat .env 查看)② 维护 .deploy_tag 历史
if grep -q '^TAG=' "$DIR/.env"; then
sed -i "s/^TAG=.*/TAG=$FINAL_TAG/" "$DIR/.env"
else
printf 'TAG=%s\n' "$FINAL_TAG" >> "$DIR/.env"
fi
echo "$FINAL_TAG" > "$STATE" echo "$FINAL_TAG" > "$STATE"
[ -n "$FINAL_PREV" ] && echo "$FINAL_PREV" > "$PREVF" || true [ -n "$FINAL_PREV" ] && echo "$FINAL_PREV" > "$PREVF" || true
echo "已部署版本 TAG=$FINAL_TAG (上一次=${FINAL_PREV:-无})" echo "已部署版本 TAG=$FINAL_TAG (上一次=${FINAL_PREV:-无});已写回 .env 的 TAG 行"
REMOTE REMOTE
green "✓ voitrans [$REGION] $MODE 完成" green "✓ [$APP_KEY/$REGION] $MODE 完成"

Loading…
Cancel
Save