QuestionCard.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. <template>
  2. <div v-if="isReadonly && contentAnswers.length > 0" class="question-card-shell readonly">
  3. <div v-for="(answer, idx) in contentAnswers" :key="idx" class="question-card">
  4. <div class="qc-body">
  5. <div class="qc-question">
  6. <span v-if="contentAnswers.length > 1" class="qc-question-seq">{{ idx + 1 }}. </span>
  7. {{ answer.header }}
  8. </div>
  9. <div class="qc-options">
  10. <div v-if="answer.freeText" class="qc-option selected">
  11. <div class="qc-option-content">
  12. <span class="qc-option-text">{{ answer.freeText }}</span>
  13. </div>
  14. </div>
  15. <template v-else-if="(answer.selected || []).length > 0">
  16. <div v-for="(label, labelIdx) in (answer.selected || [])" :key="labelIdx" class="qc-option selected">
  17. <span class="qc-option-label">{{ getOptionLabel(labelIdx) }}</span>
  18. <div class="qc-option-content">
  19. <span class="qc-option-text">{{ label }}</span>
  20. </div>
  21. </div>
  22. </template>
  23. <div v-else class="qc-option readonly-empty">
  24. <div class="qc-option-content">
  25. <span class="qc-option-text">未选择</span>
  26. </div>
  27. </div>
  28. </div>
  29. </div>
  30. </div>
  31. </div>
  32. <div v-else-if="questions.length > 0" class="question-card-shell" :class="{ submitted }">
  33. <!-- Progress dots outside questions -->
  34. <div class="qc-dots-row">
  35. <div v-for="(card, cardIdx) in questions" :key="cardIdx" class="qc-dot"
  36. :class="{ active: isQuestionAnswered(cardIdx) }" @click="scrollToQuestion(cardIdx)" />
  37. </div>
  38. <div v-for="(card, cardIdx) in questions" :key="cardIdx" class="question-card"
  39. :ref="(el) => setQuestionRef(cardIdx, el)" :class="{ highlighted: highlightCardIdx === cardIdx }">
  40. <!-- Question -->
  41. <div class="qc-body">
  42. <div class="qc-question" v-if="card.question">
  43. <span v-if="questions.length > 1" class="qc-question-seq">{{ cardIdx + 1 }}. </span>
  44. <span class="qc-question-type">{{ card.multiSelect ? '(多选)' : '(单选)' }}</span>
  45. {{ card.question }}
  46. </div>
  47. <!-- Options -->
  48. <div class="qc-options">
  49. <!-- Custom single-select dropdown for >= 6 options -->
  50. <div v-if="isDropdownCard(card)" class="qc-dropdown-wrapper">
  51. <div class="qc-option qc-custom-dropdown"
  52. :class="{ selected: isDropdownSelected(cardIdx), open: openDropdowns[cardIdx] }"
  53. @click.stop="toggleDropdown(cardIdx)">
  54. <span class="qc-option-label">{{ getSelectedDropdownLabel(cardIdx) || '?' }}</span>
  55. <div class="qc-option-content">
  56. <span class="qc-option-text">{{ getSelectedDropdownText(cardIdx) || '未选择' }}</span>
  57. </div>
  58. <span class="qc-dropdown-arrow" :class="{ open: openDropdowns[cardIdx] }">▾</span>
  59. </div>
  60. <div v-show="openDropdowns[cardIdx]" class="qc-dropdown-menu" @wheel.stop>
  61. <div v-for="(opt, optIdx) in card.options" :key="optIdx"
  62. class="qc-dropdown-item" :class="{ selected: isOptionSelected(cardIdx, optIdx) }"
  63. @click.stop="selectDropdownOption(cardIdx, optIdx)">
  64. <span class="qc-dropdown-item-label">{{ getCardOptionLabel(card, optIdx) }}</span>
  65. <span class="qc-dropdown-item-text">{{ opt.label }}</span>
  66. <span v-if="opt.description" class="qc-dropdown-item-desc">{{ opt.description }}</span>
  67. </div>
  68. </div>
  69. </div>
  70. <!-- Custom multi-select dropdown for >= 6 options -->
  71. <div v-else-if="isMultiDropdownCard(card)" class="qc-dropdown-wrapper">
  72. <div class="qc-option qc-custom-dropdown"
  73. :class="{ selected: isMultiDropdownSelected(cardIdx), open: openDropdowns[cardIdx] }"
  74. @click.stop="toggleDropdown(cardIdx)">
  75. <span class="qc-option-label">{{ getMultiDropdownLabel(cardIdx) || '?' }}</span>
  76. <div class="qc-option-content">
  77. <span class="qc-option-text">{{ getMultiDropdownText(cardIdx) || '未选择' }}</span>
  78. </div>
  79. <span class="qc-dropdown-arrow" :class="{ open: openDropdowns[cardIdx] }">▾</span>
  80. </div>
  81. <div v-show="openDropdowns[cardIdx]" class="qc-dropdown-menu" @wheel.stop>
  82. <div v-for="(opt, optIdx) in card.options" :key="optIdx"
  83. class="qc-dropdown-item" :class="{ selected: isOptionSelected(cardIdx, optIdx) }"
  84. @click.stop="toggleMultiDropdownOption(cardIdx, optIdx)">
  85. <span class="qc-dropdown-check">{{ isOptionSelected(cardIdx, optIdx) ? '✓' : '' }}</span>
  86. <span class="qc-dropdown-item-text">{{ opt.label }}</span>
  87. <span v-if="opt.description" class="qc-dropdown-item-desc">{{ opt.description }}</span>
  88. </div>
  89. </div>
  90. </div>
  91. <!-- Regular buttons -->
  92. <template v-else>
  93. <!-- 输入框常驻且在最上:用于补充信息(如公司未查到时补公司名/信用代码) -->
  94. <div v-if="card.freeformInputOnTop" class="qc-option"
  95. :class="{ selected: isFreeformSelected(cardIdx) }">
  96. <span class="qc-option-label">{{ getOtherOptionLabel(card) }}</span>
  97. <div class="qc-option-content">
  98. <textarea :ref="(el) => setFreeformRef(cardIdx, el)"
  99. class="qc-freeform-input inline"
  100. :placeholder="card.freeformPlaceholder || '请输入补充信息...'"
  101. v-model="freeformTexts[cardIdx]" rows="1" @focus="handleFreeformFocus"
  102. @blur="handleFreeformBlur" @click.stop></textarea>
  103. </div>
  104. </div>
  105. <button v-for="(opt, optIdx) in card.options" :key="optIdx" type="button" class="qc-option"
  106. :class="{ selected: isOptionSelected(cardIdx, optIdx), recommended: opt.recommended }"
  107. @click="toggleOption(cardIdx, optIdx)">
  108. <span class="qc-option-label">{{ getCardOptionLabel(card, optIdx) }}</span>
  109. <div class="qc-option-content">
  110. <span class="qc-option-text">{{ opt.label }}</span>
  111. <span v-if="opt.description" class="qc-option-desc">{{ opt.description }}</span>
  112. </div>
  113. </button>
  114. <!-- "其他" freeform option(freeformInputOnTop 时输入框已在上方,不再重复渲染) -->
  115. <div v-if="!card.freeformInputOnTop && card.allowFreeformInput !== false" class="qc-option"
  116. :class="{ selected: isFreeformSelected(cardIdx) }" @click="toggleFreeform(cardIdx)">
  117. <span class="qc-option-label">{{ getOtherOptionLabel(card) }}</span>
  118. <div class="qc-option-content">
  119. <span v-if="!isFreeformSelected(cardIdx)"
  120. class="qc-option-text qc-other-text" :class="{ 'has-text': freeformTexts[cardIdx] }">{{ freeformTexts[cardIdx] || "其他" }}</span>
  121. <textarea v-if="isFreeformSelected(cardIdx)" :ref="(el) => setFreeformRef(cardIdx, el)"
  122. class="qc-freeform-input inline" :placeholder="'请输入补充信息...'" v-model="freeformTexts[cardIdx]" rows="1"
  123. @focus="handleFreeformFocus" @blur="handleFreeformBlur" @click.stop></textarea>
  124. </div>
  125. </div>
  126. </template>
  127. </div>
  128. </div>
  129. </div>
  130. <!-- Submit -->
  131. <div class="qc-footer">
  132. <div class="qc-divider" />
  133. <div class="qc-footer-row">
  134. <span v-if="unansweredCount > 0 && canSubmit" class="qc-footer-hint" @click="scrollToFirstUnanswered">还有{{ unansweredCount }}道题未填</span>
  135. <span v-else />
  136. <button type="button" class="qc-submit-btn" :disabled="!canSubmit || isSubmitting" @click="handleSubmit">
  137. {{ isSubmitting ? '提交中...' : '提交' }}
  138. </button>
  139. </div>
  140. </div>
  141. </div>
  142. </template>
  143. <script setup lang="ts">
  144. import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
  145. import { FREEFORM_INPUT_FOCUS_KEY } from "../business-assistant/freeform-focus-control";
  146. interface QuestionOption {
  147. label: string;
  148. description?: string;
  149. recommended?: boolean;
  150. }
  151. interface Question {
  152. header: string;
  153. question: string;
  154. multiSelect?: boolean;
  155. allowFreeformInput?: boolean;
  156. message?: string;
  157. /** 输入框常驻并排在最上(用于补充信息),默认 false 保持原有"其他"行为 */
  158. freeformInputOnTop?: boolean;
  159. /** 上述常驻输入框的 placeholder */
  160. freeformPlaceholder?: string;
  161. options?: QuestionOption[];
  162. }
  163. function parseQuestions(content: string): Question[] {
  164. try {
  165. const data = JSON.parse(content);
  166. return data.questions || [];
  167. } catch {
  168. // Try to unwrap <question-cards>...</question-cards>
  169. const match = String(content || "").match(/<question-cards\b[^>]*>([\s\S]*?)<\/question-cards>/i);
  170. if (match) {
  171. try {
  172. const data = JSON.parse(match[1].trim());
  173. return data.questions || [];
  174. } catch {
  175. return [];
  176. }
  177. }
  178. return [];
  179. }
  180. }
  181. const props = defineProps<{
  182. content: string;
  183. recordMode?: boolean;
  184. sessionId?: string;
  185. }>();
  186. const questions = computed(() => parseQuestions(props.content));
  187. // Selected option indices per card (for single select, store one value; for multi, store set)
  188. const selectedOptions = ref<Record<number, Set<number>>>({});
  189. // Freeform selected per card
  190. const isFreeformActive = ref<Record<number, boolean>>({});
  191. const freeformTexts = ref<Record<number, string>>({});
  192. const freeformRefs: Record<number, HTMLTextAreaElement | null> = {};
  193. const questionRefs: Record<number, HTMLElement | null> = {};
  194. const highlightCardIdx = ref(-1);
  195. let highlightTimer: ReturnType<typeof setTimeout> | null = null;
  196. const isSubmitting = ref(false);
  197. const submitted = ref(false);
  198. const openDropdowns = ref<Record<number, boolean>>({});
  199. const freeformFocusOwner = Symbol("question-card-freeform");
  200. const freeformInputFocusControl = inject(FREEFORM_INPUT_FOCUS_KEY, null);
  201. function parseAnswersFromContent(content: string) {
  202. try {
  203. const data = JSON.parse(content);
  204. return Array.isArray(data.answers) ? data.answers : null;
  205. } catch {
  206. return null;
  207. }
  208. }
  209. const contentAnswers = computed(() => parseAnswersFromContent(props.content));
  210. const isReadonly = computed(() => !!contentAnswers.value);
  211. function handleFreeformFocus() {
  212. if (submitted.value) return;
  213. freeformInputFocusControl?.focus(freeformFocusOwner);
  214. }
  215. function handleFreeformBlur() {
  216. freeformInputFocusControl?.blur(freeformFocusOwner);
  217. }
  218. function setFreeformRef(cardIdx: number, el: any) {
  219. freeformRefs[cardIdx] = el as HTMLTextAreaElement | null;
  220. }
  221. function setQuestionRef(cardIdx: number, el: any) {
  222. questionRefs[cardIdx] = el as HTMLElement | null;
  223. }
  224. function scrollToQuestion(cardIdx: number) {
  225. const el = questionRefs[cardIdx];
  226. if (!el) return;
  227. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  228. triggerHighlight(cardIdx);
  229. }
  230. function scrollToFirstUnanswered() {
  231. const idx = questions.value.findIndex((_card, cardIdx) => {
  232. const selected = selectedOptions.value[cardIdx];
  233. const hasOption = selected && selected.size > 0;
  234. const hasFreeform = isFreeformActive.value[cardIdx] && (freeformTexts.value[cardIdx] || '').trim().length > 0;
  235. return !hasOption && !hasFreeform;
  236. });
  237. if (idx >= 0) {
  238. scrollToQuestion(idx);
  239. }
  240. }
  241. function triggerHighlight(cardIdx: number) {
  242. highlightCardIdx.value = cardIdx;
  243. if (highlightTimer) clearTimeout(highlightTimer);
  244. highlightTimer = setTimeout(() => {
  245. highlightCardIdx.value = -1;
  246. highlightTimer = null;
  247. }, 1200);
  248. }
  249. const injectSubmit = inject<(answers: any[]) => void>('submitQuestionAnswers', undefined);
  250. // PC keeps its existing selection-driven input visibility behavior.
  251. const legacyFreeformInputActive = inject<{ value: boolean } | undefined>('isFreeformInputActive', undefined);
  252. watch(isFreeformActive, () => {
  253. if (!legacyFreeformInputActive) return;
  254. legacyFreeformInputActive.value = Object.values(isFreeformActive.value).some(Boolean);
  255. }, { deep: true });
  256. const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
  257. const getOptionLabel = (idx: number) => {
  258. return OPTION_LABELS[idx] || String(idx + 1);
  259. };
  260. /**
  261. * 卡片内某个选项的序号。
  262. * freeformInputOnTop 时输入框排在最上、占用 A,因此选项序号整体后移一位。
  263. */
  264. const getCardOptionLabel = (card: Question | undefined, optIdx: number) =>
  265. getOptionLabel(card?.freeformInputOnTop ? optIdx + 1 : optIdx);
  266. const getOtherOptionLabel = (card: Question) => {
  267. // 输入框常驻在最上时它自己是 A;否则跟在所有选项之后
  268. const idx = card.freeformInputOnTop ? 0 : (card.options?.length || 0);
  269. return OPTION_LABELS[idx] || '';
  270. };
  271. const isDropdownCard = (card: Question): boolean => {
  272. return !card.multiSelect && (card.options?.length || 0) >= 6;
  273. };
  274. const isMultiDropdownCard = (card: Question): boolean => {
  275. return card.multiSelect === true && (card.options?.length || 0) >= 6;
  276. };
  277. // --- Custom dropdown helpers ---
  278. function closeAllDropdowns() {
  279. openDropdowns.value = {};
  280. }
  281. function toggleDropdown(cardIdx: number) {
  282. if (submitted.value) return;
  283. const isOpen = openDropdowns.value[cardIdx];
  284. closeAllDropdowns();
  285. if (!isOpen) {
  286. openDropdowns.value = { [cardIdx]: true };
  287. }
  288. }
  289. function onDocumentClick() {
  290. closeAllDropdowns();
  291. }
  292. onMounted(() => {
  293. document.addEventListener('click', onDocumentClick);
  294. });
  295. onUnmounted(() => {
  296. handleFreeformBlur();
  297. document.removeEventListener('click', onDocumentClick);
  298. });
  299. // Single-select dropdown
  300. const isDropdownSelected = (cardIdx: number): boolean => {
  301. const selected = selectedOptions.value[cardIdx];
  302. return !!(selected && selected.size > 0);
  303. };
  304. const getSelectedDropdownLabel = (cardIdx: number): string => {
  305. const selected = selectedOptions.value[cardIdx];
  306. if (!selected || selected.size === 0) return '';
  307. const optIdx = selected.values().next().value;
  308. return getOptionLabel(optIdx);
  309. };
  310. const getSelectedDropdownText = (cardIdx: number): string => {
  311. const selected = selectedOptions.value[cardIdx];
  312. if (!selected || selected.size === 0) return '';
  313. const optIdx = selected.values().next().value;
  314. const card = questions.value[cardIdx];
  315. const opt = card?.options?.[optIdx];
  316. if (!opt) return '';
  317. return opt.description ? `${opt.label} - ${opt.description}` : opt.label;
  318. };
  319. function selectDropdownOption(cardIdx: number, optIdx: number) {
  320. if (submitted.value) return;
  321. const card = questions.value[cardIdx];
  322. if (!card) return;
  323. isFreeformActive.value[cardIdx] = false;
  324. const selected = new Set<number>();
  325. selected.add(optIdx);
  326. selectedOptions.value = { ...selectedOptions.value, [cardIdx]: selected };
  327. closeAllDropdowns();
  328. }
  329. // Multi-select dropdown
  330. const isMultiDropdownSelected = (cardIdx: number): boolean => {
  331. const selected = selectedOptions.value[cardIdx];
  332. return !!(selected && selected.size > 0);
  333. };
  334. const getMultiDropdownLabel = (cardIdx: number): string => {
  335. const selected = selectedOptions.value[cardIdx];
  336. if (!selected || selected.size === 0) return '';
  337. return String(selected.size);
  338. };
  339. const getMultiDropdownText = (cardIdx: number): string => {
  340. const selected = selectedOptions.value[cardIdx];
  341. if (!selected || selected.size === 0) return '';
  342. const card = questions.value[cardIdx];
  343. if (!card) return '';
  344. const labels: string[] = [];
  345. selected.forEach((optIdx) => {
  346. const opt = card.options?.[optIdx];
  347. if (opt) labels.push(opt.label);
  348. });
  349. return labels.join(', ');
  350. };
  351. function toggleMultiDropdownOption(cardIdx: number, optIdx: number) {
  352. if (submitted.value) return;
  353. const card = questions.value[cardIdx];
  354. if (!card) return;
  355. isFreeformActive.value[cardIdx] = false;
  356. const selected = ensureSelected(cardIdx);
  357. if (selected.has(optIdx)) {
  358. selected.delete(optIdx);
  359. } else {
  360. selected.add(optIdx);
  361. }
  362. selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set(selected) };
  363. }
  364. // --- End custom dropdown helpers ---
  365. function ensureSelected(cardIdx: number): Set<number> {
  366. if (!selectedOptions.value[cardIdx]) {
  367. selectedOptions.value[cardIdx] = new Set();
  368. }
  369. return selectedOptions.value[cardIdx];
  370. }
  371. const isOptionSelected = (cardIdx: number, optIdx: number): boolean => {
  372. return ensureSelected(cardIdx).has(optIdx);
  373. };
  374. // 输入框常驻的卡片(freeformInputOnTop)不需要点击激活,直接视为自由输入已启用
  375. const isCardFreeformAlwaysOn = (cardIdx: number): boolean =>
  376. questions.value[cardIdx]?.freeformInputOnTop === true;
  377. /**
  378. * 自由输入那一行是否算"选中"(同时决定它的边框样式、以及答案是否算作出自输入框)。
  379. *
  380. * ⚠️ 2026-09-24 修(用户报「一次应该只有一个选项是选中样式」):
  381. * 以前这里是 `常驻输入框 || 被点开` —— **常驻输入框恒为 true**,
  382. * 于是那一行永远带着选中边框,点旁边的选项也不会变。
  383. * 现在按"答案出自谁"判定:
  384. * · 常驻输入框(freeformInputOnTop):**填了内容**、且(单选时)**没有选中别的选项** → 才算选中
  385. * · 普通「其他」输入框:被点开就算选中(原行为不变)
  386. */
  387. const isFreeformSelected = (cardIdx: number): boolean => {
  388. const card = questions.value[cardIdx];
  389. if (!isCardFreeformAlwaysOn(cardIdx)) {
  390. return !!isFreeformActive.value[cardIdx];
  391. }
  392. const hasText = (freeformTexts.value[cardIdx] || '').trim().length > 0;
  393. if (!hasText) return false;
  394. if (card?.multiSelect) return true; // 多选:选项与自由输入可以同时成立
  395. const selected = selectedOptions.value[cardIdx];
  396. return !selected || selected.size === 0;
  397. };
  398. /** 自由输入是否算作"已作答":需已启用且确实填了内容 */
  399. const hasFreeformAnswer = (cardIdx: number): boolean =>
  400. isFreeformSelected(cardIdx) && (freeformTexts.value[cardIdx] || '').trim().length > 0;
  401. const isQuestionAnswered = (cardIdx: number): boolean => {
  402. const selected = selectedOptions.value[cardIdx];
  403. const hasOption = selected && selected.size > 0;
  404. return hasOption || hasFreeformAnswer(cardIdx);
  405. };
  406. const toggleOption = (cardIdx: number, optIdx: number) => {
  407. if (submitted.value) return;
  408. const card = questions.value[cardIdx];
  409. if (!card) return;
  410. // Clear freeform when selecting options
  411. isFreeformActive.value[cardIdx] = false;
  412. handleFreeformBlur();
  413. const selected = ensureSelected(cardIdx);
  414. if (card.multiSelect) {
  415. // Multi-select: toggle
  416. if (selected.has(optIdx)) {
  417. selected.delete(optIdx);
  418. } else {
  419. selected.add(optIdx);
  420. }
  421. // Trigger reactivity
  422. selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set(selected) };
  423. } else {
  424. // Single select: toggle or replace
  425. if (selected.has(optIdx)) {
  426. selected.clear();
  427. } else {
  428. selected.clear();
  429. selected.add(optIdx);
  430. }
  431. selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set(selected) };
  432. }
  433. };
  434. const toggleFreeform = (cardIdx: number) => {
  435. if (submitted.value) return;
  436. const card = questions.value[cardIdx];
  437. if (!card) return;
  438. // Clear options when selecting "其他"
  439. isFreeformActive.value[cardIdx] = !isFreeformActive.value[cardIdx];
  440. isFreeformActive.value = { ...isFreeformActive.value };
  441. if (isFreeformActive.value[cardIdx]) {
  442. selectedOptions.value[cardIdx]?.clear();
  443. selectedOptions.value = { ...selectedOptions.value, [cardIdx]: new Set() };
  444. void nextTick(() => {
  445. freeformRefs[cardIdx]?.focus();
  446. });
  447. } else {
  448. handleFreeformBlur();
  449. }
  450. };
  451. const canSubmit = computed(() => {
  452. if (submitted.value) return false;
  453. return questions.value.some((_card, cardIdx) => {
  454. const selected = selectedOptions.value[cardIdx];
  455. const hasOption = selected && selected.size > 0;
  456. return hasOption || hasFreeformAnswer(cardIdx);
  457. });
  458. });
  459. const unansweredCount = computed(() => {
  460. return questions.value.filter((_card, cardIdx) => {
  461. const selected = selectedOptions.value[cardIdx];
  462. const hasOption = selected && selected.size > 0;
  463. const hasFreeform = isFreeformActive.value[cardIdx] && (freeformTexts.value[cardIdx] || '').trim().length > 0;
  464. return !hasOption && !hasFreeform;
  465. }).length;
  466. });
  467. const handleSubmit = () => {
  468. if (!canSubmit.value || isSubmitting.value) return;
  469. handleFreeformBlur();
  470. Object.values(freeformRefs).forEach((textarea) => textarea?.blur());
  471. closeAllDropdowns();
  472. const answers = questions.value.map((card, cardIdx) => {
  473. const selected = selectedOptions.value[cardIdx] || new Set();
  474. const selectedLabels: string[] = [];
  475. selected.forEach((optIdx) => {
  476. const opt = card.options?.[optIdx];
  477. if (opt) {
  478. selectedLabels.push(opt.label);
  479. }
  480. });
  481. return {
  482. header: card.header,
  483. selected: selectedLabels,
  484. // 常驻输入框不需要点击激活,输入的内容照样要带上
  485. freeText: isFreeformSelected(cardIdx) ? (freeformTexts.value[cardIdx] || '') : '',
  486. skipped: false,
  487. };
  488. });
  489. isSubmitting.value = true;
  490. submitted.value = true;
  491. if (injectSubmit) {
  492. injectSubmit(answers);
  493. } else {
  494. console.warn('[QuestionCard] No submit handler provided via inject');
  495. }
  496. isSubmitting.value = false;
  497. };
  498. </script>
  499. <style lang="scss" scoped>
  500. .question-card-shell {
  501. border: 1px solid rgba(199, 196, 216, 0.2);
  502. border-radius: 16px;
  503. background: #ffffff;
  504. box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
  505. padding: 0 12px;
  506. display: flex;
  507. flex-direction: column;
  508. gap: 0;
  509. margin-top: 12px;
  510. margin-bottom: 12px;
  511. max-width: 700px;
  512. }
  513. .question-card-shell.readonly .qc-option {
  514. pointer-events: none;
  515. }
  516. .question-card-shell.readonly .qc-option.readonly-empty .qc-option-text {
  517. color: rgba(70, 69, 85, 0.5);
  518. }
  519. .question-card {
  520. display: flex;
  521. flex-direction: column;
  522. gap: 12px;
  523. padding: 16px 12px;
  524. border-radius: 8px;
  525. transition: background 0.3s ease;
  526. &.highlighted {
  527. background: rgba(53, 37, 205, 0.06);
  528. }
  529. }
  530. .qc-dots-row {
  531. display: flex;
  532. justify-content: flex-end;
  533. gap: 8px;
  534. padding: 16px 12px 0;
  535. }
  536. .qc-dot {
  537. width: 8px;
  538. height: 8px;
  539. border-radius: 50%;
  540. background: rgba(199, 196, 216, 0.4);
  541. transition: background 0.2s ease;
  542. cursor: pointer;
  543. &.active {
  544. background: #3525cd;
  545. }
  546. }
  547. .qc-body {
  548. display: flex;
  549. flex-direction: column;
  550. gap: 12px;
  551. }
  552. .qc-question {
  553. font-family: "HarmonyOS Sans SC", sans-serif;
  554. font-size: 16px;
  555. font-weight: 500;
  556. color: #141b2b;
  557. line-height: 1.5;
  558. }
  559. .qc-question-seq {
  560. color: rgba(70, 69, 85, 0.5);
  561. font-weight: 400;
  562. }
  563. .qc-question-type {
  564. font-size: 12px;
  565. font-weight: 400;
  566. color: rgba(53, 37, 205, 0.6);
  567. margin-right: 4px;
  568. }
  569. .qc-options {
  570. display: flex;
  571. flex-wrap: wrap;
  572. gap: 8px;
  573. }
  574. .qc-option {
  575. flex: 1 1 calc(50% - 4px);
  576. min-width: 220px;
  577. display: flex;
  578. align-items: center;
  579. gap: 12px;
  580. padding: 12px 16px;
  581. border: 1px solid rgba(199, 196, 216, 0.3);
  582. border-radius: 12px;
  583. background: #ffffff;
  584. cursor: pointer;
  585. transition: all 0.15s ease;
  586. text-align: left;
  587. &.freeform {
  588. flex: 1 1 100%;
  589. }
  590. &:hover {
  591. border-color: rgba(53, 37, 205, 0.3);
  592. background: rgba(53, 37, 205, 0.02);
  593. }
  594. &.selected {
  595. border-color: #3525cd;
  596. background: rgba(53, 37, 205, 0.04);
  597. }
  598. }
  599. // Custom dropdown
  600. .qc-dropdown-wrapper {
  601. flex: 1 1 calc(50% - 4px);
  602. min-width: 220px;
  603. position: relative;
  604. }
  605. .qc-custom-dropdown {
  606. &.open {
  607. border-color: #3525cd;
  608. background: rgba(53, 37, 205, 0.04);
  609. }
  610. }
  611. .qc-dropdown-arrow {
  612. font-size: 12px;
  613. color: rgba(70, 69, 85, 0.4);
  614. transition: transform 0.2s ease;
  615. flex-shrink: 0;
  616. margin-left: auto;
  617. &.open {
  618. transform: rotate(180deg);
  619. }
  620. }
  621. .qc-dropdown-menu {
  622. position: absolute;
  623. top: calc(100% + 4px);
  624. left: 0;
  625. right: 0;
  626. max-height: 240px;
  627. overflow-y: auto;
  628. overscroll-behavior: contain;
  629. background: #ffffff;
  630. border: 1px solid rgba(199, 196, 216, 0.2);
  631. border-radius: 12px;
  632. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  633. z-index: 100;
  634. padding: 4px;
  635. }
  636. .qc-dropdown-item {
  637. display: flex;
  638. align-items: center;
  639. gap: 10px;
  640. padding: 10px 12px;
  641. border-radius: 8px;
  642. cursor: pointer;
  643. transition: background 0.15s ease;
  644. &:hover {
  645. background: rgba(53, 37, 205, 0.04);
  646. }
  647. &.selected {
  648. background: rgba(53, 37, 205, 0.06);
  649. }
  650. }
  651. .qc-dropdown-item-label {
  652. width: 24px;
  653. height: 24px;
  654. border-radius: 50%;
  655. background: rgba(53, 37, 205, 0.08);
  656. color: #3525cd;
  657. font-size: 12px;
  658. font-weight: 500;
  659. display: flex;
  660. align-items: center;
  661. justify-content: center;
  662. flex-shrink: 0;
  663. }
  664. .qc-dropdown-check {
  665. width: 24px;
  666. height: 24px;
  667. display: flex;
  668. align-items: center;
  669. justify-content: center;
  670. flex-shrink: 0;
  671. font-size: 14px;
  672. color: #3525cd;
  673. font-weight: 600;
  674. }
  675. .qc-dropdown-item-text {
  676. font-family: "HarmonyOS Sans SC", sans-serif;
  677. font-size: 14px;
  678. font-weight: 500;
  679. color: #141b2b;
  680. flex: 1;
  681. line-height: 1.4;
  682. }
  683. .qc-dropdown-item-desc {
  684. font-family: "HarmonyOS Sans SC", sans-serif;
  685. font-size: 12px;
  686. color: rgba(70, 69, 85, 0.5);
  687. line-height: 1.3;
  688. flex-shrink: 0;
  689. }
  690. .question-card-shell.submitted .qc-option {
  691. cursor: default;
  692. &:hover {
  693. border-color: rgba(199, 196, 216, 0.3);
  694. background: #ffffff;
  695. }
  696. }
  697. .qc-option-label {
  698. width: 24px;
  699. height: 24px;
  700. border-radius: 50%;
  701. background: rgba(53, 37, 205, 0.08);
  702. color: #3525cd;
  703. font-size: 12px;
  704. font-weight: 500;
  705. display: flex;
  706. align-items: center;
  707. justify-content: center;
  708. flex-shrink: 0;
  709. }
  710. .qc-option-content {
  711. flex: 1;
  712. display: flex;
  713. flex-direction: column;
  714. gap: 2px;
  715. min-width: 0;
  716. }
  717. .qc-option-text {
  718. font-family: "HarmonyOS Sans SC", sans-serif;
  719. font-size: 14px;
  720. font-weight: 500;
  721. color: #141b2b;
  722. line-height: 1.4;
  723. }
  724. .qc-other-text.has-text {
  725. color: #141b2b;
  726. }
  727. .qc-option-desc {
  728. font-family: "HarmonyOS Sans SC", sans-serif;
  729. font-size: 12px;
  730. color: rgba(70, 69, 85, 0.5);
  731. line-height: 1.3;
  732. }
  733. .qc-option.freeform {
  734. cursor: pointer;
  735. color: rgba(70, 69, 85, 0.4);
  736. &.selected {
  737. border-color: #3525cd;
  738. background: rgba(53, 37, 205, 0.04);
  739. }
  740. }
  741. .qc-freeform-input.inline {
  742. width: 100%;
  743. padding: 4px 0;
  744. border: none;
  745. border-bottom: 1px solid rgba(53, 37, 205, 0.4);
  746. border-radius: 0;
  747. font-family: "HarmonyOS Sans SC", sans-serif;
  748. font-size: 14px;
  749. color: #141b2b;
  750. resize: none;
  751. overflow: hidden;
  752. outline: none;
  753. background: transparent;
  754. box-sizing: border-box;
  755. transition: border-color 0.15s ease;
  756. &::placeholder {
  757. color: rgba(70, 69, 85, 0.4);
  758. }
  759. &:focus {
  760. border-bottom-color: #3525cd;
  761. }
  762. }
  763. .qc-footer {
  764. display: flex;
  765. flex-direction: column;
  766. }
  767. .qc-divider {
  768. height: 1px;
  769. background: rgba(199, 196, 216, 0.1);
  770. margin: 0;
  771. }
  772. .qc-footer-row {
  773. display: flex;
  774. align-items: center;
  775. justify-content: space-between;
  776. margin-top: 12px;
  777. width: 100%;
  778. padding: 0 12px 16px;
  779. }
  780. .qc-footer-hint {
  781. font-family: "HarmonyOS Sans SC", sans-serif;
  782. font-size: 12px;
  783. color: rgba(70, 69, 85, 0.5);
  784. margin-right: 16px;
  785. cursor: pointer;
  786. flex-shrink: 0;
  787. }
  788. .qc-submit-btn {
  789. flex-grow: 1;
  790. padding: 8px 24px;
  791. border: none;
  792. border-radius: 8px;
  793. background: #3525cd;
  794. color: #ffffff;
  795. font-family: "HarmonyOS Sans SC", sans-serif;
  796. font-size: 15px;
  797. font-weight: 500;
  798. cursor: pointer;
  799. transition: background 0.2s ease;
  800. &:hover:not(:disabled) {
  801. background: #2b1ea8;
  802. }
  803. &:disabled {
  804. background: rgba(53, 37, 205, 0.3);
  805. cursor: not-allowed;
  806. }
  807. }
  808. </style>