diff --git a/TODO(记得提交到github前删!!!).txt b/TODO(记得提交到github前删!!!).txt index 5c154ac..0be364c 100644 --- a/TODO(记得提交到github前删!!!).txt +++ b/TODO(记得提交到github前删!!!).txt @@ -12,3 +12,11 @@ rate limit功能 rss功能 完成开发后记得Debug=False + +深浅色模式切换逻辑有待完善,没实现localstoarge储存状态 + +AI审核 + +后台权限分层,审核员/管理员 + +链接跳转新页面 \ No newline at end of file diff --git a/back/main.py b/back/main.py index 8086f69..874757c 100644 --- a/back/main.py +++ b/back/main.py @@ -120,6 +120,7 @@ class ImgFile(db.Model): path = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=True) identity_token = db.Column(db.String(36), nullable=True) + created_at = db.Column(db.DateTime, default=now_time) class SiteNotice(db.Model): __tablename__ = 'site_notice' @@ -138,6 +139,15 @@ def init_db(): os.makedirs(IMG_DIR) with app.app_context(): 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: existing = SiteNotice.query.first() if not existing: @@ -231,6 +241,57 @@ def mime_allowed(mime, rules): 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端点 --- @app.route('/api/settings', methods=['GET']) def get_settings(): @@ -509,45 +570,22 @@ def upload_pic(): if 'file' not in request.files: return jsonify({"code": 2000, "data": "参数错误"}) file = request.files['file'] - if file.filename == '': - return jsonify({"code": 2000, "data": "参数错误"}) + ok, err_code, err_msg, kind = validate_upload_file(file) + if not ok: + return jsonify({"code": err_code, "data": err_msg}) - 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 jsonify({"code": 2006, "data": f"上传的图片超出{FILE_SIZE_LIMIT_MB}MB限制大小"}) - - ext = os.path.splitext(file.filename)[1].lstrip('.').lower() - 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 jsonify({"code": 2007, "data": "上传的文件类型不支持"}) - - try: - file.seek(0) - img = Image.open(file) - img.verify() - file.seek(0) - except (UnidentifiedImageError, OSError): - file.seek(0) - return jsonify({"code": 2008, "data": "上传的文件损坏"}) - except Exception: - file.seek(0) - return jsonify({"code": 2008, "data": "上传的文件损坏"}) - - if not ext and kind: + ext = None + if kind and kind.extension: ext = kind.extension + if not ext and file.filename: + ext = os.path.splitext(file.filename)[1].lstrip('.') or None filename = f"{uuid.uuid4().hex}.{ext}" if ext else uuid.uuid4().hex filepath = os.path.join(IMG_DIR, filename) file.save(filepath) identity_token = request.form.get('identity_token') 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() return jsonify({"code": 1000, "data": f"/api/files/{filename}"}) @@ -666,7 +704,7 @@ def get_posts_info(): "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.total, + "total_pages": pagination.pages, }) return jsonify({ @@ -730,7 +768,7 @@ def get_posts_by_tag(): "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.total, + "total_pages": pagination.pages, }) return jsonify({"code": 1000, "data": data}) @@ -843,7 +881,133 @@ def return_418(): abort(418) # --- 用户的管理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端点 --- # TODO: 添加管理员端点 diff --git a/code_meaning.md b/code_meaning.md index 2743b9e..4af5941 100644 --- a/code_meaning.md +++ b/code_meaning.md @@ -39,6 +39,7 @@ | 2006 | 失败。上传的图片超出限制大小。 | | 2007 | 失败。上传的图片类型不支持。 | | 2008 | 失败。上传的图片损坏。 | +| 2009 | 失败。端点需要提供Identity才能操作。 | | | | | | | | | | diff --git a/front/src/api.ts b/front/src/api.ts index 1b14314..07f426c 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -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 => { try { const response = await fetch('/api/hot_topics'); @@ -225,6 +233,153 @@ export const getTagSuggest = async (prefix: string, limit: number = 5): Promise< } }; +export interface MyPicItem { + name: string; + path: string; + created_at?: string | null; +} + +export const getMyPicPages = async (numPerPage: number): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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) => { if (json && json.code === 2004) { notifyInvalidIdentity(); diff --git a/front/src/components/CreatePost.tsx b/front/src/components/CreatePost.tsx index c1eb64d..025d5cd 100644 --- a/front/src/components/CreatePost.tsx +++ b/front/src/components/CreatePost.tsx @@ -33,7 +33,8 @@ import { } from '@fluentui/react-icons'; import { useNavigate } from 'react-router-dom'; import { useLayout } from '../context/LayoutContext'; -import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest } from '../api'; +import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest, type MyPicItem } from '../api'; +import WebDrive from './WebDrive'; const useStyles = makeStyles({ container: { @@ -127,6 +128,10 @@ const useStyles = makeStyles({ imageActions: { display: 'flex', gap: tokens.spacingHorizontalS, + }, + webDriveSurface: { + width: '920px', + maxWidth: '95vw', } }); @@ -175,6 +180,7 @@ const CreatePost: React.FC = () => { const [tagInputValue, setTagInputValue] = useState(""); const [showImageDialog, setShowImageDialog] = useState(false); const [showUrlDialog, setShowUrlDialog] = useState(false); + const [showWebDrive, setShowWebDrive] = useState(false); const [imageUrl, setImageUrl] = useState(""); const [suggestions, setSuggestions] = useState([]); const [showSuggest, setShowSuggest] = useState(false); @@ -266,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 = () => { setShowImageDialog(false); fileInputRef.current?.click(); @@ -293,7 +323,15 @@ const CreatePost: React.FC = () => { const formats = settings?.fileFormats; if (formats && formats.length > 0) { const mime = (file.type || '').toLowerCase(); - if (!mime || !formats.includes(mime)) { + 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( 上传的文件类型不支持 @@ -811,7 +849,7 @@ const CreatePost: React.FC = () => { 请选择图片来源
- +
@@ -820,6 +858,24 @@ const CreatePost: React.FC = () => { + setShowWebDrive(!!data.open)}> + + + 选择文件 + + setShowWebDrive(false)} + onSelect={(items) => { + handleWebDriveSelect(items); + setShowWebDrive(false); + }} + /> + + + + + setShowUrlDialog(!!data.open)}> diff --git a/front/src/components/Panel.tsx b/front/src/components/Panel.tsx index 63829e8..89272a5 100644 --- a/front/src/components/Panel.tsx +++ b/front/src/components/Panel.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { makeStyles, tokens, Tab, TabList, Text } from '@fluentui/react-components'; +import WebDrive from './WebDrive'; const useStyles = makeStyles({ container: { @@ -32,6 +33,18 @@ const Panel: React.FC = () => { settings: '设置', }; + const renderContent = () => { + if (tab === 'drive') { + return ; + } + return ( + <> + {titleMap[tab]} + 这里是{titleMap[tab]}的占位内容 + + ); + }; + return (
{ 设置 -
- {titleMap[tab]} - 这里是 {titleMap[tab]} 的占位内容 -
+
{renderContent()}
); }; -export default Panel; +export default Panel; \ No newline at end of file diff --git a/front/src/components/WebDrive.tsx b/front/src/components/WebDrive.tsx new file mode 100644 index 0000000..e95877d --- /dev/null +++ b/front/src/components/WebDrive.tsx @@ -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 = ({ 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([]); + const [loading, setLoading] = React.useState(false); + const [selected, setSelected] = React.useState>({}); + 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(null); + const uploadInputRef = React.useRef(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(需要身份标识, { intent: 'error' }); + } else { + dispatchToast(获取网盘失败, { 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 = async (e) => { + const file = e.target.files?.[0]; + if (!file || !activePath) return; + try { + await modifyMyPic(activePath, file); + dispatchToast(修改成功, { intent: 'success' }); + fetchData(); + } catch { + dispatchToast(修改失败, { intent: 'error' }); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleUploadChange: React.ChangeEventHandler = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + await uploadImage(file); + dispatchToast(上传成功, { intent: 'success' }); + fetchData(); + } catch (err: any) { + const msg = String(err?.message || ''); + if (msg.includes('UPLOAD_TOO_LARGE')) { + dispatchToast(上传的图片超出限制大小, { intent: 'error' }); + } else if (msg.includes('CORRUPTED_IMAGE')) { + dispatchToast(上传的文件损坏, { intent: 'error' }); + } else if (msg.includes('UNSUPPORTED_FORMAT')) { + dispatchToast(上传的文件类型不支持, { intent: 'error' }); + } else { + dispatchToast(上传失败, { intent: 'error' }); + } + } finally { + if (uploadInputRef.current) uploadInputRef.current.value = ''; + } + }; + + const handleRename = async () => { + try { + await changeMyPicName(activePath, renameValue); + dispatchToast(重命名成功, { intent: 'success' }); + setRenameOpen(false); + fetchData(); + } catch { + dispatchToast(重命名失败, { intent: 'error' }); + } + }; + + const handleDelete = async () => { + try { + await deleteMyPic(activePath); + dispatchToast(删除成功, { intent: 'success' }); + setDeleteOpen(false); + fetchData(); + } catch { + dispatchToast(删除失败, { 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 ( +
+ + +
+
+
+
+ {mode === 1 && ( +
+ {mode === 0 && ( +
+ + +
+ )} +
+ + {loading ? ( + + ) : totalPages === 0 ? ( + 暂时没有文件哦~ + ) : view === 'grid' ? ( +
+ {items.map((item) => ( +
mode === 0 && toggleSelect(item.path)} + > + {item.name { + if (mode !== 1) return; + e.stopPropagation(); + setPreview({ open: true, src: item.path, alt: item.name || 'pic' }); + }} + /> +
+ {formatNameFallback(item)} + {mode === 1 && ( +
+
+ )} +
+
+ ))} +
+ ) : ( +
+ {items.map((item) => ( +
mode === 0 && toggleSelect(item.path)} + > + + { + if (mode !== 1) return; + e.stopPropagation(); + setPreview({ open: true, src: item.path, alt: item.name || 'pic' }); + }} + > + {formatNameFallback(item)} + + {item.created_at ? item.created_at.replace('T', ' ').slice(0, 19) : ''} + {mode === 1 ? ( +
+
+ ) : ( +
+ )} +
+ ))} +
+ )} + +
+
+ + {showJump && ( +
+ 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); + } + }} + /> +
+ )} +
+ ); + } + return ( + + ); + })} +
+
+ +
+
+ + setRenameOpen(!!data.open)}> + + + 重命名 + + setRenameValue(d.value)} placeholder="输入新名称" /> + + + + + + + + + + setDeleteOpen(!!data.open)}> + + + 确认删除 + 确定要删除这张图片吗? + + + + + + + + + {preview.open && preview.src && ( + setPreview({ open: false })} /> + )} +
+ ); +}; + +export default WebDrive;