mirror of
https://github.com/MeowLynxSea/CatismImage.git
synced 2025-07-09 02:44:34 +00:00
First commit
This commit is contained in:
commit
a17deec331
2
.gitingore
Normal file
2
.gitingore
Normal file
@ -0,0 +1,2 @@
|
||||
/app/__pycache__
|
||||
/app/.env
|
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set the working directory to /app
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the current directory contents into the container at /app
|
||||
COPY . /app
|
||||
|
||||
# Install any needed packages specified in requirements.txt
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
# Make port 5070 available to the world outside this container
|
||||
EXPOSE 5070
|
||||
|
||||
# Define environment variable
|
||||
#ENV NAME World
|
||||
|
||||
# Run app.py when the container launches
|
||||
CMD ["python", "app.py"]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 梦凌汐
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
45
app/README.md
Normal file
45
app/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# CatismImage 项目
|
||||
|
||||
## 项目简介
|
||||
CatismImage 是一个基于Web的图片存储和管理系统,提供用户友好的界面来上传、管理和查看图片。
|
||||
|
||||
## 功能特点
|
||||
- 用户认证和个性化设置
|
||||
- 图片上传和管理功能
|
||||
- 实时流量和存储空间监控
|
||||
- 主题自定义功能(支持亮色/暗色模式)
|
||||
- 喵币充值系统
|
||||
- WebSocket实时数据更新
|
||||
|
||||
## 安装指南
|
||||
1. 确保已安装Python 3.8+
|
||||
2. 克隆项目仓库
|
||||
3. 安装Python依赖:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
4. 配置数据库连接信息
|
||||
5. 运行后端服务:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
6. 访问前端页面:
|
||||
```
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
1. 注册/登录后获取用户Key
|
||||
2. 使用上传功能添加图片
|
||||
3. 在仪表板查看存储使用情况和流量统计
|
||||
4. 通过充值功能获取喵币
|
||||
5. 在设置中自定义主题颜色和模式
|
||||
|
||||
## 技术栈
|
||||
- 前端:HTML5, CSS3, JavaScript, Bootstrap
|
||||
- 后端:Python Flask
|
||||
- 数据库:SQLite/MySQL
|
||||
- 实时通信:WebSocket
|
||||
|
||||
## 贡献指南
|
||||
欢迎提交Pull Request或报告Issue。请确保代码风格一致并通过所有测试。
|
82
app/app.py
Normal file
82
app/app.py
Normal file
@ -0,0 +1,82 @@
|
||||
from flask import Flask, request
|
||||
from flask_cors import CORS
|
||||
from routes.auth import auth_bp
|
||||
from routes.images import images_bp
|
||||
from routes.payment import payment_bp
|
||||
from pymongo import MongoClient
|
||||
from config import Config
|
||||
import json
|
||||
import time
|
||||
|
||||
app = Flask(__name__, static_folder='static')
|
||||
CORS(app)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(images_bp, url_prefix='/images')
|
||||
app.register_blueprint(payment_bp, url_prefix='/payment')
|
||||
|
||||
# 初始化MongoDB连接
|
||||
client = MongoClient(Config.MONGO_URI)
|
||||
db = client[Config.MONGO_DB]
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return app.send_static_file('index.html')
|
||||
|
||||
@app.route('/upload.html')
|
||||
def upload():
|
||||
return app.send_static_file('upload.html')
|
||||
|
||||
@app.route('/dashboard.html')
|
||||
def dashboard():
|
||||
return app.send_static_file('dashboard.html')
|
||||
|
||||
@app.route('/orders.html')
|
||||
def orders():
|
||||
return app.send_static_file('orders.html')
|
||||
|
||||
from flask_sock import Sock
|
||||
from models import User
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/user-info')
|
||||
def user_info_ws(ws):
|
||||
try:
|
||||
while True:
|
||||
# Get actual user info from database
|
||||
key = ws.environ.get('QUERY_STRING', '').split('key=')[1].split('&')[0] if 'key=' in ws.environ.get('QUERY_STRING', '') else None
|
||||
user = User.get_user(key) if key else None
|
||||
|
||||
user_data = {
|
||||
'nickname': user['nickname'] if user else 'Invalid User',
|
||||
# 'register_time': user['register_time'].isoformat() if user else None,
|
||||
'meowcoin': float(user['meowcoin']) if user else 0.0,
|
||||
'traffic': {
|
||||
'traffic_limit': int(user['traffic_limit']) if user else 0,
|
||||
'current_month_traffic': int(user['current_month_traffic']) if user else 0,
|
||||
'monthly_traffic': user.get('monthly_traffic', {}) if user else {}
|
||||
},
|
||||
'storage': {
|
||||
'upload_count': int(user['upload_count']) if user else 0,
|
||||
'storage_limit': int(user['storage_limit']) if user else 0,
|
||||
'used_storage': int(user['used_storage']) if user else 0
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
ws.send(json.dumps(user_data))
|
||||
except Exception as e:
|
||||
print(f"WebSocket send error: {e}")
|
||||
break
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
print(f"WebSocket connection error: {e}")
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host=Config.HOST, port=Config.PORT)
|
29
app/config.py
Normal file
29
app/config.py
Normal file
@ -0,0 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
|
||||
# S3配置
|
||||
S3_ENDPOINT = os.getenv('S3_ENDPOINT')
|
||||
S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY')
|
||||
S3_SECRET_KEY = os.getenv('S3_SECRET_KEY')
|
||||
S3_BUCKET = os.getenv('S3_BUCKET')
|
||||
|
||||
# MongoDB配置
|
||||
MONGO_URI = os.getenv('MONGO_URI')
|
||||
MONGO_DB = os.getenv('MONGO_DB')
|
||||
|
||||
if(S3_ACCESS_KEY == None or S3_SECRET_KEY == None or S3_BUCKET == None or S3_ENDPOINT == None):
|
||||
print("错误:缺少必需的S3环境变量")
|
||||
sys.exit(1)
|
||||
|
||||
if(MONGO_URI == None or MONGO_DB == None):
|
||||
print("错误:缺少必需的MongoDB环境变量")
|
||||
sys.exit(1)
|
||||
|
||||
# 服务监听配置
|
||||
HOST = '0.0.0.0'
|
||||
PORT = 5000
|
136
app/models.py
Normal file
136
app/models.py
Normal file
@ -0,0 +1,136 @@
|
||||
from pymongo import MongoClient
|
||||
from config import Config
|
||||
import random
|
||||
import string
|
||||
import datetime
|
||||
from bson.int64 import Int64
|
||||
from bson.decimal128 import Decimal128
|
||||
|
||||
client = MongoClient(Config.MONGO_URI)
|
||||
db = client[Config.MONGO_DB]
|
||||
|
||||
class User:
|
||||
collection = db['users']
|
||||
|
||||
@staticmethod
|
||||
def generate_key(length=12):
|
||||
"""生成由小写字母和数字组成的随机key"""
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return ''.join(random.choice(chars) for _ in range(length))
|
||||
|
||||
@classmethod
|
||||
def create_user(cls, nickname):
|
||||
"""创建新用户并返回生成的key"""
|
||||
key = cls.generate_key()
|
||||
user_data = {
|
||||
'key': key,
|
||||
'nickname': nickname,
|
||||
'register_time': datetime.datetime.utcnow(),
|
||||
'upload_count': Int64(0),
|
||||
'storage_limit': Int64(100 * 1024 * 1024), # 默认100MB
|
||||
'used_storage': Int64(0),
|
||||
'traffic_limit': Int64(1000 * 1024 * 1024), # 默认1000MB流量限制
|
||||
'monthly_traffic': {
|
||||
f"{datetime.datetime.utcnow().year}-{datetime.datetime.utcnow().month}": Int64(0)
|
||||
},
|
||||
'meowcoin': 0.0,
|
||||
'bills': [],
|
||||
'custom': {
|
||||
'theme_preference': 'light',
|
||||
'theme_color': '#3296fa'
|
||||
}
|
||||
}
|
||||
cls.collection.insert_one(user_data)
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def increment_upload_count(cls, key):
|
||||
"""增加用户上传图片计数"""
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$inc': {'upload_count': 1}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def decrement_upload_count(cls, key):
|
||||
"""减少用户上传图片计数"""
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$inc': {'upload_count': -1}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_user(cls, key):
|
||||
"""根据key获取用户信息"""
|
||||
user = cls.collection.find_one({'key': key})
|
||||
if user:
|
||||
user['register_time'] = user.get('register_time', datetime.datetime.utcnow())
|
||||
user['upload_count'] = Int64(user.get('upload_count', 0))
|
||||
user['storage_limit'] = Int64(user.get('storage_limit', 100 * 1024 * 1024))
|
||||
user['used_storage'] = Int64(user.get('used_storage', 0))
|
||||
user['traffic_limit'] = Int64(user.get('traffic_limit', 1000 * 1024 * 1024))
|
||||
# 获取当月流量
|
||||
now = datetime.datetime.utcnow()
|
||||
month_key = f"{now.year}-{now.month}"
|
||||
user['monthly_traffic'] = user.get('monthly_traffic', {})
|
||||
user['current_month_traffic'] = Int64(user['monthly_traffic'].get(month_key, 0))
|
||||
user['meowcoin'] = float(user.get('meowcoin', 0.0))
|
||||
user['bills'] = user.get('bills', [])
|
||||
user['custom'] = user.get('custom', {
|
||||
'theme_preference': 'light',
|
||||
'theme_color': '#3296fa'
|
||||
})
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def update_nickname(cls, key, new_nickname):
|
||||
"""更新用户昵称"""
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$set': {'nickname': new_nickname}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_storage(cls, key, size):
|
||||
"""更新用户存储使用情况"""
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$inc': {'used_storage': size}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_traffic(cls, key, size):
|
||||
"""更新用户流量使用情况"""
|
||||
now = datetime.datetime.utcnow()
|
||||
month_key = f"{now.year}-{now.month}"
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$inc': {
|
||||
f'monthly_traffic.{month_key}': size
|
||||
}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_meowcoin(cls, key, amount):
|
||||
"""更新用户meowcoin余额"""
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$inc': {'meowcoin': float(amount)}}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def add_bill(cls, key, trade_id, user_id, plan_id, show_amount, discount, total_amount):
|
||||
"""新增账单记录"""
|
||||
bill = {
|
||||
'trade_id': trade_id,
|
||||
'user_id': user_id,
|
||||
'plan_id': plan_id,
|
||||
'amount': show_amount,
|
||||
'discount': discount,
|
||||
'actual_amount': total_amount,
|
||||
'time': datetime.datetime.utcnow()
|
||||
}
|
||||
return cls.collection.update_one(
|
||||
{'key': key},
|
||||
{'$push': {'bills': bill}}
|
||||
)
|
9
app/requirements.txt
Normal file
9
app/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
||||
boto3==1.38.8
|
||||
botocore==1.38.8
|
||||
Flask==3.1.0
|
||||
flask_cors==5.0.1
|
||||
flask_sock==0.7.0
|
||||
Pillow==9.4.0
|
||||
Pillow==11.2.1
|
||||
pymongo==4.12.1
|
||||
python-dotenv==1.1.0
|
BIN
app/routes/__pycache__/auth.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/images.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/images.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/payment.cpython-311.pyc
Normal file
BIN
app/routes/__pycache__/payment.cpython-311.pyc
Normal file
Binary file not shown.
104
app/routes/auth.py
Normal file
104
app/routes/auth.py
Normal file
@ -0,0 +1,104 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from models import User
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/register', methods=['POST'])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
nickname = data.get('nickname')
|
||||
|
||||
if not nickname:
|
||||
return jsonify({'error': '请输入昵称'}), 400
|
||||
|
||||
key = User.create_user(nickname)
|
||||
return jsonify({'key': key}), 201
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
key = data.get('key')
|
||||
|
||||
if not key:
|
||||
return jsonify({'error': 'Key is required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': '密钥无效'}), 401
|
||||
|
||||
return jsonify({'nickname': user['nickname']}), 200
|
||||
|
||||
@auth_bp.route('/userinfo', methods=['GET'])
|
||||
def user_info():
|
||||
key = request.args.get('key')
|
||||
if not key:
|
||||
return jsonify({'error': 'Key is required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid key'}), 401
|
||||
|
||||
return jsonify({
|
||||
'nickname': user['nickname'],
|
||||
'register_time': user['register_time'],
|
||||
'upload_count': user['upload_count'],
|
||||
'storage_limit': user['storage_limit'],
|
||||
'used_storage': user['used_storage'],
|
||||
'traffic_limit': user['traffic_limit'],
|
||||
'current_month_traffic': user['current_month_traffic'],
|
||||
'monthly_traffic': user.get('monthly_traffic', {})
|
||||
}), 200
|
||||
|
||||
@auth_bp.route('/update_nickname', methods=['POST'])
|
||||
def update_nickname():
|
||||
data = request.get_json()
|
||||
key = data.get('key')
|
||||
new_nickname = data.get('new_nickname')
|
||||
|
||||
if not key or not new_nickname:
|
||||
return jsonify({'error': 'Key and new nickname are required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': '密钥无效'}), 401
|
||||
|
||||
User.update_nickname(key, new_nickname)
|
||||
return jsonify({'message': 'Nickname updated successfully'}), 200
|
||||
|
||||
@auth_bp.route('/get_theme', methods=['GET'])
|
||||
def get_theme():
|
||||
key = request.args.get('key')
|
||||
if not key:
|
||||
return jsonify({'error': 'Key is required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': '密钥无效'}), 401
|
||||
|
||||
return jsonify({
|
||||
'theme_mode': user['custom'].get('theme_preference', 'light'),
|
||||
'theme_color': user['custom'].get('theme_color', '#3296fa')
|
||||
}), 200
|
||||
|
||||
@auth_bp.route('/update_theme', methods=['POST'])
|
||||
def update_theme():
|
||||
data = request.get_json()
|
||||
key = data.get('key')
|
||||
theme_preference = data.get('theme_mode')
|
||||
theme_color = data.get('theme_color')
|
||||
|
||||
if not key or not theme_preference or not theme_color:
|
||||
return jsonify({'error': 'Key, theme_preference and theme_color are required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': '密钥无效'}), 401
|
||||
|
||||
User.collection.update_one(
|
||||
{'key': key},
|
||||
{'$set': {
|
||||
'custom.theme_preference': theme_preference,
|
||||
'custom.theme_color': theme_color
|
||||
}}
|
||||
)
|
||||
return jsonify({'message': 'Theme updated successfully'}), 200
|
293
app/routes/images.py
Normal file
293
app/routes/images.py
Normal file
@ -0,0 +1,293 @@
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from config import Config as AppConfig
|
||||
from models import User
|
||||
from pymongo import MongoClient
|
||||
from bson import ObjectId
|
||||
import datetime
|
||||
|
||||
# 初始化MongoDB连接
|
||||
client = MongoClient(AppConfig.MONGO_URI)
|
||||
db = client[AppConfig.MONGO_DB]
|
||||
|
||||
images_bp = Blueprint('images', __name__)
|
||||
|
||||
# 初始化S3客户端
|
||||
s3 = boto3.client(
|
||||
's3',
|
||||
endpoint_url=AppConfig.S3_ENDPOINT,
|
||||
aws_access_key_id=AppConfig.S3_ACCESS_KEY,
|
||||
aws_secret_access_key=AppConfig.S3_SECRET_KEY,
|
||||
config=Config(signature_version='s3v4')
|
||||
)
|
||||
|
||||
@images_bp.route('/upload', methods=['POST'])
|
||||
def upload_image():
|
||||
# 验证用户key
|
||||
key = request.form.get('key')
|
||||
if not key:
|
||||
return jsonify({'error': 'Key is required'}), 400
|
||||
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': '用户账户无效'}), 401
|
||||
|
||||
# 处理上传的文件
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': '文件上传失败'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': '未选择文件'}), 400
|
||||
|
||||
# 检查文件类型
|
||||
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
if '.' not in file.filename or file.filename.split('.')[-1].lower() not in allowed_extensions:
|
||||
return jsonify({'error': '不支持的文件类型'}), 400
|
||||
|
||||
# 检查文件内容是否为有效图片
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# 直接使用PIL验证图片内容
|
||||
try:
|
||||
file.seek(0)
|
||||
img = Image.open(file)
|
||||
img.verify()
|
||||
file.seek(0)
|
||||
except Exception:
|
||||
return jsonify({'error': '无效的图片文件'}), 400
|
||||
except Exception:
|
||||
return jsonify({'error': '无效的图片文件'}), 400
|
||||
|
||||
# 获取文件大小并检查存储空间
|
||||
file.seek(0, 2) # 移动到文件末尾
|
||||
file_size = file.tell() # 获取文件大小(字节)
|
||||
file.seek(0) # 重置文件指针
|
||||
|
||||
if user['used_storage'] + file_size > user['storage_limit']:
|
||||
return jsonify({'error': '存储空间不足'}), 403
|
||||
|
||||
# 生成唯一文件名: 用户key_时间戳.扩展名
|
||||
import time
|
||||
filename = f"{key}_{int(time.time())}.{file.filename.split('.')[-1]}"
|
||||
|
||||
try:
|
||||
# 生成缩略图并上传
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# 创建缩略图
|
||||
file.seek(0) # Rewind file pointer before processing
|
||||
img = Image.open(file)
|
||||
img.thumbnail((128, 128))
|
||||
|
||||
# 转换为webp格式
|
||||
thumb_filename = f"thumb_{filename.split('.')[0]}.webp"
|
||||
thumb_buffer = io.BytesIO()
|
||||
img.save(thumb_buffer, format='WEBP')
|
||||
thumb_buffer.seek(0)
|
||||
|
||||
# 上传缩略图
|
||||
try:
|
||||
s3.upload_fileobj(
|
||||
thumb_buffer,
|
||||
AppConfig.S3_BUCKET,
|
||||
thumb_filename,
|
||||
ExtraArgs={'ACL': 'public-read'}
|
||||
)
|
||||
finally:
|
||||
thumb_buffer.close()
|
||||
|
||||
# 获取文件大小
|
||||
file.seek(0, 2) # 移动到文件末尾
|
||||
file_size = file.tell() # 获取文件大小(字节)
|
||||
file.seek(0) # 重置文件指针
|
||||
|
||||
# 存储图片信息到MongoDB并立即返回响应
|
||||
image_id = db.images.insert_one({
|
||||
'filename': filename,
|
||||
'thumb_filename': thumb_filename,
|
||||
'owner_key': key,
|
||||
'created_at': datetime.datetime.utcnow(),
|
||||
'size': file_size,
|
||||
'upload_status': 'pending'
|
||||
}).inserted_id
|
||||
|
||||
# 立即返回响应
|
||||
response = jsonify({
|
||||
'url': f'/images/{str(image_id)}',
|
||||
'thumb_url': f'/images/thumb/{str(image_id)}',
|
||||
'message': '图片正在上传中'
|
||||
})
|
||||
|
||||
# 同步上传原文件到S3
|
||||
try:
|
||||
s3.upload_fileobj(
|
||||
file,
|
||||
AppConfig.S3_BUCKET,
|
||||
filename,
|
||||
ExtraArgs={'ACL': 'public-read'}
|
||||
)
|
||||
|
||||
# 更新上传状态和用户信息
|
||||
db.images.update_one(
|
||||
{'_id': image_id},
|
||||
{'$set': {'upload_status': 'completed'}}
|
||||
)
|
||||
User.increment_upload_count(key)
|
||||
User.update_storage(key, file_size)
|
||||
|
||||
return response, 200
|
||||
except Exception as e:
|
||||
db.images.update_one(
|
||||
{'_id': image_id},
|
||||
{'$set': {'upload_status': 'failed', 'error': str(e)}}
|
||||
)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@images_bp.route('/<image_id>', methods=['GET'])
|
||||
def get_image(image_id):
|
||||
try:
|
||||
# 验证image_id格式
|
||||
if not ObjectId.is_valid(image_id):
|
||||
return jsonify({'error': 'Invalid image ID format'}), 400
|
||||
|
||||
# 从MongoDB获取图片信息
|
||||
image_data = db.images.find_one({'_id': ObjectId(image_id)})
|
||||
if not image_data:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
|
||||
# 获取用户key并验证流量限制
|
||||
owner_key = image_data['owner_key']
|
||||
if owner_key:
|
||||
owner_user = User.get_user(owner_key)
|
||||
if owner_user:
|
||||
# 检查当月流量是否超出限制
|
||||
if owner_user['current_month_traffic'] + image_data['size'] > owner_user['traffic_limit']:
|
||||
return jsonify({'error': '本月流量已用完,无法访问原图'}), 403
|
||||
|
||||
# 从S3获取图片并返回
|
||||
response = s3.get_object(
|
||||
Bucket=AppConfig.S3_BUCKET,
|
||||
Key=image_data['filename']
|
||||
)
|
||||
|
||||
# 记录流量使用
|
||||
|
||||
owner_user = User.get_user(owner_key)
|
||||
if owner_user:
|
||||
User.update_traffic(owner_key, image_data['size'])
|
||||
|
||||
return Response(
|
||||
response['Body'].read(),
|
||||
mimetype=response['ContentType']
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Error getting image {image_id}: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@images_bp.route('/thumb/<image_id>', methods=['GET'])
|
||||
def get_thumbnail(image_id):
|
||||
try:
|
||||
# 验证image_id格式
|
||||
if not ObjectId.is_valid(image_id):
|
||||
return jsonify({'error': 'Invalid image ID format'}), 400
|
||||
|
||||
# 从MongoDB获取图片信息
|
||||
image_data = db.images.find_one({'_id': ObjectId(image_id)})
|
||||
if not image_data:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
|
||||
# 从S3获取缩略图并返回
|
||||
response = s3.get_object(
|
||||
Bucket=AppConfig.S3_BUCKET,
|
||||
Key=image_data['thumb_filename']
|
||||
)
|
||||
|
||||
return Response(
|
||||
response['Body'].read(),
|
||||
mimetype=response['ContentType']
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Error getting thumbnail {image_id}: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@images_bp.route('/user_images', methods=['GET'])
|
||||
def get_user_images():
|
||||
try:
|
||||
user_key = request.args.get('key')
|
||||
if not user_key:
|
||||
return jsonify({'error': 'User key is required'}), 400
|
||||
|
||||
# 验证用户是否存在
|
||||
user = User.get_user(user_key)
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# 获取用户上传的所有图片
|
||||
images = list(db.images.find({'owner_key': user_key}))
|
||||
|
||||
# 转换ObjectId为字符串并添加后端转发URL
|
||||
for img in images:
|
||||
img['_id'] = str(img['_id'])
|
||||
img['url'] = f'/images/{img["_id"]}'
|
||||
img['thumb_url'] = f'/images/thumb/{img["_id"]}'
|
||||
img['created_at'] = (img['created_at'] + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
return jsonify({'images': images})
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Error getting user images: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@images_bp.route('/<image_id>', methods=['DELETE'])
|
||||
def delete_image(image_id):
|
||||
try:
|
||||
# 验证用户key
|
||||
key = request.json.get('key')
|
||||
if not key:
|
||||
return jsonify({'error': 'Key is required'}), 400
|
||||
|
||||
# 验证用户是否存在
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid key'}), 401
|
||||
|
||||
# 验证image_id格式
|
||||
if not ObjectId.is_valid(image_id):
|
||||
return jsonify({'error': 'Invalid image ID format'}), 400
|
||||
|
||||
# 查找图片记录
|
||||
image_data = db.images.find_one({'_id': ObjectId(image_id)})
|
||||
if not image_data:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
|
||||
# 验证图片所有者
|
||||
if image_data['owner_key'] != key:
|
||||
return jsonify({'error': 'Unauthorized to delete this image'}), 403
|
||||
|
||||
# 从S3删除原文件和缩略图
|
||||
s3.delete_object(Bucket=AppConfig.S3_BUCKET, Key=image_data['filename'])
|
||||
s3.delete_object(Bucket=AppConfig.S3_BUCKET, Key=image_data['thumb_filename'])
|
||||
|
||||
# 从数据库删除记录
|
||||
# 获取图片大小
|
||||
image_size = image_data['size']
|
||||
|
||||
# 更新用户上传计数和存储使用情况
|
||||
User.decrement_upload_count(key)
|
||||
User.update_storage(key, -image_size)
|
||||
db.images.delete_one({'_id': ObjectId(image_id)})
|
||||
|
||||
return jsonify({'message': 'Image deleted successfully'}), 200
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Error deleting image {image_id}: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
91
app/routes/payment.py
Normal file
91
app/routes/payment.py
Normal file
@ -0,0 +1,91 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from models import User
|
||||
import datetime
|
||||
|
||||
payment_bp = Blueprint('payment', __name__)
|
||||
|
||||
@payment_bp.route('/orders', methods=['GET'])
|
||||
def get_orders():
|
||||
start_date = request.args.get('start')
|
||||
end_date = request.args.get('end')
|
||||
user_key = request.args.get('key')
|
||||
|
||||
if not all([start_date, end_date, user_key]):
|
||||
return jsonify({'error': 'Missing required parameters'}), 400
|
||||
|
||||
# 验证用户权限
|
||||
user = User.get_user(user_key)
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid user key'}), 401
|
||||
|
||||
# 筛选账单数据
|
||||
try:
|
||||
start = datetime.datetime.strptime(start_date, '%Y-%m-%d')
|
||||
end = datetime.datetime.strptime(end_date, '%Y-%m-%d')
|
||||
end += datetime.timedelta(days=1)
|
||||
|
||||
filtered_bills = [
|
||||
{
|
||||
'order_id': bill.get('trade_id', ''),
|
||||
'created_at': bill['time'].isoformat(),
|
||||
'amount': bill.get('actual_amount', '0'),
|
||||
'status': 'completed'
|
||||
}
|
||||
for bill in user.get('bills', [])
|
||||
if start <= bill['time'] <= end
|
||||
]
|
||||
|
||||
return jsonify(filtered_bills), 200
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid date format, use YYYY-MM-DD'}), 400
|
||||
|
||||
|
||||
@payment_bp.route('/ifdian_callback', methods=['POST'])
|
||||
def ifdian_callback():
|
||||
data = request.get_json()
|
||||
|
||||
# 验证请求格式
|
||||
if not data or 'data' not in data or 'order' not in data['data']:
|
||||
return jsonify({'ec': 200, 'error': 'Invalid request format'}), 400
|
||||
|
||||
order = data['data']['order']
|
||||
|
||||
# 如果product_type为0,直接返回
|
||||
if order.get('product_type', 0) == 0:
|
||||
return jsonify({'message': 'product_type is 0, no action needed'}), 200
|
||||
|
||||
# 处理product_type为1的情况
|
||||
if order.get('product_type', 0) == 1:
|
||||
key = order.get('remark', '')
|
||||
if not key:
|
||||
return jsonify({'ec': 200, 'error': 'Missing user key in remark'}), 400
|
||||
|
||||
# 获取用户
|
||||
user = User.get_user(key)
|
||||
if not user:
|
||||
return jsonify({'ec': 200, 'error': 'User not found'}), 404
|
||||
|
||||
try:
|
||||
# 将show_amount转换为浮点数MeowCoin
|
||||
meowcoin_amount = float(order.get('show_amount', '0'))
|
||||
|
||||
# 更新用户meowcoin余额
|
||||
User.update_meowcoin(key, meowcoin_amount)
|
||||
|
||||
# 添加账单记录
|
||||
User.add_bill(
|
||||
key=key,
|
||||
trade_id=order.get('out_trade_no', ''),
|
||||
user_id=order.get('user_id', ''),
|
||||
plan_id=order.get('plan_id', ''),
|
||||
show_amount=order.get('show_amount', '0'),
|
||||
discount=order.get('discount', '0'),
|
||||
total_amount=order.get('total_amount', '0')
|
||||
)
|
||||
|
||||
return jsonify({'ec': 200}), 200
|
||||
|
||||
except ValueError:
|
||||
return jsonify({'ec': 200, 'error': 'Invalid amount format'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'ec': 200, 'error': str(e)}), 500
|
6
app/static/css/bootstrap.min.css
vendored
Normal file
6
app/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
425
app/static/css/theme.css
Normal file
425
app/static/css/theme.css
Normal file
@ -0,0 +1,425 @@
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #212529;
|
||||
--card-bg: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #212529;
|
||||
--text-color: #f8f9fa;
|
||||
--card-bg: #343a40;
|
||||
--border-color: #495057;
|
||||
--user-info-bg: #2c3034;
|
||||
--form-label-color: #adb5bd;
|
||||
--drop-zone-border: #495057;
|
||||
--drop-zone-text: #dee2e6;
|
||||
--image-list-bg: #343a40;
|
||||
--image-list-text: #f8f9fa;
|
||||
--storage-text: #e9ecef;
|
||||
--image-count-text: #e9ecef;
|
||||
--image-meta-text: #ced4da;
|
||||
--modal-bg: #343a40;
|
||||
--modal-text: #f8f9fa;
|
||||
--modal-close: #f8f9fa;
|
||||
--loading-text: #e9ecef;
|
||||
--input-bg: #343a40;
|
||||
--input-text: #f8f9fa;
|
||||
--input-border: #495057;
|
||||
--pagination-active-bg: #495057;
|
||||
--pagination-active-text: #ffffff;
|
||||
--btn-active-bg: #0a58ca;
|
||||
--btn-active-border: #0a58ca;
|
||||
--btn-focus-shadow: rgba(10, 88, 202, 0.5);
|
||||
--progress-bg: #495057;
|
||||
|
||||
.background-layer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: #e0e0e0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
color: #e0e0e0;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||
background-color: #2d2d2d !important;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(even) {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: rgba(52, 58, 64, 0.7);
|
||||
color: #f8f9fa;
|
||||
border-color: #495057;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-color: #495057;
|
||||
color: #e0e0e0;
|
||||
background-color: rgba(45, 45, 45, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: rgba(0, 0, 0, 0.4) !important;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#imageListContainer h2,
|
||||
.card-body,
|
||||
.card-text,
|
||||
#storageUsage,
|
||||
#imageCount,
|
||||
.drop-zone p,
|
||||
#loadingIndicator p,
|
||||
.modal-content,
|
||||
.table,
|
||||
.table th,
|
||||
.table td,
|
||||
.form-label,
|
||||
.form-control {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-color: #787878;
|
||||
--theme-color-light: #787878;
|
||||
--theme-color-lighter: #787878;
|
||||
--theme-color-dark: #787878;
|
||||
--theme-color-darker: #787878;
|
||||
}
|
||||
|
||||
.background-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(125deg, var(--theme-color-darker), var(--theme-color-dark), var(--theme-color), var(--theme-color-light), var(--theme-color-lighter));
|
||||
background-size: 500%;
|
||||
animation: bgAnimation 15s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bgAnimation {
|
||||
0%{
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50%{
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100%{
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
#loginBox {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background-color: var(--user-info-bg, var(--card-bg));
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--form-label-color, var(--text-color));
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--input-bg, var(--card-bg));
|
||||
color: var(--input-text, var(--text-color));
|
||||
border-color: var(--input-border, var(--border-color));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--pagination-active-bg, #0d6efd) !important;
|
||||
color: var(--pagination-active-text, #ffffff) !important;
|
||||
border-color: var(--pagination-active-bg, #0d6efd) !important;
|
||||
}
|
||||
|
||||
.btn-primary:active, .btn-primary.active {
|
||||
background-color: var(--btn-active-bg, #0b5ed7);
|
||||
border-color: var(--btn-active-border, #0a58ca);
|
||||
}
|
||||
|
||||
.btn:hover, .btn-primary:hover, #themeDropdown:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0.9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#themeDropdown {
|
||||
background-color: #4a6bff;
|
||||
color: white;
|
||||
border: 2px solid #2a4bdf;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #themeDropdown {
|
||||
background-color: #6b8cff;
|
||||
border-color: #4a6bff;
|
||||
}
|
||||
|
||||
#themeDropdown:hover {
|
||||
background-color: #3a5bef;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #themeDropdown:hover {
|
||||
background-color: #5a7bff;
|
||||
}
|
||||
|
||||
.btn-primary:focus {
|
||||
box-shadow: 0 0 0 0.25rem var(--btn-focus-shadow, rgba(49, 132, 253, 0.5));
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border-color: var(--drop-zone-border, var(--border-color));
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.drop-zone p {
|
||||
color: var(--drop-zone-text, var(--text-color));
|
||||
}
|
||||
|
||||
#storageUsage,
|
||||
#imageCount {
|
||||
color: var(--storage-text, var(--text-color));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: var(--image-meta-text, var(--text-color));
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: var(--image-meta-text, var(--text-color));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
color: var(--modal-text, var(--text-color));
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(0);
|
||||
}
|
||||
|
||||
.selected-image {
|
||||
box-shadow: 0 0 15px rgba(0, 123, 255, 0.5);
|
||||
border: 3px solid #007bff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-close {
|
||||
filter: invert(0);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .image-action-menu {
|
||||
background-color: var(--card-bg) !important;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .image-action-menu .btn {
|
||||
color: #f8f9fa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .image-action-menu .btn:hover {
|
||||
background-color: #3d3d3d;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .image-action-menu .text-secondary {
|
||||
color: #adb5bd !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .image-action-menu .text-danger {
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
#loadingIndicator p {
|
||||
color: var(--loading-text, var(--text-color));
|
||||
}
|
||||
|
||||
#imageListContainer {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
#imageListContainer:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#imageListContainer h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #imageListContainer {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .user-info {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card {
|
||||
/* margin-bottom: 20px; */
|
||||
transition: transform 0.3s ease;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .card {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 0 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02) translateY(-3px);
|
||||
box-shadow: 0 0 rgba(255, 218, 34, 0.05);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.card img {
|
||||
object-fit: cover;
|
||||
transition: all 0.3s ease;
|
||||
transform: scale(1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#editNicknameForm {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: var(--progress-bg, #e9ecef);
|
||||
}
|
||||
|
||||
.toast {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
221
app/static/dashboard.html
Normal file
221
app/static/dashboard.html
Normal file
@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CatismImage - 仪表盘</title>
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/theme.css" rel="stylesheet">
|
||||
<link href="/static/css/theme.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-layer"></div>
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="user-info">
|
||||
<h3>用户信息 <button class="btn btn-sm btn-danger" id="logoutBtn">退出登录</button>
|
||||
<div class="btn-group ms-2">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="themeDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
主题
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="themeDropdown">
|
||||
<li><a class="dropdown-item" href="#" data-theme="light">浅色</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-theme="dark">深色</a></li>
|
||||
<li><a class="dropdown-item" href="#" id="themeSettingsBtn">更多设置</a></li>
|
||||
</ul>
|
||||
</div></h3>
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-7 mb-2">
|
||||
<label class="form-label">登录密钥</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="userKey"></span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" id="copyKeyBtn">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<label class="form-label">昵称</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="userNickname"></span>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2" id="editNicknameBtn" data-bs-toggle="modal" data-bs-target="#editNicknameModal">修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h5 class="mb-2">用量情况</h5>
|
||||
<div class="col-12 col-lg-12 mb-2">
|
||||
<label class="form-label">MeowCoin余额</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="userMeowcoin">Loading...</span>
|
||||
<button class="btn btn-sm btn-primary ms-2" id="rechargeBtn">充值</button>
|
||||
<button class="btn btn-sm btn-info ms-2" id="queryBillBtn">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-2" id="trafficUsage"></div>
|
||||
<div class="mb-3" id="storageUsage"></div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h4>上传图片</h4>
|
||||
<form id="uploadForm">
|
||||
<div class="my-3">
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<input class="form-control" type="file" id="imageFile" accept="image/*" multiple required style="display: none;">
|
||||
<p style="margin-top: 10px; cursor: pointer;">点击或拖拽图片到此处</p>
|
||||
<div id="uploadArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div id="uploadResult" class="mt-4 d-none">
|
||||
<div class="alert alert-success">
|
||||
<h5>上传成功!</h5>
|
||||
<p>图片URL: <a href="#" id="imageUrl" target="_blank"></a></p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<img id="uploadPreviewImage" class="img-thumbnail" style="max-height: 300px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div id="imageListContainer">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3>我的图片</h3>
|
||||
<button class="btn btn-sm btn-outline-secondary d-md-none my-2" type="button" data-bs-toggle="collapse" data-bs-target="#imageListCollapse" aria-expanded="false" aria-controls="imageListCollapse">
|
||||
显示/隐藏
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse d-md-block mx-2" id="imageListCollapse">
|
||||
<div class="row" id="imageList">
|
||||
<!-- 图片列表将通过JS动态加载 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" style="position: fixed; top: 20px; right: 20px; z-index: 9999;"></div>
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<!-- 主题设置模态框 -->
|
||||
<div class="modal fade" id="themeSettingsModal" tabindex="-1" aria-labelledby="themeSettingsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="themeSettingsModalLabel">主题设置</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">主题模式</label>
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle mx-2" type="button" id="themeDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
选择主题
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="themeDropdown">
|
||||
<li><a class="dropdown-item" href="#" data-theme="light">浅色</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-theme="dark">深色</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="themeColorPicker" class="form-label">主题颜色</label>
|
||||
<input type="color" class="form-control form-control-color w-50" id="themeColorPicker" value="#0d6efd">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 昵称修改模态框 -->
|
||||
<div class="modal fade" id="editNicknameModal" tabindex="-1" aria-labelledby="editNicknameModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editNicknameModalLabel">修改昵称</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="newNickname" class="form-label">新昵称</label>
|
||||
<input type="text" class="form-control" id="newNickname">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="saveNicknameBtn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 充值模态框 -->
|
||||
<div class="modal fade" id="rechargeModal" tabindex="-1" aria-labelledby="rechargeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="rechargeModalLabel">充值 MeowCoin</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="rechargeAmount" class="form-label">充值金额</label>
|
||||
<input type="number" class="form-control" id="rechargeAmount" min="1" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmRechargeBtn">确认充值</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账单查询模态框 -->
|
||||
<div class="modal fade" id="billQueryModal" tabindex="-1" aria-labelledby="billQueryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-fullscreen-lg-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="billQueryModalLabel">账单查询</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-5">
|
||||
<label for="startDate" class="form-label">开始日期</label>
|
||||
<input type="date" class="form-control" id="startDate">
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="endDate" class="form-label">结束日期</label>
|
||||
<input type="date" class="form-control" id="endDate">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" id="queryBillSubmit">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>时间</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="billTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
109
app/static/index.html
Normal file
109
app/static/index.html
Normal file
@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CatismImage - 轻量好用的图床</title>
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/theme.css" rel="stylesheet">
|
||||
<script>
|
||||
// 检测系统主题偏好
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-layer"></div>
|
||||
<div class="container" id="loginBox">
|
||||
<div class="row justify-content-center">
|
||||
<h1 class="text-center mb-4">Catism Image</h1>
|
||||
<h4 class="text-center mb-4">立志做真正轻量好用的图床</h4>
|
||||
<div class="col-md-6 mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="authTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="login-tab" data-bs-toggle="tab" data-bs-target="#login" type="button" role="tab">登录</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab">注册</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="authTabsContent">
|
||||
<div class="tab-pane fade show active" id="login" role="tabpanel">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="loginKey" class="form-label">密钥</label>
|
||||
<input type="text" class="form-control" id="loginKey" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="register" role="tabpanel">
|
||||
<form id="registerForm">
|
||||
<div class="mb-3">
|
||||
<label for="registerNickname" class="form-label">昵称</label>
|
||||
<input type="text" class="form-control" id="registerNickname" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">注册</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<div id="toastContainer" class="position-fixed bottom-0 end-0 p-3" style="z-index: 11"></div>
|
||||
|
||||
<div class="position-fixed bottom-0 start-0 p-3" style="z-index: 11">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="themeDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
主题
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="themeDropdown">
|
||||
<li><a class="dropdown-item" href="#" data-theme="light">浅色</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-theme="dark">深色</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-theme="auto">自动</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-theme]').forEach(item => {
|
||||
item.addEventListener('click', e => {
|
||||
if (e.target.tagName === 'A') {
|
||||
e.preventDefault();
|
||||
const theme = e.target.getAttribute('data-theme');
|
||||
if (theme === 'auto') {
|
||||
localStorage.removeItem('manualTheme');
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
localStorage.setItem('manualTheme', theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 防止输入框点击切换主题
|
||||
document.querySelectorAll('input').forEach(input => {
|
||||
input.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
90
app/static/js/app.js
Normal file
90
app/static/js/app.js
Normal file
@ -0,0 +1,90 @@
|
||||
// 登录表单处理
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toastContainer');
|
||||
const toastEl = document.createElement('div');
|
||||
|
||||
toastEl.className = `toast show align-items-center text-white bg-${type} border-0`;
|
||||
toastEl.setAttribute('role', 'alert');
|
||||
toastEl.setAttribute('aria-live', 'assertive');
|
||||
toastEl.setAttribute('aria-atomic', 'true');
|
||||
|
||||
toastEl.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toastEl);
|
||||
|
||||
// 自动移除Toast
|
||||
setTimeout(() => {
|
||||
toastEl.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
toastEl.remove();
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const key = document.getElementById('loginKey').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`欢迎回来, ${data.nickname}!`, 'success');
|
||||
// 保存key到localStorage
|
||||
localStorage.setItem('userKey', key);
|
||||
// 登录成功后跳转到仪表盘
|
||||
window.location.href = '/dashboard.html';
|
||||
} else {
|
||||
showToast(`登录失败: ${data.error}`, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录请求出错:', error);
|
||||
showToast('登录请求出错', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
// 注册表单处理
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const nickname = document.getElementById('registerNickname').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ nickname })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`注册成功! 您的密钥是: ${data.key}`, 'success');
|
||||
// 自动填充登录表单
|
||||
document.getElementById('loginKey').value = data.key;
|
||||
// 切换到登录标签页
|
||||
document.getElementById('login-tab').click();
|
||||
} else {
|
||||
showToast(`注册失败: ${data.error}`, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册请求出错:', error);
|
||||
showToast('注册请求出错', 'danger');
|
||||
}
|
||||
});
|
7
app/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
app/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1169
app/static/js/dashboard.js
Normal file
1169
app/static/js/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user