feat
This commit is contained in:
parent
aa900531b6
commit
0f6c1a0094
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
104
README.md
@ -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
22
components.d.ts
vendored
Normal 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
15
index.html
Normal 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
30
package.json
Normal 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
1835
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
src/App.vue
Normal file
23
src/App.vue
Normal 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
54
src/api/reader.ts
Normal 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
59
src/api/request.ts
Normal 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
87
src/auto-imports.d.ts
vendored
Normal 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
15
src/main.ts
Normal 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
33
src/router/index.ts
Normal 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
79
src/styles/global.scss
Normal 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
223
src/views/Home.vue
Normal 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
320
src/views/Reader.vue
Normal 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
33
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
37
vite.config.ts
Normal 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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user