Compare commits

...

5 Commits

Author SHA1 Message Date
0a3e255dfe characters 2025-11-15 20:21:29 +08:00
ec85ee3e82 Get characters from vndb 2025-11-15 19:57:47 +08:00
3fec078ba6 charactor 2025-11-15 18:54:47 +08:00
3e953e22b0 fix 2025-11-15 16:06:45 +08:00
c9c8eac734 Add charactor api. 2025-11-15 16:00:26 +08:00
16 changed files with 676 additions and 62 deletions

View File

@@ -22,7 +22,6 @@
"masonic": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.5.3",

BIN
frontend/public/cp.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import { CharacterParams } from "../network/models";
import { network } from "../network/network";
import showToast from "./toast";
import { useTranslation } from "../utils/i18n";
import Button from "./button";
export default function CharactorEditor({charactor, setCharactor, onDelete}: {
charactor: CharacterParams;
setCharactor: (charactor: CharacterParams) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [isUploading, setUploading] = useState(false);
const uploadImage = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (!input.files || input.files.length === 0) {
return;
}
setUploading(true);
const file = input.files[0];
const result = await network.uploadImage(file);
setUploading(false);
if (result.success) {
setCharactor({
...charactor,
image: result.data!,
});
} else {
showToast({
type: "error",
message: `Failed to upload image`,
})
}
};
input.click();
}
return <div className="h-52 shadow rounded-2xl overflow-clip flex bg-base-100">
<div className="w-36 h-full cursor-pointer relative" onClick={uploadImage}>
{
isUploading ?
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<span className="loading loading-spinner loading-lg text-white"></span>
</div>
: null
}
<img
className="w-full h-full object-cover bg-base-200/80 hover:bg-base-200 transition-colors"
src={charactor.image === 0 ? "/cp.webp" : network.getImageUrl(charactor.image)} alt={charactor.name}
/>
</div>
<div className="flex-1 p-4 flex flex-col gap-2">
<div className="flex gap-2 items-center">
<input
type="text"
className="input input-sm input-bordered flex-1"
placeholder={t("Name")}
value={charactor.name}
onChange={(e) => setCharactor({ ...charactor, name: e.target.value })}
/>
<button
className="btn btn-sm btn-error btn-square"
onClick={onDelete}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input
type="text"
className="input input-sm input-bordered"
placeholder="CV"
value={charactor.cv}
onChange={(e) => setCharactor({ ...charactor, cv: e.target.value })}
/>
<div className="flex-1">
<textarea
className="textarea textarea-bordered w-full h-full resize-none text-xs"
placeholder={t("Aliases (one per line)")}
value={charactor.alias.join('\n')}
onChange={(e) => setCharactor({
...charactor,
alias: e.target.value.split('\n').filter(line => line.trim() !== '')
})}
/>
</div>
</div>
</div>;
}
export function FetchVndbCharactersButton({vnID, onFetch}: {
vnID: string;
onFetch: (characters: CharacterParams[]) => void;
}) {
const { t } = useTranslation();
const [isFetching, setFetching] = useState(false);
const fetchCharacters = async () => {
// validate vnID (v123456)
if (!/^v\d+$/.test(vnID)) {
showToast({
type: "error",
message: t("Invalid VNDB ID format"),
});
return;
}
setFetching(true);
const res = await network.getCharactersFromVNDB(vnID);
setFetching(false);
if (res.success && res.data) {
onFetch(res.data);
} else {
showToast({
type: "error",
message: t("Failed to fetch characters from VNDB"),
});
}
};
return <Button isLoading={isFetching} onClick={fetchCharacters}>
{t("Fetch from VNDB")}
</Button>;
}

View File

@@ -256,6 +256,8 @@ export const i18nData = {
"Private": "私有",
"View {count} more replies": "查看另外 {count} 条回复",
"Survival time": "存活时间",
"Characters": "角色",
"Aliases (one per line)": "别名(每行一个)",
},
},
"zh-TW": {

View File

@@ -49,6 +49,14 @@ export interface CreateResourceParams {
images: number[];
gallery: number[];
gallery_nsfw: number[];
characters: CharacterParams[];
}
export interface CharacterParams {
name: string;
alias: string[];
cv: string;
image: number;
}
export interface Image {
@@ -88,6 +96,7 @@ export interface ResourceDetails {
related: Resource[];
gallery: number[];
galleryNsfw: number[];
charactors: CharacterParams[];
}
export interface Storage {

View File

@@ -21,6 +21,7 @@ import {
CommentWithRef,
Collection,
Statistics,
CharacterParams,
} from "./models.ts";
class Network {
@@ -802,6 +803,14 @@ class Network {
axios.get(`${this.apiBaseUrl}/config/statistics`),
);
}
async getCharactersFromVNDB(vnID: string): Promise<Response<CharacterParams[]>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/resource/vndb/characters`, {
params: { vnid: vnID },
}),
);
}
}
export const network = new Network();

View File

@@ -6,7 +6,7 @@ import {
MdDelete,
MdOutlineInfo,
} from "react-icons/md";
import { Tag } from "../network/models.ts";
import { CharacterParams, Tag } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate, useParams } from "react-router";
import showToast from "../components/toast.ts";
@@ -20,6 +20,7 @@ import {
SelectAndUploadImageButton,
UploadClipboardImageButton,
} from "../components/image_selector.tsx";
import CharactorEditor, { FetchVndbCharactersButton } from "../components/charactor_edit.tsx";
export default function EditResourcePage() {
const [title, setTitle] = useState<string>("");
@@ -30,6 +31,7 @@ export default function EditResourcePage() {
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [charactors, setCharactors] = useState<CharacterParams[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false);
const [isLoading, setLoading] = useState(true);
@@ -59,6 +61,7 @@ export default function EditResourcePage() {
setLinks(data.links ?? []);
setGalleryImages(data.gallery ?? []);
setGalleryNsfw(data.galleryNsfw ?? []);
setCharactors(data.charactors ?? []);
setLoading(false);
} else {
showToast({ message: t("Failed to load resource"), type: "error" });
@@ -104,6 +107,7 @@ export default function EditResourcePage() {
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
characters: charactors,
});
if (res.success) {
setSubmitting(false);
@@ -420,6 +424,50 @@ export default function EditResourcePage() {
/>
</div>
<div className={"h-4"}></div>
<div>
<p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{
charactors.map((charactor, index) => {
return <CharactorEditor
charactor={charactor}
setCharactor={(newCharactor) => {
const newCharactors = [...charactors];
newCharactors[index] = newCharactor;
setCharactors(newCharactors);
}}
onDelete={() => {
const newCharactors = [...charactors];
newCharactors.splice(index, 1);
setCharactors(newCharactors);
}} />;
})
}
</div>
<div className="flex my-2">
<button
className={"btn h-9"}
type={"button"}
onClick={() => {
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]);
}}
>
<MdAdd />
{t("Add Character")}
</button>
{
links.find(link => link.label.toLowerCase() === "vndb") &&
<div className="ml-4">
<FetchVndbCharactersButton
vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""}
onFetch={(fetchedCharacters) => {
setCharactors(fetchedCharacters);
}}
/>
</div>
}
</div>
</div>
{error && (
<div role="alert" className="alert alert-error my-2 shadow">
<svg

View File

@@ -6,7 +6,7 @@ import {
MdDelete,
MdOutlineInfo,
} from "react-icons/md";
import { Tag } from "../network/models.ts";
import { CharacterParams, Tag } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate } from "react-router";
import { useTranslation } from "../utils/i18n";
@@ -19,6 +19,7 @@ import {
SelectAndUploadImageButton,
UploadClipboardImageButton,
} from "../components/image_selector.tsx";
import CharactorEditor from "../components/charactor_edit.tsx";
export default function PublishPage() {
const [title, setTitle] = useState<string>("");
@@ -31,7 +32,7 @@ export default function PublishPage() {
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false);
const [charactors, setCharactors] = useState<CharacterParams[]>([]);
const isFirstLoad = useRef(true);
useEffect(() => {
@@ -110,6 +111,7 @@ export default function PublishPage() {
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
characters: charactors,
});
if (res.success) {
localStorage.removeItem("publish_data");
@@ -429,6 +431,39 @@ export default function PublishPage() {
/>
</div>
<div className={"h-4"}></div>
<div>
<p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{
charactors.map((charactor, index) => {
return <CharactorEditor
charactor={charactor}
setCharactor={(newCharactor) => {
const newCharactors = [...charactors];
newCharactors[index] = newCharactor;
setCharactors(newCharactors);
}}
onDelete={() => {
const newCharactors = [...charactors];
newCharactors.splice(index, 1);
setCharactors(newCharactors);
}} />;
})
}
</div>
<div className="flex">
<button
className={"btn my-2"}
type={"button"}
onClick={() => {
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]);
}}
>
<MdAdd />
{t("Add Character")}
</button>
</div>
</div>
{error && (
<div role="alert" className="alert alert-error my-2 shadow">
<svg

View File

@@ -18,6 +18,7 @@ import {
Tag,
Resource,
Collection,
CharacterParams,
} from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
@@ -469,6 +470,7 @@ const context = createContext<() => void>(() => {});
function Article({ resource }: { resource: ResourceDetails }) {
return (
<>
<article>
<Markdown
remarkPlugins={[remarkGfm]}
@@ -597,6 +599,9 @@ function Article({ resource }: { resource: ResourceDetails }) {
{resource.article.replaceAll("\n", " \n")}
</Markdown>
</article>
<div className="border-b border-base-300 h-8"></div>
<Characters charactors={resource.charactors} />
</>
);
}
@@ -1926,6 +1931,9 @@ function Gallery({ images, nsfw }: { images: number[], nsfw: number[] }) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<dialog
@@ -2011,21 +2019,31 @@ function Gallery({ images, nsfw }: { images: number[], nsfw: number[] }) {
</>
)}
{/* 底部圆点 */}
{/* 底部指示器 */}
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{images.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-primary w-4"
: "bg-base-content/30 hover:bg-base-content/50"
}`}
onClick={() => goToIndex(index)}
aria-label={`Go to image ${index + 1}`}
/>
))}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
{showDots ? (
/* 圆点指示器 */
<div className="flex gap-2">
{images.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-primary w-4"
: "bg-base-content/30 hover:bg-base-content/50"
}`}
onClick={() => goToIndex(index)}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
) : (
/* 数字指示器 */
<div className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
@@ -2059,3 +2077,58 @@ function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
</div>
);
}
function Characters({ charactors }: { charactors: CharacterParams[] }) {
const { t } = useTranslation();
if (!charactors || charactors.length === 0) {
return <></>;
}
return (
<div className="mt-8">
<h3 className="text-xl font-bold mb-4">{t("Characters")}</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{charactors.map((charactor, index) => (
<CharacterCard key={index} charactor={charactor} />
))}
</div>
</div>
);
}
function CharacterCard({ charactor }: { charactor: CharacterParams }) {
const navigate = useNavigate();
const handleCVClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (charactor.cv) {
navigate(`/search?keyword=${encodeURIComponent(charactor.cv)}`);
}
};
return (
<div className="group relative aspect-[3/4] overflow-hidden rounded-lg bg-base-200 shadow-sm">
<img
src={network.getImageUrl(charactor.image)}
alt={charactor.name}
className="w-full h-full object-cover"
/>
<div className="absolute bottom-1 left-1 right-1 px-2 py-2 border border-base-100/40 rounded-lg bg-base-100/60">
<h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent px-1">
{charactor.name}
</h4>
{charactor.cv && (
<button
onClick={handleCVClick}
className="hover:bg-base-200/80 px-1 border border-transparent hover:border-base-300/50 rounded-sm text-xs transition-colors cursor-pointer"
>
CV: {charactor.cv}
</button>
)}
</div>
</div>
);
}

View File

@@ -239,7 +239,7 @@ func handleUpdateResource(c fiber.Ctx) error {
if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a resource")
}
err = service.EditResource(uid, uint(id), &params)
err = service.UpdateResource(uid, uint(id), &params)
if err != nil {
return err
}
@@ -282,6 +282,22 @@ func handleGetPinnedResources(c fiber.Ctx) error {
})
}
func handleGetCharactorsFromVndb(c fiber.Ctx) error {
vnID := c.Query("vnid")
if vnID == "" {
return model.NewRequestError("VNDB ID is required")
}
characters, err := service.GetCharactorsFromVndb(vnID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[[]service.CharactorParams]{
Success: true,
Data: characters,
Message: "Characters retrieved successfully",
})
}
func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource")
{
@@ -290,6 +306,7 @@ func AddResourceRoutes(api fiber.Router) {
resource.Get("/", handleListResources)
resource.Get("/random", handleGetRandomResource)
resource.Get("/pinned", handleGetPinnedResources)
resource.Get("/vndb/characters", handleGetCharactorsFromVndb)
resource.Get("/:id", handleGetResource)
resource.Delete("/:id", handleDeleteResource)
resource.Get("/tag/:tag", handleListResourcesWithTag)

View File

@@ -60,6 +60,7 @@ func init() {
&model.Activity{},
&model.Collection{},
&model.CollectionResource{},
&model.Charactor{},
)
}

View File

@@ -47,6 +47,7 @@ func GetUnusedImages() ([]model.Image, error) {
Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM comment_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM collection_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM charactors WHERE image_id = images.id)").
Where("created_at < ?", oneDayAgo).
Find(&images).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -16,10 +16,18 @@ import (
func CreateResource(r model.Resource) (model.Resource, error) {
err := db.Transaction(func(tx *gorm.DB) error {
r.ModifiedTime = time.Now()
charactors := r.Charactors
r.Charactors = nil
err := tx.Create(&r).Error
if err != nil {
return err
}
for _, c := range charactors {
c.ResourceID = r.ID
if err := tx.Create(&c).Error; err != nil {
return err
}
}
if err := tx.Model(&model.User{}).Where("id = ?", r.UserID).Update("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil {
return err
}
@@ -42,6 +50,7 @@ func GetResourceByID(id uint) (model.Resource, error) {
Preload("Files").
Preload("Files.User").
Preload("Files.Storage").
Preload("Charactors").
First(&r, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Resource{}, model.NewNotFoundError("Resource not found")
@@ -99,22 +108,60 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
func UpdateResource(r model.Resource) error {
// Update a resource in the database
images := r.Images
tags := r.Tags
r.Images = nil
r.Tags = nil
r.Files = nil
r.ModifiedTime = time.Now()
if err := db.Save(&r).Error; err != nil {
return err
}
if err := db.Model(&r).Association("Images").Replace(images); err != nil {
return err
}
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
return err
}
return nil
return db.Transaction(func(tx *gorm.DB) error {
images := r.Images
tags := r.Tags
charactors := r.Charactors
r.Charactors = nil
r.Images = nil
r.Tags = nil
r.Files = nil
r.ModifiedTime = time.Now()
oldCharactors := []model.Charactor{}
if err := db.Model(&model.Charactor{}).Where("resource_id = ?", r.ID).Find(&oldCharactors).Error; err != nil {
return err
}
if err := db.Save(&r).Error; err != nil {
return err
}
if err := db.Model(&r).Association("Images").Replace(images); err != nil {
return err
}
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
return err
}
for _, c := range oldCharactors {
shouldDelete := true
for _, nc := range charactors {
if c.ID == nc.ID {
shouldDelete = false
break
}
}
if shouldDelete {
if err := tx.Delete(&c).Error; err != nil {
return err
}
}
}
for _, c := range charactors {
shouldAdd := true
for _, oc := range oldCharactors {
if c.Equal(&oc) {
shouldAdd = false
break
}
}
if shouldAdd {
c.ID = 0
c.ResourceID = r.ID
if err := tx.Create(&c).Error; err != nil {
return err
}
}
}
return nil
})
}
func DeleteResource(id uint) error {

44
server/model/charactor.go Normal file
View File

@@ -0,0 +1,44 @@
package model
type Charactor struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Alias []string `gorm:"serializer:json"`
CV string `gorm:"type:varchar(100)"`
ImageID uint
ResourceID uint
Image *Image `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}
type CharactorView struct {
Id uint `json:"id"`
Name string `json:"name"`
Alias []string `json:"alias"`
CV string `json:"cv"`
Image uint `json:"image"`
}
func (c *Charactor) ToView() *CharactorView {
return &CharactorView{
Id: c.ID,
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
Image: c.ImageID,
}
}
func (c *Charactor) Equal(other *Charactor) bool {
if c.Name != other.Name || c.CV != other.CV || c.ImageID != other.ImageID {
return false
}
if len(c.Alias) != len(other.Alias) {
return false
}
for i := range c.Alias {
if c.Alias[i] != other.Alias[i] {
return false
}
}
return true
}

View File

@@ -21,8 +21,9 @@ type Resource struct {
Downloads uint
Comments uint
ModifiedTime time.Time
Gallery []uint `gorm:"serializer:json"`
GalleryNsfw []uint `gorm:"serializer:json"`
Gallery []uint `gorm:"serializer:json"`
GalleryNsfw []uint `gorm:"serializer:json"`
Charactors []Charactor `gorm:"foreignKey:ResourceID"`
}
type Link struct {
@@ -40,22 +41,23 @@ type ResourceView struct {
}
type ResourceDetailView struct {
ID uint `json:"id"`
Title string `json:"title"`
AlternativeTitles []string `json:"alternativeTitles"`
Links []Link `json:"links"`
Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"`
Tags []TagView `json:"tags"`
Images []ImageView `json:"images"`
Files []FileView `json:"files"`
Author UserView `json:"author"`
Views uint `json:"views"`
Downloads uint `json:"downloads"`
Comments uint `json:"comments"`
Related []ResourceView `json:"related"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"galleryNsfw"`
ID uint `json:"id"`
Title string `json:"title"`
AlternativeTitles []string `json:"alternativeTitles"`
Links []Link `json:"links"`
Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"`
Tags []TagView `json:"tags"`
Images []ImageView `json:"images"`
Files []FileView `json:"files"`
Author UserView `json:"author"`
Views uint `json:"views"`
Downloads uint `json:"downloads"`
Comments uint `json:"comments"`
Related []ResourceView `json:"related"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"galleryNsfw"`
Charactors []CharactorView `json:"charactors"`
}
func (r *Resource) ToView() ResourceView {
@@ -94,6 +96,10 @@ func (r *Resource) ToDetailView() ResourceDetailView {
for i, file := range r.Files {
files[i] = *file.ToView()
}
charactors := make([]CharactorView, len(r.Charactors))
for i, charactor := range r.Charactors {
charactors[i] = *charactor.ToView()
}
return ResourceDetailView{
ID: r.ID,
Title: r.Title,
@@ -110,5 +116,6 @@ func (r *Resource) ToDetailView() ResourceDetailView {
Comments: r.Comments,
Gallery: r.Gallery,
GalleryNsfw: r.GalleryNsfw,
Charactors: charactors,
}
}

View File

@@ -1,6 +1,10 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"nysoure/server/config"
"nysoure/server/dao"
@@ -10,6 +14,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
@@ -22,14 +27,22 @@ const (
)
type ResourceParams struct {
Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"`
Links []model.Link `json:"links"`
Tags []uint `json:"tags"`
Article string `json:"article"`
Images []uint `json:"images"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"gallery_nsfw"`
Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"`
Links []model.Link `json:"links"`
Tags []uint `json:"tags"`
Article string `json:"article"`
Images []uint `json:"images"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"gallery_nsfw"`
Charactors []CharactorParams `json:"characters"`
}
type CharactorParams struct {
Name string `json:"name" binding:"required"`
Alias []string `json:"alias"`
CV string `json:"cv"`
Image uint `json:"image"`
}
func CreateResource(uid uint, params *ResourceParams) (uint, error) {
@@ -69,6 +82,15 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
nsfw = append(nsfw, id)
}
}
charactors := make([]model.Charactor, len(params.Charactors))
for i, c := range params.Charactors {
charactors[i] = model.Charactor{
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
ImageID: c.Image,
}
}
r := model.Resource{
Title: params.Title,
AlternativeTitles: params.AlternativeTitles,
@@ -79,6 +101,7 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
UserID: uid,
Gallery: gallery,
GalleryNsfw: nsfw,
Charactors: charactors,
}
if r, err = dao.CreateResource(r); err != nil {
return 0, err
@@ -451,7 +474,7 @@ func GetResourcesWithUser(username string, page int) ([]model.ResourceView, int,
return views, totalPages, nil
}
func EditResource(uid, rid uint, params *ResourceParams) error {
func UpdateResource(uid, rid uint, params *ResourceParams) error {
isAdmin, err := checkUserCanUpload(uid)
if err != nil {
log.Error("checkUserCanUpload error: ", err)
@@ -477,6 +500,15 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
nsfw = append(nsfw, id)
}
}
charactors := make([]model.Charactor, len(params.Charactors))
for i, c := range params.Charactors {
charactors[i] = model.Charactor{
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
ImageID: c.Image,
}
}
r.Title = params.Title
r.AlternativeTitles = params.AlternativeTitles
@@ -484,6 +516,7 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
r.Links = params.Links
r.Gallery = gallery
r.GalleryNsfw = nsfw
r.Charactors = charactors
images := make([]model.Image, len(params.Images))
for i, id := range params.Images {
@@ -562,3 +595,161 @@ func GetPinnedResources() ([]model.ResourceView, error) {
}
return views, nil
}
func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
client := http.Client{}
jsonStr := fmt.Sprintf(`
{
"filters": ["id", "=", "%s"],
"fields": "va.character.name, va.staff.name, va.staff.original, va.character.original, va.character.image.url, va.character.vns.role"
}
`, vnID)
jsonStr = strings.TrimSpace(jsonStr)
reader := strings.NewReader(jsonStr)
resp, err := client.Post("https://api.vndb.org/kana/vn", "application/json", reader)
if err != nil {
return nil, model.NewInternalServerError("Failed to fetch data from VNDB")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, model.NewInternalServerError("Failed to fetch data from VNDB")
}
// 定义 VNDB API 响应结构
type VndbResponse struct {
Results []struct {
ID string `json:"id"`
VA []struct {
Character struct {
ID string `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Image struct {
URL string `json:"url"`
} `json:"image"`
VNS []struct {
ID string `json:"id"`
Role string `json:"role"`
} `json:"vns"`
} `json:"character"`
Staff struct {
ID string `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
} `json:"staff"`
} `json:"va"`
} `json:"results"`
}
// 解析响应
var vndbResp VndbResponse
if err := json.NewDecoder(resp.Body).Decode(&vndbResp); err != nil {
return nil, model.NewInternalServerError("Failed to parse VNDB response")
}
if len(vndbResp.Results) == 0 {
return []CharactorParams{}, nil
}
result := vndbResp.Results[0]
var charactors []CharactorParams
processedCharacters := make(map[string]bool) // 避免重复角色
// 遍历声优信息
for _, va := range result.VA {
// 检查角色是否为主要角色
isPrimary := false
for _, vn := range va.Character.VNS {
if vn.Role == "primary" {
isPrimary = true
break
}
}
// 只处理主要角色
if !isPrimary {
continue
}
// 避免重复角色
if processedCharacters[va.Character.ID] {
continue
}
processedCharacters[va.Character.ID] = true
// 优先使用 original 字段作为角色名,如果没有则使用 name
characterName := strings.ReplaceAll(va.Character.Original, " ", "")
if characterName == "" {
characterName = va.Character.Name
}
if characterName == "" {
continue // 跳过没有名字的角色
}
// 使用 original 字段作为声优名,如果没有则使用 name
cvName := strings.ReplaceAll(va.Staff.Original, " ", "")
if cvName == "" {
cvName = va.Staff.Name
}
charactor := CharactorParams{
Name: characterName,
Alias: []string{}, // 按要求不添加别名
CV: cvName,
Image: 0, // 默认值,下面会下载图片
}
// 下载并保存角色图片
if va.Character.Image.URL != "" {
imageID, err := downloadAndCreateImage(va.Character.Image.URL)
if err != nil {
log.Error("Failed to download character image:", err)
// 继续处理,即使图片下载失败
} else {
charactor.Image = imageID
}
}
charactors = append(charactors, charactor)
}
return charactors, nil
}
// downloadAndCreateImage 下载图片并使用 CreateImage 保存
func downloadAndCreateImage(imageURL string) (uint, error) {
// 创建 HTTP 客户端
client := &http.Client{
Timeout: 30 * time.Second,
}
// 下载图片
resp, err := client.Get(imageURL)
if err != nil {
return 0, fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode)
}
// 读取图片数据
imageData, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read image data: %w", err)
}
// 限制图片大小,防止内存溢出
if len(imageData) > 8*1024*1024 { // 8MB 限制
return 0, fmt.Errorf("image too large")
}
// 使用系统用户ID (假设为1) 创建图片
// 注意:这里使用系统账户,实际使用时可能需要调整
imageID, err := CreateImage(1, "127.0.0.1", imageData)
if err != nil {
return 0, fmt.Errorf("failed to create image: %w", err)
}
return imageID, nil
}