Compare commits

..

10 Commits

Author SHA1 Message Date
LeonspaceX
8457ad64e0 Add webdrive upload and preview tweaks 2026-02-01 18:53:11 +08:00
LeonspaceX
5c79a9aa00 Unify success codes and update upload deps 2026-02-01 13:24:10 +08:00
LeonspaceX
3e2cdf4c74 20260131 2026-01-31 21:25:37 +08:00
LeonspaceX
3660346797 Replace imghdr with header check 2026-01-31 17:31:53 +08:00
LeonspaceX
b788c04f1d Tighten upload validation and transactions 2026-01-31 17:28:05 +08:00
LeonspaceX
3a30271fe6 Add hashtag cascade relations 2026-01-31 17:11:08 +08:00
LeonspaceX
8e561a2eb7 Refactor backend helpers and fix UI warnings 2026-01-30 23:55:36 +08:00
LeonspaceX
86baccd2ee Add local TODO note 2026-01-30 22:55:48 +08:00
LeonspaceX
6cd3aed5c5 Add tag suggestions 2026-01-30 22:45:57 +08:00
LeonspaceX
8f6ae3fdfc Add tag posts view and comment counts 2026-01-30 21:59:27 +08:00
14 changed files with 1856 additions and 122 deletions

View File

@@ -0,0 +1,22 @@
用户面板设计:
投稿 评论 文件 设置Identity token 图片压缩等级 等等)
评论支持删除,投稿支持修改+删除
评论也支持投诉,投诉按钮放进"..."里
通知功能的实现通知图标点击出现前5条点击查看全部所有的出现分页机制不要忘记参考雨云
rate limit功能
rss功能
完成开发后记得Debug=False
深浅色模式切换逻辑有待完善没实现localstoarge储存状态
AI审核
后台权限分层,审核员/管理员
链接跳转新页面

View File

@@ -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: 添加管理员端点

View File

@@ -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

View File

@@ -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才能操作。 |
| | | | | |
| | | | | |
| | | | | |

View File

@@ -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>

View File

@@ -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');
} }

View File

@@ -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="输入昵称"

View File

@@ -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) => `![${getWebDriveName(item)}](${item.path})`)
.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>

View File

@@ -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}

View File

@@ -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>
); );
}; };

View File

@@ -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"

View 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;

View 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;

View File

@@ -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>