|
|
@@ -111,15 +111,99 @@
|
|
|
<span class="dot"></span>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 计划审批 UI -->
|
|
|
- <div v-if="message.isPendingApproval" class="approval-card">
|
|
|
+ <!-- ask_user 交互提问 UI(分步向导) -->
|
|
|
+ <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user" class="approval-card">
|
|
|
<div class="approval-icon"></div>
|
|
|
<div class="approval-content">
|
|
|
- <div class="approval-title">执行计划待审批</div>
|
|
|
- <div class="approval-desc">{{ message.pendingApprovalData?.message }}</div>
|
|
|
+ <div class="approval-title">{{ message.pendingApprovalData?.message }}</div>
|
|
|
+
|
|
|
+ <!-- 计划内容 -->
|
|
|
+ <div v-if="message.pendingApprovalData?.plan" class="approval-plan">
|
|
|
+ <div class="approval-plan-label">执行计划</div>
|
|
|
+ <div class="approval-plan-content">{{ message.pendingApprovalData.plan }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 步骤指示器 -->
|
|
|
+ <div v-if="message.pendingApprovalData.ask_user.questions.length > 1" class="ask-user-stepper">
|
|
|
+ <div
|
|
|
+ v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
|
|
|
+ :key="sIdx"
|
|
|
+ :class="['ask-user-step-dot', {
|
|
|
+ active: sIdx === (askUserCurrentStep[index] ?? 0),
|
|
|
+ completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim())
|
|
|
+ }]"
|
|
|
+ @click="askUserCurrentStep[index] = sIdx"
|
|
|
+ ></div>
|
|
|
+ <span class="ask-user-step-text">{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 当前问题 -->
|
|
|
+ <div
|
|
|
+ v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]"
|
|
|
+ class="ask-user-question"
|
|
|
+ >
|
|
|
+ <div class="ask-user-question-text">
|
|
|
+ {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
|
|
|
+ <span v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false" class="ask-user-required">*</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- multiple_choice 类型 -->
|
|
|
+ <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'" class="ask-user-choices">
|
|
|
+ <div
|
|
|
+ v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
|
|
|
+ :key="cIdx"
|
|
|
+ :class="['ask-user-choice', { selected: askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) && !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
|
|
|
+ @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
|
|
|
+ >
|
|
|
+ <span>{{ getChoiceValue(choice) }}</span>
|
|
|
+ </div>
|
|
|
+ <!-- Other 选项 -->
|
|
|
+ <div
|
|
|
+ :class="['ask-user-choice', { selected: askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
|
|
|
+ @click="selectOther(index, askUserCurrentStep[index] ?? 0)"
|
|
|
+ >
|
|
|
+ <span>其他</span>
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ v-if="askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0]"
|
|
|
+ class="ask-user-other-input"
|
|
|
+ placeholder="请输入..."
|
|
|
+ :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
|
|
|
+ @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLInputElement).value"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- text 类型 -->
|
|
|
+ <div v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'" class="ask-user-text">
|
|
|
+ <textarea
|
|
|
+ class="ask-user-textarea"
|
|
|
+ placeholder="请输入..."
|
|
|
+ :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
|
|
|
+ @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLTextAreaElement).value"
|
|
|
+ ></textarea>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
<div class="approval-actions">
|
|
|
- <button class="approval-btn approve-btn" @click="emit('approve')">同意</button>
|
|
|
- <button class="approval-btn reject-btn" @click="emit('reject')">拒绝</button>
|
|
|
+ <button
|
|
|
+ v-if="(askUserCurrentStep[index] ?? 0) > 0"
|
|
|
+ class="approval-btn nav-btn"
|
|
|
+ @click="goPrevStep(index)"
|
|
|
+ >上一步</button>
|
|
|
+ <button
|
|
|
+ v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
|
|
|
+ class="approval-btn approve-btn"
|
|
|
+ :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
|
|
|
+ @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
|
|
|
+ >下一步</button>
|
|
|
+ <button
|
|
|
+ v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
|
|
|
+ class="approval-btn approve-btn"
|
|
|
+ :disabled="!isCurrentStepAnswered(index, askUserCurrentStep[index] ?? 0, message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0])"
|
|
|
+ @click="handleSubmitAskUser(index)"
|
|
|
+ >提交</button>
|
|
|
+ <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -177,7 +261,7 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
- import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
|
+ import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
|
|
import dayjs from 'dayjs';
|
|
|
import { renderMarkdown, formatFileSize, tableHtmlToMarkdown, tableHtmlToCsv } from './utils';
|
|
|
import type { Message, AttachedFile, ThinkingStep } from './types';
|
|
|
@@ -232,17 +316,94 @@
|
|
|
return '';
|
|
|
};
|
|
|
|
|
|
- defineProps<{
|
|
|
+ const props = defineProps<{
|
|
|
messages: Message[];
|
|
|
}>();
|
|
|
|
|
|
+ // 自动初始化 ask_user 答案状态
|
|
|
+ watch(
|
|
|
+ () => props.messages,
|
|
|
+ (msgs) => {
|
|
|
+ msgs.forEach((msg, idx) => {
|
|
|
+ if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
|
|
|
+ initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ { immediate: true, deep: true }
|
|
|
+ );
|
|
|
+
|
|
|
const emit = defineEmits<{
|
|
|
(e: 'file-preview', file: AttachedFile): void;
|
|
|
(e: 'download-word', url: string): void;
|
|
|
- (e: 'approve'): void;
|
|
|
- (e: 'reject'): void;
|
|
|
+ (e: 'ask-user-submit', answers: string[]): void;
|
|
|
+ (e: 'ask-user-cancel'): void;
|
|
|
}>();
|
|
|
|
|
|
+ // ask_user 答案状态(按消息索引存储)
|
|
|
+ const askUserAnswers = reactive<Record<number, string[]>>({});
|
|
|
+ const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
|
|
|
+ const askUserCurrentStep = reactive<Record<number, number>>({});
|
|
|
+
|
|
|
+ // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
|
|
|
+ const getChoiceValue = (choice: any): string => {
|
|
|
+ if (typeof choice === 'string') return choice;
|
|
|
+ if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
|
|
|
+ return String(choice);
|
|
|
+ };
|
|
|
+
|
|
|
+ const initAskUserAnswers = (index: number, questions: { question: string; type: string; choices?: any[] }[]) => {
|
|
|
+ if (askUserAnswers[index]) return;
|
|
|
+ askUserAnswers[index] = questions.map(() => '');
|
|
|
+ askUserOtherSelected[index] = questions.map(() => false);
|
|
|
+ askUserCurrentStep[index] = 0;
|
|
|
+ };
|
|
|
+
|
|
|
+ const isCurrentStepAnswered = (msgIndex: number, qIndex: number, question: { required?: boolean; type: string }) => {
|
|
|
+ const answers = askUserAnswers[msgIndex];
|
|
|
+ if (!answers) return false;
|
|
|
+ if (question.required === false && !answers[qIndex]) return true;
|
|
|
+ return !!(answers[qIndex] && answers[qIndex].trim() !== '');
|
|
|
+ };
|
|
|
+
|
|
|
+ const goNextStep = (msgIndex: number, totalQuestions: number) => {
|
|
|
+ if (askUserCurrentStep[msgIndex] < totalQuestions - 1) {
|
|
|
+ askUserCurrentStep[msgIndex]++;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const goPrevStep = (msgIndex: number) => {
|
|
|
+ if (askUserCurrentStep[msgIndex] > 0) {
|
|
|
+ askUserCurrentStep[msgIndex]--;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const selectChoice = (msgIndex: number, qIndex: number, choice: any, totalQuestions: number) => {
|
|
|
+ if (!askUserAnswers[msgIndex]) return;
|
|
|
+ askUserAnswers[msgIndex][qIndex] = getChoiceValue(choice);
|
|
|
+ askUserOtherSelected[msgIndex][qIndex] = false;
|
|
|
+ // 单选自动跳到下一题(非最后一题时)
|
|
|
+ if (qIndex < totalQuestions - 1) {
|
|
|
+ goNextStep(msgIndex, totalQuestions);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const selectOther = (msgIndex: number, qIndex: number) => {
|
|
|
+ if (!askUserOtherSelected[msgIndex]) return;
|
|
|
+ askUserOtherSelected[msgIndex][qIndex] = true;
|
|
|
+ askUserAnswers[msgIndex][qIndex] = '';
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSubmitAskUser = (index: number) => {
|
|
|
+ const answers = askUserAnswers[index];
|
|
|
+ if (!answers) return;
|
|
|
+ emit('ask-user-submit', [...answers]);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleCancelAskUser = () => {
|
|
|
+ emit('ask-user-cancel');
|
|
|
+ };
|
|
|
+
|
|
|
const messagesRef = ref<HTMLElement>();
|
|
|
const collapsedThinking = reactive<Record<number, boolean>>({});
|
|
|
const collapsedThinkingContent = reactive<Record<number, boolean>>({});
|
|
|
@@ -912,11 +1073,150 @@
|
|
|
margin-bottom: 6px;
|
|
|
}
|
|
|
|
|
|
- .approval-desc {
|
|
|
- font-size: 13px;
|
|
|
- color: #8b949e;
|
|
|
- line-height: 1.5;
|
|
|
- margin-bottom: 12px;
|
|
|
+ .approval-plan {
|
|
|
+ margin-bottom: 14px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: rgba(10, 132, 255, 0.08);
|
|
|
+ border: 1px solid rgba(10, 132, 255, 0.2);
|
|
|
+ border-radius: 6px;
|
|
|
+
|
|
|
+ .approval-plan-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #0a84ff;
|
|
|
+ font-weight: 600;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .approval-plan-content {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #c9d1d9;
|
|
|
+ line-height: 1.6;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-question {
|
|
|
+ margin-bottom: 14px;
|
|
|
+
|
|
|
+ .ask-user-question-text {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #c9d1d9;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ line-height: 1.5;
|
|
|
+
|
|
|
+ .ask-user-required {
|
|
|
+ color: #f85149;
|
|
|
+ margin-left: 2px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-choices {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+
|
|
|
+ .ask-user-choice {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: rgba(255, 255, 255, 0.04);
|
|
|
+ border: 1px solid rgba(63, 80, 106, 0.4);
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #c9d1d9;
|
|
|
+ transition: all 0.2s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: rgba(10, 132, 255, 0.1);
|
|
|
+ border-color: rgba(10, 132, 255, 0.4);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.selected {
|
|
|
+ background: rgba(10, 132, 255, 0.15);
|
|
|
+ border-color: #0a84ff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-other-input {
|
|
|
+ margin-top: 4px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #e0e6ed;
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
+ border: 1px solid rgba(63, 80, 106, 0.5);
|
|
|
+ border-radius: 6px;
|
|
|
+ outline: none;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ &:focus {
|
|
|
+ border-color: #0a84ff;
|
|
|
+ background: rgba(10, 132, 255, 0.08);
|
|
|
+ }
|
|
|
+
|
|
|
+ &::placeholder {
|
|
|
+ color: #6e7681;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-text {
|
|
|
+ .ask-user-textarea {
|
|
|
+ width: 100%;
|
|
|
+ min-height: 60px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #e0e6ed;
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
+ border: 1px solid rgba(63, 80, 106, 0.5);
|
|
|
+ border-radius: 6px;
|
|
|
+ outline: none;
|
|
|
+ resize: vertical;
|
|
|
+ font-family: inherit;
|
|
|
+
|
|
|
+ &:focus {
|
|
|
+ border-color: #0a84ff;
|
|
|
+ background: rgba(10, 132, 255, 0.08);
|
|
|
+ }
|
|
|
+
|
|
|
+ &::placeholder {
|
|
|
+ color: #6e7681;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-stepper {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+
|
|
|
+ .ask-user-step-dot {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(63, 80, 106, 0.5);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background: #0a84ff;
|
|
|
+ width: 20px;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.completed {
|
|
|
+ background: rgba(10, 132, 255, 0.5);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ask-user-step-text {
|
|
|
+ margin-left: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #6e7681;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
.approval-actions {
|
|
|
@@ -936,9 +1236,24 @@
|
|
|
background: #0a84ff;
|
|
|
color: #fff;
|
|
|
|
|
|
- &:hover {
|
|
|
+ &:hover:not(:disabled) {
|
|
|
background: #3a9bfd;
|
|
|
}
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ opacity: 0.4;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.nav-btn {
|
|
|
+ background: rgba(255, 255, 255, 0.08);
|
|
|
+ color: #c9d1d9;
|
|
|
+ border: 1px solid rgba(63, 80, 106, 0.5);
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
&.reject-btn {
|