fix: 增加分享功能

refactor
Lxy 6 days ago
parent 4822e06a64
commit 9f24d1fb94

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -49,6 +49,7 @@ import cors from 'cors'
import authRoutes from './src/routes/auth.js' import authRoutes from './src/routes/auth.js'
import plansRoutes from './src/routes/plans.js' import plansRoutes from './src/routes/plans.js'
import statsRoutes from './src/routes/stats.js' import statsRoutes from './src/routes/stats.js'
import sharesRoutes from './src/routes/shares.js'
const app = express() const app = express()
app.use(cors({ app.use(cors({
@ -63,6 +64,7 @@ app.use(express.json())
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/plans', plansRoutes) app.use('/api/plans', plansRoutes)
app.use('/api/stats', statsRoutes) app.use('/api/stats', statsRoutes)
app.use('/api/shares', sharesRoutes)
// API: Get config // API: Get config
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
@ -250,6 +252,11 @@ async function start() {
// Production: serve static files // Production: serve static files
app.use(express.static(path.join(__dirname, 'dist'))) app.use(express.static(path.join(__dirname, 'dist')))
// SPA catch-all: serve index.html for all non-API routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
})
const port = process.env.PORT || 3000 const port = process.env.PORT || 3000
app.listen(port, () => { app.listen(port, () => {
console.log(`Server running on port ${port}`) console.log(`Server running on port ${port}`)

@ -346,15 +346,17 @@ async function generatePlan() {
console.log('[generatePlan] AI 返回结果:', result) console.log('[generatePlan] AI 返回结果:', result)
console.log('[generatePlan] schemes 数量:', result.schemes?.length) console.log('[generatePlan] schemes 数量:', result.schemes?.length)
console.log('[generatePlan] result 完整 JSON:', JSON.stringify(result, null, 2))
const newSchemes = result.schemes || [] const newSchemes = result.schemes || []
// points // points
newSchemes.forEach((scheme, idx) => { newSchemes.forEach((scheme, idx) => {
console.log(`[generatePlan] 方案 ${idx} - ${scheme.name}:`, { console.log(`[generatePlan] === 方案 ${idx} 详情 ===`)
pointsCount: scheme.points?.length, console.log(` 名称:`, scheme.name)
points: scheme.points?.map(p => p.name) console.log(` points 数量:`, scheme.points?.length)
}) console.log(` points 列表:`, scheme.points?.map(p => p.name))
console.log(` points 完整数据:`, JSON.stringify(scheme.points, null, 2))
}) })
const currentSchemes = [...allSchemes.value] const currentSchemes = [...allSchemes.value]
@ -374,6 +376,12 @@ async function generatePlan() {
} }
console.log('[generatePlan] 保存方案总数:', currentSchemes.length) console.log('[generatePlan] 保存方案总数:', currentSchemes.length)
//
currentSchemes.forEach((s, i) => {
console.log(`[generatePlan] 保存前方案 ${i} - ${s.name}: points=${s.points?.length}, 列表=${s.points?.map(p => p.name)}`)
})
store.saveSchemesToStore(currentSchemes, currentHistory) store.saveSchemesToStore(currentSchemes, currentHistory)
} catch (error) { } catch (error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {

@ -50,6 +50,9 @@
<button class="prev" :disabled="store.currentStep <= 0" @click="handlePrev"> </button> <button class="prev" :disabled="store.currentStep <= 0" @click="handlePrev"> </button>
<button class="next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext"> </button> <button class="next" :disabled="store.currentStep >= store.points.length - 1" @click="handleNext"> </button>
</div> </div>
<button class="wl-share-btn" @click="handleShare" v-if="authStore.isAuthenticated">
🔗 分享行程
</button>
</div> </div>
<!-- 中间详情面板 --> <!-- 中间详情面板 -->
@ -156,20 +159,57 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Share Modal -->
<div v-if="showShareModal" class="share-modal-overlay" @click.self="closeShareModal">
<div class="share-modal">
<div class="share-modal-header">
<h3>🔗 分享行程</h3>
<button class="close-btn" @click="closeShareModal"></button>
</div>
<div class="share-modal-body">
<template v-if="shareLoading">
<div class="share-loading">
<div class="share-spinner"></div>
<p>正在生成分享链接...</p>
</div>
</template>
<template v-else-if="shareUrl">
<p class="share-success"> 分享链接已生成</p>
<div class="share-url-box">
<input type="text" :value="shareUrl" readonly class="share-url-input" />
<button class="copy-btn" @click="copyShareUrl">📋 </button>
</div>
<p class="share-hint">任何人打开此链接都可以查看该行程只读模式</p>
</template>
<template v-else-if="shareError">
<p class="share-error"> {{ shareError }}</p>
</template>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useItineraryStore } from '../stores/itinerary' import { useItineraryStore } from '../stores/itinerary'
import { useAuthStore } from '../stores/auth'
import MapView from './MapView.vue' import MapView from './MapView.vue'
import { exportToHtml } from '../services/exportHtmlService' import { exportToHtml } from '../services/exportHtmlService'
const store = useItineraryStore() const store = useItineraryStore()
const authStore = useAuthStore()
const mapView = ref(null) const mapView = ref(null)
const showRoute = ref(true) const showRoute = ref(true)
const isFetchingRealRoute = ref(false) const isFetchingRealRoute = ref(false)
// Share state
const showShareModal = ref(false)
const shareLoading = ref(false)
const shareUrl = ref('')
const shareError = ref('')
const point = computed(() => store.currentPoint) const point = computed(() => store.currentPoint)
function getSchemeLabel(i) { function getSchemeLabel(i) {
@ -260,6 +300,101 @@ function exportCurrentScheme() {
exportToHtml(scheme, `${scheme.name || '行程规划'}.html`) exportToHtml(scheme, `${scheme.name || '行程规划'}.html`)
} }
// Share functions
async function handleShare() {
if (!authStore.isAuthenticated) {
alert('请先登录后再分享')
return
}
showShareModal.value = true
shareLoading.value = true
shareUrl.value = ''
shareError.value = ''
try {
//
const idx = store.activeSchemeIndex
let planData
if (idx >= 0 && idx < store.quickSchemes.length) {
planData = {
...store.quickSchemes[idx],
points: JSON.parse(JSON.stringify(store.points))
}
} else {
planData = {
name: '我的行程',
route: store.points.map(p => p.name).join(' → '),
days: store.points.length,
totalKm: store.totalKm,
totalDriveTime: store.totalDriveTime,
budget: '—',
highlights: [],
points: JSON.parse(JSON.stringify(store.points))
}
}
//
const saveRes = await fetch('http://localhost:3001/api/plans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({
name: planData.name,
description: planData.route,
data: planData
})
})
if (!saveRes.ok) {
throw new Error('保存行程失败')
}
const saveData = await saveRes.json()
const planId = saveData.plan?.id || saveData.id
//
const shareRes = await fetch('http://localhost:3001/api/shares', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`
},
body: JSON.stringify({ planId })
})
if (!shareRes.ok) {
const errData = await shareRes.json()
throw new Error(errData.error || '创建分享失败')
}
const shareData = await shareRes.json()
console.log('[handleShare] 服务器返回的分享数据:', JSON.stringify(shareData, null, 2))
shareUrl.value = shareData.shareUrl
console.log('[handleShare] 最终分享链接:', shareUrl.value)
} catch (err) {
console.error('分享失败:', err)
shareError.value = err.message || '分享失败,请稍后重试'
} finally {
shareLoading.value = false
}
}
function closeShareModal() {
showShareModal.value = false
}
function copyShareUrl() {
navigator.clipboard.writeText(shareUrl.value).then(() => {
alert('分享链接已复制到剪贴板')
}).catch(() => {
alert('复制失败,请手动复制链接')
})
}
// points phase // points phase
watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => { watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
console.log('[Workbench] watch 触发 - phase:', newPhase, 'points 长度:', newPoints.length) console.log('[Workbench] watch 触发 - phase:', newPhase, 'points 长度:', newPoints.length)
@ -372,6 +507,159 @@ watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
cursor: not-allowed; cursor: not-allowed;
} }
/* Share Modal */
.share-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.share-modal {
background: #fff;
border-radius: 16px;
width: 480px;
max-width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
animation: modalFadeIn 0.2s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.share-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e9ecef;
}
.share-modal-header h3 {
font-size: 16px;
font-weight: 700;
color: #2d3436;
margin: 0;
}
.close-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: #f0f0f0;
color: #636e72;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.close-btn:hover {
background: #e9ecef;
color: #2d3436;
}
.share-modal-body {
padding: 20px;
}
.share-loading {
text-align: center;
padding: 30px 0;
}
.share-spinner {
width: 40px;
height: 40px;
border: 3px solid #e8e4ff;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.share-loading p {
color: #636e72;
font-size: 14px;
}
.share-success {
color: #00b894;
font-size: 14px;
font-weight: 600;
margin: 0 0 16px;
}
.share-url-box {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.share-url-input {
flex: 1;
padding: 10px 12px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 13px;
background: #f8f9fa;
color: #2d3436;
outline: none;
}
.copy-btn {
padding: 10px 16px;
border-radius: 8px;
border: 2px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover {
background: #6c5ce7;
color: #fff;
}
.share-hint {
color: #636e72;
font-size: 12px;
margin: 0;
line-height: 1.5;
}
.share-error {
color: #d63031;
font-size: 14px;
margin: 0;
padding: 20px 0;
text-align: center;
}
/* Wrap layout */ /* Wrap layout */
.wb-wrap { .wb-wrap {
display: grid; display: grid;
@ -458,10 +746,30 @@ watch([() => store.points, () => store.phase], ([newPoints, newPhase]) => {
} }
.wl-nav button:disabled { .wl-nav button:disabled {
opacity: 0.3; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.wl-share-btn {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 2px solid rgba(0, 184, 148, 0.6);
background: rgba(0, 184, 148, 0.15);
color: #95d5b2;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 12px;
}
.wl-share-btn:hover {
background: rgba(0, 184, 148, 0.3);
border-color: #00b894;
color: #fff;
}
/* Middle detail panel */ /* Middle detail panel */
.wb-mid { .wb-mid {
background: #fff; background: #fff;

@ -44,6 +44,16 @@ db.exec(`
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- 分享表
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
plan_id TEXT REFERENCES plans(id) ON DELETE CASCADE,
share_code TEXT UNIQUE NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
views INTEGER DEFAULT 0
);
-- 游客临时数据表24小时过期 -- 游客临时数据表24小时过期
CREATE TABLE IF NOT EXISTS guest_plans ( CREATE TABLE IF NOT EXISTS guest_plans (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

@ -2,11 +2,13 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './views/HomePage.vue' import HomePage from './views/HomePage.vue'
import SettingsPage from './views/SettingsPage.vue' import SettingsPage from './views/SettingsPage.vue'
import AdminPage from './views/AdminPage.vue' import AdminPage from './views/AdminPage.vue'
import SharedPlanView from './views/SharedPlanView.vue'
const routes = [ const routes = [
{ path: '/', component: HomePage }, { path: '/', component: HomePage },
{ path: '/settings', component: SettingsPage }, { path: '/settings', component: SettingsPage },
{ path: '/admin', component: AdminPage } { path: '/admin', component: AdminPage },
{ path: '/share/:shareCode', component: SharedPlanView }
] ]
const router = createRouter({ const router = createRouter({

@ -0,0 +1,91 @@
// 行程分享路由
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
import { authenticateToken } from './auth.js'
const router = express.Router()
// 创建分享链接(需要登录)
router.post('/', authenticateToken, (req, res) => {
try {
const { planId } = req.body
if (!planId) {
return res.status(400).json({ error: '缺少行程ID' })
}
// 验证行程属于当前用户
const plan = db.prepare('SELECT * FROM plans WHERE id = ? AND user_id = ?').get(planId, req.user.id)
if (!plan) {
return res.status(404).json({ error: '行程不存在或无权访问' })
}
// 生成唯一的分享码
const shareCode = uuidv4().slice(0, 8)
const shareId = uuidv4()
db.prepare('INSERT INTO shares (id, plan_id, share_code, created_by) VALUES (?, ?, ?, ?)').run(
shareId, planId, shareCode, req.user.id
)
// 开发模式下前端运行在 5173 端口API 在 3001 端口
const isDev = process.env.NODE_ENV !== 'production'
console.log('[shares] NODE_ENV:', process.env.NODE_ENV, 'isDev:', isDev)
const shareUrl = `http://localhost:5173/share/${shareCode}`
console.log('[shares] 分享URL:', shareUrl)
console.log('[shares] 生成分享链接:', { shareCode, isDev, shareUrl })
res.json({
shareCode,
shareUrl,
planName: plan.name
})
} catch (err) {
console.error('创建分享失败:', err)
res.status(500).json({ error: '创建分享失败' })
}
})
// 获取分享行程(公开访问)
router.get('/:shareCode', (req, res) => {
try {
const { shareCode } = req.params
const share = db.prepare(`
SELECT s.id, s.share_code, s.created_at, s.views,
p.id as plan_id, p.name, p.description, p.data,
u.username as creator
FROM shares s
JOIN plans p ON s.plan_id = p.id
LEFT JOIN users u ON s.created_by = u.id
WHERE s.share_code = ?
`).get(shareCode)
if (!share) {
return res.status(404).json({ error: '分享链接无效或已过期' })
}
// 更新浏览次数
db.prepare('UPDATE shares SET views = views + 1 WHERE id = ?').run(share.id)
// 解析行程数据
const planData = JSON.parse(share.data)
res.json({
shareCode: share.share_code,
planName: share.name,
planDescription: share.description,
creator: share.creator,
createdAt: share.created_at,
views: share.views + 1,
data: planData
})
} catch (err) {
console.error('获取分享失败:', err)
res.status(500).json({ error: '获取分享失败' })
}
})
export default router

@ -476,6 +476,18 @@ export async function quickPlan(userInput, onThinking = null, signal = null) {
} }
console.log(`[quickPlan] 解析成功,方案数: ${parsed.schemes.length}`) console.log(`[quickPlan] 解析成功,方案数: ${parsed.schemes.length}`)
// 详细打印每个方案的数据
parsed.schemes.forEach((scheme, idx) => {
console.log(`[quickPlan] === 方案 ${idx + 1} 详情 ===`)
console.log(` 名称: ${scheme.name}`)
console.log(` points 数量: ${scheme.points?.length || 0}`)
console.log(` points 列表: ${scheme.points?.map(p => p.name).join(', ') || '无'}`)
if (scheme.points?.length > 0) {
console.log(` points 完整数据:`, JSON.stringify(scheme.points, null, 2))
}
})
return parsed return parsed
} catch (e) { } catch (e) {
lastError = e lastError = e

@ -0,0 +1,684 @@
<template>
<div class="shared-plan-view">
<!-- Top Navigation Bar -->
<nav class="top-bar">
<div class="brand"><span class="icon"></span> 智能行程规划</div>
<div class="top-bar-right">
<span class="shared-badge">👁 只读模式</span>
<button class="home-btn" @click="$router.push('/')"></button>
</div>
</nav>
<!-- Loading State -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p>加载分享行程中...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-container">
<div class="error-icon"></div>
<h2>分享链接无效</h2>
<p>{{ error }}</p>
<button class="btn-primary" @click="$router.push('/')"></button>
</div>
<!-- Shared Workbench -->
<div v-else-if="sharedData" class="shared-workbench">
<!-- Share Info Banner -->
<div class="share-banner">
<div class="share-info">
<span class="share-icon">🔗</span>
<div>
<div class="share-title">{{ sharedData.planName }}</div>
<div class="share-meta">
<span>分享者{{ sharedData.creator || '匿名用户' }}</span>
<span>·</span>
<span>{{ formatDate(sharedData.createdAt) }}</span>
<span>·</span>
<span>{{ sharedData.views }} 次查看</span>
</div>
</div>
</div>
<button class="btn-copy" @click="copyShareLink">📋 </button>
</div>
<!-- Workbench Content (Read-only) -->
<div class="wb-wrap">
<!-- Left: Timeline -->
<div class="wb-left">
<div class="wl-title">📋 行程时间线</div>
<div
v-for="(point, idx) in sharedData.data.points"
:key="point.id || idx"
:class="['wl-item', { active: idx === currentStep }]"
@click="currentStep = idx"
>
<div class="wl-dot"></div>
<div>
<div class="wl-name">{{ point.icon || '📍' }} {{ point.name }}</div>
<div class="wl-day">{{ point.day || `Day ${idx + 1}` }} · {{ point.km || '—' }}</div>
</div>
</div>
<div class="wl-nav">
<button class="prev" :disabled="currentStep <= 0" @click="currentStep--"> </button>
<button class="next" :disabled="currentStep >= sharedData.data.points.length - 1" @click="currentStep++"> </button>
</div>
</div>
<!-- Middle: Detail Panel -->
<div class="wb-mid">
<template v-if="currentPoint">
<div class="wm-hero" v-if="currentPoint.heroImage || currentPoint.images?.[0]">
<img :src="currentPoint.heroImage || currentPoint.images[0]" :alt="currentPoint.name" @error="handleImageError" />
<div class="wm-ov">
<div class="wm-title">{{ currentPoint.name }}</div>
<div class="wm-sub">{{ currentPoint.day || `Day ${currentStep + 1}` }}</div>
</div>
</div>
<div class="wm-stats">
<div class="wm-stat"><div class="ws-val">{{ currentPoint.km || '—' }}</div><div class="ws-lbl">行驶里程</div></div>
<div class="wm-stat"><div class="ws-val">{{ currentPoint.driveTime || '—' }}</div><div class="ws-lbl">驾驶时间</div></div>
<div class="wm-stat"><div class="ws-val">{{ currentPoint.day || `Day ${currentStep + 1}` }}</div><div class="ws-lbl">第几天</div></div>
</div>
<div v-if="currentPoint.desc" class="wm-sec"><p class="wm-desc">{{ currentPoint.desc }}</p></div>
<div v-if="currentPoint.schedule?.length" class="wm-sec">
<h4>📅 行程安排</h4>
<div v-for="(s, i) in currentPoint.schedule" :key="i" class="wm-si">
<span class="si-time">{{ s.time }}</span>
<div class="si-content">
<div class="si-title">{{ s.title || s.content }}</div>
<div v-if="s.desc" class="si-desc">{{ s.desc }}</div>
</div>
</div>
</div>
<div v-if="currentPoint.foods?.length" class="wm-sec">
<h4>🍽 美食推荐</h4>
<div class="foods-grid">
<div v-for="(f, i) in currentPoint.foods" :key="i" class="food-item">
<div class="food-icon">{{ f.icon || '🍜' }}</div>
<div class="food-info">
<div class="food-name">{{ f.name }}</div>
<div v-if="f.desc" class="food-desc">{{ f.desc }}</div>
</div>
</div>
</div>
</div>
<div v-if="currentPoint.waypoints?.length" class="wm-sec">
<h4>🗺 途径推荐</h4>
<div class="waypoint-list">
<div v-for="(wp, i) in currentPoint.waypoints" :key="i" class="waypoint-item">
<div class="wp-icon">{{ wp.icon || '📍' }}</div>
<div class="wp-info">
<div class="wp-name">{{ wp.name }}</div>
<div v-if="wp.desc" class="wp-desc">{{ wp.desc }}</div>
</div>
</div>
</div>
</div>
<div v-if="currentPoint.hotel" class="wm-sec">
<h4>🏨 住宿推荐</h4>
<div class="hotel-info">{{ currentPoint.hotel }}</div>
</div>
<div v-if="currentPoint.tips" class="wm-sec">
<h4>💡 注意事项</h4>
<div class="wm-tips">{{ currentPoint.tips }}</div>
</div>
</template>
</div>
<!-- Right: Map -->
<div class="wb-right">
<MapView ref="mapRef" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import MapView from '../components/MapView.vue'
import { useItineraryStore } from '../stores/itinerary'
const route = useRoute()
const store = useItineraryStore()
const mapRef = ref(null)
const loading = ref(true)
const error = ref(null)
const sharedData = ref(null)
const currentStep = ref(0)
const currentPoint = computed(() => {
if (!sharedData.value?.data?.points) return null
return sharedData.value.data.points[currentStep.value] || null
})
onMounted(async () => {
const shareCode = route.params.shareCode
if (!shareCode) {
error.value = '无效的分享链接'
loading.value = false
return
}
try {
const res = await fetch(`http://localhost:3001/api/shares/${shareCode}`)
const data = await res.json()
if (!res.ok) {
error.value = data.error || '分享链接无效或已过期'
return
}
sharedData.value = data
// store
if (data.data?.points) {
store.loadFromAI(data.data)
store.setPhase('workbench')
}
} catch (err) {
error.value = '加载分享行程失败'
console.error(err)
} finally {
loading.value = false
}
})
function handleImageError(e) {
e.target.src = 'https://picsum.photos/800/400?random=fallback'
}
function formatDate(dateStr) {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function copyShareLink() {
const url = window.location.href
navigator.clipboard.writeText(url).then(() => {
alert('分享链接已复制到剪贴板')
}).catch(() => {
alert('复制失败,请手动复制链接')
})
}
</script>
<style scoped>
.shared-plan-view {
min-height: 100vh;
background: #f5f7fa;
}
.top-bar {
height: 60px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 700;
color: #2d3436;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.shared-badge {
background: #ffe0e0;
color: #d63031;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.home-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
transition: all 0.2s;
font-weight: 500;
}
.home-btn:hover {
background: #6c5ce7;
color: #fff;
}
/* Loading */
.loading-container {
margin-top: 80px;
text-align: center;
padding: 80px 20px;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e8e4ff;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
.error-container {
margin-top: 80px;
text-align: center;
padding: 80px 20px;
}
.error-icon {
font-size: 64px;
margin-bottom: 16px;
}
.error-container h2 {
color: #d63031;
margin-bottom: 12px;
}
.error-container p {
color: #636e72;
margin-bottom: 24px;
}
.btn-primary {
background: #6c5ce7;
color: #fff;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
/* Share Banner */
.share-banner {
background: linear-gradient(135deg, #e8e4ff, #d4c8ff);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 60px;
}
.share-info {
display: flex;
align-items: center;
gap: 12px;
}
.share-icon {
font-size: 24px;
}
.share-title {
font-size: 16px;
font-weight: 700;
color: #2d3436;
}
.share-meta {
font-size: 12px;
color: #636e72;
display: flex;
gap: 6px;
margin-top: 2px;
}
.btn-copy {
background: #fff;
border: 2px solid #6c5ce7;
color: #6c5ce7;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-copy:hover {
background: #6c5ce7;
color: #fff;
}
/* Workbench */
.shared-workbench {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
}
.wb-wrap {
flex: 1;
display: flex;
overflow: hidden;
}
/* Left Timeline */
.wb-left {
width: 220px;
background: #1b4332;
color: #fff;
padding: 16px;
overflow-y: auto;
flex-shrink: 0;
}
.wl-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.wl-item {
display: flex;
gap: 10px;
padding: 10px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
}
.wl-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.wl-item.active {
background: rgba(255, 255, 255, 0.2);
}
.wl-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #40916c;
margin-top: 4px;
flex-shrink: 0;
}
.wl-item.active .wl-dot {
background: #95d5b2;
box-shadow: 0 0 0 3px rgba(149, 213, 178, 0.3);
}
.wl-name {
font-size: 13px;
font-weight: 600;
}
.wl-day {
font-size: 11px;
color: #95d5b2;
margin-top: 2px;
}
.wl-nav {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.wl-nav button {
flex: 1;
padding: 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.wl-nav button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
}
.wl-nav button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Middle Detail */
.wb-mid {
width: 340px;
background: #fff;
overflow-y: auto;
flex-shrink: 0;
}
.wm-hero {
position: relative;
height: 180px;
overflow: hidden;
}
.wm-hero img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wm-ov {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: #fff;
}
.wm-title {
font-size: 18px;
font-weight: 700;
}
.wm-sub {
font-size: 12px;
opacity: 0.9;
margin-top: 2px;
}
.wm-stats {
display: flex;
padding: 16px;
gap: 12px;
background: #f8f9ff;
border-bottom: 1px solid #e9ecef;
}
.wm-stat {
flex: 1;
text-align: center;
}
.ws-val {
font-size: 18px;
font-weight: 700;
color: #6c5ce7;
}
.ws-lbl {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
.wm-sec {
padding: 16px;
border-bottom: 1px solid #e9ecef;
}
.wm-sec h4 {
font-size: 14px;
font-weight: 700;
color: #2d3436;
margin-bottom: 12px;
}
.wm-desc {
font-size: 13px;
color: #636e72;
line-height: 1.6;
}
.wm-si {
display: flex;
gap: 10px;
padding: 10px 0;
border-top: 1px solid #f0f0f0;
}
.si-time {
font-size: 11px;
color: #6c5ce7;
font-weight: 600;
white-space: nowrap;
min-width: 40px;
}
.si-title {
font-size: 13px;
font-weight: 600;
color: #2d3436;
}
.si-desc {
font-size: 12px;
color: #636e72;
margin-top: 2px;
}
.foods-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.food-item {
background: #f8f9ff;
padding: 10px;
border-radius: 8px;
display: flex;
gap: 8px;
}
.food-icon {
font-size: 20px;
}
.food-name {
font-size: 12px;
font-weight: 600;
color: #2d3436;
}
.food-desc {
font-size: 10px;
color: #636e72;
margin-top: 2px;
}
.waypoint-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.waypoint-item {
display: flex;
gap: 10px;
padding: 10px;
background: #f8f9ff;
border-radius: 8px;
}
.wp-icon {
font-size: 20px;
}
.wp-name {
font-size: 13px;
font-weight: 600;
color: #2d3436;
}
.wp-desc {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
.hotel-info {
font-size: 13px;
color: #636e72;
line-height: 1.5;
}
.wm-tips {
font-size: 13px;
color: #636e72;
line-height: 1.6;
background: #fff8e1;
padding: 12px;
border-radius: 8px;
border-left: 3px solid #f4a261;
}
/* Right Map */
.wb-right {
flex: 1;
position: relative;
}
</style>
Loading…
Cancel
Save