feat: 分享页面改为独立路由渲染
- 引入 vue-router,配置 / 和 /share/:id 两个路由
- 新增 ShareView.vue 独立页面,复用 MessageBubble 组件渲染消息
- 新增 HomeView.vue 提取主应用逻辑
- 分享链接格式改为 /chat-ui/share/{id}
- 删除废弃的 ShareViewModal.vue 对话框组件
- 清理 settingsStore 中废弃的 showShareViewModal 状态
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4d2caddeee
commit
9566c6e0c4
108
src/App.vue
108
src/App.vue
|
|
@ -1,19 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ dark: isDark }">
|
<div class="app" :class="{ dark: isDark }">
|
||||||
<!-- 侧边栏 -->
|
<router-view />
|
||||||
<ChatSidebar />
|
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
|
|
||||||
|
|
||||||
<!-- 模态框 -->
|
|
||||||
<SearchModal />
|
|
||||||
<ShortcutsModal />
|
|
||||||
<SettingsModal />
|
|
||||||
<ConversationSettingsModal />
|
|
||||||
<ShareModal />
|
|
||||||
<ShareResultModal />
|
|
||||||
<ShareViewModal />
|
|
||||||
|
|
||||||
<!-- Toast 通知 -->
|
<!-- Toast 通知 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
|
@ -35,32 +22,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useKeyboard, getDefaultShortcuts } from "@/composables/useKeyboard";
|
|
||||||
import ChatSidebar from "@/components/sidebar/ChatSidebar.vue";
|
|
||||||
import ChatMain from "@/components/chat/ChatMain.vue";
|
|
||||||
import SearchModal from "@/components/modals/SearchModal.vue";
|
|
||||||
import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
|
|
||||||
import SettingsModal from "@/components/modals/SettingsModal.vue";
|
|
||||||
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
|
|
||||||
import ShareModal from "@/components/modals/ShareModal.vue";
|
|
||||||
import ShareResultModal from "@/components/modals/ShareResultModal.vue";
|
|
||||||
import ShareViewModal from "@/components/modals/ShareViewModal.vue";
|
|
||||||
import { Check, AlertCircle, Info } from "@/components/icons";
|
import { Check, AlertCircle, Info } from "@/components/icons";
|
||||||
import { useAuthStore } from "./stores/auth";
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
// Stores
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const { settings } = storeToRefs(settingsStore);
|
const { settings } = storeToRefs(settingsStore);
|
||||||
|
|
||||||
// Refs
|
|
||||||
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
|
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isDark = computed(() => {
|
const isDark = computed(() => {
|
||||||
if (settings.value.theme === "system") {
|
if (settings.value.theme === "system") {
|
||||||
|
|
@ -91,77 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法
|
|
||||||
function toggleSidebar() {
|
|
||||||
settingsStore.toggleSidebar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function newChat() {
|
|
||||||
chatStore.createConversation();
|
|
||||||
showToast("已创建新对话", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusInput() {
|
|
||||||
chatMainRef.value?.focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快捷键
|
|
||||||
useKeyboard(
|
|
||||||
getDefaultShortcuts({
|
|
||||||
newChat,
|
|
||||||
toggleSidebar,
|
|
||||||
focusInput,
|
|
||||||
sendMessage: () => {}, // 由 ChatInput 内部处理
|
|
||||||
cancelStream: () => {
|
|
||||||
if (chatStore.isStreaming) {
|
|
||||||
chatStore.stopStreaming();
|
|
||||||
showToast("已停止生成", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toggleTheme: () => {
|
|
||||||
settingsStore.toggleTheme();
|
|
||||||
showToast(`主题已切换为 ${settings.value.theme}`, "success");
|
|
||||||
},
|
|
||||||
showShortcuts: () => {
|
|
||||||
settingsStore.openShortcutsModal();
|
|
||||||
},
|
|
||||||
searchConversations: () => {
|
|
||||||
settingsStore.openSearchModal();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
authStore.init();
|
|
||||||
console.log(authStore.token);
|
|
||||||
|
|
||||||
// 检测 Hash 路由
|
|
||||||
handleHashRoute();
|
|
||||||
|
|
||||||
// 监听 hash 变化
|
|
||||||
window.addEventListener('hashchange', handleHashRoute);
|
|
||||||
|
|
||||||
// // 如果没有对话,创建一个
|
|
||||||
// if (chatStore.conversations.length === 0) {
|
|
||||||
// chatStore.createConversation();
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hash 路由处理
|
|
||||||
function handleHashRoute() {
|
|
||||||
const hash = window.location.hash;
|
|
||||||
const shareMatch = hash.match(/^#\/share\/(.+)$/);
|
|
||||||
|
|
||||||
if (shareMatch) {
|
|
||||||
const shareId = shareMatch[1];
|
|
||||||
// 打开分享查看模态框
|
|
||||||
settingsStore.openShareViewModal();
|
|
||||||
// 这里可以添加加载分享数据的逻辑
|
|
||||||
console.log('Share ID:', shareId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露给全局使用
|
// 暴露给全局使用
|
||||||
window.$toast = showToast;
|
window.$toast = showToast;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -143,15 +143,18 @@ async function handleCreateShare() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 关闭当前模态框,打开结果模态框
|
// 关闭当前模态框,打开结果模态框
|
||||||
handleClose();
|
handleClose()
|
||||||
|
|
||||||
|
// 前端生成分享 URL
|
||||||
|
const shareUrl = `${window.location.origin}/chat-ui/share/${result.id}`
|
||||||
|
|
||||||
// 存储分享信息并打开结果模态框
|
// 存储分享信息并打开结果模态框
|
||||||
settingsStore.setShareResult({
|
settingsStore.setShareResult({
|
||||||
shareId: result.id,
|
shareId: result.id,
|
||||||
shareUrl: result.shareUrl,
|
shareUrl,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
expiresAt: result.expiresAt,
|
expiresAt: result.expiresAt,
|
||||||
});
|
})
|
||||||
settingsStore.openShareResultModal();
|
settingsStore.openShareResultModal();
|
||||||
|
|
||||||
// 清空密码和退出选择模式
|
// 清空密码和退出选择模式
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,6 @@ const shareUrl = computed(() => shareResult.value?.shareUrl || '');
|
||||||
const password = computed(() => shareResult.value?.password || '');
|
const password = computed(() => shareResult.value?.password || '');
|
||||||
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
|
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
|
||||||
|
|
||||||
const linkInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const passwordInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const linkCopied = ref(false);
|
const linkCopied = ref(false);
|
||||||
const passwordCopied = ref(false);
|
const passwordCopied = ref(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,550 +0,0 @@
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="modal">
|
|
||||||
<div v-if="show" class="modal-overlay" @click.self="handleClose">
|
|
||||||
<div class="modal-container share-view-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>查看分享内容</h3>
|
|
||||||
<button class="close-btn" @click="handleClose">
|
|
||||||
<X :size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-content">
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="isLoading" class="loading-state">
|
|
||||||
<Loader2 :size="32" class="spin" />
|
|
||||||
<span>加载中...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 密码验证 -->
|
|
||||||
<div v-else-if="!isVerified" class="password-verify">
|
|
||||||
<div class="verify-icon">
|
|
||||||
<Lock :size="48" />
|
|
||||||
</div>
|
|
||||||
<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="password"
|
|
||||||
class="password-input"
|
|
||||||
placeholder="请输入访问密码"
|
|
||||||
@keydown.enter="handleVerify"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="verify-btn"
|
|
||||||
:disabled="!password || isVerifying"
|
|
||||||
@click="handleVerify"
|
|
||||||
>
|
|
||||||
{{ isVerifying ? '验证中...' : '查看内容' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分享内容 -->
|
|
||||||
<div v-else class="share-content">
|
|
||||||
<div class="content-header">
|
|
||||||
<span class="conversation-count">
|
|
||||||
共 {{ shareData?.conversations?.length || 0 }} 个对话
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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">
|
|
||||||
<div
|
|
||||||
v-for="message in activeConversation.messages"
|
|
||||||
:key="message.id"
|
|
||||||
class="message-item"
|
|
||||||
:class="message.role"
|
|
||||||
>
|
|
||||||
<div class="message-role">
|
|
||||||
{{ message.role === 'user' ? '用户' : '助手' }}
|
|
||||||
</div>
|
|
||||||
<div class="message-content">
|
|
||||||
{{ message.content.text }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from "vue";
|
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
|
||||||
import { shareApi } from "@/services/shareApi";
|
|
||||||
import { hashPassword } from "@/utils/crypto";
|
|
||||||
import { formatTimestamp } from "@/utils/helpers";
|
|
||||||
import type { Share, ShareGetResponse } from "@/types/share";
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Lock,
|
|
||||||
Loader2,
|
|
||||||
MessageSquare,
|
|
||||||
Clock,
|
|
||||||
AlertCircle,
|
|
||||||
} from "@/components/icons";
|
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
const show = computed(() => settingsStore.showShareViewModal);
|
|
||||||
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const isVerifying = ref(false);
|
|
||||||
const isVerified = ref(false);
|
|
||||||
const password = ref("");
|
|
||||||
const errorMsg = ref("");
|
|
||||||
const shareInfo = ref<ShareGetResponse | null>(null);
|
|
||||||
const shareData = ref<Share | null>(null);
|
|
||||||
const activeConversationIndex = ref(0);
|
|
||||||
|
|
||||||
const activeConversation = computed(() => {
|
|
||||||
return shareData.value?.conversations?.[activeConversationIndex.value];
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatExpiresAt = computed(() => {
|
|
||||||
if (!shareInfo.value) return "";
|
|
||||||
return formatTimestamp(shareInfo.value.expiresAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取分享 ID
|
|
||||||
function getShareIdFromHash(): string | null {
|
|
||||||
const hash = window.location.hash;
|
|
||||||
const match = hash.match(/^#\/share\/(.+)$/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载分享信息
|
|
||||||
async function loadShareInfo() {
|
|
||||||
const shareId = getShareIdFromHash();
|
|
||||||
if (!shareId) {
|
|
||||||
errorMsg.value = "无效的分享链接";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
|
||||||
errorMsg.value = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
shareInfo.value = await shareApi.getShare(shareId);
|
|
||||||
|
|
||||||
// 如果已过期
|
|
||||||
if (shareInfo.value.isExpired) {
|
|
||||||
errorMsg.value = "分享链接已过期";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有密码保护,直接验证
|
|
||||||
if (!shareInfo.value.hasPassword) {
|
|
||||||
await verifyWithPassword("");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load share info:", error);
|
|
||||||
errorMsg.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 = getShareIdFromHash();
|
|
||||||
if (!shareId) return;
|
|
||||||
|
|
||||||
isVerifying.value = true;
|
|
||||||
errorMsg.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 {
|
|
||||||
errorMsg.value = "密码错误,请重试";
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to verify share:", error);
|
|
||||||
errorMsg.value = "验证失败,请重试";
|
|
||||||
} finally {
|
|
||||||
isVerifying.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
settingsStore.closeShareViewModal();
|
|
||||||
// 清除 hash
|
|
||||||
window.location.hash = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听模态框打开
|
|
||||||
watch(show, (newVal: boolean) => {
|
|
||||||
if (newVal) {
|
|
||||||
loadShareInfo();
|
|
||||||
} else {
|
|
||||||
// 重置状态
|
|
||||||
password.value = "";
|
|
||||||
errorMsg.value = "";
|
|
||||||
isVerified.value = false;
|
|
||||||
shareInfo.value = null;
|
|
||||||
shareData.value = null;
|
|
||||||
activeConversationIndex.value = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 640px;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: #1e1e2e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
border-bottom-color: #2d2d3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: #6b7280;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
color: #374151;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 40px;
|
|
||||||
color: #6b7280;
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-verify {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-icon {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-hint {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 300px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: #2d2d3d;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-msg {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #ef4444;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verify-btn {
|
|
||||||
padding: 12px 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
font-size: 14px;
|
|
||||||
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;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation-count {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
|
||||||
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;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: #2d2d3d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-item {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.user {
|
|
||||||
.message-role {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.assistant {
|
|
||||||
.message-role {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-role {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动画
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -117,12 +117,6 @@ function handleToggleSelect() {
|
||||||
emit("toggleSelect", props.conversation.id);
|
emit("toggleSelect", props.conversation.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect() {
|
|
||||||
if (!isEditing.value) {
|
|
||||||
emit("select", props.conversation.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTogglePin() {
|
function handleTogglePin() {
|
||||||
emit("togglePin", props.conversation.id);
|
emit("togglePin", props.conversation.id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
|
import router from "./router";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
// 样式
|
// 样式
|
||||||
|
|
@ -15,6 +16,9 @@ const app = createApp(App);
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
|
||||||
|
// 使用 Router
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
// 挂载应用
|
// 挂载应用
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory('/chat-ui/'),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:id',
|
||||||
|
name: 'share',
|
||||||
|
component: () => import('@/views/ShareView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
*/
|
*/
|
||||||
import { getAuthHeaders } from './request';
|
import { getAuthHeaders } from './request';
|
||||||
import type {
|
import type {
|
||||||
Share,
|
|
||||||
ShareCreateRequest,
|
ShareCreateRequest,
|
||||||
ShareCreateResponse,
|
ShareCreateResponse,
|
||||||
ShareVerifyRequest,
|
ShareVerifyRequest,
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
// 分享相关状态
|
// 分享相关状态
|
||||||
const showShareModal = ref(false);
|
const showShareModal = ref(false);
|
||||||
const showShareResultModal = ref(false);
|
const showShareResultModal = ref(false);
|
||||||
const showShareViewModal = ref(false);
|
|
||||||
const shareResult = ref<ShareResult | null>(null);
|
const shareResult = ref<ShareResult | null>(null);
|
||||||
|
|
||||||
// 主题相关
|
// 主题相关
|
||||||
|
|
@ -206,14 +205,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
showShareResultModal.value = false;
|
showShareResultModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShareViewModal() {
|
|
||||||
showShareViewModal.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeShareViewModal() {
|
|
||||||
showShareViewModal.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setShareResult(result: ShareResult) {
|
function setShareResult(result: ShareResult) {
|
||||||
shareResult.value = result;
|
shareResult.value = result;
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +339,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
// 分享相关状态
|
// 分享相关状态
|
||||||
showShareModal,
|
showShareModal,
|
||||||
showShareResultModal,
|
showShareResultModal,
|
||||||
showShareViewModal,
|
|
||||||
shareResult,
|
shareResult,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
|
|
@ -370,8 +360,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||||
closeShareModal,
|
closeShareModal,
|
||||||
openShareResultModal,
|
openShareResultModal,
|
||||||
closeShareResultModal,
|
closeShareResultModal,
|
||||||
openShareViewModal,
|
|
||||||
closeShareViewModal,
|
|
||||||
setShareResult,
|
setShareResult,
|
||||||
clearShareResult,
|
clearShareResult,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<template>
|
||||||
|
<div class="home-view">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<ChatSidebar />
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
|
||||||
|
|
||||||
|
<!-- 模态框 -->
|
||||||
|
<SearchModal />
|
||||||
|
<ShortcutsModal />
|
||||||
|
<SettingsModal />
|
||||||
|
<ConversationSettingsModal />
|
||||||
|
<ShareModal />
|
||||||
|
<ShareResultModal />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useKeyboard, getDefaultShortcuts } from '@/composables/useKeyboard'
|
||||||
|
import ChatSidebar from '@/components/sidebar/ChatSidebar.vue'
|
||||||
|
import ChatMain from '@/components/chat/ChatMain.vue'
|
||||||
|
import SearchModal from '@/components/modals/SearchModal.vue'
|
||||||
|
import ShortcutsModal from '@/components/modals/ShortcutsModal.vue'
|
||||||
|
import SettingsModal from '@/components/modals/SettingsModal.vue'
|
||||||
|
import ConversationSettingsModal from '@/components/modals/ConversationSettingsModal.vue'
|
||||||
|
import ShareModal from '@/components/modals/ShareModal.vue'
|
||||||
|
import ShareResultModal from '@/components/modals/ShareResultModal.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null)
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
settingsStore.toggleSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
chatStore.createConversation()
|
||||||
|
window.$toast?.('已创建新对话', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
chatMainRef.value?.focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷键
|
||||||
|
useKeyboard(
|
||||||
|
getDefaultShortcuts({
|
||||||
|
newChat,
|
||||||
|
toggleSidebar,
|
||||||
|
focusInput,
|
||||||
|
sendMessage: () => {},
|
||||||
|
cancelStream: () => {
|
||||||
|
if (chatStore.isStreaming) {
|
||||||
|
chatStore.stopStreaming()
|
||||||
|
window.$toast?.('已停止生成', 'info')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleTheme: () => {
|
||||||
|
settingsStore.toggleTheme()
|
||||||
|
window.$toast?.(`主题已切换`, 'success')
|
||||||
|
},
|
||||||
|
showShortcuts: () => {
|
||||||
|
settingsStore.openShortcutsModal()
|
||||||
|
},
|
||||||
|
searchConversations: () => {
|
||||||
|
settingsStore.openSearchModal()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 初始化认证
|
||||||
|
authStore.init()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home-view {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,571 @@
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function toggleTheme() {
|
||||||
|
settingsStore.toggleTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
Loading…
Reference in New Issue