Compare commits

...

8 Commits

Author SHA1 Message Date
4f564da7b3 Fix comment deleting 2025-10-02 23:01:34 +08:00
6ed0b45b41 Fix comment deleting 2025-10-02 22:48:11 +08:00
43070cdce3 Fix description 2025-10-02 22:34:51 +08:00
da8d03ec8f Fix home page 2025-10-02 22:33:42 +08:00
64116dddfd Update translation 2025-10-02 22:27:25 +08:00
97ee74899c Update home page 2025-10-02 22:25:37 +08:00
3a99d03427 fix 2025-10-02 21:38:16 +08:00
aac1992dba Add statistic api 2025-10-02 21:34:35 +08:00
13 changed files with 229 additions and 14 deletions

View File

@@ -38,6 +38,7 @@
window.siteInfo = `{{SiteInfo}}`;
window.uploadPrompt = `{{UploadPrompt}}`;
window.allowNormalUserUpload = `{{AllowNormalUserUpload}}`;
window.siteDescription = `{{SiteDescription}}`;
</script>
<script id="pre_fetch_data"></script>
<div id="root"></div>

View File

@@ -6,6 +6,7 @@ interface MyWindow extends Window {
siteInfo?: string;
uploadPrompt?: string;
allowNormalUserUpload?: string;
siteDescription?: string;
}
class App {
@@ -21,6 +22,8 @@ class App {
uploadPrompt = "";
siteDescription = "";
allowNormalUserUpload = true;
constructor() {
@@ -44,7 +47,9 @@ class App {
}
this.siteInfo = (window as MyWindow).siteInfo || "";
this.uploadPrompt = (window as MyWindow).uploadPrompt || "";
// this.allowNormalUserUpload = (window as MyWindow).allowNormalUserUpload === "true";
this.siteDescription = (window as MyWindow).siteDescription || "";
this.allowNormalUserUpload =
(window as MyWindow).allowNormalUserUpload === "true";
}
saveData() {

View File

@@ -255,6 +255,7 @@ export const i18nData = {
"您没有上传文件的权限,请联系管理员。",
"Private": "私有",
"View {count} more replies": "查看另外 {count} 条回复",
"Survival time": "存活时间",
},
},
"zh-TW": {
@@ -513,6 +514,7 @@ export const i18nData = {
"您沒有上傳檔案的權限,請聯繫管理員。",
"Private": "私有",
"View {count} more replies": "查看另外 {count} 條回覆",
"Survival time": "存活時間",
},
},
};

View File

@@ -138,6 +138,7 @@ export interface CommentWithResource {
resource: Resource;
content_truncated: boolean;
reply_count: number;
replies: Comment[];
}
export interface CommentWithRef {
@@ -204,3 +205,9 @@ export interface Collection {
images: Image[];
isPublic: boolean;
}
export interface Statistics {
total_resources: number;
total_files: number;
start_time: number;
}

View File

@@ -20,6 +20,7 @@ import {
Activity,
CommentWithRef,
Collection,
Statistics,
} from "./models.ts";
class Network {
@@ -795,6 +796,12 @@ class Network {
}),
);
}
async getStatistic(): Promise<Response<Statistics>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/config/statistics`),
);
}
}
export const network = new Network();

View File

@@ -2,12 +2,17 @@ import { useEffect, useState } from "react";
import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts";
import { app } from "../app.ts";
import { Resource, RSort } from "../network/models.ts";
import { Resource, RSort, Statistics } from "../network/models.ts";
import { useTranslation } from "../utils/i18n";
import { useAppContext } from "../components/AppContext.tsx";
import Select from "../components/select.tsx";
import { useNavigate } from "react-router";
import { useNavigator } from "../components/navigator.tsx";
import {
MdOutlineAccessTime,
MdOutlineArchive,
MdOutlineClass,
} from "react-icons/md";
export default function HomePage() {
useEffect(() => {
@@ -33,7 +38,7 @@ export default function HomePage() {
return (
<>
<PinnedResources />
<HomeHeader />
<div className={"flex pt-4 px-4 items-center"}>
<Select
values={[
@@ -64,8 +69,9 @@ export default function HomePage() {
let cachedPinnedResources: Resource[] | null = null;
function PinnedResources() {
function HomeHeader() {
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
const [statistic, setStatistic] = useState<Statistics | null>(null);
const navigator = useNavigator();
useEffect(() => {
@@ -79,9 +85,18 @@ function PinnedResources() {
network.getResampledImageUrl(prefetchData.background),
);
}
let ok1 = false;
let ok2 = false;
if (prefetchData && prefetchData.statistics) {
setStatistic(prefetchData.statistics);
ok1 = true;
}
if (prefetchData && prefetchData.pinned) {
cachedPinnedResources = prefetchData.pinned;
setPinnedResources(cachedPinnedResources!);
ok2 = true;
}
if (ok1 && ok2) {
return;
}
const fetchPinnedResources = async () => {
@@ -91,18 +106,30 @@ function PinnedResources() {
setPinnedResources(res.data ?? []);
}
};
const fetchStatistics = async () => {
const res = await network.getStatistic();
if (res.success) {
setStatistic(res.data!);
}
};
fetchPinnedResources();
}, []);
fetchStatistics();
}, [navigator]);
if (pinnedResources.length == 0) {
if (pinnedResources.length == 0 || statistic == null) {
return <></>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
{pinnedResources.map((resource) => (
<PinnedResourceItem key={resource.id} resource={resource} />
))}
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4">
<PinnedResourceItem resource={pinnedResources[0]} />
<div className={"hidden md:block"}>
<div className={"card w-full shadow p-4 mb-4 bg-base-100-tr82 h-28"}>
<h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2>
<p className={"text-xs"}>{app.siteDescription}</p>
</div>
<StatisticCard statistic={statistic} />
</div>
</div>
);
}
@@ -129,7 +156,7 @@ function PinnedResourceItem({ resource }: { resource: Resource }) {
<img
src={network.getResampledImageUrl(resource.image.id)}
alt="cover"
className="w-full aspect-[7/3] object-cover"
className="w-full h-52 lg:h-60 object-cover"
/>
</figure>
)}
@@ -140,3 +167,40 @@ function PinnedResourceItem({ resource }: { resource: Resource }) {
</a>
);
}
function StatisticCard({ statistic }: { statistic: Statistics }) {
const { t } = useTranslation();
const now = new Date();
const createdAt = new Date(statistic.start_time * 1000);
const diffTime = Math.abs(now.getTime() - createdAt.getTime());
const survivalTime = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return (
<div className="stats shadow w-full bg-base-100-tr82">
<div className="stat">
<div className="stat-figure text-secondary pt-2">
<MdOutlineClass size={28} />
</div>
<div className="stat-title">{t("Resources")}</div>
<div className="stat-value">{statistic.total_resources}</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary pt-2">
<MdOutlineArchive size={28} />
</div>
<div className="stat-title">{t("Files")}</div>
<div className="stat-value">{statistic.total_files}</div>
</div>
<div className="stat">
<div className="stat-figure text-accent pt-2">
<MdOutlineAccessTime size={28} />
</div>
<div className="stat-title">{t("Survival time")}</div>
<div className="stat-value">{survivalTime}</div>
</div>
</div>
);
}

View File

@@ -61,10 +61,22 @@ func setServerConfig(c fiber.Ctx) error {
})
}
func getStatistics(c fiber.Ctx) error {
s, err := service.GetStatistic()
if err != nil {
return model.NewInternalServerError("Failed to get statistics")
}
return c.JSON(model.Response[*service.Statistic]{
Success: true,
Data: s,
})
}
func AddConfigRoutes(r fiber.Router) {
configGroup := r.Group("/config")
{
configGroup.Get("/", getServerConfig)
configGroup.Post("/", setServerConfig)
configGroup.Get("/statistics", getStatistics)
}
}

View File

@@ -179,8 +179,14 @@ func DeleteCommentByID(commentID uint) error {
if err := tx.Model(&model.User{}).Where("id = ?", comment.UserID).Update("comments_count", gorm.Expr("comments_count - 1")).Error; err != nil {
return err
}
if err := tx.Model(&model.Resource{}).Where("id = ?", comment.RefID).Update("comments", gorm.Expr("comments - 1")).Error; err != nil {
return err
if comment.Type == model.CommentTypeResource {
if err := tx.Model(&model.Resource{}).Where("id = ?", comment.RefID).Update("comments", gorm.Expr("comments - 1")).Error; err != nil {
return err
}
} else if comment.Type == model.CommentTypeReply {
if err := tx.Model(&model.Comment{}).Where("id = ?", comment.RefID).Update("reply_count", gorm.Expr("reply_count - 1")).Error; err != nil {
return err
}
}
if err := tx.
Where("type = ? and ref_id = ?", model.ActivityTypeNewComment, commentID).

View File

@@ -239,3 +239,11 @@ func ListUserFiles(userID uint, page, pageSize int) ([]*model.File, int64, error
}
return files, count, nil
}
func CountFiles() (int64, error) {
var count int64
if err := db.Model(&model.File{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -461,3 +461,11 @@ func BatchGetResources(ids []uint) ([]model.Resource, error) {
return resources, nil
}
func CountResources() (int64, error) {
var count int64
if err := db.Model(&model.Resource{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -158,10 +158,12 @@ func serveIndexHtml(c fiber.Ctx) error {
} else if path == "/" || path == "" {
pinned, err := service.GetPinnedResources()
random, err1 := service.RandomCover()
if err == nil && err1 == nil {
statistic, err2 := service.GetStatistic()
if err == nil && err1 == nil && err2 == nil {
preFetchDataJson, _ := json.Marshal(map[string]interface{}{
"pinned": pinned,
"background": random,
"statistic": statistic,
})
preFetchData = url.PathEscape(string(preFetchDataJson))
}
@@ -169,6 +171,7 @@ func serveIndexHtml(c fiber.Ctx) error {
content = strings.ReplaceAll(content, "{{SiteName}}", siteName)
content = strings.ReplaceAll(content, "{{Description}}", description)
content = strings.ReplaceAll(content, "{{SiteDescription}}", config.ServerDescription())
content = strings.ReplaceAll(content, "{{Preview}}", preview)
content = strings.ReplaceAll(content, "{{Title}}", title)
content = strings.ReplaceAll(content, "{{Url}}", htmlUrl)

View File

@@ -0,0 +1,63 @@
package service
import (
"fmt"
"nysoure/server/dao"
"nysoure/server/utils"
"os"
"time"
)
type Statistic struct {
TotalResources int64 `json:"total_resources"`
TotalFiles int64 `json:"total_files"`
StartTime int64 `json:"start_time"`
}
var (
startTime int64
cache = utils.NewMemValueCache[*Statistic](1 * time.Minute)
)
func init() {
timeFile := utils.GetStoragePath() + "/.start_time"
if _, err := os.Stat(timeFile); os.IsNotExist(err) {
startTime = time.Now().Unix()
str := fmt.Sprintf("%d", startTime)
err := os.WriteFile(timeFile, []byte(str), 0644)
if err != nil {
panic("Failed to write start time file: " + err.Error())
}
} else {
data, err := os.ReadFile(timeFile)
if err != nil {
panic("Failed to read start time file: " + err.Error())
}
var t int64
_, err = fmt.Sscanf(string(data), "%d", &t)
if err != nil {
panic("Failed to parse start time: " + err.Error())
}
startTime = t
}
}
func getStatistic() (*Statistic, error) {
totalResources, err := dao.CountResources()
if err != nil {
return nil, err
}
totalFiles, err := dao.CountFiles()
if err != nil {
return nil, err
}
return &Statistic{
TotalResources: totalResources,
TotalFiles: totalFiles,
StartTime: startTime,
}, nil
}
func GetStatistic() (*Statistic, error) {
return cache.Get(getStatistic)
}

29
server/utils/cache.go Normal file
View File

@@ -0,0 +1,29 @@
package utils
import "time"
type MemValueCache[T any] struct {
value T
duration time.Duration
expiry time.Time
}
func NewMemValueCache[T any](duration time.Duration) *MemValueCache[T] {
return &MemValueCache[T]{
duration: duration,
}
}
func (c *MemValueCache[T]) Get(fetchFunc func() (T, error)) (T, error) {
var zero T
if time.Now().Before(c.expiry) {
return c.value, nil
}
v, err := fetchFunc()
if err != nil {
return zero, err
}
c.value = v
c.expiry = time.Now().Add(c.duration)
return v, nil
}