SuggestionBar.vue 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. <template>
  2. <div v-if="hasSuggestions" class="ai-suggest">
  3. <div class="ai-suggest__label">{{ hasGroups ? '下一步分析方向' : '继续追问' }}</div>
  4. <!-- 有合法方向分组:每个方向一行「方向名小标签 + 该组追问 chips」。
  5. 后端保证有 directions 时其 questions 并集 == 扁平 questions(漏方向的句子会兜底进「继续分析」组),
  6. 故分组模式只渲染 groups,不再从扁平列表补漏 -->
  7. <div v-if="hasGroups" class="ai-suggest__groups">
  8. <div v-for="(group, gIdx) in groups" :key="gIdx" class="ai-suggest__group">
  9. <span class="ai-suggest__dir" :title="group.name">{{ group.name }}</span>
  10. <div class="ai-suggest__list">
  11. <button
  12. v-for="(q, qIdx) in group.questions"
  13. :key="qIdx"
  14. class="ai-suggest__chip"
  15. type="button"
  16. @click="emit('pick', q)"
  17. >
  18. {{ q }}
  19. </button>
  20. </div>
  21. </div>
  22. </div>
  23. <!-- 无方向分组(旧数据 / 分组非法):保持原平铺样式 -->
  24. <div v-else class="ai-suggest__list">
  25. <button
  26. v-for="(q, idx) in flatQuestions"
  27. :key="idx"
  28. class="ai-suggest__chip"
  29. type="button"
  30. @click="emit('pick', q)"
  31. >
  32. {{ q }}
  33. </button>
  34. </div>
  35. </div>
  36. </template>
  37. <script lang="ts" setup>
  38. /** 下一步引导建议(后端 FollowupMiddleware 产出,可能带方向分组) */
  39. import { computed } from 'vue';
  40. import type { SseSuggestionDirection } from '../api/types';
  41. /** 总展示条数上限(跨方向合并计数,与旧版平铺上限一致) */
  42. const MAX_CHIPS = 5;
  43. const props = defineProps<{
  44. /** 扁平追问列表(旧字段,后端始终下发) */
  45. questions?: string[];
  46. /** 可选方向分组;有合法分组时按方向渲染,否则回退平铺 */
  47. directions?: SseSuggestionDirection[];
  48. }>();
  49. const emit = defineEmits<{ pick: [question: string] }>();
  50. /** 清洗追问:剔除空串/非字符串并去首尾空白(避免 chip 文字带空白、点击带脏值) */
  51. function normalizeQuestions(list: unknown): string[] {
  52. if (!Array.isArray(list)) return [];
  53. return list.filter((q): q is string => typeof q === 'string' && !!q.trim()).map((q) => q.trim());
  54. }
  55. /**
  56. * 合法分组:方向名非空 + 至少一条有效追问;按总上限跨组截断。
  57. * 后端保证有 directions 时分组 questions 并集 == 扁平 questions,故分组模式不再补漏。
  58. */
  59. const groups = computed<SseSuggestionDirection[]>(() => {
  60. const result: SseSuggestionDirection[] = [];
  61. if (!Array.isArray(props.directions)) return result;
  62. let remain = MAX_CHIPS;
  63. props.directions.forEach((direction) => {
  64. if (remain <= 0) return;
  65. const name = typeof direction?.name === 'string' ? direction.name.trim() : '';
  66. const items = normalizeQuestions(direction?.questions);
  67. // 空方向名 / 空问题列表:整组丢弃(不占名额,其它合法分组照常渲染)
  68. if (!name || !items.length) return;
  69. const visible = items.slice(0, remain);
  70. result.push({ name, questions: visible });
  71. remain -= visible.length;
  72. });
  73. return result;
  74. });
  75. /** 旧版平铺列表(无合法分组时使用) */
  76. const flatQuestions = computed<string[]>(() => normalizeQuestions(props.questions).slice(0, MAX_CHIPS));
  77. const hasGroups = computed(() => groups.value.length > 0);
  78. /** 分组与平铺都为空时不渲染(父级 v-if 已兜底,这里防御组件被单独使用) */
  79. const hasSuggestions = computed(() => hasGroups.value || flatQuestions.value.length > 0);
  80. </script>
  81. <style lang="less" scoped>
  82. .ai-suggest {
  83. margin-top: var(--ai-sp-3);
  84. padding-top: var(--ai-sp-3);
  85. border-top: 1px dashed var(--ai-border);
  86. &__label {
  87. margin-bottom: var(--ai-sp-2);
  88. font-size: 11px;
  89. letter-spacing: 0.04em;
  90. text-transform: uppercase;
  91. color: var(--ai-text-2);
  92. }
  93. /* 方向分组容器:每组一行,组间用紧间距分隔 */
  94. &__groups {
  95. display: flex;
  96. flex-direction: column;
  97. gap: var(--ai-sp-2);
  98. }
  99. &__group {
  100. display: flex;
  101. align-items: flex-start;
  102. gap: var(--ai-sp-2);
  103. .ai-suggest__list {
  104. flex: 1;
  105. min-width: 0;
  106. }
  107. }
  108. /* 方向名小标签:主色淡底;超长名截断,完整名由 title 兜底,避免挤占 chips 行 */
  109. &__dir {
  110. flex: 0 0 auto;
  111. max-width: 40%;
  112. margin-top: var(--ai-sp-1);
  113. padding: 1px 8px;
  114. overflow: hidden;
  115. font-size: 11px;
  116. line-height: 17px;
  117. color: var(--ai-primary);
  118. white-space: nowrap;
  119. text-overflow: ellipsis;
  120. background: var(--ai-primary-soft);
  121. border-radius: 999px;
  122. }
  123. &__list {
  124. display: flex;
  125. flex-wrap: wrap;
  126. gap: var(--ai-sp-2);
  127. }
  128. &__chip {
  129. max-width: 100%;
  130. padding: 5px 12px;
  131. font-size: 12.5px;
  132. line-height: 1.5;
  133. text-align: left;
  134. color: var(--ai-text);
  135. background: var(--ai-bg);
  136. border: 1px solid var(--ai-border);
  137. border-radius: 999px;
  138. cursor: pointer;
  139. transition:
  140. color 0.15s ease-out,
  141. border-color 0.15s ease-out,
  142. background 0.15s ease-out;
  143. &:hover {
  144. color: var(--ai-primary);
  145. border-color: fade(@primary-color, 45%);
  146. background: var(--ai-primary-softer);
  147. }
  148. }
  149. }
  150. </style>