Add user files.

This commit is contained in:
2025-07-13 16:41:39 +08:00
parent e9f6e1968e
commit 891b3d4a1a
8 changed files with 236 additions and 14 deletions

View File

@@ -103,6 +103,7 @@ export interface RFile {
size: number; size: number;
is_redirect: boolean; is_redirect: boolean;
user: User; user: User;
resource?: Resource;
} }
export interface UploadingFile { export interface UploadingFile {

View File

@@ -574,6 +574,20 @@ class Network {
); );
} }
async getUserFiles(
username: string,
page: number = 1,
): Promise<PageResponse<RFile>> {
return this._callApi(() =>
axios.get(
`${this.apiBaseUrl}/files/user/${encodeURIComponent(username)}`,
{
params: { page },
},
),
);
}
getFileDownloadLink(fileId: string, cfToken: string): string { getFileDownloadLink(fileId: string, cfToken: string): string {
return `${this.apiBaseUrl}/files/download/${fileId}?cf_token=${cfToken}`; return `${this.apiBaseUrl}/files/download/${fileId}?cf_token=${cfToken}`;
} }

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import { CommentContent } from "../components/comment_tile.tsx"; import { CommentContent } from "../components/comment_tile.tsx";
import {MdOutlineArchive, MdOutlinePhotoAlbum} from "react-icons/md"; import { MdOutlineArchive, MdOutlinePhotoAlbum } from "react-icons/md";
import Badge from "../components/badge.tsx"; import Badge from "../components/badge.tsx";
export default function ActivitiesPage() { export default function ActivitiesPage() {

View File

@@ -1,8 +1,8 @@
import { useParams, useLocation, useNavigate } from "react-router"; import { useParams, useLocation, useNavigate } from "react-router";
import { CommentWithResource, User } from "../network/models"; import { CommentWithResource, RFile, User } from "../network/models";
import { network } from "../network/network"; import { network } from "../network/network";
import showToast from "../components/toast"; import showToast from "../components/toast";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import ResourcesView from "../components/resources_view"; import ResourcesView from "../components/resources_view";
import Loading from "../components/loading"; import Loading from "../components/loading";
import Pagination from "../components/pagination"; import Pagination from "../components/pagination";
@@ -26,23 +26,27 @@ export default function UserPage() {
const username = rawUsername ? decodeURIComponent(rawUsername) : ""; const username = rawUsername ? decodeURIComponent(rawUsername) : "";
// 从 hash 中获取当前页面,默认为 resources // 从 hash 中获取当前页面,默认为 resources
const getPageFromHash = () => { const getPageFromHash = useCallback(() => {
const hash = location.hash.slice(1); // 移除 # 号 const hash = location.hash.slice(1); // 移除 # 号
if (hash === "comments") return 1; if (hash === "comments") return 1;
if (hash === "files") return 2;
return 0; // 默认为 resources return 0; // 默认为 resources
}; }, [location.hash]);
const [page, setPage] = useState(getPageFromHash()); const [page, setPage] = useState(getPageFromHash());
// 监听 hash 变化 // 监听 hash 变化
useEffect(() => { useEffect(() => {
setPage(getPageFromHash()); setPage(getPageFromHash());
}, [location.hash]); }, [location.hash, getPageFromHash]);
// 更新 hash 的函数 // 更新 hash 的函数
const updateHash = (newPage: number) => { const updateHash = (newPage: number) => {
const hash = newPage === 1 ? "#comments" : "#resources"; const hashs = ["resources", "comments", "files"];
navigate(location.pathname + hash, { replace: true }); const newHash = hashs[newPage] || "resources";
if (location.hash.slice(1) !== newHash) {
navigate(`/user/${username}#${newHash}`, { replace: true });
}
}; };
useEffect(() => { useEffect(() => {
@@ -91,10 +95,18 @@ export default function UserPage() {
> >
Comments Comments
</div> </div>
<div
role="tab"
className={`tab ${page === 2 ? "tab-active" : ""}`}
onClick={() => updateHash(2)}
>
Files
</div>
</div> </div>
<div className="w-full"> <div className="w-full">
{page === 0 && <UserResources user={user} />} {page === 0 && <UserResources user={user} />}
{page === 1 && <UserComments user={user} />} {page === 1 && <UserComments user={user} />}
{page === 2 && <UserFiles user={user} />}
</div> </div>
<div className="h-16"></div> <div className="h-16"></div>
</div> </div>
@@ -228,3 +240,119 @@ function CommentsList({
</> </>
); );
} }
function UserFiles({ user }: { user: User }) {
const [page, setPage] = useState(1);
const [maxPage, setMaxPage] = useState(0);
return (
<div className="px-2">
<FilesList
username={user.username}
page={page}
maxPageCallback={setMaxPage}
/>
{maxPage ? (
<div className={"w-full flex justify-center"}>
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
</div>
) : null}
</div>
);
}
function fileSizeToString(size: number) {
if (size < 1024) {
return size + "B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(2) + "MB";
} else {
return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
}
}
function FilesList({
username,
page,
maxPageCallback,
}: {
username: string;
page: number;
maxPageCallback: (maxPage: number) => void;
}) {
const [files, setFiles] = useState<RFile[] | null>(null);
const navigate = useNavigate();
const { t } = useTranslation();
useEffect(() => {
network.getUserFiles(username, page).then((res) => {
if (res.success) {
setFiles(res.data!);
maxPageCallback(res.totalPages || 1);
} else {
showToast({
message: res.message,
type: "error",
});
}
});
}, [maxPageCallback, page, username]);
if (files == null) {
return (
<div className={"w-full"}>
<Loading />
</div>
);
}
return (
<>
{files.map((file) => {
return (
<a
href={`/resources/${file.resource!.id}#files`}
onClick={(e) => {
e.preventDefault();
navigate(`/resources/${file.resource!.id}#files`);
}}
>
<div
className={
"card shadow p-4 my-2 hover:shadow-md transition-shadow"
}
>
<h4 className={"font-bold pb-2"}>{file!.filename}</h4>
<p className={"text-sm whitespace-pre-wrap"}>
{file!.description}
</p>
<p className={"pt-1"}>
<Badge className={"badge-soft badge-secondary text-xs mr-2"}>
<MdOutlineArchive size={16} className={"inline-block"} />
{file!.is_redirect
? t("Redirect")
: fileSizeToString(file!.size)}
</Badge>
<Badge className={"badge-soft badge-accent text-xs mr-2"}>
<MdOutlinePhotoAlbum size={16} className={"inline-block"} />
{(() => {
let title = file.resource!.title;
if (title.length > 20) {
title = title.slice(0, 20) + "...";
}
return title;
})()}
</Badge>
</p>
</div>
</a>
);
})}
</>
);
}

View File

@@ -30,6 +30,7 @@ func AddFileRoutes(router fiber.Router) {
fileGroup.Delete("/:id", deleteFile) fileGroup.Delete("/:id", deleteFile)
fileGroup.Get("/download/local", downloadLocalFile) fileGroup.Get("/download/local", downloadLocalFile)
fileGroup.Get("/download/:id", downloadFile, middleware.NewDynamicRequestLimiter(config.MaxDownloadsPerDayForSingleIP, 24*time.Hour)) fileGroup.Get("/download/:id", downloadFile, middleware.NewDynamicRequestLimiter(config.MaxDownloadsPerDayForSingleIP, 24*time.Hour))
fileGroup.Get("/user/:username", listUserFiles, middleware.NewRequestLimiter(100, 24*time.Hour))
} }
} }
@@ -273,3 +274,22 @@ func createServerDownloadTask(c fiber.Ctx) error {
Data: result, Data: result,
}) })
} }
func listUserFiles(c fiber.Ctx) error {
username := c.Params("username")
page, err := strconv.Atoi(c.Query("page", "1"))
if err != nil || page < 1 {
return model.NewRequestError("Invalid page number")
}
files, totalPages, err := service.ListUserFiles(username, page)
if err != nil {
return err
}
return c.JSON(model.PageResponse[*model.FileView]{
Success: true,
Data: files,
TotalPages: totalPages,
})
}

View File

@@ -215,3 +215,20 @@ func SetFileStorageKeyAndSize(id string, storageKey string, size int64) error {
} }
return nil return nil
} }
func ListUserFiles(userID uint, page, pageSize int) ([]*model.File, int64, error) {
var files []*model.File
var count int64
if err := db.Model(&model.File{}).
Preload("Resource").
Where("user_id = ?", userID).
Count(&count).
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&files).Error; err != nil {
return nil, 0, err
}
return files, count, nil
}

View File

@@ -21,12 +21,13 @@ type File struct {
} }
type FileView struct { type FileView struct {
ID string `json:"id"` ID string `json:"id"`
Filename string `json:"filename"` Filename string `json:"filename"`
Description string `json:"description"` Description string `json:"description"`
Size int64 `json:"size"` Size int64 `json:"size"`
IsRedirect bool `json:"is_redirect"` IsRedirect bool `json:"is_redirect"`
User UserView `json:"user"` User UserView `json:"user"`
Resource *ResourceView `json:"resource,omitempty"`
} }
func (f *File) ToView() *FileView { func (f *File) ToView() *FileView {
@@ -39,3 +40,21 @@ func (f *File) ToView() *FileView {
User: f.User.ToView(), User: f.User.ToView(),
} }
} }
func (f *File) ToViewWithResource() *FileView {
var resource *ResourceView
if f.Resource.ID != 0 {
rv := f.Resource.ToView()
resource = &rv
}
return &FileView{
ID: f.UUID,
Filename: f.Filename,
Description: f.Description,
Size: f.Size,
IsRedirect: f.RedirectUrl != "",
User: f.User.ToView(),
Resource: resource,
}
}

View File

@@ -614,3 +614,26 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
return file.ToView(), nil return file.ToView(), nil
} }
func ListUserFiles(username string, page int) ([]*model.FileView, int, error) {
user, err := dao.GetUserByUsername(username)
if err != nil {
log.Error("failed to get user by username: ", err)
return nil, 0, model.NewNotFoundError("user not found")
}
uid := user.ID
files, total, err := dao.ListUserFiles(uid, page, pageSize)
if err != nil {
log.Error("failed to list user files: ", err)
return nil, 0, model.NewInternalServerError("failed to list user files")
}
fileViews := make([]*model.FileView, len(files))
for i, file := range files {
fileViews[i] = file.ToViewWithResource()
}
totalPages := (total + pageSize - 1) / pageSize
return fileViews, int(totalPages), nil
}