拆分前端,变成 Findreve Core
This commit is contained in:
@@ -1,4 +0,0 @@
|
|||||||
> 1%
|
|
||||||
last 2 versions
|
|
||||||
not dead
|
|
||||||
not ie 11
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[*.{js,jsx,ts,tsx,vue}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
41
frontend/.github/copilot-instructions.md
vendored
41
frontend/.github/copilot-instructions.md
vendored
@@ -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
22
frontend/.gitignore
vendored
@@ -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?
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"target": "es5",
|
|
||||||
"module": "esnext",
|
|
||||||
"baseUrl": "./",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lib": [
|
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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>
|
|
||||||
@@ -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 |
@@ -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 |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;"
|
|
||||||
>
|
|
||||||
© 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
```
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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-Cloud、Hash-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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
1244
frontend/yarn.lock
1244
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user