mirror of
https://github.com/wgh136/nysoure.git
synced 2025-12-16 15:51:14 +00:00
Compare commits
5 Commits
5cd708454a
...
0a3e255dfe
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3e255dfe | |||
| ec85ee3e82 | |||
| 3fec078ba6 | |||
| 3e953e22b0 | |||
| c9c8eac734 |
@@ -22,7 +22,6 @@
|
|||||||
"masonic": "^4.1.0",
|
"masonic": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18next": "^15.5.1",
|
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.5.3",
|
"react-router": "^7.5.3",
|
||||||
|
|||||||
BIN
frontend/public/cp.webp
Normal file
BIN
frontend/public/cp.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
131
frontend/src/components/charactor_edit.tsx
Normal file
131
frontend/src/components/charactor_edit.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -256,6 +256,8 @@ export const i18nData = {
|
|||||||
"Private": "私有",
|
"Private": "私有",
|
||||||
"View {count} more replies": "查看另外 {count} 条回复",
|
"View {count} more replies": "查看另外 {count} 条回复",
|
||||||
"Survival time": "存活时间",
|
"Survival time": "存活时间",
|
||||||
|
"Characters": "角色",
|
||||||
|
"Aliases (one per line)": "别名(每行一个)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"zh-TW": {
|
"zh-TW": {
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export interface CreateResourceParams {
|
|||||||
images: number[];
|
images: number[];
|
||||||
gallery: number[];
|
gallery: number[];
|
||||||
gallery_nsfw: number[];
|
gallery_nsfw: number[];
|
||||||
|
characters: CharacterParams[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterParams {
|
||||||
|
name: string;
|
||||||
|
alias: string[];
|
||||||
|
cv: string;
|
||||||
|
image: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Image {
|
export interface Image {
|
||||||
@@ -88,6 +96,7 @@ export interface ResourceDetails {
|
|||||||
related: Resource[];
|
related: Resource[];
|
||||||
gallery: number[];
|
gallery: number[];
|
||||||
galleryNsfw: number[];
|
galleryNsfw: number[];
|
||||||
|
charactors: CharacterParams[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Storage {
|
export interface Storage {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
CommentWithRef,
|
CommentWithRef,
|
||||||
Collection,
|
Collection,
|
||||||
Statistics,
|
Statistics,
|
||||||
|
CharacterParams,
|
||||||
} from "./models.ts";
|
} from "./models.ts";
|
||||||
|
|
||||||
class Network {
|
class Network {
|
||||||
@@ -802,6 +803,14 @@ class Network {
|
|||||||
axios.get(`${this.apiBaseUrl}/config/statistics`),
|
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();
|
export const network = new Network();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
MdDelete,
|
MdDelete,
|
||||||
MdOutlineInfo,
|
MdOutlineInfo,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { Tag } from "../network/models.ts";
|
import { CharacterParams, Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
@@ -20,6 +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";
|
||||||
|
|
||||||
export default function EditResourcePage() {
|
export default function EditResourcePage() {
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
@@ -30,6 +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 [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);
|
||||||
@@ -59,6 +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 ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
showToast({ message: t("Failed to load resource"), type: "error" });
|
showToast({ message: t("Failed to load resource"), type: "error" });
|
||||||
@@ -104,6 +107,7 @@ export default function EditResourcePage() {
|
|||||||
links: links,
|
links: links,
|
||||||
gallery: galleryImages,
|
gallery: galleryImages,
|
||||||
gallery_nsfw: galleryNsfw,
|
gallery_nsfw: galleryNsfw,
|
||||||
|
characters: charactors,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -420,6 +424,50 @@ export default function EditResourcePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={"h-4"}></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 && (
|
{error && (
|
||||||
<div role="alert" className="alert alert-error my-2 shadow">
|
<div role="alert" className="alert alert-error my-2 shadow">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
MdDelete,
|
MdDelete,
|
||||||
MdOutlineInfo,
|
MdOutlineInfo,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { Tag } from "../network/models.ts";
|
import { CharacterParams, Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTranslation } from "../utils/i18n";
|
import { useTranslation } from "../utils/i18n";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SelectAndUploadImageButton,
|
SelectAndUploadImageButton,
|
||||||
UploadClipboardImageButton,
|
UploadClipboardImageButton,
|
||||||
} from "../components/image_selector.tsx";
|
} from "../components/image_selector.tsx";
|
||||||
|
import CharactorEditor from "../components/charactor_edit.tsx";
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
@@ -31,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 isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +111,7 @@ export default function PublishPage() {
|
|||||||
links: links,
|
links: links,
|
||||||
gallery: galleryImages,
|
gallery: galleryImages,
|
||||||
gallery_nsfw: galleryNsfw,
|
gallery_nsfw: galleryNsfw,
|
||||||
|
characters: charactors,
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
localStorage.removeItem("publish_data");
|
localStorage.removeItem("publish_data");
|
||||||
@@ -429,6 +431,39 @@ export default function PublishPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={"h-4"}></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 && (
|
{error && (
|
||||||
<div role="alert" className="alert alert-error my-2 shadow">
|
<div role="alert" className="alert alert-error my-2 shadow">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Resource,
|
Resource,
|
||||||
Collection,
|
Collection,
|
||||||
|
CharacterParams,
|
||||||
} from "../network/models.ts";
|
} from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
@@ -469,6 +470,7 @@ const context = createContext<() => void>(() => {});
|
|||||||
|
|
||||||
function Article({ resource }: { resource: ResourceDetails }) {
|
function Article({ resource }: { resource: ResourceDetails }) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<article>
|
<article>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
@@ -597,6 +599,9 @@ function Article({ resource }: { resource: ResourceDetails }) {
|
|||||||
{resource.article.replaceAll("\n", " \n")}
|
{resource.article.replaceAll("\n", " \n")}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</article>
|
</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 = [];
|
nsfw = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果图片数量超过8张,显示数字而不是圆点
|
||||||
|
const showDots = images.length <= 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dialog
|
<dialog
|
||||||
@@ -2011,21 +2019,31 @@ function Gallery({ images, nsfw }: { images: number[], nsfw: number[] }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 底部圆点 */}
|
{/* 底部指示器 */}
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||||
{images.map((_, index) => (
|
{showDots ? (
|
||||||
<button
|
/* 圆点指示器 */
|
||||||
key={index}
|
<div className="flex gap-2">
|
||||||
className={`w-2 h-2 rounded-full transition-all ${
|
{images.map((_, index) => (
|
||||||
index === currentIndex
|
<button
|
||||||
? "bg-primary w-4"
|
key={index}
|
||||||
: "bg-base-content/30 hover:bg-base-content/50"
|
className={`w-2 h-2 rounded-full transition-all ${
|
||||||
}`}
|
index === currentIndex
|
||||||
onClick={() => goToIndex(index)}
|
? "bg-primary w-4"
|
||||||
aria-label={`Go to image ${index + 1}`}
|
: "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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2058,4 +2076,59 @@ function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ func handleUpdateResource(c fiber.Ctx) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return model.NewUnAuthorizedError("You must be logged in to update a resource")
|
return model.NewUnAuthorizedError("You must be logged in to update a resource")
|
||||||
}
|
}
|
||||||
err = service.EditResource(uid, uint(id), ¶ms)
|
err = service.UpdateResource(uid, uint(id), ¶ms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) {
|
func AddResourceRoutes(api fiber.Router) {
|
||||||
resource := api.Group("/resource")
|
resource := api.Group("/resource")
|
||||||
{
|
{
|
||||||
@@ -290,6 +306,7 @@ 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("/:id", handleGetResource)
|
resource.Get("/:id", handleGetResource)
|
||||||
resource.Delete("/:id", handleDeleteResource)
|
resource.Delete("/:id", handleDeleteResource)
|
||||||
resource.Get("/tag/:tag", handleListResourcesWithTag)
|
resource.Get("/tag/:tag", handleListResourcesWithTag)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func init() {
|
|||||||
&model.Activity{},
|
&model.Activity{},
|
||||||
&model.Collection{},
|
&model.Collection{},
|
||||||
&model.CollectionResource{},
|
&model.CollectionResource{},
|
||||||
|
&model.Charactor{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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("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) {
|
||||||
|
|||||||
@@ -16,10 +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
|
||||||
|
r.Charactors = 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 {
|
||||||
|
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 {
|
if err := tx.Model(&model.User{}).Where("id = ?", r.UserID).Update("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -42,6 +50,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").
|
||||||
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")
|
||||||
@@ -99,22 +108,60 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
|
|||||||
|
|
||||||
func UpdateResource(r model.Resource) error {
|
func UpdateResource(r model.Resource) error {
|
||||||
// Update a resource in the database
|
// Update a resource in the database
|
||||||
images := r.Images
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
tags := r.Tags
|
images := r.Images
|
||||||
r.Images = nil
|
tags := r.Tags
|
||||||
r.Tags = nil
|
charactors := r.Charactors
|
||||||
r.Files = nil
|
r.Charactors = nil
|
||||||
r.ModifiedTime = time.Now()
|
r.Images = nil
|
||||||
if err := db.Save(&r).Error; err != nil {
|
r.Tags = nil
|
||||||
return err
|
r.Files = nil
|
||||||
}
|
r.ModifiedTime = time.Now()
|
||||||
if err := db.Model(&r).Association("Images").Replace(images); err != nil {
|
oldCharactors := []model.Charactor{}
|
||||||
return err
|
if err := db.Model(&model.Charactor{}).Where("resource_id = ?", r.ID).Find(&oldCharactors).Error; err != nil {
|
||||||
}
|
return err
|
||||||
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
|
}
|
||||||
return err
|
if err := db.Save(&r).Error; err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
|
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 {
|
func DeleteResource(id uint) error {
|
||||||
|
|||||||
44
server/model/charactor.go
Normal file
44
server/model/charactor.go
Normal 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
|
||||||
|
}
|
||||||
@@ -21,8 +21,9 @@ type Resource struct {
|
|||||||
Downloads uint
|
Downloads uint
|
||||||
Comments uint
|
Comments uint
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
@@ -40,22 +41,23 @@ type ResourceView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResourceDetailView struct {
|
type ResourceDetailView struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AlternativeTitles []string `json:"alternativeTitles"`
|
AlternativeTitles []string `json:"alternativeTitles"`
|
||||||
Links []Link `json:"links"`
|
Links []Link `json:"links"`
|
||||||
Article string `json:"article"`
|
Article string `json:"article"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Tags []TagView `json:"tags"`
|
Tags []TagView `json:"tags"`
|
||||||
Images []ImageView `json:"images"`
|
Images []ImageView `json:"images"`
|
||||||
Files []FileView `json:"files"`
|
Files []FileView `json:"files"`
|
||||||
Author UserView `json:"author"`
|
Author UserView `json:"author"`
|
||||||
Views uint `json:"views"`
|
Views uint `json:"views"`
|
||||||
Downloads uint `json:"downloads"`
|
Downloads uint `json:"downloads"`
|
||||||
Comments uint `json:"comments"`
|
Comments uint `json:"comments"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resource) ToView() ResourceView {
|
func (r *Resource) ToView() ResourceView {
|
||||||
@@ -94,6 +96,10 @@ 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))
|
||||||
|
for i, charactor := range r.Charactors {
|
||||||
|
charactors[i] = *charactor.ToView()
|
||||||
|
}
|
||||||
return ResourceDetailView{
|
return ResourceDetailView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
@@ -110,5 +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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"nysoure/server/config"
|
"nysoure/server/config"
|
||||||
"nysoure/server/dao"
|
"nysoure/server/dao"
|
||||||
@@ -10,6 +14,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"github.com/gofiber/fiber/v3/log"
|
"github.com/gofiber/fiber/v3/log"
|
||||||
@@ -22,14 +27,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ResourceParams struct {
|
type ResourceParams struct {
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
AlternativeTitles []string `json:"alternative_titles"`
|
AlternativeTitles []string `json:"alternative_titles"`
|
||||||
Links []model.Link `json:"links"`
|
Links []model.Link `json:"links"`
|
||||||
Tags []uint `json:"tags"`
|
Tags []uint `json:"tags"`
|
||||||
Article string `json:"article"`
|
Article string `json:"article"`
|
||||||
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
||||||
@@ -69,6 +82,15 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
|||||||
nsfw = append(nsfw, id)
|
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{
|
r := model.Resource{
|
||||||
Title: params.Title,
|
Title: params.Title,
|
||||||
AlternativeTitles: params.AlternativeTitles,
|
AlternativeTitles: params.AlternativeTitles,
|
||||||
@@ -79,6 +101,7 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
|||||||
UserID: uid,
|
UserID: uid,
|
||||||
Gallery: gallery,
|
Gallery: gallery,
|
||||||
GalleryNsfw: nsfw,
|
GalleryNsfw: nsfw,
|
||||||
|
Charactors: charactors,
|
||||||
}
|
}
|
||||||
if r, err = dao.CreateResource(r); err != nil {
|
if r, err = dao.CreateResource(r); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -451,7 +474,7 @@ func GetResourcesWithUser(username string, page int) ([]model.ResourceView, int,
|
|||||||
return views, totalPages, nil
|
return views, totalPages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func EditResource(uid, rid uint, params *ResourceParams) error {
|
func UpdateResource(uid, rid uint, params *ResourceParams) error {
|
||||||
isAdmin, err := checkUserCanUpload(uid)
|
isAdmin, err := checkUserCanUpload(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("checkUserCanUpload error: ", err)
|
log.Error("checkUserCanUpload error: ", err)
|
||||||
@@ -477,6 +500,15 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
|
|||||||
nsfw = append(nsfw, id)
|
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.Title = params.Title
|
||||||
r.AlternativeTitles = params.AlternativeTitles
|
r.AlternativeTitles = params.AlternativeTitles
|
||||||
@@ -484,6 +516,7 @@ func EditResource(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
|
||||||
|
|
||||||
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 {
|
||||||
@@ -562,3 +595,161 @@ func GetPinnedResources() ([]model.ResourceView, error) {
|
|||||||
}
|
}
|
||||||
return views, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user