-
+
AI 正在规划中...
-
{{ thinkingExpanded ? '收起' : '展开' }}
+
@@ -44,6 +100,9 @@
分析用户需求中...
+
+ {{ thinkingExpanded ? '收起 ▲' : '展开 ▼' }}
+
@@ -175,12 +234,19 @@ const MAX_SCHEMES = 9
const destination = ref('')
const days = ref(3)
-const startDate = ref('')
+const daysInput = ref('3')
+const daysDropdownOpen = ref(false)
+const startMonth = ref(1)
+const monthDisplay = ref('1月')
+const monthDropdownOpen = ref(false)
const transport = ref('自驾')
+const transportDisplay = ref('自驾')
+const transportDropdownOpen = ref(false)
const extraNeeds = ref('')
const isGenerating = ref(false)
const thinkingText = ref('')
const thinkingExpanded = ref(true)
+const abortController = ref(null)
// Preview modal
const previewIndex = ref(-1)
@@ -200,25 +266,74 @@ function getBadgeClass(i) {
return classes[i % 3]
}
+function onDaysInput() {
+ const val = parseInt(daysInput.value)
+ if (!isNaN(val) && val >= 1 && val <= 30) {
+ days.value = val
+ }
+}
+
+function selectDay(d) {
+ days.value = d
+ daysInput.value = String(d)
+ daysDropdownOpen.value = false
+}
+
+function onDaysBlur() {
+ setTimeout(() => {
+ daysDropdownOpen.value = false
+ // 如果输入为空或无效,恢复为 days 的值
+ const val = parseInt(daysInput.value)
+ if (isNaN(val) || val < 1 || val > 30) {
+ daysInput.value = String(days.value)
+ }
+ }, 200)
+}
+
+function selectMonth(m) {
+ startMonth.value = m
+ monthDisplay.value = `${m}月`
+ monthDropdownOpen.value = false
+}
+
+function onMonthBlur() {
+ setTimeout(() => {
+ monthDropdownOpen.value = false
+ }, 200)
+}
+
+function selectTransport(t) {
+ transport.value = t
+ transportDisplay.value = t
+ transportDropdownOpen.value = false
+}
+
+function onTransportBlur() {
+ setTimeout(() => {
+ transportDropdownOpen.value = false
+ }, 200)
+}
+
async function generatePlan() {
if (!destination.value.trim() || isGenerating.value) return
isGenerating.value = true
thinkingText.value = ''
thinkingExpanded.value = true
+ abortController.value = new AbortController()
try {
const userInput = {
destination: destination.value.trim(),
days: days.value,
- startDate: startDate.value || '近期',
+ startDate: `${startMonth.value}月`,
transport: transport.value,
extraNeeds: extraNeeds.value.trim() || '无特殊需求'
}
const result = await quickPlan(userInput, (text) => {
thinkingText.value = text
- })
+ }, abortController.value)
console.log('[generatePlan] AI 返回结果:', result)
console.log('[generatePlan] schemes 数量:', result.schemes?.length)
@@ -252,10 +367,23 @@ async function generatePlan() {
console.log('[generatePlan] 保存方案总数:', currentSchemes.length)
store.saveSchemesToStore(currentSchemes, currentHistory)
} catch (error) {
- thinkingText.value = '生成失败:' + (error.message || '请重试')
- console.error('[generatePlan] 生成失败:', error)
+ if (error.name === 'AbortError') {
+ console.log('[generatePlan] 用户取消生成')
+ thinkingText.value = '已取消生成'
+ } else {
+ thinkingText.value = '生成失败:' + (error.message || '请重试')
+ console.error('[generatePlan] 生成失败:', error)
+ }
} finally {
isGenerating.value = false
+ abortController.value = null
+ }
+}
+
+function cancelGeneration() {
+ if (abortController.value) {
+ abortController.value.abort()
+ console.log('[cancelGeneration] 取消请求已发送')
}
}
@@ -374,6 +502,58 @@ onMounted(() => {
cursor: not-allowed;
}
+/* Days combobox */
+.combobox-wrap {
+ position: relative;
+}
+
+.combobox-input {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1.5px solid #dfe6e9;
+ border-radius: 10px;
+ font-size: 14px;
+ outline: none;
+ transition: border 0.2s;
+ font-family: inherit;
+}
+
+.combobox-input:focus {
+ border-color: #6c5ce7;
+}
+
+.combobox-input:disabled {
+ background: #f5f7fa;
+ cursor: not-allowed;
+}
+
+.combobox-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: #fff;
+ border: 1.5px solid #6c5ce7;
+ border-top: none;
+ border-radius: 0 0 10px 10px;
+ box-shadow: 0 4px 12px rgba(108,92,231,0.15);
+ z-index: 100;
+ margin-top: 2px;
+}
+
+.combobox-option {
+ padding: 10px 14px;
+ cursor: pointer;
+ font-size: 14px;
+ color: #636e72;
+ transition: all 0.15s;
+}
+
+.combobox-option:hover {
+ background: #f0f0f5;
+ color: #6c5ce7;
+}
+
.btn-primary {
width: 100%;
padding: 13px;
@@ -416,7 +596,42 @@ onMounted(() => {
gap: 10px;
font-weight: 600;
font-size: 14px;
+}
+
+.btn-cancel {
+ margin-left: auto;
+ padding: 4px 12px;
+ background: #e74c3c;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-cancel:hover {
+ background: #c0392b;
+}
+
+.lp-toggle-area {
+ padding: 8px 20px;
+ text-align: center;
+ font-size: 12px;
+ color: #b2bec3;
cursor: pointer;
+ border-top: 1px solid #eee;
+}
+
+.lp-toggle-area:hover {
+ color: #6c5ce7;
+ background: #f8f9fa;
+}
+
+/* Form disabled state */
+.form-wrap.form-disabled {
+ opacity: 0.6;
+ pointer-events: none;
}
.spin {
diff --git a/src/services/aiService.js b/src/services/aiService.js
index 1dd6552..03f2f5c 100644
--- a/src/services/aiService.js
+++ b/src/services/aiService.js
@@ -325,13 +325,14 @@ function parseAIResponse(content) {
}
// Shared streaming function
-async function streamAIRequest(messages, onThinking = null) {
+async function streamAIRequest(messages, onThinking = null, signal = null) {
const apiUrl = import.meta.env.DEV ? 'http://localhost:3001/api/chat' : '/api/chat'
console.log('[streamAI] Fetching:', apiUrl)
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ messages, stream: true })
+ body: JSON.stringify({ messages, stream: true }),
+ signal
})
console.log('[streamAI] Response status:', response.status)
@@ -439,7 +440,7 @@ function parseJSONFromContent(content) {
}
}
-export async function quickPlan(userInput, onThinking = null) {
+export async function quickPlan(userInput, onThinking = null, signal = null) {
const userMessage = `目的地:${userInput.destination}
出行天数:${userInput.days}天
出发日期:${userInput.startDate}
@@ -457,7 +458,7 @@ export async function quickPlan(userInput, onThinking = null) {
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
- ], onThinking)
+ ], onThinking, signal)
const parsed = parseJSONFromContent(content)