Compare commits
10 Commits
43ec347f86
...
8457ad64e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8457ad64e0 | ||
|
|
5c79a9aa00 | ||
|
|
3e2cdf4c74 | ||
|
|
3660346797 | ||
|
|
b788c04f1d | ||
|
|
3a30271fe6 | ||
|
|
8e561a2eb7 | ||
|
|
86baccd2ee | ||
|
|
6cd3aed5c5 | ||
|
|
8f6ae3fdfc |
22
TODO(记得提交到github前删!!!).txt
Normal file
22
TODO(记得提交到github前删!!!).txt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
用户面板设计:
|
||||||
|
|
||||||
|
投稿 评论 文件 设置(Identity token 图片压缩等级 等等)
|
||||||
|
|
||||||
|
评论支持删除,投稿支持修改+删除
|
||||||
|
评论也支持投诉,投诉按钮放进"..."里
|
||||||
|
|
||||||
|
通知功能的实现,通知图标,点击出现前5条,点击查看全部所有的出现,分页机制不要忘记(参考雨云)
|
||||||
|
|
||||||
|
rate limit功能
|
||||||
|
|
||||||
|
rss功能
|
||||||
|
|
||||||
|
完成开发后记得Debug=False
|
||||||
|
|
||||||
|
深浅色模式切换逻辑有待完善,没实现localstoarge储存状态
|
||||||
|
|
||||||
|
AI审核
|
||||||
|
|
||||||
|
后台权限分层,审核员/管理员
|
||||||
|
|
||||||
|
链接跳转新页面
|
||||||
507
back/main.py
507
back/main.py
@@ -3,9 +3,13 @@
|
|||||||
from flask import Flask, jsonify, request, abort, send_from_directory
|
from flask import Flask, jsonify, request, abort, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy.orm import foreign
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
import filetype
|
||||||
|
from PIL import Image
|
||||||
|
from PIL import UnidentifiedImageError
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -23,7 +27,10 @@ db = SQLAlchemy(app)
|
|||||||
# 全局配置变量
|
# 全局配置变量
|
||||||
NEED_AUDIT = True
|
NEED_AUDIT = True
|
||||||
FILE_SIZE_LIMIT_MB = 10.0
|
FILE_SIZE_LIMIT_MB = 10.0
|
||||||
FILE_FORMATS = ["png", "jpg", "jpeg", "gif", "webp"]
|
FILE_FORMATS = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
|
|
||||||
|
def now_time():
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
# --- 定义数据库结构 ---
|
# --- 定义数据库结构 ---
|
||||||
class SiteSettings(db.Model):
|
class SiteSettings(db.Model):
|
||||||
@@ -50,23 +57,37 @@ class Submission(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
identity_token = db.Column(db.String(36), nullable=True)
|
identity_token = db.Column(db.String(36), nullable=True)
|
||||||
status = db.Column(db.String(20), default='Pending')
|
status = db.Column(db.String(20), default='Pending', index=True)
|
||||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
created_at = db.Column(db.DateTime, default=now_time)
|
||||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
updated_at = db.Column(db.DateTime, default=now_time, onupdate=now_time)
|
||||||
upvotes = db.Column(db.Integer, default=0)
|
upvotes = db.Column(db.Integer, default=0)
|
||||||
downvotes = db.Column(db.Integer, default=0)
|
downvotes = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
comments = db.relationship('Comment', backref='submission', lazy=True, cascade='all, delete-orphan')
|
comments = db.relationship('Comment', backref='submission', lazy=True, cascade='all, delete-orphan')
|
||||||
|
hashtags = db.relationship(
|
||||||
|
'Hashtag',
|
||||||
|
primaryjoin="and_(Hashtag.type==0, foreign(Hashtag.target_id)==Submission.id)",
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
lazy=True,
|
||||||
|
overlaps="hashtags"
|
||||||
|
)
|
||||||
|
|
||||||
class Comment(db.Model):
|
class Comment(db.Model):
|
||||||
__tablename__ = 'comments'
|
__tablename__ = 'comments'
|
||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False)
|
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False, index=True)
|
||||||
nickname = db.Column(db.String(50), default='匿名用户')
|
nickname = db.Column(db.String(50), default='匿名用户')
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
identity_token = db.Column(db.String(36), nullable=True)
|
identity_token = db.Column(db.String(36), nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
created_at = db.Column(db.DateTime, default=now_time)
|
||||||
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True)
|
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True)
|
||||||
|
hashtags = db.relationship(
|
||||||
|
'Hashtag',
|
||||||
|
primaryjoin="and_(Hashtag.type==1, foreign(Hashtag.target_id)==Comment.id)",
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
lazy=True,
|
||||||
|
overlaps="hashtags"
|
||||||
|
)
|
||||||
|
|
||||||
class Report(db.Model):
|
class Report(db.Model):
|
||||||
__tablename__ = 'reports'
|
__tablename__ = 'reports'
|
||||||
@@ -80,6 +101,9 @@ class Report(db.Model):
|
|||||||
|
|
||||||
class Hashtag(db.Model):
|
class Hashtag(db.Model):
|
||||||
__tablename__ = 'hashtags'
|
__tablename__ = 'hashtags'
|
||||||
|
__table_args__ = (
|
||||||
|
db.Index('ix_hashtags_type_target', 'type', 'target_id'),
|
||||||
|
)
|
||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
type = db.Column(db.Integer, nullable=False) # 0: Submission, 1: Comment
|
type = db.Column(db.Integer, nullable=False) # 0: Submission, 1: Comment
|
||||||
target_id = db.Column(db.Integer, nullable=False)
|
target_id = db.Column(db.Integer, nullable=False)
|
||||||
@@ -96,6 +120,7 @@ class ImgFile(db.Model):
|
|||||||
path = db.Column(db.String(255), nullable=False)
|
path = db.Column(db.String(255), nullable=False)
|
||||||
name = db.Column(db.String(255), nullable=True)
|
name = db.Column(db.String(255), nullable=True)
|
||||||
identity_token = db.Column(db.String(36), nullable=True)
|
identity_token = db.Column(db.String(36), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_time)
|
||||||
|
|
||||||
class SiteNotice(db.Model):
|
class SiteNotice(db.Model):
|
||||||
__tablename__ = 'site_notice'
|
__tablename__ = 'site_notice'
|
||||||
@@ -103,8 +128,8 @@ class SiteNotice(db.Model):
|
|||||||
type = db.Column(db.String(10), default='md', nullable=False)
|
type = db.Column(db.String(10), default='md', nullable=False)
|
||||||
content = db.Column(db.Text, default='', nullable=False)
|
content = db.Column(db.Text, default='', nullable=False)
|
||||||
version = db.Column(db.Integer, default=0, nullable=False)
|
version = db.Column(db.Integer, default=0, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
created_at = db.Column(db.DateTime, default=now_time)
|
||||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(), onupdate=datetime.now)
|
updated_at = db.Column(db.DateTime, default=now_time, onupdate=now_time)
|
||||||
|
|
||||||
# 初始化数据库函数
|
# 初始化数据库函数
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -114,6 +139,15 @@ def init_db():
|
|||||||
os.makedirs(IMG_DIR)
|
os.makedirs(IMG_DIR)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
try:
|
||||||
|
columns = [row[1] for row in db.session.execute("PRAGMA table_info(img_files)").fetchall()]
|
||||||
|
if "created_at" not in columns:
|
||||||
|
db.session.execute("ALTER TABLE img_files ADD COLUMN created_at DATETIME")
|
||||||
|
db.session.commit()
|
||||||
|
db.session.execute("UPDATE img_files SET created_at = ? WHERE created_at IS NULL", (now_time(),))
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
try:
|
try:
|
||||||
existing = SiteNotice.query.first()
|
existing = SiteNotice.query.first()
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -144,7 +178,7 @@ def load_config():
|
|||||||
else:
|
else:
|
||||||
parsed = raw
|
parsed = raw
|
||||||
if isinstance(parsed, list):
|
if isinstance(parsed, list):
|
||||||
FILE_FORMATS = [str(x).strip().lstrip('.').lower() for x in parsed if str(x).strip()]
|
FILE_FORMATS = [str(x).strip().lower() for x in parsed if str(x).strip()]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -159,6 +193,105 @@ def load_config():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to load deny words: {e}")
|
print(f"Warning: Failed to load deny words: {e}")
|
||||||
|
|
||||||
|
def is_identity_valid(token):
|
||||||
|
if not token:
|
||||||
|
return True
|
||||||
|
return Identity.query.filter_by(token=token).first() is not None
|
||||||
|
|
||||||
|
def normalize_identity(token):
|
||||||
|
if token and not is_identity_valid(token):
|
||||||
|
return False, None
|
||||||
|
return True, token if token else None
|
||||||
|
|
||||||
|
def find_deny_word(content):
|
||||||
|
try:
|
||||||
|
words = DENY_WORDS_CACHE
|
||||||
|
except Exception:
|
||||||
|
words = []
|
||||||
|
for word in words:
|
||||||
|
if word in content:
|
||||||
|
return word
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_hashtags(tag_type, target_id, hashtopic):
|
||||||
|
if not hashtopic:
|
||||||
|
return
|
||||||
|
for tag in hashtopic:
|
||||||
|
new_tag = Hashtag(
|
||||||
|
type=tag_type,
|
||||||
|
target_id=target_id,
|
||||||
|
name=tag
|
||||||
|
)
|
||||||
|
db.session.add(new_tag)
|
||||||
|
|
||||||
|
def mime_allowed(mime, rules):
|
||||||
|
if not rules:
|
||||||
|
return True
|
||||||
|
if not mime:
|
||||||
|
return False
|
||||||
|
mime = mime.lower()
|
||||||
|
for rule in rules:
|
||||||
|
rule = str(rule).strip().lower()
|
||||||
|
if not rule:
|
||||||
|
continue
|
||||||
|
if rule == 'image/*':
|
||||||
|
if mime.startswith('image/'):
|
||||||
|
return True
|
||||||
|
if mime == rule:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def normalize_img_path(path):
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
path = str(path).strip()
|
||||||
|
if path.startswith('/api/files/'):
|
||||||
|
path = path[len('/api/files/'):]
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
def require_identity(token):
|
||||||
|
if not token:
|
||||||
|
return False, None, 2009, "端点需要提供Identity才能操作"
|
||||||
|
if not is_identity_valid(token):
|
||||||
|
return False, None, 2004, "无效的Identity Token"
|
||||||
|
return True, token, None, None
|
||||||
|
|
||||||
|
def get_identity_from_args(source):
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
return source.get("identity_token") or source.get("identity")
|
||||||
|
|
||||||
|
def validate_upload_file(file):
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return False, 2000, "参数错误", None
|
||||||
|
file.seek(0, os.SEEK_END)
|
||||||
|
file_length = file.tell()
|
||||||
|
file.seek(0)
|
||||||
|
if FILE_SIZE_LIMIT_MB is not None:
|
||||||
|
limit_bytes = float(FILE_SIZE_LIMIT_MB) * 1024 * 1024
|
||||||
|
if file_length > limit_bytes:
|
||||||
|
return False, 2006, "上传的图片超出限制大小", None
|
||||||
|
|
||||||
|
kind = filetype.guess(file.read(5120))
|
||||||
|
file.seek(0)
|
||||||
|
detected_mime = kind.mime if kind else None
|
||||||
|
if not detected_mime or not mime_allowed(detected_mime, FILE_FORMATS):
|
||||||
|
return False, 2007, "上传的文件类型不支持", None
|
||||||
|
|
||||||
|
try:
|
||||||
|
file.seek(0)
|
||||||
|
img = Image.open(file)
|
||||||
|
img.verify()
|
||||||
|
file.seek(0)
|
||||||
|
except (UnidentifiedImageError, OSError):
|
||||||
|
file.seek(0)
|
||||||
|
return False, 2008, "上传的文件损坏", None
|
||||||
|
except Exception:
|
||||||
|
file.seek(0)
|
||||||
|
return False, 2008, "上传的文件损坏", None
|
||||||
|
|
||||||
|
return True, None, None, kind
|
||||||
|
|
||||||
# --- 用户普通api端点 ---
|
# --- 用户普通api端点 ---
|
||||||
@app.route('/api/settings', methods=['GET'])
|
@app.route('/api/settings', methods=['GET'])
|
||||||
def get_settings():
|
def get_settings():
|
||||||
@@ -191,7 +324,7 @@ def get_about():
|
|||||||
"data": settings.about
|
"data": settings.about
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# about在初始化时不会被设置,避免管理面板报错,返回默认文本
|
# about在初始化时不会被设置,避免报错,返回默认文本
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"code": 1000,
|
"code": 1000,
|
||||||
"data": "# 默认关于页面\n关于页面未设置,请前往管理面板操作。"
|
"data": "# 默认关于页面\n关于页面未设置,请前往管理面板操作。"
|
||||||
@@ -277,19 +410,16 @@ def submit_post():
|
|||||||
identity_token = data.get('identity')
|
identity_token = data.get('identity')
|
||||||
|
|
||||||
# 违禁词检测
|
# 违禁词检测
|
||||||
for word in DENY_WORDS_CACHE:
|
if find_deny_word(content):
|
||||||
if word in content:
|
return jsonify({"code": 2005, "data": "提交内容包含违禁词"})
|
||||||
return jsonify({"code": 2005, "data": "提交内容包含违禁词"})
|
|
||||||
|
|
||||||
# Identity 验证
|
# Identity 验证
|
||||||
if identity_token:
|
ok, identity_token = normalize_identity(identity_token)
|
||||||
if not Identity.query.filter_by(token=identity_token).first():
|
if not ok:
|
||||||
return jsonify({"code": 2004, "data": "无效的 Identity Token"})
|
return jsonify({"code": 2004, "data": "无效的 Identity Token"})
|
||||||
else:
|
|
||||||
identity_token = None
|
|
||||||
|
|
||||||
# 保存
|
# 保存
|
||||||
now = datetime.now()
|
now = now_time()
|
||||||
new_post = Submission(
|
new_post = Submission(
|
||||||
content=content,
|
content=content,
|
||||||
identity_token=identity_token,
|
identity_token=identity_token,
|
||||||
@@ -298,22 +428,16 @@ def submit_post():
|
|||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
db.session.add(new_post)
|
db.session.add(new_post)
|
||||||
db.session.commit()
|
db.session.flush()
|
||||||
|
|
||||||
# 保存 Hashtags
|
# 保存 Hashtags
|
||||||
if hashtopic:
|
save_hashtags(0, new_post.id, hashtopic)
|
||||||
for tag in hashtopic:
|
db.session.commit()
|
||||||
new_tag = Hashtag(
|
|
||||||
type=0, # 0 for Submission
|
|
||||||
target_id=new_post.id,
|
|
||||||
name=tag
|
|
||||||
)
|
|
||||||
db.session.add(new_tag)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
code = 1002 if new_post.status == 'Pending' else 1001
|
code = 1002 if new_post.status == 'Pending' else 1000
|
||||||
return jsonify({"code": code, "data": {"id": new_post.id}})
|
return jsonify({"code": code, "data": {"id": new_post.id}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
return jsonify({"code": 2003, "data": f"投稿失败: {str(e)}"})
|
return jsonify({"code": 2003, "data": f"投稿失败: {str(e)}"})
|
||||||
|
|
||||||
@app.route('/api/comment', methods=['POST'])
|
@app.route('/api/comment', methods=['POST'])
|
||||||
@@ -343,16 +467,13 @@ def submit_comment():
|
|||||||
return jsonify({"code": 2002, "data": "投稿不存在"})
|
return jsonify({"code": 2002, "data": "投稿不存在"})
|
||||||
|
|
||||||
# 违禁词检测
|
# 违禁词检测
|
||||||
for word in DENY_WORDS_CACHE:
|
if find_deny_word(content):
|
||||||
if word in content:
|
return jsonify({"code": 2005, "data": "提交内容包含违禁词"})
|
||||||
return jsonify({"code": 2005, "data": "提交内容包含违禁词"})
|
|
||||||
|
|
||||||
# Identity 验证
|
# Identity 验证
|
||||||
if identity_token:
|
ok, identity_token = normalize_identity(identity_token)
|
||||||
if not Identity.query.filter_by(token=identity_token).first():
|
if not ok:
|
||||||
return jsonify({"code": 2004, "data": "无效的 Identity Token"})
|
return jsonify({"code": 2004, "data": "无效的 Identity Token"})
|
||||||
else:
|
|
||||||
identity_token = None
|
|
||||||
|
|
||||||
new_comment = Comment(
|
new_comment = Comment(
|
||||||
submission_id=submission_id,
|
submission_id=submission_id,
|
||||||
@@ -362,21 +483,15 @@ def submit_comment():
|
|||||||
parent_comment_id=None if parent_comment_id == 0 else parent_comment_id
|
parent_comment_id=None if parent_comment_id == 0 else parent_comment_id
|
||||||
)
|
)
|
||||||
db.session.add(new_comment)
|
db.session.add(new_comment)
|
||||||
db.session.commit()
|
db.session.flush()
|
||||||
|
|
||||||
# 保存 Hashtags
|
# 保存 Hashtags
|
||||||
if hashtopic:
|
save_hashtags(1, new_comment.id, hashtopic)
|
||||||
for tag in hashtopic:
|
db.session.commit()
|
||||||
new_tag = Hashtag(
|
|
||||||
type=1, # 1 for Comment
|
|
||||||
target_id=new_comment.id,
|
|
||||||
name=tag
|
|
||||||
)
|
|
||||||
db.session.add(new_tag)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({"code": 1001, "data": {"id": new_comment.id}})
|
return jsonify({"code": 1000, "data": {"id": new_comment.id}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
return jsonify({"code": 2003, "data": f"评论失败: {str(e)}"})
|
return jsonify({"code": 2003, "data": f"评论失败: {str(e)}"})
|
||||||
|
|
||||||
@app.route('/api/report', methods=['POST'])
|
@app.route('/api/report', methods=['POST'])
|
||||||
@@ -399,11 +514,9 @@ def submit_report():
|
|||||||
return jsonify({"code": 2002, "data": "投稿不存在"})
|
return jsonify({"code": 2002, "data": "投稿不存在"})
|
||||||
|
|
||||||
identity_token = data.get('identity')
|
identity_token = data.get('identity')
|
||||||
if identity_token:
|
ok, identity_token = normalize_identity(identity_token)
|
||||||
if not Identity.query.filter_by(token=identity_token).first():
|
if not ok:
|
||||||
return jsonify({"code": 2004, "data": "无效的Identity Token"})
|
return jsonify({"code": 2004, "data": "无效的Identity Token"})
|
||||||
else:
|
|
||||||
identity_token = None
|
|
||||||
|
|
||||||
report = Report(
|
report = Report(
|
||||||
submission_id=submission_id,
|
submission_id=submission_id,
|
||||||
@@ -415,7 +528,7 @@ def submit_report():
|
|||||||
db.session.add(report)
|
db.session.add(report)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({"code": 1001, "data": {"id": report.id}})
|
return jsonify({"code": 1000, "data": {"id": report.id}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 2003, "data": f"投诉失败: {str(e)}"})
|
return jsonify({"code": 2003, "data": f"投诉失败: {str(e)}"})
|
||||||
|
|
||||||
@@ -457,31 +570,25 @@ def upload_pic():
|
|||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
return jsonify({"code": 2000, "data": "参数错误"})
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
if file.filename == '':
|
ok, err_code, err_msg, kind = validate_upload_file(file)
|
||||||
return jsonify({"code": 2000, "data": "参数错误"})
|
if not ok:
|
||||||
|
return jsonify({"code": err_code, "data": err_msg})
|
||||||
|
|
||||||
file.seek(0, os.SEEK_END)
|
ext = None
|
||||||
file_length = file.tell()
|
if kind and kind.extension:
|
||||||
file.seek(0)
|
ext = kind.extension
|
||||||
if FILE_SIZE_LIMIT_MB is not None:
|
if not ext and file.filename:
|
||||||
limit_bytes = float(FILE_SIZE_LIMIT_MB) * 1024 * 1024
|
ext = os.path.splitext(file.filename)[1].lstrip('.') or None
|
||||||
if file_length > limit_bytes:
|
filename = f"{uuid.uuid4().hex}.{ext}" if ext else uuid.uuid4().hex
|
||||||
return jsonify({"code": 2006, "data": "上传的图片超出限制大小"})
|
|
||||||
|
|
||||||
ext = os.path.splitext(file.filename)[1].lstrip('.').lower()
|
|
||||||
if not ext or (FILE_FORMATS and ext not in FILE_FORMATS):
|
|
||||||
return jsonify({"code": 2007, "data": "上传的文件类型不支持"})
|
|
||||||
|
|
||||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
|
||||||
filepath = os.path.join(IMG_DIR, filename)
|
filepath = os.path.join(IMG_DIR, filename)
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
|
|
||||||
identity_token = request.form.get('identity_token') or None
|
identity_token = request.form.get('identity_token') or None
|
||||||
name = file.filename or None
|
name = file.filename or None
|
||||||
db.session.add(ImgFile(path=filename, name=name, identity_token=identity_token))
|
db.session.add(ImgFile(path=filename, name=name, identity_token=identity_token, created_at=now_time()))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({"code": 1001, "data": f"/api/files/{filename}"})
|
return jsonify({"code": 1000, "data": f"/api/files/{filename}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 2003, "data": str(e)})
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
@@ -597,7 +704,7 @@ def get_posts_info():
|
|||||||
"time": s.created_at.isoformat() if s.created_at else None,
|
"time": s.created_at.isoformat() if s.created_at else None,
|
||||||
"modified": 0 if (not s.updated_at or not s.created_at or s.updated_at == s.created_at) else 1,
|
"modified": 0 if (not s.updated_at or not s.created_at or s.updated_at == s.created_at) else 1,
|
||||||
"comment_count": len(s.comments),
|
"comment_count": len(s.comments),
|
||||||
"total_pages": pagination.total,
|
"total_pages": pagination.pages,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -607,6 +714,67 @@ def get_posts_info():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 2003, "data": str(e)})
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/get_posts_by_tag', methods=['GET'])
|
||||||
|
def get_posts_by_tag():
|
||||||
|
try:
|
||||||
|
tag = request.args.get("tag")
|
||||||
|
if not tag:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
|
||||||
|
tag = str(tag).strip().strip('"').strip("'")
|
||||||
|
if tag.startswith('#'):
|
||||||
|
tag = tag[1:]
|
||||||
|
if not tag:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
per_page = 10
|
||||||
|
submission_ids_query = db.session.query(
|
||||||
|
Hashtag.target_id.label('submission_id')
|
||||||
|
).filter(
|
||||||
|
Hashtag.type == 0,
|
||||||
|
Hashtag.name == tag
|
||||||
|
)
|
||||||
|
|
||||||
|
comment_submission_ids_query = db.session.query(
|
||||||
|
Comment.submission_id.label('submission_id')
|
||||||
|
).join(
|
||||||
|
Hashtag,
|
||||||
|
db.and_(Hashtag.type == 1, Hashtag.target_id == Comment.id)
|
||||||
|
).filter(
|
||||||
|
Hashtag.name == tag
|
||||||
|
)
|
||||||
|
|
||||||
|
union_subq = submission_ids_query.union(comment_submission_ids_query).subquery()
|
||||||
|
|
||||||
|
query = Submission.query.filter(
|
||||||
|
Submission.id.in_(db.select(union_subq.c.submission_id)),
|
||||||
|
Submission.status == 'Pass'
|
||||||
|
).order_by(Submission.id.desc())
|
||||||
|
|
||||||
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for s in pagination.items:
|
||||||
|
data.append({
|
||||||
|
"id": s.id,
|
||||||
|
"content": s.content,
|
||||||
|
"upvotes": s.upvotes,
|
||||||
|
"downvotes": s.downvotes,
|
||||||
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
|
"time": s.created_at.isoformat() if s.created_at else None,
|
||||||
|
"modified": 0 if (not s.updated_at or not s.created_at or s.updated_at == s.created_at) else 1,
|
||||||
|
"comment_count": len(s.comments),
|
||||||
|
"total_pages": pagination.pages,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"code": 1000, "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
@app.route('/api/post_info', methods=['GET'])
|
@app.route('/api/post_info', methods=['GET'])
|
||||||
def get_post_info():
|
def get_post_info():
|
||||||
try:
|
try:
|
||||||
@@ -636,12 +804,73 @@ def get_post_info():
|
|||||||
@app.route('/api/hot_topics', methods=['GET'])
|
@app.route('/api/hot_topics', methods=['GET'])
|
||||||
def get_hot_topics():
|
def get_hot_topics():
|
||||||
try:
|
try:
|
||||||
|
submission_tags = db.session.query(
|
||||||
|
Hashtag.name.label('name')
|
||||||
|
).join(
|
||||||
|
Submission,
|
||||||
|
db.and_(Hashtag.type == 0, Hashtag.target_id == Submission.id)
|
||||||
|
).filter(
|
||||||
|
Submission.status == 'Pass'
|
||||||
|
)
|
||||||
|
|
||||||
|
comment_tags = db.session.query(
|
||||||
|
Hashtag.name.label('name')
|
||||||
|
).join(
|
||||||
|
Comment,
|
||||||
|
db.and_(Hashtag.type == 1, Hashtag.target_id == Comment.id)
|
||||||
|
).join(
|
||||||
|
Submission,
|
||||||
|
Comment.submission_id == Submission.id
|
||||||
|
).filter(
|
||||||
|
Submission.status == 'Pass'
|
||||||
|
)
|
||||||
|
|
||||||
|
union_subq = submission_tags.union_all(comment_tags).subquery()
|
||||||
|
|
||||||
|
rows = db.session.query(
|
||||||
|
union_subq.c.name,
|
||||||
|
db.func.count(union_subq.c.name).label('count')
|
||||||
|
).group_by(
|
||||||
|
union_subq.c.name
|
||||||
|
).order_by(
|
||||||
|
db.func.count(union_subq.c.name).desc(),
|
||||||
|
union_subq.c.name.asc()
|
||||||
|
).limit(3).all()
|
||||||
|
|
||||||
|
data = [{"name": name, "count": int(count)} for name, count in rows]
|
||||||
|
return jsonify({"code": 1000, "data": {"list": data}})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/tag_suggest', methods=['GET'])
|
||||||
|
def tag_suggest():
|
||||||
|
try:
|
||||||
|
prefix = request.args.get("prefix", "")
|
||||||
|
if prefix is None:
|
||||||
|
prefix = ""
|
||||||
|
prefix = str(prefix).strip().lstrip('#')
|
||||||
|
if not prefix:
|
||||||
|
return jsonify({"code": 1000, "data": {"list": []}})
|
||||||
|
|
||||||
|
limit = request.args.get("limit", 5, type=int)
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 10:
|
||||||
|
limit = 10
|
||||||
|
|
||||||
rows = db.session.query(
|
rows = db.session.query(
|
||||||
Hashtag.name,
|
Hashtag.name,
|
||||||
db.func.count(Hashtag.name).label('count')
|
db.func.count(Hashtag.name).label('count')
|
||||||
).group_by(Hashtag.name).order_by(db.func.count(Hashtag.name).desc()).limit(3).all()
|
).filter(
|
||||||
|
Hashtag.name.like(f"{prefix}%")
|
||||||
|
).group_by(
|
||||||
|
Hashtag.name
|
||||||
|
).order_by(
|
||||||
|
db.func.count(Hashtag.name).desc(),
|
||||||
|
Hashtag.name.asc()
|
||||||
|
).limit(limit).all()
|
||||||
|
|
||||||
data = [{"name": name, "count": int(count)} for name, count in rows]
|
data = [name for name, _ in rows]
|
||||||
return jsonify({"code": 1000, "data": {"list": data}})
|
return jsonify({"code": 1000, "data": {"list": data}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 2003, "data": str(e)})
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
@@ -652,7 +881,133 @@ def return_418():
|
|||||||
abort(418)
|
abort(418)
|
||||||
|
|
||||||
# --- 用户的管理api端点 ---
|
# --- 用户的管理api端点 ---
|
||||||
# TODO: 用户管理端点
|
@app.route('/api/my/pics_pages', methods=['GET'])
|
||||||
|
def my_pics_pages():
|
||||||
|
try:
|
||||||
|
identity = get_identity_from_args(request.args)
|
||||||
|
ok, identity, code, msg = require_identity(identity)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": code, "data": msg})
|
||||||
|
|
||||||
|
per_page = request.args.get("num_per_pages", 5, type=int)
|
||||||
|
if per_page < 1:
|
||||||
|
per_page = 5
|
||||||
|
if per_page > 20:
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
total = ImgFile.query.filter_by(identity_token=identity).count()
|
||||||
|
total_pages = (total + per_page - 1) // per_page if total > 0 else 0
|
||||||
|
return jsonify({"code": 1000, "data": {"total_pages": total_pages}})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/my/all_pics', methods=['GET'])
|
||||||
|
def my_all_pics():
|
||||||
|
try:
|
||||||
|
identity = get_identity_from_args(request.args)
|
||||||
|
ok, identity, code, msg = require_identity(identity)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": code, "data": msg})
|
||||||
|
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
per_page = request.args.get("num_per_page", 5, type=int)
|
||||||
|
if per_page < 1:
|
||||||
|
per_page = 5
|
||||||
|
if per_page > 20:
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
pagination = ImgFile.query.filter_by(identity_token=identity)\
|
||||||
|
.order_by(ImgFile.created_at.desc(), ImgFile.id.desc())\
|
||||||
|
.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"name": i.name or "",
|
||||||
|
"path": f"/api/files/{i.path}",
|
||||||
|
"created_at": i.created_at.isoformat() if i.created_at else None
|
||||||
|
} for i in pagination.items]
|
||||||
|
|
||||||
|
return jsonify({"code": 1000, "data": {"list": data}})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/my/modify_pic', methods=['POST'])
|
||||||
|
def my_modify_pic():
|
||||||
|
try:
|
||||||
|
identity = get_identity_from_args(request.form)
|
||||||
|
ok, identity, code, msg = require_identity(identity)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": code, "data": msg})
|
||||||
|
|
||||||
|
path = normalize_img_path(request.form.get("path"))
|
||||||
|
if not path:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
record = ImgFile.query.filter_by(path=path, identity_token=identity).first()
|
||||||
|
if not record:
|
||||||
|
return jsonify({"code": 2002, "data": "数据不存在"})
|
||||||
|
|
||||||
|
ok, err_code, err_msg, _ = validate_upload_file(file)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": err_code, "data": err_msg})
|
||||||
|
|
||||||
|
filepath = os.path.join(IMG_DIR, path)
|
||||||
|
file.save(filepath)
|
||||||
|
return jsonify({"code": 1000, "data": ""})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/my/change_pic_name', methods=['POST'])
|
||||||
|
def my_change_pic_name():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
identity = get_identity_from_args(data)
|
||||||
|
ok, identity, code, msg = require_identity(identity)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": code, "data": msg})
|
||||||
|
path = normalize_img_path(data.get("path") if data else None)
|
||||||
|
name = data.get("name") if data else None
|
||||||
|
if not path:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
|
||||||
|
record = ImgFile.query.filter_by(path=path, identity_token=identity).first()
|
||||||
|
if not record:
|
||||||
|
return jsonify({"code": 2002, "data": "数据不存在"})
|
||||||
|
|
||||||
|
record.name = name
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"code": 1000, "data": ""})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
|
@app.route('/api/my/del_pic', methods=['POST'])
|
||||||
|
def my_del_pic():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
identity = get_identity_from_args(data)
|
||||||
|
ok, identity, code, msg = require_identity(identity)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"code": code, "data": msg})
|
||||||
|
path = normalize_img_path(data.get("path") if data else None)
|
||||||
|
if not path:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
|
||||||
|
record = ImgFile.query.filter_by(path=path, identity_token=identity).first()
|
||||||
|
if not record:
|
||||||
|
return jsonify({"code": 2002, "data": "数据不存在"})
|
||||||
|
|
||||||
|
filepath = os.path.join(IMG_DIR, path)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
db.session.delete(record)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"code": 1000, "data": ""})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 2003, "data": str(e)})
|
||||||
|
|
||||||
# --- 管理员api端点 ---
|
# --- 管理员api端点 ---
|
||||||
# TODO: 添加管理员端点
|
# TODO: 添加管理员端点
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
Flask>=3.0.3
|
Flask>=3.0.3
|
||||||
Flask-CORS>=4.0.1
|
Flask-CORS>=4.0.1
|
||||||
Flask-SQLAlchemy>=3.1.1
|
Flask-SQLAlchemy>=3.1.1
|
||||||
|
filetype>=1.2.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|||||||
@@ -28,18 +28,18 @@
|
|||||||
|
|
||||||
| Code | 含义 |
|
| Code | 含义 |
|
||||||
| ---- | ---------------------------------------------------- |
|
| ---- | ---------------------------------------------------- |
|
||||||
| 1000 | 正常。适用于大多数成功的GET请求的返回。 |
|
| 1000 | 正常。适用于大多数成功的请求的返回。 |
|
||||||
| 1001 | 正常。适用于大多数成功的POST请求的返回。 |
|
|
||||||
| 1002 | 正常。提交内容需要等待审核。 |
|
| 1002 | 正常。提交内容需要等待审核。 |
|
||||||
| 2000 | 失败。请求方式错误,例如缺少指定参数。 |
|
| 2000 | 失败。请求格式错误,例如缺少指定参数。 |
|
||||||
| 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 |
|
| 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 |
|
||||||
| 2002 | 失败。数据不存在。 |
|
| 2002 | 失败。数据不存在。 |
|
||||||
| 2003 | 失败。服务器内部错误。 |
|
| 2003 | 失败。服务器内部错误。表示未预期的服务端异常,不应频繁出现。 |
|
||||||
| 2004 | 失败。试图使用不存在的Identity。 |
|
| 2004 | 失败。试图使用不存在的Identity。 |
|
||||||
| 2005 | 失败。提交内容包含违禁词。 |
|
| 2005 | 失败。提交内容包含违禁词。 |
|
||||||
| 2006 | 失败。上传的图片超出限制大小。 |
|
| 2006 | 失败。上传的图片超出限制大小。 |
|
||||||
| 2007 | 失败。上传的文件类型不支持。 |
|
| 2007 | 失败。上传的图片类型不支持。 |
|
||||||
| 404 | api端点不存在。 |
|
| 2008 | 失败。上传的图片损坏。 |
|
||||||
|
| 2009 | 失败。端点需要提供Identity才能操作。 |
|
||||||
| | |
|
| | |
|
||||||
| | |
|
| | |
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import CreatePost from './components/CreatePost';
|
|||||||
import PostCard from './components/PostCard';
|
import PostCard from './components/PostCard';
|
||||||
import ImageViewer from './components/ImageViewer';
|
import ImageViewer from './components/ImageViewer';
|
||||||
import Panel from './components/Panel';
|
import Panel from './components/Panel';
|
||||||
|
import TagPosts from './components/TagPosts';
|
||||||
import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api';
|
import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api';
|
||||||
import { useLayout } from './context/LayoutContext';
|
import { useLayout } from './context/LayoutContext';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -119,6 +120,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> =
|
|||||||
content={article.content}
|
content={article.content}
|
||||||
upvotes={article.upvotes}
|
upvotes={article.upvotes}
|
||||||
downvotes={article.downvotes}
|
downvotes={article.downvotes}
|
||||||
|
commentCount={article.comment_count ?? 0}
|
||||||
time={article.time}
|
time={article.time}
|
||||||
modified={article.modified}
|
modified={article.modified}
|
||||||
onPreviewImage={onPreviewImage}
|
onPreviewImage={onPreviewImage}
|
||||||
@@ -133,6 +135,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> =
|
|||||||
content={article.content}
|
content={article.content}
|
||||||
upvotes={article.upvotes}
|
upvotes={article.upvotes}
|
||||||
downvotes={article.downvotes}
|
downvotes={article.downvotes}
|
||||||
|
commentCount={article.comment_count ?? 0}
|
||||||
time={article.time}
|
time={article.time}
|
||||||
modified={article.modified}
|
modified={article.modified}
|
||||||
onPreviewImage={onPreviewImage}
|
onPreviewImage={onPreviewImage}
|
||||||
@@ -226,6 +229,7 @@ function App() {
|
|||||||
return () => window.removeEventListener('identity_invalid', handler as EventListener);
|
return () => window.removeEventListener('identity_invalid', handler as EventListener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -313,6 +317,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Home onPreviewImage={openImageViewer} />} />
|
<Route index element={<Home onPreviewImage={openImageViewer} />} />
|
||||||
<Route path="create" element={<CreatePost />} />
|
<Route path="create" element={<CreatePost />} />
|
||||||
|
<Route path="tag" element={<TagPosts onPreviewImage={openImageViewer} />} />
|
||||||
<Route path="panel" element={<Panel />} />
|
<Route path="panel" element={<Panel />} />
|
||||||
<Route path="about" element={<About />} />
|
<Route path="about" element={<About />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
217
front/src/api.ts
217
front/src/api.ts
@@ -21,12 +21,12 @@ export const getSettings = async (): Promise<SiteSettings> => {
|
|||||||
let fileFormats: string[] | undefined;
|
let fileFormats: string[] | undefined;
|
||||||
if (data.file_formats) {
|
if (data.file_formats) {
|
||||||
if (Array.isArray(data.file_formats)) {
|
if (Array.isArray(data.file_formats)) {
|
||||||
fileFormats = data.file_formats.map((x: any) => String(x).trim().replace(/^\./, '').toLowerCase()).filter((x: string) => x);
|
fileFormats = data.file_formats.map((x: any) => String(x).trim().toLowerCase()).filter((x: string) => x);
|
||||||
} else if (typeof data.file_formats === 'string') {
|
} else if (typeof data.file_formats === 'string') {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data.file_formats);
|
const parsed = JSON.parse(data.file_formats);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
fileFormats = parsed.map((x: any) => String(x).trim().replace(/^\./, '').toLowerCase()).filter((x: string) => x);
|
fileFormats = parsed.map((x: any) => String(x).trim().toLowerCase()).filter((x: string) => x);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse error
|
// ignore parse error
|
||||||
@@ -176,6 +176,14 @@ const notifyInvalidIdentity = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const require_identity_token = (): string => {
|
||||||
|
const token = localStorage.getItem('identity_token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
export const getHotTopics = async (): Promise<HotTopicItem[]> => {
|
export const getHotTopics = async (): Promise<HotTopicItem[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/hot_topics');
|
const response = await fetch('/api/hot_topics');
|
||||||
@@ -193,6 +201,185 @@ export const getHotTopics = async (): Promise<HotTopicItem[]> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTagSuggest = async (prefix: string, limit: number = 5): Promise<string[]> => {
|
||||||
|
const cleaned = prefix.trim().replace(/^#/, '');
|
||||||
|
if (!cleaned) return [];
|
||||||
|
const cacheKey = `tag_suggest_${cleaned}`;
|
||||||
|
const cached = sessionStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed as string[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore cache error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tag_suggest?prefix=${encodeURIComponent(cleaned)}&limit=${limit}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 1000 && json.data && Array.isArray(json.data.list)) {
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify(json.data.list));
|
||||||
|
return json.data.list as string[];
|
||||||
|
}
|
||||||
|
throw new Error('Invalid response code or missing data');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch tag suggest:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MyPicItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMyPicPages = async (numPerPage: number): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const identity = require_identity_token();
|
||||||
|
const response = await fetch(`/api/my/pics_pages?num_per_pages=${numPerPage}&identity_token=${encodeURIComponent(identity)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 2004) {
|
||||||
|
notifyInvalidIdentity();
|
||||||
|
throw new Error('Identity token invalid');
|
||||||
|
}
|
||||||
|
if (json.code === 2009) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
if (json.code === 1000 && json.data) {
|
||||||
|
return Number(json.data.total_pages) || 0;
|
||||||
|
}
|
||||||
|
throw new Error(json.data || 'Failed to fetch pages');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my pic pages:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyPics = async (page: number, numPerPage: number): Promise<MyPicItem[]> => {
|
||||||
|
try {
|
||||||
|
const identity = require_identity_token();
|
||||||
|
const response = await fetch(`/api/my/all_pics?page=${page}&num_per_page=${numPerPage}&identity_token=${encodeURIComponent(identity)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 2004) {
|
||||||
|
notifyInvalidIdentity();
|
||||||
|
throw new Error('Identity token invalid');
|
||||||
|
}
|
||||||
|
if (json.code === 2009) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
if (json.code === 1000 && json.data && Array.isArray(json.data.list)) {
|
||||||
|
return json.data.list as MyPicItem[];
|
||||||
|
}
|
||||||
|
throw new Error(json.data || 'Failed to fetch pics');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch my pics:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modifyMyPic = async (path: string, file: File): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const identity = require_identity_token();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('identity_token', identity);
|
||||||
|
formData.append('path', path);
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await fetch('/api/my/modify_pic', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 2004) {
|
||||||
|
notifyInvalidIdentity();
|
||||||
|
throw new Error('Identity token invalid');
|
||||||
|
}
|
||||||
|
if (json.code === 2009) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
if (json.code !== 1000) {
|
||||||
|
throw new Error(json.data || 'Modify failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to modify pic:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeMyPicName = async (path: string, name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const identity = require_identity_token();
|
||||||
|
const response = await fetch('/api/my/change_pic_name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ identity_token: identity, path, name }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 2004) {
|
||||||
|
notifyInvalidIdentity();
|
||||||
|
throw new Error('Identity token invalid');
|
||||||
|
}
|
||||||
|
if (json.code === 2009) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
if (json.code !== 1000) {
|
||||||
|
throw new Error(json.data || 'Rename failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rename pic:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteMyPic = async (path: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const identity = require_identity_token();
|
||||||
|
const response = await fetch('/api/my/del_pic', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ identity_token: identity, path }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 2004) {
|
||||||
|
notifyInvalidIdentity();
|
||||||
|
throw new Error('Identity token invalid');
|
||||||
|
}
|
||||||
|
if (json.code === 2009) {
|
||||||
|
throw new Error('IDENTITY_REQUIRED');
|
||||||
|
}
|
||||||
|
if (json.code !== 1000) {
|
||||||
|
throw new Error(json.data || 'Delete failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete pic:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePostApiCode = (json: any) => {
|
const handlePostApiCode = (json: any) => {
|
||||||
if (json && json.code === 2004) {
|
if (json && json.code === 2004) {
|
||||||
notifyInvalidIdentity();
|
notifyInvalidIdentity();
|
||||||
@@ -284,6 +471,23 @@ export const fetchArticles = async (page: number, signal?: AbortSignal): Promise
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchArticlesByTag = async (tag: string, page: number, signal?: AbortSignal): Promise<Article[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/get_posts_by_tag?tag=${encodeURIComponent(tag)}&page=${page}`, { signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.code === 1000 && Array.isArray(json.data)) {
|
||||||
|
return json.data as Article[];
|
||||||
|
}
|
||||||
|
throw new Error('Invalid response code or missing data');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch articles by tag:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const voteArticle = async (id: number, type: 'up' | 'down'): Promise<void> => {
|
export const voteArticle = async (id: number, type: 'up' | 'down'): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/${type}`, {
|
const response = await fetch(`/api/${type}`, {
|
||||||
@@ -382,7 +586,7 @@ export const postComment = async (commentData: PostCommentRequest): Promise<Post
|
|||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
handlePostApiCode(json);
|
handlePostApiCode(json);
|
||||||
if (json.code === 1001 && json.data?.id !== undefined) {
|
if (json.code === 1000 && json.data?.id !== undefined) {
|
||||||
return { id: Number(json.data.id) };
|
return { id: Number(json.data.id) };
|
||||||
}
|
}
|
||||||
if (json.code === 2005) {
|
if (json.code === 2005) {
|
||||||
@@ -417,7 +621,7 @@ export const reportPost = async (reportData: { id: number; title: string; conten
|
|||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
handlePostApiCode(json);
|
handlePostApiCode(json);
|
||||||
if (json.code === 1001 && json.data?.id !== undefined) {
|
if (json.code === 1000 && json.data?.id !== undefined) {
|
||||||
return { id: Number(json.data.id) };
|
return { id: Number(json.data.id) };
|
||||||
}
|
}
|
||||||
throw new Error(json.data || 'Report failed');
|
throw new Error(json.data || 'Report failed');
|
||||||
@@ -444,12 +648,15 @@ export const uploadImage = async (file: File): Promise<string> => {
|
|||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
handlePostApiCode(json);
|
handlePostApiCode(json);
|
||||||
if (json.code === 1001 && typeof json.data === 'string') {
|
if (json.code === 1000 && typeof json.data === 'string') {
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
if (json.code === 2006) {
|
if (json.code === 2006) {
|
||||||
throw new Error('UPLOAD_TOO_LARGE');
|
throw new Error('UPLOAD_TOO_LARGE');
|
||||||
}
|
}
|
||||||
|
if (json.code === 2008) {
|
||||||
|
throw new Error('CORRUPTED_IMAGE');
|
||||||
|
}
|
||||||
if (json.code === 2007) {
|
if (json.code === 2007) {
|
||||||
throw new Error('UNSUPPORTED_FORMAT');
|
throw new Error('UNSUPPORTED_FORMAT');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Dismiss24Regular, ArrowReply24Regular, ArrowClockwise24Regular } from '
|
|||||||
import { getComments, postComment } from '../api';
|
import { getComments, postComment } from '../api';
|
||||||
import type { Comment as CommentType } from '../api';
|
import type { Comment as CommentType } from '../api';
|
||||||
import { useLayout } from '../context/LayoutContext';
|
import { useLayout } from '../context/LayoutContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
@@ -119,9 +120,10 @@ const remarkTagPlugin = () => {
|
|||||||
const parts = child.value.split(/(#\S+)/g);
|
const parts = child.value.split(/(#\S+)/g);
|
||||||
return parts.map((part: string) => {
|
return parts.map((part: string) => {
|
||||||
if (part.match(/^#\S+$/)) {
|
if (part.match(/^#\S+$/)) {
|
||||||
|
const clean = part.replace(/^#/, '');
|
||||||
return {
|
return {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
url: 'tag:' + part,
|
url: '/tag/#' + encodeURIComponent(clean),
|
||||||
children: [{ type: 'text', value: part }]
|
children: [{ type: 'text', value: part }]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -146,6 +148,7 @@ const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { toasterId, triggerStaticsRefresh } = useLayout();
|
const { toasterId, triggerStaticsRefresh } = useLayout();
|
||||||
const { dispatchToast } = useToastController(toasterId);
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
|
const navigate = useNavigate();
|
||||||
const [comments, setComments] = useState<CommentType[]>([]);
|
const [comments, setComments] = useState<CommentType[]>([]);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [nickname, setNickname] = useState('');
|
const [nickname, setNickname] = useState('');
|
||||||
@@ -155,6 +158,7 @@ const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComments([]);
|
setComments([]);
|
||||||
@@ -235,6 +239,9 @@ useEffect(() => {
|
|||||||
|
|
||||||
const handleReply = (comment: CommentType) => {
|
const handleReply = (comment: CommentType) => {
|
||||||
setReplyTo(comment);
|
setReplyTo(comment);
|
||||||
|
setTimeout(() => {
|
||||||
|
inputContainerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelReply = () => {
|
const cancelReply = () => {
|
||||||
@@ -254,10 +261,14 @@ useEffect(() => {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const sameDay = now.toDateString() === date.toDateString();
|
const sameDay = now.toDateString() === date.toDateString();
|
||||||
if (sameDay) return `${diffHours}小时前`;
|
if (sameDay) return `${diffHours}小时前`;
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
if (diffMonths >= 1 && diffMonths < 12) return `${diffMonths}个月前`;
|
const diffDays = Math.max(1, Math.floor((todayMidnight - dateMidnight) / 86400000));
|
||||||
if (diffDays >= 1 && diffMonths < 1) return `${diffDays}天前`;
|
if (diffDays < 30) return `${diffDays}天前`;
|
||||||
|
let diffMonths = (now.getFullYear() * 12 + now.getMonth()) - (date.getFullYear() * 12 + date.getMonth());
|
||||||
|
if (now.getDate() < date.getDate()) diffMonths -= 1;
|
||||||
|
if (diffMonths < 1) diffMonths = 1;
|
||||||
|
if (diffMonths < 12) return `${diffMonths}个月前`;
|
||||||
const yyyy = date.getFullYear();
|
const yyyy = date.getFullYear();
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const dd = String(date.getDate()).padStart(2, '0');
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
@@ -284,8 +295,17 @@ useEffect(() => {
|
|||||||
remarkPlugins={[remarkGfm, remarkTagPlugin]}
|
remarkPlugins={[remarkGfm, remarkTagPlugin]}
|
||||||
components={{
|
components={{
|
||||||
a: ({ node, ...props }) => {
|
a: ({ node, ...props }) => {
|
||||||
if (props.href && props.href.startsWith('tag:')) {
|
const href = props.href;
|
||||||
return <span className={styles.tag}>{props.children}</span>;
|
if (href && typeof href === 'string' && href.startsWith('/tag/#')) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(href);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <a {...props} />;
|
return <a {...props} />;
|
||||||
},
|
},
|
||||||
@@ -317,7 +337,7 @@ useEffect(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.inputContainer}>
|
<div className={styles.inputContainer} ref={inputContainerRef}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.nicknameInput}
|
className={styles.nicknameInput}
|
||||||
placeholder="输入昵称"
|
placeholder="输入昵称"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Text,
|
Text,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
shorthands,
|
shorthands,
|
||||||
tokens,
|
tokens,
|
||||||
Input,
|
Input,
|
||||||
@@ -32,7 +33,8 @@ import {
|
|||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLayout } from '../context/LayoutContext';
|
import { useLayout } from '../context/LayoutContext';
|
||||||
import { saveDraft, getDraft, createPost, uploadImage } from '../api';
|
import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest, type MyPicItem } from '../api';
|
||||||
|
import WebDrive from './WebDrive';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -49,12 +51,32 @@ const useStyles = makeStyles({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
marginBottom: '20px',
|
marginBottom: '20px',
|
||||||
|
position: 'relative',
|
||||||
'& .w-md-editor': {
|
'& .w-md-editor': {
|
||||||
height: '100% !important',
|
height: '100% !important',
|
||||||
boxShadow: tokens.shadow16,
|
boxShadow: tokens.shadow16,
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
suggestBox: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: tokens.spacingHorizontalM,
|
||||||
|
bottom: tokens.spacingVerticalM,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
boxShadow: tokens.shadow16,
|
||||||
|
padding: tokens.spacingVerticalXS,
|
||||||
|
minWidth: '200px',
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
|
suggestItem: {
|
||||||
|
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
suggestItemActive: {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -86,6 +108,19 @@ const useStyles = makeStyles({
|
|||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
boxShadow: tokens.shadow16,
|
boxShadow: tokens.shadow16,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tagSuggestBox: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
bottom: '100%',
|
||||||
|
marginBottom: tokens.spacingVerticalXS,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
boxShadow: tokens.shadow16,
|
||||||
|
padding: tokens.spacingVerticalXS,
|
||||||
|
minWidth: '200px',
|
||||||
|
zIndex: 30,
|
||||||
},
|
},
|
||||||
tagButtonWrapper: {
|
tagButtonWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -93,6 +128,10 @@ const useStyles = makeStyles({
|
|||||||
imageActions: {
|
imageActions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: tokens.spacingHorizontalS,
|
gap: tokens.spacingHorizontalS,
|
||||||
|
},
|
||||||
|
webDriveSurface: {
|
||||||
|
width: '920px',
|
||||||
|
maxWidth: '95vw',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,17 +180,32 @@ const CreatePost: React.FC = () => {
|
|||||||
const [tagInputValue, setTagInputValue] = useState("");
|
const [tagInputValue, setTagInputValue] = useState("");
|
||||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
||||||
|
const [showWebDrive, setShowWebDrive] = useState(false);
|
||||||
const [imageUrl, setImageUrl] = useState("");
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggest, setShowSuggest] = useState(false);
|
||||||
|
const [activeSuggest, setActiveSuggest] = useState(0);
|
||||||
|
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
||||||
|
const [showTagSuggest, setShowTagSuggest] = useState(false);
|
||||||
|
const [activeTagSuggest, setActiveTagSuggest] = useState(0);
|
||||||
|
|
||||||
const tagInputRef = useRef<HTMLDivElement>(null);
|
const tagInputRef = useRef<HTMLDivElement>(null);
|
||||||
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
const tagButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const editorApiRef = useRef<any>(null);
|
const editorApiRef = useRef<any>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const valueRef = useRef<string>("");
|
const valueRef = useRef<string>("");
|
||||||
const autoSaveIntervalRef = useRef<number | null>(null);
|
const autoSaveIntervalRef = useRef<number | null>(null);
|
||||||
const lastInputAtRef = useRef<number | null>(null);
|
const lastInputAtRef = useRef<number | null>(null);
|
||||||
const activeElapsedRef = useRef<number>(0);
|
const activeElapsedRef = useRef<number>(0);
|
||||||
const hasStartedAutoSaveRef = useRef(false);
|
const hasStartedAutoSaveRef = useRef(false);
|
||||||
|
const suggestTimerRef = useRef<number | null>(null);
|
||||||
|
const suggestPrefixRef = useRef<string>("");
|
||||||
|
const suggestRangeRef = useRef<{ start: number; end: number } | null>(null);
|
||||||
|
const tagSuggestTimerRef = useRef<number | null>(null);
|
||||||
|
const tagSuggestPrefixRef = useRef<string>("");
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const isTagComposingRef = useRef(false);
|
||||||
|
|
||||||
const handlePostSubmit = async () => {
|
const handlePostSubmit = async () => {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
@@ -166,7 +220,7 @@ const CreatePost: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await createPost(value);
|
const response = await createPost(value);
|
||||||
if (response.code === 1001 || response.code === 1002) {
|
if (response.code === 1000 || response.code === 1002) {
|
||||||
// 清除草稿
|
// 清除草稿
|
||||||
saveDraft('');
|
saveDraft('');
|
||||||
setValue('');
|
setValue('');
|
||||||
@@ -218,6 +272,30 @@ const CreatePost: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertMarkdown = (markdown: string) => {
|
||||||
|
const api = editorApiRef.current;
|
||||||
|
if (api && typeof api.replaceSelection === 'function') {
|
||||||
|
api.replaceSelection(markdown);
|
||||||
|
} else {
|
||||||
|
setValue(prev => `${prev || ''}\n${markdown}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWebDriveName = (item: MyPicItem) => {
|
||||||
|
if (item.name) return item.name;
|
||||||
|
const path = item.path || '';
|
||||||
|
const ext = path.split('.').pop() || 'png';
|
||||||
|
return `pic.${ext}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebDriveSelect = (items: MyPicItem[]) => {
|
||||||
|
if (!items || items.length === 0) return;
|
||||||
|
const markdown = items
|
||||||
|
.map((item) => ``)
|
||||||
|
.join('\n');
|
||||||
|
insertMarkdown(markdown);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLocalUpload = () => {
|
const handleLocalUpload = () => {
|
||||||
setShowImageDialog(false);
|
setShowImageDialog(false);
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -244,8 +322,16 @@ const CreatePost: React.FC = () => {
|
|||||||
}
|
}
|
||||||
const formats = settings?.fileFormats;
|
const formats = settings?.fileFormats;
|
||||||
if (formats && formats.length > 0) {
|
if (formats && formats.length > 0) {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
const mime = (file.type || '').toLowerCase();
|
||||||
if (!ext || !formats.includes(ext)) {
|
const allowed = !!mime && formats.some((rule) => {
|
||||||
|
const normalized = String(rule || '').toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized === 'image/*') {
|
||||||
|
return mime.startsWith('image/');
|
||||||
|
}
|
||||||
|
return normalized === mime;
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
dispatchToast(
|
dispatchToast(
|
||||||
<Toast>
|
<Toast>
|
||||||
<ToastTitle>上传的文件类型不支持</ToastTitle>
|
<ToastTitle>上传的文件类型不支持</ToastTitle>
|
||||||
@@ -270,6 +356,13 @@ const CreatePost: React.FC = () => {
|
|||||||
</Toast>,
|
</Toast>,
|
||||||
{ intent: 'error' }
|
{ intent: 'error' }
|
||||||
);
|
);
|
||||||
|
} else if (msg.includes('CORRUPTED_IMAGE')) {
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>上传的文件损坏</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'error' }
|
||||||
|
);
|
||||||
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
|
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
|
||||||
dispatchToast(
|
dispatchToast(
|
||||||
<Toast>
|
<Toast>
|
||||||
@@ -390,6 +483,154 @@ const CreatePost: React.FC = () => {
|
|||||||
setShowTagInput(false);
|
setShowTagInput(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchTagSuggest = (prefix: string) => {
|
||||||
|
if (tagSuggestTimerRef.current) {
|
||||||
|
window.clearTimeout(tagSuggestTimerRef.current);
|
||||||
|
}
|
||||||
|
tagSuggestTimerRef.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const list = await getTagSuggest(prefix, 5);
|
||||||
|
setTagSuggestions(list);
|
||||||
|
setActiveTagSuggest(0);
|
||||||
|
setShowTagSuggest(list.length > 0);
|
||||||
|
} catch {
|
||||||
|
setTagSuggestions([]);
|
||||||
|
setShowTagSuggest(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTagSuggest = (tag: string) => {
|
||||||
|
const parts = tagInputValue.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
setTagInputValue(`#${tag}`);
|
||||||
|
} else {
|
||||||
|
parts[parts.length - 1] = `#${tag}`;
|
||||||
|
setTagInputValue(parts.join(' '));
|
||||||
|
}
|
||||||
|
setShowTagSuggest(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagInputChange = (_: unknown, data: { value: string }) => {
|
||||||
|
const next = data.value;
|
||||||
|
setTagInputValue(next);
|
||||||
|
const parts = next.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const last = parts[parts.length - 1] || '';
|
||||||
|
const prefix = last.replace(/^#/, '');
|
||||||
|
if (prefix.length < 2) {
|
||||||
|
setShowTagSuggest(false);
|
||||||
|
setTagSuggestions([]);
|
||||||
|
tagSuggestPrefixRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prefix) {
|
||||||
|
setShowTagSuggest(false);
|
||||||
|
setTagSuggestions([]);
|
||||||
|
tagSuggestPrefixRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tagSuggestPrefixRef.current !== prefix) {
|
||||||
|
tagSuggestPrefixRef.current = prefix;
|
||||||
|
fetchTagSuggest(prefix);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPrefixAtCursor = (text: string, cursor: number) => {
|
||||||
|
if (cursor <= 0) return null;
|
||||||
|
let i = cursor - 1;
|
||||||
|
while (i >= 0 && !/\s/.test(text[i])) {
|
||||||
|
if (text[i] === '#') break;
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
if (i < 0 || text[i] !== '#') return null;
|
||||||
|
if (i > 0 && !/\s/.test(text[i - 1])) return null;
|
||||||
|
const prefix = text.slice(i + 1, cursor);
|
||||||
|
if (!prefix || /\s/.test(prefix)) return null;
|
||||||
|
return { prefix, start: i, end: cursor };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSuggest = (prefix: string) => {
|
||||||
|
if (suggestTimerRef.current) {
|
||||||
|
window.clearTimeout(suggestTimerRef.current);
|
||||||
|
}
|
||||||
|
suggestTimerRef.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const list = await getTagSuggest(prefix, 5);
|
||||||
|
setSuggestions(list);
|
||||||
|
setActiveSuggest(0);
|
||||||
|
setShowSuggest(list.length > 0);
|
||||||
|
} catch {
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggest(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestApply = (tag: string) => {
|
||||||
|
const range = suggestRangeRef.current;
|
||||||
|
if (!range) return;
|
||||||
|
const insert = `#${tag} `;
|
||||||
|
const before = valueRef.current.slice(0, range.start);
|
||||||
|
const after = valueRef.current.slice(range.end);
|
||||||
|
const next = `${before}${insert}${after}`;
|
||||||
|
setValue(next);
|
||||||
|
setShowSuggest(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
const nextPos = range.start + insert.length;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.selectionStart = nextPos;
|
||||||
|
el.selectionEnd = nextPos;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const cursor = el.selectionStart ?? 0;
|
||||||
|
const text = el.value ?? '';
|
||||||
|
const info = extractPrefixAtCursor(text, cursor);
|
||||||
|
if (!info) {
|
||||||
|
setShowSuggest(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
suggestPrefixRef.current = '';
|
||||||
|
suggestRangeRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestRangeRef.current = { start: info.start, end: info.end };
|
||||||
|
if (info.prefix.length < 2) {
|
||||||
|
setShowSuggest(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
suggestPrefixRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestPrefixRef.current !== info.prefix) {
|
||||||
|
suggestPrefixRef.current = info.prefix;
|
||||||
|
fetchSuggest(info.prefix);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
if (!showSuggest || suggestions.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveSuggest(prev => (prev + 1) % suggestions.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveSuggest(prev => (prev - 1 + suggestions.length) % suggestions.length);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const tag = suggestions[activeSuggest];
|
||||||
|
if (tag) handleSuggestApply(tag);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowSuggest(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
valueRef.current = value ?? "";
|
valueRef.current = value ?? "";
|
||||||
@@ -444,6 +685,19 @@ const CreatePost: React.FC = () => {
|
|||||||
height="100%"
|
height="100%"
|
||||||
textareaProps={{
|
textareaProps={{
|
||||||
placeholder: "请在此输入投稿内容...",
|
placeholder: "请在此输入投稿内容...",
|
||||||
|
onKeyUp: handleEditorKeyUp,
|
||||||
|
onKeyDown: handleEditorKeyDown,
|
||||||
|
onCompositionStart: () => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
},
|
||||||
|
onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
// Trigger suggest after composition ends
|
||||||
|
handleEditorKeyUp(e as unknown as React.KeyboardEvent<HTMLTextAreaElement>);
|
||||||
|
},
|
||||||
|
ref: (el: HTMLTextAreaElement | null) => {
|
||||||
|
textareaRef.current = el;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
commands={commands}
|
commands={commands}
|
||||||
extraCommands={getExtraCommands()}
|
extraCommands={getExtraCommands()}
|
||||||
@@ -459,6 +713,25 @@ const CreatePost: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{showSuggest && suggestions.length > 0 && (
|
||||||
|
<div className={styles.suggestBox}>
|
||||||
|
{suggestions.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${item}-${idx}`}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.suggestItem,
|
||||||
|
idx === activeSuggest ? styles.suggestItemActive : undefined
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSuggestApply(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
@@ -475,13 +748,65 @@ const CreatePost: React.FC = () => {
|
|||||||
<div className={styles.tagInputContainer} ref={tagInputRef}>
|
<div className={styles.tagInputContainer} ref={tagInputRef}>
|
||||||
<Input
|
<Input
|
||||||
value={tagInputValue}
|
value={tagInputValue}
|
||||||
onChange={(_, data) => setTagInputValue(data.value)}
|
onChange={handleTagInputChange}
|
||||||
placeholder="输入tag..."
|
placeholder="输入tag..."
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleTagSubmit();
|
if (isTagComposingRef.current) return;
|
||||||
|
if (showTagSuggest && tagSuggestions.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTagSuggest(prev => (prev + 1) % tagSuggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTagSuggest(prev => (prev - 1 + tagSuggestions.length) % tagSuggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const tag = tagSuggestions[activeTagSuggest];
|
||||||
|
if (tag) applyTagSuggest(tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowTagSuggest(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleTagSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isTagComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={(e) => {
|
||||||
|
isTagComposingRef.current = false;
|
||||||
|
handleTagInputChange(e, { value: (e.target as HTMLInputElement).value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button appearance="primary" size="small" onClick={handleTagSubmit}>OK</Button>
|
<Button appearance="primary" size="small" onClick={handleTagSubmit}>OK</Button>
|
||||||
|
{showTagSuggest && tagSuggestions.length > 0 && (
|
||||||
|
<div className={styles.tagSuggestBox}>
|
||||||
|
{tagSuggestions.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${item}-${idx}`}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.suggestItem,
|
||||||
|
idx === activeTagSuggest ? styles.suggestItemActive : undefined
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
applyTagSuggest(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -524,7 +849,7 @@ const CreatePost: React.FC = () => {
|
|||||||
<DialogContent>请选择图片来源</DialogContent>
|
<DialogContent>请选择图片来源</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<div className={styles.imageActions}>
|
<div className={styles.imageActions}>
|
||||||
<Button appearance="secondary" onClick={() => {}}>网盘上传</Button>
|
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowWebDrive(true); }}>网盘上传</Button>
|
||||||
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowUrlDialog(true); }}>输入URL</Button>
|
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowUrlDialog(true); }}>输入URL</Button>
|
||||||
<Button appearance="primary" onClick={handleLocalUpload}>本地上传</Button>
|
<Button appearance="primary" onClick={handleLocalUpload}>本地上传</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,6 +858,24 @@ const CreatePost: React.FC = () => {
|
|||||||
</DialogSurface>
|
</DialogSurface>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showWebDrive} onOpenChange={(_, data) => setShowWebDrive(!!data.open)}>
|
||||||
|
<DialogSurface className={styles.webDriveSurface}>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>选择文件</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<WebDrive
|
||||||
|
mode={0}
|
||||||
|
onClose={() => setShowWebDrive(false)}
|
||||||
|
onSelect={(items) => {
|
||||||
|
handleWebDriveSelect(items);
|
||||||
|
setShowWebDrive(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={showUrlDialog} onOpenChange={(_, data) => setShowUrlDialog(!!data.open)}>
|
<Dialog open={showUrlDialog} onOpenChange={(_, data) => setShowUrlDialog(!!data.open)}>
|
||||||
<DialogSurface>
|
<DialogSurface>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
|
|||||||
const [filename, setFilename] = React.useState<string>('image');
|
const [filename, setFilename] = React.useState<string>('image');
|
||||||
const startRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
const startRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const offsetRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
const offsetRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
const imgRef = React.useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setScale(1);
|
setScale(1);
|
||||||
@@ -100,7 +101,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
|
|||||||
.catch(() => setFilename(fallback));
|
.catch(() => setFilename(fallback));
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
const handleWheel: React.WheelEventHandler<HTMLImageElement> = (e) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const next = clamp(scale + (e.deltaY < 0 ? 0.15 : -0.15), 0.5, 5);
|
const next = clamp(scale + (e.deltaY < 0 ? 0.15 : -0.15), 0.5, 5);
|
||||||
setScale(next);
|
setScale(next);
|
||||||
@@ -129,6 +130,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
|
|||||||
setOffset({ x: 0, y: 0 });
|
setOffset({ x: 0, y: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = imgRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', handleWheel as EventListener);
|
||||||
|
};
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(src, { mode: 'cors' });
|
const res = await fetch(src, { mode: 'cors' });
|
||||||
@@ -161,10 +171,10 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, alt, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.viewerContainer} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.viewerContainer} onClick={(e) => e.stopPropagation()}>
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt || 'image'}
|
alt={alt || 'image'}
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
onWheel={handleWheel}
|
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUpOrLeave}
|
onMouseUp={handleMouseUpOrLeave}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { makeStyles, tokens, Tab, TabList, Text } from '@fluentui/react-components';
|
import { makeStyles, tokens, Tab, TabList, Text } from '@fluentui/react-components';
|
||||||
|
import WebDrive from './WebDrive';
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -32,6 +33,18 @@ const Panel: React.FC = () => {
|
|||||||
settings: '设置',
|
settings: '设置',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (tab === 'drive') {
|
||||||
|
return <WebDrive mode={1} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text weight="semibold">{titleMap[tab]}</Text>
|
||||||
|
<Text className={styles.placeholder}>这里是{titleMap[tab]}的占位内容</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<TabList
|
<TabList
|
||||||
@@ -45,10 +58,7 @@ const Panel: React.FC = () => {
|
|||||||
<Tab value="settings">设置</Tab>
|
<Tab value="settings">设置</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>{renderContent()}</div>
|
||||||
<Text weight="semibold">{titleMap[tab]}</Text>
|
|
||||||
<Text className={styles.placeholder}>这里是 {titleMap[tab]} 的占位内容</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
Button,
|
Button,
|
||||||
tokens,
|
tokens,
|
||||||
|
mergeClasses,
|
||||||
useToastController,
|
useToastController,
|
||||||
Toast,
|
Toast,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Warning24Regular,
|
Warning24Regular,
|
||||||
} from '@fluentui/react-icons';
|
} from '@fluentui/react-icons';
|
||||||
import { useLayout } from '../context/LayoutContext';
|
import { useLayout } from '../context/LayoutContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import CommentSection from './CommentSection';
|
import CommentSection from './CommentSection';
|
||||||
import ReportPost from './ReportPost';
|
import ReportPost from './ReportPost';
|
||||||
|
|
||||||
@@ -35,9 +37,10 @@ const remarkTagPlugin = () => {
|
|||||||
const parts = child.value.split(/(#\S+)/g);
|
const parts = child.value.split(/(#\S+)/g);
|
||||||
return parts.map((part: string) => {
|
return parts.map((part: string) => {
|
||||||
if (part.match(/^#\S+$/)) {
|
if (part.match(/^#\S+$/)) {
|
||||||
|
const clean = part.replace(/^#/, '');
|
||||||
return {
|
return {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
url: 'tag:' + part,
|
url: '/tag/#' + encodeURIComponent(clean),
|
||||||
children: [{ type: 'text', value: part }]
|
children: [{ type: 'text', value: part }]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -206,6 +209,7 @@ interface PostCardProps {
|
|||||||
content: string;
|
content: string;
|
||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
|
commentCount?: number;
|
||||||
time?: string;
|
time?: string;
|
||||||
modified?: number;
|
modified?: number;
|
||||||
onPreviewImage?: (src: string, alt?: string) => void;
|
onPreviewImage?: (src: string, alt?: string) => void;
|
||||||
@@ -216,6 +220,7 @@ const PostCard = ({
|
|||||||
content,
|
content,
|
||||||
upvotes,
|
upvotes,
|
||||||
downvotes,
|
downvotes,
|
||||||
|
commentCount = 0,
|
||||||
time,
|
time,
|
||||||
modified,
|
modified,
|
||||||
onPreviewImage,
|
onPreviewImage,
|
||||||
@@ -223,6 +228,7 @@ const PostCard = ({
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { toasterId } = useLayout();
|
const { toasterId } = useLayout();
|
||||||
const { dispatchToast } = useToastController(toasterId);
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
|
const navigate = useNavigate();
|
||||||
const [votes, setVotes] = React.useState({ upvotes, downvotes });
|
const [votes, setVotes] = React.useState({ upvotes, downvotes });
|
||||||
const [hasVoted, setHasVoted] = React.useState(false);
|
const [hasVoted, setHasVoted] = React.useState(false);
|
||||||
const [voteChoice, setVoteChoice] = React.useState<'up' | 'down' | null>(null);
|
const [voteChoice, setVoteChoice] = React.useState<'up' | 'down' | null>(null);
|
||||||
@@ -246,10 +252,14 @@ const PostCard = ({
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const sameDay = now.toDateString() === date.toDateString();
|
const sameDay = now.toDateString() === date.toDateString();
|
||||||
if (sameDay) return `${diffHours}小时前`;
|
if (sameDay) return `${diffHours}小时前`;
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
if (diffMonths >= 1 && diffMonths < 12) return `${diffMonths}个月前`;
|
const diffDays = Math.max(1, Math.floor((todayMidnight - dateMidnight) / 86400000));
|
||||||
if (diffDays >= 1 && diffMonths < 1) return `${diffDays}天前`;
|
if (diffDays < 30) return `${diffDays}天前`;
|
||||||
|
let diffMonths = (now.getFullYear() * 12 + now.getMonth()) - (date.getFullYear() * 12 + date.getMonth());
|
||||||
|
if (now.getDate() < date.getDate()) diffMonths -= 1;
|
||||||
|
if (diffMonths < 1) diffMonths = 1;
|
||||||
|
if (diffMonths < 12) return `${diffMonths}个月前`;
|
||||||
const yyyy = date.getFullYear();
|
const yyyy = date.getFullYear();
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const dd = String(date.getDate()).padStart(2, '0');
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
@@ -267,8 +277,17 @@ const PostCard = ({
|
|||||||
remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]}
|
remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]}
|
||||||
components={{
|
components={{
|
||||||
a: ({ node, ...props }) => {
|
a: ({ node, ...props }) => {
|
||||||
if (props.href && props.href.startsWith('tag:')) {
|
const href = props.href;
|
||||||
return <span style={{ color: tokens.colorBrandForeground1 }}>{props.children}</span>;
|
if (href && typeof href === 'string' && href.startsWith('/tag/#')) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(href);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <a {...props} />;
|
return <a {...props} />;
|
||||||
},
|
},
|
||||||
@@ -291,7 +310,10 @@ const PostCard = ({
|
|||||||
<Button
|
<Button
|
||||||
icon={<ArrowUp24Regular primaryFill={voteChoice === 'up' ? tokens.colorBrandForegroundLink : undefined} />}
|
icon={<ArrowUp24Regular primaryFill={voteChoice === 'up' ? tokens.colorBrandForegroundLink : undefined} />}
|
||||||
appearance="transparent"
|
appearance="transparent"
|
||||||
className={`${styles.actionButton} ${voteChoice === 'up' ? styles.actionButtonActive : ''}`}
|
className={mergeClasses(
|
||||||
|
styles.actionButton,
|
||||||
|
voteChoice === 'up' ? styles.actionButtonActive : undefined
|
||||||
|
)}
|
||||||
style={voteChoice === 'up' ? { color: tokens.colorBrandForegroundLink } : undefined}
|
style={voteChoice === 'up' ? { color: tokens.colorBrandForegroundLink } : undefined}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (hasVoted) {
|
if (hasVoted) {
|
||||||
@@ -324,7 +346,10 @@ const PostCard = ({
|
|||||||
<Button
|
<Button
|
||||||
icon={<ArrowDown24Regular primaryFill={voteChoice === 'down' ? tokens.colorBrandForegroundLink : undefined} />}
|
icon={<ArrowDown24Regular primaryFill={voteChoice === 'down' ? tokens.colorBrandForegroundLink : undefined} />}
|
||||||
appearance="transparent"
|
appearance="transparent"
|
||||||
className={`${styles.actionButton} ${voteChoice === 'down' ? styles.actionButtonActive : ''}`}
|
className={mergeClasses(
|
||||||
|
styles.actionButton,
|
||||||
|
voteChoice === 'down' ? styles.actionButtonActive : undefined
|
||||||
|
)}
|
||||||
style={voteChoice === 'down' ? { color: tokens.colorBrandForegroundLink } : undefined}
|
style={voteChoice === 'down' ? { color: tokens.colorBrandForegroundLink } : undefined}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (hasVoted) {
|
if (hasVoted) {
|
||||||
@@ -359,7 +384,9 @@ const PostCard = ({
|
|||||||
appearance="transparent"
|
appearance="transparent"
|
||||||
className={styles.actionButton}
|
className={styles.actionButton}
|
||||||
onClick={() => setShowComments(!showComments)}
|
onClick={() => setShowComments(!showComments)}
|
||||||
/>
|
>
|
||||||
|
{commentCount}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<Warning24Regular />}
|
icon={<Warning24Regular />}
|
||||||
appearance="transparent"
|
appearance="transparent"
|
||||||
|
|||||||
232
front/src/components/TagPosts.tsx
Normal file
232
front/src/components/TagPosts.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Toast, ToastTitle, makeStyles, tokens, useToastController } from '@fluentui/react-components';
|
||||||
|
import { ArrowLeft24Regular } from '@fluentui/react-icons';
|
||||||
|
import { fetchArticlesByTag, type Article } from '../api';
|
||||||
|
import { useLayout } from '../context/LayoutContext';
|
||||||
|
import PostCard from './PostCard';
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
width: '100%',
|
||||||
|
padding: tokens.spacingVerticalM,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TagPosts: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { refreshTrigger, toasterId } = useLayout();
|
||||||
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
|
|
||||||
|
const [tag, setTag] = useState('');
|
||||||
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [tagRefreshTick, setTagRefreshTick] = useState(0);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const observer = useRef<IntersectionObserver | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastRefreshAtRef = useRef<number>(0);
|
||||||
|
const REFRESH_COOLDOWN_MS = 5000;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const raw = location.hash ? location.hash.slice(1) : '';
|
||||||
|
let decoded = raw;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
decoded = raw;
|
||||||
|
}
|
||||||
|
decoded = decoded.replace(/^#/, '').trim();
|
||||||
|
setTag(decoded);
|
||||||
|
}, [location.pathname, location.hash]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tag) return;
|
||||||
|
const key = `tag_scroll_${tag}`;
|
||||||
|
const saved = sessionStorage.getItem(key);
|
||||||
|
if (saved && containerRef.current) {
|
||||||
|
const value = Number(saved);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
containerRef.current.scrollTop = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tag]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tag) return;
|
||||||
|
const key = `tag_scroll_${tag}`;
|
||||||
|
return () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
sessionStorage.setItem(key, String(containerRef.current.scrollTop));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [tag]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setArticles([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
setTagRefreshTick(t => t + 1);
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [tag]);
|
||||||
|
|
||||||
|
const lastArticleRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
if (loading) return;
|
||||||
|
if (observer.current) observer.current.disconnect();
|
||||||
|
observer.current = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting && hasMore) {
|
||||||
|
setPage(prevPage => prevPage + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (node) observer.current.observe(node);
|
||||||
|
}, [loading, hasMore]);
|
||||||
|
|
||||||
|
const doRefresh = useCallback(() => {
|
||||||
|
if (refreshing || loading) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRefreshAtRef.current < REFRESH_COOLDOWN_MS) return;
|
||||||
|
lastRefreshAtRef.current = now;
|
||||||
|
setRefreshing(true);
|
||||||
|
setArticles([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setPage(1);
|
||||||
|
setTagRefreshTick(t => t + 1);
|
||||||
|
if (containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
|
}, [refreshing, loading]);
|
||||||
|
|
||||||
|
const onWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
|
||||||
|
if (atTop && e.deltaY < 0) {
|
||||||
|
doRefresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
doRefresh();
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, doRefresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
if (!tag) {
|
||||||
|
setHasMore(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasMore) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const newArticles = await fetchArticlesByTag(tag, page, signal);
|
||||||
|
if (newArticles.length === 0) {
|
||||||
|
setHasMore(false);
|
||||||
|
} else {
|
||||||
|
setArticles(prev => [...prev, ...newArticles]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
|
console.error('Failed to load tag articles:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
if (refreshing) {
|
||||||
|
setRefreshing(false);
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>刷新成功</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'success' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadArticles();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [tag, page, hasMore, tagRefreshTick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container} ref={containerRef} onWheel={onWheel}>
|
||||||
|
<div className={styles.topBar}>
|
||||||
|
<Button appearance="transparent" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
{tag && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: tokens.colorNeutralForeground2, marginLeft: tokens.spacingHorizontalS }}>|</span>
|
||||||
|
<span style={{ color: tokens.colorNeutralForeground2 }}>#{tag}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minHeight: '100%' }}>
|
||||||
|
{articles.map((article, index) => {
|
||||||
|
if (articles.length === index + 1 && hasMore) {
|
||||||
|
return (
|
||||||
|
<div ref={lastArticleRef} key={article.id}>
|
||||||
|
<PostCard
|
||||||
|
id={article.id}
|
||||||
|
content={article.content}
|
||||||
|
upvotes={article.upvotes}
|
||||||
|
downvotes={article.downvotes}
|
||||||
|
commentCount={article.comment_count ?? 0}
|
||||||
|
time={article.time}
|
||||||
|
modified={article.modified}
|
||||||
|
onPreviewImage={onPreviewImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PostCard
|
||||||
|
key={article.id}
|
||||||
|
id={article.id}
|
||||||
|
content={article.content}
|
||||||
|
upvotes={article.upvotes}
|
||||||
|
downvotes={article.downvotes}
|
||||||
|
commentCount={article.comment_count ?? 0}
|
||||||
|
time={article.time}
|
||||||
|
modified={article.modified}
|
||||||
|
onPreviewImage={onPreviewImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{loading && <div>加载中...</div>}
|
||||||
|
{!loading && !hasMore && (
|
||||||
|
<div style={{ width: '100%', display: 'flex', alignItems: 'center', margin: '16px 0' }}>
|
||||||
|
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
|
||||||
|
<div style={{ padding: '0 12px', color: tokens.colorNeutralForeground3, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
已经到底了喵~
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagPosts;
|
||||||
493
front/src/components/WebDrive.tsx
Normal file
493
front/src/components/WebDrive.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Spinner,
|
||||||
|
Dialog,
|
||||||
|
DialogSurface,
|
||||||
|
DialogBody,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
useToastController,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
} from '@fluentui/react-components';
|
||||||
|
import {
|
||||||
|
Grid24Regular,
|
||||||
|
List24Regular,
|
||||||
|
Edit24Regular,
|
||||||
|
Rename24Regular,
|
||||||
|
Delete24Regular,
|
||||||
|
Image24Regular,
|
||||||
|
ChevronLeft24Regular,
|
||||||
|
ChevronRight24Regular,
|
||||||
|
CloudArrowUp24Regular,
|
||||||
|
} from '@fluentui/react-icons';
|
||||||
|
import { getMyPicPages, getMyPics, type MyPicItem, modifyMyPic, changeMyPicName, deleteMyPic, uploadImage } from '../api';
|
||||||
|
import { useLayout } from '../context/LayoutContext';
|
||||||
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
topActions: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
},
|
||||||
|
viewToggle: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
},
|
||||||
|
gridCard: {
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
padding: tokens.spacingVerticalS,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
},
|
||||||
|
gridImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '160px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
},
|
||||||
|
previewable: {
|
||||||
|
cursor: 'zoom-in',
|
||||||
|
},
|
||||||
|
gridFooter: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
nameText: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
gridActions: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
outline: `2px solid ${tokens.colorBrandForeground1}`,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: tokens.spacingVerticalXS,
|
||||||
|
},
|
||||||
|
listRow: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '32px 1fr 180px 120px',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
padding: tokens.spacingVerticalXS,
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
},
|
||||||
|
listActions: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
},
|
||||||
|
pager: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
},
|
||||||
|
pagerButton: {
|
||||||
|
minWidth: 'auto',
|
||||||
|
},
|
||||||
|
pagerInputWrap: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
pagerInput: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: 0,
|
||||||
|
marginBottom: tokens.spacingVerticalXS,
|
||||||
|
padding: tokens.spacingVerticalXS,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
boxShadow: tokens.shadow16,
|
||||||
|
},
|
||||||
|
footerBar: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
selectBar: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WebDriveMode = 0 | 1;
|
||||||
|
|
||||||
|
interface WebDriveProps {
|
||||||
|
mode: WebDriveMode;
|
||||||
|
onClose?: () => void;
|
||||||
|
onSelect?: (items: MyPicItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNameFallback = (item: MyPicItem) => {
|
||||||
|
if (item.name) return item.name;
|
||||||
|
const path = item.path || '';
|
||||||
|
const ext = path.split('.').pop() || 'png';
|
||||||
|
return `pic.${ext}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebDrive: React.FC<WebDriveProps> = ({ mode, onClose, onSelect }) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const { toasterId } = useLayout();
|
||||||
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
|
const [view, setView] = React.useState<'grid' | 'list'>('grid');
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [totalPages, setTotalPages] = React.useState(0);
|
||||||
|
const [perPage, setPerPage] = React.useState(5);
|
||||||
|
const [items, setItems] = React.useState<MyPicItem[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||||
|
const [showJump, setShowJump] = React.useState(false);
|
||||||
|
const [jumpValue, setJumpValue] = React.useState('');
|
||||||
|
const [renameOpen, setRenameOpen] = React.useState(false);
|
||||||
|
const [renameValue, setRenameValue] = React.useState('');
|
||||||
|
const [activePath, setActivePath] = React.useState('');
|
||||||
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
|
const [preview, setPreview] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const uploadInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fetchData = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const pages = await getMyPicPages(perPage);
|
||||||
|
setTotalPages(pages);
|
||||||
|
const list = await getMyPics(page, perPage);
|
||||||
|
setItems(list);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = String(err?.message || '');
|
||||||
|
if (msg.includes('IDENTITY_REQUIRED')) {
|
||||||
|
dispatchToast(<Toast><ToastTitle>需要身份标识</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
} else {
|
||||||
|
dispatchToast(<Toast><ToastTitle>获取网盘失败</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, perPage, dispatchToast]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [perPage]);
|
||||||
|
|
||||||
|
const toggleSelect = (path: string) => {
|
||||||
|
setSelected(prev => ({ ...prev, [path]: !prev[path] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModify = (path: string) => {
|
||||||
|
setActivePath(path);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !activePath) return;
|
||||||
|
try {
|
||||||
|
await modifyMyPic(activePath, file);
|
||||||
|
dispatchToast(<Toast><ToastTitle>修改成功</ToastTitle></Toast>, { intent: 'success' });
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
dispatchToast(<Toast><ToastTitle>修改失败</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await uploadImage(file);
|
||||||
|
dispatchToast(<Toast><ToastTitle>上传成功</ToastTitle></Toast>, { intent: 'success' });
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = String(err?.message || '');
|
||||||
|
if (msg.includes('UPLOAD_TOO_LARGE')) {
|
||||||
|
dispatchToast(<Toast><ToastTitle>上传的图片超出限制大小</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
} else if (msg.includes('CORRUPTED_IMAGE')) {
|
||||||
|
dispatchToast(<Toast><ToastTitle>上传的文件损坏</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
|
||||||
|
dispatchToast(<Toast><ToastTitle>上传的文件类型不支持</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
} else {
|
||||||
|
dispatchToast(<Toast><ToastTitle>上传失败</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
try {
|
||||||
|
await changeMyPicName(activePath, renameValue);
|
||||||
|
dispatchToast(<Toast><ToastTitle>重命名成功</ToastTitle></Toast>, { intent: 'success' });
|
||||||
|
setRenameOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
dispatchToast(<Toast><ToastTitle>重命名失败</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteMyPic(activePath);
|
||||||
|
dispatchToast(<Toast><ToastTitle>删除成功</ToastTitle></Toast>, { intent: 'success' });
|
||||||
|
setDeleteOpen(false);
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
dispatchToast(<Toast><ToastTitle>删除失败</ToastTitle></Toast>, { intent: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = React.useMemo(() => {
|
||||||
|
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
return [1, 2, 'ellipsis', totalPages];
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const selectedCount = React.useMemo(
|
||||||
|
() => Object.values(selected).filter(Boolean).length,
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<input ref={uploadInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleUploadChange} />
|
||||||
|
<div className={styles.topBar}>
|
||||||
|
<div className={styles.viewToggle}>
|
||||||
|
<Button
|
||||||
|
appearance={view === 'grid' ? 'primary' : 'subtle'}
|
||||||
|
icon={<Grid24Regular />}
|
||||||
|
onClick={() => setView('grid')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
appearance={view === 'list' ? 'primary' : 'subtle'}
|
||||||
|
icon={<List24Regular />}
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.topActions}>
|
||||||
|
{mode === 1 && (
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<CloudArrowUp24Regular />}
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
|
title="上传图片"
|
||||||
|
aria-label="上传图片"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mode === 0 && (
|
||||||
|
<div className={styles.selectBar}>
|
||||||
|
<Button appearance="secondary" onClick={onClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const picked = items.filter(i => selected[i.path]);
|
||||||
|
onSelect?.(picked);
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner size="small" />
|
||||||
|
) : totalPages === 0 ? (
|
||||||
|
<Text>暂时没有文件哦~</Text>
|
||||||
|
) : view === 'grid' ? (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className={`${styles.gridCard} ${mode === 0 && selected[item.path] ? styles.selected : ''}`}
|
||||||
|
onClick={() => mode === 0 && toggleSelect(item.path)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={`${styles.gridImage} ${mode === 1 ? styles.previewable : ''}`}
|
||||||
|
src={item.path}
|
||||||
|
alt={item.name || 'pic'}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (mode !== 1) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreview({ open: true, src: item.path, alt: item.name || 'pic' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.gridFooter}>
|
||||||
|
<Text className={styles.nameText} title={formatNameFallback(item)}>{formatNameFallback(item)}</Text>
|
||||||
|
{mode === 1 && (
|
||||||
|
<div className={styles.gridActions}>
|
||||||
|
<Button appearance="subtle" icon={<Edit24Regular />} title="替换文件" aria-label="替换文件" onClick={(e) => { e.stopPropagation(); openModify(item.path); }} />
|
||||||
|
<Button appearance="subtle" icon={<Rename24Regular />} title="重命名" aria-label="重命名" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setRenameValue(item.name || ''); setRenameOpen(true); }} />
|
||||||
|
<Button appearance="subtle" icon={<Delete24Regular />} title="删除" aria-label="删除" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setDeleteOpen(true); }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className={`${styles.listRow} ${mode === 0 && selected[item.path] ? styles.selected : ''}`}
|
||||||
|
onClick={() => mode === 0 && toggleSelect(item.path)}
|
||||||
|
>
|
||||||
|
<Image24Regular />
|
||||||
|
<Text
|
||||||
|
className={`${styles.nameText} ${mode === 1 ? styles.previewable : ''}`}
|
||||||
|
title={formatNameFallback(item)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (mode !== 1) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreview({ open: true, src: item.path, alt: item.name || 'pic' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatNameFallback(item)}
|
||||||
|
</Text>
|
||||||
|
<Text>{item.created_at ? item.created_at.replace('T', ' ').slice(0, 19) : ''}</Text>
|
||||||
|
{mode === 1 ? (
|
||||||
|
<div className={styles.listActions}>
|
||||||
|
<Button appearance="subtle" icon={<Edit24Regular />} title="替换文件" aria-label="替换文件" onClick={(e) => { e.stopPropagation(); openModify(item.path); }} />
|
||||||
|
<Button appearance="subtle" icon={<Rename24Regular />} title="重命名" aria-label="重命名" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setRenameValue(item.name || ''); setRenameOpen(true); }} />
|
||||||
|
<Button appearance="subtle" icon={<Delete24Regular />} title="删除" aria-label="删除" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setDeleteOpen(true); }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.footerBar}>
|
||||||
|
<div className={styles.pager}>
|
||||||
|
<Button className={styles.pagerButton} appearance="subtle" icon={<ChevronLeft24Regular />} onClick={() => setPage(p => Math.max(1, p - 1))} />
|
||||||
|
{pages.map((p, idx) => {
|
||||||
|
if (p === 'ellipsis') {
|
||||||
|
return (
|
||||||
|
<div key={`ellipsis-${idx}`} className={styles.pagerInputWrap}>
|
||||||
|
<Button appearance="subtle" onClick={() => setShowJump(v => !v)}>...</Button>
|
||||||
|
{showJump && (
|
||||||
|
<div className={styles.pagerInput}>
|
||||||
|
<Input
|
||||||
|
value={jumpValue}
|
||||||
|
onChange={(_, d) => setJumpValue(d.value)}
|
||||||
|
placeholder="跳转页"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const n = Number(jumpValue);
|
||||||
|
if (!Number.isNaN(n) && n >= 1 && n <= totalPages) {
|
||||||
|
setPage(n);
|
||||||
|
}
|
||||||
|
setShowJump(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={`page-${p}`}
|
||||||
|
appearance={p === page ? 'primary' : 'subtle'}
|
||||||
|
onClick={() => setPage(p as number)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button className={styles.pagerButton} appearance="subtle" icon={<ChevronRight24Regular />} onClick={() => setPage(p => Math.min(totalPages || 1, p + 1))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select value={String(perPage)} onChange={(_, data) => setPerPage(Number(data.value))}>
|
||||||
|
<option value="5">5个/页</option>
|
||||||
|
<option value="10">10个/页</option>
|
||||||
|
<option value="20">20个/页</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={renameOpen} onOpenChange={(_, data) => setRenameOpen(!!data.open)}>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>重命名</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Input value={renameValue} onChange={(_, d) => setRenameValue(d.value)} placeholder="输入新名称" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button appearance="secondary" onClick={() => setRenameOpen(false)}>取消</Button>
|
||||||
|
<Button appearance="primary" onClick={handleRename}>确定</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={(_, data) => setDeleteOpen(!!data.open)}>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogContent>确定要删除这张图片吗?</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button appearance="secondary" onClick={() => setDeleteOpen(false)}>取消</Button>
|
||||||
|
<Button appearance="primary" onClick={handleDelete}>删除</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{preview.open && preview.src && (
|
||||||
|
<ImageViewer src={preview.src} alt={preview.alt} onClose={() => setPreview({ open: false })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebDrive;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// v1的时候就连统计信息也5秒获取一次,不妥,所以改成逻辑触发刷新
|
// v1的时候就连统计信息也5秒获取一次,不妥,所以改成逻辑触发刷新
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -99,6 +100,7 @@ const Widgets: React.FC = () => {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout();
|
const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout();
|
||||||
const { dispatchToast } = useToastController(toasterId);
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
|
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
|
||||||
const [statics, setStatics] = useState<StaticsData | null>(null);
|
const [statics, setStatics] = useState<StaticsData | null>(null);
|
||||||
const [topics, setTopics] = useState<HotTopicItem[]>([]);
|
const [topics, setTopics] = useState<HotTopicItem[]>([]);
|
||||||
@@ -238,7 +240,13 @@ const Widgets: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
topics.map((topic, index) => (
|
topics.map((topic, index) => (
|
||||||
<React.Fragment key={`${topic.name}-${index}`}>
|
<React.Fragment key={`${topic.name}-${index}`}>
|
||||||
<div className={styles.topicRow}>
|
<div
|
||||||
|
className={styles.topicRow}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/tag/#${encodeURIComponent(topic.name)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Text className={styles.topicName}>#{topic.name}</Text>
|
<Text className={styles.topicName}>#{topic.name}</Text>
|
||||||
<Text className={styles.topicCount}>{topic.count}个投稿</Text>
|
<Text className={styles.topicCount}>{topic.count}个投稿</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user