V2.0.0-alpha1

This commit is contained in:
2025-04-22 03:22:17 +08:00
parent 2a217c4b8c
commit 39bbc94d07
53 changed files with 5019 additions and 1203 deletions

2
JWT.py
View File

@@ -3,6 +3,8 @@ from model import database
import asyncio
oauth2_scheme = OAuth2PasswordBearer(
scheme_name='获取 JWT Bearer 令牌',
description='用于获取 JWT Bearer 令牌,需要以表单的形式提交',
tokenUrl="/api/token"
)

View File

@@ -2,7 +2,6 @@
* @Author: 于小丘 海枫
* @Date: 2024-11-29 20:06:02
* @LastEditors: Yuerchu admin@yuxiaoqiu.cn
* @LastEditTime: 2024-11-29 20:28:54
* @FilePath: /Findreve/README.md
* @Description: Findreve
*
@@ -28,7 +27,10 @@
</p>
## 介绍 Introduction
Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,并确保丢失后能够安全找回。每个物品都会被分配一个 `唯一 ID` ,并生成一个 `安全链接` ,可轻松嵌入到 `二维码``NFC 标签` 中。当扫描该代码时会将拾得者引导至一个专门的网页上面显示物品详情和您的联系信息既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。
Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,并确保丢失后能够安全找回。
每个物品都会被分配一个 `唯一 ID` ,并生成一个 `安全链接` ,可轻松嵌入到 `二维码``NFC 标签` 中。
当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,既保障隐私又便于沟通。
无论您是在管理个人物品还是专业资产Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。
Findreve is a powerful and intuitive solution, an enhanced version of Findreve, designed
to help you manage your personal items and ensure their safe recovery in case of loss. Each
@@ -69,4 +71,6 @@ Open Source Free Version: Licensed under the `GPLv3`.
基于赞助的专业版:基于您的赞助,您可获得附加功能和源代码的版本,允许进一步开发用于个人或内部使用。
然而,不允许重新分发修改后的或原始的源代码。
Donation-Based Premium Version: By making a donation, you can access a version with additional features and source code, which allows further development for personal or internal use. However, redistribution of the modified or original source code is not permitted.
Donation-Based Premium Version: By making a donation, you can access a version with additional features
and source code, which allows further development for personal or internal use. However, redistribution
of the modified or original source code is not permitted.

29
app.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
import model.database
# 定义程序参数
APP_NAME: str = 'Findreve'
VERSION: str = '2.0.0'
summary='标记、追踪与找回 —— 就这么简单。'
description='Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,'\
'并确保丢失后能够安全找回。每个物品都会被分配一个 唯一 ID '\
'并生成一个 安全链接 ,可轻松嵌入到 二维码 或 NFC 标签 中。'\
'当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,'\
'既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产,'\
'Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。'
# Findreve 的生命周期
@asynccontextmanager
async def lifespan(app: FastAPI):
await model.database.Database().init_db()
yield
# 定义 Findreve 服务器
app = FastAPI(
title=APP_NAME,
version=VERSION,
summary=summary,
description=description,
lifespan=lifespan
)

4
frontend/.browserslistrc Normal file
View File

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

5
frontend/.editorconfig Normal file
View File

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

View File

@@ -0,0 +1,41 @@
# 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 Normal file
View File

@@ -0,0 +1,22 @@
.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?

79
frontend/README.md Normal file
View File

@@ -0,0 +1,79 @@
# 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

13
frontend/index.html Normal file
View File

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

20
frontend/jsconfig.json Normal file
View File

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

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"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.2.2",
"vite-plugin-vuetify": "^2.1.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,92 @@
<!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>

86
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,86 @@
<!-- src/App.vue -->
<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>
<script>
export default {
data() {
return {
isLoggedIn: false,
isLoading: true
}
},
created() {
this.checkLoginStatus()
},
mounted() {
// 确保主题和样式已完全加载后再显示内容
this.$nextTick(() => {
// 短暂延迟确保DOM完全渲染
setTimeout(() => {
this.isLoading = false
}, 200)
})
// 添加路由变化时的加载状态
this.$router.beforeEach((to, from, next) => {
this.isLoading = true
next()
})
this.$router.afterEach(() => {
// 路由加载完成后,短暂延迟以确保组件已渲染
setTimeout(() => {
this.isLoading = false
}, 100)
})
},
methods: {
checkLoginStatus() {
this.isLoggedIn = !!localStorage.getItem('user-token')
},
logout() {
localStorage.removeItem('user-token')
this.isLoggedIn = false
// 如果在管理页面退出,则重定向到首页
if (this.$route.meta.requiresAuth) {
this.$router.push('/')
}
}
},
watch: {
$route() {
this.checkLoginStatus()
}
}
}
</script>
<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.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 526 B

View File

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

View File

@@ -0,0 +1,82 @@
<template>
<v-footer
app
height="40"
>
<a
v-for="item in items"
:key="item.title"
class="d-inline-block mx-2 social-link"
:href="item.href"
rel="noopener noreferrer"
target="_blank"
:title="item.title"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
</template>
<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>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
</style>

View File

@@ -0,0 +1,136 @@
<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>
<script>
/**
* 缓存状态组件
*
* 显示当前本地缓存的状态信息,支持清除缓存
*/
import storageService from '@/services/storage_service';
export default {
name: 'CacheStatus',
data() {
return {
cachedItemsCount: 0,
lastCleanTime: null,
cacheMessage: '',
cacheMessageType: 'info',
clearing: false
}
},
created() {
this.updateCacheInfo();
},
methods: {
/**
* 更新缓存信息
*/
updateCacheInfo() {
try {
const allItems = storageService.getAllCachedItems();
this.cachedItemsCount = Object.keys(allItems).length;
// 获取上次清理时间这里需要在storage_service中添加记录
const cleanTimeStr = localStorage.getItem('findreve-last-clean-time');
this.lastCleanTime = cleanTimeStr ? new Date(parseInt(cleanTimeStr)) : null;
} catch (error) {
console.error('获取缓存信息失败', error);
}
},
/**
* 清除所有缓存
*/
async clearCache() {
this.clearing = true;
try {
// 小延迟以显示加载效果
await new Promise(resolve => setTimeout(resolve, 600));
storageService.clearAllCache();
localStorage.setItem('findreve-last-clean-time', Date.now().toString());
this.updateCacheInfo();
this.cacheMessage = '缓存已成功清除';
this.cacheMessageType = 'success';
} catch (error) {
console.error('清除缓存失败', error);
this.cacheMessage = '清除缓存失败: ' + error.message;
this.cacheMessageType = 'error';
} finally {
this.clearing = false;
}
},
/**
* 格式化日期显示
*
* @param {Date} date - 日期对象
* @returns {string} 格式化的日期字符串
*/
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();
}
}
}
}
</script>

View File

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

View File

@@ -0,0 +1,35 @@
# 组件
此文件夹中的 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>
```

72
frontend/src/main.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,858 @@
<!-- src/views/Admin.vue -->
<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-qrcode-scan" title="生成码" value="qrcodes" @click="currentTab = 'qrcodes'"></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>
<template v-slot:append>
<div class="pa-2">
<v-btn block color="primary" @click="drawer = false">
关闭菜单
</v-btn>
</div>
</template>
</v-navigation-drawer>
<!-- 主内容区 -->
<v-main>
<v-container>
<!-- 仪表盘 -->
<div v-if="currentTab === 'dashboard'">
<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>
<!-- 物品管理 -->
<div v-if="currentTab === 'items'">
<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="fetchItems">刷新数据</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>
</div>
<!-- 其他标签页内容 -->
<div v-if="currentTab === 'qrcodes'">
<h2 class="text-h4 mb-4">生成二维码</h2>
<p>此功能正在开发中...</p>
</div>
<div v-if="currentTab === 'settings'">
<h2 class="text-h4 mb-4">用户设置</h2>
<!-- 添加缓存状态组件 -->
<CacheStatus />
<v-divider class="my-4"></v-divider>
<p>其他设置功能正在开发中...</p>
</div>
<div v-if="currentTab === 'about'">
<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="text-caption text-right">版本: 1.0.0</div>
</v-card-text>
</v-card>
</div>
</v-container>
</v-main>
<!-- 物品编辑对话框 -->
<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>
</v-container>
</template>
<script>
/**
* 管理面板组件
*
* 提供物品管理功能,包括添加、编辑、删除物品,
* 以及查看物品状态和生成二维码等功能。
*
* 此组件还包含仪表盘视图,显示物品统计信息和最近活动。
*/
import apiService from '@/services/api_service';
import CacheStatus from '@/components/CacheStatus.vue';
export default {
name: 'AdminView',
components: {
CacheStatus
},
data() {
return {
// 界面控制
drawer: false,
currentTab: 'dashboard',
loading: false,
search: '',
statusFilter: 'all',
// 物品管理
items: [],
editItem: {
id: null,
key: '',
name: '',
icon: '',
phone: '',
status: 'ok',
context: ''
},
defaultItem: {
id: null,
key: '',
name: '',
icon: '',
phone: '',
status: 'ok',
context: ''
},
// 对话框控制
itemDialog: false,
deleteDialog: false,
qrDialog: false,
saving: false,
deleting: false,
formValid: false,
// 选中的物品和删除项
selectedItem: null,
deleteItem: null,
// 表格配置
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 }
],
statusOptions: [
{ title: '全部状态', value: 'all' },
{ title: '正常', value: 'ok' },
{ title: '丢失', value: 'lost' }
],
// 仪表盘数据
itemStats: {
total: 0,
normal: 0,
lost: 0,
scans: 0
}
}
},
computed: {
/**
* 过滤后的物品列表
*
* 根据搜索文本和状态筛选条件过滤物品列表
* @returns {Array} 过滤后的物品数组
*/
filteredItems() {
let result = [...this.items];
// 应用状态筛选
if (this.statusFilter !== 'all') {
result = result.filter(item => item.status === this.statusFilter);
}
return result;
}
},
created() {
// 检查用户是否已登录
this.checkAuth();
// 获取物品列表
this.fetchItems();
},
methods: {
/**
* 检查用户是否已登录
*
* 如果未登录,重定向到登录页面
*/
checkAuth() {
const token = localStorage.getItem('user-token');
if (!token) {
this.$router.push({
path: '/login',
query: { redirect: this.$route.fullPath }
});
}
},
/**
* 获取物品列表
*
* 从API获取所有物品数据并更新统计信息
*/
async fetchItems() {
try {
this.loading = true;
const data = await apiService.get('/api/admin/items');
if (data.code === 0 && Array.isArray(data.data)) {
this.items = data.data;
this.updateStats();
} else {
throw new Error(data.msg || '获取物品列表失败');
}
} catch (error) {
console.error('获取物品列表错误:', error);
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'error',
message: error.message || '加载物品数据失败'
});
});
} finally {
this.loading = false;
}
},
/**
* 更新统计信息
*
* 根据物品列表计算各种统计数据
*/
updateStats() {
// 计算物品总数
this.itemStats.total = this.items.length;
// 计算正常和丢失物品数量
this.itemStats.normal = this.items.filter(item => item.status === 'ok').length;
this.itemStats.lost = this.items.filter(item => item.status === 'lost').length;
// 假设扫描次数是从物品中累计的一个属性,如果没有可以模拟一个值
// this.itemStats.scans = this.items.reduce((sum, item) => sum + (item.scans || 0), 0) || 42;
},
/**
* 计算百分比
*
* @param {string} type - 物品类型normal或lost
* @returns {number} 百分比值
*/
getPercentage(type) {
if (this.itemStats.total === 0) return 0;
return Math.round((this.itemStats[type] / this.itemStats.total) * 100);
},
/**
* 打开物品对话框
*
* @param {Object|null} item - 要编辑的物品为null时表示添加新物品
*/
openItemDialog(item = null) {
if (item) {
this.editItem = JSON.parse(JSON.stringify(item)); // 深拷贝
} else {
this.editItem = JSON.parse(JSON.stringify(this.defaultItem));
// 为新物品生成一个随机标识码
this.editItem.key = this.generateRandomKey();
}
this.$nextTick(() => {
if (this.$refs.itemForm) {
this.$refs.itemForm.resetValidation();
}
});
this.itemDialog = true;
},
/**
* 保存物品
*
* 根据是否有ID决定是添加新物品还是更新现有物品
*/
async saveItem() {
if (!this.formValid) return;
try {
this.saving = true;
let data;
if (this.editItem.id) {
// 更新现有物品
const params = new URLSearchParams();
const { id, key, name, icon, phone, status, context } = this.editItem;
params.append('id', id);
params.append('key', key);
params.append('name', name);
params.append('icon', icon || '');
params.append('phone', phone);
params.append('status', status);
// 只有在状态为lost且有context时才添加context参数
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 } = this.editItem;
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 || '保存物品失败');
}
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'success',
message: this.editItem.id ? '物品更新成功' : '物品添加成功'
});
});
this.itemDialog = false;
this.fetchItems(); // 刷新物品列表
} catch (error) {
console.error('保存物品错误:', error);
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'error',
message: error.message || '保存物品失败'
});
});
} finally {
this.saving = false;
}
},
/**
* 确认删除物品
*
* @param {Object} item - 要删除的物品
*/
confirmDelete(item) {
this.deleteItem = item;
this.deleteDialog = true;
},
/**
* 确认删除物品
*/
async deleteItemConfirm() {
if (!this.deleteItem || !this.deleteItem.id) return;
try {
this.deleting = true;
const data = await apiService.delete(`/api/admin/items?id=${encodeURIComponent(this.deleteItem.id)}`);
if (data.code !== 0) {
throw new Error(data.msg || '删除物品失败');
}
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'success',
message: '物品已成功删除'
});
});
this.deleteDialog = false;
this.fetchItems(); // 刷新物品列表
} catch (error) {
console.error('删除物品错误:', error);
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'error',
message: error.message || '删除物品失败'
});
});
} finally {
this.deleting = false;
}
},
/**
* 显示二维码
*
* @param {Object} item - 要显示二维码的物品
*/
showQRCode(item) {
this.selectedItem = item;
this.qrDialog = true;
},
/**
* 获取二维码URL
*
* @param {string} key - 物品标识码
* @returns {string} 二维码图片URL
*/
getQRCodeUrl(key) {
// 使用QR Server API生成二维码
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)}`;
},
/**
* 生成随机标识码
*
* @returns {string} 随机生成的标识码
*/
generateRandomKey() {
// 生成一个8位的随机字母数字组合
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
},
/**
* 获取状态对应的颜色
*
* @param {string} status - 物品状态
* @returns {string} 对应的颜色名称
*/
getStatusColor(status) {
const statusMap = {
ok: "success",
lost: "error",
default: "grey"
};
return statusMap[status] || statusMap.default;
},
/**
* 获取状态对应的文本
*
* @param {string} status - 物品状态
* @returns {string} 对应的状态文本
*/
getStatusText(status) {
const statusMap = {
ok: "正常",
lost: "丢失",
default: "未知"
};
return statusMap[status] || statusMap.default;
},
/**
* 格式化日期显示
*
* @param {string} create_time - 日期字符串
* @returns {string} 格式化的日期文本
*/
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;
}
},
/**
* 重置所有筛选条件
*/
resetFilters() {
this.search = '';
this.statusFilter = 'all';
},
/**
* 退出登录
*/
logout() {
localStorage.removeItem('user-token');
this.$router.push('/login');
this.$nextTick(() => {
this.$root.$emit('show-toast', {
color: 'info',
message: '您已成功退出登录'
});
});
}
}
};
</script>
<style scoped>
.admin-container {
min-height: 100vh;
padding: 0;
}
/* 确保数据表格在移动设备上响应式滚动 */
@media (max-width: 768px) {
.v-data-table {
overflow-x: auto;
}
}
</style>

View File

@@ -0,0 +1,286 @@
<!-- src/views/Found.vue -->
<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>
<script>
/**
* 物品详情页面
*
* 显示找到的物品的详细信息,包括联系方式、物品状态等
* 支持从本地缓存加载数据,提高页面加载速度
*/
import apiService from '@/services/api_service';
import storageService from '@/services/storage_service';
export default {
name: "FoundView",
data() {
return {
key: null,
item: null,
loading: true,
error: null,
showQRDialog: false,
isFromCache: false, // 标识数据是否来自缓存
};
},
created() {
// 从URL获取物品的key
const urlParams = new URLSearchParams(window.location.search);
this.key = urlParams.get('key');
if (this.key) {
// 尝试先从缓存获取数据
this.loadFromCacheAndFetch();
} else {
this.loading = false;
this.error = "缺少物品标识,无法获取信息";
}
},
methods: {
/**
* 从缓存加载数据并获取最新数据
*
* 优先显示本地缓存的数据同时从API获取最新数据
*/
async loadFromCacheAndFetch() {
try {
// 先尝试从缓存获取数据
const cachedItem = storageService.getItemFromCache(this.key);
if (cachedItem) {
// 如果有缓存,立即显示
this.item = cachedItem;
this.isFromCache = true;
this.loading = true; // 保持加载状态,同时获取最新数据
// 在后台获取最新数据
this.fetchItemDetails(true);
} else {
// 没有缓存,直接获取最新数据
this.loading = true;
this.fetchItemDetails(false);
}
} catch (err) {
console.error("Error loading cached data:", err);
// 如果缓存加载失败,直接获取最新数据
this.fetchItemDetails(false);
}
},
/**
* 获取物品详情数据
*
* @param {boolean} isBackground - 是否在后台获取数据(已显示缓存数据)
*/
async fetchItemDetails(isBackground = false) {
try {
if (!isBackground) {
this.loading = true;
}
const data = await apiService.getObject(this.key);
// 更新本地缓存
storageService.saveItemToCache(this.key, data);
// 更新界面数据
this.item = data;
this.isFromCache = false;
} catch (err) {
console.error("Error fetching item details:", err);
// 如果是后台请求且已有缓存数据显示,则不显示错误
if (!isBackground || !this.item) {
this.error = "获取物品信息失败:" + err.message;
}
} finally {
this.loading = false;
}
},
/**
* 获取状态对应的颜色
*
* @param {string} status - 物品状态
* @returns {string} 对应的颜色名称
*/
getStatusColor(status) {
const statusMap = {
ok: "success",
lost: "error",
default: "grey"
};
return statusMap[status] || statusMap.default;
},
/**
* 获取状态对应的文本
*
* @param {string} status - 物品状态
* @returns {string} 对应的状态文本
*/
getStatusText(status) {
const statusMap = {
ok: "正常",
lost: "丢失",
default: "未知"
};
return statusMap[status] || statusMap.default;
},
/**
* 格式化日期显示
*
* @param {string} dateStr - 日期字符串
* @returns {string} 格式化的日期文本
*/
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;
}
}
}
};
</script>
<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>

600
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,600 @@
<template>
<v-container fluid>
<!-- 顶部封面区 -->
<v-parallax
src="https://www.yxqi.cn/wp-content/uploads/2024/07/2f02c888032aba72abd82588de04f880.webp"
height="400"
>
<div class="d-flex flex-column fill-height justify-center align-center text-white">
<v-avatar size="150" class="mb-5 elevation-10 hover-scale">
<v-img src="https://www.yxqi.cn/wp-content/uploads/2024/08/4a2eb538026d80effb0349aa7acfe628.webp" alt="头像"></v-img>
</v-avatar>
<h1 class="text-h2 font-weight-bold mb-2">于小丘</h1>
<div class="text-h6 mb-3">开发者 / 音乐人 / 创造者</div>
<div class="d-flex">
<v-btn
v-for="(link, i) in socialLinks"
:key="i"
:icon="link.icon"
:href="link.url"
variant="text"
color="white"
class="mx-2"
target="_blank"
></v-btn>
</div>
</div>
</v-parallax>
<!-- 显示缓存状态提示 -->
<v-slide-y-transition>
<v-alert
v-if="isFromCache && showCacheAlert"
color="info"
variant="tonal"
density="compact"
class="ma-2 text-center"
closable
@click:close="showCacheAlert = false"
>
<div class="d-flex align-center justify-center">
<span>正在使用缓存数据数据更新时间: {{ formatCacheTime }}</span>
<v-btn
v-if="!refreshing"
variant="text"
size="small"
prepend-icon="mdi-refresh"
class="ml-2"
@click="refreshData"
>
刷新
</v-btn>
<v-progress-circular
v-else
indeterminate
size="20"
width="2"
color="primary"
class="ml-2"
></v-progress-circular>
</div>
</v-alert>
</v-slide-y-transition>
<!-- 关于我 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">关于我</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
目前是机电一体化专业大二学生坐标广州最喜欢用代码创造有趣的东西主攻 Python 开发Golang/PHP/Flutter正在努力修炼中
我的开源作品有HFR-CloudHash-Checker商业项目有 HeyAuth授权系统 HeyPress嘿帕主题 是多个知名开源项目的贡献者
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="primary" class="mr-2" icon="mdi-code-tags"></v-icon>
开发技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">专注于全栈开发 Python 为主力语言同时熟悉多种编程语言与框架</p>
<v-chip-group>
<v-chip v-for="(skill, i) in devSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="error" class="mr-2" icon="mdi-palette"></v-icon>
设计技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">熟练使用各种创意软件从平面设计到视频剪辑热衷于创造视觉体验</p>
<v-chip-group>
<v-chip v-for="(skill, i) in designSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="fill-height hover-card">
<v-card-item>
<v-card-title class="text-h5">
<v-icon color="success" class="mr-2" icon="mdi-music"></v-icon>
音乐技能
</v-card-title>
</v-card-item>
<v-card-text>
<p class="mb-2">热衷于音乐创作和制作擅长使用各种音乐软件创造独特的声音</p>
<v-chip-group>
<v-chip v-for="(skill, i) in musicSkills" :key="i" :color="skill.color" variant="flat" size="small" class="ma-1">
{{ skill.name }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- 项目展示 -->
<v-sheet color="grey-lighten-4" class="py-12">
<v-container>
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">我的项目</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
这些是我最近开发的一些项目涵盖了不同的技术栈和应用场景
</div>
</v-col>
</v-row>
<v-row>
<v-col v-for="(project, i) in projects" :key="i" cols="12" sm="6" lg="4">
<v-card class="fill-height hover-card">
<v-img
:src="project.image"
height="200"
cover
class="align-end text-white"
gradient="to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.8) 100%"
>
<v-card-title class="text-h5">{{ project.title }}</v-card-title>
</v-img>
<v-card-text>
<div class="d-flex align-center mb-3">
<v-chip :color="project.tagColor" size="small" variant="flat">{{ project.tag }}</v-chip>
<v-spacer></v-spacer>
<v-tooltip location="top" text="查看项目">
<template v-slot:activator="{ props }">
<v-btn icon="mdi-open-in-new" size="small" variant="text" :href="project.link" target="_blank" v-bind="props"></v-btn>
</template>
</v-tooltip>
</div>
<p>{{ project.description }}</p>
<v-chip-group>
<v-chip v-for="(tech, j) in project.tech" :key="j" size="x-small" variant="outlined" class="mr-1">
{{ tech }}
</v-chip>
</v-chip-group>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-sheet>
<!-- 音乐作品 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">音乐作品</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
音乐是我另一种表达自我的方式这些是我创作的一些音乐作品
</div>
</v-col>
</v-row>
<v-row>
<v-col v-for="(music, i) in musicWorks" :key="i" cols="12" md="4">
<v-card class="fill-height hover-card">
<v-img
:src="music.cover"
height="200"
cover
></v-img>
<v-card-item>
<v-card-title>{{ music.title }}</v-card-title>
<v-card-subtitle>{{ music.tag }}</v-card-subtitle>
</v-card-item>
<v-card-text>
<p>{{ music.description }}</p>
</v-card-text>
<v-card-actions>
<v-btn
prepend-icon="mdi-play-circle"
variant="tonal"
color="primary"
:href="music.link"
target="_blank"
>
试听
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- 时间线 -->
<v-sheet color="grey-lighten-4" class="py-12">
<v-container>
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">我的历程</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
这是我的个人发展历程每一步都是宝贵的经验
</div>
</v-col>
</v-row>
<v-row justify="center">
<v-col cols="12" md="8">
<v-timeline side="end" align="start">
<v-timeline-item
v-for="(event, i) in timeline"
:key="i"
:dot-color="event.color"
:icon="event.icon"
size="small"
>
<div class="d-flex justify-space-between mb-2">
<strong class="text-primary">{{ event.title }}</strong>
<div class="text-caption">{{ event.date }}</div>
</div>
<div>{{ event.content }}</div>
</v-timeline-item>
</v-timeline>
</v-col>
</v-row>
</v-container>
</v-sheet>
<!-- 联系我 -->
<v-container class="py-12">
<v-row>
<v-col cols="12" class="text-center mb-8">
<h2 class="text-h3 font-weight-bold">联系我</h2>
<div class="mx-auto mt-3 text-body-1 max-width-text">
如果你有任何问题或者合作机会欢迎随时联系我
</div>
</v-col>
</v-row>
</v-container>
<v-footer class="text-center d-flex flex-column">
<div class="pt-4 pb-2 text-white">
<strong>Copyright (C) 2018-{{ new Date().getFullYear() }} 于小丘Yuerchu. </strong> All Rights Reserved.
<a href="https://beian.miit.gov.cn/" target="_blank" class="text-decoration-none">粤ICP备2024285776号-1</a> ·
<a href="http://www.beian.gov.cn/" target="_blank" class="text-decoration-none">粤公网安备 44020302000232 </a>
</div>
</v-footer>
</v-container>
</template>
<script setup lang="ts">
/**
* 首页视图组件
*
* 这是一个现代化的个人主页视图,包含个人介绍、技能展示、项目展示、
* 音乐作品、时间线和联系方式等部分,采用响应式设计适配各种设备。
* 支持本地数据缓存,提升用户体验。
*/
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<number | null>(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 '未知时间';
}
});
/**
* 从服务加载主页数据
*
* 在实际应用中这里应该是API请求
* 目前仅为模拟API请求的示例函数
* @returns {Promise<Object>} 首页数据对象
*/
const fetchHomeData = async (): Promise<any> => {
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 在真实应用中这里应该是API调用
// const response = await fetch('/api/home');
// return response.json();
// 这里使用静态数据模拟API返回
return {
socialLinks,
devSkills,
designSkills,
musicSkills,
projects,
musicWorks,
timeline
};
};
/**
* 从本地存储加载数据
*
* 使用统一的存储服务获取缓存的首页数据
* 如果数据存在且未过期,则使用缓存数据
*/
const loadFromCache = () => {
try {
// 从统一存储服务获取缓存数据
const cachedItem = storageService.getItemFromCache(HOME_CACHE_KEY);
if (cachedItem) {
// 找到有效缓存
// 在实际应用中,可以使用缓存数据更新组件状态
// updateComponentState(cachedItem);
// 获取缓存时间戳以显示在UI上
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 {
// 在实际应用中,这里应该获取当前组件的最新状态
// 或者直接缓存API返回的数据
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();
// 在实际应用中,需要用新数据更新组件状态
// updateComponentWithNewData(newData);
// 更新缓存
storageService.saveItemToCache(HOME_CACHE_KEY, newData);
// 更新UI状态
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 {
// 模拟API请求
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>
<style scoped>
.max-width-text {
max-width: 700px;
}
.hover-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-card:hover {
transform: translateY(-5px);
box-shadow: 0 14px 28px rgba(0,0,0,0.15), 0 10px 10px rgba(0,0,0,0.12) !important;
}
.hover-scale {
transition: transform 0.3s ease;
}
.hover-scale:hover {
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,219 @@
<!-- src/views/Login.vue -->
<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>
<script>
/**
* 登录页面组件
*
* 处理用户登录认证成功后保存token并重定向
* 包含表单验证、记住我功能和错误处理
* 支持显示令牌过期的提示
* 支持自动验证现有令牌并跳转
*/
import apiService from '@/services/api_service';
export default {
data() {
return {
username: '',
password: '',
errorMessage: '',
loading: false,
formValid: false,
tokenExpired: false,
checkingToken: true, // 是否正在验证令牌
usernameRules: [
v => !!v || '用户名不能为空'
],
passwordRules: [
v => !!v || '密码不能为空',
v => (v && v.length >= 6) || '密码长度不能小于6位'
]
}
},
created() {
// 检查是否是因为令牌过期而重定向
this.tokenExpired = this.$route.query.expired === 'true';
// 如果不是因为令牌过期重定向,则验证令牌
if (!this.tokenExpired) {
this.validateExistingToken();
} else {
this.checkingToken = false;
}
},
methods: {
/**
* 验证现有令牌
*
* 检查本地是否有JWT令牌如果有则验证其有效性
* 如果令牌有效,自动重定向到目标页面
*/
async validateExistingToken() {
try {
this.checkingToken = true;
const token = localStorage.getItem('user-token');
// 如果有令牌,验证其有效性
if (token) {
const isValid = await apiService.validateToken();
if (isValid) {
// 令牌有效,重定向到目标页面
console.log('令牌有效,正在重定向');
const redirectPath = this.$route.query.redirect || '/';
this.$router.push(redirectPath);
return;
} else {
// 令牌无效,清除
console.log('令牌无效,已清除');
localStorage.removeItem('user-token');
}
}
} catch (error) {
console.error('验证令牌时出错:', error);
} finally {
this.checkingToken = false;
}
},
/**
* 处理用户登录
*
* 发送登录请求到后端API处理成功和失败情况
* 支持表单验证
*/
async login() {
// 表单验证
const { valid } = await this.$refs.loginForm.validate();
if (!valid) return;
this.loading = true;
this.errorMessage = '';
try {
const result = await apiService.login(this.username, this.password);
if (result.success) {
// 登录成功
this.$root.$emit('show-toast', {
color: 'success',
message: '登录成功,正在跳转...'
});
// 登录成功后重定向
const redirectPath = this.$route.query.redirect || '/';
this.$router.push(redirectPath);
} else {
// 登录失败
this.errorMessage = result.error;
}
} catch (error) {
console.error('登录错误:', error);
this.errorMessage = error.message || '登录过程中发生错误,请稍后再试';
} finally {
this.loading = false;
}
}
}
}
</script>
<style scoped>
.login-card {
border-radius: 8px;
padding: 16px;
}
/* 确保移动设备上有合适的内边距 */
@media (max-width: 600px) {
.v-container {
padding: 12px;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<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>
<script>
/**
* 404错误页面组件
* 当用户访问不存在的路由时显示此页面
*/
export default {
name: 'NotFound'
}
</script>

70
frontend/vite.config.mjs Normal file
View File

@@ -0,0 +1,70 @@
// 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 Normal file

File diff suppressed because it is too large Load Diff

56
main.py
View File

@@ -2,61 +2,35 @@
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-11-29 20:04:41
FilePath: /Findreve/main.py
Description: Findreve
Description: 标记、追踪与找回 —— 就这么简单。
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
Copyright (c) 2018-2025 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import app, ui
import model.database
import asyncio
asyncio.run(model.database.Database().init_db())
import notfound
from routes.frontend import main_page
from routes.frontend import found
from routes.frontend import login
from routes.frontend.admin import home
from routes.frontend.admin import auth
from routes.frontend.admin import about
from routes.frontend.admin import items
from routes.backend import session
from routes.backend import admin
from routes.backend import object
import model
# 导入库
from app import app
from fastapi.staticfiles import StaticFiles
from routes import (session, admin, object)
import logging
notfound.create()
main_page.create()
found.create()
login.create()
home.create()
auth.create()
about.create()
items.create()
# 挂载路由
app.include_router(admin.Router)
app.include_router(session.Router)
app.include_router(object.Router)
# 添加静态文件目录
try:
app.add_static_files(url_path='/static', local_directory='static')
app.mount("/static", StaticFiles(directory="static"), name="static")
except RuntimeError:
logging.error('无法挂载静态目录')
# 启动函数 Startup function
def startup():
asyncio.run(model.database.Database().init_db())
ui.run(
# 作为主程序启动时
if __name__ == '__main__':
import uvicorn
uvicorn.run(
'main:app',
host='0.0.0.0',
favicon='🚀',
port=8080,
title='Findreve',
native=False,
language='zh-CN',
fastapi_docs=True)
if __name__ in {"__main__", "__mp_main__"}:
startup()
reload=True)

View File

@@ -15,6 +15,7 @@ import tool
import logging
from typing import Optional
# 数据库类
class Database:
def __init__(self, db_path: str = "data.db"):
self.db_path = db_path
@@ -103,7 +104,8 @@ class Database:
logging.info("数据库初始化完成并提交更改")
async def add_object(self, key: str, name: str, icon: str = None, phone: str = None):
"""添加新对象
"""
添加新对象
:param key: 序列号
:param name: 名称
@@ -134,7 +136,8 @@ class Database:
lost_description: Optional[str] = None,
find_ip: Optional[str] = None,
lost_time: Optional[str] = None):
"""更新对象信息
"""
更新对象信息
:param id: 对象ID
:param key: 序列号
@@ -171,7 +174,8 @@ class Database:
await db.commit()
async def get_object(self, id: int = None, key: str = None):
"""获取对象
"""
获取对象
:param id: 对象ID
:param key: 序列号
@@ -187,7 +191,8 @@ class Database:
return await cursor.fetchall()
async def delete_object(self, id: int):
"""删除对象
"""
删除对象
:param id: 对象ID
"""
@@ -196,7 +201,8 @@ class Database:
await db.commit()
async def set_setting(self, name: str, value: str):
"""设置配置项
"""
设置配置项
:param name: 配置项名称
:param value: 配置项值
@@ -209,7 +215,8 @@ class Database:
await db.commit()
async def get_setting(self, name: str):
"""获取配置项
"""
获取配置项
:param name: 配置项名称
"""

View File

@@ -1,6 +1,16 @@
from pydantic import BaseModel
from typing import Literal, Optional
class DefaultResponse(BaseModel):
code: int = 0
data: dict | list | None = None
msg: str = ""
msg: str = ""
class ObjectData(BaseModel):
id: int
key: str
name: str
icon: str
status: Literal['ok', 'lost']
phone: str
context: Optional[str] = None

View File

@@ -1,19 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-11-29 20:05:13
FilePath: /Findreve/notfound.py
Description: Findreve 404
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import app
from fastapi import Request
from fastapi.responses import HTMLResponse
def create() -> None:
@app.get('/404')
async def not_found_page(request: Request) -> HTMLResponse:
return HTMLResponse(status_code=404)

177
routes/admin.py Normal file
View File

@@ -0,0 +1,177 @@
from fastapi import APIRouter
from typing import Annotated, Literal, Optional
from fastapi import Depends, Query
from fastapi import HTTPException
import JWT
from model import database
from model.response import DefaultResponse
from model.items import Item
# 验证是否为管理员
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]) -> Literal[True]:
'''
验证是否为管理员。
使用方法:
>>> APIRouter(dependencies=[Depends(is_admin)])
'''
return True
Router = APIRouter(
prefix='/api/admin',
tags=['管理员 Admin'],
dependencies=[Depends(is_admin)]
)
@Router.get(
path='/',
summary='验证管理员身份',
description='返回管理员身份验证结果',
response_model=DefaultResponse,
response_description='当前为管理员'
)
async def verity_admin(
is_admin: Annotated[str, Depends(is_admin)]
) -> Literal[True]:
'''
使用 API 验证是否为管理员。
- 若为管理员,返回 `True`
- 若不是管理员,抛出 `401` 错误
'''
return is_admin
@Router.get(
path='/items',
summary='获取物品信息',
description='返回物品信息列表',
response_model=DefaultResponse,
response_description='物品信息列表'
)
async def get_items(
id: Optional[int] = Query(default=None, ge=1, description='物品ID'),
key: Optional[str] = Query(default=None, description='物品序列号')):
'''
获得物品信息。
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
'''
results = await database.Database().get_object(id=id, key=key)
if results is not None:
if not isinstance(results, list):
items = [results]
else:
items = results
item = []
for i in items:
item.append(Item(
id=i[0],
key=i[1],
name=i[2],
icon=i[3],
status=i[4],
phone=i[5],
lost_description=i[6],
find_ip=i[7],
create_time=i[8],
lost_time=i[9]
))
return DefaultResponse(data=item)
else:
return DefaultResponse(data=[])
@Router.post(
path='/items',
summary='添加物品信息',
description='添加新的物品信息',
response_model=DefaultResponse,
response_description='添加物品成功'
)
async def add_items(
key: str,
name: str,
icon: str,
phone: str) -> DefaultResponse:
'''
添加物品信息。
- **key**: 物品的关键字
- **name**: 物品的名称
- **icon**: 物品的图标
- **phone**: 联系电话
'''
try:
await database.Database().add_object(
key=key, name=name, icon=icon, phone=phone)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return DefaultResponse()
@Router.patch(
path='/items',
summary='更新物品信息',
description='更新现有物品的信息',
response_model=DefaultResponse,
response_description='更新物品成功'
)
async def update_items(
id: int = Query(ge=1),
key: Optional[str] = None,
name: Optional[str] = None,
icon: Optional[str] = None,
status: Optional[str] = None,
phone: Optional[int] = None,
lost_description: Optional[str] = None,
find_ip: Optional[str] = None,
lost_time: Optional[str] = None) -> DefaultResponse:
'''
更新物品信息。
只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。
- **id**: 物品的ID
- **key**: 物品的序列号 **不建议修改此项,这样会导致生成的物品二维码直接失效**
- **name**: 物品的名称
- **icon**: 物品的图标
- **status**: 物品的状态
- **phone**: 联系电话
- **lost_description**: 物品丢失描述
- **find_ip**: 找到物品的IP
- **lost_time**: 物品丢失时间
'''
try:
await database.Database().update_object(
id=id,
key=key, name=name, icon=icon, status=status, phone=phone,
lost_description=lost_description, find_ip=find_ip,
lost_time=lost_time
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return DefaultResponse()
@Router.delete(
path='/items',
summary='删除物品信息',
description='删除指定的物品信息',
response_model=DefaultResponse,
response_description='删除物品成功'
)
async def delete_items(
id: int) -> DefaultResponse:
'''
删除物品信息。
- **id**: 物品的ID
'''
try:
await database.Database().delete_object(id=id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return DefaultResponse()

View File

@@ -1,109 +0,0 @@
from fastapi import APIRouter
from typing import Annotated, Optional
from fastapi import Depends
from fastapi import HTTPException, status
from jwt import InvalidTokenError
import jwt, JWT
from model import database
from model.response import DefaultResponse
from model.items import Item
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Login required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
username = payload.get("sub")
if username is None:
raise credentials_exception
except InvalidTokenError:
raise credentials_exception
if not username == await database.Database().get_setting('account'):
raise credentials_exception
return True
Router = APIRouter(
prefix='/api/admin',
tags=['admin'],
dependencies=[Depends(is_admin)]
)
@Router.get('/')
async def verity_admin(
is_admin: Annotated[str, Depends(is_admin)]
):
return is_admin
@Router.get('/items')
async def get_items(
id: Optional[int] = None,
key: Optional[str] = None):
results = await database.Database().get_object(id=id, key=key)
if results is not None:
if not isinstance(results, list):
items = [results]
else:
items = results
item = []
for i in items:
item.append(Item(
id=i[0],
key=i[1],
name=i[2],
icon=i[3],
status=i[4],
phone=i[5],
lost_description=i[6],
find_ip=i[7],
create_time=i[8],
lost_time=i[9]
))
return DefaultResponse(data=item)
else:
return DefaultResponse(data=[])
@Router.post('/items')
async def add_items(
key: str,
name: str,
icon: str,
phone: str):
await database.Database().add_object(
key=key, name=name, icon=icon, phone=phone)
@Router.patch('/items')
async def update_items(
id: int,
key: Optional[str] = None,
name: Optional[str] = None,
icon: Optional[str] = None,
status: Optional[str] = None,
phone: Optional[int] = None,
lost_description: Optional[str] = None,
find_ip: Optional[str] = None,
lost_time: Optional[str] = None):
try:
await database.Database().update_object(
id=id,
key=key, name=name, icon=icon, status=status, phone=phone,
lost_description=lost_description, find_ip=find_ip,
lost_time=lost_time
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return DefaultResponse()
@Router.delete('/items')
async def delete_items(
id: int):
try:
await database.Database().delete_object(id=id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return DefaultResponse()

View File

@@ -1,4 +0,0 @@
from . import about
from . import auth
from . import home
from . import items

View File

@@ -1,43 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-12-14 20:03:49
FilePath: /Findreve/admin.py
Description: Findreve 后台管理 admin
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui
from fastapi import Request
from tool import *
from ..framework import frame
def create():
@ui.page('/admin/about')
async def admin_about(request: Request):
ui.add_head_html("""
<style type="text/css" src="/static/css/about.css"></style>
<script type="text/javascript" src="/static/js/main.js"></script>
""")
async with frame(request=request):
# 关于 Findreve
with ui.tab_panel('about'):
ui.label('关于 Findreve').classes('text-2xl font-bold')
about = ui.markdown('''加载中...''')
try:
# 延长超时时间到10秒
about_text = await ui.run_javascript('get_about()', timeout=10.0)
if isinstance(about_text, dict) and 'status' in about_text and about_text['status'] == 'failed':
about.set_content(f'加载失败: {about_text.get("detail", "未知错误")}')
else:
about.set_content(about_text)
except Exception as e:
ui.notify(f'加载失败: {str(e)}', color='negative')
about.set_content(f'### 无法加载内容\n\n出现错误: {str(e)}')

View File

@@ -1,41 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-12-14 20:03:49
FilePath: /Findreve/admin.py
Description: Findreve 后台管理 admin
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui
from tool import *
from fastapi import Request
from ..framework import frame
def create():
@ui.page('/admin/auth')
async def admin_auth(request: Request):
# Findreve 授权
async with frame(request=request):
ui.label('Findreve 授权').classes('text-2xl text-bold')
with ui.element('div').classes('p-2 bg-orange-100 w-full'):
with ui.row(align_items='center'):
ui.icon('favorite').classes('text-rose-500 text-2xl')
ui.label('感谢您使用 Findreve').classes('text-rose-500 text-bold')
with ui.column():
ui.markdown('> 使用付费版本请在下方进行授权验证'
'<br>'
'Findreve 是一款良心、厚道的好产品!创作不易,支持正版,从我做起!'
'<br>'
'如需在生产环境部署请前往 `auth.yxqi.cn` 购买正版'
).classes('text-rose-500')
ui.markdown('- Findreve 官网:[https://auth.yxqi.cn](https://auth.yxqi.cn)\n'
'- 作者联系方式QQ 2372526808\n'
'- 管理我的授权:[https://auth.yxqi.cn/product/5](https://auth.yxqi.cn/product/5)\n'
).classes('text-rose-500')
ui.label('您正在使用免费版本无需授权可体验完整版Findreve。').classes('text-bold')

View File

@@ -1,29 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-12-14 20:03:49
FilePath: /Findreve/admin.py
Description: Findreve 后台管理 admin
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui, app
from fastapi import Request
from fastapi.responses import RedirectResponse
from tool import *
from ..framework import frame
def create():
@app.get('/admin')
async def jump():
return RedirectResponse(url='/admin/home')
@ui.page('/admin/home')
async def admin_home(request: Request):
async with frame(request=request):
with ui.tab_panel('main_page'):
ui.label('首页配置').classes('text-2xl text-bold')
ui.label('暂不支持请直接修改main_page.py').classes('text-md text-gray-600').classes('w-full')

View File

@@ -1,453 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-12-14 20:03:49
FilePath: /Findreve/admin.py
Description: Findreve 后台管理 admin
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
import asyncio
from nicegui import ui
from typing import Dict
import qrcode
import base64
import json
from io import BytesIO
from fastapi import Request
from tool import *
from ..framework import frame, loding_process
def create():
@ui.page('/admin/items')
async def admin_items(request: Request):
ui.add_head_html("""
<script type="text/javascript" src="/static/js/main.js"></script>
""")
dark_mode = ui.dark_mode(value=True)
async with frame():
# 表格列的显示隐藏开关
def tableToggle(column: Dict, visible: bool, table) -> None:
column['classes'] = '' if visible else 'hidden'
column['headerClasses'] = '' if visible else 'hidden'
table.update()
# 列表选择函数
async def objectTableOnSelect():
try:
status = str(object_table.selected[0]['status'])
except:
status = None
# 刷新FAB按钮状态
if status:
# 选中正常物品,显示编辑按钮
addObjectFAB.set_visibility(False)
editObjectFAB.set_visibility(True)
else:
addObjectFAB.set_visibility(True)
editObjectFAB.set_visibility(False)
try:
# 预填充编辑表单
if object_table.selected:
selected_item = object_table.selected[0]
edit_object_name.set_value(selected_item.get('name', ''))
edit_object_icon.set_value(selected_item.get('icon', ''))
edit_object_phone.set_value(selected_item.get('phone', ''))
edit_object_key.set_value(selected_item.get('key', ''))
# 设置丢失状态开关
edit_set_object_lost.set_value(selected_item.get('status') == '丢失')
# 设置物主留言
lostReason.set_value(selected_item.get('lost_description', ''))
except:
# 当物品列表未选中,显示添加物品按钮,其他按钮不显示
addObjectFAB.set_visibility(True)
return
# 添加物品
async def addObject():
dialogAddObjectIcon.disable()
async def on_success():
await reloadTable(tips=False)
# 清空输入框
object_name.set_value('')
object_icon.set_value('')
object_phone.set_value('')
object_key.set_value('')
with ui.dialog() as addObjectSuccessDialog, ui.card().style('width: 90%; max-width: 500px'):
ui.button(icon='done').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
ui.label('添加成功').classes('w-full text-h5 text-center')
ui.label('你可以使用下面的链接来访问这个物品')
ui.code(request.base_url.hostname+ '/found?key=' + object_key.value).classes('w-full')
# 生成二维码
qr_data = request.base_url.hostname + '/found?key=' + object_key.value
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill='black', back_color='white')
# 将二维码转换为Base64
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
# 展示二维码
with ui.row(align_items='center').classes('w-full'):
ui.space()
ui.image(f'data:image/png;base64,{img_str}').classes('w-1/3')
ui.space()
with ui.row(align_items='center').classes('w-full'):
ui.space()
ui.button("下载二维码", on_click=lambda: ui.download(buffered.getvalue(), 'qrcode.png')) \
.props('flat rounded')
ui.button("返回", on_click=lambda: (addObjectDialog.close(), addObjectSuccessDialog.close(), addObjectSuccessDialog.delete())) \
.props('flat rounded')
addObjectSuccessDialog.open()
if object_name.value == "" or object_icon == "" or object_phone == "":
ui.notify('必填字段不能为空', color='negative')
dialogAddObjectIcon.enable()
return
if not object_phone.validate():
ui.notify('号码输入有误,请检查!', color='negative')
dialogAddObjectIcon.enable()
return
if object_key.value == "":
object_key.set_value(generate_password())
async with loding_process(
success_content='添加成功',
on_success=on_success,
on_finally=dialogAddObjectIcon.enable()
):
# 正确序列化字符串参数
key = json.dumps(object_key.value)
name = json.dumps(object_name.value)
icon = json.dumps(object_icon.value)
phone = json.dumps(object_phone.value)
result = await ui.run_javascript(
f'addItems({key}, {name}, {icon}, {phone})'
)
if result.get('status') == 'failed':
raise Exception(f"添加失败: {result.get('detail', '未知错误')}")
# 添加物品对话框
with ui.dialog() as addObjectDialog, ui.card().style('width: 90%; max-width: 500px'):
ui.button(icon='add_circle').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
ui.label('添加物品').classes('w-full text-h5 text-center')
with ui.scroll_area().classes('w-full'):
object_name = ui.input('物品名称').classes('w-full')
ui.label('显示的物品名称').classes('-mt-3')
with ui.row(align_items='center').classes('w-full'):
with ui.column().classes('w-1/2 flex-grow'):
object_icon = ui.input('物品图标').classes('w-full')
with ui.row(align_items='center').classes('-mt-3'):
ui.label('将在右侧实时预览图标')
ui.link('图标列表', 'https://fonts.google.com/icons?icon.set=Material+Icons')
ui.icon('').classes('text-2xl flex-grow').bind_name_from(object_icon, 'value')
object_phone = ui.input('物品绑定手机号', validation={'请输入中国大陆格式的11位手机号': lambda value: len(value) == 11 and value.isdigit()}).classes('w-full')
ui.label('目前仅支持中国大陆格式的11位手机号').classes('-mt-3')
object_key = ui.input('物品Key(可选,不填自动生成)').classes('w-full')
ui.label('物品Key为物品的唯一标识可用于物品找回').classes('-mt-3')
async def handle_add_object():
await addObject()
dialogAddObjectIcon = ui.button("添加并生成二维码", icon='qr_code', on_click=handle_add_object) \
.classes('items-center w-full').props('rounded')
ui.button("返回", on_click=addObjectDialog.close) \
.classes('w-full').props('flat rounded')
async def editObjectPrepare():
'''
读取选中物品的ID并预填充编辑表单
'''
try:
# 获取选中物品的ID
item_id = str(object_table.selected[0]['id'])
id_json = json.dumps(item_id)
result: dict = await ui.run_javascript(f'getItem({id_json})')
if result.get('status') == 'failed':
ui.notify(f"获取物品信息失败: {result.get('detail', '未知错误')}", color='negative')
return
except Exception as e:
ui.notify(f"操作失败: {str(e)}", color='negative')
return
else:
result = result['data']['data'][0]
# 预填充编辑表单
edit_object_name.set_value(result['name'])
edit_object_icon.set_value(result['icon'])
edit_object_phone.set_value(result['phone'])
edit_object_key.set_value(result['key'])
edit_set_object_lost.set_value(result['status'] == 'lost')
lostReason.set_value(result['lost_description'])
editObjectDialog.open()
async def editObject():
dialogEditObjectIcon.disable()
async def on_success():
await reloadTable(tips=False)
edit_object_name.set_value('')
edit_object_icon.set_value('')
edit_object_phone.set_value('')
edit_object_key.set_value('')
lostReason.set_value('')
edit_set_object_lost.set_value(False)
editObjectDialog.close()
if edit_object_name.value == "" or edit_object_icon.value == "" or edit_object_phone.value == "":
ui.notify('必填字段不能为空', color='negative')
dialogEditObjectIcon.enable()
return
if not edit_object_phone.validate():
ui.notify('号码输入有误,请检查!', color='negative')
dialogEditObjectIcon.enable()
return
if edit_object_key.value == "":
ui.notify('物品Key不能为空', color='negative')
dialogEditObjectIcon.enable()
return
async with loding_process(
success_content='更新成功',
on_success=on_success,
on_error=dialogEditObjectIcon.enable(),
on_finally=dialogEditObjectIcon.enable()
):
# 获取选中物品的ID
item_id = str(object_table.selected[0]['id'])
# 正确序列化字符串参数
id_json = json.dumps(item_id)
key = json.dumps(edit_object_key.value)
name = json.dumps(edit_object_name.value)
icon = json.dumps(edit_object_icon.value)
phone = json.dumps(edit_object_phone.value)
# 处理状态和物主留言
status = json.dumps('lost' if edit_set_object_lost.value else 'ok')
context = json.dumps(lostReason.value if edit_set_object_lost.value else '')
result = await ui.run_javascript(
f'updateItems({id_json}, {key}, {name}, {icon}, {phone}, {status}, {context})'
)
if result.get('status') == 'failed':
raise Exception(f"更新失败: {result.get('detail', '未知错误')}")
# 编辑物品对话框
with ui.dialog() as editObjectDialog, ui.card().style('width: 90%; max-width: 500px'):
ui.button(icon='edit').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
ui.label('编辑物品信息').classes('w-full text-h5 text-center')
with ui.scroll_area().classes('w-full'):
edit_object_name = ui.input('物品名称').classes('w-full')
ui.label('显示的物品名称').classes('-mt-3')
with ui.row(align_items='center').classes('w-full'):
with ui.column().classes('w-1/2 flex-grow'):
edit_object_icon = ui.input('物品图标').classes('w-full')
with ui.row(align_items='center').classes('-mt-3'):
ui.label('将在右侧实时预览图标')
ui.link('图标列表', 'https://fonts.google.com/icons?icon.set=Material+Icons')
ui.icon('').classes('text-2xl flex-grow').bind_name_from(edit_object_icon, 'value')
edit_object_phone = ui.input('物品绑定手机号').classes('w-full')
ui.label('目前仅支持中国大陆格式的11位手机号').classes('-mt-3')
edit_object_key = ui.input('物品Key').classes('w-full').props('readonly')
ui.label('物品Key为物品的唯一标识不可修改').classes('-mt-3')
edit_set_object_lost = ui.switch('设置物品状态为丢失').classes('w-full')
ui.label('确定要设置这个物品为丢失吗?').bind_visibility_from(edit_set_object_lost, 'value').classes('-mt-3')
ui.html('设置为丢失以后,<b>你的电话号码将会被完整地显示在物品页面</b>(不是“*** **** 8888”而是“188 8888 8888”),以供拾到者能够记下你的电话号码。此外,在页面底部将会显示一个按钮,这个按钮能够一键拨打预先设置好的电话。').bind_visibility_from(edit_set_object_lost, 'value').classes('-mt-3')
lostReason = ui.input('物主留言') \
.classes('block w-full text-gray-900').bind_visibility_from(edit_set_object_lost, 'value').classes('-mt-3')
ui.label('非必填,但建议填写,以方便拾到者联系你').classes('-mt-3').bind_visibility_from(edit_set_object_lost, 'value').classes('-mt-3')
ui.separator().classes('my-4')
with ui.card().classes('w-full bg-red-50 dark:bg-red-900 q-pa-md'):
ui.label('危险区域').classes('text-red-500 font-bold')
ui.button('删除物品', icon='delete_forever') \
.classes('w-full text-red-500').props('flat').on_click(lambda: delete_confirmation_dialog.open())
async def handle_edit_object():
await editObject()
dialogEditObjectIcon = ui.button("确认提交", on_click=handle_edit_object) \
.classes('items-center w-full').props('rounded')
ui.button("返回", on_click=editObjectDialog.close) \
.classes('w-full').props('flat rounded')
# 删除确认对话框
with ui.dialog() as delete_confirmation_dialog, ui.card().style('width: 90%; max-width: 500px'):
ui.button(icon='warning').props('outline round').classes('mx-auto w-auto shadow-sm w-fill text-red-500')
ui.label('确认删除物品').classes('w-full text-h5 text-center text-red-500')
ui.label('此操作不可撤销,删除后物品数据将永久丢失!').classes('w-full text-center text-red-500')
async def handle_delete_item():
async def on_success():
await reloadTable(tips=False)
delete_confirmation_dialog.close()
editObjectDialog.close()
async with loding_process(
success_content='物品已删除',
on_success=on_success
):
# 获取选中物品的ID
item_id = str(object_table.selected[0]['id'])
id_json = json.dumps(item_id)
result = await ui.run_javascript(f'deleteItem({id_json})')
if result.get('status') == 'failed':
raise Exception(f"删除失败: {result.get('detail', '未知错误')}")
with ui.row().classes('w-full'):
ui.space()
ui.button('取消', icon='close', on_click=delete_confirmation_dialog.close).props('flat')
ui.button('确认删除', icon='delete_forever', on_click=handle_delete_item).classes('text-red-500').props('flat')
async def fetch_and_process_objects():
"""获取并处理所有物品数据"""
try:
# 调用前端JavaScript获取数据
response = await ui.run_javascript('getItems()')
if response['status'] == 'failed':
if str(response['detail']).find('Unauthorized'):
ui.notify('未登录或登录已过期,请重新登录', color='negative')
await asyncio.sleep(2)
ui.navigate.to('/login?redirect_to=/admin/items')
ui.notify(response['detail'], color='negative')
return []
# 从response中提取数据
raw_data = response.get('data', {})
objects = raw_data.get('data', []) if isinstance(raw_data, dict) else raw_data
# 进行数据处理,类似旧版本的逻辑
status_map = {'ok': '正常', 'lost': '丢失'}
processed_objects = []
for obj in objects:
# 确保obj是字典
if isinstance(obj, dict):
# 复制对象避免修改原始数据
processed_obj = obj.copy()
# 状态映射
if 'status' in processed_obj:
processed_obj['status'] = status_map.get(processed_obj['status'], processed_obj['status'])
# 时间格式化
if 'create_time' in processed_obj and processed_obj['create_time']:
processed_obj['create_time'] = format_time_diff(processed_obj['create_time'])
if 'lost_time' in processed_obj and processed_obj['lost_time']:
processed_obj['lost_time'] = format_time_diff(processed_obj['lost_time'])
processed_objects.append(processed_obj)
return processed_objects
except Exception as e:
ui.notify(f"获取数据失败: {str(e)}", color='negative')
print(f"Error in fetch_and_process_objects: {str(e)}")
return []
async def reloadTable(tips: bool = True):
objects = await fetch_and_process_objects()
object_table.update_rows(objects)
if tips:
ui.notify('刷新成功')
object_columns = [
{'name': 'id', 'label': '内部ID', 'field': 'id', 'required': True, 'align': 'left'},
{'name': 'key', 'label': '物品Key', 'field': 'key', 'required': True, 'align': 'left'},
{'name': 'name', 'label': '物品名称', 'field': 'name', 'required': True, 'align': 'left'},
{'name': 'icon', 'label': '物品图标', 'field': 'icon', 'required': True, 'align': 'left'},
{'name': 'status', 'label': '物品状态', 'field': 'status', 'required': True, 'align': 'left'},
{'name': 'phone', 'label': '物品绑定手机', 'field': 'phone', 'required': True, 'align': 'left'},
{'name': 'context', 'label': '丢失描述', 'field': 'context', 'required': True, 'align': 'left'},
{'name': 'find_ip', 'label': '物品发现IP', 'field': 'find_ip', 'required': True, 'align': 'left'},
{'name': 'create_time', 'label': '物品创建时间', 'field': 'create_time', 'required': True, 'align': 'left'},
{'name': 'lost_time', 'label': '物品丢失时间', 'field': 'lost_time', 'required': True, 'align': 'left'}
]
objects = await fetch_and_process_objects()
object_table = ui.table(
title='物品 & 库存',
row_key='id',
pagination=10,
selection='single',
columns=object_columns,
rows=objects,
# on_select=lambda: objectTableOnSelect()
).classes('w-full').props('flat')
object_table.add_slot('body-cell-status', '''
<q-td key="status" :props="props">
<q-badge :color="props.value === '正常' ? 'green' : 'red'">
{{ props.value }}
</q-badge>
</q-td>
''')
with object_table.add_slot('top-right'):
ui.input('搜索物品').classes('px-2') \
.bind_value(object_table, 'filter') \
.props('rounded outlined dense clearable')
ui.button(icon='refresh', on_click=lambda: reloadTable()).classes('px-2').props('flat fab-mini')
with ui.button(icon='menu').classes('px-2').props('flat fab-mini'):
with ui.menu(), ui.column().classes('gap-0 p-4'):
for column in object_columns:
ui.switch(column['label'], value=True, on_change=lambda e,
column=column: tableToggle(column=column, visible=e.value, table=object_table))
# FAB按钮
with ui.page_sticky(x_offset=24, y_offset=24) \
.bind_visibility_from(object_table, 'selected', backward=lambda x: not x) as addObjectFAB:
ui.button(icon='add', on_click=addObjectDialog.open) \
.props('fab')
with ui.page_sticky(x_offset=24, y_offset=24) \
.bind_visibility_from(addObjectFAB, 'visible', backward=lambda x: not x) as editObjectFAB:
ui.button(icon='edit', on_click=editObjectPrepare) \
.props('fab')

View File

@@ -1,124 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-11-29 20:03:58
FilePath: /Findreve/found.py
Description: Findreve 物品详情页 found
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui
from fastapi import Request
from .framework import frame
from tool import format_phone
def create_header(object_data, status):
"""创建卡片标题部分"""
icon_color = 'red' if status == 'lost' else None
ui.button(icon=object_data[3], color=icon_color).props('outline round').classes('mx-auto w-auto shadow-sm w-fill max-md:hidden')
title = ui.label('关于此 '+ object_data[2]).classes('text-h5 w-full text-center')
if status == 'lost':
with title:
ui.badge('已被标记为丢失 Already lost', color='red').classes('text-lg -right-10').props('floating')
ui.label('About this '+ object_data[2]).classes('text-xs w-full text-center text-gray-500 -mt-3')
def create_basic_info(object_data):
"""创建基本信息部分"""
ui.label('序列号(Serial number)'+ object_data[1]).classes('text-md w-full text-center -mt-1')
# 根据状态决定是否隐藏手机号
is_private = object_data[4] != 'ok' and object_data[4] != 'lost'
ui.label('物主(Owner)'+ format_phone(object_data[5], private=is_private)).classes('text-md w-full text-center -mt-3')
# 丢失时间(如果有)
if object_data[4] == 'lost' and len(object_data) > 9 and object_data[9]:
ui.label('丢失时间(Lost time)'+ object_data[9]).classes('text-md w-full text-center -mt-3')
def create_status_message(object_data, status):
"""根据状态创建提示信息"""
ui.space()
# 如果是丢失状态且有留言,显示留言
if status == 'lost' and len(object_data) > 6 and object_data[6]:
ui.label('物主留言(Owner message)'+ object_data[6]).classes('text-md w-full text-center')
ui.space()
messages = {
'ok': ('此物品尚未标记为丢失状态。如果你意外捡到了此物品,请尽快联系物主。',
'This item has not been marked as lost. If you accidentally picked it up, please contact the owner as soon as possible.'),
'lost': ('此物品已被物主标记为丢失。您可以通过上面的电话号码来联系物主。',
'This item has been marked as lost by the owner. You can contact the owner through the phone number above.'),
'default': ('此物品状态信息已丢失。如果您捡到了这个物品,请尽快联系物主。如果你是物主,请修改物品信息状态。',
'The item status information has been lost. If you have found this item, please contact the owner as soon as possible. If you are the owner, please modify the item status information.')
}
msg = messages.get(status, messages['default'])
ui.label(msg[0]).classes('text-md w-full text-center')
ui.label(msg[1]).classes('text-xs w-full text-center text-gray-500 -mt-3')
def create_contact_button(phone_number):
"""创建联系按钮"""
if phone_number:
ui.button('联系物主',
on_click=lambda: ui.navigate.to('tel:' + phone_number)) \
.classes('items-center w-full').props('rounded')
def display_item_card(object_data):
"""显示物品信息卡片"""
status = object_data[4]
with ui.card().classes('absolute-center w-3/4 h-3/4'):
# 创建卡片各部分
create_header(object_data, status)
create_basic_info(object_data)
create_status_message(object_data, status)
# 只有状态为'ok'或'lost'时显示联系按钮
if status in ['ok', 'lost']:
create_contact_button(object_data[5])
def create() -> None:
@ui.page('/found')
async def found_page(request: Request, key: str = "") -> None:
ui.add_head_html(
'''
<meta name="robots" content="noindex, nofollow">
<script type="text/javascript" src="/static/js/main.js"></script>
'''
)
await ui.context.client.connected()
async with frame(page='found', request=request):
if key == "" or key == None:
ui.navigate.to('/404')
return
# 加载dialog
with ui.dialog().props('persistent') as loading, ui.card():
with ui.row(align_items='center'):
ui.spinner(size='lg')
with ui.column():
ui.label('数据加载中...')
ui.label('Loading...').classes('text-xs text-gray-500 -mt-3')
loading.open()
try:
object_data = await ui.run_javascript(f'getObject("{key}")')
if object_data['status'] != 'success':
ui.navigate.to('/404')
else:
object_data = object_data['data']
display_item_card(object_data)
except Exception as e:
ui.notify(f'加载失败: {str(e)}', color='negative')
ui.navigate.to('/404')
finally:
loading.close()

View File

@@ -1,114 +0,0 @@
from contextlib import asynccontextmanager
from nicegui import ui
import asyncio
from fastapi import Request
from typing import Optional, Literal
@asynccontextmanager
async def frame(
request: Request = None,
page: Literal['admin', 'session', 'found'] = 'admin',
redirect_to: str = None
):
ui.add_head_html("""
<script type="text/javascript" src="/static/js/main.js"></script>
""")
await ui.context.client.connected()
is_login = await ui.run_javascript('is_login()', timeout=3)
if str(is_login).lower() != 'true':
if page not in ['session', 'found']:
ui.navigate.to(f'/login?redirect_to={request.url.path}')
else:
if page == 'session':
ui.navigate.to(redirect_to)
if page != 'found':
ui.dark_mode(value=True)
with ui.header() \
.classes('items-center py-2 px-5 no-wrap').props('elevated'):
ui.button(icon='menu', on_click=lambda: left_drawer.toggle()).props('flat color=white round')
ui.button(text="Findreve 仪表盘" if page == 'admin' else "Findreve").classes('text-lg').props('flat color=white no-caps')
ui.space()
if str(is_login).lower() == 'true':
ui.button(icon='logout', on_click=lambda: ui.run_javascript('logout()')) \
.props('flat color=white fab-mini').tooltip('退出登录')
with ui.left_drawer() as left_drawer:
with ui.column(align_items='center').classes('w-full'):
ui.image('/static/Findreve.png').classes('w-1/3 mx-auto')
ui.label('Findreve').classes('text-2xl text-bold')
ui.label("免费版,无需授权").classes('text-sm text-gray-500')
if page == 'admin':
ui.button('首页 & 信息', icon='fingerprint', on_click=lambda: ui.navigate.to('/admin/home')) \
.classes('w-full').props('flat no-caps')
ui.button('物品 & 库存', icon='settings', on_click=lambda: ui.navigate.to('/admin/items')) \
.classes('w-full').props('flat no-caps')
ui.button('产品 & 授权', icon='settings', on_click=lambda: ui.navigate.to('/admin/auth')) \
.classes('w-full').props('flat no-caps')
ui.button('关于 & 反馈', icon='settings', on_click=lambda: ui.navigate.to('/admin/about')) \
.classes('w-full').props('flat no-caps')
if page == 'found':
left_drawer.hide()
with ui.column().classes('w-full'):
yield
@asynccontextmanager
async def loding_process(
content: str = '正在处理,请稍后...',
success_content: str = '操作成功',
error_content: str = '操作失败',
on_success: Optional[callable] = None,
on_error: Optional[callable] = None,
on_finally: Optional[callable] = None
):
"""
加载提示框
:param content: 提示内容
:param success_content: 成功提示内容
:param error_content: 失败提示内容
:param on_success: 成功回调函数
:param on_error: 失败回调函数
~~~
使用方法
>>> async with loding_process():
# 处理代码
"""
notify = ui.notification(
message=content,
timeout=None
)
notify.spinner = True
try:
yield
except Exception as e:
notify.spinner = False
notify.type = 'negative'
notify.message = error_content + ':' + str(e)
await asyncio.sleep(3)
notify.dismiss()
if on_error:
await on_error(e)
else:
notify.spinner = False
notify.type = 'positive'
notify.message = success_content
if on_success:
await on_success()
await asyncio.sleep(3)
notify.dismiss()
finally:
if on_finally:
await on_finally()

View File

@@ -1,56 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-11-29 20:29:26
FilePath: /Findreve/login.py
Description: Findreve 登录界面 Login
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui
from typing import Optional
from fastapi.responses import RedirectResponse
from .framework import frame
def create() -> Optional[RedirectResponse]:
@ui.page('/login')
async def session(redirect_to: str = "/"):
ui.add_head_html("""
<script type="text/javascript" src="/static/js/main.js"></script>
""")
ui.page_title('登录 Findreve')
async with frame(page='session', redirect_to=redirect_to):
await ui.context.client.connected()
async def login():
if username.value == "" or password.value == "":
ui.notify('账号或密码不能为空', color='negative')
return
try:
result = await ui.run_javascript(f"login('{username.value}', '{password.value}')")
if result['status'] == 'success':
ui.navigate.to(redirect_to)
else:
ui.notify(f"登录失败: {result['detail']}", type="negative")
except Exception as e:
ui.notify(f"登录失败: {str(e)}", type="negative")
# 创建一个绝对中心的登录卡片
with ui.card().classes('absolute-center round-lg').style('width: 70%; max-width: 500px'):
# 登录标签
ui.button(icon='lock').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
ui.label('登录 Findreve').classes('text-h5 w-full text-center')
# 用户名/密码框
username = ui.input('账号').on('keydown.enter', login) \
.classes('block w-full text-gray-900').props('filled')
password = ui.input('密码', password=True, password_toggle_button=True) \
.on('keydown.enter', login).classes('block w-full text-gray-900').props('filled')
# 按钮布局
ui.button('登录', on_click=lambda: login()).classes('items-center w-full').props('rounded')

View File

@@ -1,151 +0,0 @@
'''
Author: 于小丘 海枫
Date: 2024-10-02 15:23:34
LastEditors: Yuerchu admin@yuxiaoqiu.cn
LastEditTime: 2024-11-29 20:04:24
FilePath: /Findreve/main_page.py
Description: Findreve 个人主页 main_page
Copyright (c) 2018-2025 by 于小丘Yuerchu, All Rights Reserved.
'''
from nicegui import ui
from fastapi import Request
def create_chip(name: str, color: str, tooltip: str) -> ui.chip:
"""Create a UI chip with tooltip"""
return ui.chip(name, color=color).classes('p-4').props('floating').tooltip(tooltip)
def create() -> None:
@ui.page('/')
async def main_page(request: Request) -> None:
dark_mode = ui.dark_mode(value=True)
# 添加页面过渡动画
ui.add_head_html('''
<style type="text/css" src="/static/css/main_page.css"></style>
''')
with ui.row(align_items='center').classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl p-24'):
with ui.column(align_items='center').classes('px-2 max-md:hidden'):
ui.chip('🐍 Python 是最好的语言').classes('text-xs -mt-1 -right-3').props('floating outline')
ui.chip('🎹 精通 FL Studio Mobile').classes('text-xs -mt-1').props('floating outline')
ui.chip('🎨 熟悉 Ps/Pr/Ae/Au/Ai').classes('text-xs -mt-1').props('floating outline')
ui.chip('🏎 热爱竞速(如地平线5)').classes('text-xs -mt-1 -right-3').props('floating outline')
with ui.avatar().classes('w-32 h-32 transition-transform duration-300 hover:scale-110 cursor-pointer'):
ui.image('/static/heyfun.jpg').classes('w-32 h-32')
with ui.column().classes('px-2 max-md:hidden'):
ui.chip('喜欢去广州图书馆看书 📕').classes('text-xs -mt-1 -left-3').props('floating outline')
ui.chip('致力做安卓苹果开发者 📱').classes('text-xs -mt-1').props('floating outline')
ui.chip('正在自研全链个人生态 🔧').classes('text-xs -mt-1').props('floating outline')
ui.chip('致力与开源社区同发展 🤝').classes('text-xs -mt-1 -left-3').props('floating outline')
ui.label('关于本站').classes('w-full text-4xl text-bold text-center py-6 subpixel-antialiased')
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl py-4'):
with ui.card().classes('w-full sm:w-1/5 lg:w-1/7 flex-grow p-8 bg-gradient-to-br from-indigo-700 to-blue-500'):
ui.label('你好,很高兴认识你👋').classes('text-md text-white')
with ui.row(align_items='center'):
ui.label('我叫').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased')
ui.label('于小丘').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased').tooltip('英文名叫Yuerchu也可以叫我海枫')
ui.label('是一名 开发者、音乐人').classes('text-md text-white -mt-1')
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow flex flex-col justify-center'):
ui.code('void main() {\n printf("为了尚未完成的未来");\n}', language='c').classes('text-3xl max-[768px]:text-xl text-bold text-white flex-grow w-full h-full')
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl -mt-3'):
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow p-4'):
ui.label('技能').classes('text-md text-gray-500')
ui.label('开启创造力').classes('text-4xl text-bold -mt-1 right-4')
with ui.row().classes('items-center'):
create_chip('Python', 'amber-400', 'Python是世界上最好的语言')
create_chip('Kotlin', 'violet-400', 'Kotlin给安卓开发APP')
create_chip('Golang', 'sky-400', 'Golang写后端')
create_chip('Lua', 'blue-900', '用aLua给安卓开发给罗技鼠标写鼠标宏')
create_chip('c', 'red-400', 'C写嵌入式开发')
create_chip('FL Studio', 'orange-600', 'FL Studio是世界上最好的宿主')
create_chip('Photoshop', 'blue-950', '修图/抠图/画画一站通')
create_chip('Premiere', 'indigo-900', '剪视频比较顺手,但是一开风扇狂转')
create_chip('After Effects', 'indigo-950', '制作特效,电脑太烂了做不了太花的')
create_chip('Audition', 'purple-900', '写歌做母带挺好用的')
create_chip('Illustrator', 'amber-800', '自制字体和画动态SVG')
create_chip('HTML', 'red-900', '前端入门三件套,不学这玩意其他学了没用')
create_chip('CSS3', 'cyan-900', '. window{ show: none; }')
create_chip('JavaScript', 'lime-900', '还在努力学习中,只会一些简单的')
create_chip('git', 'amber-700', '版本管理是真好用')
create_chip('Docker', 'sky-600', '容器化部署')
create_chip('chatGPT', 'emerald-600', '文本助驾,写代码/写文章/写论文')
create_chip('SAI2', 'gray-950', '入门绘画')
create_chip('ips Draw', 'gray-900', '自认为是iOS端最佳绘画软件')
create_chip('AutoCAD', 'gray-950', '画图/绘制电路图')
create_chip('SolidWorks', 'gray-900', '画图/绘制3D模型')
create_chip('EasyEDA', 'gray-950', '画图/绘制电路图')
create_chip('KiCad', 'gray-900', '画图/绘制电路图')
create_chip('Altium Designer', 'gray-950', '画图/绘制电路图')
ui.label('...').classes('text-md text-gray-500')
with ui.card().classes('w-full sm:w-1/3 lg:w-1/6 flex-grow flex flex-col justify-center'):
ui.label('生涯').classes('text-md text-gray-500')
ui.label('无限进步').classes('text-4xl text-bold -mt-1 right-4')
with ui.timeline(side='right', layout='comfortable'):
ui.timeline_entry('那天我买了第一台服务器并搭建了我第一个Wordpress站点',
title='梦开始的地方',
subtitle='2022年1月21日')
ui.timeline_entry('准备从Cloudreve项目脱离自建网盘系统DiskNext',
title='自建生态计划开始',
subtitle='2024年3月1日')
ui.timeline_entry('目前正在开发HeyAuth、Findreve、DiskNext',
title='项目框架仍在研发中',
subtitle='现在',
icon='rocket')
ui.label('我的作品').classes('w-full text-center text-2xl text-bold p-4')
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('DiskNext').classes('text-lg text-bold')
ui.chip('B端程序').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://pan.yxqi.cn'))).props('flat fab-mini')
ui.label('一个基于NiceGUI的网盘系统性能与Golang媲美').classes('text-sm -mt-3')
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('Findreve').classes('text-lg text-bold')
ui.chip('C端程序').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://i.yxqi.cn'))).props('flat fab-mini')
ui.label('一个基于NiceGUI的个人主页配合物品丢失找回系统').classes('text-sm -mt-3')
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('HeyAuth').classes('text-lg text-bold')
ui.chip('B端程序').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://auth.yxqi.cn'))).props('flat fab-mini')
ui.label('一个基于NiceGUI的B+C端多应用授权系统').classes('text-sm -mt-3')
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('与枫同奔 Run With Fun').classes('text-lg text-bold')
ui.chip('词曲').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=2148944359'))).props('flat fab-mini')
ui.label('我愿如流星赶月那样飞奔').classes('text-sm -mt-3')
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('HeyFun\'s Story').classes('text-lg text-bold')
ui.chip('自设印象曲').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1889436124'))).props('flat fab-mini')
ui.label('飞奔在星辰大海之间的少年').classes('text-sm -mt-3')
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
with ui.row().classes('items-center w-full -mt-2'):
ui.label('2020Fall').classes('text-lg text-bold')
ui.chip('年度纯音乐').classes('text-xs').props('floating')
ui.space()
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1863630345'))).props('flat fab-mini')
ui.label('耗时6个月完成的年度纯音乐').classes('text-sm -mt-3')

View File

@@ -2,12 +2,18 @@ import random
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from model.database import Database
from model.response import DefaultResponse
from model.response import DefaultResponse, ObjectData
import asyncio
Router = APIRouter(prefix='/api/object', tags=['object'])
Router = APIRouter(prefix='/api/object', tags=['物品 Object'])
@Router.get('/{item_key}')
@Router.get(
path='/{item_key}',
summary="获取物品信息",
description="根据物品键获取物品信息",
response_model=DefaultResponse,
response_description="物品信息"
)
async def get_object(item_key: str, request: Request):
"""
获取物品信息 / Get object information
@@ -27,9 +33,15 @@ async def get_object(item_key: str, request: Request):
else:
await asyncio.sleep(random.uniform(0.10, 0.30))
return DefaultResponse(
data=object_data
)
return DefaultResponse(data=ObjectData(
id=object_data[0],
key=object_data[1],
name=object_data[2],
icon=object_data[3],
status=object_data[4],
phone=object_data[5],
context=object_data[6]
).model_dump())
else: return JSONResponse(
status_code=404,
content=DefaultResponse(

View File

@@ -1,4 +1,4 @@
from nicegui import app
# 导入库
from typing import Annotated
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
@@ -10,8 +10,9 @@ from model.token import Token
from model import database
from tool import verify_password
Router = APIRouter()
Router = APIRouter(tags=["令牌 session"])
# 创建令牌
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
@@ -22,6 +23,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
encoded_jwt = jwt.encode(to_encode, JWT.SECRET_KEY, algorithm='HS256')
return encoded_jwt
# 验证账号密码
async def authenticate_user(username: str, password: str):
# 验证账号和密码
account = await database.Database().get_setting('account')
@@ -33,7 +35,13 @@ async def authenticate_user(username: str, password: str):
return {'is_authenticated': True}
# FastAPI 登录路由 / FastAPI login route
@app.post("/api/token")
@Router.post(
path="/api/token",
summary="获取访问令牌",
description="使用用户名和密码获取访问令牌",
response_model=Token,
response_description="访问令牌"
)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token: