|
@@ -1,35 +1,10 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <div v-if="isReadonly && contentAnswers.length > 0" class="question-card-shell readonly">
|
|
|
|
|
- <div v-for="(answer, idx) in contentAnswers" :key="idx" class="question-card">
|
|
|
|
|
- <div class="qc-body">
|
|
|
|
|
- <div class="qc-question">
|
|
|
|
|
- <span v-if="contentAnswers.length > 1" class="qc-question-seq">{{ idx + 1 }}. </span>
|
|
|
|
|
- {{ answer.header }}
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="qc-options">
|
|
|
|
|
- <div v-if="answer.freeText" class="qc-option selected">
|
|
|
|
|
- <div class="qc-option-content">
|
|
|
|
|
- <span class="qc-option-text">{{ answer.freeText }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <template v-else-if="(answer.selected || []).length > 0">
|
|
|
|
|
- <div v-for="(label, labelIdx) in (answer.selected || [])" :key="labelIdx" class="qc-option selected">
|
|
|
|
|
- <span class="qc-option-label">{{ getOptionLabel(labelIdx) }}</span>
|
|
|
|
|
- <div class="qc-option-content">
|
|
|
|
|
- <span class="qc-option-text">{{ label }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </template>
|
|
|
|
|
- <div v-else class="qc-option readonly-empty">
|
|
|
|
|
- <div class="qc-option-content">
|
|
|
|
|
- <span class="qc-option-text">未选择</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-else-if="questions.length > 0" class="question-card-shell" :class="{ submitted }">
|
|
|
|
|
|
|
+ <!-- 只读态**不再另起一套模板**:同一个问卷模板 + 只读开关(禁用交互、还原选中样式)。
|
|
|
|
|
+ 用户 2026-09-24:「问卷长什么样,选完后还是长什么样,样式不能变」——
|
|
|
|
|
+ 之前只读态走的是另一套更简陋的标记(没有 (单选) 标签、没有选项列表、没有提交按钮),
|
|
|
|
|
+ 作答后卡片会"缩水"成一个小框,看起来像坏了。 -->
|
|
|
|
|
+ <div v-if="questions.length > 0" class="question-card-shell"
|
|
|
|
|
+ :class="{ submitted: submitted || isReadonly, readonly: isReadonly }">
|
|
|
<!-- Progress dots outside questions -->
|
|
<!-- Progress dots outside questions -->
|
|
|
<div class="qc-dots-row">
|
|
<div class="qc-dots-row">
|
|
|
<div v-for="(card, cardIdx) in questions" :key="cardIdx" class="qc-dot"
|
|
<div v-for="(card, cardIdx) in questions" :key="cardIdx" class="qc-dot"
|
|
@@ -104,7 +79,7 @@
|
|
|
<textarea :ref="(el) => setFreeformRef(cardIdx, el)"
|
|
<textarea :ref="(el) => setFreeformRef(cardIdx, el)"
|
|
|
class="qc-freeform-input inline"
|
|
class="qc-freeform-input inline"
|
|
|
:placeholder="card.freeformPlaceholder || '请输入补充信息...'"
|
|
:placeholder="card.freeformPlaceholder || '请输入补充信息...'"
|
|
|
- v-model="freeformTexts[cardIdx]" rows="1"
|
|
|
|
|
|
|
+ v-model="freeformTexts[cardIdx]" rows="1" :readonly="isReadonly"
|
|
|
@focus="handleAlwaysOnFreeformInteract(cardIdx)"
|
|
@focus="handleAlwaysOnFreeformInteract(cardIdx)"
|
|
|
@click="handleAlwaysOnFreeformInteract(cardIdx)"
|
|
@click="handleAlwaysOnFreeformInteract(cardIdx)"
|
|
|
@blur="handleFreeformBlur"></textarea>
|
|
@blur="handleFreeformBlur"></textarea>
|
|
@@ -130,6 +105,7 @@
|
|
|
class="qc-option-text qc-other-text" :class="{ 'has-text': freeformTexts[cardIdx] }">{{ freeformTexts[cardIdx] || "其他" }}</span>
|
|
class="qc-option-text qc-other-text" :class="{ 'has-text': freeformTexts[cardIdx] }">{{ freeformTexts[cardIdx] || "其他" }}</span>
|
|
|
<textarea v-if="isFreeformSelected(cardIdx)" :ref="(el) => setFreeformRef(cardIdx, el)"
|
|
<textarea v-if="isFreeformSelected(cardIdx)" :ref="(el) => setFreeformRef(cardIdx, el)"
|
|
|
class="qc-freeform-input inline" :placeholder="'请输入补充信息...'" v-model="freeformTexts[cardIdx]" rows="1"
|
|
class="qc-freeform-input inline" :placeholder="'请输入补充信息...'" v-model="freeformTexts[cardIdx]" rows="1"
|
|
|
|
|
+ :readonly="isReadonly"
|
|
|
@focus="handleFreeformFocus" @blur="handleFreeformBlur" @click.stop></textarea>
|
|
@focus="handleFreeformFocus" @blur="handleFreeformBlur" @click.stop></textarea>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -190,8 +166,54 @@ const freeformInputFocusControl = inject(FREEFORM_INPUT_FOCUS_KEY, null);
|
|
|
const contentAnswers = computed(() => parseAnswersFromContent(props.content));
|
|
const contentAnswers = computed(() => parseAnswersFromContent(props.content));
|
|
|
const isReadonly = computed(() => !!contentAnswers.value);
|
|
const isReadonly = computed(() => !!contentAnswers.value);
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 只读态:把写回内容里的 answers **还原成界面上的选中状态**,让它渲染成"就是刚才那份问卷"。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么需要:只读态现在复用同一个问卷模板(用户 2026-09-24:「问卷长什么样,选完后还是
|
|
|
|
|
+ * 长什么样,样式不能变」),而这个模板的选中样式来自 `selectedOptions` / `freeformTexts`。
|
|
|
|
|
+ * 写回的 answers 只有选项**文字**,所以这里按文字反查选项下标。
|
|
|
|
|
+ */
|
|
|
|
|
+const hydrateFromAnswers = () => {
|
|
|
|
|
+ const answers = contentAnswers.value;
|
|
|
|
|
+ if (!answers) return;
|
|
|
|
|
+ const nextSelected: Record<number, Set<number>> = {};
|
|
|
|
|
+ const nextTexts: Record<number, string> = {};
|
|
|
|
|
+ const nextActive: Record<number, boolean> = {};
|
|
|
|
|
+
|
|
|
|
|
+ questions.value.forEach((card, cardIdx) => {
|
|
|
|
|
+ const answer = answers[cardIdx];
|
|
|
|
|
+ if (!answer) return;
|
|
|
|
|
+ const labels = (Array.isArray(answer.selected) ? answer.selected : []).map((item) => String(item ?? ''));
|
|
|
|
|
+ if (labels.length) {
|
|
|
|
|
+ const indices = new Set<number>();
|
|
|
|
|
+ (card.options || []).forEach((opt, optIdx) => {
|
|
|
|
|
+ if (opt && labels.includes(String(opt.label ?? ''))) indices.add(optIdx);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (indices.size) nextSelected[cardIdx] = indices;
|
|
|
|
|
+ }
|
|
|
|
|
+ const freeText = String(answer.freeText ?? '');
|
|
|
|
|
+ if (freeText.trim()) {
|
|
|
|
|
+ nextTexts[cardIdx] = freeText;
|
|
|
|
|
+ // 答案出自自由输入 → 那一行也要显示成选中
|
|
|
|
|
+ if (!nextSelected[cardIdx]) nextActive[cardIdx] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ selectedOptions.value = nextSelected;
|
|
|
|
|
+ freeformTexts.value = { ...freeformTexts.value, ...nextTexts };
|
|
|
|
|
+ isFreeformActive.value = nextActive;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ [() => props.content, () => questions.value.length],
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (isReadonly.value) hydrateFromAnswers();
|
|
|
|
|
+ },
|
|
|
|
|
+ { immediate: true }
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
function handleFreeformFocus() {
|
|
function handleFreeformFocus() {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
freeformInputFocusControl?.focus(freeformFocusOwner);
|
|
freeformInputFocusControl?.focus(freeformFocusOwner);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -278,7 +300,7 @@ function closeAllDropdowns() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function toggleDropdown(cardIdx: number) {
|
|
function toggleDropdown(cardIdx: number) {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const isOpen = openDropdowns.value[cardIdx];
|
|
const isOpen = openDropdowns.value[cardIdx];
|
|
|
closeAllDropdowns();
|
|
closeAllDropdowns();
|
|
|
if (!isOpen) {
|
|
if (!isOpen) {
|
|
@@ -323,7 +345,7 @@ const getSelectedDropdownText = (cardIdx: number): string => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function selectDropdownOption(cardIdx: number, optIdx: number) {
|
|
function selectDropdownOption(cardIdx: number, optIdx: number) {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const card = questions.value[cardIdx];
|
|
const card = questions.value[cardIdx];
|
|
|
if (!card) return;
|
|
if (!card) return;
|
|
|
isFreeformActive.value[cardIdx] = false;
|
|
isFreeformActive.value[cardIdx] = false;
|
|
@@ -359,7 +381,7 @@ const getMultiDropdownText = (cardIdx: number): string => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function toggleMultiDropdownOption(cardIdx: number, optIdx: number) {
|
|
function toggleMultiDropdownOption(cardIdx: number, optIdx: number) {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const card = questions.value[cardIdx];
|
|
const card = questions.value[cardIdx];
|
|
|
if (!card) return;
|
|
if (!card) return;
|
|
|
isFreeformActive.value[cardIdx] = false;
|
|
isFreeformActive.value[cardIdx] = false;
|
|
@@ -418,7 +440,7 @@ const isFreeformSelected = (cardIdx: number): boolean => {
|
|
|
* · **单选卡片顺手清掉已选选项** —— 这正是"一次只有一个选项是选中样式"
|
|
* · **单选卡片顺手清掉已选选项** —— 这正是"一次只有一个选项是选中样式"
|
|
|
*/
|
|
*/
|
|
|
const handleAlwaysOnFreeformInteract = (cardIdx: number) => {
|
|
const handleAlwaysOnFreeformInteract = (cardIdx: number) => {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const card = questions.value[cardIdx];
|
|
const card = questions.value[cardIdx];
|
|
|
if (!card) return;
|
|
if (!card) return;
|
|
|
if (!isFreeformActive.value[cardIdx]) {
|
|
if (!isFreeformActive.value[cardIdx]) {
|
|
@@ -441,7 +463,7 @@ const isQuestionAnswered = (cardIdx: number): boolean => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const toggleOption = (cardIdx: number, optIdx: number) => {
|
|
const toggleOption = (cardIdx: number, optIdx: number) => {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const card = questions.value[cardIdx];
|
|
const card = questions.value[cardIdx];
|
|
|
if (!card) return;
|
|
if (!card) return;
|
|
|
|
|
|
|
@@ -472,7 +494,7 @@ const toggleOption = (cardIdx: number, optIdx: number) => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const toggleFreeform = (cardIdx: number) => {
|
|
const toggleFreeform = (cardIdx: number) => {
|
|
|
- if (submitted.value) return;
|
|
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return;
|
|
|
const card = questions.value[cardIdx];
|
|
const card = questions.value[cardIdx];
|
|
|
if (!card) return;
|
|
if (!card) return;
|
|
|
|
|
|
|
@@ -492,7 +514,8 @@ const toggleFreeform = (cardIdx: number) => {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const canSubmit = computed(() => {
|
|
const canSubmit = computed(() => {
|
|
|
- if (submitted.value) return false;
|
|
|
|
|
|
|
+ // 只读态(已作答、内容已写回)不允许再提交 —— 提交按钮保持可见但禁用(样式与问卷一致)
|
|
|
|
|
+ if (submitted.value || isReadonly.value) return false;
|
|
|
return questions.value.some((_card, cardIdx) => {
|
|
return questions.value.some((_card, cardIdx) => {
|
|
|
const selected = selectedOptions.value[cardIdx];
|
|
const selected = selectedOptions.value[cardIdx];
|
|
|
const hasOption = selected && selected.size > 0;
|
|
const hasOption = selected && selected.size > 0;
|