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:
肖应宇 2026-03-25 15:38:38 +08:00
parent 4d2caddeee
commit 9566c6e0c4
12 changed files with 695 additions and 681 deletions

View File

@ -1,19 +1,6 @@
<template>
<div class="app" :class="{ dark: isDark }">
<!-- 侧边栏 -->
<ChatSidebar />
<!-- 主内容区 -->
<ChatMain ref="chatMainRef" @toggle-sidebar="toggleSidebar" />
<!-- 模态框 -->
<SearchModal />
<ShortcutsModal />
<SettingsModal />
<ConversationSettingsModal />
<ShareModal />
<ShareResultModal />
<ShareViewModal />
<router-view />
<!-- Toast 通知 -->
<Teleport to="body">
@ -35,32 +22,14 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ref, computed } from "vue";
import { storeToRefs } from "pinia";
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 ShareViewModal from "@/components/modals/ShareViewModal.vue";
import { Check, AlertCircle, Info } from "@/components/icons";
import { useAuthStore } from "./stores/auth";
const authStore = useAuthStore();
// Stores
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const { settings } = storeToRefs(settingsStore);
// Refs
const chatMainRef = ref<InstanceType<typeof ChatMain> | null>(null);
//
const isDark = computed(() => {
if (settings.value.theme === "system") {
@ -91,77 +60,6 @@ function showToast(message: string, type: Toast["type"] = "info") {
}, 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;
</script>

View File

@ -143,15 +143,18 @@ async function handleCreateShare() {
});
//
handleClose();
handleClose()
// URL
const shareUrl = `${window.location.origin}/chat-ui/share/${result.id}`
//
settingsStore.setShareResult({
shareId: result.id,
shareUrl: result.shareUrl,
shareUrl,
password: password.value,
expiresAt: result.expiresAt,
});
})
settingsStore.openShareResultModal();
// 退

View File

@ -95,8 +95,6 @@ const shareUrl = computed(() => shareResult.value?.shareUrl || '');
const password = computed(() => shareResult.value?.password || '');
const expiresAt = computed(() => shareResult.value?.expiresAt || 0);
const linkInput = ref<HTMLInputElement | null>(null);
const passwordInput = ref<HTMLInputElement | null>(null);
const linkCopied = ref(false);
const passwordCopied = ref(false);

View File

@ -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>

View File

@ -117,12 +117,6 @@ function handleToggleSelect() {
emit("toggleSelect", props.conversation.id);
}
function handleSelect() {
if (!isEditing.value) {
emit("select", props.conversation.id);
}
}
function handleTogglePin() {
emit("togglePin", props.conversation.id);
}

View File

@ -31,7 +31,6 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";

View File

@ -1,5 +1,6 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
// 样式
@ -15,6 +16,9 @@ const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// 使用 Router
app.use(router);
// 挂载应用
app.mount("#app");

20
src/router/index.ts Normal file
View File

@ -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

View File

@ -3,7 +3,6 @@
*/
import { getAuthHeaders } from './request';
import type {
Share,
ShareCreateRequest,
ShareCreateResponse,
ShareVerifyRequest,

View File

@ -97,7 +97,6 @@ export const useSettingsStore = defineStore("settings", () => {
// 分享相关状态
const showShareModal = ref(false);
const showShareResultModal = ref(false);
const showShareViewModal = ref(false);
const shareResult = ref<ShareResult | null>(null);
// 主题相关
@ -206,14 +205,6 @@ export const useSettingsStore = defineStore("settings", () => {
showShareResultModal.value = false;
}
function openShareViewModal() {
showShareViewModal.value = true;
}
function closeShareViewModal() {
showShareViewModal.value = false;
}
function setShareResult(result: ShareResult) {
shareResult.value = result;
}
@ -348,7 +339,6 @@ export const useSettingsStore = defineStore("settings", () => {
// 分享相关状态
showShareModal,
showShareResultModal,
showShareViewModal,
shareResult,
// 方法
@ -370,8 +360,6 @@ export const useSettingsStore = defineStore("settings", () => {
closeShareModal,
openShareResultModal,
closeShareResultModal,
openShareViewModal,
closeShareViewModal,
setShareResult,
clearShareResult,
updateSettings,

90
src/views/HomeView.vue Normal file
View File

@ -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>

571
src/views/ShareView.vue Normal file
View File

@ -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>