This commit is contained in:
cuijiawang 2025-11-05 14:42:50 +08:00
parent aa900531b6
commit 0f6c1a0094
18 changed files with 3007 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

104
README.md
View File

@ -0,0 +1,104 @@
# 文本阅读器 - 用户端
这是一个基于 Vue 3 + TypeScript + Vite + Vant 构建的移动端文本阅读器应用。
## 功能特性
- 📱 响应式设计,自适应各种屏幕尺寸
- 📖 流畅的文本阅读体验
- 🔍 文件搜索功能
- 🎨 多种阅读主题(白色、护眼黄、绿色、灰色、夜间模式)
- 📏 可调节字体大小和行间距
- 💾 自动保存阅读设置
- ⚡ 下拉刷新
## 技术栈
- Vue 3
- TypeScript
- Vite
- Vant 4移动端组件库
- Vue Router
- Pinia状态管理
- Axios
- Sass
## 快速开始
### 安装依赖
```bash
npm install
# 或
pnpm install
```
### 开发模式
```bash
npm run dev
```
### 生产构建
```bash
npm run build
```
### 预览生产构建
```bash
npm run preview
```
## 目录结构
```
src/
├── api/ # API 接口
│ ├── request.ts # axios 封装
│ └── reader.ts # 阅读器相关接口
├── router/ # 路由配置
├── styles/ # 全局样式
├── views/ # 页面组件
│ ├── Home.vue # 首页(文件列表)
│ └── Reader.vue # 阅读页面
├── App.vue # 根组件
└── main.ts # 入口文件
```
## 配置说明
### API 代理配置
`vite.config.ts` 中配置了 API 代理:
```typescript
server: {
proxy: {
'/api': {
target: 'http://localhost:9220',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
}
```
请根据实际后端服务地址修改 `target`
## 使用说明
1. 在首页可以查看所有可用的文本文件
2. 点击文件卡片进入阅读页面
3. 在阅读页面点击右上角设置图标可以调整阅读设置
4. 支持下拉刷新文件列表
5. 支持搜索文件名或描述
## 注意事项
- 确保后端服务已启动并运行在正确的端口
- 建议使用现代浏览器以获得最佳体验
- 移动端访问时建议添加到主屏幕以获得类似原生应用的体验

22
components.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanButton: typeof import('vant/es')['Button']
VanEmpty: typeof import('vant/es')['Empty']
VanIcon: typeof import('vant/es')['Icon']
VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search']
VanSlider: typeof import('vant/es')['Slider']
}
}

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>文本阅读器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "agileboot-reader-mobile",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"axios": "^1.6.2",
"pinia": "^2.1.7",
"vant": "^4.8.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.27",
"unplugin-vue-components": "^0.26.0",
"unplugin-auto-import": "^0.17.2",
"@types/node": "^20.10.0",
"sass": "^1.69.5"
}
}

1835
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<div id="app">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</template>
<script setup lang="ts">
// App
</script>
<style scoped>
#app {
width: 100%;
height: 100vh;
overflow: hidden;
}
</style>

54
src/api/reader.ts Normal file
View File

@ -0,0 +1,54 @@
import { request } from './request'
export interface TextFile {
fileId: number
fileName: string
originalFileName: string
filePath: string
fileSize: number
description: string
status: number
uploadUserId: number
uploadUserName: string
createTime: string
updateTime: string
}
export interface TextFileContent {
fileId: number
fileName: string
content: string
fileSize: number
}
/**
*
*/
export const getAllTextFiles = () => {
return request<TextFile[]>({
url: '/reader/public/files',
method: 'get'
})
}
/**
*
*/
export const getFileDetail = (fileId: number) => {
return request<TextFile>({
url: `/reader/public/file/${fileId}`,
method: 'get'
})
}
/**
*
*/
export const readFileContent = (fileId: number) => {
return request<TextFileContent>({
url: `/reader/public/read/${fileId}`,
method: 'get'
})
}

59
src/api/request.ts Normal file
View File

@ -0,0 +1,59 @@
import axios from 'axios'
import { showToast, showLoadingToast, closeToast } from 'vant'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
interface ResponseData<T = any> {
code: number
data: T
msg: string
}
const service: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ResponseData>) => {
const { code, data, msg } = response.data
if (code === 200 || code === 0) {
return response.data
} else {
showToast({
message: msg || '请求失败',
type: 'fail'
})
return Promise.reject(new Error(msg || '请求失败'))
}
},
(error) => {
showToast({
message: error.message || '网络错误',
type: 'fail'
})
return Promise.reject(error)
}
)
export default service
export const request = <T = any>(config: AxiosRequestConfig): Promise<ResponseData<T>> => {
return service.request<any, ResponseData<T>>(config)
}

87
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,87 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import 'vant/lib/index.css'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

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

@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '文本阅读器' }
},
{
path: '/reader/:id',
name: 'Reader',
component: () => import('@/views/Reader.vue'),
meta: { title: '阅读' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = to.meta.title as string
}
next()
})
export default router

79
src/styles/global.scss Normal file
View File

@ -0,0 +1,79 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f7f8fa;
}
#app {
width: 100%;
height: 100%;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
/* 列表项动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 文件卡片样式 */
.file-card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
}
/* 阅读器样式 */
.reader-content {
line-height: 1.8;
font-size: 16px;
color: #333;
padding: 20px;
background: #fff;
p {
margin-bottom: 1em;
text-indent: 2em;
}
}

223
src/views/Home.vue Normal file
View File

@ -0,0 +1,223 @@
<template>
<div class="home-container">
<!-- 导航栏 -->
<van-nav-bar title="文本阅读器" fixed placeholder>
<template #right>
<van-icon name="search" size="18" @click="showSearch = true" />
</template>
</van-nav-bar>
<!-- 搜索框 -->
<van-search
v-show="showSearch"
v-model="searchText"
placeholder="搜索文件名或描述"
@cancel="showSearch = false"
/>
<!-- 文件列表 -->
<div class="file-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-empty
v-if="filteredFiles.length === 0 && !loading"
description="暂无文件"
/>
<div v-else class="file-items">
<div
v-for="file in filteredFiles"
:key="file.fileId"
class="file-card"
@click="openFile(file.fileId)"
>
<div class="file-header">
<van-icon name="notes-o" size="24" color="#1989fa" />
<span class="file-name">{{ file.originalFileName }}</span>
</div>
<div v-if="file.description" class="file-description">
{{ file.description }}
</div>
<div class="file-footer">
<span class="file-size">{{ formatFileSize(file.fileSize) }}</span>
<span class="file-date">{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
</van-pull-refresh>
</div>
<!-- 加载中 -->
<van-loading v-if="loading" class="loading" size="24px" vertical>
加载中...
</van-loading>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAllTextFiles, type TextFile } from '@/api/reader'
import { showToast } from 'vant'
const router = useRouter()
const files = ref<TextFile[]>([])
const loading = ref(false)
const refreshing = ref(false)
const showSearch = ref(false)
const searchText = ref('')
//
const filteredFiles = computed(() => {
if (!searchText.value) {
return files.value
}
const keyword = searchText.value.toLowerCase()
return files.value.filter(
(file) =>
file.originalFileName.toLowerCase().includes(keyword) ||
(file.description && file.description.toLowerCase().includes(keyword))
)
})
//
const fetchFiles = async () => {
try {
loading.value = true
const response = await getAllTextFiles()
files.value = response.data || []
} catch (error) {
showToast('获取文件列表失败')
} finally {
loading.value = false
refreshing.value = false
}
}
//
const onRefresh = () => {
fetchFiles()
}
//
const openFile = (fileId: number) => {
router.push(`/reader/${fileId}`)
}
//
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KB`
} else {
return `${(size / (1024 * 1024)).toFixed(2)} MB`
}
}
//
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const day = 24 * 60 * 60 * 1000
if (diff < day) {
return '今天'
} else if (diff < 2 * day) {
return '昨天'
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`
} else {
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
onMounted(() => {
fetchFiles()
})
</script>
<style scoped lang="scss">
.home-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f7f8fa;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.file-items {
padding-bottom: 20px;
}
.file-card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
}
.file-header {
display: flex;
align-items: center;
margin-bottom: 8px;
.file-name {
margin-left: 8px;
font-size: 16px;
font-weight: 500;
color: #323233;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.file-description {
font-size: 14px;
color: #969799;
margin-bottom: 8px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.file-footer {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #c8c9cc;
.file-size,
.file-date {
display: inline-block;
}
}
.loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>

320
src/views/Reader.vue Normal file
View File

@ -0,0 +1,320 @@
<template>
<div class="reader-container">
<!-- 导航栏 -->
<van-nav-bar
:title="fileInfo?.fileName || '阅读'"
left-arrow
fixed
placeholder
@click-left="onBack"
>
<template #right>
<van-icon name="setting-o" size="18" @click="showSettings = true" />
</template>
</van-nav-bar>
<!-- 阅读内容 -->
<div class="reader-wrapper" :style="readerStyle">
<van-loading v-if="loading" class="loading" size="24px" vertical>
加载中...
</van-loading>
<div v-else class="reader-content" :style="contentStyle">
<h2 class="file-title">{{ fileInfo?.fileName }}</h2>
<div class="content-text" v-html="formattedContent"></div>
</div>
</div>
<!-- 设置面板 -->
<van-popup
v-model:show="showSettings"
position="bottom"
:style="{ height: '40%' }"
round
>
<div class="settings-panel">
<h3>阅读设置</h3>
<div class="setting-item">
<span>字体大小</span>
<div class="font-size-control">
<van-button
size="small"
@click="changeFontSize(-2)"
:disabled="fontSize <= 12"
>
A-
</van-button>
<span class="font-size-value">{{ fontSize }}px</span>
<van-button
size="small"
@click="changeFontSize(2)"
:disabled="fontSize >= 24"
>
A+
</van-button>
</div>
</div>
<div class="setting-item">
<span>行间距</span>
<van-slider
v-model="lineHeight"
:min="1.2"
:max="2.5"
:step="0.1"
active-color="#1989fa"
/>
</div>
<div class="setting-item">
<span>背景颜色</span>
<div class="theme-colors">
<div
v-for="theme in themes"
:key="theme.name"
class="theme-item"
:class="{ active: currentTheme === theme.name }"
:style="{ backgroundColor: theme.bgColor }"
@click="changeTheme(theme.name)"
>
<van-icon
v-if="currentTheme === theme.name"
name="success"
color="#fff"
/>
</div>
</div>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { readFileContent, type TextFileContent } from '@/api/reader'
import { showToast } from 'vant'
const router = useRouter()
const route = useRoute()
const fileInfo = ref<TextFileContent | null>(null)
const loading = ref(false)
const showSettings = ref(false)
//
const fontSize = ref(16)
const lineHeight = ref(1.8)
const currentTheme = ref('white')
const themes = [
{ name: 'white', bgColor: '#ffffff', textColor: '#333333' },
{ name: 'yellow', bgColor: '#fef8e6', textColor: '#333333' },
{ name: 'green', bgColor: '#e3f5e1', textColor: '#333333' },
{ name: 'gray', bgColor: '#f0f0f0', textColor: '#333333' },
{ name: 'dark', bgColor: '#1f1f1f', textColor: '#cccccc' }
]
//
const readerStyle = computed(() => {
const theme = themes.find((t) => t.name === currentTheme.value)
return {
backgroundColor: theme?.bgColor || '#ffffff'
}
})
const contentStyle = computed(() => {
const theme = themes.find((t) => t.name === currentTheme.value)
return {
fontSize: `${fontSize.value}px`,
lineHeight: lineHeight.value,
color: theme?.textColor || '#333333'
}
})
//
const formattedContent = computed(() => {
if (!fileInfo.value?.content) return ''
return fileInfo.value.content
.split('\n')
.filter((line) => line.trim())
.map((line) => `<p>${line}</p>`)
.join('')
})
//
const fetchContent = async () => {
try {
loading.value = true
const fileId = Number(route.params.id)
const response = await readFileContent(fileId)
fileInfo.value = response.data
} catch (error) {
showToast('获取文件内容失败')
router.back()
} finally {
loading.value = false
}
}
//
const onBack = () => {
router.back()
}
//
const changeFontSize = (delta: number) => {
const newSize = fontSize.value + delta
if (newSize >= 12 && newSize <= 24) {
fontSize.value = newSize
}
}
//
const changeTheme = (themeName: string) => {
currentTheme.value = themeName
}
onMounted(() => {
fetchContent()
// localStorage
const savedSettings = localStorage.getItem('readerSettings')
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings)
fontSize.value = settings.fontSize || 16
lineHeight.value = settings.lineHeight || 1.8
currentTheme.value = settings.theme || 'white'
} catch (e) {
console.error('读取设置失败', e)
}
}
})
//
const saveSettings = () => {
localStorage.setItem(
'readerSettings',
JSON.stringify({
fontSize: fontSize.value,
lineHeight: lineHeight.value,
theme: currentTheme.value
})
)
}
//
watch([fontSize, lineHeight, currentTheme], () => {
saveSettings()
})
</script>
<style scoped lang="scss">
.reader-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.reader-wrapper {
flex: 1;
overflow-y: auto;
transition: background-color 0.3s;
}
.reader-content {
max-width: 800px;
margin: 0 auto;
padding: 20px 16px;
transition: all 0.3s;
.file-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
text-align: center;
}
.content-text {
:deep(p) {
margin-bottom: 1em;
text-indent: 2em;
word-break: break-word;
}
}
}
.loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.settings-panel {
padding: 20px;
h3 {
font-size: 18px;
margin-bottom: 20px;
text-align: center;
}
.setting-item {
margin-bottom: 24px;
> span {
display: block;
font-size: 14px;
color: #646566;
margin-bottom: 12px;
}
}
.font-size-control {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
.font-size-value {
font-size: 14px;
color: #323233;
min-width: 50px;
text-align: center;
}
}
.theme-colors {
display: flex;
gap: 12px;
.theme-item {
width: 50px;
height: 50px;
border-radius: 8px;
border: 2px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: #1989fa;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
}
</style>

33
tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

37
vite.config.ts Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'
import AutoImport from 'unplugin-auto-import/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [VantResolver()],
}),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:18080', // 统一通过Gateway访问
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})