Compare commits

...

6 Commits

Author SHA1 Message Date
e2c30e5d77 improve characters UI 2025-11-16 21:23:12 +08:00
5a253b60d0 fix empty character image 2025-11-16 20:56:14 +08:00
03bf9ec97b update character image 2025-11-16 20:28:23 +08:00
92e4e05d7d fix 2025-11-16 18:49:28 +08:00
46a19c12fa typo 2025-11-16 18:46:22 +08:00
9d9a2545f9 Role for character 2025-11-16 18:37:33 +08:00
12 changed files with 278 additions and 120 deletions

View File

@@ -1,13 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { CharacterParams } from "../network/models"; import { CharacterParams, CharacterRole } from "../network/models";
import { network } from "../network/network"; import { network } from "../network/network";
import showToast from "./toast"; import showToast from "./toast";
import { useTranslation } from "../utils/i18n"; import { useTranslation } from "../utils/i18n";
import Button from "./button"; import Button from "./button";
export default function CharactorEditor({charactor, setCharactor, onDelete}: { export default function CharacterEditer({character, setCharacter, onDelete}: {
charactor: CharacterParams; character: CharacterParams;
setCharactor: (charactor: CharacterParams) => void; setCharacter: (character: CharacterParams) => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,8 +26,8 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
const result = await network.uploadImage(file); const result = await network.uploadImage(file);
setUploading(false); setUploading(false);
if (result.success) { if (result.success) {
setCharactor({ setCharacter({
...charactor, ...character,
image: result.data!, image: result.data!,
}); });
} else { } else {
@@ -51,7 +51,7 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
} }
<img <img
className="w-full h-full object-cover bg-base-200/80 hover:bg-base-200 transition-colors" 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} src={character.image === 0 ? "/cp.webp" : network.getImageUrl(character.image)} alt={character.name}
/> />
</div> </div>
@@ -61,8 +61,8 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
type="text" type="text"
className="input input-sm input-bordered flex-1" className="input input-sm input-bordered flex-1"
placeholder={t("Name")} placeholder={t("Name")}
value={charactor.name} value={character.name}
onChange={(e) => setCharactor({ ...charactor, name: e.target.value })} onChange={(e) => setCharacter({ ...character, name: e.target.value })}
/> />
<button <button
className="btn btn-sm btn-error btn-square" className="btn btn-sm btn-error btn-square"
@@ -78,17 +78,26 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
type="text" type="text"
className="input input-sm input-bordered" className="input input-sm input-bordered"
placeholder="CV" placeholder="CV"
value={charactor.cv} value={character.cv}
onChange={(e) => setCharactor({ ...charactor, cv: e.target.value })} onChange={(e) => setCharacter({ ...character, cv: e.target.value })}
/> />
<select
className="select select-sm select-bordered"
value={character.role}
onChange={(e) => setCharacter({ ...character, role: e.target.value as CharacterRole })}
>
<option value="primary">{t("Primary Role")}</option>
<option value="side">{t("Side Role")}</option>
</select>
<div className="flex-1"> <div className="flex-1">
<textarea <textarea
className="textarea textarea-bordered w-full h-full resize-none text-xs" className="textarea textarea-bordered w-full h-full resize-none text-xs"
placeholder={t("Aliases (one per line)")} placeholder={t("Aliases (one per line)")}
value={charactor.alias.join('\n')} value={character.alias.join('\n')}
onChange={(e) => setCharactor({ onChange={(e) => setCharacter({
...charactor, ...character,
alias: e.target.value.split('\n').filter(line => line.trim() !== '') alias: e.target.value.split('\n').filter(line => line.trim() !== '')
})} })}
/> />

View File

@@ -52,11 +52,14 @@ export interface CreateResourceParams {
characters: CharacterParams[]; characters: CharacterParams[];
} }
export type CharacterRole = 'primary' | 'side';
export interface CharacterParams { export interface CharacterParams {
name: string; name: string;
alias: string[]; alias: string[];
cv: string; cv: string;
image: number; image: number;
role: CharacterRole;
} }
export interface Image { export interface Image {
@@ -96,7 +99,7 @@ export interface ResourceDetails {
related: Resource[]; related: Resource[];
gallery: number[]; gallery: number[];
galleryNsfw: number[]; galleryNsfw: number[];
charactors: CharacterParams[]; characters: CharacterParams[];
} }
export interface Storage { export interface Storage {

View File

@@ -20,7 +20,7 @@ import {
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
import CharactorEditor, { FetchVndbCharactersButton } from "../components/charactor_edit.tsx"; import CharacterEditer, { FetchVndbCharactersButton } from "../components/character_edit.tsx";
export default function EditResourcePage() { export default function EditResourcePage() {
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
@@ -31,7 +31,7 @@ export default function EditResourcePage() {
const [links, setLinks] = useState<{ label: string; url: string }[]>([]); const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]); const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]); const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [charactors, setCharactors] = useState<CharacterParams[]>([]); const [characters, setCharacters] = useState<CharacterParams[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
@@ -61,7 +61,7 @@ export default function EditResourcePage() {
setLinks(data.links ?? []); setLinks(data.links ?? []);
setGalleryImages(data.gallery ?? []); setGalleryImages(data.gallery ?? []);
setGalleryNsfw(data.galleryNsfw ?? []); setGalleryNsfw(data.galleryNsfw ?? []);
setCharactors(data.charactors ?? []); setCharacters(data.characters ?? []);
setLoading(false); setLoading(false);
} else { } else {
showToast({ message: t("Failed to load resource"), type: "error" }); showToast({ message: t("Failed to load resource"), type: "error" });
@@ -107,7 +107,7 @@ export default function EditResourcePage() {
links: links, links: links,
gallery: galleryImages, gallery: galleryImages,
gallery_nsfw: galleryNsfw, gallery_nsfw: galleryNsfw,
characters: charactors, characters: characters,
}); });
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -428,18 +428,18 @@ export default function EditResourcePage() {
<p className={"my-1"}>{t("Characters")}</p> <p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{ {
charactors.map((charactor, index) => { characters.map((character, index) => {
return <CharactorEditor return <CharacterEditer
charactor={charactor} character={character}
setCharactor={(newCharactor) => { setCharacter={(newCharacter) => {
const newCharactors = [...charactors]; const newCharacters = [...characters];
newCharactors[index] = newCharactor; newCharacters[index] = newCharacter;
setCharactors(newCharactors); setCharacters(newCharacters);
}} }}
onDelete={() => { onDelete={() => {
const newCharactors = [...charactors]; const newCharacters = [...characters];
newCharactors.splice(index, 1); newCharacters.splice(index, 1);
setCharactors(newCharactors); setCharacters(newCharacters);
}} />; }} />;
}) })
} }
@@ -449,7 +449,7 @@ export default function EditResourcePage() {
className={"btn h-9"} className={"btn h-9"}
type={"button"} type={"button"}
onClick={() => { onClick={() => {
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]); setCharacters([...characters, { name: "", alias: [], cv: "", image: 0, role: "primary" }]);
}} }}
> >
<MdAdd /> <MdAdd />
@@ -461,7 +461,7 @@ export default function EditResourcePage() {
<FetchVndbCharactersButton <FetchVndbCharactersButton
vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""} vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""}
onFetch={(fetchedCharacters) => { onFetch={(fetchedCharacters) => {
setCharactors(fetchedCharacters); setCharacters(fetchedCharacters);
}} }}
/> />
</div> </div>

View File

@@ -19,7 +19,7 @@ import {
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
import CharactorEditor from "../components/charactor_edit.tsx"; import CharacterEditer from "../components/character_edit.tsx";
export default function PublishPage() { export default function PublishPage() {
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
@@ -32,7 +32,7 @@ export default function PublishPage() {
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]); const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [charactors, setCharactors] = useState<CharacterParams[]>([]); const [characters, setCharacters] = useState<CharacterParams[]>([]);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
useEffect(() => { useEffect(() => {
@@ -111,7 +111,7 @@ export default function PublishPage() {
links: links, links: links,
gallery: galleryImages, gallery: galleryImages,
gallery_nsfw: galleryNsfw, gallery_nsfw: galleryNsfw,
characters: charactors, characters: characters,
}); });
if (res.success) { if (res.success) {
localStorage.removeItem("publish_data"); localStorage.removeItem("publish_data");
@@ -435,18 +435,18 @@ export default function PublishPage() {
<p className={"my-1"}>{t("Characters")}</p> <p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{ {
charactors.map((charactor, index) => { characters.map((character, index) => {
return <CharactorEditor return <CharacterEditer
charactor={charactor} character={character}
setCharactor={(newCharactor) => { setCharacter={(newCharacter) => {
const newCharactors = [...charactors]; const newCharacters = [...characters];
newCharactors[index] = newCharactor; newCharacters[index] = newCharacter;
setCharactors(newCharactors); setCharacters(newCharacters);
}} }}
onDelete={() => { onDelete={() => {
const newCharactors = [...charactors]; const newCharacters = [...characters];
newCharactors.splice(index, 1); newCharacters.splice(index, 1);
setCharactors(newCharactors); setCharacters(newCharacters);
}} />; }} />;
}) })
} }
@@ -456,7 +456,7 @@ export default function PublishPage() {
className={"btn my-2"} className={"btn my-2"}
type={"button"} type={"button"}
onClick={() => { onClick={() => {
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]); setCharacters([...characters, { name: "", alias: [], cv: "", image: 0, role: "primary"}]);
}} }}
> >
<MdAdd /> <MdAdd />

View File

@@ -600,7 +600,7 @@ function Article({ resource }: { resource: ResourceDetails }) {
</Markdown> </Markdown>
</article> </article>
<div className="border-b border-base-300 h-8"></div> <div className="border-b border-base-300 h-8"></div>
<Characters charactors={resource.charactors} /> <Characters characters={resource.characters} />
</> </>
); );
} }
@@ -2078,54 +2078,69 @@ function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
); );
} }
function Characters({ charactors }: { charactors: CharacterParams[] }) { function Characters({ characters }: { characters: CharacterParams[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!charactors || charactors.length === 0) { let main = characters.filter((c) => c.role === "primary");
let other1 = characters.filter((c) => c.role !== "primary" && c.image);
let other2 = characters.filter((c) => c.role !== "primary" && !c.image);
characters = [...main, ...other1, ...other2];
if (!characters || characters.length === 0) {
return <></>; return <></>;
} }
return ( return (
<div className="mt-8"> <div className="mt-8">
<h3 className="text-xl font-bold mb-4">{t("Characters")}</h3> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{charactors.map((charactor, index) => ( {characters.map((character, index) => (
<CharacterCard key={index} charactor={charactor} /> <CharacterCard key={index} character={character} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function CharacterCard({ charactor }: { charactor: CharacterParams }) { function CharacterCard({ character }: { character: CharacterParams }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const handleCVClick = (e: React.MouseEvent) => { const handleCVClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (charactor.cv) { if (character.cv) {
navigate(`/search?keyword=${encodeURIComponent(charactor.cv)}`); navigate(`/search?keyword=${encodeURIComponent(character.cv)}`);
} }
}; };
return ( return (
<div className="group relative aspect-[3/4] overflow-hidden rounded-lg bg-base-200 shadow-sm"> <div className="group relative aspect-[3/4] overflow-hidden rounded-lg bg-base-200 shadow-sm">
<img <img
src={network.getImageUrl(charactor.image)} src={character.image ? network.getImageUrl(character.image) : "/cp.webp"}
alt={charactor.name} alt={character.name}
className="w-full h-full object-cover" 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"> <div className="absolute bottom-1 left-1 right-1 px-1 py-1 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"> <h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent">
{charactor.name} {character.name}
{
character.role === "primary" ? (
<span className="bg-primary/80 rounded-lg px-2 py-0.5 text-primary-content ml-1" style={{
fontSize: "10px",
}}>
Main
</span>
) : null
}
</h4> </h4>
{charactor.cv && ( {character.cv && (
<button <button
onClick={handleCVClick} 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" className="hover:bg-base-200/80 border border-transparent hover:border-base-300/50 rounded-sm text-xs transition-colors cursor-pointer"
> >
CV: {charactor.cv} CV: {character.cv}
</button> </button>
)} )}
</div> </div>

View File

@@ -282,22 +282,63 @@ func handleGetPinnedResources(c fiber.Ctx) error {
}) })
} }
func handleGetCharactorsFromVndb(c fiber.Ctx) error { func handleGetCharactersFromVndb(c fiber.Ctx) error {
vnID := c.Query("vnid") vnID := c.Query("vnid")
if vnID == "" { if vnID == "" {
return model.NewRequestError("VNDB ID is required") return model.NewRequestError("VNDB ID is required")
} }
characters, err := service.GetCharactorsFromVndb(vnID) characters, err := service.GetCharactersFromVndb(vnID)
if err != nil { if err != nil {
return err return err
} }
return c.Status(fiber.StatusOK).JSON(model.Response[[]service.CharactorParams]{ return c.Status(fiber.StatusOK).JSON(model.Response[[]service.CharacterParams]{
Success: true, Success: true,
Data: characters, Data: characters,
Message: "Characters retrieved successfully", Message: "Characters retrieved successfully",
}) })
} }
func handleUpdateCharacterImage(c fiber.Ctx) error {
resourceIdStr := c.Params("resourceId")
characterIdStr := c.Params("characterId")
if resourceIdStr == "" || characterIdStr == "" {
return model.NewRequestError("Resource ID and Character ID are required")
}
resourceId, err := strconv.Atoi(resourceIdStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
characterId, err := strconv.Atoi(characterIdStr)
if err != nil {
return model.NewRequestError("Invalid character ID")
}
var params struct {
ImageID uint `json:"image_id"`
}
body := c.Body()
err = json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a character")
}
err = service.UpdateCharacterImage(uid, uint(resourceId), uint(characterId), params.ImageID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Character image updated successfully",
})
}
func AddResourceRoutes(api fiber.Router) { func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource") resource := api.Group("/resource")
{ {
@@ -306,11 +347,12 @@ func AddResourceRoutes(api fiber.Router) {
resource.Get("/", handleListResources) resource.Get("/", handleListResources)
resource.Get("/random", handleGetRandomResource) resource.Get("/random", handleGetRandomResource)
resource.Get("/pinned", handleGetPinnedResources) resource.Get("/pinned", handleGetPinnedResources)
resource.Get("/vndb/characters", handleGetCharactorsFromVndb) resource.Get("/vndb/characters", handleGetCharactersFromVndb)
resource.Get("/:id", handleGetResource) resource.Get("/:id", handleGetResource)
resource.Delete("/:id", handleDeleteResource) resource.Delete("/:id", handleDeleteResource)
resource.Get("/tag/:tag", handleListResourcesWithTag) resource.Get("/tag/:tag", handleListResourcesWithTag)
resource.Get("/user/:username", handleGetResourcesWithUser) resource.Get("/user/:username", handleGetResourcesWithUser)
resource.Post("/:id", handleUpdateResource) resource.Post("/:id", handleUpdateResource)
resource.Put("/:resourceId/character/:characterId/image", handleUpdateCharacterImage)
} }
} }

View File

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

View File

@@ -47,7 +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 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 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 collection_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM charactors WHERE image_id = images.id)"). Where("NOT EXISTS (SELECT 1 FROM characters WHERE image_id = images.id)").
Where("created_at < ?", oneDayAgo). Where("created_at < ?", oneDayAgo).
Find(&images).Error; err != nil { Find(&images).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -16,14 +16,18 @@ import (
func CreateResource(r model.Resource) (model.Resource, error) { func CreateResource(r model.Resource) (model.Resource, error) {
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
r.ModifiedTime = time.Now() r.ModifiedTime = time.Now()
charactors := r.Charactors characters := r.Characters
r.Charactors = nil r.Characters = nil
err := tx.Create(&r).Error err := tx.Create(&r).Error
if err != nil { if err != nil {
return err return err
} }
for _, c := range charactors { for _, c := range characters {
c.ResourceID = r.ID c.ResourceID = r.ID
// If ImageID is 0, set it to nil to avoid foreign key constraint error
if c.ImageID != nil && *c.ImageID == 0 {
c.ImageID = nil
}
if err := tx.Create(&c).Error; err != nil { if err := tx.Create(&c).Error; err != nil {
return err return err
} }
@@ -50,7 +54,7 @@ func GetResourceByID(id uint) (model.Resource, error) {
Preload("Files"). Preload("Files").
Preload("Files.User"). Preload("Files.User").
Preload("Files.Storage"). Preload("Files.Storage").
Preload("Charactors"). Preload("Characters").
First(&r, id).Error; err != nil { First(&r, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Resource{}, model.NewNotFoundError("Resource not found") return model.Resource{}, model.NewNotFoundError("Resource not found")
@@ -111,14 +115,14 @@ func UpdateResource(r model.Resource) error {
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
images := r.Images images := r.Images
tags := r.Tags tags := r.Tags
charactors := r.Charactors characters := r.Characters
r.Charactors = nil r.Characters = nil
r.Images = nil r.Images = nil
r.Tags = nil r.Tags = nil
r.Files = nil r.Files = nil
r.ModifiedTime = time.Now() r.ModifiedTime = time.Now()
oldCharactors := []model.Charactor{} oldCharacters := []model.Character{}
if err := db.Model(&model.Charactor{}).Where("resource_id = ?", r.ID).Find(&oldCharactors).Error; err != nil { if err := db.Model(&model.Character{}).Where("resource_id = ?", r.ID).Find(&oldCharacters).Error; err != nil {
return err return err
} }
if err := db.Save(&r).Error; err != nil { if err := db.Save(&r).Error; err != nil {
@@ -130,9 +134,9 @@ func UpdateResource(r model.Resource) error {
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil { if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
return err return err
} }
for _, c := range oldCharactors { for _, c := range oldCharacters {
shouldDelete := true shouldDelete := true
for _, nc := range charactors { for _, nc := range characters {
if c.ID == nc.ID { if c.ID == nc.ID {
shouldDelete = false shouldDelete = false
break break
@@ -144,9 +148,9 @@ func UpdateResource(r model.Resource) error {
} }
} }
} }
for _, c := range charactors { for _, c := range characters {
shouldAdd := true shouldAdd := true
for _, oc := range oldCharactors { for _, oc := range oldCharacters {
if c.Equal(&oc) { if c.Equal(&oc) {
shouldAdd = false shouldAdd = false
break break
@@ -155,6 +159,10 @@ func UpdateResource(r model.Resource) error {
if shouldAdd { if shouldAdd {
c.ID = 0 c.ID = 0
c.ResourceID = r.ID c.ResourceID = r.ID
// If ImageID is 0, set it to nil to avoid foreign key constraint error
if c.ImageID != nil && *c.ImageID == 0 {
c.ImageID = nil
}
if err := tx.Create(&c).Error; err != nil { if err := tx.Create(&c).Error; err != nil {
return err return err
} }
@@ -517,3 +525,22 @@ func CountResources() (int64, error) {
} }
return count, nil return count, nil
} }
// UpdateCharacterImage 更新角色的图片ID
func UpdateCharacterImage(characterID, imageID uint) error {
var updateValue interface{}
if imageID == 0 {
updateValue = nil
} else {
updateValue = imageID
}
result := db.Model(&model.Character{}).Where("id = ?", characterID).Update("image_id", updateValue)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.NewNotFoundError("Character not found")
}
return nil
}

View File

@@ -1,35 +1,49 @@
package model package model
type Charactor struct { type Character struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"` Name string `gorm:"type:varchar(100);not null"`
Alias []string `gorm:"serializer:json"` Alias []string `gorm:"serializer:json"`
CV string `gorm:"type:varchar(100)"` CV string `gorm:"type:varchar(100)"`
ImageID uint Role string `gorm:"type:varchar(20);default:primary"`
ImageID *uint
ResourceID uint ResourceID uint
Image *Image `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` Image *Image `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
} }
type CharactorView struct { type CharacterView struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Alias []string `json:"alias"` Alias []string `json:"alias"`
CV string `json:"cv"` CV string `json:"cv"`
Role string `json:"role"`
Image uint `json:"image"` Image uint `json:"image"`
} }
func (c *Charactor) ToView() *CharactorView { func (c *Character) ToView() *CharacterView {
return &CharactorView{ var imageID uint
if c.ImageID != nil {
imageID = *c.ImageID
}
return &CharacterView{
Id: c.ID, Id: c.ID,
Name: c.Name, Name: c.Name,
Alias: c.Alias, Alias: c.Alias,
CV: c.CV, CV: c.CV,
Image: c.ImageID, Role: c.Role,
Image: imageID,
} }
} }
func (c *Charactor) Equal(other *Charactor) bool { func (c *Character) Equal(other *Character) bool {
if c.Name != other.Name || c.CV != other.CV || c.ImageID != other.ImageID { if c.Name != other.Name || c.CV != other.CV || c.Role != other.Role {
return false
}
// Compare ImageID pointers
if (c.ImageID == nil) != (other.ImageID == nil) {
return false
}
if c.ImageID != nil && other.ImageID != nil && *c.ImageID != *other.ImageID {
return false return false
} }
if len(c.Alias) != len(other.Alias) { if len(c.Alias) != len(other.Alias) {

View File

@@ -23,7 +23,7 @@ type Resource struct {
ModifiedTime time.Time ModifiedTime time.Time
Gallery []uint `gorm:"serializer:json"` Gallery []uint `gorm:"serializer:json"`
GalleryNsfw []uint `gorm:"serializer:json"` GalleryNsfw []uint `gorm:"serializer:json"`
Charactors []Charactor `gorm:"foreignKey:ResourceID"` Characters []Character `gorm:"foreignKey:ResourceID"`
} }
type Link struct { type Link struct {
@@ -57,7 +57,7 @@ type ResourceDetailView struct {
Related []ResourceView `json:"related"` Related []ResourceView `json:"related"`
Gallery []uint `json:"gallery"` Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"galleryNsfw"` GalleryNsfw []uint `json:"galleryNsfw"`
Charactors []CharactorView `json:"charactors"` Characters []CharacterView `json:"characters"`
} }
func (r *Resource) ToView() ResourceView { func (r *Resource) ToView() ResourceView {
@@ -96,9 +96,9 @@ func (r *Resource) ToDetailView() ResourceDetailView {
for i, file := range r.Files { for i, file := range r.Files {
files[i] = *file.ToView() files[i] = *file.ToView()
} }
charactors := make([]CharactorView, len(r.Charactors)) characters := make([]CharacterView, len(r.Characters))
for i, charactor := range r.Charactors { for i, character := range r.Characters {
charactors[i] = *charactor.ToView() characters[i] = *character.ToView()
} }
return ResourceDetailView{ return ResourceDetailView{
ID: r.ID, ID: r.ID,
@@ -116,6 +116,6 @@ func (r *Resource) ToDetailView() ResourceDetailView {
Comments: r.Comments, Comments: r.Comments,
Gallery: r.Gallery, Gallery: r.Gallery,
GalleryNsfw: r.GalleryNsfw, GalleryNsfw: r.GalleryNsfw,
Charactors: charactors, Characters: characters,
} }
} }

View File

@@ -35,13 +35,14 @@ type ResourceParams struct {
Images []uint `json:"images"` Images []uint `json:"images"`
Gallery []uint `json:"gallery"` Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"gallery_nsfw"` GalleryNsfw []uint `json:"gallery_nsfw"`
Charactors []CharactorParams `json:"characters"` Characters []CharacterParams `json:"characters"`
} }
type CharactorParams struct { type CharacterParams struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Alias []string `json:"alias"` Alias []string `json:"alias"`
CV string `json:"cv"` CV string `json:"cv"`
Role string `json:"role"`
Image uint `json:"image"` Image uint `json:"image"`
} }
@@ -82,13 +83,22 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
nsfw = append(nsfw, id) nsfw = append(nsfw, id)
} }
} }
charactors := make([]model.Charactor, len(params.Charactors)) characters := make([]model.Character, len(params.Characters))
for i, c := range params.Charactors { for i, c := range params.Characters {
charactors[i] = model.Charactor{ role := c.Role
if role == "" {
role = "primary"
}
var imageID *uint
if c.Image != 0 {
imageID = &c.Image
}
characters[i] = model.Character{
Name: c.Name, Name: c.Name,
Alias: c.Alias, Alias: c.Alias,
CV: c.CV, CV: c.CV,
ImageID: c.Image, Role: role,
ImageID: imageID,
} }
} }
r := model.Resource{ r := model.Resource{
@@ -101,7 +111,7 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
UserID: uid, UserID: uid,
Gallery: gallery, Gallery: gallery,
GalleryNsfw: nsfw, GalleryNsfw: nsfw,
Charactors: charactors, Characters: characters,
} }
if r, err = dao.CreateResource(r); err != nil { if r, err = dao.CreateResource(r); err != nil {
return 0, err return 0, err
@@ -500,13 +510,22 @@ func UpdateResource(uid, rid uint, params *ResourceParams) error {
nsfw = append(nsfw, id) nsfw = append(nsfw, id)
} }
} }
charactors := make([]model.Charactor, len(params.Charactors)) characters := make([]model.Character, len(params.Characters))
for i, c := range params.Charactors { for i, c := range params.Characters {
charactors[i] = model.Charactor{ role := c.Role
if role == "" {
role = "primary"
}
var imageID *uint
if c.Image != 0 {
imageID = &c.Image
}
characters[i] = model.Character{
Name: c.Name, Name: c.Name,
Alias: c.Alias, Alias: c.Alias,
CV: c.CV, CV: c.CV,
ImageID: c.Image, Role: role,
ImageID: imageID,
} }
} }
@@ -516,7 +535,7 @@ func UpdateResource(uid, rid uint, params *ResourceParams) error {
r.Links = params.Links r.Links = params.Links
r.Gallery = gallery r.Gallery = gallery
r.GalleryNsfw = nsfw r.GalleryNsfw = nsfw
r.Charactors = charactors r.Characters = characters
images := make([]model.Image, len(params.Images)) images := make([]model.Image, len(params.Images))
for i, id := range params.Images { for i, id := range params.Images {
@@ -596,7 +615,7 @@ func GetPinnedResources() ([]model.ResourceView, error) {
return views, nil return views, nil
} }
func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) { func GetCharactersFromVndb(vnID string) ([]CharacterParams, error) {
client := http.Client{} client := http.Client{}
jsonStr := fmt.Sprintf(` jsonStr := fmt.Sprintf(`
{ {
@@ -647,26 +666,24 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
} }
if len(vndbResp.Results) == 0 { if len(vndbResp.Results) == 0 {
return []CharactorParams{}, nil return []CharacterParams{}, nil
} }
result := vndbResp.Results[0] result := vndbResp.Results[0]
var charactors []CharactorParams var characters []CharacterParams
processedCharacters := make(map[string]bool) // 避免重复角色 processedCharacters := make(map[string]bool) // 避免重复角色
// 遍历声优信息 // 遍历声优信息
for _, va := range result.VA { for _, va := range result.VA {
// 检查角色是否为主要角色 role := "Unknown"
isPrimary := false
for _, vn := range va.Character.VNS { for _, vn := range va.Character.VNS {
if vn.Role == "primary" { if vn.ID == vnID {
isPrimary = true role = vn.Role
break break
} }
} }
// 只处理主要角色 if role != "primary" && role != "side" {
if !isPrimary {
continue continue
} }
@@ -691,10 +708,11 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
cvName = va.Staff.Name cvName = va.Staff.Name
} }
charactor := CharactorParams{ character := CharacterParams{
Name: characterName, Name: characterName,
Alias: []string{}, // 按要求不添加别名 Alias: []string{},
CV: cvName, CV: cvName,
Role: role,
Image: 0, // 默认值,下面会下载图片 Image: 0, // 默认值,下面会下载图片
} }
@@ -705,14 +723,14 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
log.Error("Failed to download character image:", err) log.Error("Failed to download character image:", err)
// 继续处理,即使图片下载失败 // 继续处理,即使图片下载失败
} else { } else {
charactor.Image = imageID character.Image = imageID
} }
} }
charactors = append(charactors, charactor) characters = append(characters, character)
} }
return charactors, nil return characters, nil
} }
// downloadAndCreateImage 下载图片并使用 CreateImage 保存 // downloadAndCreateImage 下载图片并使用 CreateImage 保存
@@ -753,3 +771,33 @@ func downloadAndCreateImage(imageURL string) (uint, error) {
return imageID, nil return imageID, nil
} }
// UpdateCharacterImage 更新角色的图片ID
func UpdateCharacterImage(uid, resourceID, characterID, imageID uint) error {
// 检查资源是否存在并且用户有权限修改
resource, err := dao.GetResourceByID(resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return model.NewNotFoundError("Resource not found")
}
return err
}
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
return err
}
// 检查用户是否有权限修改这个资源
if resource.UserID != uid && !isAdmin {
return model.NewUnAuthorizedError("You don't have permission to modify this resource")
}
// 更新角色图片
err = dao.UpdateCharacterImage(characterID, imageID)
if err != nil {
return err
}
return nil
}