拆分前端,变成 Findreve Core

This commit is contained in:
2025-08-05 00:12:26 +08:00
parent 52cff970da
commit bb54761bd4
36 changed files with 0 additions and 4654 deletions

View File

@@ -1,4 +0,0 @@
> 1%
last 2 versions
not dead
not ie 11

View File

@@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -1,41 +0,0 @@
# Findreve Frontend 项目指南 - GitHub Copilot 指令
## 项目概述
Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,并确保丢失后能够安全找回。每个物品都会被分配一个 `唯一 ID` ,并生成一个 `安全链接` ,可轻松嵌入到 `二维码``NFC 标签` 中。当扫描该代码时会将拾得者引导至一个专门的网页上面显示物品详情和您的联系信息既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。
而 Findreve Frontend 作为 Findreve 的前端,采用 Vue + Vuetify 3 开发。
## 项目规划
[ ] 追平 Findreve 早期基于 NiceGUI 开发的前端
## 代码规范
- 使用类型提示增强代码可读性
- 所有函数和类都应有reST风格的文档字符串(docstring)
- 项目的日志模块使用英语输出
- 使用异步编程模式处理并发
- 尽可能写出弹性可扩展、可维护的代码
## 项目结构
- `.github/` : Github 相关
- `public/` : 纯静态文件
- `src/`
- `.browserslistrc`
- `.editorconfig`
- `.gitignore`
- `README.md`
- `index.html`
- `jsconfig.json`
- `package.json`
- `vite.config.mjs`
- `yarn.lock`
## 回复用户规则
- 当用户提出了产品的问题或者解决问题的思路时,应当在适时且随机的时候回答前肯定用户的想法
-`你的理解非常到位,抓住了问题的核心``这个想法非常不错` 等等
- 每次鼓励尽可能用不同的词语和语法,但也不要次次都鼓励
## 命名约定
- 类名: className
- 函数和变量: getInfo
- 常量: UPPER_SNAKE_CASE
- 文件名: snake_case.vue
- 模块名: snake_case

22
frontend/.gitignore vendored
View File

@@ -1,22 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,79 +0,0 @@
# Vuetify (Default)
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
## ❗️ Important Links
- 📄 [Docs](https://vuetifyjs.com/)
- 🚨 [Issues](https://issues.vuetifyjs.com/)
- 🏬 [Store](https://store.vuetifyjs.com/)
- 🎮 [Playground](https://play.vuetifyjs.com/)
- 💬 [Discord](https://community.vuetifyjs.com)
## 💿 Install
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
| Package Manager | Command |
|---------------------------------------------------------------|----------------|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
| [bun](https://bun.sh/#getting-started) | `bun install` |
After completing the installation, your environment is ready for Vuetify development.
## ✨ Features
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts)
-**Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
## 💡 Usage
This section covers how to start the development server and build your project for production.
### Starting the Development Server
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
```bash
yarn dev
```
(Repeat for npm, pnpm, and bun with respective commands.)
> Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
### Building for Production
To build your project for production, use:
```bash
yarn build
```
(Repeat for npm, pnpm, and bun with respective commands.)
Once the build process is completed, your application will be ready for deployment in a production environment.
## 💪 Support Vuetify Development
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
## 📑 License
[MIT](http://opensource.org/licenses/MIT)
Copyright (c) 2016-present Vuetify, LLC

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Findreve</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "bundler",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

View File

@@ -1,29 +0,0 @@
{
"name": "frontend",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "7.4.47",
"qrcode": "^1.5.3",
"roboto-fontface": "*",
"vue": "^3.5.13",
"vue-router": "4",
"vuetify": "^3.8.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"globals": "^16.0.0",
"sass-embedded": "^1.86.3",
"unplugin-fonts": "^1.3.1",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.3.4",
"vite-plugin-vuetify": "^2.1.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Findreve - 物品管理与寻回解决方案" />
<title>Findreve</title>
<!-- 预加载关键资源 -->
<link rel="preload" href="/src/assets/styles/global.css" as="style">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css" as="style">
<!-- 添加初始样式以防止闪烁 -->
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
/* 初始加载样式 */
#app {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* 针对暗色和亮色模式的初始背景色 */
.v-theme--dark {
background-color: #121212;
color: #ffffff;
}
.v-theme--light {
background-color: #ffffff;
color: #000000;
}
/* 初始加载指示器 */
.app-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
transition: opacity 0.3s ease;
}
[data-app-loaded="true"] .app-loading {
opacity: 0;
pointer-events: none;
}
</style>
</head>
<body>
<!-- 初始加载指示器 -->
<div class="app-loading">
<div class="loading-spinner">
<!-- 简单的CSS加载动画 -->
<div style="width: 40px; height: 40px; border: 4px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: #2196F3; animation: spin 1s linear infinite;"></div>
</div>
</div>
<div id="app"></div>
<script>
// 检测应用加载状态
window.addEventListener('load', function() {
setTimeout(function() {
document.querySelector('.app-loading').style.display = 'none';
}, 500);
});
</script>
<!-- 添加CSS动画 -->
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,82 +0,0 @@
<script setup>
import { ref, watch, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const isLoggedIn = ref(false)
const isLoading = ref(true)
// 检查登录状态
const checkLoginStatus = () => {
isLoggedIn.value = !!localStorage.getItem('user-token')
}
// 退出登录
const logout = () => {
localStorage.removeItem('user-token')
isLoggedIn.value = false
if (router.currentRoute.value.meta.requiresAuth) {
router.push('/')
}
}
// 路由变化时检查登录状态
watch(() => router.currentRoute.value, () => {
checkLoginStatus()
})
// 组件创建时检查登录状态
checkLoginStatus()
// 确保主题和样式已完全加载后再显示内容
onMounted(() => {
nextTick(() => {
setTimeout(() => {
isLoading.value = false
}, 200)
})
})
// 路由变化时的加载状态
router.beforeEach(() => {
isLoading.value = true
})
router.afterEach(() => {
setTimeout(() => {
isLoading.value = false
}, 100)
})
</script>
<template>
<v-app>
<!-- 添加加载指示器 -->
<div v-if="isLoading" class="loading-overlay">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</div>
<!-- 使用过渡效果包装主内容 -->
<v-main>
<v-fade-transition>
<router-view v-if="!isLoading"></router-view>
</v-fade-transition>
</v-main>
</v-app>
</template>
<style>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,6 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

View File

@@ -1,23 +0,0 @@
/* 全局样式定义 */
.hover-scale {
transition: transform 0.3s ease;
}
.hover-scale:hover {
transform: scale(1.1);
}
.max-width-7xl {
max-width: 1280px;
}
/* 响应式调整 */
@media (max-width: 600px) {
.text-h3 {
font-size: 1.75rem !important;
}
.text-h4 {
font-size: 1.5rem !important;
}
}

View File

@@ -1,83 +0,0 @@
<script setup>
const items = [
{
title: 'Vuetify Documentation',
icon: `$vuetify`,
href: 'https://vuetifyjs.com/',
},
{
title: 'Vuetify Support',
icon: 'mdi-shield-star-outline',
href: 'https://support.vuetifyjs.com/',
},
{
title: 'Vuetify X',
icon: ['M2.04875 3.00002L9.77052 13.3248L1.99998 21.7192H3.74882L10.5519 14.3697L16.0486 21.7192H22L13.8437 10.8137L21.0765 3.00002H19.3277L13.0624 9.76874L8.0001 3.00002H2.04875ZM4.62054 4.28821H7.35461L19.4278 20.4308H16.6937L4.62054 4.28821Z'],
href: 'https://x.com/vuetifyjs',
},
{
title: 'Vuetify GitHub',
icon: `mdi-github`,
href: 'https://github.com/vuetifyjs/vuetify',
},
{
title: 'Vuetify Discord',
icon: ['M22,24L16.75,19L17.38,21H4.5A2.5,2.5 0 0,1 2,18.5V3.5A2.5,2.5 0 0,1 4.5,1H19.5A2.5,2.5 0 0,1 22,3.5V24M12,6.8C9.32,6.8 7.44,7.95 7.44,7.95C8.47,7.03 10.27,6.5 10.27,6.5L10.1,6.33C8.41,6.36 6.88,7.53 6.88,7.53C5.16,11.12 5.27,14.22 5.27,14.22C6.67,16.03 8.75,15.9 8.75,15.9L9.46,15C8.21,14.73 7.42,13.62 7.42,13.62C7.42,13.62 9.3,14.9 12,14.9C14.7,14.9 16.58,13.62 16.58,13.62C16.58,13.62 15.79,14.73 14.54,15L15.25,15.9C15.25,15.9 17.33,16.03 18.73,14.22C18.73,14.22 18.84,11.12 17.12,7.53C17.12,7.53 15.59,6.36 13.9,6.33L13.73,6.5C13.73,6.5 15.53,7.03 16.56,7.95C16.56,7.95 14.68,6.8 12,6.8M9.93,10.59C10.58,10.59 11.11,11.16 11.1,11.86C11.1,12.55 10.58,13.13 9.93,13.13C9.29,13.13 8.77,12.55 8.77,11.86C8.77,11.16 9.28,10.59 9.93,10.59M14.1,10.59C14.75,10.59 15.27,11.16 15.27,11.86C15.27,12.55 14.75,13.13 14.1,13.13C13.46,13.13 12.94,12.55 12.94,11.86C12.94,11.16 13.45,10.59 14.1,10.59Z'],
href: 'https://community.vuetifyjs.com/',
},
{
title: 'Vuetify Reddit',
icon: `mdi-reddit`,
href: 'https://reddit.com/r/vuetifyjs',
},
]
</script>
<template>
<v-footer
app
height="40"
>
<a
v-for="item in items"
:key="item.title"
class="d-inline-block mx-2 social-link"
:href="item.href"
rel="noopener noreferrer"
target="_blank"
:title="item.title"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
</template>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
</style>

View File

@@ -1,115 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import storageService from '@/services/storage_service'
const cachedItemsCount = ref(0)
const lastCleanTime = ref(null)
const cacheMessage = ref('')
const cacheMessageType = ref('info')
const clearing = ref(false)
/**
* 格式化日期显示
* @param {Date} date - 日期对象
* @returns {string} 格式化的日期字符串
*/
const formatDate = (date) => {
if (!date) return ''
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date)
} catch (e) {
return date.toString()
}
}
/**
* 更新缓存信息
*/
const updateCacheInfo = () => {
try {
const allItems = storageService.getAllCachedItems()
cachedItemsCount.value = Object.keys(allItems).length
const cleanTimeStr = localStorage.getItem('findreve-last-clean-time')
lastCleanTime.value = cleanTimeStr ? new Date(parseInt(cleanTimeStr)) : null
} catch (error) {
console.error('获取缓存信息失败', error)
}
}
/**
* 清除所有缓存
*/
const clearCache = async () => {
clearing.value = true
try {
await new Promise(resolve => setTimeout(resolve, 600))
storageService.clearAllCache()
localStorage.setItem('findreve-last-clean-time', Date.now().toString())
updateCacheInfo()
cacheMessage.value = '缓存已成功清除'
cacheMessageType.value = 'success'
} catch (error) {
console.error('清除缓存失败', error)
cacheMessage.value = '清除缓存失败: ' + error.message
cacheMessageType.value = 'error'
} finally {
clearing.value = false
}
}
onMounted(() => {
updateCacheInfo()
})
</script>
<template>
<v-card class="my-3">
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-database" class="mr-2" color="primary"></v-icon>
本地缓存状态
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-3">
<div>
<div class="text-subtitle-1">
已缓存物品数量: <strong>{{ cachedItemsCount }}</strong>
</div>
<div class="text-caption text-grey">
上次清理时间: {{ lastCleanTime ? formatDate(lastCleanTime) : '从未清理' }}
</div>
</div>
<v-spacer></v-spacer>
<v-btn
color="error"
variant="outlined"
size="small"
@click="clearCache"
:loading="clearing"
prepend-icon="mdi-delete"
>
清除缓存
</v-btn>
</div>
<v-alert v-if="cacheMessage"
:type="cacheMessageType"
variant="tonal"
closable
@click:close="cacheMessage = ''"
class="mt-2"
density="compact"
>
{{ cacheMessage }}
</v-alert>
</v-card-text>
</v-card>
</template>

View File

@@ -1,13 +0,0 @@
<template>
<v-app-bar :elevation="0">
<template v-slot:prepend>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
</template>
<v-app-bar-title>Findreve</v-app-bar-title>
<template v-slot:append>
<v-btn icon="mdi-dots-vertical"></v-btn>
</template>
</v-app-bar>
</template>

View File

@@ -1,35 +0,0 @@
# 组件
此文件夹中的 Vue 模板文件会被自动导入。
## 🚀 使用方法
自动导入功能由 [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components) 实现。该插件会自动导入 `src/components` 目录下创建的 `.vue` 文件,并将它们注册为全局组件。这意味着你可以在应用程序中直接使用任何组件而无需手动导入。
以下示例假设存在一个位于 `src/components/MyComponent.vue` 的组件:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
当模板渲染时,组件的导入语句会被自动内联,最终呈现为:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```

View File

@@ -1,73 +0,0 @@
<script setup>
const systemInfo = {
version: '2.0.0 Alpha',
releaseDate: '2025-07-15',
framework: 'FastAPI + Vue'
}
</script>
<template>
<div>
<h2 class="text-h4 mb-4">关于 Findreve</h2>
<v-card>
<v-card-text>
<p class="text-body-1 mb-4">
Findreve 是一款强大且直观的解决方案旨在帮助您管理个人物品并确保丢失后能够安全找回
每个物品都会被分配一个唯一 ID并生成一个安全链接可轻松嵌入到二维码或 NFC 标签中
当扫描该代码时会将拾得者引导至一个专门的网页上面显示物品详情和您的联系信息既保障隐私又便于沟通
</p>
<p class="text-body-1 mb-4">
无论您是在管理个人物品还是专业资产Findreve 都能以高效简便的方式弥合丢失与找回之间的距离
</p>
<v-divider class="my-4"></v-divider>
<div class="d-flex align-center mb-4">
<v-icon size="large" color="primary" class="mr-3">mdi-information-outline</v-icon>
<div>
<div class="text-subtitle-1 font-weight-bold">系统信息</div>
<div class="text-body-2">版本: {{ systemInfo.version }}</div>
</div>
</div>
<div class="d-flex align-center mb-4">
<v-icon size="large" color="primary" class="mr-3">mdi-code-tags</v-icon>
<div>
<div class="text-subtitle-1 font-weight-bold">技术栈</div>
<div class="text-body-2">
前端: Vue.js, Vuetify, Vite<br>
后端: FastAPI, Python, SQLite
</div>
</div>
</div>
<div class="d-flex align-center">
<v-icon size="large" color="primary" class="mr-3">mdi-account-group</v-icon>
<div>
<div class="text-subtitle-1 font-weight-bold">开发团队</div>
<div class="text-body-2">
由一群热爱技术的开发者共同创建和维护
</div>
</div>
</div>
<v-divider class="my-4"></v-divider>
<div class="text-center">
<v-btn color="primary" variant="text" prepend-icon="mdi-help-circle">
帮助中心
</v-btn>
<v-btn color="primary" variant="text" prepend-icon="mdi-email" href="mailto:admin@yuxiaoqiu.cn">
联系我们
</v-btn>
<v-btn color="primary" variant="text" prepend-icon="mdi-github" href="https://github.com/findreve">
源代码
</v-btn>
</div>
<div class="text-caption text-center mt-4">
© 2018 - {{ new Date().getFullYear() }} 于小丘Yuerchu. 保留所有权利.
</div>
</v-card-text>
</v-card>
</div>
</template>

View File

@@ -1,85 +0,0 @@
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => []
}
})
// 仪表盘数据
const itemStats = ref({
total: 0,
normal: 0,
lost: 0,
scans: 0
})
/**
* 更新统计信息
*/
const updateStats = (items) => {
itemStats.value.total = items.length
itemStats.value.normal = items.filter(item => item.status === 'ok').length
itemStats.value.lost = items.filter(item => item.status === 'lost').length
itemStats.value.scans = items.reduce((sum, item) => sum + (item.views || 0), 0)
if (itemStats.value.scans === 0) itemStats.value.scans = Math.floor(Math.random() * 100) + 50
}
/**
* 计算百分比
*/
const getPercentage = (type) => {
if (itemStats.value.total === 0) return 0
return Math.round((itemStats.value[type] / itemStats.value.total) * 100)
}
watch(() => props.items, (newItems) => {
updateStats(newItems)
}, { immediate: true })
</script>
<template>
<div>
<h2 class="text-h4 mb-4">仪表盘</h2>
<v-row>
<v-col cols="12" sm="6" lg="3">
<v-card class="mx-auto" color="primary" theme="dark">
<v-card-text>
<div class="text-overline">所有物品</div>
<div class="text-h4">{{ itemStats.total }}</div>
<v-progress-linear model-value="100" color="white" class="mt-2"></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" lg="3">
<v-card class="mx-auto" color="success">
<v-card-text>
<div class="text-overline">正常物品</div>
<div class="text-h4">{{ itemStats.normal }}</div>
<v-progress-linear :model-value="getPercentage('normal')" color="white" class="mt-2"></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" lg="3">
<v-card class="mx-auto" color="error">
<v-card-text>
<div class="text-overline">丢失物品</div>
<div class="text-h4">{{ itemStats.lost }}</div>
<v-progress-linear :model-value="getPercentage('lost')" color="white" class="mt-2"></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" lg="3">
<v-card class="mx-auto" color="info">
<v-card-text>
<div class="text-overline">扫描次数</div>
<div class="text-h4">{{ itemStats.scans }}</div>
<v-progress-linear model-value="100" color="white" class="mt-2"></v-progress-linear>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>

View File

@@ -1,521 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue'
import apiService from '@/services/api_service'
const props = defineProps({
items: {
type: Array,
required: true
}
})
const emit = defineEmits(['refresh'])
// 界面控制
const loading = ref(false)
const search = ref('')
const statusFilter = ref('all')
// 物品管理
const editItem = ref({
id: null,
key: '',
name: '',
icon: '',
phone: '',
status: 'ok',
context: ''
})
const defaultItem = {
id: null,
key: '',
name: '',
icon: '',
phone: '',
status: 'ok',
context: ''
}
// 对话框控制
const itemDialog = ref(false)
const deleteDialog = ref(false)
const qrDialog = ref(false)
const saving = ref(false)
const deleting = ref(false)
const formValid = ref(false)
// 选中的物品和删除项
const selectedItem = ref(null)
const deleteItem = ref(null)
// 表格配置
const headers = [
{ title: 'ID', key: 'id', sortable: true },
{ title: '物品名称', key: 'name', sortable: true },
{ title: '标识码', key: 'key', sortable: true },
{ title: '状态', key: 'status', sortable: true },
{ title: '创建时间', key: 'created_at', sortable: true },
{ title: '操作', key: 'actions', sortable: false }
]
const statusOptions = [
{ title: '全部状态', value: 'all' },
{ title: '正常', value: 'ok' },
{ title: '丢失', value: 'lost' }
]
/**
* 过滤后的物品列表
*/
const filteredItems = computed(() => {
let result = [...props.items]
if (statusFilter.value !== 'all') {
result = result.filter(item => item.status === statusFilter.value)
}
return result
})
/**
* 打开物品对话框
*/
const openItemDialog = (item = null) => {
editItem.value = item ? JSON.parse(JSON.stringify(item)) : JSON.parse(JSON.stringify(defaultItem))
if (!item) {
editItem.value.key = generateRandomKey()
}
itemDialog.value = true
}
/**
* 保存物品
*/
const saveItem = async () => {
if (!formValid.value) return
try {
saving.value = true
let data
if (editItem.value.id) {
const params = new URLSearchParams()
const { id, key, name, icon, phone, status, context } = editItem.value
params.append('id', id)
params.append('key', key)
params.append('name', name)
params.append('icon', icon || '')
params.append('phone', phone)
params.append('status', status)
if (status === 'lost' && context) {
params.append('context', context)
}
data = await apiService.patch(`/api/admin/items?${params.toString()}`, '')
} else {
const params = new URLSearchParams()
const { key, name, icon, phone } = editItem.value
params.append('key', key)
params.append('name', name)
params.append('icon', icon || '')
params.append('phone', phone)
data = await apiService.post(`/api/admin/items?${params.toString()}`, '')
}
if (data.code !== 0) {
throw new Error(data.msg || '保存物品失败')
}
itemDialog.value = false
emit('refresh')
} catch (error) {
console.error('保存物品错误:', error)
} finally {
saving.value = false
}
}
/**
* 确认删除物品
*/
const confirmDelete = (item) => {
deleteItem.value = item
deleteDialog.value = true
}
/**
* 确认删除物品
*/
const deleteItemConfirm = async () => {
if (!deleteItem.value?.id) return
try {
deleting.value = true
const data = await apiService.delete(`/api/admin/items?id=${encodeURIComponent(deleteItem.value.id)}`)
if (data.code !== 0) {
throw new Error(data.msg || '删除物品失败')
}
deleteDialog.value = false
emit('refresh')
} catch (error) {
console.error('删除物品错误:', error)
} finally {
deleting.value = false
}
}
/**
* 显示二维码
*/
const showQRCode = (item) => {
selectedItem.value = item
qrDialog.value = true
}
/**
* 获取二维码URL
*/
const getQRCodeUrl = (key) => {
const currentUrl = window.location.origin
const foundUrl = `${currentUrl}/found?key=${encodeURIComponent(key)}`
return `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(foundUrl)}`
}
/**
* 生成随机标识码
*/
const generateRandomKey = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 获取状态对应的颜色
*/
const getStatusColor = (status) => {
const statusMap = {
ok: "success",
lost: "error",
default: "grey"
}
return statusMap[status] || statusMap.default
}
/**
* 获取状态对应的文本
*/
const getStatusText = (status) => {
const statusMap = {
ok: "正常",
lost: "丢失",
default: "未知"
}
return statusMap[status] || statusMap.default
}
/**
* 格式化日期显示
*/
const formatDate = (dateStr) => {
if (!dateStr) return "未知时间"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
} catch (e) {
return dateStr
}
}
/**
* 重置所有筛选条件
*/
const resetFilters = () => {
search.value = ''
statusFilter.value = 'all'
}
</script>
<template>
<div>
<div class="d-flex justify-space-between align-center mb-4">
<h2 class="text-h4">物品管理</h2>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="openItemDialog()"
class="text-none"
>
添加物品
</v-btn>
</div>
<!-- 物品筛选和搜索 -->
<v-card class="mb-4">
<v-card-text>
<v-row>
<v-col cols="12" sm="4">
<v-text-field
v-model="search"
label="搜索物品"
prepend-inner-icon="mdi-magnify"
single-line
hide-details
variant="outlined"
density="comfortable"
class="rounded-lg"
></v-text-field>
</v-col>
<v-col cols="12" sm="4">
<v-select
v-model="statusFilter"
:items="statusOptions"
label="状态筛选"
variant="outlined"
density="comfortable"
hide-details
class="rounded-lg"
></v-select>
</v-col>
<v-col cols="12" sm="4" class="d-flex align-center">
<v-btn color="primary" variant="text" prepend-icon="mdi-refresh" @click="refreshItems">刷新数据</v-btn>
<v-btn color="error" variant="text" prepend-icon="mdi-filter-remove" @click="resetFilters">重置筛选</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 物品列表表格 -->
<v-data-table
:headers="headers"
:items="filteredItems"
:loading="loading"
:items-per-page="10"
:search="search"
:no-data-text="loading ? '加载中...' : '没有找到匹配的物品'"
>
<!-- 自定义状态显示 -->
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
class="text-white"
>
{{ getStatusText(item.status) }}
</v-chip>
</template>
<!-- 自定义日期显示 -->
<template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<!-- 操作按钮 -->
<template v-slot:item.actions="{ item }">
<div class="d-flex">
<v-btn
icon
color="info"
variant="text"
size="small"
@click="openItemDialog(item)"
class="mr-1"
>
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">编辑</v-tooltip>
</v-btn>
<v-btn
icon
color="error"
variant="text"
size="small"
@click="confirmDelete(item)"
class="mr-1"
>
<v-icon>mdi-delete</v-icon>
<v-tooltip activator="parent" location="top">删除</v-tooltip>
</v-btn>
<v-btn
icon
color="success"
variant="text"
size="small"
@click="showQRCode(item)"
>
<v-icon>mdi-qrcode</v-icon>
<v-tooltip activator="parent" location="top">二维码</v-tooltip>
</v-btn>
</div>
</template>
</v-data-table>
<!-- 物品编辑对话框 -->
<v-dialog v-model="itemDialog" max-width="600px">
<v-card>
<v-card-title class="text-h5 bg-primary text-white pa-4">
{{ editItem.id ? '编辑物品' : '添加新物品' }}
</v-card-title>
<v-card-text class="pt-4">
<v-form ref="itemForm" v-model="formValid">
<v-container>
<v-row>
<v-col cols="12">
<v-text-field
v-model="editItem.name"
label="物品名称"
required
:rules="[v => !!v || '物品名称不能为空']"
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="editItem.key"
label="物品标识码"
required
:rules="[v => !!v || '标识码不能为空']"
:disabled="!!editItem.id"
variant="outlined"
density="comfortable"
hint="用于生成二维码的唯一标识,创建后不可修改"
persistent-hint
></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="editItem.phone"
label="联系电话"
required
:rules="[
v => !!v || '联系电话不能为空',
v => /^\d{11}$/.test(v) || '请输入有效的11位手机号码'
]"
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="editItem.icon"
label="图标 (可选)"
placeholder="例如mdi-laptop"
variant="outlined"
density="comfortable"
hint="Material Design Icons的图标名称"
persistent-hint
></v-text-field>
</v-col>
<v-col cols="12">
<v-select
v-model="editItem.status"
:items="[
{ title: '正常', value: 'ok' },
{ title: '丢失', value: 'lost' }
]"
label="物品状态"
required
variant="outlined"
density="comfortable"
></v-select>
</v-col>
<v-col cols="12" v-if="editItem.status === 'lost'">
<v-textarea
v-model="editItem.context"
label="丢失上下文"
variant="outlined"
rows="3"
placeholder="请描述物品丢失的时间、地点等信息..."
></v-textarea>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" variant="text" @click="itemDialog = false">取消</v-btn>
<v-btn
color="primary"
@click="saveItem"
:loading="saving"
:disabled="!formValid"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 确认删除对话框 -->
<v-dialog v-model="deleteDialog" max-width="400">
<v-card>
<v-card-title class="text-h5 bg-error text-white pa-4">确认删除</v-card-title>
<v-card-text class="pt-4">
<p class="text-body-1">您确定要删除物品 "{{ deleteItem?.name || '' }}" </p>
<p class="text-caption text-error">此操作不可逆删除后将无法恢复</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="deleteDialog = false">取消</v-btn>
<v-btn
color="error"
@click="deleteItemConfirm"
:loading="deleting"
>
确认删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 二维码展示对话框 -->
<v-dialog v-model="qrDialog" max-width="350">
<v-card>
<v-card-title class="text-h5 bg-primary text-white pa-4">物品二维码</v-card-title>
<v-card-text class="text-center pa-4">
<div v-if="selectedItem">
<p class="text-h6 mb-2">{{ selectedItem.name }}</p>
<p class="text-subtitle-2 mb-4">ID: {{ selectedItem.key }}</p>
<!-- 二维码图片 -->
<div class="bg-white pa-4 d-inline-block rounded">
<img
:src="getQRCodeUrl(selectedItem.key)"
alt="QR Code"
width="200"
height="200"
/>
</div>
<p class="text-caption mt-3">请使用屏幕截图或保存图片功能保存二维码</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="qrDialog = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<style scoped>
/* 确保数据表格在移动设备上响应式滚动 */
@media (max-width: 768px) {
.v-data-table {
overflow-x: auto;
}
}
</style>

View File

@@ -1,85 +0,0 @@
<script setup>
import CacheStatus from '@/components/CacheStatus.vue'
const userInfo = {
username: 'admin',
email: 'admin@example.com'
}
const settings = {
darkMode: false,
notifications: true
}
</script>
<template>
<div>
<h2 class="text-h4 mb-4">用户设置</h2>
<!-- 添加缓存状态组件 -->
<CacheStatus />
<v-divider class="my-4"></v-divider>
<v-card class="mb-4">
<v-card-title>个人信息</v-card-title>
<v-card-text>
<v-form>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="userInfo.username"
label="用户名"
readonly
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="userInfo.email"
label="邮箱地址"
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12">
<v-btn color="primary" class="mr-2">更新信息</v-btn>
<v-btn color="secondary" variant="outlined">修改密码</v-btn>
</v-col>
</v-row>
</v-form>
</v-card-text>
</v-card>
<v-card>
<v-card-title>系统设置</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-switch v-model="settings.darkMode" color="primary"></v-switch>
</template>
<v-list-item-title>深色模式</v-list-item-title>
<v-list-item-subtitle>启用后将使用深色主题</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-switch v-model="settings.notifications" color="primary"></v-switch>
</template>
<v-list-item-title>通知提醒</v-list-item-title>
<v-list-item-subtitle>接收物品状态变更通知</v-list-item-subtitle>
</v-list-item>
</v-list>
<div class="d-flex justify-end mt-4">
<v-btn color="primary">保存设置</v-btn>
</div>
</v-card-text>
</v-card>
<p class="text-caption text-center mt-4">更多设置功能正在开发中...</p>
</div>
</template>

View File

@@ -1,72 +0,0 @@
/**
* main.js
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Components
import App from './App.vue'
// Composables
import { createApp } from 'vue'
// 先导入样式确保在应用挂载前已加载CSS
import './assets/styles/global.css' // 导入全局样式
// 添加预加载完成标志以避免闪屏
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.setAttribute('data-app-loaded', 'true')
})
// 创建应用实例
const app = createApp(App)
// 异步导入其他依赖以优化初始加载
Promise.all([
import('./plugins/vuetify'), // Vuetify
import('./router'), // 路由
import('./services/api_service'), // API服务
import('./services/storage_service') // 本地存储服务
]).then(([{ default: vuetify }, { default: router }, { default: apiService }, { default: storageService }]) => {
// 添加全局事件总线功能
app.config.globalProperties.$root = {
$on: (event, callback) => {
if (!app.config.globalProperties._eventBus) app.config.globalProperties._eventBus = {}
if (!app.config.globalProperties._eventBus[event]) app.config.globalProperties._eventBus[event] = []
app.config.globalProperties._eventBus[event].push(callback)
},
$off: (event, callback) => {
if (!app.config.globalProperties._eventBus || !app.config.globalProperties._eventBus[event]) return
if (!callback) {
app.config.globalProperties._eventBus[event] = []
} else {
app.config.globalProperties._eventBus[event] = app.config.globalProperties._eventBus[event].filter(cb => cb !== callback)
}
},
$emit: (event, ...args) => {
if (!app.config.globalProperties._eventBus || !app.config.globalProperties._eventBus[event]) return
app.config.globalProperties._eventBus[event].forEach(callback => callback(...args))
}
}
// 将API服务注册为全局属性
app.config.globalProperties.$api = apiService
// 将存储服务注册为全局属性
app.config.globalProperties.$storage = storageService
// 定期清理过期缓存
setInterval(() => {
storageService.cleanExpiredCache();
}, 30 * 60 * 1000); // 每30分钟执行一次
// 使用插件
app.use(router)
app.use(vuetify)
// 确保所有资源都加载完毕后再挂载应用
setTimeout(() => {
app.mount('#app')
}, 0)
})

View File

@@ -1,3 +0,0 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.

View File

@@ -1,12 +0,0 @@
/**
* plugins/index.js
*
* Automatically included in `./src/main.js`
*/
// Plugins
import vuetify from './vuetify'
export function registerPlugins (app) {
app.use(vuetify)
}

View File

@@ -1,61 +0,0 @@
/**
* plugins/vuetify.js
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// 预设主题以防止闪烁
const setInitialTheme = () => {
// 检查本地存储中的主题首选项
const savedTheme = localStorage.getItem('vuetify-theme-preference') || 'dark'
// 在DOM加载前应用主题类避免闪烁
document.documentElement.classList.add(`v-theme--${savedTheme}`)
return savedTheme
}
const defaultTheme = setInitialTheme()
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme,
themes: {
light: {
dark: false,
colors: {
primary: '#1867C0',
secondary: '#5CBBF6',
}
},
dark: {
dark: true,
colors: {
primary: '#2196F3',
secondary: '#03A9F4',
}
}
},
options: {
// 启用自定义属性以提高渲染性能
customProperties: true,
// 缓存主题以避免重新计算
cspNonce: 'findreve-theme',
// 减少主题变化时的闪烁
variations: false
}
},
defaults: {
VBtn: {
variant: 'flat'
},
}
})

View File

@@ -1,66 +0,0 @@
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Found from '@/views/Found.vue'
import Admin from '@/views/Admin.vue'
import Login from '@/views/Login.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{
path: '/',
name: 'Home',
meta: { title: '主页'},
component: Home
},
{
path: '/found',
name: 'Found',
meta: { title: '关于此物品'},
component: Found
},
{
path: '/admin',
name: 'Admin',
component: Admin,
meta: {
requiresAuth: true,
title: 'Findreve 仪表盘'
}
},
{
path: '/login',
name: 'Login',
meta: { title: '登录 Findreve'},
component: Login
},
// 添加404路由必须放在最后以匹配所有未定义的路径
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: '404 - 页面未找到' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫,用于检查用户是否已登录并更新页面标题
router.beforeEach((to, from, next) => {
// 更新页面标题
document.title = to.meta.title || 'Findreve'
const isAuthenticated = localStorage.getItem('user-token') // 简单的认证检查,实际应用中可能更复杂
if (to.meta.requiresAuth && !isAuthenticated) {
// 如果路由需要认证但用户未登录,重定向到登录页
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
})
export default router

View File

@@ -1,315 +0,0 @@
/**
* API 服务
*
* 提供统一的 HTTP 请求处理,包括认证令牌管理、错误处理等功能。
* 自动处理令牌过期情况,在令牌失效时重定向到登录页面。
* 集成了本地缓存功能,支持优先使用缓存数据。
*/
import router from '@/router';
import storageService from './storage_service';
class ApiService {
/**
* 发送 HTTP 请求
*
* @param {string} url - 请求地址
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
async request(url, options = {}) {
// 默认请求头
const headers = {
'accept': 'application/json',
...options.headers
};
// 添加认证令牌
const token = localStorage.getItem('user-token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
...options,
headers
});
// 处理认证错误
if (response.status === 401) {
console.log('认证失败,可能是令牌已过期');
// 清除过期的令牌
localStorage.removeItem('user-token');
// 保存当前路径,用于登录后跳转回来
const currentPath = router.currentRoute.value.fullPath;
if (currentPath !== '/login') {
// 跳转到登录页面,带上重定向参数
router.push({
path: '/login',
query: { redirect: currentPath, expired: 'true' }
});
// 抛出错误,中断后续处理
throw new Error('认证已过期,请重新登录');
}
}
// 处理其他错误
if (!response.ok) {
// 尝试解析错误信息
let errorMessage;
try {
const errorData = await response.json();
errorMessage = errorData.msg || errorData.detail || `请求失败: ${response.status}`;
} catch (e) {
errorMessage = `请求失败: ${response.status} ${response.statusText}`;
}
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error('API 请求错误:', error);
throw error;
}
}
/**
* GET 请求
*
* @param {string} url - 请求地址
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
get(url, options = {}) {
return this.request(url, {
method: 'GET',
...options
});
}
/**
* POST 请求
*
* @param {string} url - 请求地址
* @param {Object|FormData|string} data - 请求数据
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
post(url, data, options = {}) {
const requestOptions = {
method: 'POST',
...options
};
// 根据数据类型设置请求体和内容类型
if (data) {
if (data instanceof FormData) {
requestOptions.body = data;
} else if (typeof data === 'string') {
requestOptions.body = data;
} else {
requestOptions.body = JSON.stringify(data);
requestOptions.headers = {
'Content-Type': 'application/json',
...options.headers
};
}
}
return this.request(url, requestOptions);
}
/**
* PATCH 请求
*
* @param {string} url - 请求地址
* @param {Object|FormData|string} data - 请求数据
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
patch(url, data, options = {}) {
const requestOptions = {
method: 'PATCH',
...options
};
// 处理请求体
if (data) {
if (data instanceof FormData || typeof data === 'string') {
requestOptions.body = data;
} else {
requestOptions.body = JSON.stringify(data);
requestOptions.headers = {
'Content-Type': 'application/json',
...options.headers
};
}
} else {
requestOptions.body = '';
}
return this.request(url, requestOptions);
}
/**
* DELETE 请求
*
* @param {string} url - 请求地址
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
delete(url, options = {}) {
return this.request(url, {
method: 'DELETE',
...options
});
}
/**
* 提交表单数据
*
* @param {string} url - 请求地址
* @param {Object} formData - 表单数据对象
* @param {Object} options - 请求选项
* @returns {Promise<Object>} 响应数据
*/
submitForm(url, formData, options = {}) {
const urlSearchParams = new URLSearchParams();
// 将对象转换为 URLSearchParams
Object.keys(formData).forEach(key => {
if (formData[key] !== undefined && formData[key] !== null) {
urlSearchParams.append(key, formData[key]);
}
});
return this.post(url, urlSearchParams.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
...options
});
}
/**
* 登录请求
*
* @param {string} username - 用户名
* @param {string} password - 密码
* @returns {Promise<Object>} 登录结果
*/
async login(username, password) {
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
formData.append('grant_type', 'password');
const response = await fetch('/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json'
},
body: formData
});
if (!response.ok) {
let errorMessage = '登录失败';
if (response.status === 401) {
errorMessage = '账号或密码错误';
} else {
try {
const errorData = await response.json();
errorMessage = errorData.detail || '登录失败';
} catch (e) {
console.error('解析错误响应失败:', e);
}
}
throw new Error(errorMessage);
}
const data = await response.json();
localStorage.setItem('user-token', data.access_token);
return { success: true, data };
} catch (error) {
console.error('登录错误:', error);
return { success: false, error: error.message };
}
}
/**
* 验证当前令牌是否有效
*
* 通过调用 /api/admin/ 接口验证当前令牌的有效性
*
* @returns {Promise<boolean>} 令牌是否有效
*/
async validateToken() {
try {
// 检查是否有令牌
const token = localStorage.getItem('user-token');
if (!token) {
console.log('没有找到认证令牌');
return false;
}
// 调用验证接口
const response = await this.get('/api/admin/');
return response === true;
} catch (error) {
console.log('令牌验证失败:', error);
return false;
}
}
/**
* 获取物品详情
*
* 根据物品标识码获取物品信息,支持缓存机制
*
* @param {string} key - 物品标识码
* @param {boolean} useCache - 是否优先使用缓存数据
* @returns {Promise<Object>} 物品详情
*/
async getObject(key, useCache = true) {
try {
// 1. 如果允许使用缓存,先检查是否有缓存数据
if (useCache) {
const cachedItem = storageService.getItemFromCache(key);
if (cachedItem) {
console.log('Using cached item data:', key);
return cachedItem;
}
}
// 2. 没有缓存或不使用缓存从API获取数据
const data = await this.get(`/api/object/${encodeURIComponent(key)}`);
if (data.code === 0) {
// 3. 获取数据成功后,保存到缓存
storageService.saveItemToCache(key, data.data);
return data.data;
} else {
throw new Error(data.msg || '获取物品信息失败');
}
} catch (error) {
console.error('获取物品错误:', error);
throw error;
}
}
/**
* 清除API结果缓存
* 可用于强制刷新数据或在用户登出时清除敏感信息
*/
clearCache() {
storageService.clearAllCache();
}
}
export default new ApiService();

View File

@@ -1,205 +0,0 @@
/**
* 本地存储服务
*
* 提供本地数据的存储和获取功能,支持缓存物品详情和其他应用数据
* 包括缓存过期时间控制和数据版本管理
*/
const STORAGE_KEYS = {
ITEMS_CACHE: 'findreve-items-cache',
CACHE_VERSION: 'findreve-cache-version'
};
// 当前缓存版本号,当数据结构变更时修改此值使旧缓存失效
const CURRENT_CACHE_VERSION = '1.0';
// 缓存默认过期时间24小时
const DEFAULT_CACHE_EXPIRY = 24 * 60 * 60 * 1000;
class StorageService {
constructor() {
// 初始化时检查缓存版本,清除过期缓存
this.initializeCache();
}
/**
* 初始化缓存
*
* 检查缓存版本,如果版本不匹配则清除所有缓存
*/
initializeCache() {
try {
const cachedVersion = localStorage.getItem(STORAGE_KEYS.CACHE_VERSION);
// 如果版本号不匹配,清除所有缓存
if (cachedVersion !== CURRENT_CACHE_VERSION) {
console.log('Cache version mismatch, clearing cache...');
this.clearAllCache();
localStorage.setItem(STORAGE_KEYS.CACHE_VERSION, CURRENT_CACHE_VERSION);
}
} catch (error) {
console.error('Error initializing cache:', error);
// 出错时尝试清除缓存
this.clearAllCache();
}
}
/**
* 保存物品数据到本地缓存
*
* @param {string} key - 物品唯一标识
* @param {Object} itemData - 物品详情数据
* @param {number} expiryTime - 缓存过期时间(毫秒)默认24小时
*/
saveItemToCache(key, itemData, expiryTime = DEFAULT_CACHE_EXPIRY) {
try {
// 获取现有缓存
const itemsCache = this.getAllCachedItems() || {};
// 更新缓存,添加时间戳
itemsCache[key] = {
data: itemData,
timestamp: Date.now(),
expiry: expiryTime
};
// 保存回本地存储
localStorage.setItem(STORAGE_KEYS.ITEMS_CACHE, JSON.stringify(itemsCache));
console.log(`Item cached: ${key}`);
} catch (error) {
console.error('Error saving item to cache:', error);
}
}
/**
* 从缓存获取物品数据
*
* @param {string} key - 物品唯一标识
* @returns {Object|null} 缓存的物品数据如果不存在或已过期则返回null
*/
getItemFromCache(key) {
try {
const itemsCache = this.getAllCachedItems() || {};
const cachedItem = itemsCache[key];
// 检查是否存在缓存
if (!cachedItem) {
return null;
}
// 检查缓存是否过期
const now = Date.now();
if (now - cachedItem.timestamp > cachedItem.expiry) {
console.log(`Cache expired for item: ${key}`);
this.removeItemFromCache(key);
return null;
}
console.log(`Cache hit for item: ${key}`);
return cachedItem.data;
} catch (error) {
console.error('Error retrieving item from cache:', error);
return null;
}
}
/**
* 获取缓存项的时间戳
*
* @param {string} key - 缓存项的唯一标识
* @returns {number|null} 缓存项的时间戳如果不存在则返回null
*/
getCacheTimestamp(key) {
try {
const itemsCache = this.getAllCachedItems() || {};
const cachedItem = itemsCache[key];
if (cachedItem && cachedItem.timestamp) {
return cachedItem.timestamp;
}
return null;
} catch (error) {
console.error('Error getting cache timestamp:', error);
return null;
}
}
/**
* 从缓存中移除物品数据
*
* @param {string} key - 物品唯一标识
*/
removeItemFromCache(key) {
try {
const itemsCache = this.getAllCachedItems() || {};
if (itemsCache[key]) {
delete itemsCache[key];
localStorage.setItem(STORAGE_KEYS.ITEMS_CACHE, JSON.stringify(itemsCache));
console.log(`Removed item from cache: ${key}`);
}
} catch (error) {
console.error('Error removing item from cache:', error);
}
}
/**
* 获取所有缓存的物品数据
*
* @returns {Object|null} 包含所有缓存物品的对象
*/
getAllCachedItems() {
try {
const cachedData = localStorage.getItem(STORAGE_KEYS.ITEMS_CACHE);
return cachedData ? JSON.parse(cachedData) : {};
} catch (error) {
console.error('Error getting all cached items:', error);
return {};
}
}
/**
* 清理过期的缓存项目
*
* 遍历所有缓存项目并移除已过期的条目
*/
cleanExpiredCache() {
try {
const now = Date.now();
const itemsCache = this.getAllCachedItems() || {};
let hasExpired = false;
// 检查每个缓存项是否过期
Object.keys(itemsCache).forEach(key => {
const item = itemsCache[key];
if (now - item.timestamp > item.expiry) {
delete itemsCache[key];
hasExpired = true;
console.log(`Expired cache removed: ${key}`);
}
});
// 如果有过期项,更新缓存
if (hasExpired) {
localStorage.setItem(STORAGE_KEYS.ITEMS_CACHE, JSON.stringify(itemsCache));
}
} catch (error) {
console.error('Error cleaning expired cache:', error);
}
}
/**
* 清除所有缓存数据
*/
clearAllCache() {
try {
localStorage.removeItem(STORAGE_KEYS.ITEMS_CACHE);
console.log('All cache cleared');
} catch (error) {
console.error('Error clearing cache:', error);
}
}
}
export default new StorageService();

View File

@@ -1,133 +0,0 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import apiService from '@/services/api_service'
import Dashboard from '@/components/admin/Dashboard.vue'
import ItemsManagement from '@/components/admin/ItemsManagement.vue'
import UserSettings from '@/components/admin/UserSettings.vue'
import AboutSystem from '@/components/admin/AboutSystem.vue'
const router = useRouter()
// 界面控制
const drawer = ref(false)
const currentTab = ref('dashboard')
const items = ref([])
// 检查用户是否已登录
const checkAuth = () => {
const token = localStorage.getItem('user-token')
if (!token) {
router.push({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
}
// 获取物品列表
const fetchItems = async () => {
try {
const data = await apiService.get('/api/admin/items')
if (data.code === 0 && Array.isArray(data.data)) {
items.value = data.data
} else {
throw new Error(data.msg || '获取物品列表失败')
}
} catch (error) {
console.error('获取物品列表错误:', error)
nextTick(() => {
emit('show-toast', {
color: 'error',
message: error.message || '加载物品数据失败'
})
})
}
}
// 退出登录
const logout = () => {
localStorage.removeItem('user-token')
router.push('/login')
nextTick(() => {
emit('show-toast', {
color: 'info',
message: '您已成功退出登录'
})
})
}
// 组件创建时执行
checkAuth()
fetchItems()
</script>
<template>
<v-container fluid class="admin-container">
<!-- 页面顶部应用栏 -->
<v-app-bar flat density="comfortable" :elevation="1">
<template v-slot:prepend>
<v-app-bar-nav-icon @click="drawer = !drawer" color="white"></v-app-bar-nav-icon>
</template>
<v-app-bar-title class="text-white">Findreve 管理面板</v-app-bar-title>
<template v-slot:append>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" color="white">
<v-avatar size="36">
<v-img src="https://www.yxqi.cn/wp-content/uploads/2024/08/4a2eb538026d80effb0349aa7acfe628.webp" alt="用户头像"></v-img>
</v-avatar>
</v-btn>
</template>
<v-list>
<v-list-item @click="logout">
<template v-slot:prepend>
<v-icon icon="mdi-logout" color="error"></v-icon>
</template>
<v-list-item-title>退出登录</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-app-bar>
<!-- 侧边导航栏 -->
<v-navigation-drawer v-model="drawer" temporary>
<template v-slot:prepend>
<div class="pa-4 text-center">
<v-avatar size="96" class="mb-2">
<v-img src="https://www.yxqi.cn/wp-content/uploads/2024/08/4a2eb538026d80effb0349aa7acfe628.webp" alt="Logo"></v-img>
</v-avatar>
<div class="text-h6">Findreve</div>
<div class="text-caption">物品丢失找回系统</div>
</div>
</template>
<v-divider></v-divider>
<v-list nav>
<v-list-item prepend-icon="mdi-view-dashboard" title="仪表盘" value="dashboard" @click="currentTab = 'dashboard'"></v-list-item>
<v-list-item prepend-icon="mdi-tag-multiple" title="物品管理" value="items" @click="currentTab = 'items'"></v-list-item>
<v-list-item prepend-icon="mdi-account-cog" title="用户设置" value="settings" @click="currentTab = 'settings'"></v-list-item>
<v-list-item prepend-icon="mdi-information" title="关于系统" value="about" @click="currentTab = 'about'"></v-list-item>
</v-list>
</v-navigation-drawer>
<!-- 主内容区 -->
<v-main>
<v-container>
<!-- 使用拆分后的组件 -->
<Dashboard v-if="currentTab === 'dashboard'" :items="items" />
<ItemsManagement v-if="currentTab === 'items'" :items="items" @refresh="fetchItems" />
<UserSettings v-if="currentTab === 'settings'" />
<AboutSystem v-if="currentTab === 'about'" />
</v-container>
</v-main>
</v-container>
</template>
<style scoped>
.admin-container {
min-height: 100vh;
padding: 0;
}
</style>

View File

@@ -1,270 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import apiService from '@/services/api_service'
import storageService from '@/services/storage_service'
// 响应式数据
const key = ref(null)
const item = ref(null)
const loading = ref(true)
const error = ref(null)
const showQRDialog = ref(false)
const isFromCache = ref(false)
/**
* 获取状态对应的颜色
* @param {string} status - 物品状态
* @returns {string} 对应的颜色名称
*/
const getStatusColor = (status) => {
const statusMap = {
ok: "success",
lost: "error",
default: "grey"
}
return statusMap[status] || statusMap.default
}
/**
* 获取状态对应的文本
* @param {string} status - 物品状态
* @returns {string} 对应的状态文本
*/
const getStatusText = (status) => {
const statusMap = {
ok: "正常",
lost: "丢失",
default: "未知"
}
return statusMap[status] || statusMap.default
}
/**
* 格式化日期显示
* @param {string} dateStr - 日期字符串
* @returns {string} 格式化的日期文本
*/
const formatDate = (dateStr) => {
if (!dateStr) return "未知时间"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
} catch (e) {
return dateStr
}
}
/**
* 从缓存加载数据并获取最新数据
* 优先显示本地缓存的数据同时从API获取最新数据
*/
const loadFromCacheAndFetch = async () => {
try {
// 先尝试从缓存获取数据
const cachedItem = storageService.getItemFromCache(key.value)
if (cachedItem) {
// 如果有缓存,立即显示
item.value = cachedItem
isFromCache.value = true
loading.value = true // 保持加载状态,同时获取最新数据
// 在后台获取最新数据
fetchItemDetails(true)
} else {
// 没有缓存,直接获取最新数据
loading.value = true
fetchItemDetails(false)
}
} catch (err) {
console.error("Error loading cached data:", err)
// 如果缓存加载失败,直接获取最新数据
fetchItemDetails(false)
}
}
/**
* 获取物品详情数据
* @param {boolean} isBackground - 是否在后台获取数据(已显示缓存数据)
*/
const fetchItemDetails = async (isBackground = false) => {
try {
if (!isBackground) {
loading.value = true
}
const data = await apiService.getObject(key.value)
// 更新本地缓存
storageService.saveItemToCache(key.value, data)
// 更新界面数据
item.value = data
isFromCache.value = false
} catch (err) {
console.error("Error fetching item details:", err)
// 如果是后台请求且已有缓存数据显示,则不显示错误
if (!isBackground || !item.value) {
error.value = "获取物品信息失败:" + err.message
}
} finally {
loading.value = false
}
}
// 组件挂载时执行
onMounted(() => {
// 从URL获取物品的key
const urlParams = new URLSearchParams(window.location.search)
key.value = urlParams.get('key')
if (key.value) {
// 尝试先从缓存获取数据
loadFromCacheAndFetch()
} else {
loading.value = false
error.value = "缺少物品标识,无法获取信息"
}
})
</script>
<template>
<v-container class="found-container">
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<!-- 加载状态显示 -->
<v-skeleton-loader
v-if="loading && !item"
type="card, article"
class="mx-auto"
></v-skeleton-loader>
<!-- 错误信息显示 -->
<v-alert
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
>
{{ error }}
</v-alert>
<!-- 物品详情卡片 -->
<v-card v-if="item" class="found-item-card">
<v-card-title class="text-h4 d-flex align-center">
<v-icon :icon="item.icon || 'mdi-tag'" size="large" class="mr-2"></v-icon>
{{ item.name || '未命名物品' }}
</v-card-title>
<v-card-subtitle class="text-subtitle-1">
ID: {{ item.id || '未知' }}
<v-chip v-if="isFromCache" size="x-small" color="grey" class="ml-2" variant="outlined">缓存</v-chip>
</v-card-subtitle>
<v-divider class="my-2"></v-divider>
<v-card-text>
<div class="d-flex align-center mb-4">
<v-chip
:color="getStatusColor(item.status)"
class="mr-2"
variant="elevated"
>
{{ getStatusText(item.status) }}
</v-chip>
<span class="text-caption">最后更新: {{ formatDate(item.updated_at) }}</span>
</div>
<!-- 物品描述或者丢失上下文 - 只在丢失状态下显示 -->
<div v-if="item.status === 'lost' && item.context" class="mb-4">
<v-alert variant="tonal" color="error" class="context-box">
<div class="text-subtitle-1 font-weight-bold mb-2">丢失信息</div>
<div>{{ item.context }}</div>
</v-alert>
</div>
<!-- 创建者/主人信息 -->
<v-card variant="outlined" class="mb-4">
<v-card-title class="text-h6">
<v-icon icon="mdi-account" class="mr-2"></v-icon>
联系信息
</v-card-title>
<v-card-text>
<v-list>
<v-list-item v-if="item.phone">
<template v-slot:prepend>
<v-icon icon="mdi-phone"></v-icon>
</template>
<v-list-item-title>{{ item.phone }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<!-- 仍在加载更新数据时显示 -->
<div v-if="loading && isFromCache" class="text-center my-3">
<v-progress-circular
indeterminate
size="24"
width="2"
color="primary"
class="mr-2"
></v-progress-circular>
<span class="text-caption">正在获取最新数据...</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-phone"
v-if="item.phone"
:href="`tel:${item.phone}`"
>
联系失主
</v-btn>
</v-card-actions>
</v-card>
<!-- 未找到物品信息时显示 -->
<v-alert
v-if="!loading && !error && !item"
type="warning"
variant="tonal"
class="mt-4"
>
未找到物品信息请检查链接是否正确
</v-alert>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.found-container {
padding-top: 20px;
padding-bottom: 40px;
}
.found-item-card {
border-radius: 12px;
overflow: hidden;
}
.context-box {
border-left: 4px solid;
}
</style>

View File

@@ -1,543 +0,0 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import storageService from '@/services/storage_service'
// 缓存相关状态
const isFromCache = ref(false)
const showCacheAlert = ref(false)
const refreshing = ref(false)
const cacheTimestamp = ref(null)
// 本地存储键名
const HOME_CACHE_KEY = 'home-data'
// 格式化缓存时间
const formatCacheTime = computed(() => {
if (!cacheTimestamp.value) return ''
try {
const date = new Date(cacheTimestamp.value)
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
} catch (e) {
return '未知时间'
}
})
// 从服务加载主页数据
const fetchHomeData = async () => {
await new Promise(resolve => setTimeout(resolve, 800))
return {
socialLinks,
devSkills,
designSkills,
musicSkills,
projects,
musicWorks,
timeline
}
}
// 从本地存储加载数据
const loadFromCache = () => {
try {
const cachedItem = storageService.getItemFromCache(HOME_CACHE_KEY)
if (cachedItem) {
const cachedTimestamp = storageService.getCacheTimestamp(HOME_CACHE_KEY)
if (cachedTimestamp) {
cacheTimestamp.value = cachedTimestamp
isFromCache.value = true
showCacheAlert.value = true
}
console.log('Using cached home page data')
return true
}
} catch (error) {
console.error('Error loading cached data:', error)
}
return false
}
// 保存数据到本地存储
const saveToCache = async () => {
try {
const currentData = await fetchHomeData()
storageService.saveItemToCache(HOME_CACHE_KEY, currentData)
console.log('Home page data cached')
} catch (error) {
console.error('Error saving data to cache:', error)
}
}
// 刷新数据
const refreshData = async () => {
refreshing.value = true
try {
const newData = await fetchHomeData()
storageService.saveItemToCache(HOME_CACHE_KEY, newData)
isFromCache.value = false
showCacheAlert.value = false
console.log('Home data refreshed successfully')
} catch (error) {
console.error('Error refreshing home data:', error)
} finally {
refreshing.value = false
}
}
// 组件挂载时执行
onMounted(async () => {
const hasCachedData = loadFromCache()
if (!hasCachedData) {
try {
await fetchHomeData()
saveToCache()
} catch (error) {
console.error('Error fetching initial home data:', error)
}
}
})
// 静态数据定义
// 社交媒体链接
const socialLinks = [
{ icon: 'mdi-github', url: 'https://github.com/Yuerchu' },
{ icon: 'mdi-music', url: 'https://music.163.com/#/artist?id=48986728' },
{ icon: 'mdi-web', url: 'https://www.yxqi.cn' },
{ icon: 'mdi-email', url: 'mailto:admin@yuxiaoqiu.cn' },
];
// 开发技能
const devSkills = [
{ name: 'Python', color: 'amber-darken-1' },
{ name: 'Kotlin', color: 'purple-lighten-2' },
{ name: 'Golang', color: 'light-blue' },
{ name: 'Lua', color: 'blue-darken-4' },
{ name: 'C', color: 'red' },
{ name: 'HTML/CSS', color: 'red-darken-3' },
{ name: 'JavaScript', color: 'lime-darken-3' },
{ name: 'Git', color: 'amber-darken-3' },
{ name: 'Docker', color: 'light-blue-darken-1' },
];
// 设计技能
const designSkills = [
{ name: 'Photoshop', color: 'blue-darken-4' },
{ name: 'Premiere', color: 'indigo-darken-3' },
{ name: 'After Effects', color: 'indigo-darken-4' },
{ name: 'Audition', color: 'purple-darken-4' },
{ name: 'Illustrator', color: 'amber-darken-3' },
{ name: 'UI/UX', color: 'pink-darken-2' },
{ name: 'SAI2', color: 'grey-darken-3' },
];
// 音乐技能
const musicSkills = [
{ name: 'FL Studio', color: 'orange-darken-2' },
{ name: '作曲', color: 'deep-purple' },
{ name: '编曲', color: 'indigo' },
{ name: '混音', color: 'blue' },
{ name: '母带处理', color: 'teal' },
{ name: 'Midi创作', color: 'cyan' },
];
// 项目作品
const projects = [
{
title: 'DiskNext',
tag: 'B端系统',
tagColor: 'primary',
image: 'https://cdn.vuetifyjs.com/images/cards/sunshine.jpg',
description: '基于 NiceGUI 打造的高性能网盘系统,提供快速、安全的文件存储与分享服务。',
link: 'https://pan.yxqi.cn',
tech: ['Python', 'NiceGUI', 'SQLite', 'Docker']
},
{
title: 'Findreve',
tag: 'C端应用',
tagColor: 'success',
image: 'https://cdn.vuetifyjs.com/images/cards/road.jpg',
description: '个人主页与物品丢失找回系统,帮助用户追踪和找回丢失物品。',
link: 'https://i.yxqi.cn',
tech: ['Vue', 'Vuetify', 'FastAPI', 'MySQL']
},
{
title: 'HeyAuth',
tag: 'B+C端系统',
tagColor: 'info',
image: 'https://cdn.vuetifyjs.com/images/cards/plane.jpg',
description: '多应用授权系统,提供统一的身份验证和权限管理服务。',
link: 'https://auth.yxqi.cn',
tech: ['Python', 'JWT', 'OAuth2', 'Redis']
}
];
// 音乐作品
const musicWorks = [
{
title: '与枫同奔 Run With Fun',
tag: '词曲',
description: '我愿如流星赶月那样飞奔,向着远方的梦想不断前行。',
link: 'https://music.163.com/#/song?id=2148944359',
cover: 'https://cdn.vuetifyjs.com/images/cards/foster.jpg'
},
{
title: 'HeyFun\'s Story',
tag: '自设印象曲',
description: '飞奔在星辰大海之间的少年,勇敢探索未知的世界。',
link: 'https://music.163.com/#/song?id=1889436124',
cover: 'https://cdn.vuetifyjs.com/images/cards/house.jpg'
},
{
title: '2020Fall',
tag: '年度纯音乐',
description: '耗时6个月完成的年度纯音乐作品记录2020年的回忆。',
link: 'https://music.163.com/#/song?id=1863630345',
cover: 'https://cdn.vuetifyjs.com/images/cards/store.jpg'
}
];
// 时间线
const timeline = [
{
title: '梦开始的地方',
date: '2022年1月21日',
content: '购买了第一台服务器,并搭建了第一个 Wordpress 站点,开始了我的网络创作之旅。',
color: 'primary',
icon: 'mdi-server'
},
{
title: '音乐作品发布',
date: '2023年10月29日',
content: '在网易云音乐发布了收官作《与枫同奔 Release》截止到 2025 年 4 月 21 日获得了 7000+ 播放。',
color: 'deep-purple',
icon: 'mdi-music'
},
{
title: '自建生态计划开始',
date: '2024年3月1日',
content: '从 Cloudreve 项目脱离,开始自建网盘系统 DiskNext ,迈出了建立个人技术生态的第一步。',
color: 'amber',
icon: 'mdi-cloud'
},
{
title: '当前进展',
date: '现在',
content: '目前正在开发 HeyAuth、Findreve、DiskNext 三个核心系统,构建完整的个人应用生态。',
color: 'success',
icon: 'mdi-rocket'
}
];
</script>
<template>
<v-container fluid>
<!-- 顶部封面区 -->
<v-parallax
src="https://www.yxqi.cn/wp-content/uploads/2024/07/2f02c888032aba72abd82588de04f880.webp"
height="400"
>
<div class="d-flex flex-column fill-height justify-center align-center text-white">
<v-avatar size="150" class="mb-5 elevation-10 hover-scale">
<v-img src="https://www.yxqi.cn/wp-content/uploads/2024/08/4a2eb538026d80effb0349aa7acfe628.webp" alt="头像"></v-img>
</v-avatar>
<h1 class="text-h2 font-weight-bold mb-2">于小丘</h1>
<div class="text-h6 mb-3">开发者 / 音乐人 / 创造者</div>
<div class="d-flex">
<v-btn
v-for="(link, i) in socialLinks"
:key="i"
:icon="link.icon"
:href="link.url"
variant="text"
color="white"
class="mx-2"
target="_blank"
></v-btn>
</div>
</div>
</v-parallax>
<!-- 显示缓存状态提示 -->
<v-slide-y-transition>
<v-alert
v-if="isFromCache && showCacheAlert"
color="info"
variant="tonal"
density="compact"
class="ma-2 text-center"
closable
@click:close="showCacheAlert = false"
>
<div class="d-flex align-center justify-center">
<span>正在使用缓存数据数据更新时间: {{ formatCacheTime }}</span>
<v-btn
v-if="!refreshing"
variant="text"
size="small"
prepend-icon="mdi-refresh"
class="ml-2"
@click="refreshData"
>
刷新
</v-btn>
<v-progress-circular
v-else
indeterminate
size="20"
width="2"
color="primary"
class="ml-2"
></v-progress-circular>
</div>
</v-alert>
</v-slide-y-transition>
<!-- 关于我 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">关于我</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
目前是机电一体化专业大二学生坐标广州最喜欢用代码创造有趣的东西主攻 Python 开发Golang/PHP/Flutter正在努力修炼中
我的开源作品有HFR-CloudHash-Checker商业项目有 HeyAuth授权系统 HeyPress嘿帕主题 是多个知名开源项目的贡献者
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="primary" class="mr-2" icon="mdi-code-tags"></v-icon>
开发技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">专注于全栈开发 Python 为主力语言同时熟悉多种编程语言与框架</p>
<v-chip-group>
<v-chip v-for="(skill, i) in devSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="error" class="mr-2" icon="mdi-palette"></v-icon>
设计技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">熟练使用各种创意软件从平面设计到视频剪辑热衷于创造视觉体验</p>
<v-chip-group>
<v-chip v-for="(skill, i) in designSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="success" class="mr-2" icon="mdi-music"></v-icon>
音乐技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">热衷于音乐创作和制作擅长使用各种音乐软件创造独特的声音</p>
<v-chip-group>
<v-chip v-for="(skill, i) in musicSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- 项目展示 -->
<v-sheet color="grey-lighten-4" class="py-12">
<v-container>
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">我的项目</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
这些是我最近开发的一些项目涵盖了不同的技术栈和应用场景
</div>
</v-col>
</v-row>
<v-row>
<v-col v-for="(project, i) in projects" :key="i" cols="12" sm="6" lg="4">
<v-card class="fill-height hover-card">
<v-img
:src="project.image"
height="200"
cover
class="align-end text-white"
gradient="to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.8) 100%"
>
<v-card-title class="text-h5">{{ project.title }}</v-card-title>
</v-img>
<v-card-text>
<div class="d-flex align-center mb-3">
<v-chip :color="project.tagColor" size="small" variant="flat">{{ project.tag }}</v-chip>
<v-spacer></v-spacer>
<v-tooltip location="top" text="查看项目">
<template v-slot:activator="{ props }">
<v-btn icon="mdi-open-in-new" size="small" variant="text" :href="project.link" target="_blank" v-bind="props"></v-btn>
</template>
</v-tooltip>
</div>
<p>{{ project.description }}</p>
<v-chip-group>
<v-chip v-for="(tech, j) in project.tech" :key="j" size="x-small" variant="outlined" class="mr-1">
{{ tech }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-sheet>
<!-- 音乐作品 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">音乐作品</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
音乐是我另一种表达自我的方式这些是我创作的一些音乐作品
</div>
</v-col>
</v-row>
<v-row>
<v-col v-for="(music, i) in musicWorks" :key="i" cols="12" md="4">
<v-card class="fill-height hover-card">
<v-img
:src="music.cover"
height="200"
cover
></v-img>
<v-card-item>
<v-card-title>{{ music.title }}</v-card-title>
<v-card-subtitle>{{ music.tag }}</v-card-subtitle>
</v-card-item>
<v-card-text>
<p>{{ music.description }}</p>
</v-card-text>
<v-card-actions>
<v-btn
prepend-icon="mdi-play-circle"
variant="tonal"
color="primary"
:href="music.link"
target="_blank"
>
试听
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- 时间线 -->
<v-sheet color="grey-lighten-4" class="py-12">
<v-container>
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">我的历程</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
这是我的个人发展历程每一步都是宝贵的经验
</div>
</v-col>
</v-row>
<v-row justify="center">
<v-col cols="12" md="8">
<v-timeline side="end" align="start">
<v-timeline-item
v-for="(event, i) in timeline"
:key="i"
:dot-color="event.color"
:icon="event.icon"
size="small"
>
<div class="d-flex justify-space-between mb-2">
<strong class="text-primary">{{ event.title }}</strong>
<div class="text-caption">{{ event.date }}</div>
</div>
<div>{{ event.content }}</div>
</v-timeline-item>
</v-timeline>
</v-col>
</v-row>
</v-container>
</v-sheet>
<!-- 联系我 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">联系我</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
如果你有任何问题或者合作机会欢迎随时联系我
</div>
</v-col>
</v-row>
</v-container>
<v-footer class="text-center d-flex flex-column">
<div class="pt-4 pb-2 text-white">
<strong>Copyright (C) 2018-{{ new Date().getFullYear() }} 于小丘Yuerchu. </strong> All Rights Reserved.
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-decoration-none">粤ICP备2024285776号-1</a> ·
<a href="http://www.beian.gov.cn/" target="_blank" class="text-decoration-none">粤公网安备 44020302000232 </a>
</div>
</v-footer>
</v-container>
</template>
<style scoped>
.max-width-text {
max-width: 700px;
}
.hover-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-card:hover {
transform: translateY(-5px);
box-shadow: 0 14px 28px rgba(0,0,0,0.15), 0 10px 10px rgba(0,0,0,0.12) !important;
}
.hover-scale {
transition: transform 0.3s ease;
}
.hover-scale:hover {
transform: scale(1.1);
}
</style>

View File

@@ -1,198 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import apiService from '@/services/api_service'
const emit = defineEmits(['show-toast'])
const route = useRoute()
const router = useRouter()
// 表单数据
const username = ref('')
const password = ref('')
const errorMessage = ref('')
const loading = ref(false)
const formValid = ref(false)
const tokenExpired = ref(false)
const checkingToken = ref(true)
const loginForm = ref(null)
// 表单验证规则
const usernameRules = [
v => !!v || '用户名不能为空'
]
const passwordRules = [
v => !!v || '密码不能为空',
v => (v && v.length >= 6) || '密码长度不能小于6位'
]
// 验证现有令牌
const validateExistingToken = async () => {
try {
checkingToken.value = true
const token = localStorage.getItem('user-token')
if (token) {
const isValid = await apiService.validateToken()
if (isValid) {
console.log('令牌有效,正在重定向')
const redirectPath = route.query.redirect || '/'
router.push(redirectPath)
return
} else {
console.log('令牌无效,已清除')
localStorage.removeItem('user-token')
}
}
} catch (error) {
console.error('验证令牌时出错:', error)
} finally {
checkingToken.value = false
}
}
// 处理用户登录
const login = async () => {
const { valid } = await loginForm.value.validate()
if (!valid) return
loading.value = true
errorMessage.value = ''
try {
const result = await apiService.login(username.value, password.value)
if (result.success) {
// 登录成功
emit('show-toast', {
color: 'success',
message: '登录成功,正在跳转...'
})
// 登录成功后重定向
const redirectPath = route.query.redirect || '/'
router.push(redirectPath)
} else {
// 登录失败
errorMessage.value = result.error
}
} catch (error) {
console.error('登录错误:', error)
errorMessage.value = error.message || '登录过程中发生错误,请稍后再试'
} finally {
loading.value = false
}
}
onMounted(() => {
// 检查是否是因为令牌过期而重定向
tokenExpired.value = route.query.expired === 'true'
// 如果不是因为令牌过期重定向,则验证令牌
if (!tokenExpired.value) {
validateExistingToken()
} else {
checkingToken.value = false
}
})
</script>
<template>
<v-container class="fill-height">
<v-row justify="center" align="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="login-card elevation-4">
<v-card-title class="text-center pt-6 pb-6">
<h2>登录 Findreve</h2>
</v-card-title>
<v-card-text>
<v-alert
v-if="tokenExpired"
type="warning"
variant="tonal"
icon="mdi-clock-alert-outline"
class="mb-4"
>
登录已过期请重新登录
</v-alert>
<div v-if="checkingToken" class="text-center py-4">
<v-progress-circular
indeterminate
color="primary"
:size="50"
:width="5"
></v-progress-circular>
<div class="mt-3">正在验证登录状态...</div>
</div>
<v-form v-else @submit.prevent="login" ref="loginForm" v-model="formValid">
<v-text-field
v-model="username"
label="用户名"
prepend-inner-icon="mdi-account"
required
:disabled="loading"
:rules="usernameRules"
variant="outlined"
density="comfortable"
></v-text-field>
<v-text-field
v-model="password"
label="密码"
type="password"
prepend-inner-icon="mdi-lock"
required
:disabled="loading"
:rules="passwordRules"
variant="outlined"
density="comfortable"
autocomplete="current-password"
></v-text-field>
<v-btn
type="submit"
color="primary"
block
:loading="loading"
:disabled="loading || !formValid"
class="mt-2"
>
登录
</v-btn>
</v-form>
<v-alert
v-if="errorMessage"
type="error"
closable
variant="tonal"
@click:close="errorMessage = ''"
class="mt-4"
>
{{ errorMessage }}
</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.login-card {
border-radius: 8px;
padding: 16px;
}
/* 确保移动设备上有合适的内边距 */
@media (max-width: 600px) {
.v-container {
padding: 12px;
}
}
</style>

View File

@@ -1,36 +0,0 @@
<script setup>
/**
* 404错误页面组件
* 当用户访问不存在的路由时显示此页面
*/
</script>
<template>
<v-container class="fill-height">
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" class="text-center">
<v-card elevation="3" class="pa-6">
<v-card-title class="text-h4 mb-4">
<v-icon size="x-large" color="error" class="me-2">mdi-alert-circle</v-icon>
404 - 页面未找到
</v-card-title>
<v-card-text class="text-body-1 mb-4">
很抱歉您访问的页面不存在或已被移除
</v-card-text>
<v-card-actions class="justify-center">
<v-btn
color="primary"
variant="elevated"
size="large"
@click="$router.push('/')"
>
返回首页
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</template>

View File

@@ -1,70 +0,0 @@
// Plugins
import Components from 'unplugin-vue-components/vite'
import Vue from '@vitejs/plugin-vue'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import ViteFonts from 'unplugin-fonts/vite'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
Vue({
template: { transformAssetUrls },
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify(),
Components(),
ViteFonts({
google: {
families: [{
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900',
}],
},
}),
],
optimizeDeps: {
exclude: ['vuetify'],
},
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: [
'.js',
'.json',
'.jsx',
'.mjs',
'.ts',
'.tsx',
'.vue',
],
},
server: {
port: 3000,
proxy: {
// 配置代理
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
secure: false,
// 如果后端API不包含/api前缀可以使用下面的配置移除它
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
css: {
preprocessorOptions: {
sass: {
api: 'modern-compiler',
},
scss: {
api:'modern-compiler',
},
},
},
})

File diff suppressed because it is too large Load Diff