567 lines
11 KiB
Vue
567 lines
11 KiB
Vue
<template>
|
|
<div class="share-view" :class="{ dark: isDark }">
|
|
<!-- 加载状态 -->
|
|
<div v-if="isLoading" class="loading-state">
|
|
<Loader2 :size="32" class="spin" />
|
|
<span>加载中...</span>
|
|
</div>
|
|
|
|
<!-- 错误状态 -->
|
|
<div v-else-if="error" class="error-state">
|
|
<AlertCircle :size="48" />
|
|
<p class="error-message">{{ error }}</p>
|
|
<button class="retry-btn" @click="loadShareInfo">重试</button>
|
|
</div>
|
|
|
|
<!-- 密码验证 -->
|
|
<div v-else-if="!isVerified" class="password-verify">
|
|
<div class="verify-container">
|
|
<div class="verify-icon">
|
|
<Lock :size="48" />
|
|
</div>
|
|
<h2 class="verify-title">查看分享内容</h2>
|
|
<p class="verify-hint">请输入访问密码查看分享内容</p>
|
|
|
|
<div v-if="shareInfo" class="share-info">
|
|
<span class="info-item">
|
|
<MessageSquare :size="14" />
|
|
{{ shareInfo.conversationCount }} 个对话
|
|
</span>
|
|
<span v-if="shareInfo.isExpired" class="info-item expired">
|
|
<AlertCircle :size="14" />
|
|
已过期
|
|
</span>
|
|
<span v-else class="info-item">
|
|
<Clock :size="14" />
|
|
{{ formatExpiresAt }} 过期
|
|
</span>
|
|
</div>
|
|
|
|
<div class="password-input-wrapper">
|
|
<input
|
|
v-model="password"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
class="password-input"
|
|
placeholder="请输入访问密码"
|
|
@keydown.enter="handleVerify"
|
|
/>
|
|
<button class="toggle-visibility" @click="showPassword = !showPassword">
|
|
<Eye v-if="!showPassword" :size="18" />
|
|
<EyeOff v-else :size="18" />
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="verifyError" class="verify-error">{{ verifyError }}</p>
|
|
|
|
<button
|
|
class="verify-btn"
|
|
:disabled="!password || isVerifying"
|
|
@click="handleVerify"
|
|
>
|
|
{{ isVerifying ? '验证中...' : '查看内容' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 分享内容 -->
|
|
<div v-else class="share-content">
|
|
<!-- 顶部导航栏 -->
|
|
<header class="share-header">
|
|
<div class="header-left">
|
|
<h1 class="share-title">分享的对话</h1>
|
|
<span class="conversation-count">
|
|
共 {{ shareData?.conversations?.length || 0 }} 个对话
|
|
</span>
|
|
</div>
|
|
<!-- <button class="theme-toggle" @click="toggleTheme">
|
|
<Sun v-if="isDark" :size="20" />
|
|
<Moon v-else :size="20" />
|
|
</button> -->
|
|
</header>
|
|
|
|
<!-- 对话切换标签 -->
|
|
<div v-if="shareData?.conversations && shareData.conversations.length > 1" class="conversation-tabs">
|
|
<button
|
|
v-for="(conv, index) in shareData.conversations"
|
|
:key="conv.id"
|
|
class="tab-btn"
|
|
:class="{ active: activeConversationIndex === index }"
|
|
@click="activeConversationIndex = index"
|
|
>
|
|
{{ conv.title || '未命名对话' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 消息列表 -->
|
|
<div class="message-list">
|
|
<template v-if="activeConversation">
|
|
<MessageBubble
|
|
v-for="message in visibleMessages"
|
|
:key="message.id"
|
|
:message="message"
|
|
:show-timestamp="true"
|
|
:readonly="true"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useSettingsStore } from '@/stores/settings'
|
|
import { storeToRefs } from 'pinia'
|
|
import { shareApi } from '@/services/shareApi'
|
|
import { hashPassword } from '@/utils/crypto'
|
|
import { formatTimestamp } from '@/utils/helpers'
|
|
import { MessageRole } from '@/types/chat'
|
|
import type { Share, ShareGetResponse } from '@/types/share'
|
|
import MessageBubble from '@/components/message/MessageBubble.vue'
|
|
import {
|
|
Lock,
|
|
Loader2,
|
|
MessageSquare,
|
|
Clock,
|
|
AlertCircle,
|
|
Eye,
|
|
EyeOff,
|
|
} from '@/components/icons'
|
|
|
|
const route = useRoute()
|
|
const settingsStore = useSettingsStore()
|
|
const { settings } = storeToRefs(settingsStore)
|
|
|
|
// 状态
|
|
const isLoading = ref(false)
|
|
const isVerifying = ref(false)
|
|
const isVerified = ref(false)
|
|
const password = ref('')
|
|
const showPassword = ref(false)
|
|
const error = ref('')
|
|
const verifyError = ref('')
|
|
const shareInfo = ref<ShareGetResponse | null>(null)
|
|
const shareData = ref<Share | null>(null)
|
|
const activeConversationIndex = ref(0)
|
|
|
|
// 计算属性
|
|
const isDark = computed(() => {
|
|
if (settings.value.theme === 'system') {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
}
|
|
return settings.value.theme === 'dark'
|
|
})
|
|
|
|
const formatExpiresAt = computed(() => {
|
|
if (!shareInfo.value) return ''
|
|
return formatTimestamp(shareInfo.value.expiresAt)
|
|
})
|
|
|
|
const activeConversation = computed(() => {
|
|
return shareData.value?.conversations?.[activeConversationIndex.value]
|
|
})
|
|
|
|
const visibleMessages = computed(() => {
|
|
if (!activeConversation.value?.messages) return []
|
|
return activeConversation.value.messages.filter(
|
|
(message) => message.role !== MessageRole.SYSTEM
|
|
)
|
|
})
|
|
|
|
// 方法
|
|
async function loadShareInfo() {
|
|
const shareId = route.params.id as string
|
|
if (!shareId) {
|
|
error.value = '无效的分享链接'
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
shareInfo.value = await shareApi.getShare(shareId)
|
|
|
|
if (shareInfo.value.isExpired) {
|
|
error.value = '分享链接已过期'
|
|
return
|
|
}
|
|
|
|
// 如果没有密码保护,直接验证
|
|
if (!shareInfo.value.hasPassword) {
|
|
await verifyWithPassword('')
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load share info:', err)
|
|
error.value = '分享不存在或已过期'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleVerify() {
|
|
if (!password.value || isVerifying.value) return
|
|
await verifyWithPassword(password.value)
|
|
}
|
|
|
|
async function verifyWithPassword(pwd: string) {
|
|
const shareId = route.params.id as string
|
|
if (!shareId) return
|
|
|
|
isVerifying.value = true
|
|
verifyError.value = ''
|
|
|
|
try {
|
|
const passwordHash = pwd ? await hashPassword(pwd) : ''
|
|
const result = await shareApi.verifyShare(shareId, { passwordHash })
|
|
|
|
if (result.valid && result.share) {
|
|
shareData.value = result.share
|
|
isVerified.value = true
|
|
} else {
|
|
verifyError.value = '密码错误,请重试'
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to verify share:', err)
|
|
verifyError.value = '验证失败,请重试'
|
|
} finally {
|
|
isVerifying.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadShareInfo()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.share-view {
|
|
width: 100%;
|
|
min-height: 100vh;
|
|
background: #ffffff;
|
|
|
|
&.dark {
|
|
background: #11111b;
|
|
color: #e5e7eb;
|
|
}
|
|
}
|
|
|
|
// 加载状态
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
gap: 16px;
|
|
color: #6b7280;
|
|
|
|
.spin {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
// 错误状态
|
|
.error-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
gap: 16px;
|
|
color: #6b7280;
|
|
|
|
.error-message {
|
|
font-size: 16px;
|
|
color: #ef4444;
|
|
margin: 0;
|
|
}
|
|
|
|
.retry-btn {
|
|
padding: 10px 24px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: #3b82f6;
|
|
color: white;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: #2563eb;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 密码验证
|
|
.password-verify {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
.verify-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.verify-icon {
|
|
color: #6b7280;
|
|
}
|
|
|
|
.verify-title {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
|
|
.dark & {
|
|
color: #f3f4f6;
|
|
}
|
|
}
|
|
|
|
.verify-hint {
|
|
font-size: 14px;
|
|
color: #6b7280;
|
|
margin: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.share-info {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-top: 8px;
|
|
|
|
.info-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 13px;
|
|
color: #6b7280;
|
|
|
|
&.expired {
|
|
color: #ef4444;
|
|
}
|
|
}
|
|
}
|
|
|
|
.password-input-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
max-width: 320px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.password-input {
|
|
width: 100%;
|
|
padding: 14px 48px 14px 16px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
text-align: center;
|
|
letter-spacing: 2px;
|
|
|
|
.dark & {
|
|
background: #2d2d3d;
|
|
border-color: #374151;
|
|
color: #f3f4f6;
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: #3b82f6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
}
|
|
|
|
.toggle-visibility {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: #6b7280;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
|
|
.dark & {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
}
|
|
}
|
|
|
|
.verify-error {
|
|
font-size: 13px;
|
|
color: #ef4444;
|
|
margin: 0;
|
|
}
|
|
|
|
.verify-btn {
|
|
width: 100%;
|
|
max-width: 320px;
|
|
padding: 14px;
|
|
border: none;
|
|
border-radius: 12px;
|
|
background: #3b82f6;
|
|
color: white;
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
&:hover:not(:disabled) {
|
|
background: #2563eb;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
// 分享内容
|
|
.share-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
height: 100%;
|
|
}
|
|
|
|
.share-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
background: white;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
|
|
.dark & {
|
|
background: #1e1e2e;
|
|
border-bottom-color: #2d2d3d;
|
|
}
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.share-title {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
|
|
.dark & {
|
|
color: #f3f4f6;
|
|
}
|
|
}
|
|
|
|
.conversation-count {
|
|
font-size: 13px;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.theme-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: #f3f4f6;
|
|
color: #6b7280;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
.dark & {
|
|
background: #2d2d3d;
|
|
color: #e5e7eb;
|
|
}
|
|
|
|
&:hover {
|
|
background: #e5e7eb;
|
|
|
|
.dark & {
|
|
background: #374151;
|
|
}
|
|
}
|
|
}
|
|
|
|
.conversation-tabs {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 12px 24px;
|
|
background: #f9fafb;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
overflow-x: auto;
|
|
|
|
.dark & {
|
|
background: #1e1e2e;
|
|
border-bottom-color: #2d2d3d;
|
|
}
|
|
}
|
|
|
|
.tab-btn {
|
|
flex-shrink: 0;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: white;
|
|
color: #6b7280;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
|
|
.dark & {
|
|
background: #2d2d3d;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
&:hover {
|
|
background: #e5e7eb;
|
|
|
|
.dark & {
|
|
background: #374151;
|
|
}
|
|
}
|
|
|
|
&.active {
|
|
background: #3b82f6;
|
|
color: white;
|
|
}
|
|
}
|
|
|
|
.message-list {
|
|
flex: 1;
|
|
padding: 20px 0;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|