refactor: modularize frontend flows

This commit is contained in:
Lan
2026-06-03 02:01:57 +08:00
parent 9300607f96
commit a11e7900b4
85 changed files with 4654 additions and 4363 deletions
+39 -416
View File
@@ -42,24 +42,23 @@
<FileDetailModal
:visible="!!selectedRecord"
:record="selectedRecord"
@close="selectedRecord = null"
@show-content-preview="showContentPreview"
:get-download-url="getDownloadUrl"
:get-qr-code-value="getQRCodeValue"
@close="closeDetails"
@preview-content="showContentPreview"
/>
<ContentPreviewModal
:visible="showPreview"
:rendered-content="renderedContent"
@close="showPreview = false"
@close="closeContentPreview"
@copy-content="copyContent"
/>
</div>
</template>
<script setup lang="ts">
import { ref, inject, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { inject, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import PageHeader from '@/components/common/PageHeader.vue'
import RetrieveForm from '@/components/common/RetrieveForm.vue'
import PageFooter from '@/components/common/PageFooter.vue'
@@ -67,424 +66,48 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
import FileDetailModal from '@/components/common/FileDetailModal.vue'
import FileRecordList from '@/components/common/FileRecordList.vue'
import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue'
import { useRetrieveFlow } from '@/composables'
import { useConfigStore } from '@/stores/configStore'
// 定义数据接口
interface FileRecord {
id: number
code: string
filename: string
size: string
downloadUrl: string | null
content: string | null
date: string
}
interface InputStatus {
readonly: boolean
loading: boolean
}
import { useRouter, useRoute } from 'vue-router'
import { useFileDataStore } from '@/stores/fileData'
import { storeToRefs } from 'pinia'
import api from '@/utils/api'
import type { ApiResponse } from '@/types'
interface RetrieveResponse {
code: string
name: string
text: string
size: number
}
import { saveAs } from 'file-saver'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useAlertStore } from '@/stores/alertStore'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const alertStore = useAlertStore()
const baseUrl = window.location.origin
const router = useRouter()
const isDarkMode = inject('isDarkMode')
const fileStore = useFileDataStore()
const { receiveData } = storeToRefs(fileStore)
const code = ref('')
const inputStatus = ref<InputStatus>({
readonly: false,
loading: false
})
const error = ref('')
const selectedRecord = ref<FileRecord | null>(null)
const showDrawer = ref(false)
const route = useRoute()
// 使用 receiveData 替代原来的 records
const records = receiveData
const config = JSON.parse(localStorage.getItem('config') || '{}')
const codeInput = ref<HTMLInputElement | null>(null)
onMounted(() => {
if (codeInput.value) {
codeInput.value.focus()
}
const query_code = route.query.code
if (query_code && typeof query_code === 'string') {
code.value = query_code
}
})
watch(code, (newVal) => {
if (newVal.length === 5) {
handleSubmit()
}
})
const getDownloadUrl = (record: FileRecord) => {
if (record.downloadUrl) {
if (record.downloadUrl.startsWith('http')) {
return record.downloadUrl
} else {
return `${baseUrl}${record.downloadUrl}`
}
}
return ''
}
// 在其他代码后添加复制功能
const copyContent = async () => {
if (selectedRecord.value && selectedRecord.value.content) {
await copyToClipboard(selectedRecord.value.content, {
successMsg: t('fileRecord.contentCopied'),
errorMsg: t('fileRecord.copyFailed')
})
}
}
const handleSubmit = async () => {
if (code.value.length !== 5) {
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
return
}
inputStatus.value.readonly = true
inputStatus.value.loading = true
try {
const response = await api.post('/share/select/', {
code: code.value
})
const res = (response.data || response) as ApiResponse<RetrieveResponse>
if (res && res.code === 200) {
if (res.detail) {
const isFile = res.detail.text.startsWith('/share/download') || res.detail.name !== 'Text'
const newFileData = {
id: Date.now(),
code: res.detail.code,
filename: res.detail.name,
size: formatFileSize(res.detail.size),
downloadUrl: isFile ? res.detail.text : null,
content: isFile ? null : res.detail.text,
date: new Date().toLocaleString()
}
let flag = true
fileStore.receiveData.forEach((file: FileRecord) => {
if (file.code === newFileData.code) {
flag = false
}
})
if (flag) {
fileStore.addReceiveData(newFileData)
}
if (isFile) {
selectedRecord.value = newFileData
} else {
selectedRecord.value = newFileData
showPreview.value = true
}
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
} else {
alertStore.showAlert(t('retrieve.messages.invalidCodeError'), 'error')
}
} else {
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
}
} catch (err: unknown) {
console.error('Retrieve failed:', err)
const error = err as { response?: { data?: { detail?: string } }; message?: string }
const errorMessage = error?.response?.data?.detail || error?.message || t('retrieve.messages.unknownError')
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
} finally {
inputStatus.value.readonly = false
inputStatus.value.loading = false
code.value = ''
}
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 ' + t('fileSize.bytes')
const k = 1024
const sizes = [t('fileSize.bytes'), t('fileSize.kb'), t('fileSize.mb'), t('fileSize.gb'), t('fileSize.tb')]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const viewDetails = (record: FileRecord) => {
selectedRecord.value = record
}
const deleteRecord = (id: number) => {
const index = records.value.findIndex((record: FileRecord) => record.id === id)
if (index !== -1) {
fileStore.deleteReceiveData(index)
}
}
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
const router = useRouter()
const configStore = useConfigStore()
const { config } = storeToRefs(configStore)
const {
code,
inputStatus,
error,
records,
selectedRecord,
showDrawer,
showPreview,
renderedContent,
closeContentPreview,
closeDetails,
copyContent,
deleteRecord,
downloadRecord,
handleSubmit,
showContentPreview,
toggleDrawer,
viewDetails
} = useRetrieveFlow()
const toSend = () => {
router.push('/send')
}
const getQRCodeValue = (record: FileRecord) => {
if (record.downloadUrl) {
return `${baseUrl}${record.downloadUrl}`
} else {
return `${baseUrl}?code=${record.code}`
onMounted(() => {
const queryCode = route.query.code
if (queryCode && typeof queryCode === 'string') {
code.value = queryCode
}
}
})
const downloadRecord = (record: FileRecord) => {
if (record.downloadUrl) {
// 如果是文件,直接下载
window.open(
`${record.downloadUrl.startsWith('http') ? '' : baseUrl}${record.downloadUrl}`,
'_blank'
)
} else if (record.content) {
// 如果是文本,转成txt下载
const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${record.filename}.txt`)
watch(code, (newCode) => {
if (newCode.length === 5) {
void handleSubmit()
}
}
const showPreview = ref(false)
const renderedContent = ref('')
// 监听selectedRecord变化,异步渲染内容
watch(
() => selectedRecord.value?.content,
async (content) => {
if (content) {
try {
// 使用 marked 解析 Markdown,然后用 DOMPurify 清理 HTML 防止 XSS
const rawHtml = await marked(content)
renderedContent.value = DOMPurify.sanitize(rawHtml, {
// 允许的标签和属性
ALLOWED_TAGS: [
'p',
'br',
'strong',
'em',
'u',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'a',
'img'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
// 禁用危险的协议
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
})
} catch (error) {
console.error('Markdown 渲染失败:', error)
renderedContent.value = content // fallback 到原始内容
}
} else {
renderedContent.value = ''
}
},
{ immediate: true }
)
const showContentPreview = () => {
showPreview.value = true
}
})
</script>
<style scoped>
@keyframes blob {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(20px, -50px) scale(1.1);
}
50% {
transform: translate(-20px, 20px) scale(0.9);
}
75% {
transform: translate(50px, 50px) scale(1.05);
}
}
@media (min-width: 640px) {
.sm\:w-120 {
width: 30rem;
/* 480px */
}
}
.animate-spin-slow {
animation: spin 8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.w-97-100 {
width: 97%;
}
/* 添加 Markdown 样式 */
:deep(.prose) {
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
:deep(.prose h1),
:deep(.prose h2),
:deep(.prose h3),
:deep(.prose h4),
:deep(.prose h5),
:deep(.prose h6) {
color: rgb(79, 70, 229);
/* text-indigo-600 */
word-wrap: break-word;
overflow-wrap: break-word;
}
:deep(.prose p),
:deep(.prose div),
:deep(.prose span),
:deep(.prose code),
:deep(.prose pre) {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
:deep(.prose pre) {
white-space: pre-wrap;
overflow-x: auto;
}
:deep(.prose code) {
white-space: pre-wrap;
}
@media (prefers-color-scheme: dark) {
:deep(.prose h1),
:deep(.prose h2),
:deep(.prose h3),
:deep(.prose h4),
:deep(.prose h5),
:deep(.prose h6) {
color: rgb(129, 140, 248);
/* text-indigo-400 */
}
}
/* 添加新的宽度类 */
@media (min-width: 640px) {
.sm\:w-120 {
width: 30rem;
/* 480px */
}
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.3) rgba(243, 244, 246, 0.5);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(243, 244, 246, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
transition: background-color 0.3s;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* 深色模式下的滚动条样式 */
:deep([class*='dark']) .custom-scrollbar {
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(31, 41, 55, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(75, 85, 99, 0.5);
}
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.7);
}
/* 确保滚动容器有背景色 */
.custom-scrollbar {
background: inherit;
}
/* 文本换行相关样式 */
.break-words {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>