Add webdrive upload and preview tweaks

This commit is contained in:
LeonspaceX
2026-02-01 18:53:11 +08:00
parent 5c79a9aa00
commit 8457ad64e0
7 changed files with 929 additions and 42 deletions

View File

@@ -12,3 +12,11 @@ rate limit功能
rss功能
完成开发后记得Debug=False
深浅色模式切换逻辑有待完善没实现localstoarge储存状态
AI审核
后台权限分层,审核员/管理员
链接跳转新页面

View File

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

View File

@@ -39,6 +39,7 @@
| 2006 | 失败。上传的图片超出限制大小。 |
| 2007 | 失败。上传的图片类型不支持。 |
| 2008 | 失败。上传的图片损坏。 |
| 2009 | 失败。端点需要提供Identity才能操作。 |
| | |
| | |
| | |

View File

@@ -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[]> => {
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<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) => {
if (json && json.code === 2004) {
notifyInvalidIdentity();

View File

@@ -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<string[]>([]);
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(
<Toast>
<ToastTitle></ToastTitle>
@@ -811,7 +849,7 @@ const CreatePost: React.FC = () => {
<DialogContent></DialogContent>
<DialogActions>
<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="primary" onClick={handleLocalUpload}></Button>
</div>
@@ -820,6 +858,24 @@ const CreatePost: React.FC = () => {
</DialogSurface>
</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)}>
<DialogSurface>
<DialogBody>

View File

@@ -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 <WebDrive mode={1} />;
}
return (
<>
<Text weight="semibold">{titleMap[tab]}</Text>
<Text className={styles.placeholder}>{titleMap[tab]}</Text>
</>
);
};
return (
<div className={styles.container}>
<TabList
@@ -45,12 +58,9 @@ const Panel: React.FC = () => {
<Tab value="settings"></Tab>
</TabList>
<div className={styles.section}>
<Text weight="semibold">{titleMap[tab]}</Text>
<Text className={styles.placeholder}> {titleMap[tab]} </Text>
</div>
<div className={styles.section}>{renderContent()}</div>
</div>
);
};
export default Panel;
export default Panel;

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;