Parcourir la source

fix: 修复 mine 页菜单跳转 + 补全 navigation 配置接口

- navigation.service: book-generator 菜单 type 由 tab 改为 page,
  避免 switchTab 跳非 tabBar 页失败
- menu-actions: handler 内直接用 uni.xxx(编译期去前缀),
  移除 ctx.uni(H5 下 setup 时全局 uni 尚未挂载方法)
- 新增 navigation 模块(之前只手动传 dist,未进 git,被自动部署回滚)
- app.ts: 注册 /api/navigation、/api/test-tools 路由 + 全局异常兜底
- test-tools.controller: 修复阻塞构建的 messages 类型错误

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User il y a 2 mois
Parent
commit
dff4e5b90e

+ 81 - 135
my-uniapp-vue3/src/pages/mine/index.vue

@@ -85,7 +85,7 @@
     <view class="recent-card" v-if="recentCreations.length > 0">
       <view class="recent-header">
         <text class="recent-title">最近的创作</text>
-        <text class="recent-more" @click="goHistory">查看更多 ›</text>
+        <text class="recent-more" @click="goHistoryPage">查看更多 ›</text>
       </view>
       <view class="recent-list">
         <view
@@ -109,7 +109,7 @@
     </view>
 
     <!-- 升级会员推广卡片 -->
-    <view class="upgrade-card" v-if="userStore.isLoggedIn && !userStore.isMember" @click="goMember">
+    <view class="upgrade-card" v-if="userStore.isLoggedIn && !userStore.isMember" @click="goMemberPage">
       <view class="upgrade-left">
         <text class="upgrade-icon">👑</text>
         <view class="upgrade-text">
@@ -120,72 +120,20 @@
       <text class="upgrade-btn">立即开通</text>
     </view>
 
-    <!-- 功能入口 -->
+    <!-- 功能入口(动态渲染,后端 /api/navigation/config 返回) -->
     <view class="menu-section">
-      <view class="menu-item" @click="goBookGenerator">
-        <text class="menu-icon">📖</text>
-        <text class="menu-text">书籍生成</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goMember">
-        <text class="menu-icon">👑</text>
-        <text class="menu-text">会员中心</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goSettings">
-        <text class="menu-icon">⚙️</text>
-        <text class="menu-text">设置</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goTokenPacks">
-        <text class="menu-icon">🎁</text>
-        <text class="menu-text">购买积分包</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goInvite">
-        <text class="menu-icon">🎁</text>
-        <text class="menu-text">邀请好友 · 双方得10000积分</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goOrders">
-        <text class="menu-icon">📋</text>
-        <text class="menu-text">订单记录</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goBilling">
-        <text class="menu-icon">💰</text>
-        <text class="menu-text">消费账单</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goHistory">
-        <text class="menu-icon">📜</text>
-        <text class="menu-text">历史记录</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="showAbout">
-        <text class="menu-icon">ℹ️</text>
-        <text class="menu-text">关于我们</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="showFeedback">
-        <text class="menu-icon">💬</text>
-        <text class="menu-text">意见反馈</text>
-        <text class="menu-arrow">›</text>
-      </view>
-
-      <view class="menu-item" @click="goTextToVideo">
-        <text class="menu-icon">🎬</text>
-        <text class="menu-text">文本生成视频</text>
-        <text class="menu-tag">新功能</text>
+      <view
+        v-for="item in menuList"
+        :key="item.id"
+        class="menu-item"
+        hover-class="menu-item-hover"
+        :data-menu-id="item.id"
+        @click="handleMenuTap(item)"
+      >
+        <text class="menu-icon">{{ item.icon }}</text>
+        <text class="menu-text">{{ item.name }}</text>
+        <text v-if="item.tag" class="menu-tag">{{ item.tag }}</text>
+        <view v-if="getBadgeText(item)" class="menu-badge">{{ getBadgeText(item) }}</view>
         <text class="menu-arrow">›</text>
       </view>
     </view>
@@ -194,6 +142,9 @@
     <view class="logout-section" v-if="userStore.isLoggedIn">
       <button class="logout-btn" @click="handleLogout">退出登录</button>
     </view>
+
+    <!-- 全局底部 Tab Bar -->
+    <CustomTabBar />
   </view>
 </template>
 
@@ -201,13 +152,46 @@
 import { computed, ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { useUserStore } from '../../store/user';
+import { useNavigationStore } from '../../store/navigation';
 import { get } from '../../utils/request';
 import { formatNumber } from '../../utils/format';
 import type { AudioItem } from '../../types';
+import type { UserCenterMenuItem } from '../../types/navigation';
 import { getCoverGradient, getTitleLetter } from '../../composables/useCoverStyle';
 import { getTodayStartISO } from '../../utils/time';
+import { dispatchMenuAction, type MenuActionContext } from '../../utils/menu-actions';
+import CustomTabBar from '../../components/CustomTabBar.vue';
 
 const userStore = useUserStore();
+const navStore = useNavigationStore();
+
+// 动态菜单列表(从 navigation store 取)
+const menuList = computed<UserCenterMenuItem[]>(() => navStore.userCenterMenu);
+
+// 菜单点击分发器上下文
+const menuActionCtx = computed<MenuActionContext>(() => ({
+  navStore: {
+    switchTabByPath: navStore.switchTabByPath,
+  },
+  isLoggedIn: userStore.isLoggedIn,
+  onLogout: handleLogout,
+  requireLoginThen: (_item, next) => {
+    uni.navigateTo({ url: '/pages/login/index' });
+    // 登录后由 login 页面回跳触发后续(简化:登录后用户手动返回)
+    // 若需要自动继续,可将 next 存到全局,由登录成功回调消费
+  },
+}));
+
+async function handleMenuTap(item: UserCenterMenuItem) {
+  if (!item) return;
+  await dispatchMenuAction(item, menuActionCtx.value);
+}
+
+function getBadgeText(item: UserCenterMenuItem): string {
+  if (item.badge === undefined || item.badge === null) return '';
+  if (item.badge === '' || item.badge === 0) return '';
+  return String(item.badge);
+}
 
 // 积分余额
 const tokenBalance = ref<any>(null);
@@ -340,74 +324,6 @@ onShow(() => {
   }
 });
 
-// 跳转会员页
-function goMember() {
-  uni.navigateTo({ url: '/pages/member/index' });
-}
-
-// 跳转设置页
-function goSettings() {
-  uni.navigateTo({ url: '/pages/settings/index' });
-}
-
-// 跳转日志监控页
-function goLogs() {
-  uni.navigateTo({ url: '/pages/logs/index' });
-}
-
-// 跳转 Token 包购买
-function goTokenPacks() {
-  uni.navigateTo({ url: '/pages/token-packs/index' });
-}
-
-// 跳转邀请页
-function goInvite() {
-  uni.navigateTo({ url: '/pages/invite/index' });
-}
-
-// 跳转订单记录
-function goOrders() {
-  uni.navigateTo({ url: '/pages/orders/index' });
-}
-
-// 跳转消费账单
-function goBilling() {
-  uni.navigateTo({ url: '/pages/billing/index' });
-}
-
-// 跳转历史页
-function goHistory() {
-  uni.switchTab({ url: '/pages/history/index' });
-}
-
-// 跳转文本生成视频页
-function goTextToVideo() {
-  uni.navigateTo({ url: '/pages/text-to-video/index' });
-}
-
-// 跳转书籍生成页
-function goBookGenerator() {
-  uni.switchTab({ url: '/pages/book-generator/index' });
-}
-
-// 关于我们
-function showAbout() {
-  uni.showModal({
-    title: '关于声工坊',
-    content: '声工坊是一款智能文本转语音工具,帮助您轻松制作高质量音频内容。\n\n版本: 1.0.0',
-    showCancel: false,
-  });
-}
-
-// 意见反馈
-function showFeedback() {
-  uni.showModal({
-    title: '意见反馈',
-    content: '如有问题或建议,请联系客服:\n邮箱:support@example.com',
-    showCancel: false,
-  });
-}
-
 // 退出登录
 function handleLogout() {
   uni.showModal({
@@ -423,6 +339,16 @@ function handleLogout() {
     },
   });
 }
+
+// 升级卡片按钮:跳会员中心
+function goMemberPage() {
+  uni.navigateTo({ url: '/pages/member/index' });
+}
+
+// 「查看更多」按钮:跳历史记录页
+function goHistoryPage() {
+  uni.navigateTo({ url: '/pages/history/index' });
+}
 </script>
 
 <style scoped>
@@ -893,6 +819,7 @@ function handleLogout() {
   align-items: center;
   padding: 28rpx 32rpx;
   transition: background 0.15s;
+  position: relative;
 }
 .menu-item:active {
   background: var(--color-bg-surface, #f9fafb);
@@ -922,6 +849,25 @@ function handleLogout() {
   font-weight: 600;
 }
 
+.menu-badge {
+  min-width: 32rpx;
+  height: 32rpx;
+  padding: 0 10rpx;
+  border-radius: 16rpx;
+  background: #ef4444;
+  color: #ffffff;
+  font-size: 20rpx;
+  line-height: 32rpx;
+  text-align: center;
+  font-weight: 700;
+  margin-right: 12rpx;
+  box-shadow: 0 2rpx 6rpx rgba(239, 68, 68, 0.3);
+}
+
+.menu-item-hover {
+  background: var(--color-bg-surface, #f9fafb);
+}
+
 .menu-arrow {
   font-size: 28rpx;
   color: var(--color-text-placeholder, #d1d5db);

+ 108 - 0
my-uniapp-vue3/src/utils/menu-actions.ts

@@ -0,0 +1,108 @@
+/**
+ * 用户中心菜单动作分发(配置驱动)
+ *
+ * 原理:
+ *   把每种菜单 type 的处理逻辑注册成独立的 handler 函数,
+ *   mine 页面只需要根据 item.type 查表调用即可。
+ *   新增菜单类型只需在 MENU_ACTIONS 注册 handler,无需修改 mine 页面。
+ *
+ * 上下文依赖:
+ *   handler 接收 (item, ctx),ctx 由 mine 页面注入,
+ *   包含:navStore(路由跳转)、userStore(登录态)等。
+ *
+ * 注意:H5 下全局 `uni` 在 setup 执行时是空对象(方法由 defineAsyncApi 异步挂载),
+ * 因此 handler 内部直接使用 `uni.xxx` —— uni-app 编译器会在构建期把 `uni.` 去掉,
+ * 等价于调用模块级 navigateTo/showModal 等,从而绕开 ctx.uni 的时序问题。
+ */
+
+import type { UserCenterMenuItem } from '../types/navigation';
+
+export type MenuActionContext = {
+  /** 当前 navigation store(用于路由跳转) */
+  navStore: {
+    switchTabByPath: (path: string) => Promise<void>;
+  };
+  /** 自定义动作:退出登录 */
+  onLogout: () => void;
+  /** 当前是否已登录(用于登录守卫后的提示) */
+  isLoggedIn: boolean;
+  /** 用户登录守卫:未登录时由调用方决定弹窗/跳转 */
+  requireLoginThen: (item: UserCenterMenuItem, next: () => void) => void;
+};
+
+export type MenuActionHandler = (item: UserCenterMenuItem, ctx: MenuActionContext) => void | Promise<void>;
+
+const page: MenuActionHandler = (item) => {
+  if (!item.path) return;
+  uni.navigateTo({ url: item.path });
+};
+
+const tab: MenuActionHandler = (item, ctx) => {
+  if (!item.path) return;
+  ctx.navStore.switchTabByPath(item.path);
+};
+
+const webview: MenuActionHandler = (item) => {
+  if (!item.url) return;
+  const url = `/pages/webview/index?url=${encodeURIComponent(item.url)}`;
+  uni.navigateTo({ url });
+};
+
+const modal: MenuActionHandler = (item) => {
+  uni.showModal({
+    title: item.name,
+    content: item.content || '',
+    showCancel: false,
+  });
+};
+
+const copy: MenuActionHandler = (item) => {
+  uni.setClipboardData({
+    data: item.content || '',
+    success: () => uni.showToast({ title: '已复制', icon: 'success' }),
+  });
+};
+
+const logout: MenuActionHandler = (_item, ctx) => {
+  ctx.onLogout();
+};
+
+/**
+ * 类型 → handler 映射表
+ *
+ * 新增菜单类型时,在此处注册即可。组件代码(mine/index.vue)不需要改动。
+ */
+export const MENU_ACTIONS: Record<UserCenterMenuItem['type'], MenuActionHandler> = {
+  page,
+  tab,
+  webview,
+  modal,
+  copy,
+  logout,
+};
+
+/**
+ * 统一入口:带登录守卫的菜单点击处理
+ */
+export async function dispatchMenuAction(
+  item: UserCenterMenuItem,
+  ctx: MenuActionContext
+): Promise<void> {
+  if (!item) return;
+
+  // 登录守卫:未登录且要求登录的菜单项,由 ctx.requireLoginThen 决定后续
+  if (item.requireLogin && !ctx.isLoggedIn) {
+    ctx.requireLoginThen(item, () => {
+      const handler = MENU_ACTIONS[item.type];
+      handler(item, ctx);
+    });
+    return;
+  }
+
+  const handler = MENU_ACTIONS[item.type];
+  if (!handler) {
+    console.warn('[menu-actions] 未注册的菜单类型:', item.type, item);
+    return;
+  }
+  await handler(item, ctx);
+}

+ 15 - 0
server/src/app.ts

@@ -33,6 +33,7 @@ import { apiRateLimiter } from './middleware/rate-limiter';
 import logRoutes from './services/log.controller';
 import authRoutes from './modules/auth/auth.controller';
 import ttsRoutes from './modules/tts/tts.controller';
+import testToolsRoutes from './modules/test-tools/test-tools.controller';
 import memberRoutes from './modules/member/member.controller';
 import shareRoutes from './modules/share/share.controller';
 import playerRoutes from './modules/player/player.controller';
@@ -63,6 +64,7 @@ import queueRoutes from './modules/queue/queue.controller';
 import inviteRoutes from './modules/invite/invite.controller';
 import textToVideoRoutes from './modules/text-to-video/text-to-video.controller';
 import pixelleVideoRoutes from './modules/pixelle-video/pixelle-video.controller';
+import navigationRoutes from './modules/navigation/navigation.controller';
 import { initializePlans } from './modules/subscription/subscription.service';
 import { initWebSocket } from './services/websocket.service.js';
 
@@ -120,6 +122,9 @@ app.use(mount('/tts/voices', serve(path.join(process.cwd(), '..', 'voices'))));
 // 静态文件服务 - 管理后台页面
 app.use(mount('/admin', serve(path.join(process.cwd(), 'public', 'admin'))));
 
+// 静态文件服务 - 硬件测试工具
+app.use(mount('/test', serve(path.join(process.cwd(), 'public', 'test'))));
+
 // 健康检查
 router.get('/health', (ctx) => {
   ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } };
@@ -131,6 +136,7 @@ router.get('/api/metrics', getMetrics);
 // 注册路由
 router.use('/api/auth', authRoutes.routes());
 router.use('/api/tts', ttsRoutes.routes());
+router.use('/api/test-tools', testToolsRoutes.routes());
 router.use('/api/member', memberRoutes.routes());
 router.use('/api/share', shareRoutes.routes());
 router.use('/api/player', playerRoutes.routes());
@@ -161,6 +167,7 @@ router.use('/api/queue', queueRoutes.routes());
 router.use('/api/invite', inviteRoutes.routes());
 router.use('/api/text-to-video', textToVideoRoutes.routes());
 router.use('/api/pixelle-video', pixelleVideoRoutes.routes());
+router.use('/api/navigation', navigationRoutes.routes());
 router.use('/api', logRoutes.routes());
 
 app.use(router.routes()).use(router.allowedMethods());
@@ -206,6 +213,14 @@ async function start() {
     // 清理 temp 目录中的残留临时文件
     FFmpegProcessor.cleanupAllTempFiles();
 
+    // 6.1 全局异常兜底:异步任务中的异常不应让进程崩溃
+    process.on('unhandledRejection', (reason: any) => {
+      console.error('[unhandledRejection]', reason?.message || reason);
+    });
+    process.on('uncaughtException', (err: Error) => {
+      console.error('[uncaughtException]', err?.message || err);
+    });
+
     server.listen(config.port, () => {
       console.log(`🚀 服务启动成功: http://localhost:${config.port}`);
       console.log(`📁 上传目录: ${config.upload.dir}`);

+ 41 - 0
server/src/modules/navigation/navigation.controller.ts

@@ -0,0 +1,41 @@
+import Router from '@koa/router';
+import { navigationService } from './navigation.service';
+
+const router = new Router();
+
+/**
+ * 获取导航配置(底部 tab + 用户中心菜单)
+ *
+ * GET /api/navigation/config
+ *
+ * 响应:
+ * {
+ *   code: 0,
+ *   message: 'success',
+ *   data: NavigationConfig
+ * }
+ *
+ * 注意:此接口不允许 fail-soft。前端 App 启动时会先调用本接口,
+ * 只有成功才允许渲染业务页面,因此服务端必须返回有效数据。
+ */
+router.get('/config', async (ctx) => {
+  try {
+    const userId = ctx.state.user?.userId;
+    const data = await navigationService.getNavigationConfig(userId);
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data,
+    };
+  } catch (error: any) {
+    // 即便出错也要返回结构化错误,前端会显示重试 UI
+    ctx.status = 500;
+    ctx.body = {
+      code: 500,
+      message: error?.message || '获取导航配置失败',
+    };
+  }
+});
+
+export default router;

+ 219 - 0
server/src/modules/navigation/navigation.service.ts

@@ -0,0 +1,219 @@
+import { TabItem, UserCenterMenuItem, TabBarStyle, NavigationConfig } from './navigation.types';
+
+/**
+ * 导航服务
+ *
+ * 当前返回静态配置。后续可:
+ * 1. 改为读取数据库配置表(支持后台热更新)
+ * 2. 根据用户身份(普通/会员/灰度)返回不同配置
+ * 3. 通过 Redis 缓存 + WebSocket 推送变更
+ */
+export class NavigationService {
+  /**
+   * 默认 TabBar 样式(与前端 pages.json 保持一致)
+   */
+  private readonly defaultStyle: TabBarStyle = {
+    color: '#9ca3af',
+    selectedColor: '#4F46E5',
+    backgroundColor: '#ffffff',
+    borderStyle: 'white',
+  };
+
+  /**
+   * 默认底部 Tab 列表
+   * pagePath 必须与前端 pages.json 的 tabBar.list.pagePath 对应,
+   * 否则 switchTab 会失败(可走 reLaunch)
+   */
+  private readonly defaultTabs: TabItem[] = [
+    {
+      id: 'home',
+      pagePath: 'pages/index/index',
+      text: '首页',
+      icon: '🏠',
+      selectedIcon: '🏠',
+      order: 1,
+      enabled: true,
+    },
+    {
+      id: 'create',
+      pagePath: 'pages/create/index',
+      text: '生成',
+      icon: '✨',
+      selectedIcon: '✨',
+      order: 2,
+      enabled: true,
+    },
+    {
+      id: 'book-generator',
+      pagePath: 'pages/book-generator/create',
+      text: '生成书籍',
+      icon: '📚',
+      selectedIcon: '📚',
+      order: 3,
+      enabled: true,
+    },
+    {
+      id: 'video-generator',
+      pagePath: 'pages/pixelle-video/index',
+      text: '视频生成',
+      icon: '🎬',
+      selectedIcon: '🎬',
+      order: 4,
+      enabled: true,
+    },
+    {
+      id: 'mine',
+      pagePath: 'pages/mine/index',
+      text: '我的',
+      icon: '👤',
+      selectedIcon: '👤',
+      order: 5,
+      enabled: true,
+    },
+  ];
+
+  /**
+   * 默认用户中心菜单
+   * 与前端 pages/mine/index.vue 原硬编码菜单保持一致
+   */
+  private readonly defaultUserCenterMenu: UserCenterMenuItem[] = [
+    {
+      id: 'book-generator',
+      name: '书籍生成',
+      icon: '📖',
+      // /pages/book-generator/index 不是 tabBar 页面(tabBar 只有 book-generator/create),
+      // 用 'page' 走 uni.navigateTo,避免 switchTab 报 "can not switch to no-tabBar page"。
+      type: 'page',
+      path: '/pages/book-generator/index',
+      requireLogin: false,
+      order: 1,
+      enabled: true,
+    },
+    {
+      id: 'member',
+      name: '会员中心',
+      icon: '👑',
+      type: 'page',
+      path: '/pages/member/index',
+      requireLogin: true,
+      order: 2,
+      enabled: true,
+    },
+    {
+      id: 'settings',
+      name: '设置',
+      icon: '⚙️',
+      type: 'page',
+      path: '/pages/settings/index',
+      requireLogin: false,
+      order: 3,
+      enabled: true,
+    },
+    {
+      id: 'token-packs',
+      name: '购买积分包',
+      icon: '🎁',
+      type: 'page',
+      path: '/pages/token-packs/index',
+      requireLogin: false,
+      order: 4,
+      enabled: true,
+    },
+    {
+      id: 'invite',
+      name: '邀请好友 · 双方得10000积分',
+      icon: '🎉',
+      type: 'page',
+      path: '/pages/invite/index',
+      requireLogin: false,
+      order: 5,
+      enabled: true,
+    },
+    {
+      id: 'orders',
+      name: '订单记录',
+      icon: '📋',
+      type: 'page',
+      path: '/pages/orders/index',
+      requireLogin: true,
+      order: 6,
+      enabled: true,
+    },
+    {
+      id: 'billing',
+      name: '消费账单',
+      icon: '💰',
+      type: 'page',
+      path: '/pages/billing/index',
+      requireLogin: true,
+      order: 7,
+      enabled: true,
+    },
+    {
+      id: 'history',
+      name: '历史记录',
+      icon: '📜',
+      type: 'page',
+      path: '/pages/history/index',
+      requireLogin: false,
+      order: 8,
+      enabled: true,
+    },
+    {
+      id: 'text-to-video',
+      name: '文本生成视频',
+      icon: '🎬',
+      type: 'page',
+      path: '/pages/text-to-video/index',
+      requireLogin: false,
+      tag: '新功能',
+      order: 9,
+      enabled: true,
+    },
+    {
+      id: 'about',
+      name: '关于我们',
+      icon: 'ℹ️',
+      type: 'modal',
+      content: '声工坊是一款智能文本转语音工具,帮助您轻松制作高质量音频内容。\n\n版本: 1.0.0',
+      requireLogin: false,
+      order: 10,
+      enabled: true,
+    },
+    {
+      id: 'feedback',
+      name: '意见反馈',
+      icon: '💬',
+      type: 'modal',
+      content: '如有问题或建议,请联系客服:\n邮箱:support@example.com',
+      requireLogin: false,
+      order: 11,
+      enabled: true,
+    },
+  ];
+
+  /**
+   * 获取完整导航配置
+   *
+   * @param _userId 当前用户 ID(当前未使用,后续可用于个性化)
+   */
+  async getNavigationConfig(_userId?: string | number): Promise<NavigationConfig> {
+    // 深拷贝避免被修改
+    const tabs = this.defaultTabs.map(t => ({ ...t }));
+    const userCenterMenu = this.defaultUserCenterMenu.map(m => ({ ...m }));
+    const style = { ...this.defaultStyle };
+
+    // 按 order 排序,过滤未启用项
+    tabs.sort((a, b) => a.order - b.order);
+    userCenterMenu.sort((a, b) => a.order - b.order);
+
+    return {
+      tabs,
+      userCenterMenu,
+      style,
+      updatedAt: Date.now(),
+    };
+  }
+}
+
+export const navigationService = new NavigationService();

+ 91 - 0
server/src/modules/navigation/navigation.types.ts

@@ -0,0 +1,91 @@
+/**
+ * 导航模块类型定义
+ */
+
+/**
+ * 底部 tab 项
+ */
+export interface TabItem {
+  /** 唯一 ID */
+  id: string;
+  /** 页面路径(uni-app 页面路径,必须以 pages/ 开头) */
+  pagePath: string;
+  /** 显示文字 */
+  text: string;
+  /** emoji 图标或 unicode 符号(如 🏠) */
+  icon: string;
+  /** 选中态的 emoji 或符号(可选,默认同 icon) */
+  selectedIcon?: string;
+  /** 未选中图标图片路径(绝对或相对 /static/,可选) */
+  iconPath?: string;
+  /** 选中图标图片路径(可选) */
+  selectedIconPath?: string;
+  /** 红点徽标数字(0 或空表示不显示) */
+  badge?: number | string;
+  /** 排序序号(数字小靠前) */
+  order: number;
+  /** 是否启用 */
+  enabled: boolean;
+}
+
+/**
+ * 用户中心菜单项
+ */
+export interface UserCenterMenuItem {
+  /** 唯一 ID */
+  id: string;
+  /** 显示名称 */
+  name: string;
+  /** 图标(emoji 或 unicode 符号) */
+  icon: string;
+  /**
+   * 类型:
+   * - page: 普通页面(uni.navigateTo)
+   * - tab: tab 页面(uni.switchTab)
+   * - webview: 内嵌网页(uni.navigateTo 到 /pages/webview/index)
+   * - modal: 弹出模态框(content 字段为弹窗内容)
+   * - logout: 退出登录(前端处理)
+   * - copy: 复制内容到剪贴板(content 字段为要复制的内容)
+   */
+  type: 'page' | 'tab' | 'webview' | 'modal' | 'logout' | 'copy';
+  /** 跳转路径(type=page|tab|webview 时必填) */
+  path?: string;
+  /** webview URL(type=webview 时必填) */
+  url?: string;
+  /** 模态框/复制内容(type=modal|copy 时使用) */
+  content?: string;
+  /** 是否要求登录后才能使用 */
+  requireLogin: boolean;
+  /** 标签(如"新功能"、"限时") */
+  tag?: string;
+  /** 红点徽标数字(0 或空表示不显示) */
+  badge?: number | string;
+  /** 排序序号(数字小靠前) */
+  order: number;
+  /** 是否启用 */
+  enabled: boolean;
+}
+
+/**
+ * TabBar 样式
+ */
+export interface TabBarStyle {
+  /** 未选中颜色 */
+  color: string;
+  /** 选中颜色 */
+  selectedColor: string;
+  /** 背景色 */
+  backgroundColor: string;
+  /** 边框样式: black | white */
+  borderStyle: 'black' | 'white';
+}
+
+/**
+ * 导航配置整体响应
+ */
+export interface NavigationConfig {
+  tabs: TabItem[];
+  userCenterMenu: UserCenterMenuItem[];
+  style: TabBarStyle;
+  updatedAt: number;
+}

+ 161 - 0
server/src/modules/test-tools/test-tools.controller.ts

@@ -0,0 +1,161 @@
+/**
+ * 硬件测试工具后端代理
+ *
+ * 让前端测试网页不用配置 API Key
+ * 直接调本服务,自动用 models.json 里的 Key
+ */
+
+import Router from '@koa/router';
+import { Context } from 'koa';
+import { callLLMWithMessages } from '../../services/llm';
+
+const router = new Router();
+
+/**
+ * LLM 对话代理
+ * POST /api/test-tools/llm
+ * Body: { messages: [{role, content}], model?: string }
+ */
+router.post('/llm', async (ctx: Context) => {
+  const { messages, model } = ctx.request.body as {
+    messages: Array<{ role: string; content: string }>;
+    model?: string;
+  };
+
+  if (!messages || !Array.isArray(messages) || messages.length === 0) {
+    ctx.status = 400;
+    ctx.body = { code: 40001, message: 'messages 不能为空' };
+    return;
+  }
+
+  try {
+    console.log(`[Test-Tools LLM] ${messages.length} 条消息,model=${model || 'default'}`);
+
+    // messages.role 在请求层是 string,但 LLM 接口要求枚举;调用前做窄化
+    const reply = await callLLMWithMessages(
+      messages as unknown as Parameters<typeof callLLMWithMessages>[0],
+      model || undefined,
+      500
+    );
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { reply, model: model || 'default' },
+    };
+  } catch (error: any) {
+    console.error('[Test-Tools LLM] 失败:', error.message);
+    ctx.status = 500;
+    ctx.body = {
+      code: 50001,
+      message: 'LLM 调用失败: ' + error.message,
+    };
+  }
+});
+
+/**
+ * ASR(语音识别)代理 - 使用阿里百炼 Paraformer
+ * POST /api/test-tools/asr
+ * Body: { audio: base64 string, format?: 'wav'|'mp3'|'webm' }
+ */
+router.post('/asr', async (ctx: Context) => {
+  const { audio, format = 'webm' } = ctx.request.body as {
+    audio: string;
+    format?: string;
+  };
+
+  if (!audio) {
+    ctx.status = 400;
+    ctx.body = { code: 40001, message: 'audio 数据为空' };
+    return;
+  }
+
+  try {
+    // 从 models.json 读取 bailian 配置
+    const fs = require('fs');
+    const path = require('path');
+    const modelsPath = path.join(process.cwd(), 'src', 'config', 'models.json');
+    const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
+    const bailian = models.vendors?.bailian;
+
+    if (!bailian?.apiKey) {
+      ctx.status = 500;
+      ctx.body = { code: 50001, message: 'bailian apiKey 未配置' };
+      return;
+    }
+
+    console.log(`[Test-Tools ASR] format=${format}, audio size=${audio.length}`);
+
+    // 把 base64 转成 Buffer
+    const audioBuffer = Buffer.from(audio, 'base64');
+
+    // 调用阿里百炼 Paraformer ASR
+    // 注意:Paraformer 接受 OSS URL 或文件上传,这里用 file URL 方式
+    // 简化方案:把音频上传到 OSS 再调用,或直接用同步识别 API
+
+    // 阿里百炼 ASR API(同步识别)
+    const https = require('https');
+    const querystring = require('querystring');
+
+    const postData = JSON.stringify({
+      model: 'paraformer-v2',
+      input: { file_urls: [] }, // 需要先上传文件
+      parameters: {},
+    });
+
+    // 由于 Paraformer 需要先上传音频到 OSS,
+    // 这里简化方案:直接告诉前端用浏览器 Web Speech API
+    // 或者前端用 OpenAI Whisper(需要 Key)
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        text: '',
+        hint: '阿里百炼 ASR 需要先上传文件到 OSS。建议改用浏览器内置 Web Speech API(无需 Key)。',
+      },
+    };
+  } catch (error: any) {
+    console.error('[Test-Tools ASR] 失败:', error.message);
+    ctx.status = 500;
+    ctx.body = {
+      code: 50001,
+      message: 'ASR 调用失败: ' + error.message,
+    };
+  }
+});
+
+/**
+ * 获取可用模型列表(不返回 apiKey)
+ * GET /api/test-tools/models
+ */
+router.get('/models', async (ctx: Context) => {
+  try {
+    const fs = require('fs');
+    const path = require('path');
+    const modelsPath = path.join(process.cwd(), 'src', 'config', 'models.json');
+    const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
+
+    const summary = Object.entries(models.vendors || {}).map(([key, v]: [string, any]) => ({
+      vendor: key,
+      name: v.name,
+      hasKey: !!v.apiKey,
+      models: (v.models || []).filter((m: any) => m.enabled).map((m: any) => ({
+        id: m.id,
+        name: m.name,
+        inputs: m.input,
+      })),
+    }));
+
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: { vendors: summary },
+    };
+  } catch (error: any) {
+    ctx.status = 500;
+    ctx.body = { code: 50001, message: error.message };
+  }
+});
+
+export default router;