Browse Source

上传基础服务代码

main
liwei1dao 3 months ago
parent
commit
040af15192
  1. 4
      apps/admin/.nuxt/components.d.ts
  2. 1
      apps/admin/.nuxt/imports.d.ts
  3. 2
      apps/admin/.nuxt/manifest/latest.json
  4. 2
      apps/admin/.nuxt/manifest/meta/dev.json
  5. 6
      apps/admin/.nuxt/nitro.json
  6. 4
      apps/admin/.nuxt/nuxt.d.ts
  7. 4
      apps/admin/.nuxt/nuxt.node.d.ts
  8. 2
      apps/admin/.nuxt/tailwind/postcss.mjs
  9. 4
      apps/admin/.nuxt/tsconfig.app.json
  10. 2
      apps/admin/.nuxt/tsconfig.json
  11. 2
      apps/admin/.nuxt/tsconfig.shared.json
  12. 2
      apps/admin/.nuxt/types/build.d.ts
  13. 4
      apps/admin/.nuxt/types/components.d.ts
  14. 2
      apps/admin/.nuxt/types/imports.d.ts
  15. 9
      apps/admin/.nuxt/types/plugins.d.ts
  16. 721
      apps/admin/app/components/AppDeployTab.vue
  17. 317
      apps/admin/app/components/AppModuleTab.vue
  18. 4
      apps/admin/app/layouts/default.vue
  19. 405
      apps/admin/app/pages/appconfig.vue
  20. 1128
      apps/admin/app/pages/apps.vue
  21. 292
      apps/admin/app/pages/apps/[name].vue
  22. 309
      apps/admin/app/pages/apps/index.vue
  23. 733
      apps/admin/app/pages/meetingsvc.vue
  24. 644
      apps/admin/app/pages/serviceconfig.vue
  25. 32
      apps/admin/app/types/app.ts
  26. 3
      apps/services/comm/const.go
  27. 219
      apps/services/comm/echomeetsvc.go
  28. 2
      apps/services/comm/nats.go
  29. 7
      apps/services/modules/console/api_config.go
  30. 318
      apps/services/modules/console/api_echomeet_orch.go
  31. 218
      apps/services/modules/console/api_echomeetsvc.go
  32. 93
      apps/services/modules/console/api_svctemplate.go
  33. 62
      apps/services/modules/console/model_echomeet_orch.go
  34. 21
      apps/services/modules/console/server.go
  35. 19
      apps/services/swagger/docs.go
  36. 15
      apps/services/swagger/swagger.json
  37. 12
      apps/services/swagger/swagger.yaml
  38. 2
      deploy/voitrans/confs/gateway.yaml.example

4
apps/admin/.nuxt/components.d.ts

@ -14,7 +14,9 @@ type HydrationStrategies = {
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
export const AppDeployTab: typeof import("../app/components/AppDeployTab.vue")['default']
export const AppLoading: typeof import("../app/components/AppLoading.vue")['default']
export const AppModuleTab: typeof import("../app/components/AppModuleTab.vue")['default']
export const AppToast: typeof import("../app/components/AppToast.vue")['default']
export const LangSelect: typeof import("../app/components/LangSelect.vue")['default']
export const NuxtWelcome: typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/welcome.vue")['default']
@ -41,7 +43,9 @@ export const Head: typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin
export const Html: typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/head/runtime/components")['Html']
export const Body: typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/head/runtime/components")['Body']
export const NuxtIsland: typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/nuxt-island")['default']
export const LazyAppDeployTab: LazyComponent<typeof import("../app/components/AppDeployTab.vue")['default']>
export const LazyAppLoading: LazyComponent<typeof import("../app/components/AppLoading.vue")['default']>
export const LazyAppModuleTab: LazyComponent<typeof import("../app/components/AppModuleTab.vue")['default']>
export const LazyAppToast: LazyComponent<typeof import("../app/components/AppToast.vue")['default']>
export const LazyLangSelect: LazyComponent<typeof import("../app/components/LangSelect.vue")['default']>
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/welcome.vue")['default']>

1
apps/admin/.nuxt/imports.d.ts

@ -37,3 +37,4 @@ export { useLoading } from '../app/composables/useLoading';
export { useToast, ToastType, Toast } from '../app/composables/useToast';
export { IDENTITY_LABELS, useAuthStore } from '../app/stores/auth';
export { defineStore, acceptHMRUpdate, usePinia, storeToRefs } from '../node_modules/.pnpm/@pinia+nuxt@0.11.3_magicast@0.5.3_pinia@3.0.4_typescript@5.9.3_vue@3.5.38_typescript@5.9.3__/node_modules/@pinia/nuxt/dist/runtime/composables';
export { useNuxtDevTools } from '../node_modules/.pnpm/@nuxt+devtools@3.2.4_vite@7.3.5_jiti@2.7.0_terser@5.48.0_yaml@2.9.0__vue@3.5.38_typescript@5.9.3_/node_modules/@nuxt/devtools/dist/runtime/use-nuxt-devtools';

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

@ -1 +1 @@
{"id":"dev","timestamp":1782957264073}
{"id":"dev","timestamp":1783752803916}

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

@ -1 +1 @@
{"id":"dev","timestamp":1782957264073,"prerendered":[]}
{"id":"dev","timestamp":1783752803916,"prerendered":[]}

6
apps/admin/.nuxt/nitro.json

@ -1,5 +1,5 @@
{
"date": "2026-07-02T01:54:25.086Z",
"date": "2026-07-11T06:53:25.092Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
@ -9,9 +9,9 @@
"nitro": "2.13.4"
},
"dev": {
"pid": 58824,
"pid": 29449,
"workerAddress": {
"socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-58824-1-1-597.sock"
"socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-29449-1-1-683.sock"
}
}
}

4
apps/admin/.nuxt/nuxt.d.ts

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

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

@ -1,7 +1,7 @@
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxt/devtools" />
/// <reference types="@nuxt/telemetry" />
/// <reference path="types/nitro-layouts.d.ts" />
/// <reference path="types/modules.d.ts" />
/// <reference path="types/runtime-config.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 7/7/2026, 7:51:39 PM
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/11/2026, 2:53:24 PM
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";

4
apps/admin/.nuxt/tsconfig.app.json

@ -107,7 +107,7 @@
"./imports"
],
"#app-manifest": [
"./manifest/meta/a1179f43-7381-4f05-9786-c76278046c3a.json"
"./manifest/meta/dev.json"
],
"#components": [
"./components"
@ -197,7 +197,7 @@
"../node_modules/.pnpm/@nuxt+telemetry@2.8.0_@nuxt+kit@4.4.8_magicast@0.5.3_/node_modules/dist/runtime/server",
"../node_modules/.pnpm/@nuxt+telemetry@2.8.0_@nuxt+kit@4.4.8_magicast@0.5.3_/node_modules/*.*",
"../node_modules/.pnpm/@nuxt+telemetry@2.8.0_@nuxt+kit@4.4.8_magicast@0.5.3_/node_modules/dist/*.*",
"../.output",
"dev",
"../server"
],
"vueCompilerOptions": {

2
apps/admin/.nuxt/tsconfig.json

@ -107,7 +107,7 @@
"./imports"
],
"#app-manifest": [
"./manifest/meta/a1179f43-7381-4f05-9786-c76278046c3a.json"
"./manifest/meta/dev.json"
],
"#components": [
"./components"

2
apps/admin/.nuxt/tsconfig.shared.json

@ -107,7 +107,7 @@
"./imports"
],
"#app-manifest": [
"./manifest/meta/a1179f43-7381-4f05-9786-c76278046c3a.json"
"./manifest/meta/dev.json"
],
"#build": [
"."

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

@ -12,6 +12,8 @@ declare module "#build/paths.mjs";
declare module "#build/root-component.mjs";
declare module "#build/plugins.server.mjs";
declare module "#build/test-component-wrapper.mjs";
declare module "#build/devtools/settings.mjs";
declare module "#build/runtime.vue-devtools-client.8eTWAfLgVpINiZM922q1ozAHX8JlyUM2pBnpX1z932g.js";
declare module "#build/routes.mjs";
declare module "#build/pages.mjs";
declare module "#build/router.options.mjs";

4
apps/admin/.nuxt/types/components.d.ts

@ -14,7 +14,9 @@ type HydrationStrategies = {
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
interface _GlobalComponents {
AppDeployTab: typeof import("../../app/components/AppDeployTab.vue")['default']
AppLoading: typeof import("../../app/components/AppLoading.vue")['default']
AppModuleTab: typeof import("../../app/components/AppModuleTab.vue")['default']
AppToast: typeof import("../../app/components/AppToast.vue")['default']
LangSelect: typeof import("../../app/components/LangSelect.vue")['default']
NuxtWelcome: typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/welcome.vue")['default']
@ -41,7 +43,9 @@ interface _GlobalComponents {
Html: typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/head/runtime/components")['Html']
Body: typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/head/runtime/components")['Body']
NuxtIsland: typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/nuxt-island")['default']
LazyAppDeployTab: LazyComponent<typeof import("../../app/components/AppDeployTab.vue")['default']>
LazyAppLoading: LazyComponent<typeof import("../../app/components/AppLoading.vue")['default']>
LazyAppModuleTab: LazyComponent<typeof import("../../app/components/AppModuleTab.vue")['default']>
LazyAppToast: LazyComponent<typeof import("../../app/components/AppToast.vue")['default']>
LazyLangSelect: LazyComponent<typeof import("../../app/components/LangSelect.vue")['default']>
LazyNuxtWelcome: LazyComponent<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/components/welcome.vue")['default']>

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

@ -125,6 +125,7 @@ declare global {
const useModel: typeof import('vue').useModel
const useNuxtApp: typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/nuxt').useNuxtApp
const useNuxtData: typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/asyncData').useNuxtData
const useNuxtDevTools: typeof import('../../node_modules/.pnpm/@nuxt+devtools@3.2.4_vite@7.3.5_jiti@2.7.0_terser@5.48.0_yaml@2.9.0__vue@3.5.38_typescript@5.9.3_/node_modules/@nuxt/devtools/dist/runtime/use-nuxt-devtools').useNuxtDevTools
const usePinia: typeof import('../../node_modules/.pnpm/@pinia+nuxt@0.11.3_magicast@0.5.3_pinia@3.0.4_typescript@5.9.3_vue@3.5.38_typescript@5.9.3__/node_modules/@pinia/nuxt/dist/runtime/composables').usePinia
const usePreviewMode: typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/preview').usePreviewMode
const useRequestEvent: typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/ssr').useRequestEvent
@ -345,6 +346,7 @@ declare module 'vue' {
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useNuxtApp: UnwrapRef<typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/nuxt')['useNuxtApp']>
readonly useNuxtData: UnwrapRef<typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/asyncData')['useNuxtData']>
readonly useNuxtDevTools: UnwrapRef<typeof import('../../node_modules/.pnpm/@nuxt+devtools@3.2.4_vite@7.3.5_jiti@2.7.0_terser@5.48.0_yaml@2.9.0__vue@3.5.38_typescript@5.9.3_/node_modules/@nuxt/devtools/dist/runtime/use-nuxt-devtools')['useNuxtDevTools']>
readonly usePinia: UnwrapRef<typeof import('../../node_modules/.pnpm/@pinia+nuxt@0.11.3_magicast@0.5.3_pinia@3.0.4_typescript@5.9.3_vue@3.5.38_typescript@5.9.3__/node_modules/@pinia/nuxt/dist/runtime/composables')['usePinia']>
readonly usePreviewMode: UnwrapRef<typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/preview')['usePreviewMode']>
readonly useRequestEvent: UnwrapRef<typeof import('../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/composables/ssr')['useRequestEvent']>

9
apps/admin/.nuxt/types/plugins.d.ts

@ -10,20 +10,27 @@ type NuxtAppInjections =
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/revive-payload.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/head/runtime/plugins/unhead.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/pages/runtime/plugins/router.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/browser-devtools-timing.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/payload.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/dev-server-logs.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/navigation-repaint.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/check-outdated-build.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/revive-payload.server.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/chunk-reload.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/@pinia+nuxt@0.11.3_magicast@0.5.3_pinia@3.0.4_typescript@5.9.3_vue@3.5.38_typescript@5.9.3__/node_modules/@pinia/nuxt/dist/runtime/plugin.vue3.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/pages/runtime/plugins/prefetch.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/pages/runtime/plugins/check-if-page-unused.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/@nuxt+devtools@3.2.4_vite@7.3.5_jiti@2.7.0_terser@5.48.0_yaml@2.9.0__vue@3.5.38_typescript@5.9.3_/node_modules/@nuxt/devtools/dist/runtime/plugins/devtools.server.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/@nuxt+devtools@3.2.4_vite@7.3.5_jiti@2.7.0_terser@5.48.0_yaml@2.9.0__vue@3.5.38_typescript@5.9.3_/node_modules/@nuxt/devtools/dist/runtime/plugins/devtools.client.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/warn.dev.server.js")> &
InjectionType<typeof import("../../node_modules/.pnpm/nuxt@4.4.8_@babel+plugin-syntax-jsx@7.29.7_@babel+core@7.29.7__@babel+plugin-syntax-typ_25b4a4016600149c0331b87503aafc6b/node_modules/nuxt/dist/app/plugins/check-if-layout-used.js")> &
InjectionType<typeof import("../../app/plugins/auth.client")>
declare module '#app' {
interface NuxtApp extends NuxtAppInjections { }
interface NuxtAppLiterals {
pluginName: 'nuxt:revive-payload:client' | 'nuxt:head' | 'nuxt:router' | 'nuxt:payload' | 'nuxt:revive-payload:server' | 'nuxt:chunk-reload' | 'pinia' | 'nuxt:global-components' | 'nuxt:prefetch'
pluginName: 'vue-devtools-client' | 'nuxt:revive-payload:client' | 'nuxt:head' | 'nuxt:router' | 'nuxt:browser-devtools-timing' | 'nuxt:payload' | 'nuxt:revive-payload:server' | 'nuxt:chunk-reload' | 'pinia' | 'nuxt:global-components' | 'nuxt:prefetch' | 'nuxt:checkIfPageUnused' | 'nuxt:checkIfLayoutUsed'
}
}

721
apps/admin/app/components/AppDeployTab.vue

@ -0,0 +1,721 @@
<template>
<div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">区域部署</div>
<div class="card-desc">
每个区域一份部署,各自连自己的业务库。「升级」会 SSH 到绑定的环境节点,在部署目录执行
<span class="font-mono">docker compose pull &amp; up -d</span>。
</div>
</div>
<button class="btn btn-primary btn-xs text-white shrink-0" @click="openNew()">+ 部署新环境</button>
</div>
<div v-if="!deployments.length" class="py-14 text-center text-slate-400 text-sm">
该应用还没有部署,点右上「+ 部署新环境」添加。
</div>
<div v-for="d in deployments" :key="d.id" class="dep" :class="{ pending: d.status === 'pending' }">
<div class="min-w-0">
<div class="dep-name">
<span class="chip chip-dep">部署</span>
<span class="truncate">{{ d.name || '部署#' + d.id }}</span>
<span v-if="d.status === 'pending'" class="chip chip-warn">待部署</span>
<span v-else class="chip chip-ok">已接入</span>
<span v-if="!d.enabled" class="chip">停用</span>
</div>
<div class="dep-sub">#{{ d.id }} · {{ d.region || '未指定区域' }} · 业务库 {{ dbName(d.service_dsn) }}</div>
</div>
<div>
<div class="field-l">环境节点</div>
<select
v-model.number="d.env_id"
class="select select-bordered select-xs w-full text-xs"
:disabled="envSavingId === d.id"
@change="bindEnv(d)"
>
<option :value="0">未绑定</option>
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}</option>
</select>
</div>
<div>
<div class="field-l">当前版本</div>
<div class="flex items-center gap-1.5">
<span class="chip chip-mono" :class="isPinned(d.image_tag) ? 'chip-ok' : 'chip-warn'">{{ d.image_tag || 'latest' }}</span>
<span v-if="!isPinned(d.image_tag)" class="text-[10.5px] text-amber-700" title="latest 是可变 tag,升级后无法从列表看出实际版本">未固定</span>
</div>
</div>
<div class="min-w-0">
<div class="field-l">部署目录</div>
<div class="font-mono text-[11px] truncate" :class="d.deploy_dir ? 'text-slate-500' : 'text-red-500'">
{{ d.deploy_dir || '未填' }}
</div>
</div>
<div class="dep-acts">
<button class="btn btn-xs btn-warning" @click="openUpgrade(d)">升级</button>
<button class="btn btn-xs" @click="emit('configure', d.id)">配置</button>
<button
class="btn btn-xs btn-ghost text-teal-600"
:disabled="!d.base_url"
:title="d.base_url ? '打开该部署的 Swagger 文档' : '该部署未配置对外地址(base_url)'"
@click="openSwagger(d)"
>API 测试</button>
<button class="btn btn-xs btn-ghost" @click="openForm(d)">编辑</button>
<button class="btn btn-xs btn-ghost text-emerald-600" :disabled="d.status === 'pending'" @click="openAnalyze(d)">分析</button>
<button class="btn btn-xs btn-ghost text-indigo-500" :disabled="d.status === 'pending'" @click="notifyApp(d.id)">发事件</button>
<button class="btn btn-xs btn-ghost text-red-500" @click="delDeploy(d)">删除</button>
</div>
</div>
</div>
<!-- ============ 部署表单 ============ -->
<dialog ref="formDlg" class="modal">
<div class="modal-box max-w-3xl">
<h3 class="font-bold text-lg">{{ dIsEditing ? '编辑部署' : '部署新环境' }}</h3>
<div v-if="!dIsEditing" class="tabs tabs-boxed bg-slate-100 w-fit my-3">
<a class="tab" :class="dMode === 'existing' ? 'tab-active' : ''" @click="setMode('existing')">添加现有</a>
<a class="tab" :class="dMode === 'deploy' ? 'tab-active' : ''" @click="setMode('deploy')">部署到目标环境</a>
</div>
<p class="text-xs text-slate-400 mb-4" :class="dIsEditing ? 'mt-1' : ''">
<template v-if="dIsEditing">改好后回列表点「升级」执行远程部署(拉新镜像并重启)。</template>
<template v-else-if="dMode === 'existing'">把「已经部署好的应用区域」加进来:填地址与业务库 DSN,测试通过后保存。</template>
<template v-else>选一个目标环境节点,先建关联并标记<b>待部署</b>;保存后回列表点「升级」执行首次部署。</template>
</p>
<form class="space-y-3" @submit.prevent="saveDeploy">
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">所属应用</span></label>
<input :value="appName" type="text" class="input input-bordered input-sm bg-slate-100" readonly />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">部署名称 *</span></label>
<input v-model="dForm.name" type="text" class="input input-bordered input-sm" required :placeholder="`如 ${appName}(国内)`" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">区域</span></label>
<select v-model="dForm.region" class="select select-bordered select-sm">
<option value="">未指定</option>
<option v-for="r in availableRegions" :key="r" :value="r">{{ r }}</option>
</select>
<span class="text-[11px] text-slate-400 mt-1">已部署的区域不再出现(一个应用每区域一份部署)。</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">部署环境{{ dMode === 'deploy' ? ' *' : '' }}</span></label>
<select v-model.number="dForm.env_id" class="select select-bordered select-sm">
<option :value="0">{{ dMode === 'deploy' ? '请选择目标环境' : '未关联' }}</option>
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option>
</select>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">部署目录</span></label>
<input v-model="dForm.deploy_dir" type="text" class="input input-bordered input-sm font-mono text-xs" placeholder="/home/work/app" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">镜像名</span></label>
<input v-model="dForm.image_name" type="text" class="input input-bordered input-sm font-mono text-xs" :placeholder="defaultImage || '默认取应用'" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">镜像 Tag</span></label>
<input v-model="dForm.image_tag" type="text" class="input input-bordered input-sm font-mono text-xs" placeholder="0.1.1 / latest" />
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">业务库 DSN{{ dMode === 'existing' ? ' *' : '' }}</span></label>
<input
v-model="dForm.service_dsn" type="text" class="input input-bordered input-sm font-mono text-xs"
placeholder="root:pwd@tcp(host:3306)/earphone?charset=utf8mb4&parseTime=True&loc=Local"
@input="resetTest"
/>
<span class="text-[11px] text-slate-400 mt-1">业务功能配置、会议记录服务配置都写在这个库里。</span>
</div>
<template v-if="dMode === 'existing'">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">应用地址 *</span></label>
<input v-model="dForm.base_url" type="text" class="input input-bordered input-sm" placeholder="https://api.xxx.com/" @input="resetTest" />
<span class="text-[11px] text-slate-400 mt-1">用于通知应用业务服务事件(如配置更新)、打开 Swagger。</span>
</div>
<div v-if="testResult.show" class="text-sm space-y-0.5">
<div :class="testResult.apiOk ? 'text-green-700' : 'text-red-600'">
{{ testResult.apiOk ? '✓' : '✗' }} 应用地址:{{ testResult.apiMsg }}
</div>
<div :class="testResult.dbOk ? 'text-green-700' : 'text-red-600'">
{{ testResult.dbOk ? '✓' : '✗' }} 业务库:{{ testResult.dbMsg }}
</div>
</div>
</template>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label>
<input v-model="dForm.remark" type="text" class="input input-bordered input-sm" />
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="dForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">启用</span>
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="formDlg?.close()">取消</button>
<button v-if="dMode === 'existing'" type="button" class="btn btn-outline btn-sm" :disabled="testing" @click="testConn">
{{ testing ? '测试中…' : '测试连接' }}
</button>
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="!dIsEditing && dMode === 'existing' && !testPassed">保存</button>
</div>
<p v-if="!dIsEditing && dMode === 'existing'" class="text-[11px] text-slate-400 text-right">
需先「测试连接」通过(应用地址可达 + 业务库可连)才能保存。
</p>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- ============ 升级 ============ -->
<dialog ref="upgradeDlg" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg">升级部署</h3>
<p class="text-sm text-slate-500 mt-1">{{ up.label }}</p>
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed">
将 <b>SSH 连到该部署绑定的环境节点</b>,进入部署目录执行
<span class="font-mono">docker login → compose pull → up -d → 清理旧镜像</span>。<br />
· 需服务器上已维护好 <span class="font-mono">docker-compose.yml / .env / confs</span>(本操作不上传真实配置)。<br />
· 会<b>重启该部署的容器</b>,请确认无误后再执行。
</div>
<dl class="grid grid-cols-[76px_1fr] gap-x-3 gap-y-1 text-xs mt-4">
<dt class="text-slate-400">环境节点</dt>
<dd :class="up.envName ? 'font-medium text-slate-700' : 'font-medium text-red-500'">
{{ up.envName || '未绑定 —— 请先在「编辑」里选择环境节点' }}
</dd>
<dt class="text-slate-400">部署目录</dt>
<dd :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'">
{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}
</dd>
</dl>
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-slate-500">镜像版本 Tag</label>
<button type="button" class="text-xs text-indigo-500 hover:underline" :disabled="up.running" @click="up.tagMode = up.tagMode === 'list' ? 'custom' : 'list'">
{{ up.tagMode === 'list' ? '手动输入…' : '从仓库选择' }}
</button>
</div>
<div v-if="up.tagMode === 'list'" class="flex gap-2">
<select v-model="up.tag" class="select select-bordered select-sm flex-1 font-mono" :disabled="up.running || up.tagsLoading">
<option value="">{{ up.tagsLoading ? '加载可用版本中…' : '请选择版本' }}</option>
<option v-if="up.tag && !up.tags.includes(up.tag)" :value="up.tag">{{ up.tag }}(当前记录)</option>
<option v-for="t in up.tags" :key="t" :value="t">{{ t }}{{ t === 'latest' ? ' · 不固定,不推荐' : '' }}</option>
</select>
<button type="button" class="btn btn-ghost btn-sm" :disabled="up.running || up.tagsLoading" @click="loadTags">刷新</button>
</div>
<input v-else v-model="up.tag" type="text" class="input input-bordered input-sm w-full font-mono" placeholder="0.1.1" :disabled="up.running" />
<p v-if="up.tagsError" class="text-xs text-amber-600 mt-1">
拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试)
</p>
<p v-else-if="!isPinned(up.tag)" class="text-xs text-amber-600 mt-1">
⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。
</p>
</div>
<div v-if="up.show" class="mt-3">
<div class="flex items-center gap-2 text-xs mb-1">
<span v-if="up.running" class="loading loading-spinner loading-xs text-amber-500"></span>
<span v-else-if="up.ok" class="text-green-600 font-semibold">✓ 部署完成</span>
<span v-else class="text-red-600 font-semibold">✗ 部署失败</span>
<span class="text-slate-400">{{ up.running ? '执行中…' : '' }}</span>
</div>
<pre ref="upLogBox" class="bg-slate-900 text-slate-100 text-[11px] leading-relaxed rounded-lg p-3 h-64 overflow-auto font-mono whitespace-pre-wrap">{{ up.log || '(等待输出…)' }}</pre>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" :disabled="up.running" @click="upgradeDlg?.close()">关闭</button>
<button class="btn btn-warning btn-sm" :disabled="up.running" @click="runUpgrade">{{ up.btnText }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button :disabled="up.running">close</button></form>
</dialog>
<!-- ============ 历史数据分析 ============ -->
<dialog ref="analyzeDlg" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">分析历史数据</h3>
<p class="text-sm text-slate-500 mt-1">{{ az.label }}</p>
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed">
扫描该应用业务库,按 <b>天 + 产品 + 区域</b> 生成历史统计(新增用户 / 订单 / 资源发放·使用 / 激活绑定设备),汇入 console 主库。<br />
· <b>分析前会先清理该应用+区域在所选区间内的旧统计行</b>,避免数据重叠。<br />
· 登录、活跃用户、AI对话等无每日原始记录,历史无法还原(由实时管道往后采集)。<br />
· 仅对实时管道上线前的区间运行。留空 = 全部历史。大库可能耗时。
</div>
<div class="grid grid-cols-2 gap-3 mt-4">
<div><label class="text-xs text-slate-500">起始日(含)</label><input v-model="az.start" type="date" class="input input-bordered input-sm w-full" /></div>
<div><label class="text-xs text-slate-500">结束日(含)</label><input v-model="az.end" type="date" class="input input-bordered input-sm w-full" /></div>
</div>
<div v-if="az.mode" class="text-sm mt-3">
<div v-if="az.mode === 'progress'" class="bg-slate-50 border border-slate-200 rounded-lg p-3">
<div class="flex justify-between items-center text-xs mb-1.5">
<span class="text-slate-600">
<template v-if="az.curName"><span class="loading loading-spinner loading-xs"></span> 正在:{{ az.curName }} ({{ az.curIndex }}/{{ az.curTotal }})</template>
<template v-else>准备中...</template>
</span>
<span class="font-mono text-slate-400">{{ az.pct }}%</span>
</div>
<progress class="progress progress-primary w-full h-2" :value="az.pct" max="100"></progress>
<div class="mt-2 text-xs space-y-0.5">
<div v-for="(nm, i) in az.steps" :key="i" :class="i === az.curIndex - 1 ? 'text-slate-600' : 'text-green-600'">
<template v-if="i === az.curIndex - 1"><span class="loading loading-spinner loading-xs"></span> {{ nm }}</template>
<template v-else>✓ {{ nm }}</template>
</div>
</div>
</div>
<div v-else-if="az.mode === 'error'" class="text-red-600">✗ {{ az.errMsg }}</div>
<div v-else-if="az.mode === 'done' && az.report" class="bg-green-50 border border-green-100 rounded-lg p-3">
<div class="font-semibold text-green-700 mb-2">✓ 分析完成</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-600 mb-2">
<div>应用:<b>{{ az.report.app_name || '' }}</b></div>
<div>区域:{{ az.report.region || '未指定' }}</div>
<div>区间:{{ (az.report.start_day || '不限') + ' ~ ' + (az.report.end_day || '不限') }}</div>
<div>清理旧行:{{ az.report.cleared_rows || 0 }}</div>
<div>写入天数:<b>{{ az.report.days_written || 0 }}</b></div>
<div>写入行数:{{ az.report.rows_written || 0 }}</div>
</div>
<div class="border-t border-green-100 pt-2 text-xs">
<div v-for="(s, i) in (az.report.steps || [])" :key="i" class="flex justify-between py-0.5">
<span class="text-slate-500">{{ s.name }}</span>
<span class="font-mono text-slate-700">{{ (s.groups || 0) + ' 组' }}</span>
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" :disabled="az.running" @click="analyzeDlg?.close()">关闭</button>
<button class="btn btn-primary btn-sm text-white" :disabled="az.running" @click="runAnalyze">{{ az.btnText }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button :disabled="az.running">close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
import type { Deployment, EnvOption } from '~/types/app'
const props = defineProps<{
appName: string
defaultImage: string
deployments: Deployment[]
envs: EnvOption[]
}>()
const emit = defineEmits<{ refresh: []; configure: [id: number] }>()
const { consoleApi } = useApi()
const { success, error } = useToast()
const auth = useAuthStore()
const router = useRouter()
const REGIONS = ['中国', '美国', '东亚', '东南亚', '南亚', '中东', '欧洲', '俄罗斯', '非洲', '南美', '北美', '大洋洲', '巴西', '印度']
function dbName(dsn?: string) {
const m = (dsn || '').match(/\/([^/?]+)\?/)
return m ? m[1] : (dsn ? '(自定义)' : '未配置')
}
function isPinned(tag?: string) {
const t = (tag || '').trim()
return !!t && t !== 'latest'
}
function envName(id?: number) {
if (!id) return ''
return props.envs.find((e) => e.id === id)?.name ?? '#' + id
}
// ---- 内联绑定环境节点 ----
const envSavingId = ref(0)
async function bindEnv(d: Deployment) {
envSavingId.value = d.id
try {
await consoleApi('apps/bindenv', { id: d.id, env_id: Number(d.env_id) || 0 })
success('部署环境已更新')
} catch (e: any) {
error(e?.message ?? '切换环境失败')
emit('refresh') // 回滚为服务器状态
} finally {
envSavingId.value = 0
}
}
function openSwagger(d: Deployment) {
let base = (d.base_url || '').trim().replace(/\/+$/, '')
if (!base) { error('该部署未配置对外地址(base_url)'); return }
if (!/^https?:\/\//i.test(base)) base = 'https://' + base
window.open(base + '/swagger/index.html', '_blank', 'noopener')
}
function notifyApp(id: number) {
if (!window.confirm(`向部署 #${id} 发送一条配置 reload 事件?`)) return
consoleApi<{ subject?: string }>('config/notify', { app_id: id, type: 'all', action: 'reload' })
.then((d) => success('已发送 → ' + (d && d.subject)))
.catch((e: any) => error(e?.message ?? '发送失败'))
}
function delDeploy(d: Deployment) {
if (!window.confirm(`删除部署「${d.name || '#' + d.id}」?(不影响应用自身的库)`)) return
consoleApi('apps/del', { id: d.id })
.then(() => { success('已删除'); emit('refresh') })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
// ---- 部署表单 ----
const formDlg = ref<HTMLDialogElement | null>(null)
const dIsEditing = ref(false)
const dMode = ref<'existing' | 'deploy'>('existing')
const dForm = reactive({
id: 0, name: '', region: '', env_id: 0, base_url: '', service_dsn: '',
deploy_dir: '', image_name: '', image_tag: '', remark: '', enabled: true,
})
const testPassed = ref(false)
const testing = ref(false)
const testResult = reactive({ show: false, apiOk: false, apiMsg: '', dbOk: false, dbMsg: '' })
const availableRegions = computed(() => {
const used = new Set(props.deployments.filter((d) => d.id !== dForm.id).map((d) => d.region).filter(Boolean))
return REGIONS.filter((r) => !used.has(r) || r === dForm.region)
})
function resetTest() {
testPassed.value = false
testResult.show = false
}
function setMode(m: 'existing' | 'deploy') {
dMode.value = m
resetTest()
}
function openNew() {
dIsEditing.value = false
dMode.value = 'existing'
Object.assign(dForm, {
id: 0, name: '', region: '', env_id: 0, base_url: '', service_dsn: '',
deploy_dir: '', image_name: props.defaultImage, image_tag: '', remark: '', enabled: true,
})
resetTest()
formDlg.value?.showModal()
}
function openForm(d: Deployment) {
dIsEditing.value = true
dMode.value = d.status === 'pending' ? 'deploy' : 'existing'
Object.assign(dForm, {
id: d.id, name: d.name || '', region: d.region || '', env_id: d.env_id || 0,
base_url: d.base_url || '', service_dsn: d.service_dsn || '', deploy_dir: d.deploy_dir || '',
image_name: d.image_name || '', image_tag: d.image_tag || '', remark: d.remark || '', enabled: !!d.enabled,
})
resetTest()
formDlg.value?.showModal()
}
async function testConn() {
const baseUrl = dForm.base_url.trim()
const serviceDsn = dForm.service_dsn.trim()
if (!baseUrl || !serviceDsn) { error('请先填应用地址和业务库 DSN'); return }
testing.value = true
try {
const d = await consoleApi<{ ok: boolean; api_ok: boolean; api_msg: string; db_ok: boolean; db_msg: string }>(
'apps/test', { base_url: baseUrl, service_dsn: serviceDsn },
)
testResult.apiOk = !!d.api_ok
testResult.apiMsg = d.api_msg
testResult.dbOk = !!d.db_ok
testResult.dbMsg = d.db_msg
testResult.show = true
testPassed.value = !!d.ok
if (testPassed.value) success('检测通过,可以保存')
else error('检测未通过')
} catch (e: any) {
error(e?.message ?? '测试失败')
} finally {
testing.value = false
}
}
async function saveDeploy() {
if (!dForm.name.trim()) { error('请填部署名称'); return }
if (!dIsEditing.value && dMode.value === 'existing' && !testPassed.value) { error('请先「测试连接」通过再保存'); return }
if (dMode.value === 'deploy' && !dForm.env_id) { error('请选择部署目标环境'); return }
const data: Record<string, unknown> = {
app_name: props.appName,
status: dMode.value === 'deploy' ? 'pending' : 'existing',
name: dForm.name,
region: dForm.region,
env_id: Number(dForm.env_id) || 0,
base_url: dMode.value === 'deploy' ? '' : dForm.base_url,
service_dsn: dForm.service_dsn,
deploy_dir: dForm.deploy_dir,
image_name: dForm.image_name,
image_tag: dForm.image_tag,
remark: dForm.remark,
enabled: dForm.enabled,
}
let path = 'apps/add'
if (dIsEditing.value && dForm.id) { data.id = Number(dForm.id); path = 'apps/update' }
try {
await consoleApi(path, data)
success('保存成功')
formDlg.value?.close()
emit('refresh')
} catch (e: any) {
error(e?.message ?? '保存失败')
}
}
// ---- 升级(NDJSON 流式回显)----
const upgradeDlg = ref<HTMLDialogElement | null>(null)
const upLogBox = ref<HTMLElement | null>(null)
const up = reactive({
id: 0, label: '', envName: '', deployDir: '', tag: '',
tags: [] as string[], tagMode: 'list' as 'list' | 'custom', tagsLoading: false, tagsError: '',
log: '', show: false, running: false, ok: false, canRun: false, btnText: '开始升级',
})
function openUpgrade(d: Deployment) {
up.id = d.id
up.label = '部署:' + (d.name || '部署#' + d.id) + (d.region ? ' · ' + d.region : '')
up.envName = envName(d.env_id)
up.deployDir = d.deploy_dir || ''
up.tag = d.image_tag || ''
up.tags = []
up.tagMode = 'list'
up.tagsError = ''
up.log = ''
up.show = false
up.running = false
up.ok = false
up.canRun = !!(d.env_id && (d.deploy_dir || '').trim())
up.btnText = '开始升级'
upgradeDlg.value?.showModal()
if (up.canRun) loadTags()
}
async function loadTags() {
if (!up.id) return
up.tagsLoading = true
up.tagsError = ''
try {
const r = await consoleApi<{ repo: string; current: string; tags: string[] }>('apps/tags', { id: up.id })
up.tags = r.tags || []
up.tagMode = 'list'
if (!up.tag || !up.tags.includes(up.tag)) {
up.tag = up.tags.find((t) => t !== 'latest') || up.tag || ''
}
} catch (e: any) {
up.tagsError = e?.message ?? '拉取失败'
} finally {
up.tagsLoading = false
}
}
async function runUpgrade() {
if (up.running) return
if (!up.canRun) {
const miss: string[] = []
if (!up.envName) miss.push('绑定环境节点')
if (!up.deployDir) miss.push('填写部署目录')
window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`)
return
}
const tag = (up.tag || '').trim()
if (!isPinned(tag)) {
if (!window.confirm(`未选择明确版本号(当前 = ${tag || '空 → 后端回退 latest'})。\nlatest 是可变 tag,升级后列表无法体现具体版本号,确定继续?`)) return
}
if (!window.confirm(`确认升级【${up.label}】?\n目标版本:${tag || 'latest'}\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return
up.running = true
up.show = true
up.ok = false
up.log = ''
up.btnText = '升级中…'
let hadError: string | null = null
let needLogout = false
function append(s: string) {
up.log += (up.log ? '\n' : '') + s
nextTick(() => { if (upLogBox.value) upLogBox.value.scrollTop = upLogBox.value.scrollHeight })
}
function handleLine(line: string) {
line = (line || '').trim()
if (!line) return
let ev: any
try { ev = JSON.parse(line) } catch { return }
if (ev.code === 18) { needLogout = true; return }
if (ev.code !== undefined && ev.code !== 0 && ev.type === undefined) { hadError = ev.msg || '请求被拒绝'; return }
if (ev.type === 'log') append(ev.line || '')
else if (ev.type === 'error') { hadError = ev.msg; append('✗ ' + (ev.msg || '')) }
}
try {
const resp = await fetch('/console/api/apps/upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token },
body: JSON.stringify({ id: up.id, tag }),
})
const reader = resp.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const r = await reader.read()
if (r.done) break
buf += decoder.decode(r.value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() as string
for (const l of lines) handleLine(l)
}
handleLine(buf)
} catch (ex: any) {
hadError = '网络异常: ' + ex.message
append('✗ ' + hadError)
}
if (needLogout) { auth.logout(); router.push('/'); return }
up.running = false
up.btnText = '重新升级'
up.ok = !hadError
if (hadError) error(hadError)
else { success('升级完成'); emit('refresh') }
}
// ---- 历史数据分析(NDJSON 流式进度)----
interface ReportStep { name: string; groups: number }
interface AnalyzeReport {
app_name: string; region: string; start_day: number; end_day: number
cleared_rows: number; days_written: number; rows_written: number; steps: ReportStep[]
}
const analyzeDlg = ref<HTMLDialogElement | null>(null)
const az = reactive({
appId: 0, label: '', start: '', end: '',
mode: '' as '' | 'progress' | 'error' | 'done',
steps: [] as string[], curIndex: 0, curTotal: 0, curName: '', pct: 0,
errMsg: '', report: null as AnalyzeReport | null, running: false, btnText: '开始分析',
})
function ymd(s: string) { return parseInt((s || '').replace(/-/g, '')) || 0 }
function openAnalyze(d: Deployment) {
Object.assign(az, {
appId: d.id, label: '部署:' + (d.name || '部署#' + d.id) + ' (#' + d.id + ')',
start: '', end: '', mode: '', steps: [], curIndex: 0, curTotal: 0, curName: '', pct: 0,
errMsg: '', report: null, running: false, btnText: '开始分析',
})
analyzeDlg.value?.showModal()
}
async function runAnalyze() {
const s = az.start || '最早'
const e = az.end || '至今'
if (!window.confirm(`将先删除【${az.label}】在 ${s} ~ ${e} 区间的历史统计数据,然后重新分析写入。\n\n确认继续?`)) return
Object.assign(az, { running: true, btnText: '分析中...', mode: 'progress', steps: [], curIndex: 0, curTotal: 0, curName: '', pct: 0, report: null, errMsg: '' })
let doneReport: AnalyzeReport | null = null
let errMsg: string | null = null
let needLogout = false
function handleLine(line: string) {
line = (line || '').trim()
if (!line) return
let ev: any
try { ev = JSON.parse(line) } catch { return }
if (ev.code === 18) { needLogout = true; return }
if (ev.code !== undefined && ev.code !== 0) { errMsg = ev.msg || '请求被拒绝'; return }
if (ev.type === 'progress') {
az.steps[ev.index - 1] = ev.name
az.pct = Math.round((ev.index / (ev.total + 1)) * 100)
az.curIndex = ev.index
az.curTotal = ev.total
az.curName = ev.name
} else if (ev.type === 'done') doneReport = ev.report
else if (ev.type === 'error') errMsg = ev.msg
}
try {
const resp = await fetch('/console/api/apps/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token },
body: JSON.stringify({ app_id: az.appId, start_day: ymd(az.start), end_day: ymd(az.end) }),
})
const reader = resp.body!.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const r = await reader.read()
if (r.done) break
buf += decoder.decode(r.value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() as string
for (const l of lines) handleLine(l)
}
handleLine(buf)
} catch (ex: any) {
errMsg = '网络异常: ' + ex.message
}
if (needLogout) { auth.logout(); router.push('/'); return }
az.running = false
az.btnText = '重新分析'
if (errMsg) { az.mode = 'error'; az.errMsg = errMsg; return }
if (!doneReport) { az.mode = 'error'; az.errMsg = '分析中断,未收到完成事件'; return }
az.mode = 'done'
az.report = doneReport
success('分析完成')
emit('refresh')
}
defineExpose({ openNew })
</script>
<style scoped>
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 74ch; line-height: 1.55; }
.dep { display: grid; grid-template-columns: 1.5fr 1fr 1fr .9fr auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #eef2f7; }
.dep:last-child { border-bottom: 0; }
.dep:hover { background: #fcfcfd; }
.dep.pending { background: linear-gradient(90deg, #fef3c7 0%, transparent 34%); }
.dep-name { font-weight: 600; font-size: 13.5px; color: #334155; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.dep-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; font-family: 'SF Mono', Menlo, monospace; }
.dep-acts { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.field-l { font-size: 10px; color: #94a3b8; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; margin-bottom: 3px; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-dep { background: #ecfeff; color: #0e7490; }
.chip-ok { background: #dcfce7; color: #15803d; }
.chip-warn { background: #fef3c7; color: #b45309; }
.chip-mono { font-family: 'SF Mono', Menlo, monospace; font-weight: 600; }
@media (max-width: 1280px) {
.dep { grid-template-columns: 1fr 1fr; }
.dep-acts { grid-column: 1 / -1; justify-content: flex-start; }
}
</style>

317
apps/admin/app/components/AppModuleTab.vue

@ -0,0 +1,317 @@
<template>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">业务功能配置</div>
<div class="card-desc">
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>「{{ depName }}」这个部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。
</div>
</div>
<button
class="btn btn-outline btn-error btn-xs shrink-0"
:disabled="saving || !!dbError"
title="把该部署业务库里所有业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。用于升级/迁移把库写脏后的整体恢复。"
@click="resetAll"
>全部重置为配置文件</button>
</div>
<div v-if="dbError" class="m-4 alert alert-warning py-2 text-sm">
<span>
无法连接应用业务库:{{ dbError }}<br />
<span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span>
</span>
</div>
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<div v-else class="cfg">
<aside class="cfg-nav">
<div v-for="grp in groups" :key="grp.name">
<div class="cfg-group">{{ grp.name }}</div>
<button
v-for="m in grp.modules"
:key="m.module"
class="cfg-item"
:aria-current="curModule === m.module"
@click="curModule = m.module"
>
<span>{{ m.name }}</span>
<span v-if="dataModules.has(m.module)" class="dot" title="库中已有配置"></span>
</button>
</div>
</aside>
<section class="cfg-form">
<template v-if="curDef">
<div class="flex items-start justify-between gap-3 mb-4 flex-wrap">
<div>
<div class="text-sm font-semibold text-slate-800">{{ curDef.name }}</div>
<div v-if="curDef.desc" class="text-[11px] text-slate-400 mt-0.5">{{ curDef.desc }}</div>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-xs text-amber-600" :disabled="saving"
title="丢弃库中该模块的全部配置,改用业务服务配置文件(yaml)里的初始值重新写入。"
@click="resetModule"
>重置</button>
<button v-if="dataModules.has(curModule)" class="btn btn-ghost btn-xs text-rose-500" :disabled="saving" @click="clearModule">清空</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !!dbError" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 max-w-2xl">
<div v-for="f in curDef.fields" :key="f.key" class="form-control">
<label class="label py-0.5">
<span class="label-text text-sm">
{{ f.label }}
<span v-if="f.required" class="text-rose-400">*</span>
<span v-if="f.secret" class="lock-tag ml-1">🔒</span>
<span class="text-[10px] font-normal text-slate-300 ml-1 font-mono">{{ f.key }}</span>
</span>
</label>
<label v-if="f.type === 'bool'" class="cursor-pointer flex items-center gap-2">
<input type="checkbox" class="toggle toggle-sm toggle-primary" :checked="cur[f.key] === 'true'" @change="setBool(f.key, $event)" />
<span class="text-xs text-slate-500">{{ cur[f.key] === 'true' ? '开启' : '关闭' }}</span>
</label>
<select v-else-if="f.type === 'select'" v-model="cur[f.key]" class="select select-bordered select-sm w-full">
<option value="">请选择</option>
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<textarea
v-else-if="f.type === 'textarea'" v-model="cur[f.key]"
class="textarea textarea-bordered w-full font-mono text-xs" rows="3"
:placeholder="f.secret ? '留空/保持 [encrypted] 则不变' : (f.placeholder || '')"
></textarea>
<div v-else-if="f.type === 'password'" class="relative">
<input
v-model="cur[f.key]" :type="show[f.key] ? 'text' : 'password'"
class="input input-bordered input-sm w-full font-mono text-xs pr-14"
placeholder="留空/保持 [encrypted] 则不变"
/>
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700" @click="show[f.key] = !show[f.key]">
{{ show[f.key] ? '隐藏' : '显示' }}
</button>
</div>
<input v-else v-model="cur[f.key]" type="text" class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="f.placeholder || ''" />
<span v-if="f.desc" class="text-[11px] text-slate-400 mt-0.5">{{ f.desc }}</span>
</div>
</div>
</template>
<div v-else class="py-10 text-center text-slate-400 text-sm">暂无可配置的业务模块。</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
const props = defineProps<{ appId: number; depName: string }>()
const { consoleApi } = useApi()
const { success, info, error } = useToast() as any
interface FieldOption { value: string; label: string }
interface ModuleField {
key: string; label: string; type: string; secret: boolean; required: boolean
placeholder?: string; desc?: string; options?: FieldOption[]
}
interface ModuleDef { module: string; name: string; group: string; sys_key: string; desc?: string; fields: ModuleField[] }
interface ModItem { module: string; key: string; value: string; encrypted: boolean }
interface ApplyResult { module: string; status: string; err?: string }
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] }
const catalog = ref<ModuleDef[]>([])
const curModule = ref('')
const values = reactive<Record<string, Record<string, string>>>({})
const dataModules = reactive(new Set<string>())
const show = reactive<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref(false)
const dbError = ref('')
const curDef = computed(() => catalog.value.find((m) => m.module === curModule.value) || null)
const cur = computed(() => values[curModule.value] || {})
const groups = computed(() => {
const order: string[] = []
const map = new Map<string, ModuleDef[]>()
for (const m of catalog.value) {
if (!map.has(m.group)) { map.set(m.group, []); order.push(m.group) }
map.get(m.group)!.push(m)
}
return order.map((name) => ({ name, modules: map.get(name)! }))
})
function setBool(key: string, e: Event) {
const bag = values[curModule.value] || (values[curModule.value] = {})
bag[key] = (e.target as HTMLInputElement).checked ? 'true' : 'false'
}
async function loadCatalog() {
try {
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {})
catalog.value = d?.modules ?? []
for (const m of catalog.value) if (!values[m.module]) values[m.module] = {}
if (catalog.value.length && !curModule.value) curModule.value = catalog.value[0]!.module
} catch (e: any) {
error(e?.message ?? '获取配置目录失败')
}
}
async function loadItems() {
if (!props.appId) return
loading.value = true
dbError.value = ''
try {
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: props.appId })
for (const m of catalog.value) values[m.module] = {}
dataModules.clear()
for (const it of (d?.items ?? [])) {
if (!values[it.module]) values[it.module] = {}
values[it.module]![it.key] = it.value
dataModules.add(it.module)
}
} catch (e: any) {
dbError.value = e?.message ?? '连接失败'
} finally {
loading.value = false
}
}
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') {
const list = acks ?? []
if (!list.length) {
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`)
return
}
const statusOf = (a: ConfigAck): string | undefined => {
const rs = module ? a.results?.filter((r) => r.module === module) : a.results
if (!rs || !rs.length) return undefined
if (rs.some((r) => r.status === 'failed')) return 'failed'
if (rs.some((r) => r.status === 'restart_required')) return 'restart_required'
if (rs.some((r) => r.status === 'applied')) return 'applied'
return undefined
}
const errOf = (a: ConfigAck) => (module ? a.results?.find((r) => r.module === module) : a.results?.find((r) => r.status === 'failed'))?.err ?? ''
const failed = list.filter((a) => statusOf(a) === 'failed')
const restart = list.filter((a) => statusOf(a) === 'restart_required')
const applied = list.filter((a) => statusOf(a) === 'applied')
if (failed.length) {
error(`${verb},但 ${failed.length}/${list.length} 个服务重载失败(仍在使用旧配置):${failed[0]!.instance}: ${errOf(failed[0]!)}`)
} else if (restart.length) {
info(`${verb}。${restart.length}/${list.length} 个服务需重启才生效(含支付等暂不支持热更新的模块)`)
} else if (applied.length) {
success(`${verb},${applied.length}/${list.length} 个服务已实时生效`)
} else {
info(`${verb},收到 ${list.length} 个服务的回执`)
}
}
async function save() {
if (!curDef.value) return
saving.value = true
try {
const items = curDef.value.fields.map((f) => ({ key: f.key, value: values[curModule.value]?.[f.key] ?? '' }))
const d = await consoleApi<{ saved: number; acks?: ConfigAck[] }>('modulecfg/save', { app_id: props.appId, module: curModule.value, items })
reportAcks(d?.acks, curModule.value)
await loadItems()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function resetModule() {
if (!curDef.value) return
if (!confirm(`确认把「${curDef.value.name}」还原为业务服务配置文件(yaml)里的初始值?\n\n库中该模块现有的配置将被全部覆盖,且无法撤销。`)) return
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { app_id: props.appId, module: curModule.value })
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '重置失败')
} finally {
saving.value = false
}
}
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块,故要求打字确认。
async function resetAll() {
const ans = prompt(
`⚠️ 危险操作:把「${props.depName}」业务库里【所有】业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。\n\n` +
`这会覆盖你在后台手工保存过的全部配置(例如邮件的 Resend 设置会被打回 yaml 默认值),且无法撤销。\n\n` +
`确认请输入:RESET`,
)
if (ans !== 'RESET') { if (ans !== null) info('已取消(未输入 RESET)'); return }
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', { app_id: props.appId, module: '*' })
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '全部重置失败')
} finally {
saving.value = false
}
}
async function clearModule() {
if (!curModule.value) return
if (!confirm(`确认清空「${curDef.value?.name}」的全部配置?`)) return
saving.value = true
try {
await consoleApi('modulecfg/del', { app_id: props.appId, module: curModule.value })
success('已清空')
await loadItems()
} catch (e: any) {
error(e?.message ?? '清空失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadCatalog()
await loadItems()
})
</script>
<style scoped>
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 78ch; line-height: 1.55; }
.cfg { display: grid; grid-template-columns: 190px 1fr; }
.cfg-nav { border-right: 1px solid #eef2f7; padding: 12px 10px; background: #fcfcfd; }
.cfg-group { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #94a3b8; padding: 10px 8px 4px; }
.cfg-item {
width: 100%; border: 0; background: none; font-size: 13px; color: #334155; text-align: left;
padding: 7px 10px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.cfg-item:hover { background: #f8fafc; }
.cfg-item[aria-current='true'] { background: #eef2ff; color: #4338ca; font-weight: 600; }
.cfg-item .dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; flex-shrink: 0; }
.cfg-form { padding: 16px 20px; min-width: 0; }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; }
@media (max-width: 900px) {
.cfg { grid-template-columns: 1fr; }
.cfg-nav { border-right: 0; border-bottom: 1px solid #eef2f7; }
}
</style>

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

@ -171,15 +171,15 @@ const menuGroups = [
{ id: 'globalconfig', label: '全局环境配置', to: '/globalconfig', icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9' },
{ id: 'mcpconfig', label: 'MCP配置', to: '/mcpconfig', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-3-4h.01M16 16h.01' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ id: 'meetingsvc', label: '会议记录服务配置', to: '/meetingsvc', 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: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'meetingsvc', label: '会议记录服务', to: '/meetingsvc', 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: 'calltranslate', label: '通话翻译配置', to: '/calltranslate', 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' },
],
},
{
label: 'SaaS 管理',
items: [
{ 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: 'environments', label: '环境节点', to: '/environments', icon: 'M5 3h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2zM5 13h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4a2 2 0 012-2zM7 7h.01M7 17h.01' },
],
},

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

@ -1,405 +0,0 @@
<template>
<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" style="background:linear-gradient(135deg,#eef2ff,#faf5ff);border:1px solid #e0e7ff">
<div class="flex items-center gap-4">
<div class="app-avatar">{{ appInitial }}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-base font-bold text-slate-800">{{ dep.name || appName }}</span>
<span class="badge badge-sm" style="background:#e0e7ff;color:#4338ca;border:none">#{{ appId }}</span>
<span v-if="dep.app_name" class="badge badge-ghost badge-sm">应用:{{ dep.app_name }}</span>
<span v-if="dep.region" class="badge badge-ghost badge-sm">{{ dep.region }}</span>
<span v-if="dep.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span>
<span v-else class="badge badge-success badge-sm text-white">已接入</span>
</div>
<div class="text-[11px] text-slate-500 mt-1 flex flex-wrap gap-x-4 gap-y-0.5">
<span v-if="dep.base_url">地址:<span class="font-mono">{{ dep.base_url }}</span></span>
<span>业务库:<span class="font-mono">{{ dbName(dep.service_dsn) }}</span></span>
</div>
</div>
</div>
<!-- 部署环境切换(老数据 env_id=0 未绑定,可在此绑定/切换)-->
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-indigo-100">
<span class="text-xs text-slate-500 shrink-0">部署环境</span>
<select v-model.number="envId" class="select select-bordered select-sm w-64" :disabled="envSaving" @change="bindEnv">
<option :value="0">未绑定</option>
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option>
</select>
<span v-if="envSaving" class="loading loading-spinner loading-xs text-indigo-400"></span>
<span class="text-[11px] text-slate-400">切换后即时保存。</span>
</div>
</div>
<!-- ===== 业务功能配置 ===== -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-5">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-slate-800">业务功能配置</h3>
<p class="text-[11px] text-slate-400 mt-0.5">
登录 / 短信 / 邮件 / 支付等应用独立配置,保存在<b>该部署的业务库</b>(不进公共库)。密钥字段加密落库,显示时脱敏。
</p>
</div>
<button class="btn btn-outline btn-error btn-xs shrink-0" :disabled="saving || dbError !== ''"
title="把该部署业务库里所有业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。用于升级/迁移把库写脏后的整体恢复。"
@click="resetAll">全部重置为配置文件</button>
</div>
<!-- 业务库不可达(如待部署/连接失败)提示 -->
<div v-if="dbError" class="alert alert-warning py-2 text-sm mb-3">
<span>无法连接应用业务库:{{ dbError }}<br><span class="text-xs opacity-70">待部署的应用先完成部署、或检查业务库 DSN 连通后再配置。</span></span>
</div>
<div v-if="loading" class="py-12 text-center"><span class="loading loading-spinner loading-sm"></span></div>
<div v-else class="flex gap-5">
<!-- 左侧:按分组的模块导航 -->
<aside class="w-48 shrink-0">
<div v-for="grp in groups" :key="grp.name" class="mb-3">
<div class="text-[11px] font-semibold text-slate-400 px-2 mb-1">{{ grp.name }}</div>
<button
v-for="m in grp.modules"
:key="m.module"
class="w-full flex items-center justify-between text-left px-3 py-2 rounded-lg text-sm mb-0.5 transition-colors"
:class="curModule === m.module ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-slate-600 hover:bg-slate-50'"
@click="curModule = m.module"
>
<span>{{ m.name }}</span>
<span v-if="dataModules.has(m.module)" class="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span>
</button>
</div>
</aside>
<!-- 右侧:当前模块表单 -->
<section class="flex-1 min-w-0">
<template v-if="curDef">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm font-semibold text-slate-800">{{ curDef.name }}</div>
<div v-if="curDef.desc" class="text-[11px] text-slate-400 mt-0.5">{{ curDef.desc }}</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs text-amber-600" :disabled="saving"
title="丢弃库中该模块的全部配置,改用业务服务配置文件(yaml)里的初始值重新写入。用于库被改脏后还原。"
@click="resetModule">重置</button>
<button v-if="dataModules.has(curModule)" class="btn btn-ghost btn-xs text-rose-500" :disabled="saving" @click="clearModule">清空</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving || dbError !== ''" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 max-w-2xl">
<div v-for="f in curDef.fields" :key="f.key" class="form-control">
<label class="label py-0.5">
<span class="label-text text-sm">
{{ f.label }}
<span v-if="f.required" class="text-rose-400">*</span>
<span v-if="f.secret" class="lock-tag ml-1">🔒</span>
<span class="text-[10px] font-normal text-slate-300 ml-1 font-mono">{{ f.key }}</span>
</span>
</label>
<!-- bool -->
<label v-if="f.type === 'bool'" class="cursor-pointer flex items-center gap-2">
<input type="checkbox" class="toggle toggle-sm toggle-primary"
:checked="cur[f.key] === 'true'"
@change="setBool(f.key, $event)" />
<span class="text-xs text-slate-500">{{ cur[f.key] === 'true' ? '开启' : '关闭' }}</span>
</label>
<!-- select -->
<select v-else-if="f.type === 'select'" v-model="cur[f.key]" class="select select-bordered select-sm w-full">
<option value="">请选择</option>
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<!-- textarea -->
<textarea v-else-if="f.type === 'textarea'" v-model="cur[f.key]"
class="textarea textarea-bordered w-full font-mono text-xs" rows="3"
:placeholder="f.secret ? '留空/保持 [encrypted] 则不变' : (f.placeholder || '')"></textarea>
<!-- password -->
<div v-else-if="f.type === 'password'" class="relative">
<input v-model="cur[f.key]" :type="show[f.key] ? 'text' : 'password'"
class="input input-bordered input-sm w-full font-mono text-xs pr-14"
:placeholder="'留空/保持 [encrypted] 则不变'" />
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-slate-700"
@click="show[f.key] = !show[f.key]">{{ show[f.key] ? '隐藏' : '显示' }}</button>
</div>
<!-- text -->
<input v-else v-model="cur[f.key]" type="text"
class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="f.placeholder || ''" />
<span v-if="f.desc" class="text-[11px] text-slate-400 mt-0.5">{{ f.desc }}</span>
</div>
</div>
</template>
<div v-else class="py-10 text-center text-slate-400 text-sm">暂无可配置的业务模块。</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '应用服务配置' })
const { consoleApi } = useApi()
const { success, info, error } = useToast() as any
const route = useRoute()
const router = useRouter()
interface FieldOption { value: string; label: string }
interface ModuleField {
key: string; label: string; type: string; secret: boolean; required: boolean
placeholder?: string; desc?: string; options?: FieldOption[]
}
interface ModuleDef { module: string; name: string; group: string; sys_key: string; desc?: string; fields: ModuleField[] }
interface ModItem { module: string; key: string; value: string; encrypted: boolean }
interface Deployment {
id: number; app_name: string; status: string; name: string; region: string
env_id: number; base_url: string; service_dsn: string
}
interface EnvOption { id: number; name: string; code: string }
const appId = computed(() => Number(route.query.id) || 0)
const appName = computed(() => (route.query.name as string) || '未指定应用')
const appInitial = computed(() => ((dep.app_name || appName.value || 'A').charAt(0).toUpperCase()))
const dep = reactive<Deployment>({ id: 0, app_name: '', status: '', name: '', region: '', env_id: 0, base_url: '', service_dsn: '' })
const envs = ref<EnvOption[]>([])
const envId = ref(0)
const envSaving = ref(false)
const catalog = ref<ModuleDef[]>([])
const curModule = ref('')
const values = reactive<Record<string, Record<string, string>>>({})
const dataModules = reactive(new Set<string>())
const show = reactive<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref(false)
const dbError = ref('')
const curDef = computed(() => catalog.value.find((m) => m.module === curModule.value) || null)
const cur = computed(() => values[curModule.value] || {})
const groups = computed(() => {
const order: string[] = []
const map = new Map<string, ModuleDef[]>()
for (const m of catalog.value) {
if (!map.has(m.group)) { map.set(m.group, []); order.push(m.group) }
map.get(m.group)!.push(m)
}
return order.map((name) => ({ name, modules: map.get(name)! }))
})
function setBool(key: string, e: Event) {
const bag = values[curModule.value] || (values[curModule.value] = {})
bag[key] = (e.target as HTMLInputElement).checked ? 'true' : 'false'
}
function goBack() { router.push('/apps') }
function dbName(dsn?: string) {
const m = (dsn || '').match(/\/([^/?]+)\?/)
return m ? m[1] : (dsn ? '(自定义)' : '—')
}
async function loadBase() {
try {
const [deps, es] = await Promise.all([
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
const found = (deps ?? []).find((d) => d.id === appId.value)
if (found) Object.assign(dep, found)
envs.value = es ?? []
envId.value = dep.env_id || 0
} catch (e: any) {
error(e?.message ?? '获取应用信息失败')
}
}
async function loadCatalog() {
try {
const d = await consoleApi<{ modules: ModuleDef[] }>('modulecfg/catalog', {})
catalog.value = d?.modules ?? []
// 初始化各模块的值容器,避免模板访问 undefined。
for (const m of catalog.value) if (!values[m.module]) values[m.module] = {}
if (catalog.value.length && !curModule.value) curModule.value = catalog.value[0].module
} catch (e: any) {
error(e?.message ?? '获取配置目录失败')
}
}
async function loadItems() {
if (!appId.value) return
loading.value = true
dbError.value = ''
try {
const d = await consoleApi<{ items: ModItem[] }>('modulecfg/list', { app_id: appId.value })
// 重置为目录默认容器,再灌入库值。
for (const m of catalog.value) values[m.module] = {}
dataModules.clear()
for (const it of (d?.items ?? [])) {
if (!values[it.module]) values[it.module] = {}
values[it.module][it.key] = it.value
dataModules.add(it.module)
}
} catch (e: any) {
dbError.value = e?.message ?? '连接失败'
} finally {
loading.value = false
}
}
async function bindEnv() {
if (!dep.id) return
envSaving.value = true
try {
await consoleApi('apps/bindenv', { id: dep.id, env_id: envId.value })
dep.env_id = envId.value
success('部署环境已更新')
} catch (e: any) {
error(e?.message ?? '切换环境失败')
envId.value = dep.env_id || 0 // 回滚
} finally {
envSaving.value = false
}
}
interface ApplyResult { module: string; status: string; err?: string }
interface ConfigAck { app_name: string; instance: string; results: ApplyResult[] }
// reportAcks 把各业务服务的热重载回执翻成一句人话(一条回执 = 一个服务,如 home / api)。
// module 指定则只看该模块的结果;留空(全部重置)则聚合该服务所有模块的结果。
// 三态如实呈现:把"需重启才生效"说成"已生效",比不给回执更糟——用户会以为新凭据已在用。
function reportAcks(acks: ConfigAck[] | undefined, module: string | undefined, verb = '配置已保存') {
const list = acks ?? []
if (!list.length) {
info(`${verb},但未收到任何服务的生效回执 —— 目标应用可能未运行、未填对外地址(base_url)、或两端 FIELD_ENCRYPT_KEY 不一致,需重启后生效`)
return
}
// 每个服务取一个代表状态:任一模块 failed→failed;否则任一 restart_required→restart;否则 applied。
const statusOf = (a: ConfigAck): string | undefined => {
const rs = module ? a.results?.filter((r) => r.module === module) : a.results
if (!rs || !rs.length) return undefined
if (rs.some((r) => r.status === 'failed')) return 'failed'
if (rs.some((r) => r.status === 'restart_required')) return 'restart_required'
if (rs.some((r) => r.status === 'applied')) return 'applied'
return undefined
}
const errOf = (a: ConfigAck) => (module ? a.results?.find((r) => r.module === module) : a.results?.find((r) => r.status === 'failed'))?.err ?? ''
const failed = list.filter((a) => statusOf(a) === 'failed')
const restart = list.filter((a) => statusOf(a) === 'restart_required')
const applied = list.filter((a) => statusOf(a) === 'applied')
if (failed.length) {
error(`${verb},但 ${failed.length}/${list.length} 个服务重载失败(仍在使用旧配置):${failed[0].instance}: ${errOf(failed[0])}`)
} else if (restart.length) {
info(`${verb}。${restart.length}/${list.length} 个服务需重启才生效(含支付等暂不支持热更新的模块)`)
} else if (applied.length) {
success(`${verb},${applied.length}/${list.length} 个服务已实时生效`)
} else {
info(`${verb},收到 ${list.length} 个服务的回执`)
}
}
// resetModule 让业务服务用它自己配置文件(yaml)里的初始值,覆盖库中该模块的配置。
// 数据来源在业务服务那边(明文密钥不出它的容器),console 只负责发起并转达回执。
async function resetModule() {
if (!curDef.value) return
if (!confirm(
`确认把「${curDef.value.name}」还原为业务服务配置文件(yaml)里的初始值?\n\n` +
`库中该模块现有的配置将被全部覆盖,且无法撤销。`,
)) return
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', {
app_id: appId.value, module: curModule.value,
})
reportAcks(d?.acks, curModule.value, '已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '重置失败')
} finally {
saving.value = false
}
}
// resetAll 整库还原为配置文件初始值(module="*")。破坏性大——会覆盖后台手工保存过的所有模块
// (如把邮件打回 yaml 默认发信配置),故要求打字确认。
async function resetAll() {
const depName = dep.name || appName.value || '该部署'
const ans = prompt(
`⚠️ 危险操作:把「${depName}」业务库里【所有】业务模块的配置,整体还原为业务服务配置文件(yaml)的初始值。\n\n` +
`这会覆盖你在后台手工保存过的全部配置(例如邮件的 Resend 设置会被打回 yaml 默认值),且无法撤销。\n\n` +
`确认请输入:RESET`,
)
if (ans !== 'RESET') { if (ans !== null) info('已取消(未输入 RESET)'); return }
saving.value = true
try {
const d = await consoleApi<{ module: string; acks?: ConfigAck[] }>('modulecfg/reset', {
app_id: appId.value, module: '*',
})
reportAcks(d?.acks, undefined, '整库已还原为配置文件初始值')
await loadItems()
} catch (e: any) {
error(e?.message ?? '全部重置失败')
} finally {
saving.value = false
}
}
async function save() {
if (!curDef.value) return
saving.value = true
try {
const items = curDef.value.fields.map((f) => ({ key: f.key, value: values[curModule.value]?.[f.key] ?? '' }))
const d = await consoleApi<{ saved: number; acks?: ConfigAck[] }>('modulecfg/save', { app_id: appId.value, module: curModule.value, items })
reportAcks(d?.acks, curModule.value)
await loadItems() // 重新拉取(密文回脱敏)
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function clearModule() {
if (!curModule.value) return
if (!confirm(`确认清空「${curDef.value?.name}」的全部配置?`)) return
saving.value = true
try {
await consoleApi('modulecfg/del', { app_id: appId.value, module: curModule.value })
success('已清空')
await loadItems()
} catch (e: any) {
error(e?.message ?? '清空失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadBase()
await loadCatalog()
await loadItems()
})
</script>
<style scoped>
.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); }
.lock-tag { font-size: 10px; font-weight: 600; color: #dc2626; background: #fef2f2; padding: 1px 5px; border-radius: 5px; }
</style>

1128
apps/admin/app/pages/apps.vue

File diff suppressed because it is too large

292
apps/admin/app/pages/apps/[name].vue

@ -0,0 +1,292 @@
<template>
<div>
<!-- 面包屑 -->
<div class="flex items-center gap-2 mb-4 text-sm">
<button class="btn btn-ghost btn-xs" @click="router.push('/apps')">&larr; 应用中心</button>
<span class="text-slate-300">/</span>
<span class="font-semibold text-slate-700">{{ appName }}</span>
</div>
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<span class="loading loading-spinner loading-sm"></span>
</div>
<div v-else-if="!product" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<p class="text-slate-500">找不到应用「{{ appName }}」</p>
<button class="btn btn-sm mt-4" @click="router.push('/apps')">返回应用中心</button>
</div>
<template v-else>
<!-- 应用头卡 -->
<div class="hero">
<div class="hero-logo">{{ (product.name || '?').charAt(0).toUpperCase() }}</div>
<div class="flex-1 min-w-[240px]">
<div class="hero-name">
{{ product.name }}
<span class="chip" :class="product.enabled ? 'chip-ok' : ''">{{ product.enabled ? '启用' : '停用' }}</span>
<span class="chip chip-mono">🐳 {{ product.image_name || '未设置镜像' }}</span>
</div>
<div class="text-xs text-slate-500 mt-1.5">{{ product.remark || '—' }}</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm" @click="openAppForm()">编辑应用</button>
<button class="btn btn-primary btn-sm text-white" @click="deployTab?.openNew()">+ 部署新环境</button>
</div>
</div>
<!-- KPI -->
<div class="kpis">
<div class="kpi accent">
<div class="kpi-l">部署</div>
<div class="kpi-v">{{ deployments.length }}<small>个区域</small></div>
</div>
<div class="kpi ok">
<div class="kpi-l">已接入</div>
<div class="kpi-v">{{ okCount }}</div>
</div>
<div class="kpi" :class="pendingCount ? 'warn' : ''">
<div class="kpi-l">待部署</div>
<div class="kpi-v">{{ pendingCount }}</div>
</div>
<div class="kpi">
<div class="kpi-l">版本分布</div>
<div class="kpi-v" style="font-size:14px;padding-top:5px">{{ versionSpread }}</div>
</div>
</div>
<!-- Tabs -->
<div class="tablist" role="tablist">
<button class="tab" role="tab" :aria-selected="tab === 'deps'" @click="tab = 'deps'">
部署环境 <span class="tab-n">{{ deployments.length }}</span>
</button>
<button class="tab" role="tab" :aria-selected="tab === 'modules'" @click="tab = 'modules'">业务功能配置</button>
</div>
<!-- 配置目标切换器:业务功能配置写「某个部署自己的业务库」 -->
<div v-if="tab === 'modules'" class="depbar">
<span class="text-xs text-slate-500 font-semibold shrink-0">配置目标</span>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="d in deployments"
:key="d.id"
class="depchip"
:aria-pressed="curDep === d.id"
:title="d.status === 'pending' ? '待部署:业务库可能还未就绪' : ''"
@click="curDep = d.id"
>
<span class="dot" :class="{ pending: d.status === 'pending' }"></span>
{{ d.region || d.name || '部署#' + d.id }}
<span class="text-[10.5px] text-slate-400 font-mono">#{{ d.id }}</span>
</button>
<span v-if="!deployments.length" class="text-xs text-slate-400 py-1">该应用还没有部署,无处存放配置</span>
</div>
<span class="text-[11px] text-slate-400 ml-auto">配置存在该部署自己的业务库里,各区域互不影响</span>
</div>
<!-- 面板 -->
<AppDeployTab
v-show="tab === 'deps'"
ref="deployTab"
:app-name="appName"
:default-image="product.image_name || ''"
:deployments="deployments"
:envs="envs"
@refresh="loadAll"
@configure="onConfigure"
/>
<AppModuleTab v-if="tab === 'modules' && curDep" :key="'m' + curDep" :app-id="curDep" :dep-name="curDepName" />
<div v-if="tab === 'modules' && !curDep" class="bg-white rounded-xl border border-slate-100 py-16 text-center text-slate-400">
先在「部署环境」里创建一个部署,才能配置。
</div>
</template>
<!-- 编辑应用 -->
<dialog ref="appDlg" class="modal">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-lg">编辑应用</h3>
<p class="text-xs text-slate-400 mt-1">
应用名是关联键:部署、账号作用域、第三方服务配置都按它挂载,改名会断开已有关联。
</p>
<form class="mt-4 space-y-3" @submit.prevent="saveProduct">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">应用名 *</span></label>
<input v-model="pForm.name" type="text" class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">默认镜像名</span></label>
<input v-model="pForm.image_name" type="text" class="input input-bordered input-sm w-full font-mono text-xs" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label>
<input v-model="pForm.remark" type="text" class="input input-bordered input-sm w-full" />
</div>
<label class="flex items-center gap-2 cursor-pointer pt-1">
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">启用</span>
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="appDlg?.close()">取消</button>
<button type="submit" class="btn btn-primary btn-sm text-white">保存</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import type { Deployment, EnvOption, Product } from '~/types/app'
definePageMeta({ title: '应用详情' })
const { consoleApi } = useApi()
const { success, error } = useToast()
const route = useRoute()
const router = useRouter()
// vue-router 已经把 path 段解码过了,这里不能再 decode 一次:
// 应用名里带 % 时 decodeURIComponent 会直接抛 URIError。
const appName = computed(() => String(route.params.name || ''))
const product = ref<Product | null>(null)
const deployments = ref<Deployment[]>([])
const envs = ref<EnvOption[]>([])
const loading = ref(false)
const tab = ref<'deps' | 'modules'>('deps')
const curDep = ref(0)
const deployTab = ref<{ openNew: () => void } | null>(null)
const okCount = computed(() => deployments.value.filter((d) => d.status !== 'pending').length)
const pendingCount = computed(() => deployments.value.filter((d) => d.status === 'pending').length)
const curDepName = computed(() => {
const d = deployments.value.find((x) => x.id === curDep.value)
return d ? (d.name || '部署#' + d.id) : ''
})
const versionSpread = computed(() => {
const tags = [...new Set(deployments.value.filter((d) => d.status !== 'pending').map((d) => d.image_tag || 'latest'))]
return tags.length ? tags.join(' · ') : '—'
})
async function loadAll() {
loading.value = true
try {
const [ps, ds, es] = await Promise.all([
consoleApi<Product[]>('products/list', {}),
consoleApi<Deployment[]>('apps/list', {}),
consoleApi<EnvOption[]>('envs/list', {}),
])
product.value = (ps ?? []).find((p) => p.name === appName.value) ?? null
deployments.value = (ds ?? []).filter((d) => d.app_name === appName.value)
envs.value = es ?? []
// 默认配置目标:优先已接入的部署(待部署的业务库通常还连不上)
if (!deployments.value.some((d) => d.id === curDep.value)) {
const first = deployments.value.find((d) => d.status !== 'pending') ?? deployments.value[0]
curDep.value = first?.id ?? 0
}
} catch (e: any) {
error(e?.message ?? '加载应用详情失败')
} finally {
loading.value = false
}
}
// 从部署列表点「配置」:带着那个部署跳到业务功能配置
function onConfigure(id: number) {
curDep.value = id
tab.value = 'modules'
}
// ---- 编辑应用 ----
const appDlg = ref<HTMLDialogElement | null>(null)
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true })
function openAppForm() {
const p = product.value
if (!p) return
pForm.id = p.id
pForm.name = p.name
pForm.image_name = p.image_name ?? ''
pForm.remark = p.remark ?? ''
pForm.enabled = !!p.enabled
appDlg.value?.showModal()
}
async function saveProduct() {
try {
const renamed = pForm.name !== appName.value
await consoleApi('products/update', {
id: pForm.id, name: pForm.name, image_name: pForm.image_name, remark: pForm.remark, enabled: pForm.enabled,
})
success('保存成功')
appDlg.value?.close()
// 改了应用名,当前 URL 已失效,跳到新名字下
if (renamed) router.replace(`/apps/${encodeURIComponent(pForm.name)}`)
else await loadAll()
} catch (e: any) {
error(e?.message ?? '保存失败')
}
}
watch(appName, loadAll)
onMounted(loadAll)
</script>
<style scoped>
.hero {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 20px 22px; margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 4px 16px rgba(15, 23, 42, .03);
display: flex; gap: 18px; align-items: center; flex-wrap: wrap;
}
.hero-logo {
width: 58px; height: 58px; border-radius: 15px; flex-shrink: 0; color: #fff;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: grid; place-items: center; font-weight: 800; font-size: 24px;
box-shadow: 0 8px 22px rgba(79, 70, 229, .3);
}
.hero-name { font-size: 20px; font-weight: 700; color: #1e293b; letter-spacing: -.015em; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.kpi {
background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 13px 15px;
position: relative; overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #e2e8f0; }
.kpi.ok::before { background: #16a34a; }
.kpi.warn::before { background: #d97706; }
.kpi.accent::before { background: #4f46e5; }
.kpi-l { font-size: 10.5px; color: #94a3b8; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; }
.kpi-v { font-size: 22px; font-weight: 700; color: #1e293b; margin-top: 3px; letter-spacing: -.02em; font-family: 'SF Mono', Menlo, monospace; font-variant-numeric: tabular-nums; }
.kpi-v small { font-size: 12px; color: #94a3b8; font-weight: 500; margin-left: 3px; }
.tablist { display: flex; gap: 2px; border-bottom: 1px solid #e2e8f0; margin-bottom: 18px; overflow-x: auto; }
.tab {
border: 0; background: none; font-size: 13.5px; color: #64748b; padding: 10px 15px; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; display: flex; align-items: center; gap: 6px;
}
.tab:hover { color: #1e293b; }
.tab[aria-selected='true'] { color: #4338ca; border-bottom-color: #4f46e5; font-weight: 600; }
.tab-n { font-size: 10.5px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; border-radius: 20px; padding: 0 6px; font-family: 'SF Mono', Menlo, monospace; }
.tab[aria-selected='true'] .tab-n { background: #eef2ff; color: #4338ca; border-color: transparent; }
.depbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px;
}
.depchip {
border: 1px solid #e2e8f0; background: #fff; border-radius: 20px; padding: 4px 12px; font-size: 12px;
cursor: pointer; color: #334155; display: flex; align-items: center; gap: 6px;
}
.depchip:hover { border-color: #0e7490; }
.depchip[aria-pressed='true'] { background: #ecfeff; border-color: #0e7490; color: #0e7490; font-weight: 600; }
.depchip .dot { width: 6px; height: 6px; border-radius: 50%; background: #16a34a; }
.depchip .dot.pending { background: #d97706; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-ok { background: #dcfce7; color: #15803d; }
.chip-mono { font-family: 'SF Mono', Menlo, monospace; font-weight: 600; }
</style>

309
apps/admin/app/pages/apps/index.vue

@ -0,0 +1,309 @@
<template>
<div>
<div class="flex items-start justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">应用中心</h2>
<p class="text-sm text-slate-400 mt-1 max-w-3xl">
一张卡片 = 一个 <span class="chip chip-app">应用</span>,卡片里是它在各区域的 <span class="chip chip-dep">部署</span> 概况。
点卡片进入应用详情:编辑应用、部署新环境、升级服务、改各项配置。
</p>
</div>
<button class="btn btn-primary btn-sm text-white" @click="openAppForm()">+ 添加应用</button>
</div>
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<span class="loading loading-spinner loading-sm"></span>
</div>
<div v-else-if="!groups" class="bg-white rounded-xl border border-slate-100 py-16 text-center text-slate-400">
暂无应用,先「添加应用」
</div>
<div v-else class="app-grid">
<!-- 应用卡片 -->
<article
v-for="g in productGroups"
:key="g.product.name"
class="app-card"
:class="{ off: !g.product.enabled }"
tabindex="0"
role="button"
@click="enter(g.product.name)"
@keydown.enter.prevent="enter(g.product.name)"
@keydown.space.prevent="enter(g.product.name)"
>
<div class="ac-head">
<div class="ac-logo">{{ initial(g.product.name) }}</div>
<div class="min-w-0 flex-1">
<div class="ac-title">{{ g.product.name }}</div>
<div class="ac-meta">{{ g.product.image_name ? '🐳 ' + g.product.image_name : '未设置默认镜像' }}</div>
</div>
<span v-if="!g.product.enabled" class="chip">停用</span>
</div>
<p class="ac-remark">{{ g.product.remark || '—' }}</p>
<div class="ac-stats">
<div class="ac-stat">
<div class="ac-stat-n" :class="{ zero: !g.deployments.length }">{{ g.deployments.length }}</div>
<div class="ac-stat-l">部署</div>
</div>
<div class="ac-stat">
<div class="ac-stat-n" :class="{ zero: !g.okCount }">{{ g.okCount }}</div>
<div class="ac-stat-l">已接入</div>
</div>
<div class="ac-stat">
<div class="ac-stat-n" :class="g.pendingCount ? 'warn' : 'zero'">{{ g.pendingCount }}</div>
<div class="ac-stat-l">待部署</div>
</div>
</div>
<div class="ac-regions">
<template v-if="g.deployments.length">
<span v-for="d in g.deployments" :key="d.id" class="chip chip-dep">
{{ d.region || '未指定' }}<template v-if="d.status === 'pending'"> · 待部署</template>
</span>
</template>
<span v-else class="text-xs text-slate-300">还没有任何部署</span>
</div>
<div class="ac-foot" @click.stop>
<button class="btn btn-ghost btn-xs" @click="openAppForm(g.product)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(g.product)">删除</button>
<button class="btn btn-primary btn-xs text-white" @click="enter(g.product.name)">进入详情 →</button>
</div>
</article>
<!-- 未分组部署:不是应用,不能进详情,只能逐条归位 -->
<article v-if="orphans.length" class="app-card orphan">
<div class="ac-head">
<div class="ac-logo">?</div>
<div class="min-w-0 flex-1">
<div class="ac-title">未分组部署</div>
<div class="ac-meta">需要归位到某个应用</div>
</div>
<span class="chip chip-warn">待归位</span>
</div>
<p class="ac-remark">未填所属应用名、或应用已被删除的历史部署。选一个应用把它归位,之后就能在该应用详情里管理。</p>
<div class="orphan-list">
<div v-for="d in orphans" :key="d.id" class="orphan-row">
<div class="min-w-0">
<div class="text-sm font-medium text-slate-700 truncate">{{ d.name || '部署#' + d.id }}</div>
<div class="text-[11px] text-slate-400 font-mono">#{{ d.id }} · {{ d.region || '未指定区域' }}</div>
</div>
<select v-model="assignTo[d.id]" class="select select-bordered select-xs w-32">
<option value="">选择应用…</option>
<option v-for="p in products" :key="p.id" :value="p.name">{{ p.name }}</option>
</select>
<button class="btn btn-xs" :disabled="!assignTo[d.id]" @click="assign(d)">归位</button>
</div>
</div>
</article>
</div>
<!-- 应用 新增/编辑 -->
<dialog ref="appDlg" class="modal">
<div class="modal-box max-w-lg">
<h3 class="font-bold text-lg">{{ pEditing ? '编辑应用' : '添加应用' }}</h3>
<p class="text-xs text-slate-400 mt-1">
应用名是关联键:部署、账号作用域、第三方服务配置都按它挂载,改名会断开已有关联。
</p>
<form class="mt-4 space-y-3" @submit.prevent="saveProduct">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">应用名 *</span></label>
<input v-model="pForm.name" type="text" class="input input-bordered input-sm w-full" required placeholder="如 Voitrans" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">默认镜像名</span></label>
<input v-model="pForm.image_name" type="text" class="input input-bordered input-sm w-full font-mono text-xs" placeholder="voitrans-a11(新建部署时预填)" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label>
<input v-model="pForm.remark" type="text" class="input input-bordered input-sm w-full" />
</div>
<label class="flex items-center gap-2 cursor-pointer pt-1">
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="text-sm">启用</span>
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="appDlg?.close()">取消</button>
<button type="submit" class="btn btn-primary btn-sm text-white">保存</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '应用中心' })
interface Product { id: number; name: string; image_name?: string; remark: string; enabled: boolean }
interface Deployment { id: number; app_name: string; status: string; name: string; region: string }
const { consoleApi } = useApi()
const { success, error } = useToast()
const router = useRouter()
const products = ref<Product[]>([])
const deployments = ref<Deployment[]>([])
const loading = ref(false)
const assignTo = reactive<Record<number, string>>({})
const byApp = computed(() => {
const m = new Map<string, Deployment[]>()
for (const d of deployments.value) {
const k = d.app_name || ''
if (!m.has(k)) m.set(k, [])
m.get(k)!.push(d)
}
return m
})
const productGroups = computed(() =>
products.value.map((p) => {
const deps = byApp.value.get(p.name) || []
return {
product: p,
deployments: deps,
okCount: deps.filter((d) => d.status !== 'pending').length,
pendingCount: deps.filter((d) => d.status === 'pending').length,
}
}),
)
// 未分组:app_name 为空、或指向一个已不存在的应用
const orphans = computed(() => {
const known = new Set(products.value.map((p) => p.name))
return deployments.value.filter((d) => !d.app_name || !known.has(d.app_name))
})
const groups = computed(() => productGroups.value.length + (orphans.value.length ? 1 : 0))
function initial(name: string) { return (name || '?').charAt(0).toUpperCase() }
function enter(name: string) { router.push(`/apps/${encodeURIComponent(name)}`) }
async function loadAll() {
loading.value = true
try {
const [ps, ds] = await Promise.all([
consoleApi<Product[]>('products/list', {}),
consoleApi<Deployment[]>('apps/list', {}),
])
products.value = ps ?? []
deployments.value = ds ?? []
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
} finally {
loading.value = false
}
}
// ---- 应用表单 ----
const appDlg = ref<HTMLDialogElement | null>(null)
const pEditing = ref(false)
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true })
function openAppForm(p?: Product) {
pEditing.value = !!p
pForm.id = p?.id ?? 0
pForm.name = p?.name ?? ''
pForm.image_name = p?.image_name ?? ''
pForm.remark = p?.remark ?? ''
pForm.enabled = p ? !!p.enabled : true
appDlg.value?.showModal()
}
async function saveProduct() {
const data: Record<string, unknown> = {
name: pForm.name, image_name: pForm.image_name, remark: pForm.remark, enabled: pForm.enabled,
}
let path = 'products/add'
if (pEditing.value && pForm.id) { data.id = Number(pForm.id); path = 'products/update' }
try {
await consoleApi(path, data)
success('保存成功')
appDlg.value?.close()
await loadAll()
} catch (e: any) {
error(e?.message ?? '保存失败')
}
}
function delProduct(p: Product) {
if (!window.confirm(`删除应用「${p.name}」?(其下若有区域部署将被拒绝)`)) return
consoleApi('products/del', { id: p.id })
.then(() => { success('已删除'); loadAll() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
async function assign(d: Deployment) {
const target = assignTo[d.id]
if (!target) return
try {
await consoleApi('apps/assign', { id: d.id, app_name: target })
success(`已归位到「${target}」`)
delete assignTo[d.id]
await loadAll()
} catch (e: any) {
error(e?.message ?? '归位失败')
}
}
onMounted(loadAll)
</script>
<style scoped>
.app-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; }
.app-card {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 4px 16px rgba(15, 23, 42, .03);
cursor: pointer; position: relative; display: flex; flex-direction: column;
transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
}
.app-card:hover { transform: translateY(-2px); box-shadow: 0 10px 40px rgba(15, 23, 42, .12); border-color: #e2e8f0; }
.app-card:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }
.app-card::before { content: ''; position: absolute; inset: 0 0 auto; height: 3px; background: linear-gradient(90deg, #4f46e5, #7c3aed); }
.app-card.off::before { background: #94a3b8; }
.app-card.orphan { cursor: default; border-color: #fde68a; }
.app-card.orphan::before { background: #f59e0b; }
.app-card.orphan:hover { transform: none; }
.ac-head { padding: 18px 18px 12px; display: flex; gap: 13px; align-items: flex-start; }
.ac-logo {
width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0; color: #fff;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: grid; place-items: center; font-weight: 800; font-size: 18px;
box-shadow: 0 5px 14px rgba(79, 70, 229, .28);
}
.app-card.off .ac-logo { background: #94a3b8; box-shadow: none; }
.app-card.orphan .ac-logo { background: #f59e0b; box-shadow: none; }
.ac-title { font-size: 15.5px; font-weight: 700; color: #1e293b; letter-spacing: -.01em; }
.ac-meta { font-size: 11.5px; color: #94a3b8; margin-top: 3px; }
.ac-remark { font-size: 12px; color: #64748b; padding: 0 18px 14px; margin: 0; min-height: 2.4em; }
.ac-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid #eef2f7; background: #fcfcfd; }
.ac-stat { padding: 11px 8px; text-align: center; border-right: 1px solid #eef2f7; }
.ac-stat:last-child { border-right: 0; }
.ac-stat-n { font-size: 17px; font-weight: 700; line-height: 1.2; color: #1e293b; font-family: 'SF Mono', Menlo, monospace; font-variant-numeric: tabular-nums; }
.ac-stat-n.warn { color: #b45309; }
.ac-stat-n.zero { color: #cbd5e1; }
.ac-stat-l { font-size: 10px; color: #94a3b8; margin-top: 2px; letter-spacing: .03em; }
.ac-regions { padding: 11px 18px; display: flex; gap: 5px; flex-wrap: wrap; align-items: center; border-top: 1px solid #eef2f7; min-height: 42px; }
.ac-foot { padding: 10px 14px; border-top: 1px solid #eef2f7; display: flex; gap: 6px; justify-content: flex-end; margin-top: auto; }
.orphan-list { border-top: 1px solid #eef2f7; }
.orphan-row { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-bottom: 1px solid #f8fafc; }
.orphan-row:last-child { border-bottom: 0; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-app { background: #eef2ff; color: #4338ca; }
.chip-dep { background: #ecfeff; color: #0e7490; }
.chip-warn { background: #fef3c7; color: #b45309; }
</style>

733
apps/admin/app/pages/meetingsvc.vue

@ -1,161 +1,228 @@
<template>
<div class="p-4 space-y-4">
<!-- 顶部:标题 + 应用选择 -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<!-- 顶部:标题 + 作用域 -->
<div class="flex items-start justify-between mb-4 flex-wrap gap-3">
<div>
<h1 class="text-lg font-semibold">会议记录服务配置</h1>
<p class="text-xs text-base-content/60 mt-1">
识别 / 翻译 / 总结 三类服务统一在此管理:按优先级自动选择,识别叠加「支持语言」过滤,总结为客户端可选的具体模型条目。改配置后 home 需重启生效。
<h2 class="text-lg font-semibold text-slate-800">会议记录服务</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
识别 / 翻译 / 总结三段,从<b>第三方服务配置</b>里选已配好的服务来编排(不再单独填凭据)。
<span class="text-slate-500">先配「全局默认」作兜底,再按应用 / 区域覆盖</span>——没配覆盖的应用自动继承全局默认。
</p>
</div>
<label class="flex items-center gap-2 text-sm">
<span class="text-base-content/60">部署应用</span>
<select v-model.number="appId" class="select select-bordered select-sm" @change="loadList">
<option v-for="a in apps" :key="a.id" :value="a.id">{{ a.app_name || a.name }}({{ a.region }})</option>
</select>
</label>
</div>
<!-- 类别 tab -->
<div class="tabs tabs-boxed w-fit">
<a
v-for="c in categories"
:key="c.value"
class="tab"
:class="{ 'tab-active': curCat === c.value }"
@click="curCat = c.value"
>{{ c.label }}<span class="ml-1 opacity-60">{{ (grouped[c.value] || []).length }}</span></a>
<!-- 作用域选择器 -->
<div class="scopebar">
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 font-semibold">作用域</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadOrch">
<option value="">全局默认(兜底)</option>
<option v-for="a in apps" :key="a" :value="a">应用:{{ a }}</option>
</select>
<select v-model.number="scopeRegion" class="select select-bordered select-sm" @change="loadOrch">
<option :value="0">全区域</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<span class="text-[11px] text-slate-400">
{{ scopeApp ? `正在编辑「${scopeApp}」` : '正在编辑全局默认' }}{{ scopeRegion ? ` · ${regionLabel(scopeRegion)}` : ' · 全区域' }}
<template v-if="isOverride">(覆盖全局默认)</template>
</span>
</div>
<div v-if="dbError" class="alert alert-error text-sm">{{ dbError }}</div>
<!-- 列表 -->
<div class="bg-base-100 rounded-lg border border-base-200 overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th class="w-16">优先级</th>
<th>名称 / 条目id</th>
<th>服务商</th>
<th v-if="curCatHasLangs">支持语言</th>
<th class="w-20">启用</th>
<th class="w-28">凭据</th>
<th class="w-40 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(it, idx) in curList" :key="it.id">
<td>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-xs" :disabled="idx === 0" @click="move(idx, -1)">↑</button>
<button class="btn btn-ghost btn-xs" :disabled="idx === curList.length - 1" @click="move(idx, 1)">↓</button>
</div>
</td>
<td>
<div class="font-medium">{{ it.name || it.id }}</div>
<div class="text-xs text-base-content/50 font-mono">{{ it.id }}</div>
</td>
<td><span class="badge badge-ghost badge-sm">{{ it.provider }}</span></td>
<td v-if="curCatHasLangs">
<div v-if="it.support_langs" class="flex flex-wrap gap-1">
<span v-for="l in it.support_langs.split(',').slice(0, 6)" :key="l" class="badge badge-outline badge-xs font-mono">{{ l }}</span>
<span v-if="it.support_langs.split(',').length > 6" class="text-xs opacity-50">…</span>
</div>
<span v-else class="text-xs opacity-40">全部(默认)</span>
</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="it.enabled" @change="toggleEnabled(it)" />
</td>
<td>
<span v-if="secretCount(it) > 0" class="text-xs">🔒 {{ secretCount(it) }}</span>
<span v-else class="text-xs opacity-40">—</span>
</td>
<td class="text-right">
<button class="btn btn-ghost btn-xs" @click="openEdit(it)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" @click="del(it)">删除</button>
</td>
</tr>
<tr v-if="!curList.length">
<td :colspan="curCatHasLangs ? 7 : 6" class="text-center text-base-content/40 py-6">暂无服务,点击下方「添加」</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
<span class="loading loading-spinner loading-sm"></span>
</div>
<button class="btn btn-primary btn-sm" @click="openAdd">+ 添加{{ curCatLabel }}服务</button>
<!-- 编辑/新增 弹窗 -->
<dialog ref="dlg" class="modal">
<div class="modal-box max-w-xl">
<h3 class="font-semibold mb-3">{{ editing.__isNew ? '添加' : '编辑' }}{{ curCatLabel }}服务</h3>
<div class="space-y-3">
<!-- 服务商(新增时可选,编辑时锁定) -->
<label class="form-control">
<span class="label-text text-xs">服务商</span>
<select
v-model="editing.provider"
class="select select-bordered select-sm"
:disabled="!editing.__isNew"
@change="onProviderChange"
>
<option v-for="p in providerOptions" :key="p.provider" :value="p.provider">{{ p.name }}({{ p.provider }})</option>
</select>
</label>
<template v-else>
<!-- 无可用服务提示 -->
<div v-if="!available.length" class="alert alert-warning py-2 text-sm mb-4">
<span>
该作用域没有可选服务。先去<b>第三方服务配置</b>添加「录音文件识别 / 机器翻译 / 文本或多媒体大模型」类的服务
(全局默认或该应用作用域),这里才能选。
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="form-control">
<span class="label-text text-xs">条目 id(唯一)</span>
<input v-model="editing.id" class="input input-bordered input-sm font-mono" :disabled="!editing.__isNew" />
</label>
<label class="form-control">
<span class="label-text text-xs">展示名{{ curCat === 3 ? '(客户端可见)' : '' }}</span>
<input v-model="editing.name" class="input input-bordered input-sm" />
</label>
<!-- ============ 识别 ============ -->
<div class="card">
<div class="card-head">
<div>
<div class="card-title">识别(录音文件转写)</div>
<div class="card-desc">按顺序找<b>第一个「已启用 + 支持源语言」</b>的服务,不支持则跳到下一个。把便宜的排前面即"优先用性价比高的"。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(1).length < 2 || busy" @click="sortByCost(1)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(1).length" @click="openAdd(1)">+ 添加识别服务</button>
</div>
</div>
<!-- 目录字段 -->
<div v-for="f in curFields" :key="f.key" class="form-control">
<span class="label-text text-xs">{{ f.label }}<span v-if="f.required" class="text-error">*</span></span>
<textarea
v-if="f.type === 'textarea'"
v-model="editing.values[f.key]"
class="textarea textarea-bordered textarea-sm font-mono"
rows="3"
:placeholder="f.placeholder"
></textarea>
<input
v-else
v-model="editing.values[f.key]"
:type="f.secret ? 'password' : 'text'"
class="input input-bordered input-sm"
:placeholder="f.placeholder"
autocomplete="off"
/>
<span v-if="f.desc" class="text-xs text-base-content/50 mt-0.5">{{ f.desc }}</span>
<div class="probe-bar">
<span class="text-xs text-slate-500 font-semibold">选路预览</span>
<select v-model="probeLang" class="select select-bordered select-xs w-44 text-xs">
<option v-for="o in LANG_OPTIONS" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
</select>
<span v-if="asrHit" class="probe-out">命中 {{ svcName(asrHit.svc_id) }}<template v-if="asrHit.cost_hint"> · ¥{{ asrHit.cost_hint }}/时</template></span>
<span v-else class="probe-out bad">无可用服务 —— {{ langName(probeLang) }} 会转写失败</span>
</div>
<div v-if="!ofKind(1).length" class="py-10 text-center text-slate-400 text-sm">暂无识别服务,点右上「+ 添加识别服务」</div>
<div v-for="(o, i) in ofKind(1)" :key="o.row_id" class="svc asr" :class="{ hit: asrHit && asrHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: asrHit && asrHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name" :class="{ skip: asrSkipped(o) }">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="flex flex-wrap gap-1">
<template v-if="svcLangs(o.svc_id)">
<span v-for="l in svcLangs(o.svc_id).split(',').slice(0, 5)" :key="l" class="lang" :class="{ on: l === probeLang }">{{ l }}</span>
<span v-if="svcLangs(o.svc_id).split(',').length > 5" class="lang">+{{ svcLangs(o.svc_id).split(',').length - 5 }}</span>
</template>
<span v-else class="text-[11px] text-slate-400">未限定</span>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /时</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(1, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(1).length - 1 || busy" aria-label="下移" @click="move(1, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="asrWhyClass(o)">{{ asrWhy(o) }}</div>
</div>
</div>
<!-- ============ 翻译 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">翻译</div>
<div class="card-desc">按顺序取<b>第一个已启用</b>的服务;调用失败自动回退下一个。</div>
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs" :disabled="ofKind(2).length < 2 || busy" @click="sortByCost(2)">⇅ 按参考单价重排</button>
<button class="btn btn-primary btn-xs text-white" :disabled="!addable(2).length" @click="openAdd(2)">+ 添加翻译服务</button>
</div>
</div>
<div v-if="!ofKind(2).length" class="py-10 text-center text-slate-400 text-sm">暂无翻译服务</div>
<div v-for="(o, i) in ofKind(2)" :key="o.row_id" class="svc mt" :class="{ hit: mtHit && mtHit.row_id === o.row_id, off: !o.enabled }">
<div class="svc-rank" :class="{ hit: mtHit && mtHit.row_id === o.row_id }">{{ i + 1 }}</div>
<div class="min-w-0">
<div class="svc-name">{{ svcName(o.svc_id) }}</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万字符</small></template><span v-else class="text-slate-300">—</span></div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="arrow" :disabled="i === 0 || busy" aria-label="上移" @click="move(2, i, -1)">↑</button>
<button class="arrow" :disabled="i === ofKind(2).length - 1 || busy" aria-label="下移" @click="move(2, i, 1)">↓</button>
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
<div class="svc-why" :class="!o.enabled ? 'no' : (mtHit && mtHit.row_id === o.row_id ? 'ok' : '')">
<template v-if="!o.enabled">已停用,跳过</template>
<template v-else-if="mtHit && mtHit.row_id === o.row_id">✓ 默认由它承接,失败顺延</template>
<template v-else>备用:前面失败时接手</template>
</div>
</div>
</div>
<!-- 识别:支持语言 -->
<div v-if="curCatHasLangs" class="form-control">
<span class="label-text text-xs">支持语言(留空=不限,按引擎默认)</span>
<LangSelect v-model="editing.support_langs" :multiple="true" />
<!-- ============ 总结 ============ -->
<div class="card mt-4">
<div class="card-head">
<div>
<div class="card-title">总结(大模型)</div>
<div class="card-desc">后台指定默认模型,客户端可在允许范围内自选。带 🖼 的是<b>多媒体大模型</b>,能识别会议里的图片。</div>
</div>
<button class="btn btn-primary btn-xs text-white shrink-0" :disabled="!addable(3).length" @click="openAdd(3)">+ 添加模型</button>
</div>
<div class="mode-bar">
<label class="flex items-center gap-2 cursor-pointer shrink-0">
<input type="checkbox" class="toggle toggle-primary toggle-sm" :checked="llmClientChoice" :disabled="busy" @change="toggleClientChoice" />
<span class="text-sm font-semibold text-slate-700">允许客户端指定模型</span>
</label>
<p class="mode-txt">
<template v-if="llmClientChoice">
前端能选 <b>{{ pickable.length }}</b> 个模型<template v-if="pickable.length">:{{ pickable.map((x) => svcName(x.svc_id)).join('、') }}</template>
<template v-else>(一个都没勾「前端可见」,等同强制默认)</template>。不传则用默认 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>。
</template>
<template v-else>
前端不下发模型列表,一律走默认模型 <b>{{ llmDefault ? svcName(llmDefault.svc_id) : '—' }}</b>;客户端传了也忽略。
</template>
</p>
</div>
<div class="grid grid-cols-2 gap-3 items-end">
<label class="form-control">
<span class="label-text text-xs">优先级(小=优先)</span>
<input v-model.number="editing.priority" type="number" class="input input-bordered input-sm" />
</label>
<label class="flex items-center gap-2 pb-2">
<input v-model="editing.enabled" type="checkbox" class="toggle toggle-success toggle-sm" />
<span class="text-sm">启用</span>
</label>
<div v-if="!ofKind(3).length" class="py-10 text-center text-slate-400 text-sm">暂无模型</div>
<div v-for="o in ofKind(3)" :key="o.row_id" class="svc llm" :class="{ isdefault: o.is_default, off: !o.enabled }">
<div class="grid place-items-center">
<input type="radio" name="llmdef" class="radio radio-primary radio-xs" :checked="o.is_default" :disabled="!o.enabled || busy" @change="patch(o, { is_default: true })" />
</div>
<div class="min-w-0">
<div class="svc-name">
{{ svcName(o.svc_id) }}
<span v-if="isVision(o.svc_id)" title="多媒体大模型,能识图">🖼</span>
<span v-if="o.is_default" class="chip chip-app ml-1">默认</span>
</div>
<div class="svc-prov">{{ svcProvider(o.svc_id) }} · {{ o.svc_id }}</div>
</div>
<div class="svc-cost"><template v-if="o.cost_hint">¥{{ o.cost_hint }}<small> /百万token</small></template><span v-else class="text-slate-300">—</span></div>
<div>
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.client_visible" :disabled="!llmClientChoice || !o.enabled || busy"
:title="!llmClientChoice ? '先打开「允许客户端指定模型」' : ''" @change="patch(o, { client_visible: !o.client_visible })" />
</div>
<div><input type="checkbox" class="toggle toggle-success toggle-sm" :checked="o.enabled" :disabled="busy" @change="patch(o, { enabled: !o.enabled })" /></div>
<div class="svc-acts">
<button class="btn btn-ghost btn-xs" @click="openEditCost(o)">单价</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="del(o)">移除</button>
</div>
</div>
<div class="col-h llm-h"><div></div><div></div><div>参考单价</div><div>前端可见</div><div>启用</div><div></div></div>
</div>
</template>
<!-- 添加服务弹窗 -->
<dialog ref="addDlg" class="modal">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg">添加{{ kindLabel(addKind) }}服务</h3>
<p class="text-xs text-slate-400 mt-1">从第三方服务配置里选一个已配好的服务加入本段编排。</p>
<div class="mt-4 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">服务</span></label>
<select v-model="addForm.svc_id" class="select select-bordered select-sm">
<option value="">请选择服务…</option>
<option v-for="s in addable(addKind)" :key="s.id" :value="s.id">
{{ s.name }}{{ addKind === 3 && catsHas(s.categories, 9) ? ' 🖼' : '' }}({{ s.provider || s.id }})
</option>
</select>
<span v-if="!addable(addKind).length" class="text-[11px] text-amber-600 mt-1">没有可添加的服务了(都加过了,或服务池里没这类服务)。</span>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm font-medium">参考单价({{ costUnit(addKind) }},可空)</span></label>
<input v-model.number="addForm.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm font-mono text-xs" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">仅用于排序/展示,填你的实付价。</span>
</div>
<div v-if="addKind === 3" class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.is_default" type="checkbox" class="toggle toggle-primary toggle-sm" /><span class="text-sm">设为默认</span></label>
<label class="flex items-center gap-2 cursor-pointer"><input v-model="addForm.client_visible" type="checkbox" class="toggle toggle-success toggle-sm" /><span class="text-sm">前端可见</span></label>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="addDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="!addForm.svc_id || saving" @click="doAdd">{{ saving ? '添加中…' : '添加' }}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- 改单价弹窗 -->
<dialog ref="costDlg" class="modal">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg">参考单价</h3>
<p class="text-xs text-slate-400 mt-1">{{ costEdit.name }}({{ costUnit(costEdit.kind) }})</p>
<input v-model.number="costEdit.cost_hint" type="number" step="0.01" min="0" class="input input-bordered input-sm w-full font-mono text-xs mt-3" placeholder="0" />
<div class="modal-action">
<button class="btn btn-ghost btn-sm" @click="closeEdit">取消</button>
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存' }}</button>
<button class="btn btn-ghost btn-sm" @click="costDlg?.close()">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="saving" @click="doSaveCost">保存</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
@ -167,188 +234,314 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions'
definePageMeta({ title: '会议记录服务配置' })
definePageMeta({ title: '会议记录服务' })
const { consoleApi } = useApi()
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const EncMask = '[encrypted]'
interface Field { key: string; label: string; type: string; secret: boolean; required: boolean; placeholder?: string; desc?: string }
interface CatalogItem { category: number; provider: string; name: string; has_langs: boolean; multi: boolean; fields: Field[] }
interface Category { value: number; label: string }
interface SvcConfig {
id: string; category: number; provider: string; name: string
priority: number; enabled: boolean; support_langs: string
values: Record<string, string>
__isNew?: boolean
}
interface AppItem { id: number; app_name: string; name: string; region: string }
const apps = ref<AppItem[]>([])
const appId = ref(0)
const catalog = ref<CatalogItem[]>([])
const categories = ref<Category[]>([])
const curCat = ref(1)
const items = ref<SvcConfig[]>([])
const dbError = ref('')
const saving = ref(false)
const dlg = ref<HTMLDialogElement | null>(null)
const editing = reactive<SvcConfig>(blank(1, ''))
function blank(cat: number, provider: string): SvcConfig {
return { id: '', category: cat, provider, name: '', priority: 100, enabled: true, support_langs: '', values: {}, __isNew: true }
const REGIONS = [
{ value: 1, label: '中国' }, { value: 2, label: '美国' }, { value: 3, label: '东亚' },
{ value: 4, label: '东南亚' }, { value: 5, label: '南亚' }, { value: 6, label: '中东' },
{ value: 7, label: '欧洲' }, { value: 8, label: '俄罗斯' }, { value: 9, label: '非洲' },
{ value: 10, label: '南美' }, { value: 11, label: '北美' }, { value: 12, label: '大洋洲' },
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
function regionLabel(v: number) { return REGIONS.find((r) => r.value === v)?.label ?? '区域' + v }
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string }
interface Orch {
row_id: number; app_name: string; region: number; kind: number; svc_id: string
priority: number; is_default: boolean; client_visible: boolean; cost_hint: number; enabled: boolean
}
// 当前类别的目录(服务商列表)
const providerOptions = computed(() => catalog.value.filter((c) => c.category === curCat.value))
const curCatLabel = computed(() => categories.value.find((c) => c.value === curCat.value)?.label || '')
const curCatHasLangs = computed(() => providerOptions.value.some((c) => c.has_langs))
const curDef = computed(() => catalog.value.find((c) => c.category === editing.category && c.provider === editing.provider) || null)
const curFields = computed(() => curDef.value?.fields || [])
// 按类别归集 + 优先级排序
const grouped = computed<Record<number, SvcConfig[]>>(() => {
const m: Record<number, SvcConfig[]> = {}
for (const it of items.value) (m[it.category] ||= []).push(it)
for (const k in m) m[k].sort((a, b) => a.priority - b.priority)
const apps = ref<string[]>([])
const scopeApp = ref('')
const scopeRegion = ref(0)
const items = ref<Orch[]>([])
const available = ref<SvcView[]>([])
const kindCats = ref<Record<string, number[]>>({ '1': [8], '2': [3], '3': [5, 9] })
const llmClientChoice = ref(false)
const isOverride = ref(false)
const loading = ref(false)
const saving = ref(false)
const busy = ref(false)
const probeLang = ref('zh-CN')
const addDlg = ref<HTMLDialogElement | null>(null)
const costDlg = ref<HTMLDialogElement | null>(null)
const addKind = ref(1)
const addForm = reactive({ svc_id: '', cost_hint: 0, is_default: false, client_visible: false })
const costEdit = reactive({ row_id: 0, name: '', kind: 1, cost_hint: 0 })
function kindLabel(k: number) { return k === 1 ? '识别' : k === 2 ? '翻译' : '总结' }
function costUnit(k: number) { return k === 1 ? '元/小时' : k === 2 ? '元/百万字符' : '元/百万token' }
function langName(code: string) { return LANG_OPTIONS.find((o) => o.code === code)?.name ?? code }
function catsHas(cats: string, v: number) { return (cats || '').split(',').map((x) => Number(x.trim())).includes(v) }
const svcMap = computed(() => {
const m = new Map<string, SvcView>()
for (const s of available.value) m.set(s.id, s)
return m
})
const curList = computed(() => grouped.value[curCat.value] || [])
function secretCount(it: SvcConfig): number {
const def = catalog.value.find((c) => c.category === it.category && c.provider === it.provider)
if (!def) return 0
return def.fields.filter((f) => f.secret && (it.values?.[f.key] ?? '') !== '').length
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id }
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '?' }
function svcLangs(id: string) { return svcMap.value.get(id)?.languages ?? '' }
function isVision(id: string) { return catsHas(svcMap.value.get(id)?.categories ?? '', 9) }
// 某段的编排(按优先级)
function ofKind(k: number) {
return items.value.filter((o) => o.kind === k).sort((a, b) => a.priority - b.priority)
}
// 某段可添加的服务 = 可选服务里 categories 命中该段类别、且还没加进本段编排
function addable(k: number): SvcView[] {
const cats = kindCats.value[String(k)] ?? []
const used = new Set(items.value.filter((o) => o.kind === k).map((o) => o.svc_id))
return available.value.filter((s) => !used.has(s.id) && s.categories.split(',').some((c) => cats.includes(Number(c.trim()))))
}
async function loadApps() {
try {
const list = await consoleApi<AppItem[]>('apps/list', {})
apps.value = list ?? []
if (apps.value.length && !appId.value) appId.value = apps.value[0].id
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
// 识别选路预览
function svcSupportsLang(id: string, lang: string) {
const ls = svcLangs(id)
if (!ls) return true // 未限定 = 交给引擎判断,先当支持
return ls.split(',').map((x) => x.trim()).includes(lang)
}
const asrHit = computed(() => ofKind(1).find((o) => o.enabled && svcSupportsLang(o.svc_id, probeLang.value)) ?? null)
const mtHit = computed(() => ofKind(2).find((o) => o.enabled) ?? null)
function asrSkipped(o: Orch) { return asrHit.value?.row_id !== o.row_id && (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) }
function asrWhyClass(o: Orch) {
if (asrHit.value?.row_id === o.row_id) return 'ok'
if (!o.enabled || !svcSupportsLang(o.svc_id, probeLang.value)) return 'no'
return ''
}
function asrWhy(o: Orch) {
if (asrHit.value?.row_id === o.row_id) {
return svcLangs(o.svc_id) ? `✓ 当前源语言(${langName(probeLang.value)})由它承接` : `✓ 未限定语言,先落到它(实际支持由引擎判断)`
}
if (!o.enabled) return '已停用,跳过'
if (!svcSupportsLang(o.svc_id, probeLang.value)) return `不支持 ${langName(probeLang.value)}(${probeLang.value}),跳过 → 顺延下一个`
return '备用:前面都跳过时接手'
}
async function loadCatalog() {
// 总结
const llmDefault = computed(() => ofKind(3).find((o) => o.is_default) ?? null)
const pickable = computed(() => ofKind(3).filter((o) => o.enabled && o.client_visible))
// ---- 加载 ----
async function loadApps() {
try {
const d = await consoleApi<{ catalog: CatalogItem[]; categories: Category[] }>('echomeetsvc/catalog', {})
catalog.value = d?.catalog ?? []
categories.value = d?.categories ?? []
if (categories.value.length) curCat.value = categories.value[0].value
} catch (e: any) {
error(e?.message ?? '获取配置目录失败')
}
const list = await consoleApi<{ name: string }[]>('products/list', {})
apps.value = (list ?? []).map((p) => p.name).filter(Boolean)
} catch { apps.value = [] }
}
async function loadList() {
if (!appId.value) return
dbError.value = ''
async function loadOrch() {
loading.value = true
try {
const d = await consoleApi<{ items: SvcConfig[] }>('echomeetsvc/list', { app_id: appId.value })
items.value = (d?.items ?? []).map((r) => ({ ...r, values: r.values || {} }))
const d = await webApi<{ items: Orch[]; setting: { llm_client_choice: boolean }; available: SvcView[]; kind_cats: Record<string, number[]> }>(
'getechoorch', { app_name: scopeApp.value, region: scopeRegion.value },
)
items.value = d?.items ?? []
available.value = d?.available ?? []
kindCats.value = d?.kind_cats ?? kindCats.value
llmClientChoice.value = !!d?.setting?.llm_client_choice
// 是否为覆盖:非全局作用域且本作用域有编排/开关
isOverride.value = (!!scopeApp.value || scopeRegion.value !== 0) && (items.value.length > 0 || llmClientChoice.value)
} catch (e: any) {
dbError.value = e?.message ?? '获取配置失败'
error(e?.message ?? '加载失败')
} finally {
loading.value = false
}
}
function openAdd() {
const first = providerOptions.value[0]
Object.assign(editing, blank(curCat.value, first?.provider || ''))
onProviderChange()
dlg.value?.showModal()
}
function openEdit(it: SvcConfig) {
Object.assign(editing, JSON.parse(JSON.stringify(it)), { __isNew: false })
if (!editing.values) editing.values = {}
dlg.value?.showModal()
// ---- 编排增改删 ----
async function saveOrch(orch: Partial<Orch>) {
await webApi('saveechoorch', { app_name: scopeApp.value, region: scopeRegion.value, orch })
}
function closeEdit() { dlg.value?.close() }
// 新增时切换服务商:重置字段容器 + 生成默认 id/名称
function onProviderChange() {
const def = curDef.value
editing.values = {}
if (def) {
for (const f of def.fields) editing.values[f.key] = ''
const prefix = curCat.value === 1 ? 'asr' : curCat.value === 2 ? 'mt' : 'llm'
let base = `${prefix}_${editing.provider}`
// 总结允许一商多模型,冲突则追加序号
if (items.value.some((x) => x.id === base)) {
let n = 2
while (items.value.some((x) => x.id === `${base}_${n}`)) n++
base = `${base}_${n}`
}
editing.id = base
if (!editing.name) editing.name = def.name
if (curCat.value === 1) editing.support_langs = ''
}
function openAdd(k: number) {
addKind.value = k
addForm.svc_id = ''
addForm.cost_hint = 0
addForm.is_default = false
addForm.client_visible = false
addDlg.value?.showModal()
}
async function save() {
if (!editing.id.trim()) { error('条目 id 必填'); return }
if (editing.__isNew && items.value.some((x) => x.id === editing.id)) { error('条目 id 已存在'); return }
async function doAdd() {
if (!addForm.svc_id) return
saving.value = true
try {
const { __isNew, ...cfg } = editing
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: cfg })
success('已保存')
closeEdit()
await loadList()
const list = ofKind(addKind.value)
const priority = list.length ? (list[list.length - 1]!.priority + 10) : 10
await saveOrch({
kind: addKind.value, svc_id: addForm.svc_id, priority,
cost_hint: Number(addForm.cost_hint) || 0,
is_default: addKind.value === 3 ? addForm.is_default : false,
client_visible: addKind.value === 3 ? addForm.client_visible : false,
enabled: true,
})
success('已添加')
addDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
error(e?.message ?? '添加失败')
} finally {
saving.value = false
}
}
async function del(it: SvcConfig) {
if (!confirm(`确定删除「${it.name || it.id}」?`)) return
async function patch(o: Orch, changes: Partial<Orch>) {
busy.value = true
try {
await consoleApi('echomeetsvc/del', { app_id: appId.value, id: it.id })
success('已删除')
await loadList()
await saveOrch({ ...o, ...changes })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '删除失败')
error(e?.message ?? '操作失败')
await loadOrch()
} finally {
busy.value = false
}
}
async function toggleEnabled(it: SvcConfig) {
async function del(o: Orch) {
if (!confirm(`从本段移除「${svcName(o.svc_id)}」?(不影响第三方服务配置里的服务本身)`)) return
try {
// 保存时密钥字段是脱敏值 [encrypted],后端会保留原密文,安全。
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: { ...it, enabled: !it.enabled } })
await loadList()
await webApi('delechoorch', { row_id: o.row_id })
success('已移除')
await loadOrch()
} catch (e: any) {
error(e?.message ?? '操作失败')
await loadList()
error(e?.message ?? '移除失败')
}
}
// 同类内上下移:与相邻项交换优先级并提交
async function move(idx: number, dir: number) {
const list = curList.value
const a = list[idx]
const b = list[idx + dir]
if (!a || !b) return
const pa = a.priority, pb = b.priority
// 排序
async function persistOrder(ordered: Orch[]) {
busy.value = true
try {
await consoleApi('echomeetsvc/reorder', {
app_id: appId.value,
items: [{ id: a.id, priority: pb }, { id: b.id, priority: pa }],
})
await loadList()
await webApi('reorderechoorch', { items: ordered.map((o, i) => ({ row_id: o.row_id, priority: (i + 1) * 10 })) })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '排序失败')
await loadOrch()
} finally {
busy.value = false
}
}
function move(k: number, idx: number, dir: number) {
const list = [...ofKind(k)]
const j = idx + dir
if (j < 0 || j >= list.length) return
const tmp = list[idx]!
list[idx] = list[j]!
list[j] = tmp
persistOrder(list)
}
function sortByCost(k: number) {
const list = [...ofKind(k)].sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
return (a.cost_hint || Number.MAX_VALUE) - (b.cost_hint || Number.MAX_VALUE)
})
persistOrder(list)
}
// 改单价
function openEditCost(o: Orch) {
costEdit.row_id = o.row_id
costEdit.name = svcName(o.svc_id)
costEdit.kind = o.kind
costEdit.cost_hint = o.cost_hint
costDlg.value?.showModal()
}
async function doSaveCost() {
const o = items.value.find((x) => x.row_id === costEdit.row_id)
if (!o) return
saving.value = true
try {
await saveOrch({ ...o, cost_hint: Number(costEdit.cost_hint) || 0 })
success('已保存')
costDlg.value?.close()
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
saving.value = false
}
}
async function toggleClientChoice() {
busy.value = true
try {
await webApi('saveechoorchsetting', { app_name: scopeApp.value, region: scopeRegion.value, llm_client_choice: !llmClientChoice.value })
await loadOrch()
} catch (e: any) {
error(e?.message ?? '保存失败')
await loadOrch()
} finally {
busy.value = false
}
}
onMounted(async () => {
await Promise.all([loadApps(), loadCatalog()])
await loadList()
await loadApps()
await loadOrch()
})
</script>
<style scoped>
.scopebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px; }
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; box-shadow: 0 1px 2px rgba(15, 23, 42, .04); overflow: hidden; }
.card-head { padding: 15px 18px; border-bottom: 1px solid #eef2f7; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.card-title { font-size: 14.5px; font-weight: 700; color: #1e293b; }
.card-desc { font-size: 11.5px; color: #64748b; margin-top: 3px; max-width: 76ch; line-height: 1.55; }
.probe-bar { display: flex; align-items: center; gap: 10px; padding: 11px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.probe-out { font-size: 11.5px; font-weight: 600; color: #15803d; background: #dcfce7; padding: 3px 9px; border-radius: 6px; }
.probe-out.bad { color: #dc2626; background: #fee2e2; }
.mode-bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; background: #fcfcfd; border-bottom: 1px solid #eef2f7; flex-wrap: wrap; }
.mode-txt { font-size: 12px; color: #64748b; flex: 1; min-width: 260px; line-height: 1.6; margin: 0; }
.mode-txt b { color: #1e293b; font-weight: 600; }
.svc { display: grid; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid #eef2f7; }
.svc:last-child { border-bottom: 0; }
.svc:hover { background: #fcfcfd; }
.svc.off { opacity: .55; }
.svc.hit { background: linear-gradient(90deg, #dcfce7, transparent 42%); }
.svc.isdefault { background: linear-gradient(90deg, #eef2ff, transparent 42%); }
.svc.asr { grid-template-columns: 34px 1fr 170px 110px 60px auto; }
.svc.mt { grid-template-columns: 34px 1fr 130px 60px auto; }
.svc.llm { grid-template-columns: 40px 1fr 130px 90px 60px auto; }
.col-h { padding: 8px 16px; background: #fcfcfd; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; color: #94a3b8; font-weight: 700; display: grid; gap: 12px; }
.col-h.llm-h { grid-template-columns: 40px 1fr 130px 90px 60px auto; border-top: 1px solid #eef2f7; }
.svc-rank { width: 26px; height: 26px; border-radius: 8px; display: grid; place-items: center; background: #f8fafc; border: 1px solid #e2e8f0; color: #64748b; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; font-weight: 700; }
.svc-rank.hit { background: #16a34a; border-color: #16a34a; color: #fff; }
.svc-name { font-weight: 600; font-size: 13.5px; color: #334155; }
.svc-name.skip { text-decoration: line-through; text-decoration-color: #cbd5e1; }
.svc-prov { font-size: 11px; color: #94a3b8; font-family: 'SF Mono', Menlo, monospace; margin-top: 2px; }
.svc-cost { font-family: 'SF Mono', Menlo, monospace; font-size: 12.5px; font-variant-numeric: tabular-nums; color: #334155; }
.svc-cost small { color: #94a3b8; font-size: 10px; }
.svc-acts { display: flex; gap: 3px; align-items: center; justify-content: flex-end; }
.svc-why { grid-column: 2 / -1; font-size: 11px; color: #64748b; margin-top: -2px; }
.svc-why.ok { color: #15803d; font-weight: 600; }
.svc-why.no { color: #b45309; }
.arrow { width: 24px; height: 24px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; color: #64748b; cursor: pointer; display: grid; place-items: center; font-size: 11px; padding: 0; }
.arrow:hover:not(:disabled) { border-color: #4f46e5; color: #4f46e5; }
.arrow:disabled { opacity: .25; cursor: not-allowed; }
.lang { font-family: 'SF Mono', Menlo, monospace; font-size: 9.5px; padding: 1px 5px; border-radius: 4px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; }
.lang.on { background: #dcfce7; border-color: transparent; color: #15803d; font-weight: 700; }
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
.chip-app { background: #eef2ff; color: #4338ca; }
@media (max-width: 1100px) {
.svc.asr, .svc.mt, .svc.llm { grid-template-columns: 34px 1fr; }
.col-h { display: none; }
.svc-acts { grid-column: 1 / -1; justify-content: flex-start; }
.svc-why { grid-column: 1 / -1; }
}
</style>

644
apps/admin/app/pages/serviceconfig.vue

@ -7,7 +7,7 @@
<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">按服务类型分组管理已接入的第三方服务(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;区域默认<b>中国</b>,切换区域可为该区域覆盖字段值。</p>
<p class="text-xs text-slate-400 mt-1">按服务类型分组管理已接入的第三方服务(火山引擎、阿里云、Azure 等)。默认展示<b>全局默认</b>配置,可切换到具体应用;区域默认<b>中国</b>,切换区域后点击卡片可为该区域覆盖字段值。</p>
</div>
<div class="flex items-center gap-3">
<select v-model="scopeApp" class="select select-bordered select-sm" @change="onScopeChange">
@ -15,71 +15,210 @@
<option v-for="a in scopeApps" :key="a" :value="a">应用:{{ a }}</option>
</select>
<select v-model.number="curRegion" class="select select-bordered select-sm" @change="onRegionChange">
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
<option :value="0">默认(全区域)</option>
<option v-for="r in REGIONS" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<button class="btn btn-ghost btn-sm" @click="openTplModal">服务商模板</button>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 添加服务</button>
</div>
</div>
<!-- 主区域:左侧列表 + 右侧详情 -->
<div class="flex gap-4" style="align-items:flex-start">
<!-- 左侧服务列表 -->
<div style="width:248px;flex-shrink:0">
<div class="flex flex-col gap-2">
<div
v-for="s in allSvcs"
:key="s.id"
class="svc-item"
:class="{ active: curSvc && curSvc.id === s.id }"
@click="selectSvc(s)"
>
<div class="svc-logo" :style="{ background: s.color || '#6366f1' }">{{ s.icon || (s.name || '').charAt(0) }}</div>
<div style="min-width:0;flex:1">
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-800 text-sm truncate">{{ s.name }}</span>
<span class="badge badge-xs" :class="s.enable ? 'badge-success' : 'badge-ghost'">{{ s.enable ? '启用' : '停用' }}</span>
</div>
<div class="flex flex-wrap gap-1 mt-1 items-center">
<span v-if="s.provider" class="provider-chip">{{ s.provider }}</span>
<span v-for="v in parseCats(s.categories)" :key="v" class="cat-chip" :style="{ color: scatInfo(v).color, background: scatInfo(v).bg }">{{ scatInfo(v).short }}</span>
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span>
<!-- 区域分叉提示条 -->
<div v-if="curRegion !== 0" class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
✦ 当前处于「{{ curRegionLabel }}」区域视图:点击服务卡片编辑该区域的字段分叉(覆盖值 / 停用字段 / 区域专属字段)。要改服务本身的默认配置,请切回「默认(全区域)」。
</div>
<!-- 服务卡片:按服务类型分组 -->
<div v-if="groupedSvcs.length" class="flex flex-col gap-6">
<section v-for="g in groupedSvcs" :key="g.v">
<div class="flex items-center gap-2 mb-3">
<span class="cat-chip" :style="{ color: g.color, background: g.bg }">{{ g.short }}</span>
<h3 class="text-sm font-semibold text-slate-700">{{ g.label }}</h3>
<span class="text-xs text-slate-400">{{ g.items.length }} 个服务</span>
<div class="flex-1 h-px bg-slate-100 ml-2"></div>
</div>
<div class="svc-grid">
<div v-for="s in g.items" :key="s.id" class="svc-card" @click="selectSvc(s)">
<div class="flex items-start gap-3">
<div class="svc-logo" :style="{ background: s.color || '#6366f1' }">{{ s.icon || (s.name || '').charAt(0) }}</div>
<div style="min-width:0;flex:1">
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-800 text-sm truncate">{{ s.name }}</span>
<span class="badge badge-xs" :class="s.enable ? 'badge-success' : 'badge-ghost'">{{ s.enable ? '启用' : '停用' }}</span>
</div>
<p class="text-[11px] text-slate-400 font-mono truncate mt-0.5" :title="s.id">{{ s.id }}</p>
</div>
</div>
<p class="text-xs text-slate-500 mt-2 line-clamp-2" style="min-height:32px">{{ s.description || '—' }}</p>
<div class="flex flex-wrap items-center gap-1 mt-2">
<span v-if="s.provider" class="provider-chip">{{ s.provider }}</span>
<span class="field-count">{{ (s.fields || []).length }} 字段</span>
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span>
</div>
</div>
<!-- 该类型下的新增入口 -->
<div class="svc-card add-card" @click="startAdd(g.v)">
<span class="text-2xl text-slate-300">+</span>
<span class="text-xs text-slate-400 mt-1">添加{{ g.short }}服务</span>
</div>
<p v-if="!allSvcs.length" class="text-xs text-slate-400 text-center py-8">暂无服务</p>
</div>
</div>
</section>
</div>
<div v-else class="bg-white rounded-2xl border border-slate-100 shadow-sm text-center text-slate-300 py-20">
<div class="text-5xl mb-3">⚙️</div>
<p class="text-sm mb-4">尚未配置任何第三方服务</p>
<button class="btn btn-primary btn-sm text-white" @click="startAdd()">+ 添加服务</button>
</div>
<!-- 右侧详情/编辑 -->
<div class="flex-1 min-w-0">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<!-- 空状态 -->
<div v-if="detailMode === 'empty'" class="text-center text-slate-300 py-20">
<div class="text-5xl mb-3">⚙️</div>
<p class="text-sm">从左侧选择一个服务开始配置</p>
<!-- ===== 服务编辑弹窗(默认编辑 / 新增向导 / 区域分叉) ===== -->
<div v-if="svcModalVisible" class="modal-overlay" @click.self="closeSvcModal">
<div class="svc-box">
<!-- 默认模式:服务全量编辑 -->
<template v-if="detailMode === 'default' && curSvc">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ curSvc.id }}<span v-if="editForm.provider"> · 服务商 {{ editForm.provider }}</span></p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-error btn-sm btn-outline" @click="deleteSvc">删除服务</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存</button>
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">关闭</button>
</div>
</div>
<div class="divider text-xs text-slate-400 my-3">基本信息</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">服务名称 *</span></label>
<input v-model="editForm.name" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">启用</span></label>
<input v-model="editForm.enable" type="checkbox" class="toggle toggle-primary mt-3" />
</div>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">服务描述</span></label>
<input v-model="editForm.description" class="input input-bordered" />
</div>
<div class="mb-4">
<p class="text-sm font-medium text-slate-700 mb-2">服务类型</p>
<span v-if="editType" class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
<span class="text-xs text-slate-500 ml-2">{{ editType ? scatInfo(editType).label : '未设置' }}</span>
<span class="text-[11px] text-slate-400 ml-2">(类型在创建时确定,不可更改)</span>
</div>
<div class="divider text-xs text-slate-400 my-3">配置字段(全区域共用)</div>
<div>
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,点击下方添加</p>
<div v-else class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in editFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
<LangSelect v-else-if="f.type === 'lang'" v-model="f.def_value" />
<template v-else-if="f.encrypted">
<div class="relative">
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f.def_value" placeholder="留空保留原值" />
<button type="button" class="absolute right-2 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 class="lock-badge ml-1">🔒 加密</span>
</template>
<input v-else class="field-input" v-model="f.def_value" />
</td>
<td><button class="btn btn-ghost btn-xs text-error" @click="removeField(i)">×</button></td>
</tr>
</tbody>
</table>
</div>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
</div>
</template>
<!-- 默认模式:服务全量编辑 -->
<template v-else-if="detailMode === 'default' && curSvc">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ curSvc.id }}<span v-if="editForm.provider"> · 服务商 {{ editForm.provider }}</span></p>
</div>
<!-- 新增服务表单(类型优先向导:① 服务类型 → ② 服务商 → ③ 配置) -->
<template v-else-if="detailMode === 'new'">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-slate-800">新增第三方服务</h3>
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">取消</button>
</div>
<!-- 步骤指示 -->
<div class="wizard-steps mb-5">
<div class="wstep" :class="{ on: wizardStep >= 1, cur: wizardStep === 1 }" @click="wizardStep = 1">① 服务类型<span v-if="editType"> · {{ scatInfo(editType).short }}</span></div>
<div class="wline"></div>
<div class="wstep" :class="{ on: wizardStep >= 2, cur: wizardStep === 2, disabled: !editType }" @click="editType && (wizardStep = 2)">② 服务商<span v-if="editForm.provider"> · {{ editForm.provider }}</span></div>
<div class="wline"></div>
<div class="wstep" :class="{ on: wizardStep >= 3, cur: wizardStep === 3, disabled: wizardStep < 3 }">③ 配置</div>
</div>
<!-- 步骤1:选择服务类型(单选,每个服务归属单一类型) -->
<template v-if="wizardStep === 1">
<p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p>
<div class="type-grid">
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
</div>
</div>
</template>
<!-- 步骤2:选择服务商 -->
<template v-else-if="wizardStep === 2">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-slate-500">为「{{ scatInfo(editType).label }}」选择服务商:</p>
<button class="btn btn-ghost btn-xs" @click="wizardStep = 1">← 上一步</button>
</div>
<div class="type-grid">
<div v-for="t in templatesForType" :key="t.id" class="type-card" :class="{ on: selectedTemplateId === t.id }" @click="chooseProvider(t)">
<span class="provider-chip">{{ t.provider }}</span>
<span class="text-sm text-slate-700 mt-2">{{ t.name }}</span>
<span class="text-[11px] text-slate-400 mt-0.5">{{ (t.fields || []).length }} 字段</span>
</div>
<div class="type-card" @click="chooseProvider(null)">
<span class="provider-chip" style="background:#f1f5f9;color:#475569">自定义</span>
<span class="text-sm text-slate-700 mt-2">空白自定义</span>
<span class="text-[11px] text-slate-400 mt-0.5">手动添加字段</span>
</div>
</div>
<p v-if="!templatesForType.length" class="text-xs text-slate-400 mt-2">该类型暂无内置模板,可选「空白自定义」,或到「服务商模板」新增。</p>
</template>
<!-- 步骤3:填配置 -->
<template v-else>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
<span v-if="editForm.provider" class="provider-chip">{{ editForm.provider }}</span>
</div>
<div class="flex gap-2">
<button class="btn btn-error btn-sm btn-outline" @click="deleteSvc">删除服务</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存</button>
<button class="btn btn-ghost btn-xs" @click="wizardStep = 2">← 上一步</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(true)">创建</button>
</div>
</div>
<div class="divider text-xs text-slate-400 my-3">基本信息</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">服务ID * <span class="text-slate-400 text-xs">(唯一,如 tts_azure)</span></span></label>
<input v-model="editForm.id" class="input input-bordered font-mono" placeholder="tts_azure" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">服务名称 *</span></label>
<input v-model="editForm.name" class="input input-bordered" />
<input v-model="editForm.name" class="input input-bordered" placeholder="微软 Azure TTS" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">服务商标识 <span class="text-slate-400 text-xs">(如 azure/alibaba)</span></span></label>
<input v-model="editForm.provider" class="input input-bordered font-mono" placeholder="azure" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">启用</span></label>
@ -88,17 +227,11 @@
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">服务描述</span></label>
<input v-model="editForm.description" class="input input-bordered" />
<input v-model="editForm.description" class="input input-bordered" placeholder="简述该服务的用途" />
</div>
<div class="mb-4">
<p class="text-sm font-medium text-slate-700 mb-2">服务类型</p>
<span v-if="editType" class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
<span class="text-xs text-slate-500 ml-2">{{ editType ? scatInfo(editType).label : '未设置' }}</span>
<span class="text-[11px] text-slate-400 ml-2">(类型在创建时确定,不可更改)</span>
</div>
<div class="divider text-xs text-slate-400 my-3">配置字段(全区域共用)</div>
<div class="divider text-xs text-slate-400 my-3">配置字段</div>
<div>
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,点击下方添加</p>
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,选模板自动带出或点击下方添加</p>
<div v-else class="overflow-x-auto">
<table class="table text-xs">
<thead>
@ -131,221 +264,104 @@
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
</div>
</template>
</template>
<!-- 新增服务表单(类型优先向导:① 服务类型 → ② 服务商 → ③ 配置) -->
<template v-else-if="detailMode === 'new'">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-slate-800">新增第三方服务</h3>
<button class="btn btn-ghost btn-sm" @click="cancelAdd">取消</button>
</div>
<!-- 步骤指示 -->
<div class="wizard-steps mb-5">
<div class="wstep" :class="{ on: wizardStep >= 1, cur: wizardStep === 1 }" @click="wizardStep = 1">① 服务类型<span v-if="editType"> · {{ scatInfo(editType).short }}</span></div>
<div class="wline"></div>
<div class="wstep" :class="{ on: wizardStep >= 2, cur: wizardStep === 2, disabled: !editType }" @click="editType && (wizardStep = 2)">② 服务商<span v-if="editForm.provider"> · {{ editForm.provider }}</span></div>
<div class="wline"></div>
<div class="wstep" :class="{ on: wizardStep >= 3, cur: wizardStep === 3, disabled: wizardStep < 3 }">③ 配置</div>
</div>
<!-- 步骤1:选择服务类型(单选,每个服务归属单一类型) -->
<template v-if="wizardStep === 1">
<p class="text-sm text-slate-500 mb-3">请选择要接入的服务类型(每个服务归属单一类型,独立配置):</p>
<div class="type-grid">
<div v-for="sc in SCAT" :key="sc.v" class="type-card" :class="{ on: editType === sc.v }" @click="chooseType(sc.v)">
<span class="cat-chip" :style="{ color: sc.color, background: sc.bg }">{{ sc.short }}</span>
<span class="text-sm text-slate-700 mt-2">{{ sc.label }}</span>
</div>
</div>
</template>
<!-- 步骤2:选择服务商 -->
<template v-else-if="wizardStep === 2">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-slate-500">为「{{ scatInfo(editType).label }}」选择服务商:</p>
<button class="btn btn-ghost btn-xs" @click="wizardStep = 1">← 上一步</button>
</div>
<div class="type-grid">
<div v-for="t in templatesForType" :key="t.id" class="type-card" :class="{ on: selectedTemplateId === t.id }" @click="chooseProvider(t)">
<span class="provider-chip">{{ t.provider }}</span>
<span class="text-sm text-slate-700 mt-2">{{ t.name }}</span>
<span class="text-[11px] text-slate-400 mt-0.5">{{ (t.fields || []).length }} 字段</span>
</div>
<div class="type-card" @click="chooseProvider(null)">
<span class="provider-chip" style="background:#f1f5f9;color:#475569">自定义</span>
<span class="text-sm text-slate-700 mt-2">空白自定义</span>
<span class="text-[11px] text-slate-400 mt-0.5">手动添加字段</span>
</div>
</div>
<p v-if="!templatesForType.length" class="text-xs text-slate-400 mt-2">该类型暂无内置模板,可选「空白自定义」,或到「服务商模板」新增。</p>
</template>
<!-- 步骤3:填配置 -->
<template v-else>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="cat-chip" :style="{ color: scatInfo(editType).color, background: scatInfo(editType).bg }">{{ scatInfo(editType).short }}</span>
<span v-if="editForm.provider" class="provider-chip">{{ editForm.provider }}</span>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs" @click="wizardStep = 2">← 上一步</button>
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(true)">创建</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">服务ID * <span class="text-slate-400 text-xs">(唯一,如 tts_azure)</span></span></label>
<input v-model="editForm.id" class="input input-bordered font-mono" placeholder="tts_azure" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">服务名称 *</span></label>
<input v-model="editForm.name" class="input input-bordered" placeholder="微软 Azure TTS" />
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">服务商标识 <span class="text-slate-400 text-xs">(如 azure/alibaba)</span></span></label>
<input v-model="editForm.provider" class="input input-bordered font-mono" placeholder="azure" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">启用</span></label>
<input v-model="editForm.enable" type="checkbox" class="toggle toggle-primary mt-3" />
</div>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">服务描述</span></label>
<input v-model="editForm.description" class="input input-bordered" placeholder="简述该服务的用途" />
</div>
<div class="divider text-xs text-slate-400 my-3">配置字段</div>
<div>
<p v-if="!editFields.length" class="text-xs text-slate-400 py-4 text-center">尚无字段,选模板自动带出或点击下方添加</p>
<div v-else class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>默认值</th><th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in editFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f.def_value" :multiple="true" />
<LangSelect v-else-if="f.type === 'lang'" v-model="f.def_value" />
<template v-else-if="f.encrypted">
<div class="relative">
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f.def_value" placeholder="留空保留原值" />
<button type="button" class="absolute right-2 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 class="lock-badge ml-1">🔒 加密</span>
</template>
<input v-else class="field-input" v-model="f.def_value" />
</td>
<td><button class="btn btn-ghost btn-xs text-error" @click="removeField(i)">×</button></td>
</tr>
</tbody>
</table>
<!-- 区域覆盖模式 -->
<template v-else-if="detailMode === 'region' && curSvc">
<div v-if="regionLoading" class="text-center py-10 text-slate-400">加载中...</div>
<template v-else>
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
<span class="badge badge-warning badge-sm mt-0.5">{{ curRegionLabel }} 分叉模式</span>
</div>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
</div>
</template>
</template>
<!-- 区域覆盖模式 -->
<template v-else-if="detailMode === 'region' && curSvc">
<div v-if="regionLoading" class="text-center py-10 text-slate-400">加载中...</div>
<template v-else>
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="svc-logo" :style="{ background: curSvc.color || '#6366f1', width: '44px', height: '44px', fontSize: '20px' }">{{ curSvc.icon || (curSvc.name || '').charAt(0) }}</div>
<div>
<h3 class="text-base font-semibold text-slate-800">{{ curSvc.name }}</h3>
<span class="badge badge-warning badge-sm mt-0.5">{{ curRegionLabel }} 分叉模式</span>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm text-white" @click="openSyncModal(false)">保存分叉</button>
<button class="btn btn-ghost btn-sm" @click="closeSvcModal">关闭</button>
</div>
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
✦ 当前编辑「{{ curRegionLabel }}」的字段分叉:默认字段可覆盖值或勾选「不适用」停用;也可新增本区域专属字段。留空的覆盖字段继承默认值,加密字段留空保留已存储密文。
</div>
</div>
<div class="rounded-lg px-3 py-2 mb-4 text-xs" style="background:#fefce8;border:1px solid #fde047;color:#854d0e">
✦ 当前编辑「{{ curRegionLabel }}」的字段分叉:默认字段可覆盖值或勾选「不适用」停用;也可新增本区域专属字段。留空的覆盖字段继承默认值,加密字段留空保留已存储密文。
</div>
<!-- 默认字段(含停用开关 + 值覆盖) -->
<p class="text-sm font-medium text-slate-700 mb-2">默认字段</p>
<div class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-24 text-center">状态</th><th class="w-20 text-center">本区域不适用</th><th>有效值</th>
</tr>
</thead>
<tbody>
<tr v-for="(f, i) in overrideFields" :key="i" :class="{ 'opacity-50': f.disabled }">
<td class="font-mono text-slate-700 break-all" :title="f.key">{{ f.key }}</td>
<td class="text-slate-500">{{ f.description }}</td>
<td class="text-center">
<span v-if="f.disabled" class="disabled-badge">停用</span>
<span v-else-if="f.is_overridden" class="override-badge">覆盖</span>
<span v-else class="inherit-badge">继承</span>
</td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs" v-model="f.disabled" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f._input" :multiple="true" :disabled="f.disabled" />
<LangSelect v-else-if="f.type === 'lang'" v-model="f._input" :disabled="f.disabled" />
<template v-else-if="f.encrypted">
<div class="relative">
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" :disabled="f.disabled" placeholder="留空继承默认" />
<button type="button" class="absolute right-2 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 class="lock-badge ml-1">🔒</span>
</template>
<input v-else class="field-input" v-model="f._input" :disabled="f.disabled" :placeholder="'留空继承:' + (f.value || '')" />
</td>
</tr>
<tr v-if="!overrideFields.length"><td colspan="5" class="text-center text-slate-400 py-3">该服务尚无默认字段</td></tr>
</tbody>
</table>
</div>
<!-- 默认字段(含停用开关 + 值覆盖) -->
<p class="text-sm font-medium text-slate-700 mb-2">默认字段</p>
<div class="overflow-x-auto">
<!-- 区域专属字段 -->
<div class="divider text-xs text-slate-400 my-3">{{ curRegionLabel }} 专属字段</div>
<div>
<p v-if="!extraRegionFields.length" class="text-xs text-slate-400 py-2 text-center">尚无区域专属字段,点击下方添加</p>
<div v-else class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr>
<th class="w-44">字段名</th><th>字段描述</th><th class="w-24 text-center">状态</th><th class="w-20 text-center">本区域不适用</th><th>有效值</th>
</tr>
<tr><th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>值</th><th class="w-12"></th></tr>
</thead>
<tbody>
<tr v-for="(f, i) in overrideFields" :key="i" :class="{ 'opacity-50': f.disabled }">
<td class="font-mono text-slate-700 break-all" :title="f.key">{{ f.key }}</td>
<td class="text-slate-500">{{ f.description }}</td>
<td class="text-center">
<span v-if="f.disabled" class="disabled-badge">停用</span>
<span v-else-if="f.is_overridden" class="override-badge">覆盖</span>
<span v-else class="inherit-badge">继承</span>
</td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs" v-model="f.disabled" /></td>
<tr v-for="(f, i) in extraRegionFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<LangSelect v-if="f.type === 'langs'" v-model="f._input" :multiple="true" :disabled="f.disabled" />
<LangSelect v-else-if="f.type === 'lang'" v-model="f._input" :disabled="f.disabled" />
<template v-else-if="f.encrypted">
<template v-if="f.encrypted">
<div class="relative">
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" :disabled="f.disabled" placeholder="留空继承默认" />
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" placeholder="留空保留原值" />
<button type="button" class="absolute right-2 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 class="lock-badge ml-1">🔒</span>
</template>
<input v-else class="field-input" v-model="f._input" :disabled="f.disabled" :placeholder="'留空继承:' + (f.value || '')" />
<input v-else class="field-input" v-model="f._input" />
</td>
<td><button class="btn btn-ghost btn-xs text-error" @click="removeExtraField(i)">×</button></td>
</tr>
<tr v-if="!overrideFields.length"><td colspan="5" class="text-center text-slate-400 py-3">该服务尚无默认字段</td></tr>
</tbody>
</table>
</div>
<!-- 区域专属字段 -->
<div class="divider text-xs text-slate-400 my-3">{{ curRegionLabel }} 专属字段</div>
<div>
<p v-if="!extraRegionFields.length" class="text-xs text-slate-400 py-2 text-center">尚无区域专属字段,点击下方添加</p>
<div v-else class="overflow-x-auto">
<table class="table text-xs">
<thead>
<tr><th class="w-44">字段名</th><th>字段描述</th><th class="w-16 text-center">加密</th><th>值</th><th class="w-12"></th></tr>
</thead>
<tbody>
<tr v-for="(f, i) in extraRegionFields" :key="i">
<td><input class="field-input" v-model="f.key" placeholder="key" :title="f.key" /></td>
<td><input class="field-input" v-model="f.description" placeholder="用途描述" /></td>
<td class="text-center"><input type="checkbox" class="checkbox checkbox-xs checkbox-primary" v-model="f.encrypted" /></td>
<td>
<template v-if="f.encrypted">
<div class="relative">
<input :type="f._show ? 'text' : 'password'" class="field-input pr-14" v-model="f._input" placeholder="留空保留原值" />
<button type="button" class="absolute right-2 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 class="lock-badge ml-1">🔒</span>
</template>
<input v-else class="field-input" v-model="f._input" />
</td>
<td><button class="btn btn-ghost btn-xs text-error" @click="removeExtraField(i)">×</button></td>
</tr>
</tbody>
</table>
</div>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addExtraField">+ 添加区域专属字段</button>
</div>
</template>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addExtraField">+ 添加区域专属字段</button>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- 同步区域弹窗 -->
<div v-show="syncVisible" class="sync-overlay">
<div v-show="syncVisible" class="modal-overlay" style="z-index:120">
<div class="sync-box">
<div class="flex items-start gap-3 mb-4">
<div style="width:42px;height:42px;background:linear-gradient(135deg,#4f46e5,#7c3aed);border-radius:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
@ -385,7 +401,7 @@
</div>
<!-- 服务商模板管理弹窗 -->
<div v-show="tplModalVisible" class="sync-overlay">
<div v-show="tplModalVisible" class="modal-overlay" style="z-index:130">
<div class="tpl-box">
<!-- 模板列表 -->
<template v-if="tplView === 'list'">
@ -499,13 +515,15 @@ const REGIONS = [
{ value: 13, label: '巴西' }, { value: 14, label: '印度' },
]
// 服务类别常量(与后端 svcCatXxx 一一对应)。每个服务实例只归属其中一种类型。
// 服务类别常量(与后端 svcCatXxx 一一对应)。categories 支持逗号分隔多值,一个服务可同属多类。
const SCAT = [
{ v: 1, label: 'STT 语音识别', short: 'STT', color: '#0369a1', bg: '#dbeafe' },
{ v: 8, label: '录音文件识别', short: '录音识别', color: '#0e7490', bg: '#cffafe' },
{ v: 2, label: 'TTS 语音合成', short: 'TTS', color: '#7c3aed', bg: '#ede9fe' },
{ v: 3, label: 'MT 机器翻译', short: 'MT', color: '#059669', bg: '#dcfce7' },
{ v: 4, label: 'AST 端到端翻译', short: 'AST', color: '#c2410c', bg: '#fff7ed' },
{ v: 5, label: 'LLM 大语言', short: 'LLM', color: '#b45309', bg: '#fef9c3' },
{ v: 5, label: 'LLM 文本大模型', short: '文本LLM', color: '#b45309', bg: '#fef9c3' },
{ v: 9, label: '多媒体大模型', short: '多媒体LLM', color: '#9333ea', bg: '#f3e8ff' },
{ v: 7, label: 'STS 端到端对话', short: 'STS', color: '#be185d', bg: '#fce7f3' },
{ v: 6, label: '存储 OSS/COS', short: '存储', color: '#475569', bg: '#f1f5f9' },
]
@ -581,8 +599,7 @@ async function loadScopeApps() {
}
}
function onScopeChange() {
curSvc.value = null
detailMode.value = 'empty'
closeSvcModal()
loadSvcs()
}
@ -601,7 +618,8 @@ const backendReady = ref(true)
const allSvcs = ref<ThirdSvcConfig[]>([])
const curSvc = ref<ThirdSvcConfig | null>(null)
const curRegion = ref(0)
const detailMode = ref<'empty' | 'default' | 'new' | 'region'>('empty')
const svcModalVisible = ref(false)
const detailMode = ref<'default' | 'new' | 'region'>('default')
// 编辑中数据
const editForm = reactive({ id: '', name: '', provider: '', description: '', enable: false })
@ -623,6 +641,19 @@ const curRegionLabel = computed(() => {
return r ? r.label : '区域' + curRegion.value
})
// 服务卡片按服务类型分组;未归类的服务归入末尾的「未分类」组
const groupedSvcs = computed(() => {
const groups = SCAT.map((sc) => ({
...sc,
items: allSvcs.value.filter((s) => parseCats(s.categories).includes(sc.v)),
})).filter((g) => g.items.length)
const unclassified = allSvcs.value.filter((s) => !parseCats(s.categories).length)
if (unclassified.length) {
groups.push({ v: 0, label: '未分类', short: '未分类', color: '#64748b', bg: '#f1f5f9', items: unclassified })
}
return groups
})
// 模板按类别分组(供下拉与模板管理列表)
const templatesByCat = computed(() => {
return SCAT.map((sc) => ({
@ -645,7 +676,7 @@ async function loadSvcs() {
}
if (curSvc.value) {
const found = allSvcs.value.find((s) => s.id === curSvc.value!.id)
if (found) selectSvc(found)
if (found) curSvc.value = found
}
}
@ -659,21 +690,23 @@ async function loadTemplates() {
}
}
// ===== 区域变更 =====
// ===== 区域变更:关闭弹窗,卡片列表本身与区域无关 =====
function onRegionChange() {
if (curSvc.value) selectSvc(curSvc.value)
else if (detailMode.value !== 'new') detailMode.value = 'empty'
closeSvcModal()
}
// ===== 选中服务 =====
// ===== 点击卡片:默认区域进全量编辑,其他区域进分叉编辑 =====
function selectSvc(svc: ThirdSvcConfig) {
if (!svc) return
curSvc.value = svc
if (curRegion.value === 0) {
renderDefaultDetail()
} else {
renderRegionDetail()
}
svcModalVisible.value = true
if (curRegion.value === 0) renderDefaultDetail()
else renderRegionDetail()
}
function closeSvcModal() {
svcModalVisible.value = false
curSvc.value = null
}
// ===== 默认模式:服务全量编辑 =====
@ -689,19 +722,20 @@ function renderDefaultDetail() {
detailMode.value = 'default'
}
// ===== 新增服务(类型优先向导) =====
function startAdd() {
// ===== 新增服务(类型优先向导);presetType 由分组卡片的「+」带入 =====
function startAdd(presetType = 0) {
curSvc.value = null
editForm.id = ''
editForm.name = ''
editForm.provider = ''
editForm.description = ''
editForm.enable = false
editType.value = 0
editType.value = presetType
editFields.value = []
selectedTemplateId.value = ''
wizardStep.value = 1
wizardStep.value = presetType ? 2 : 1
detailMode.value = 'new'
svcModalVisible.value = true
}
// 当前所选类型下的服务商模板
@ -738,11 +772,6 @@ function chooseProvider(t: SvcTemplate | null) {
wizardStep.value = 3
}
function cancelAdd() {
if (curSvc.value) selectSvc(curSvc.value)
else detailMode.value = 'empty'
}
// ===== 默认字段表格 =====
function addField() {
editFields.value.push({ key: '', description: '', encrypted: false, def_value: '', sort: editFields.value.length, _show: false })
@ -863,10 +892,9 @@ async function deleteSvc() {
error(e?.message ?? '删除失败')
return
}
curSvc.value = null
closeSvcModal()
success('已删除')
await loadSvcs()
detailMode.value = 'empty'
}
// ===== 同步弹窗 =====
@ -906,16 +934,17 @@ function closeSyncModal() {
async function doSaveOnly() {
closeSyncModal()
await performSave(isNewSave.value)
const ok = await performSave(isNewSave.value)
if (ok) closeSvcModal()
}
async function doSaveAndSync() {
const targets = syncTargets.value.slice()
closeSyncModal()
const ok = await performSave(isNewSave.value)
if (!ok || !targets.length) return
if (!ok) return
const svcId = curSvc.value ? curSvc.value.id : null
if (!svcId) return
if (!targets.length || !svcId) { closeSvcModal(); return }
let synced = 0
try {
const res = await webApi<{ synced: number }>('syncsvctoregins', { app_name: scopeApp.value, svc_id: svcId, src_region: curRegion.value, target_regions: targets })
@ -929,6 +958,7 @@ async function doSaveAndSync() {
return r ? r.label : '区域' + v
})
success('已保存并同步到:' + names.join('、') + '(' + synced + ' 个)')
closeSvcModal()
}
// ===== 服务商模板管理弹窗 =====
@ -1020,25 +1050,53 @@ onMounted(() => {
</script>
<style scoped>
.svc-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #eef2f7;
/* 服务卡片网格 */
.svc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
gap: 12px;
}
.svc-card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 14px;
cursor: pointer;
transition: all 0.15s;
}
.svc-item:hover {
.svc-card:hover {
border-color: #c7d2fe;
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.12);
transform: translateY(-1px);
}
.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
border-color: #e2e8f0;
background: #fafbff;
min-height: 120px;
}
.add-card:hover {
border-color: #a5b4fc;
background: #f5f3ff;
}
.svc-item.active {
border-color: #6366f1;
background: linear-gradient(135deg, #eef2ff, #f5f3ff);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
.field-count {
display: inline-flex;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.svc-logo {
width: 36px;
@ -1179,8 +1237,8 @@ onMounted(() => {
background: #f8fafc;
color: #cbd5e1;
}
/* 同步弹窗 */
.sync-overlay {
/* 弹窗 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
@ -1190,6 +1248,16 @@ onMounted(() => {
justify-content: center;
backdrop-filter: blur(2px);
}
.svc-box {
background: #fff;
border-radius: 20px;
padding: 26px;
width: 860px;
max-width: 96vw;
max-height: 90vh;
overflow: auto;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
}
.sync-box {
background: #fff;
border-radius: 20px;

32
apps/admin/app/types/app.ts

@ -0,0 +1,32 @@
// 应用中心(/apps)共享类型:应用、区域部署、部署环境节点。
// 字段与 console 后端 modules/console 的 Product / AppRegistry / Environment 对齐。
export interface Product {
id: number
name: string
image_name?: string
remark: string
enabled: boolean
}
export interface Deployment {
id: number
app_name: string
status: string // 'pending' = 待部署;其余 = 已接入
name: string
region: string
env_id: number
base_url: string
service_dsn: string
deploy_dir: string
image_name: string
image_tag: string
remark: string
enabled: boolean
}
export interface EnvOption {
id: number
name: string
code: string
}

3
apps/services/comm/const.go

@ -61,7 +61,8 @@ const (
TableAdmin = "adnim" //后台用户表
TableEchomeetTemplate = "echomeet_template" //模板表
TableEchomeetRecord = "echomeet_record" //会议记录表
TableEchomeetSvcConfig = "echomeet_svc_config" //会议记录服务配置(识别/翻译/总结三类,按优先级;存应用业务库,全局一份)
TableEchomeetOrch = "echomeet_orch" //会议记录服务编排(引用 svc_config 的服务,按 app_name+region 分层;存 console 共享库)
TableEchomeetOrchSetting = "echomeet_orch_setting" //会议记录编排的作用域级开关(每 app_name+region 一行;如客户端可否指定总结模型)
TableChannelApp = "channelapp" //渠道包管理
TableFactory = "factory" //厂商数据表

219
apps/services/comm/echomeetsvc.go

@ -1,219 +0,0 @@
package comm
import (
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
)
// 会议记录(echomeet)服务配置托管:把「识别 / 翻译 / 总结」三类服务配置全部搬进管理后台。
//
// 与旧的「一会一服务商,三段同源」不同:识别/翻译/总结各自维护一份**按优先级排序的服务清单**,
// - 识别:按优先级 + 各服务「支持语言」自动选择(跳过不支持源语言的服务);
// - 翻译:按优先级自动选择;
// - 总结:每条 = 一个具体文本大模型条目,客户端按 id 选择。
//
// 存储沿用 [[app_module_config]] 的路子:存**应用自己的业务库**(mysql/pg,随部署)、**全局一份**(无 app_name)。
// console 后台经 getServiceDB 写、home 的 echomeet 启动时 LoadOrSeed 读(改配置需重启生效)。
// 密钥字段以 AES-256-GCM 密文落库(comm.Encrypt,${FIELD_ENCRYPT_KEY},console 与业务侧同一把 key),
// 返回前端脱敏为 EncMask。字段清单由 EchoSvcCatalog 数据驱动(哪些字段、类型、是否密钥)。
// 服务类别。
const (
EchoSvcCatASR = 1 // 识别(转写)
EchoSvcCatMT = 2 // 翻译
EchoSvcCatLLM = 3 // 总结(文本大模型)
)
// EchoSvcConfig 一行 = 某类别下一条服务/模型条目。存应用业务库,全局一份。
type EchoSvcConfig struct {
Id string `gorm:"primaryKey;column:id;size:64" json:"id"` // 稳定键:asr_ali / mt_bytedance / llm_qwen_plus
Category int32 `gorm:"column:category;index:idx_echosvc_cat" json:"category"` // 1 识别 / 2 翻译 / 3 总结
Provider string `gorm:"column:provider;size:32" json:"provider"` // bytedance/ali/google/microsoft(识别/翻译);doubao/openai/gemini(总结)
Name string `gorm:"column:name;size:128" json:"name"` // 展示名(总结类即模型友好名,客户端可见)
Priority int32 `gorm:"column:priority" json:"priority"` // 同类内升序,小=优先
Enabled bool `gorm:"column:enabled" json:"enabled"` // 是否启用
SupportLangs string `gorm:"column:support_langs;type:text" json:"support_langs"` // 识别用:逗号分隔 BCP-47;空=按代码默认支持集
Values map[string]string `gorm:"column:values;type:text;serializer:json" json:"values"` // 参数/凭据值;密钥字段存密文
Createtime int64 `gorm:"column:createtime" json:"createtime"`
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}
func (EchoSvcConfig) TableName() string { return TableEchomeetSvcConfig }
// EchoSvcField 一个配置字段的元数据(驱动前端表单 + 后端密钥判定)。
type EchoSvcField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text|password|textarea|url
Secret bool `json:"secret"`
Required bool `json:"required"`
Placeholder string `json:"placeholder,omitempty"`
Desc string `json:"desc,omitempty"`
}
// EchoSvcProviderDef 某类别下某服务商/模型实现的字段模板。
type EchoSvcProviderDef struct {
Category int32 `json:"category"` // 所属类别
Provider string `json:"provider"` // 存库 Provider 值
Name string `json:"name"` // 展示名
HasLangs bool `json:"has_langs"` // 是否展示「支持语言」(识别类为 true)
Multi bool `json:"multi"` // 是否允许同 provider 多条(总结类为 true:一个服务商多个模型)
Fields []EchoSvcField `json:"fields"`
}
// 常用字段构造助手。
func esfText(key, label string, required bool) EchoSvcField {
return EchoSvcField{Key: key, Label: label, Type: "text", Required: required}
}
func esfSecret(key, label string, required bool) EchoSvcField {
return EchoSvcField{Key: key, Label: label, Type: "password", Secret: true, Required: required}
}
// EchoSvcCatalog 会议记录服务字段目录:识别/翻译/总结 × 各服务商,字段依据 home.yaml 的 echomeet 配置结构。
var EchoSvcCatalog = []EchoSvcProviderDef{
// ===== 识别(转写)=====
{Category: EchoSvcCatASR, Provider: "bytedance", Name: "字节跳动", HasLangs: true, Fields: []EchoSvcField{
esfText("AppID", "AppID", true),
esfSecret("Token", "Token", false),
esfText("Callback", "回调地址", false),
}},
{Category: EchoSvcCatASR, Provider: "ali", Name: "阿里云 DashScope", HasLangs: true, Fields: []EchoSvcField{
esfSecret("ApiKey", "ApiKey", true),
esfText("Callback", "回调地址", false),
}},
{Category: EchoSvcCatASR, Provider: "google", Name: "Google Cloud", HasLangs: true, Fields: []EchoSvcField{
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true,
Desc: "服务账号 JSON 内容或服务器上的文件路径"},
}},
{Category: EchoSvcCatASR, Provider: "microsoft", Name: "Azure 语音", HasLangs: true, Fields: []EchoSvcField{
esfSecret("Key", "Key", true),
esfText("Region", "Region", true),
}},
// ===== 翻译 =====
{Category: EchoSvcCatMT, Provider: "bytedance", Name: "火山翻译", Fields: []EchoSvcField{
esfText("AccessKey", "AccessKey", true),
esfSecret("SecretKey", "SecretKey", true),
esfText("Region", "Region", false),
}},
{Category: EchoSvcCatMT, Provider: "ali", Name: "阿里机器翻译", Fields: []EchoSvcField{
esfText("AccessKeyId", "AccessKeyId", true),
esfSecret("AccessKeySecret", "AccessKeySecret", true),
esfText("Region", "Region", false),
}},
{Category: EchoSvcCatMT, Provider: "google", Name: "Google 翻译", Fields: []EchoSvcField{
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true,
Desc: "服务账号 JSON 内容或服务器上的文件路径"},
}},
{Category: EchoSvcCatMT, Provider: "microsoft", Name: "Azure 翻译", Fields: []EchoSvcField{
esfSecret("Key", "Key", true),
esfText("Region", "Region", false),
esfText("Endpoint", "Endpoint", false),
}},
// ===== 总结(文本大模型;一个服务商可配多条不同模型)=====
{Category: EchoSvcCatLLM, Provider: "doubao", Name: "豆包(字节)", Multi: true, Fields: []EchoSvcField{
esfSecret("Apikey", "ApiKey", true),
esfText("Model", "模型 ID", true),
}},
{Category: EchoSvcCatLLM, Provider: "openai", Name: "OpenAI 兼容端点(通义/Azure OpenAI 等)", Multi: true, Fields: []EchoSvcField{
esfText("BaseURL", "BaseURL", true),
esfSecret("Token", "Token", true),
esfText("Model", "模型 ID", true),
}},
{Category: EchoSvcCatLLM, Provider: "gemini", Name: "Gemini(Google)", Multi: true, Fields: []EchoSvcField{
esfSecret("Apikey", "ApiKey", true),
esfText("Model", "模型 ID", true),
esfText("Endpoint", "自定义 Endpoint(可选)", false),
}},
}
// FindEchoSvcDef 按 (类别, provider) 取字段模板;找不到返回 nil。
func FindEchoSvcDef(category int32, provider string) *EchoSvcProviderDef {
for i := range EchoSvcCatalog {
if EchoSvcCatalog[i].Category == category && EchoSvcCatalog[i].Provider == provider {
return &EchoSvcCatalog[i]
}
}
return nil
}
// EchoSvcFieldIsSecret 判断某 (类别,provider,字段) 是否密钥字段。目录优先,回退通用命名规则。
func EchoSvcFieldIsSecret(category int32, provider, key string) bool {
if def := FindEchoSvcDef(category, provider); def != nil {
for _, f := range def.Fields {
if f.Key == key {
return f.Secret
}
}
}
return IsSecretField(key)
}
// EncryptEchoValues 返回 values 的副本,把密钥字段加密(空值/已是 EncMask 的不动,交由调用方决定)。
func EncryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string {
out := make(map[string]string, len(values))
for k, v := range values {
if EchoSvcFieldIsSecret(category, provider, k) && v != "" && v != EncMask {
if ct, err := Encrypt(encKey, v); err == nil {
out[k] = ct
continue
}
}
out[k] = v
}
return out
}
// DecryptEchoValues 返回 values 的副本,把密钥字段解密(供运行时消费)。
func DecryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string {
out := make(map[string]string, len(values))
for k, v := range values {
if EchoSvcFieldIsSecret(category, provider, k) && v != "" {
plain, err := Decrypt(encKey, v)
if err == nil {
out[k] = plain
continue
}
// 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文用作第三方 API key,
// 表现为鉴权失败,而后台显示"已配置",无从排查。
log.Errorf("[EchoSvcConfig] 密钥字段解密失败 category=%d provider=%s key=%s err=%v 本服务key指纹=%s %s",
category, provider, k, err, KeyFingerprint(encKey), DiagnoseDecryptFailure(encKey, v))
}
out[k] = v
}
return out
}
// MaskEchoValues 返回 values 的副本,把密钥字段脱敏为 EncMask(返回前端用)。
func MaskEchoValues(category int32, provider string, values map[string]string) map[string]string {
out := make(map[string]string, len(values))
for k, v := range values {
if EchoSvcFieldIsSecret(category, provider, k) && v != "" {
out[k] = EncMask
continue
}
out[k] = v
}
return out
}
// EnsureEchoSvcTable 建表(幂等)。db 为应用业务库。
func EnsureEchoSvcTable(db mysql.ISys) error {
if db == nil {
return nil
}
return db.CreateTable(TableEchomeetSvcConfig, &EchoSvcConfig{})
}
// LoadEchoSvcConfigs 读全部会议记录服务配置行(未解密)。
func LoadEchoSvcConfigs(db mysql.ISys) ([]*EchoSvcConfig, error) {
if db == nil {
return nil, nil
}
rows := make([]*EchoSvcConfig, 0)
if err := db.Find(TableEchomeetSvcConfig, &rows, ""); err != nil {
return nil, err
}
return rows, nil
}

2
apps/services/comm/nats.go

@ -37,7 +37,7 @@ const (
ConfigKindServiceConfig = "service_config" // 应用×服务基础设施/运行配置(后台改后广播;DSN 等多需重启才生效)
ConfigKindAgent = "agent" // yunyan 自管 Agent 配置(后台改后广播;下发客户端)
ConfigKindModuleConfig = "module_config" // 应用业务功能配置(登录/短信/邮件/支付;存业务库,后台改后广播、业务侧重载)
ConfigKindEchomeetSvc = "echomeet_svc" // 会议记录服务配置(识别/翻译/总结优先级+凭据;存业务库,后台改后广播;改配置重启生效)
ConfigKindEchomeetOrch = "echomeet_orch" // 会议记录服务编排(引用 svc_config 的服务,按 app_name+region 分层;存 console 库,下发/运行时接入后用)
)
// ConfigChangedEvent console→业务 的「配置已变更」广播事件。

7
apps/services/modules/console/api_config.go

@ -78,6 +78,13 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableAgentConfig, &AgentConfig{}); err != nil {
return err
}
// 会议记录服务编排(引用 svc_config 的服务,按 app_name+region 分层)+ 作用域开关。
if err := pg.CreateTable(comm.TableEchomeetOrch, &EchoOrchestration{}); err != nil {
return err
}
if err := pg.CreateTable(comm.TableEchomeetOrchSetting, &EchoOrchSetting{}); err != nil {
return err
}
// 一次性迁移:老表数据 → 新表 app_name='' 全局作用域(新表为空且老表存在时)。
migrateLegacyConfigToScoped()
// 幂等 seed 内置服务商模板(仅补缺失项,不覆盖用户改动)。

318
apps/services/modules/console/api_echomeet_orch.go

@ -0,0 +1,318 @@
package console
import (
"sort"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 会议记录服务编排 (echomeet orch) ============================
//
// admin「会议记录服务」页的后端。编排**不存凭据**,只引用第三方服务配置(svc_config)里配好的
// 服务 id,按作用域 (app_name, region) 分层:app_name='' 且 region=0 = 全局默认兜底。
// 数据存 console 共享库(postgres),与 svc_config 同库。
//
// 前端流程与「通话翻译配置」一致:先 getsvcconfigs 拿服务池、再 getechoorch 拿编排,前端组装。
// web api(handleWeb 分发),前端统一用 webApi。
// echoOrchScope 解析请求里的作用域参数。region<0 归一为 0(全区域)。
type echoOrchScope struct {
AppName string `json:"app_name"`
Region int32 `json:"region"`
}
func normScope(s *echoOrchScope) {
s.AppName = strings.TrimSpace(s.AppName)
if s.Region < 0 {
s.Region = 0
}
}
// getEchoOrch 列出某作用域下的编排 + 作用域开关。app_name='' 且 region=0 为全局默认。
func (this *serverComp) getEchoOrch(c *gin.Context) {
var req echoOrchScope
_ = c.ShouldBindJSON(&req)
normScope(&req)
items := make([]*EchoOrchestration, 0)
if err := postgres.Find(comm.TableEchomeetOrch, &items, "app_name=? AND region=?", req.AppName, req.Region); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Kind != items[j].Kind {
return items[i].Kind < items[j].Kind
}
return items[i].Priority < items[j].Priority
})
setting := &EchoOrchSetting{}
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, req.Region); err != nil {
setting = &EchoOrchSetting{AppName: req.AppName, Region: req.Region} // 未设置 → 零值
}
writeOK(c, gin.H{
"items": items,
"setting": setting,
"available": this.echoOrchAvailableSvcs(req.AppName),
// 各编排类别对应的服务池类别,前端据此从「可选服务」里筛出每段能选的服务。
"kind_cats": gin.H{
"1": echoKindSvcCats[echoKindASR],
"2": echoKindSvcCats[echoKindMT],
"3": echoKindSvcCats[echoKindLLM],
},
})
}
// echoOrchSvcView 供前端选服务/显示名/识别语言的精简服务视图(不含凭据)。
type echoOrchSvcView struct {
Id string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Categories string `json:"categories"` // 逗号分隔类别,前端按 kind_cats 筛
Languages string `json:"languages"` // 识别服务的支持语言(逗号分隔 BCP-47),用于选路预览
}
// echoOrchAvailableSvcs 返回该作用域可见的启用服务(本作用域 + 全局兜底,同 id 应用覆盖全局)。
func (this *serverComp) echoOrchAvailableSvcs(appName string) []echoOrchSvcView {
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND enable=true", appName); err != nil {
return nil
}
// 去重:同业务 id,应用作用域优先于全局兜底。
pick := make(map[string]*ThirdSvcConfig, len(rows))
for _, r := range rows {
if cur, ok := pick[r.Id]; !ok || (r.AppName != "" && cur.AppName == "") {
pick[r.Id] = r
}
}
out := make([]echoOrchSvcView, 0, len(pick))
for _, r := range pick {
langs := ""
for _, f := range r.Fields {
if f.Key == "languages" {
langs = f.DefValue
break
}
}
out = append(out, echoOrchSvcView{
Id: r.Id, Name: r.Name, Provider: r.Provider, Categories: r.Categories, Languages: langs,
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Id < out[j].Id })
return out
}
// echoOrchSvcAllowed 校验 svc_id 在该作用域可见(本作用域或全局兜底)且类别与 kind 匹配。
// 返回 (ok, 提示)。服务池按 app_name 分作用域,应用作用域看不到别的应用、但能继承全局。
func (this *serverComp) echoOrchSvcAllowed(appName, svcId string, kind int32) (bool, string) {
wantCats := echoKindSvcCats[kind]
if len(wantCats) == 0 {
return false, "未知的编排类别"
}
// 该作用域可见的服务 = app_name 自己的 + 全局默认的。
rows := make([]*ThirdSvcConfig, 0)
if err := postgres.Find(comm.TableSvcConfig, &rows, "(app_name=? OR app_name='') AND id=?", appName, svcId); err != nil {
return false, err.Error()
}
for _, r := range rows {
if !r.Enable {
continue
}
for _, cat := range strings.Split(r.Categories, ",") {
for _, want := range wantCats {
if strings.TrimSpace(cat) == strconv.Itoa(int(want)) {
return true, ""
}
}
}
}
return false, "所选服务不存在、已停用,或类别与该段不匹配"
}
// saveEchoOrch 新增/更新一条编排(按 row_id;0 为新增)。
// 总结类默认模型同作用域唯一;非总结类强制清零 is_default/client_visible。
func (this *serverComp) saveEchoOrch(c *gin.Context) {
var req struct {
echoOrchScope
Orch EchoOrchestration `json:"orch"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
normScope(&req.echoOrchScope)
o := req.Orch
o.AppName = req.AppName
o.Region = req.Region
o.SvcId = strings.TrimSpace(o.SvcId)
if o.SvcId == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "svc_id 必填")
return
}
if _, ok := echoKindSvcCats[o.Kind]; !ok {
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的编排类别 kind")
return
}
if ok, msg := this.echoOrchSvcAllowed(req.AppName, o.SvcId, o.Kind); !ok {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 只有总结类有默认/客户端可见;其余强制清零。停用的不能当默认。
if o.Kind != echoKindLLM {
o.IsDefault = false
o.ClientVisible = false
}
if !o.Enabled {
o.IsDefault = false
}
now := time.Now().Unix()
o.Updatetime = now
if o.RowId != 0 {
old := &EchoOrchestration{}
if err := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", o.RowId); err != nil {
writeErr(c, pb.ErrorCode_DBError, "编排不存在: "+err.Error())
return
}
o.AppName, o.Region, o.Createtime = old.AppName, old.Region, old.Createtime
if err := postgres.Save(comm.TableEchomeetOrch, &o); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
} else {
// 同作用域同类同服务不可重复。
dup := &EchoOrchestration{}
if postgres.FindOne(comm.TableEchomeetOrch, dup, "app_name=? AND region=? AND kind=? AND svc_id=?",
o.AppName, o.Region, o.Kind, o.SvcId) == nil && dup.RowId != 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "该服务已在此段添加")
return
}
o.Createtime = now
if err := postgres.Insert(comm.TableEchomeetOrch, &o); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
// 默认模型同作用域唯一:本行置默认后,抹掉同作用域同类其余行。先写后清,避免中途失败留空默认。
if o.IsDefault {
if res := postgres.Exec(
"UPDATE "+comm.TableEchomeetOrch+" SET is_default=false WHERE app_name=? AND region=? AND kind=? AND row_id<>?",
o.AppName, o.Region, o.Kind, o.RowId); res.Error != nil {
writeErr(c, pb.ErrorCode_DBError, "清理旧默认模型失败: "+res.Error.Error())
return
}
}
writeOK(c, gin.H{"row_id": o.RowId})
}
// delEchoOrch 删除一条编排。删掉的若是默认模型,顺延同作用域同类一个可用的。
func (this *serverComp) delEchoOrch(c *gin.Context) {
var req struct {
RowId uint64 `json:"row_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.RowId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "row_id 必填")
return
}
old := &EchoOrchestration{}
had := postgres.FindOne(comm.TableEchomeetOrch, old, "row_id=?", req.RowId) == nil && old.RowId != 0
if err := postgres.Delete(comm.TableEchomeetOrch, "row_id=?", req.RowId); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if had && old.IsDefault && old.Kind == echoKindLLM {
this.promoteEchoOrchDefault(old.AppName, old.Region)
}
writeOK(c, gin.H{"deleted": req.RowId})
}
// promoteEchoOrchDefault 该作用域总结类无默认时,把优先级最高的启用条目提为默认。best-effort。
func (this *serverComp) promoteEchoOrchDefault(appName string, region int32) {
rows := make([]*EchoOrchestration, 0)
if err := postgres.Find(comm.TableEchomeetOrch, &rows, "app_name=? AND region=? AND kind=?", appName, region, echoKindLLM); err != nil {
return
}
var best *EchoOrchestration
for _, r := range rows {
if r.IsDefault {
return // 还有默认
}
if !r.Enabled {
continue
}
if best == nil || r.Priority < best.Priority {
best = r
}
}
if best == nil {
return
}
postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET is_default=true WHERE row_id=?", best.RowId)
}
// reorderEchoOrch 批量调整同作用域内的优先级(拖拽/上下移后提交)。
func (this *serverComp) reorderEchoOrch(c *gin.Context) {
var req struct {
Items []struct {
RowId uint64 `json:"row_id"`
Priority int32 `json:"priority"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
now := time.Now().Unix()
updated := 0
for _, it := range req.Items {
if it.RowId == 0 {
continue
}
if res := postgres.Exec("UPDATE "+comm.TableEchomeetOrch+" SET priority=?, updatetime=? WHERE row_id=?",
it.Priority, now, it.RowId); res.Error != nil {
writeErr(c, pb.ErrorCode_DBError, res.Error.Error())
return
}
updated++
}
writeOK(c, gin.H{"updated": updated})
}
// saveEchoOrchSetting 保存作用域级开关(客户端可否指定总结模型),upsert 单行。
func (this *serverComp) saveEchoOrchSetting(c *gin.Context) {
var req struct {
echoOrchScope
LLMClientChoice bool `json:"llm_client_choice"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
normScope(&req.echoOrchScope)
now := time.Now().Unix()
old := &EchoOrchSetting{}
if postgres.FindOne(comm.TableEchomeetOrchSetting, old, "app_name=? AND region=?", req.AppName, req.Region) == nil && old.RowId != 0 {
old.LLMClientChoice = req.LLMClientChoice
old.Updatetime = now
if err := postgres.Save(comm.TableEchomeetOrchSetting, old); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
} else {
row := &EchoOrchSetting{AppName: req.AppName, Region: req.Region, LLMClientChoice: req.LLMClientChoice, Updatetime: now}
if err := postgres.Insert(comm.TableEchomeetOrchSetting, row); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
writeOK(c, gin.H{"llm_client_choice": req.LLMClientChoice})
}

218
apps/services/modules/console/api_echomeetsvc.go

@ -1,218 +0,0 @@
package console
import (
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 会议记录服务配置 (echomeetsvc) ============================
//
// admin「会议记录服务配置」页的后端:读写**应用业务库**里的 echomeet_svc_config 表
// (识别/翻译/总结三类,各自按优先级;识别可配支持语言;总结为具体模型条目)。全局一份、无 app_name。
//
// 业务库连接按部署 appId 从 registry 动态取(与 modulecfg 同机制);
// 密钥字段(是否密钥以 comm.EchoSvcCatalog 为准)AES-GCM 加密落库、返回前端脱敏为 comm.EncMask。
// 改配置后广播 ConfigKindEchomeetSvc;home 侧 echomeet 改配置需重启生效。
// echoSvcCatalog 返回会议记录服务字段目录(前端据此渲染各服务商表单、识别语言选择器)。与选中应用无关。
func (this *serverComp) echoSvcCatalog(c *gin.Context) {
writeOK(c, gin.H{
"catalog": comm.EchoSvcCatalog,
"categories": []gin.H{
{"value": comm.EchoSvcCatASR, "label": "识别"},
{"value": comm.EchoSvcCatMT, "label": "翻译"},
{"value": comm.EchoSvcCatLLM, "label": "总结"},
},
})
}
// echoSvcList 列出某部署业务库里的会议记录服务配置;密钥脱敏。
func (this *serverComp) echoSvcList(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
}
_ = c.ShouldBindJSON(&req)
if req.AppId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := comm.EnsureEchoSvcTable(db); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
rows, err := comm.LoadEchoSvcConfigs(db)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
for _, r := range rows {
r.Values = comm.MaskEchoValues(r.Category, r.Provider, r.Values)
}
writeOK(c, gin.H{"items": rows})
}
// echoSvcSave 保存单条服务配置:新增/更新(按 id)。
// 是否密钥以目录为准(不信前端);密钥字段回传占位/空则保留原密文,否则加密新明文;目录外的键忽略。
func (this *serverComp) echoSvcSave(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Config comm.EchoSvcConfig `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
cfg := req.Config
cfg.Id = strings.TrimSpace(cfg.Id)
cfg.Provider = strings.TrimSpace(cfg.Provider)
if req.AppId == 0 || cfg.Id == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填")
return
}
def := comm.FindEchoSvcDef(cfg.Category, cfg.Provider)
if def == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的服务类别/服务商组合")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := comm.EnsureEchoSvcTable(db); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 取旧行以保留密钥密文。
old := &comm.EchoSvcConfig{}
hasOld := db.FindOne(comm.TableEchomeetSvcConfig, old, "id=?", cfg.Id) == nil && old.Id != ""
// 合法字段过滤 + 密钥处理。
allowed := make(map[string]bool, len(def.Fields))
for _, f := range def.Fields {
allowed[f.Key] = true
}
encKey := this.options.EncryptKey
stored := make(map[string]string, len(cfg.Values))
for k, v := range cfg.Values {
if !allowed[k] {
continue
}
if comm.EchoSvcFieldIsSecret(cfg.Category, cfg.Provider, k) {
if v == "" || v == comm.EncMask {
if hasOld {
stored[k] = old.Values[k] // 保留原密文
}
continue
}
ct, ee := comm.Encrypt(encKey, v)
if ee != nil {
writeErr(c, pb.ErrorCode_SystemError, "加密失败: "+ee.Error())
return
}
stored[k] = ct
continue
}
stored[k] = v
}
cfg.Values = stored
now := time.Now().Unix()
cfg.Updatetime = now
if hasOld {
cfg.Createtime = old.Createtime
if e := db.Save(comm.TableEchomeetSvcConfig, &cfg); e != nil {
writeErr(c, pb.ErrorCode_DBError, e.Error())
return
}
} else {
cfg.Createtime = now
if e := db.Insert(comm.TableEchomeetSvcConfig, &cfg); e != nil {
writeErr(c, pb.ErrorCode_DBError, e.Error())
return
}
}
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0)
writeOK(c, gin.H{"id": cfg.Id})
}
// echoSvcDel 删除一条服务配置。
func (this *serverComp) echoSvcDel(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Id string `json:"id"`
}
_ = c.ShouldBindJSON(&req)
req.Id = strings.TrimSpace(req.Id)
if req.AppId == 0 || req.Id == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
if err := db.Delete(comm.TableEchomeetSvcConfig, "id=?", req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "delete", 0)
writeOK(c, gin.H{"deleted": req.Id})
}
// echoSvcReorder 批量调整同类内优先级(拖拽排序后提交)。
func (this *serverComp) echoSvcReorder(c *gin.Context) {
var req struct {
AppId uint32 `json:"app_id"`
Items []struct {
Id string `json:"id"`
Priority int32 `json:"priority"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if req.AppId == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填")
return
}
db, err := this.module.registry.getServiceDB(req.AppId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error())
return
}
now := time.Now().Unix()
updated := 0
for _, it := range req.Items {
id := strings.TrimSpace(it.Id)
if id == "" {
continue
}
row := &comm.EchoSvcConfig{}
if e := db.FindOne(comm.TableEchomeetSvcConfig, row, "id=?", id); e != nil || row.Id == "" {
continue
}
row.Priority = it.Priority
row.Updatetime = now
if e := db.Save(comm.TableEchomeetSvcConfig, row); e != nil {
writeErr(c, pb.ErrorCode_DBError, e.Error())
return
}
updated++
}
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0)
writeOK(c, gin.H{"updated": updated})
}

93
apps/services/modules/console/api_svctemplate.go

@ -121,15 +121,18 @@ func (this *serverComp) delSvcTemplate(c *gin.Context) {
// ============================== 内置模板 seed ==============================
// 服务类别常量(与 admin 前端 SCAT / serviceconfig.vue 一致)。每个服务实例只归属其中一种。
// 服务类别常量(与 admin 前端 SCAT / serviceconfig.vue 一致)。
// 一个服务实例的 categories 是逗号分隔多值,可同属多类(如 GPT-4o 同属「文本大模型」+「多媒体大模型」)。
const (
svcCatSTT int32 = 1 // 语音识别
svcCatTTS int32 = 2 // 语音合成
svcCatMT int32 = 3 // 机器翻译
svcCatAST int32 = 4 // 端到端语音翻译
svcCatLLM int32 = 5 // 大语言模型
svcCatStorage int32 = 6 // 对象存储
svcCatSTS int32 = 7 // 端到端语音对话
svcCatSTT int32 = 1 // 语音识别(实时/短语音,通话翻译在用)
svcCatTTS int32 = 2 // 语音合成
svcCatMT int32 = 3 // 机器翻译
svcCatAST int32 = 4 // 端到端语音翻译
svcCatLLM int32 = 5 // 文本大模型
svcCatStorage int32 = 6 // 对象存储
svcCatSTS int32 = 7 // 端到端语音对话
svcCatASRFile int32 = 8 // 录音文件识别(会议记录离线转写,接口/凭据区别于实时 STT)
svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结)
)
// sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。
@ -137,6 +140,11 @@ func sf(key, desc string, encrypted bool) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted}
}
// sfd 构造一个带默认值的模板字段(如回调相对路径——由代码路由决定,运营通常不用改)。
func sfd(key, desc string, encrypted bool, defValue string) SvcField {
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue}
}
// tpl 构造一个内置模板(Builtin=true,字段 sort 按传入顺序自动编号)。
func tpl(id, provider, name string, category, order int32, fields ...SvcField) ThirdSvcTemplate {
for i := range fields {
@ -151,6 +159,23 @@ func tpl(id, provider, name string, category, order int32, fields ...SvcField) T
// audioOutputCats 需要输出音频、须配置「支持语言 + 默认语言」的服务类别(TTS/AST/STS)。
var audioOutputCats = map[int32]bool{svcCatTTS: true, svcCatAST: true, svcCatSTS: true}
// asrLangCats 需要「支持语言(多选)」但不需要默认语言的识别类:录音文件识别。
// 语义是「该服务能识别哪些语种」,会议记录编排按它做选路过滤(源语言不支持则跳到下一个)。
var asrLangCats = map[int32]bool{svcCatASRFile: true}
// 录音文件识别全语种集(本系统 BCP-47 全集,与 admin useLangOptions.ts 一致)。
// 字节大模型 / Google / Azure 三家在此全集内均全量支持。
const asrLangsAll = "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,id-ID,es-ES,es-MX,pt-BR,pt-PT,de-DE,fr-FR,it-IT,nl-NL,pl-PL,ru-RU,uk-UA,tr-TR,vi-VN,th-TH,ms-MY,fil-PH,ar-SA"
// asrFileDefaultLangs 各录音识别服务商官方支持语种默认值(2026-07 核对),seed 时预填 languages 字段,运营可再增删。
// 阿里 paraformer-v2 窄(官方仅中含粤/英/日/韩/德/法/俄);其余三家本全集内全支持。
var asrFileDefaultLangs = map[string]string{
"bytedance": asrLangsAll,
"google": asrLangsAll,
"azure": asrLangsAll,
"alibaba": "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,de-DE,fr-FR,ru-RU", // paraformer-v2
}
// builtinSvcTemplates 内置服务商模板清单(参考 polychat-agent 的服务商与字段归纳,可后续增改)。
func builtinSvcTemplates() []ThirdSvcTemplate {
list := []ThirdSvcTemplate{
@ -194,7 +219,7 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
// LLM 大语言模型
// LLM 文本大模型
tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0,
sf("api_key", "API Key", true), sf("model", "模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false), sf("temperature", "生成温度 0~2", false), sf("max_tokens", "最大输出 Token", false)),
tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1,
@ -206,6 +231,33 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("llm_qwen", "qwen", "通义千问(阿里)", svcCatLLM, 4,
sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false)),
// 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据)
//
// ⚠️ 回调只存**相对路径**,不存完整 URL:一个服务(尤其全局默认的)会被所有应用共用,
// 而每个应用/部署的对外地址不同。完整回调地址由运行时拼:<该应用的对外地址> + callback_path。
// 默认值取自 echomeet 的反射路由(home 服务前缀 /api/home/ + <module>_<method小写>),
// 改 echomeet 回调方法名或网关前缀时要同步这里。Google/Azure 走轮询、无回调,故无此字段。
tpl("asrfile_bytedance", "bytedance", "字节跳动 录音识别大模型", svcCatASRFile, 0,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true),
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_backcall")),
tpl("asrfile_alibaba", "alibaba", "阿里云 DashScope 录音识别", svcCatASRFile, 1,
sf("api_key", "DashScope ApiKey", true),
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_alibackcall")),
tpl("asrfile_google", "google", "Google Cloud Speech", svcCatASRFile, 2,
sf("json", "服务账号 JSON(内容或路径)", true)),
tpl("asrfile_azure", "azure", "微软 Azure 语音", svcCatASRFile, 3,
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastasia", false)),
// 多媒体大模型(能识别图像,用于带图会议总结;一个全能模型服务可同时勾选「文本大模型」)
tpl("llmv_openai", "openai", "OpenAI(GPT-4o 视觉)", svcCatLLMVision, 0,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false)),
tpl("llmv_qwen", "qwen", "通义千问 VL(阿里)", svcCatLLMVision, 1,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 qwen-vl-max", false), sf("base_url", "接口地址(可选)", false)),
tpl("llmv_gemini", "google", "Gemini(Google)", svcCatLLMVision, 2,
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gemini-2.5-flash", false), sf("endpoint", "自定义 Endpoint(可选)", false)),
tpl("llmv_doubao", "doubao", "豆包视觉(火山引擎)", svcCatLLMVision, 3,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
// STS 端到端语音对话
tpl("sts_volcengine", "volcengine", "火山引擎 STS", svcCatSTS, 0,
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
@ -222,17 +274,26 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("storage_tencent_cos", "tencent_cos", "腾讯云 COS", svcCatStorage, 1,
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), sf("bucket", "存储桶", false), sf("region", "地域,如 ap-guangzhou", false)),
}
// 语音输出类服务(TTS/AST/STS)统一前置「支持语言 + 默认语言」两字段:
// languages(langs) 存逗号分隔 BCP-47(如 zh-CN,en-US),default_lang(lang) 存单个码。
// 前置语言字段(languages 存逗号分隔 BCP-47 如 zh-CN,en-US;default_lang 存单个码):
// - 音频输出类(TTS/AST/STS):支持语言 + 默认语言;
// - 录音文件识别(ASRFile):只需「支持语言」,用于会议记录选路过滤,无默认语言概念。
// 不绑定单一语言,音色/语速等仍由各服务商自有字段决定,运行时按请求语言选择。
for i := range list {
if !audioOutputCats[list[i].Category] {
cat := list[i].Category
var langFields []SvcField
switch {
case audioOutputCats[cat]:
langFields = []SvcField{
{Key: "languages", Description: "支持的语言(可多选)", Type: "langs"},
{Key: "default_lang", Description: "默认语言", Type: "lang"},
}
case asrLangCats[cat]:
langFields = []SvcField{
{Key: "languages", Description: "支持识别的语言(可多选,选路按此过滤)", Type: "langs", DefValue: asrFileDefaultLangs[list[i].Provider]},
}
default:
continue
}
langFields := []SvcField{
{Key: "languages", Description: "支持的语言(可多选)", Type: "langs"},
{Key: "default_lang", Description: "默认语言", Type: "lang"},
}
list[i].Fields = append(langFields, list[i].Fields...)
for j := range list[i].Fields {
list[i].Fields[j].Sort = int32(j)

62
apps/services/modules/console/model_echomeet_orch.go

@ -0,0 +1,62 @@
package console
// 会议记录服务编排 (echomeet orchestration)。
//
// 与旧的 echomeet_svc_config(每应用一份、在业务库、自己存凭据)不同:
// 编排**不存凭据**,只引用「第三方服务配置」(svc_config) 里配好的服务 id,按
// 作用域 = (app_name, region) 分层:app_name='' 且 region=0 是「全局默认」兜底,
// 越具体越优先。凭据、区域覆盖都由 svc_config / svc_region_override 那一套负责。
//
// 三类(Kind)各自映射到服务池的服务类别,管理端选服务时按此过滤:
// 识别 → svcCatASRFile(8);翻译 → svcCatMT(3);总结 → svcCatLLM(5) + svcCatLLMVision(9)。
// 识别/翻译按 Priority 升序选路,总结按 IsDefault + ClientVisible + 应用级开关。
//
// 存 console 共享库(postgres),与 svc_config 同库,便于 join 服务名/解析下发。
//
// ⚠️ 回调地址契约(下发时必须遵守):录音文件识别服务的 callback_path 字段存的是**相对路径**
// (如 /api/home/echomeet_backcall),不是完整 URL——同一个服务会被所有应用共用,而各应用/部署
// 的对外地址不同,写死完整 URL 只对一个应用有效。
// 完整回调 = 该部署的对外地址(AppRegistry.BaseUrl) + callback_path,**由 console 在下发时拼接**
// (console 才有注册表里的 base_url;业务服务未必知道自己的对外地址,尤其多区域部署)。
// 下发解析顺序:(app,region) → (app,0) → ('',region) → ('',0) 全局默认兜底,越具体越优先。
// 编排类别(会议记录三段)。
const (
echoKindASR int32 = 1 // 识别(录音文件转写)
echoKindMT int32 = 2 // 翻译
echoKindLLM int32 = 3 // 总结(大模型)
)
// echoKindSvcCats 各编排类别对应的服务池类别(前端选服务时按此过滤,后端校验 svc_id 归属用)。
var echoKindSvcCats = map[int32][]int32{
echoKindASR: {svcCatASRFile},
echoKindMT: {svcCatMT},
echoKindLLM: {svcCatLLM, svcCatLLMVision},
}
// EchoOrchestration 一行 = 某作用域(app_name,region) 下某类别选中的一个服务引用 + 排序/标记。
// 唯一:(app_name, region, kind, svc_id)。svc_id 引用 ThirdSvcConfig.Id(同作用域解析凭据)。
type EchoOrchestration struct {
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"`
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_echo_orch,priority:1" json:"app_name"` // ''=全局默认兜底
Region int32 `gorm:"column:region;uniqueIndex:uidx_echo_orch,priority:2" json:"region"` // 0=全区域兜底,>0=特定区域
Kind int32 `gorm:"column:kind;uniqueIndex:uidx_echo_orch,priority:3" json:"kind"` // 1识别/2翻译/3总结
SvcId string `gorm:"column:svc_id;size:64;uniqueIndex:uidx_echo_orch,priority:4" json:"svc_id"` // 引用 svc_config 的服务 id
Priority int32 `gorm:"column:priority" json:"priority"` // 同类内升序,小=优先
IsDefault bool `gorm:"column:is_default" json:"is_default"` // 总结用:默认模型,同作用域内至多一条
ClientVisible bool `gorm:"column:client_visible" json:"client_visible"` // 总结用:是否允许客户端选择
CostHint float64 `gorm:"column:cost_hint" json:"cost_hint"` // 参考单价,仅排序/展示;识别元/时、翻译元/百万字符、总结元/百万token
Enabled bool `gorm:"column:enabled" json:"enabled"`
Createtime int64 `gorm:"column:createtime" json:"createtime"`
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}
// EchoOrchSetting 会议记录编排的作用域级开关。每 (app_name, region) 一行。
type EchoOrchSetting struct {
RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"`
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_echo_orch_set,priority:1" json:"app_name"`
Region int32 `gorm:"column:region;uniqueIndex:uidx_echo_orch_set,priority:2" json:"region"`
// LLMClientChoice 是否允许客户端指定总结模型。false=强制默认模型;true=客户端可在 ClientVisible 且 Enabled 里选。
LLMClientChoice bool `gorm:"column:llm_client_choice" json:"llm_client_choice"`
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}

21
apps/services/modules/console/server.go

@ -118,11 +118,6 @@ func (this *serverComp) routes(eng *gin.Engine) {
apps.POST("/modulecfg/del", this.moduleCfgDel)
apps.POST("/modulecfg/reset", this.moduleCfgReset) // 还原为业务服务配置文件(yaml)的初始值
// 会议记录服务配置(识别/翻译/总结优先级 + 凭据 + 识别语言 + 总结模型清单;存应用业务库)。
apps.POST("/echomeetsvc/catalog", this.echoSvcCatalog)
apps.POST("/echomeetsvc/list", this.echoSvcList)
apps.POST("/echomeetsvc/save", this.echoSvcSave)
apps.POST("/echomeetsvc/del", this.echoSvcDel)
apps.POST("/echomeetsvc/reorder", this.echoSvcReorder)
// 部署环境(区域)管理:与应用注册同权限(超管 + 管理员)。
apps.POST("/envs/list", this.envList)
apps.POST("/envs/test", this.envTest)
@ -381,6 +376,22 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_synccalltranslatepairs":
this.syncCallTranslatePairs(c)
return
// 会议记录服务编排:引用 svc_config 的服务,按 app_name+region 分层(app_name=''&region=0 为全局默认)。
case "api_getechoorch":
this.getEchoOrch(c)
return
case "api_saveechoorch":
this.saveEchoOrch(c)
return
case "api_delechoorch":
this.delEchoOrch(c)
return
case "api_reorderechoorch":
this.reorderEchoOrch(c)
return
case "api_saveechoorchsetting":
this.saveEchoOrchSetting(c)
return
// 邮件下发(生产设备码清单):multipart 表单 + 全局 email 系统,与选中应用无关。
case "api_sendsmtpemail":
this.sendSmtpEmail(c)

19
apps/services/swagger/docs.go

@ -1,5 +1,5 @@
// Package swagger Code generated by swaggo/swag. DO NOT EDIT
package swagger
// Package swg2 Code generated by swaggo/swag. DO NOT EDIT
package swg2
import "github.com/swaggo/swag"
@ -2094,7 +2094,7 @@ const docTemplate = `{
}
}
},
"/api/home/user_getappconfigv2_v2": {
"/api/home/user_getappconfig_v2": {
"post": {
"security": [
{
@ -2125,7 +2125,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "成功返回",
"description": "成功返回(整体响应体经 AES-CBC 加密,响应头 X-Encrypted:1)",
"schema": {
"allOf": [
{
@ -4119,6 +4119,10 @@ const docTemplate = `{
"description": "@go_tags(` + "`" + `gorm:\"id:50;primaryKey;autoIncrement json:\"id\"` + "`" + `) 索引id",
"type": "integer"
},
"imageurls": {
"description": "@go_tags(` + "`" + `gorm:\"imageurls\" json:\"imageurls\"` + "`" + `) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结",
"type": "string"
},
"isdistinguishspeaker": {
"description": "@go_tags(` + "`" + `gorm:\"isdistinguishspeaker\" json:\"isdistinguishspeaker\"` + "`" + `) 是否区分说话人",
"type": "boolean"
@ -5096,6 +5100,13 @@ const docTemplate = `{
"description": "@go_tags(` + "`" + `json:\"filepath\"` + "`" + `) //音频文件路径",
"type": "string"
},
"imageurls": {
"description": "@go_tags(` + "`" + `json:\"imageurls\"` + "`" + `) //注释图片url列表(作为多模态内容参与AI总结)",
"type": "array",
"items": {
"type": "string"
}
},
"original": {
"description": "@go_tags(` + "`" + `gorm:\"original\" json:\"original\"` + "`" + `) 听写内容",
"type": "array",

15
apps/services/swagger/swagger.json

@ -2086,7 +2086,7 @@
}
}
},
"/api/home/user_getappconfigv2_v2": {
"/api/home/user_getappconfig_v2": {
"post": {
"security": [
{
@ -2117,7 +2117,7 @@
],
"responses": {
"200": {
"description": "成功返回",
"description": "成功返回(整体响应体经 AES-CBC 加密,响应头 X-Encrypted:1)",
"schema": {
"allOf": [
{
@ -4111,6 +4111,10 @@
"description": "@go_tags(`gorm:\"id:50;primaryKey;autoIncrement json:\"id\"`) 索引id",
"type": "integer"
},
"imageurls": {
"description": "@go_tags(`gorm:\"imageurls\" json:\"imageurls\"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结",
"type": "string"
},
"isdistinguishspeaker": {
"description": "@go_tags(`gorm:\"isdistinguishspeaker\" json:\"isdistinguishspeaker\"`) 是否区分说话人",
"type": "boolean"
@ -5088,6 +5092,13 @@
"description": "@go_tags(`json:\"filepath\"`) //音频文件路径",
"type": "string"
},
"imageurls": {
"description": "@go_tags(`json:\"imageurls\"`) //注释图片url列表(作为多模态内容参与AI总结)",
"type": "array",
"items": {
"type": "string"
}
},
"original": {
"description": "@go_tags(`gorm:\"original\" json:\"original\"`) 听写内容",
"type": "array",

12
apps/services/swagger/swagger.yaml

@ -726,6 +726,9 @@ definitions:
id:
description: '@go_tags(`gorm:"id:50;primaryKey;autoIncrement json:"id"`) 索引id'
type: integer
imageurls:
description: '@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结'
type: string
isdistinguishspeaker:
description: '@go_tags(`gorm:"isdistinguishspeaker" json:"isdistinguishspeaker"`)
是否区分说话人'
@ -1463,6 +1466,11 @@ definitions:
filepath:
description: '@go_tags(`json:"filepath"`) //音频文件路径'
type: string
imageurls:
description: '@go_tags(`json:"imageurls"`) //注释图片url列表(作为多模态内容参与AI总结)'
items:
type: string
type: array
original:
description: '@go_tags(`gorm:"original" json:"original"`) 听写内容'
items:
@ -4256,7 +4264,7 @@ paths:
summary: 获取应用配置
tags:
- User
/api/home/user_getappconfigv2_v2:
/api/home/user_getappconfig_v2:
post:
consumes:
- application/json
@ -4272,7 +4280,7 @@ paths:
- application/json
responses:
"200":
description: 成功返回
description: 成功返回(整体响应体经 AES-CBC 加密,响应头 X-Encrypted:1)
schema:
allOf:
- $ref: '#/definitions/comm.HttpResult'

2
deploy/voitrans/confs/gateway.yaml.example

@ -24,7 +24,7 @@ modules:
- user_sgin
- api_getuser
- user_getappconfig
- user_getappconfigv2_v2
- user_getappconfig_v2 #加密版应用配置(apiV2Comp.GetAppConfig,路由=user_<方法名小写>_v2);改方法名要同步这里,否则未登录取配置会被拒
- user_getchannelapp
- user_verification
- public_info

Loading…
Cancel
Save