feat(items): add server field and improve item management
refactor(ai): enhance item structure generation with schema validation feat(ai): add token usage tracking and cost calculation fix(ui): improve item card number formatting with locale support docs(items): add documentation for item value separation test(items): add tests for value field validation
This commit is contained in:
1 parent
dc169f9eba
commit
d3704602fd
22 files changed
+1317
-95
No files matched your search
@@ -6,6 +6,7 @@ import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePag
|
||||
import { getSession } from "@/lib/redis/session";
|
||||
import { getUserById } from "@/lib/prisma/queries/users";
|
||||
import { searchAdminItems } from "@/lib/prisma/queries/items";
|
||||
import { getActiveServers } from "@/lib/prisma/queries/servers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
@@ -38,7 +39,10 @@ export default async function AdminNewItemPage({
|
||||
redirect(`/${locale}`);
|
||||
}
|
||||
|
||||
const initialItems = await searchAdminItems({ page: 1, pageSize: 1 });
|
||||
const [initialItems, activeServers] = await Promise.all([
|
||||
searchAdminItems({ page: 1, pageSize: 1 }),
|
||||
getActiveServers(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -47,7 +51,7 @@ export default async function AdminNewItemPage({
|
||||
<AdminItemCreatePage
|
||||
locale={locale}
|
||||
categories={initialItems.categories ?? []}
|
||||
serverOptions={initialItems.servers ?? []}
|
||||
serverOptions={(activeServers ?? []).map((s) => s.server_name)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAdminUser } from "@/lib/auth/middleware";
|
||||
import { generateItemStructure } from "@/lib/ai/generate-item-structure";
|
||||
import { calculateTokenCost } from "@/lib/ai/token-pricing";
|
||||
import { recordTokenUsage } from "@/lib/ai/token-usage";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminUser(request);
|
||||
@@ -26,12 +28,16 @@ export async function POST(request: NextRequest) {
|
||||
const images: string[] = Array.isArray(body?.images)
|
||||
? body.images.filter((item: unknown) => typeof item === "string")
|
||||
: [];
|
||||
const hasDescription = !!description;
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
if (!name || !description) {
|
||||
// Nova regra: aceitar quando houver descrição OU ao menos uma imagem.
|
||||
// Nome pode ser vazio; o assistente do front lida com sugestão/confirmar_nome.
|
||||
if (!hasDescription && !hasImages) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Informe nome e descrição para gerar o JSON estrutural.",
|
||||
error: "Forneça descrição ou ao menos uma imagem para análise.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -57,10 +63,46 @@ export async function POST(request: NextRequest) {
|
||||
.filter((image) => image.base64Data),
|
||||
});
|
||||
|
||||
// Cálculo de custo baseado em usageMetadata
|
||||
const model = (result.model ?? "gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING;
|
||||
const breakdown = calculateTokenCost("gemini-2.5-flash", result.usage);
|
||||
|
||||
// Persistir histórico para relatórios (não bloqueia resposta; erros são logados no Redis util)
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
try {
|
||||
await recordTokenUsage({
|
||||
id: requestId,
|
||||
timestamp: Date.now(),
|
||||
model: result.model ?? "gemini-2.5-flash",
|
||||
inputTokens: breakdown.inputTokens,
|
||||
outputTokens: breakdown.outputTokens,
|
||||
totalTokens: breakdown.totalTokens,
|
||||
costUsd: breakdown.totalUsd,
|
||||
context: {
|
||||
route: "/api/admin/items/analyze",
|
||||
scope,
|
||||
server,
|
||||
hasImages,
|
||||
hasDescription,
|
||||
categoriesCount: categories.length,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Falhas de Redis não devem impedir a análise
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
structure: result.structure,
|
||||
usage: {
|
||||
promptTokens: breakdown.inputTokens,
|
||||
outputTokens: breakdown.outputTokens,
|
||||
totalTokens: breakdown.totalTokens,
|
||||
},
|
||||
costUsd: breakdown.totalUsd,
|
||||
model: result.model ?? "gemini-2.5-flash",
|
||||
requestId,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
@@ -129,6 +129,7 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||
const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : "";
|
||||
const originServer = typeof body?.server === "string" ? body.server.trim() || null : null;
|
||||
|
||||
if (!name || !category) {
|
||||
return NextResponse.json(
|
||||
@@ -150,11 +151,19 @@ export async function POST(request: NextRequest) {
|
||||
...dynamicFields,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// Se enviado servidor de origem, inclui na linha do SQLite e garante presença na lista de servidores do item
|
||||
if (originServer) {
|
||||
sqliteData.server = originServer;
|
||||
}
|
||||
|
||||
const sqliteId = insertCategoryItem({ category, data: sqliteData });
|
||||
|
||||
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {};
|
||||
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
|
||||
|
||||
const itemServers = parseList(body?.servers);
|
||||
const finalServers = originServer ? Array.from(new Set([originServer, ...itemServers])) : itemServers;
|
||||
|
||||
const item = await createAdminItem({
|
||||
item_name: name,
|
||||
category,
|
||||
@@ -165,7 +174,7 @@ export async function POST(request: NextRequest) {
|
||||
weight: typeof body?.weight === "number" ? body.weight : null,
|
||||
slots: typeof body?.slots === "number" ? body.slots : null,
|
||||
skill: typeof body?.skill === "string" ? body.skill.trim() || null : null,
|
||||
servers: parseList(body?.servers),
|
||||
servers: finalServers,
|
||||
tags: parseList(body?.tags),
|
||||
description:
|
||||
typeof body?.description === "string" ? body.description.trim() || null : null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
|
||||
tags: payload.tags,
|
||||
metadata: payload.metadata,
|
||||
fields: payload.fields,
|
||||
server: payload.server ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/admin/items", {
|
||||
|
||||
@@ -32,6 +32,7 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
|
||||
tags: payload.tags,
|
||||
metadata: payload.metadata,
|
||||
fields: payload.fields,
|
||||
server: payload.server ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/admin/items", {
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface ItemFormPayload {
|
||||
tags: string[];
|
||||
metadata?: Record<string, any>;
|
||||
fields?: Record<string, string | boolean | null>;
|
||||
server?: string | null;
|
||||
}
|
||||
|
||||
interface ItemReference {
|
||||
@@ -190,10 +191,22 @@ export function AdminItemForm({
|
||||
const [skill, setSkill] = useState("");
|
||||
const [serversCsv, setServersCsv] = useState("");
|
||||
const [tagsCsv, setTagsCsv] = useState("");
|
||||
const [originServer, setOriginServer] = useState<string>("");
|
||||
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult>(null);
|
||||
const [analysisLoading, setAnalysisLoading] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [tokenUsage, setTokenUsage] = useState<{
|
||||
promptTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
} | null>(null);
|
||||
const [accumulatedCostUsd, setAccumulatedCostUsd] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 0;
|
||||
const stored = window.localStorage.getItem("aiCostAccumulatedUsd");
|
||||
return stored ? Number(stored) || 0 : 0;
|
||||
});
|
||||
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
|
||||
|
||||
const [categoryTemplates, setCategoryTemplates] = useState<CategoryTemplate[]>([]);
|
||||
@@ -216,6 +229,27 @@ export function AdminItemForm({
|
||||
const dynamicInitializedRef = useRef(false);
|
||||
const lastCategoryRef = useRef<string | null>(null);
|
||||
const [pendingPrefill, setPendingPrefill] = useState<PrefillPayload | null>(null);
|
||||
const [unsavedFields, setUnsavedFields] = useState<Set<string>>(new Set());
|
||||
const [showPrefillModal, setShowPrefillModal] = useState(false);
|
||||
type DiffEntry = { field: string; current: string | null; next: string | null };
|
||||
const [prefillDiff, setPrefillDiff] = useState<{ changed: DiffEntry[]; added: DiffEntry[]; unchanged: DiffEntry[] }>({
|
||||
changed: [],
|
||||
added: [],
|
||||
unchanged: [],
|
||||
});
|
||||
const [showExitConfirm, setShowExitConfirm] = useState(false);
|
||||
const [aiSuggestedName, setAiSuggestedName] = useState(false);
|
||||
const [confirmNome, setConfirmNome] = useState(false);
|
||||
|
||||
const markUnsaved = useCallback((key: string) => {
|
||||
setUnsavedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isUnsaved = useCallback((key: string) => unsavedFields.has(key), [unsavedFields]);
|
||||
|
||||
const resetFormState = useCallback(
|
||||
(templateList: CategoryTemplate[]) => {
|
||||
@@ -415,7 +449,7 @@ export function AdminItemForm({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
scope: serverOptions.length > 0 ? "server" : "global",
|
||||
server: serverOptions[0] ?? null,
|
||||
server: originServer || (serverOptions[0] ?? null),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -425,6 +459,23 @@ export function AdminItemForm({
|
||||
|
||||
const data = await res.json();
|
||||
setAnalysis(data.structure ?? null);
|
||||
if (data?.usage && typeof data?.costUsd === "number") {
|
||||
setTokenUsage({
|
||||
promptTokens: Number(data.usage.promptTokens || 0),
|
||||
outputTokens: Number(data.usage.outputTokens || 0),
|
||||
totalTokens: Number(data.usage.totalTokens || 0),
|
||||
costUsd: Number(data.costUsd || 0),
|
||||
});
|
||||
setAccumulatedCostUsd((prev) => {
|
||||
const next = Number(prev) + Number(data.costUsd || 0);
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("aiCostAccumulatedUsd", String(next));
|
||||
}
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setAnalysisError(
|
||||
@@ -449,6 +500,7 @@ export function AdminItemForm({
|
||||
dynamicInitializedRef.current = false;
|
||||
lastCategoryRef.current = null;
|
||||
setCategory(value.toLowerCase());
|
||||
markUnsaved("category");
|
||||
}, []);
|
||||
|
||||
const handleAddCategoryField = useCallback(() => {
|
||||
@@ -587,20 +639,134 @@ export function AdminItemForm({
|
||||
|
||||
const submitLabel = isEdit ? "Salvar alterações" : "Criar item";
|
||||
|
||||
const normalizeValue = (value: unknown): string | null => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getCurrentSnapshot = useCallback(() => {
|
||||
return {
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
imageUrl,
|
||||
imageLocalPath,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
servers: parseCsv(serversCsv),
|
||||
tags: parseCsv(tagsCsv),
|
||||
description,
|
||||
metadata: { ...prefilledMetadata, ...dynamicValues },
|
||||
server: originServer || null,
|
||||
};
|
||||
}, [name, category, rarity, imageUrl, imageLocalPath, level, weight, slots, skill, serversCsv, tagsCsv, description, prefilledMetadata, dynamicValues, originServer]);
|
||||
|
||||
const computePrefillDiff = useCallback((prefill: PrefillPayload) => {
|
||||
const current = getCurrentSnapshot();
|
||||
const changed: DiffEntry[] = [];
|
||||
const added: DiffEntry[] = [];
|
||||
const unchanged: DiffEntry[] = [];
|
||||
|
||||
const topKeys: Array<keyof PrefillPayload> = [
|
||||
"name",
|
||||
"category",
|
||||
"rarity",
|
||||
"imageUrl",
|
||||
"imageLocalPath",
|
||||
"level",
|
||||
"weight",
|
||||
"slots",
|
||||
"skill",
|
||||
"servers",
|
||||
"tags",
|
||||
"description",
|
||||
];
|
||||
|
||||
for (const key of topKeys) {
|
||||
if (prefill[key] === undefined) continue;
|
||||
const next = normalizeValue(
|
||||
key === "servers" || key === "tags"
|
||||
? Array.isArray(prefill[key])
|
||||
? (prefill[key] as string[])
|
||||
: (String(prefill[key] ?? "").split(",").map((v) => v.trim()).filter(Boolean))
|
||||
: prefill[key]
|
||||
);
|
||||
const currentVal = normalizeValue((current as any)[key]);
|
||||
const field = key === "servers" ? "servers" : key === "tags" ? "tags" : (key as string);
|
||||
if (currentVal !== next) {
|
||||
if (currentVal === null || currentVal === "") {
|
||||
added.push({ field, current: currentVal, next });
|
||||
} else {
|
||||
changed.push({ field, current: currentVal, next });
|
||||
}
|
||||
} else {
|
||||
unchanged.push({ field, current: currentVal, next });
|
||||
}
|
||||
}
|
||||
|
||||
const meta = prefill.metadata && typeof prefill.metadata === "object" ? prefill.metadata : {};
|
||||
for (const [k, v] of Object.entries(meta as Record<string, unknown>)) {
|
||||
if (k === "category") continue;
|
||||
const currentVal = normalizeValue(current.metadata[k]);
|
||||
const next = normalizeValue(v);
|
||||
const field = `meta.${k}`;
|
||||
if (currentVal !== next) {
|
||||
if (currentVal === null || currentVal === "") {
|
||||
added.push({ field, current: currentVal, next });
|
||||
} else {
|
||||
changed.push({ field, current: currentVal, next });
|
||||
}
|
||||
} else {
|
||||
unchanged.push({ field, current: currentVal, next });
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, added, unchanged };
|
||||
}, [getCurrentSnapshot]);
|
||||
|
||||
const markUnsavedFromDiff = useCallback((diff: { changed: DiffEntry[]; added: DiffEntry[] }) => {
|
||||
const next = new Set<string>();
|
||||
for (const d of [...diff.changed, ...diff.added]) {
|
||||
next.add(d.field);
|
||||
}
|
||||
setUnsavedFields(next);
|
||||
}, []);
|
||||
|
||||
const renderUnsavedTag = (field: string) =>
|
||||
unsavedFields.has(field) ? (
|
||||
<span className="ml-2 text-[10px] text-amber-600">Não salvo</span>
|
||||
) : null;
|
||||
|
||||
const handlePrimarySubmit = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
setSubmitStatus("Informe o nome do item.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiSuggestedName && !confirmNome) {
|
||||
setSubmitStatus("Confirme o nome sugerido pela IA.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category.trim()) {
|
||||
setSubmitStatus("Informe a categoria do item.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!originServer || !originServer.trim()) {
|
||||
setSubmitStatus("Selecione o servidor de origem.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitStatus(null);
|
||||
try {
|
||||
const metadataPayload: Record<string, unknown> = { ...prefilledMetadata };
|
||||
if (aiSuggestedName) {
|
||||
metadataPayload.confirmar_nome = confirmNome;
|
||||
}
|
||||
if (allowAiTools && analysis) {
|
||||
metadataPayload.aiStructure = analysis;
|
||||
}
|
||||
@@ -624,6 +790,7 @@ export function AdminItemForm({
|
||||
tags: parseCsv(tagsCsv),
|
||||
metadata: metadataPayload,
|
||||
fields: dynamicValues,
|
||||
server: originServer,
|
||||
});
|
||||
|
||||
setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso.");
|
||||
@@ -671,9 +838,39 @@ export function AdminItemForm({
|
||||
void handlePrimarySubmit();
|
||||
};
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
if (unsavedFields.size > 0) {
|
||||
setShowExitConfirm(true);
|
||||
return;
|
||||
}
|
||||
onCancel?.();
|
||||
}, [unsavedFields, onCancel]);
|
||||
|
||||
const formClassName = className ?? "flex h-full flex-col";
|
||||
const bodyClassName = contentClassName ?? "flex-1 min-h-0 overflow-y-auto px-6 py-4";
|
||||
|
||||
const [prefillLoading, setPrefillLoading] = useState(false);
|
||||
const [prefillAnimating, setPrefillAnimating] = useState(false);
|
||||
const [prefillStatus, setPrefillStatus] = useState<string | null>(null);
|
||||
|
||||
const formattedAnalysis = useMemo(() => {
|
||||
if (!analysis) return null;
|
||||
try {
|
||||
const cloned: any = JSON.parse(JSON.stringify(analysis));
|
||||
const cols = cloned?.columns;
|
||||
if (cols && typeof cols.description === "string") {
|
||||
const s = cols.description as string;
|
||||
if (s.length > 140) {
|
||||
// Insere quebras de linha após vírgulas para melhorar legibilidade
|
||||
cols.description = s.replace(/,\s+/g, ",\n");
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
} catch {
|
||||
return analysis;
|
||||
}
|
||||
}, [analysis]);
|
||||
|
||||
const applyPrefill = useCallback(
|
||||
(prefill: PrefillPayload) => {
|
||||
if (prefill.name !== undefined) setName(prefill.name ?? "");
|
||||
@@ -717,6 +914,11 @@ export function AdminItemForm({
|
||||
if (template) {
|
||||
setDynamicValues(buildDynamicInitialValues(template, prefill.metadata));
|
||||
}
|
||||
|
||||
const origem = typeof prefill.metadata?.origem_dados === "string" ? (prefill.metadata as any).origem_dados : null;
|
||||
const hasAutoName = prefill.name !== undefined && String(prefill.name ?? "").trim().length > 0;
|
||||
setAiSuggestedName(Boolean(hasAutoName && origem === "automatico"));
|
||||
setConfirmNome(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -735,13 +937,36 @@ export function AdminItemForm({
|
||||
useEffect(() => {
|
||||
if (!pendingPrefill) return;
|
||||
if (categoryTemplates.length === 0) return;
|
||||
const diff = computePrefillDiff(pendingPrefill);
|
||||
setPrefillDiff(diff);
|
||||
setShowPrefillModal(true);
|
||||
}, [pendingPrefill, categoryTemplates, computePrefillDiff]);
|
||||
|
||||
const handleConfirmPrefill = useCallback(() => {
|
||||
if (!pendingPrefill) return;
|
||||
applyPrefill(pendingPrefill);
|
||||
const diff = computePrefillDiff(pendingPrefill);
|
||||
markUnsavedFromDiff(diff);
|
||||
setPendingPrefill(null);
|
||||
}, [applyPrefill, pendingPrefill, categoryTemplates]);
|
||||
setShowPrefillModal(false);
|
||||
}, [pendingPrefill, applyPrefill, computePrefillDiff, markUnsavedFromDiff]);
|
||||
|
||||
const hasUnsaved = unsavedFields.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsaved) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [hasUnsaved]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleFormSubmit} className={formClassName}>
|
||||
<form onSubmit={handleFormSubmit} className={formClassName} aria-busy={prefillLoading} aria-live="polite">
|
||||
{headerSlot?.({ title, description: descriptionText }) ??
|
||||
(
|
||||
<div className="shrink-0 px-6 pt-6">
|
||||
@@ -754,18 +979,42 @@ export function AdminItemForm({
|
||||
<div className="grid min-w-0 gap-6 lg:grid-cols-[1.15fr,0.85fr]">
|
||||
<div className="space-y-4 min-w-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-name">Nome</Label>
|
||||
<Label htmlFor="item-name">Nome {isUnsaved("name") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-name"
|
||||
placeholder="Ex.: Final Fate"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setAiSuggestedName(false);
|
||||
setConfirmNome(false);
|
||||
markUnsaved("name");
|
||||
}}
|
||||
/>
|
||||
{aiSuggestedName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="confirm-ai-name"
|
||||
checked={confirmNome}
|
||||
onCheckedChange={(checked) => {
|
||||
setConfirmNome(Boolean(checked));
|
||||
markUnsaved("confirmar_nome");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="confirm-ai-name" className="text-xs">
|
||||
Confirmar nome sugerido pela IA
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Categoria</Label>
|
||||
<Label>Categoria {isUnsaved("category") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={category}
|
||||
@@ -823,75 +1072,110 @@ export function AdminItemForm({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-rarity">Raridade</Label>
|
||||
<Label htmlFor="item-rarity">Raridade {isUnsaved("rarity") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-rarity"
|
||||
placeholder="Ex.: legendary"
|
||||
value={rarity}
|
||||
onChange={(event) => setRarity(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setRarity(event.target.value);
|
||||
markUnsaved("rarity");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-image-url">Imagem (URL)</Label>
|
||||
<Label htmlFor="item-image-url">Imagem (URL) {isUnsaved("imageUrl") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-image-url"
|
||||
placeholder="https://..."
|
||||
value={imageUrl}
|
||||
onChange={(event) => setImageUrl(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setImageUrl(event.target.value);
|
||||
markUnsaved("imageUrl");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-image-local">Imagem local (caminho)</Label>
|
||||
<Label htmlFor="item-image-local">Imagem local (caminho) {isUnsaved("imageLocalPath") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-image-local"
|
||||
placeholder="Opcional"
|
||||
value={imageLocalPath}
|
||||
onChange={(event) => setImageLocalPath(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setImageLocalPath(event.target.value);
|
||||
markUnsaved("imageLocalPath");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-level">Level requerido</Label>
|
||||
<Label htmlFor="item-level">Level requerido {isUnsaved("level") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-level"
|
||||
placeholder="Ex.: 250"
|
||||
value={level}
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setLevel(event.target.value);
|
||||
markUnsaved("level");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-weight">Peso</Label>
|
||||
<Label htmlFor="item-weight">Peso {isUnsaved("weight") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-weight"
|
||||
placeholder="Ex.: 45"
|
||||
value={weight}
|
||||
onChange={(event) => setWeight(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setWeight(event.target.value);
|
||||
markUnsaved("weight");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-slots">Slots</Label>
|
||||
<Label htmlFor="item-slots">Slots {isUnsaved("slots") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-slots"
|
||||
placeholder="Ex.: 2"
|
||||
value={slots}
|
||||
onChange={(event) => setSlots(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setSlots(event.target.value);
|
||||
markUnsaved("slots");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-skill">Skill requerido</Label>
|
||||
<Label htmlFor="item-skill">Skill requerido {isUnsaved("skill") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-skill"
|
||||
placeholder="Ex.: Sword 115"
|
||||
value={skill}
|
||||
onChange={(event) => setSkill(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setSkill(event.target.value);
|
||||
markUnsaved("skill");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -899,7 +1183,11 @@ export function AdminItemForm({
|
||||
{dynamicColumns.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Campos específicos da categoria</Label>
|
||||
<Label className="text-sm font-semibold">
|
||||
Campos específicos da categoria {isUnsaved("fields") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defina valores adicionais conforme o modelo da categoria selecionada.
|
||||
</p>
|
||||
@@ -918,7 +1206,9 @@ export function AdminItemForm({
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
{column.name.replace(/_/g, " ")}{isUnsaved(`fields.${column.name}`) && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
@@ -930,7 +1220,9 @@ export function AdminItemForm({
|
||||
return (
|
||||
<div key={column.name} className="space-y-2">
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
{column.name.replace(/_/g, " ")}{isUnsaved(`fields.${column.name}`) && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`dynamic-${column.name}`}
|
||||
@@ -943,9 +1235,10 @@ export function AdminItemForm({
|
||||
? ""
|
||||
: String(value)
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleDynamicValueChange(column, event.target.value)
|
||||
}
|
||||
onChange={(event) => {
|
||||
handleDynamicValueChange(column, event.target.value);
|
||||
markUnsaved(`fields.${column.name}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -955,12 +1248,42 @@ export function AdminItemForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-servers">Servidores (separados por vírgula)</Label>
|
||||
<Label htmlFor="origin-server">Servidor de origem {isUnsaved("server") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Select
|
||||
value={originServer}
|
||||
onValueChange={(value) => {
|
||||
setOriginServer(value);
|
||||
markUnsaved("server");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="origin-server" className="w-full">
|
||||
<SelectValue placeholder="Selecione um servidor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverOptions.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!originServer && (
|
||||
<p className="text-[11px] text-destructive">Campo obrigatório. Selecione o servidor de origem.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-servers">Servidores (separados por vírgula) {isUnsaved("servers") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-servers"
|
||||
placeholder="Ex.: Ferobra, Boneka"
|
||||
value={serversCsv}
|
||||
onChange={(event) => setServersCsv(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setServersCsv(event.target.value);
|
||||
markUnsaved("servers");
|
||||
}}
|
||||
/>
|
||||
{serverSuggestions && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
@@ -970,24 +1293,34 @@ export function AdminItemForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-tags">Tags adicionais</Label>
|
||||
<Label htmlFor="item-tags">Tags adicionais {isUnsaved("tags") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Input
|
||||
id="item-tags"
|
||||
placeholder="Ex.: endgame, two-handed"
|
||||
value={tagsCsv}
|
||||
onChange={(event) => setTagsCsv(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setTagsCsv(event.target.value);
|
||||
markUnsaved("tags");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-description">Descrição detalhada</Label>
|
||||
<Textarea
|
||||
id="item-description"
|
||||
placeholder="Cole aqui a descrição oficial do item..."
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
<Label htmlFor="item-description">Descrição detalhada {isUnsaved("description") && (
|
||||
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
|
||||
)}</Label>
|
||||
<Textarea
|
||||
id="item-description"
|
||||
placeholder="Cole aqui a descrição oficial do item..."
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value);
|
||||
markUnsaved("description");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
@@ -996,12 +1329,100 @@ export function AdminItemForm({
|
||||
{analysisLoading ? "Analisando..." : "Analisar com IA"}
|
||||
</Button>
|
||||
)}
|
||||
{allowAiTools && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setPrefillStatus(null);
|
||||
if (!analysis) {
|
||||
setPrefillStatus("Gere o JSON com IA antes de preencher automaticamente.");
|
||||
return;
|
||||
}
|
||||
setPrefillLoading(true);
|
||||
|
||||
// Construir prefill a partir de analysis { table, columns }
|
||||
const columns = (analysis as any)?.columns ?? {};
|
||||
const table = (analysis as any)?.table ?? null;
|
||||
|
||||
const pick = (obj: any, keyList: string[]): any => {
|
||||
for (const k of keyList) {
|
||||
if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const topName = pick(columns, ["nome", "name", "item_name"]);
|
||||
const imageUrlPref = pick(columns, ["image_url", "url", "imagem"]);
|
||||
const imageLocalPref = pick(columns, ["image_local_path", "local_path"]);
|
||||
const rarityPref = pick(columns, ["rarity", "raridade"]);
|
||||
const levelPref = pick(columns, ["level", "nivel"]);
|
||||
const weightPref = pick(columns, ["weight", "peso"]);
|
||||
const slotsPref = pick(columns, ["slots"]);
|
||||
const skillPref = pick(columns, ["skill", "habilidade"]);
|
||||
const descriptionPref = pick(columns, ["description", "descricao"]);
|
||||
const serversPref = pick(columns, ["servers", "servidores"]);
|
||||
const tagsPref = pick(columns, ["tags", "etiquetas"]);
|
||||
|
||||
// Montar metadata com demais chaves
|
||||
const baseKeys = new Set([
|
||||
"nome","name","item_name","image_url","url","imagem","image_local_path","local_path","rarity","raridade","level","nivel","weight","peso","slots","skill","habilidade","description","descricao","servers","servidores","tags","etiquetas","category","categoria"
|
||||
]);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(columns)) {
|
||||
if (!baseKeys.has(k)) {
|
||||
metadata[k] = v as unknown;
|
||||
}
|
||||
}
|
||||
if (table && typeof table === "string") {
|
||||
metadata.category = table.toLowerCase();
|
||||
}
|
||||
|
||||
const prefill: PrefillPayload = {
|
||||
name: topName !== undefined ? String(topName) : undefined,
|
||||
category: table ? String(table).toLowerCase() : undefined,
|
||||
rarity: rarityPref !== undefined ? String(rarityPref) : undefined,
|
||||
imageUrl: imageUrlPref !== undefined ? String(imageUrlPref) : undefined,
|
||||
imageLocalPath: imageLocalPref !== undefined ? String(imageLocalPref) : undefined,
|
||||
level: levelPref !== undefined ? (typeof levelPref === "number" ? levelPref : String(levelPref)) : undefined,
|
||||
weight: weightPref !== undefined ? (typeof weightPref === "number" ? weightPref : String(weightPref)) : undefined,
|
||||
slots: slotsPref !== undefined ? (typeof slotsPref === "number" ? slotsPref : String(slotsPref)) : undefined,
|
||||
skill: skillPref !== undefined ? String(skillPref) : undefined,
|
||||
description: descriptionPref !== undefined ? String(descriptionPref) : undefined,
|
||||
servers: serversPref !== undefined ? (Array.isArray(serversPref) ? serversPref : String(serversPref)) : undefined,
|
||||
tags: tagsPref !== undefined ? (Array.isArray(tagsPref) ? tagsPref : String(tagsPref)) : undefined,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Aplicar prefill e marcar campos alterados
|
||||
const diff = computePrefillDiff(prefill);
|
||||
setPrefillAnimating(true);
|
||||
applyPrefill(prefill);
|
||||
markUnsavedFromDiff(diff);
|
||||
setPrefillStatus("Campos preenchidos automaticamente com base no JSON.");
|
||||
setTimeout(() => setPrefillAnimating(false), 600);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPrefillStatus(e instanceof Error ? e.message : "Falha ao preencher automaticamente.");
|
||||
} finally {
|
||||
setPrefillLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={analysisLoading || prefillLoading}
|
||||
aria-label="Preencher automaticamente a partir do JSON"
|
||||
aria-disabled={analysisLoading || prefillLoading}
|
||||
>
|
||||
{prefillLoading ? "Preenchendo..." : "Preencher Automaticamente"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{submitStatus && <p className="text-xs text-muted-foreground">{submitStatus}</p>}
|
||||
{prefillStatus && <p className="text-xs text-muted-foreground">{prefillStatus}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 min-w-0">
|
||||
@@ -1019,10 +1440,130 @@ export function AdminItemForm({
|
||||
{analysisError}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px]">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{analysis ? JSON.stringify(analysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
|
||||
{tokenUsage && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline" className="px-2 py-1">
|
||||
Tokens: in {tokenUsage.promptTokens} · out {tokenUsage.outputTokens} · total {tokenUsage.totalTokens}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="px-2 py-1">
|
||||
Custo (req): US$ {tokenUsage.costUsd.toFixed(6)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="px-2 py-1">
|
||||
Acumulado (sessão): US$ {accumulatedCostUsd.toFixed(6)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className={`max-w-full w-full rounded-md border bg-muted/40 p-0 ${prefillAnimating ? "animate-pulse" : ""}`} role="region" aria-label="Pré-visualização do JSON gerado">
|
||||
<pre className="whitespace-pre overflow-x-auto max-w-full w-full min-w-0 p-4 font-mono rounded-md shadow-sm bg-muted text-sm" aria-live="polite">
|
||||
{formattedAnalysis ? JSON.stringify(formattedAnalysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
|
||||
</pre>
|
||||
{formattedAnalysis && (formattedAnalysis as any)?.schemaSuggestion && (
|
||||
<div className="p-3 border-t bg-background">
|
||||
<p className="text-xs text-muted-foreground mb-2">Sugestão de esquema (SQLite)</p>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="px-2 py-1 uppercase">
|
||||
{(formattedAnalysis as any).schemaSuggestion.table || "tabela"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{Array.isArray((formattedAnalysis as any).schemaSuggestion.columns) ? (
|
||||
<ul className="list-disc ml-5">
|
||||
{(formattedAnalysis as any).schemaSuggestion.columns.map((col: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
{String(col?.name ?? "coluna")} : {String(col?.type ?? "TEXT")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Sem colunas sugeridas.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 border-t bg-background flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setPrefillStatus(null);
|
||||
if (!analysis) {
|
||||
setPrefillStatus("Gere o JSON com IA antes de preencher automaticamente.");
|
||||
return;
|
||||
}
|
||||
setPrefillLoading(true);
|
||||
|
||||
const columns = (analysis as any)?.columns ?? {};
|
||||
const table = (analysis as any)?.table ?? null;
|
||||
|
||||
const pick = (obj: any, keyList: string[]): any => {
|
||||
for (const k of keyList) {
|
||||
if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const topName = pick(columns, ["nome", "name", "item_name"]);
|
||||
const imageUrlPref = pick(columns, ["image_url", "url", "imagem"]);
|
||||
const imageLocalPref = pick(columns, ["image_local_path", "local_path"]);
|
||||
const rarityPref = pick(columns, ["rarity", "raridade"]);
|
||||
const levelPref = pick(columns, ["level", "nivel"]);
|
||||
const weightPref = pick(columns, ["weight", "peso"]);
|
||||
const slotsPref = pick(columns, ["slots"]);
|
||||
const skillPref = pick(columns, ["skill", "habilidade"]);
|
||||
const descriptionPref = pick(columns, ["description", "descricao"]);
|
||||
const serversPref = pick(columns, ["servers", "servidores"]);
|
||||
const tagsPref = pick(columns, ["tags", "etiquetas"]);
|
||||
|
||||
const baseKeys = new Set([
|
||||
"nome","name","item_name","image_url","url","imagem","image_local_path","local_path","rarity","raridade","level","nivel","weight","peso","slots","skill","habilidade","description","descricao","servers","servidores","tags","etiquetas","category","categoria"
|
||||
]);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(columns)) {
|
||||
if (!baseKeys.has(k)) {
|
||||
metadata[k] = v as unknown;
|
||||
}
|
||||
}
|
||||
if (table && typeof table === "string") {
|
||||
metadata.category = table.toLowerCase();
|
||||
}
|
||||
|
||||
const prefill: PrefillPayload = {
|
||||
name: topName !== undefined ? String(topName) : undefined,
|
||||
category: table ? String(table).toLowerCase() : undefined,
|
||||
rarity: rarityPref !== undefined ? String(rarityPref) : undefined,
|
||||
imageUrl: imageUrlPref !== undefined ? String(imageUrlPref) : undefined,
|
||||
imageLocalPath: imageLocalPref !== undefined ? String(imageLocalPref) : undefined,
|
||||
level: levelPref !== undefined ? (typeof levelPref === "number" ? levelPref : String(levelPref)) : undefined,
|
||||
weight: weightPref !== undefined ? (typeof weightPref === "number" ? weightPref : String(weightPref)) : undefined,
|
||||
slots: slotsPref !== undefined ? (typeof slotsPref === "number" ? slotsPref : String(slotsPref)) : undefined,
|
||||
skill: skillPref !== undefined ? String(skillPref) : undefined,
|
||||
description: descriptionPref !== undefined ? String(descriptionPref) : undefined,
|
||||
servers: serversPref !== undefined ? (Array.isArray(serversPref) ? serversPref : String(serversPref)) : undefined,
|
||||
tags: tagsPref !== undefined ? (Array.isArray(tagsPref) ? tagsPref : String(tagsPref)) : undefined,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const diff = computePrefillDiff(prefill);
|
||||
setPrefillAnimating(true);
|
||||
applyPrefill(prefill);
|
||||
markUnsavedFromDiff(diff);
|
||||
setPrefillStatus("Campos preenchidos automaticamente com base no JSON.");
|
||||
setTimeout(() => setPrefillAnimating(false), 600);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPrefillStatus(e instanceof Error ? e.message : "Falha ao preencher automaticamente.");
|
||||
} finally {
|
||||
setPrefillLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={analysisLoading || prefillLoading}
|
||||
aria-label="Preencher automaticamente a partir do JSON"
|
||||
aria-disabled={analysisLoading || prefillLoading}
|
||||
>
|
||||
{prefillLoading ? "Preenchendo..." : "Preencher Automaticamente"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1056,11 +1597,11 @@ export function AdminItemForm({
|
||||
isEdit,
|
||||
submitting,
|
||||
submitLabel,
|
||||
onCancel,
|
||||
onCancel: handleCancelClick,
|
||||
onSubmitClick: handlePrimarySubmit,
|
||||
}) ?? (
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
|
||||
<Button type="button" variant="outline" onClick={handleCancelClick} disabled={submitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
@@ -1070,6 +1611,31 @@ export function AdminItemForm({
|
||||
)}
|
||||
</form>
|
||||
|
||||
<Dialog open={showExitConfirm} onOpenChange={setShowExitConfirm}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Descartar alterações?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Existem alterações não salvas. Deseja sair sem salvar?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowExitConfirm(false)}>
|
||||
Continuar editando
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowExitConfirm(false);
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
Descartar alterações
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showNewCategoryModal} onOpenChange={setShowNewCategoryModal}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -55,6 +55,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiStatus, setApiStatus] = useState<{ code: number | null; ok: boolean | null; durationMs: number | null; checkedAt: string | null }>({ code: null, ok: null, durationMs: null, checkedAt: null });
|
||||
|
||||
const [serverFilter, setServerFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
@@ -66,33 +67,132 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeServers, setActiveServers] = useState<string[]>([]);
|
||||
const [serversLoading, setServersLoading] = useState<boolean>(false);
|
||||
|
||||
const searchDebounce = useRef<number | undefined>(undefined);
|
||||
|
||||
function isAdminItemsResponse(val: any): val is AdminItemsResponse {
|
||||
return (
|
||||
val &&
|
||||
Array.isArray(val.items) &&
|
||||
typeof val.total === "number" &&
|
||||
typeof val.page === "number" &&
|
||||
typeof val.pageSize === "number" &&
|
||||
Array.isArray(val.servers) &&
|
||||
Array.isArray(val.categories)
|
||||
);
|
||||
}
|
||||
|
||||
// Carrega servidores ativos independentemente dos itens cadastrados
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadServers() {
|
||||
try {
|
||||
setServersLoading(true);
|
||||
const res = await fetch("/api/servers", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
const names: string[] = Array.isArray(json?.servers)
|
||||
? json.servers
|
||||
.map((s: any) => (typeof s?.serverName === "string" ? s.serverName : null))
|
||||
.filter((n: string | null): n is string => Boolean(n))
|
||||
: [];
|
||||
if (!cancelled) {
|
||||
setActiveServers(names);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[AdminItemManager] erro ao buscar /api/servers:", e);
|
||||
} finally {
|
||||
if (!cancelled) setServersLoading(false);
|
||||
}
|
||||
}
|
||||
loadServers();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set("q", search.trim());
|
||||
if (serverFilter !== "all") params.set("server", serverFilter);
|
||||
if (categoryFilter !== "all") params.set("category", categoryFilter);
|
||||
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(pageSize));
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set("q", search.trim());
|
||||
if (serverFilter !== "all") params.set("server", serverFilter);
|
||||
if (categoryFilter !== "all") params.set("category", categoryFilter);
|
||||
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(pageSize));
|
||||
|
||||
const res = await fetch(`/api/admin/items?${params.toString()}`, {
|
||||
const url = `/api/admin/items?${params.toString()}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 15000);
|
||||
const start = performance.now();
|
||||
|
||||
console.group("[AdminItemManager] fetchItems");
|
||||
console.log("URL:", url);
|
||||
console.log("QueryParams:", Object.fromEntries(params.entries()));
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() });
|
||||
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`);
|
||||
console.log("Headers:", {
|
||||
"content-type": res.headers.get("content-type"),
|
||||
"x-powered-by": res.headers.get("x-powered-by"),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Não foi possível carregar os itens.");
|
||||
let errorPayload: any = null;
|
||||
try {
|
||||
errorPayload = await res.json();
|
||||
} catch {
|
||||
// pode não ser JSON
|
||||
}
|
||||
const baseMsg =
|
||||
res.status === 401
|
||||
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
|
||||
: res.status === 403
|
||||
? "Acesso restrito ao administrador."
|
||||
: "Não foi possível carregar os itens.";
|
||||
const apiMsg = errorPayload?.error || errorPayload?.message || null;
|
||||
const composed = apiMsg ? `${baseMsg} (${apiMsg})` : `${baseMsg} (HTTP ${res.status})`;
|
||||
console.error("Erro na API:", composed, "Payload:", errorPayload);
|
||||
throw new Error(composed);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!isAdminItemsResponse(json)) {
|
||||
console.error("Resposta inválida da API:", json);
|
||||
throw new Error("Resposta inválida da API de itens.");
|
||||
}
|
||||
const json = (await res.json()) as AdminItemsResponse;
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
|
||||
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
|
||||
} catch (err: any) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
setApiStatus((prev) => ({ code: prev.code, ok: false, durationMs, checkedAt: new Date().toISOString() }));
|
||||
if (err?.name === "AbortError") {
|
||||
console.error("Requisição abortada por timeout");
|
||||
setError("Tempo de resposta excedido. Tente novamente.");
|
||||
} else {
|
||||
console.error("Falha ao carregar itens:", err);
|
||||
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
|
||||
}
|
||||
} finally {
|
||||
console.groupEnd();
|
||||
window.clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
|
||||
@@ -235,8 +335,14 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-sm text-muted-foreground flex items-center">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
{loading ? "Carregando..." : `${data.total} itens encontrados`}
|
||||
{apiStatus.code !== null && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
|
||||
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -397,7 +503,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
mode="edit"
|
||||
item={selectedItem ?? undefined}
|
||||
categories={data.categories}
|
||||
serverOptions={data.servers}
|
||||
serverOptions={activeServers.length ? activeServers : data.servers}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
@@ -40,15 +40,28 @@ export function ItemAiAssistant({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rawJson, setRawJson] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
const disabled = useMemo(() => !name.trim() || !description.trim(), [name, description]);
|
||||
// Habilita geração se houver ao menos uma imagem válida OU nome+descrição preenchidos
|
||||
const canGenerate = useMemo(() => {
|
||||
const hasValidImage = images.length > 0;
|
||||
const hasTextInputs = name.trim() && description.trim();
|
||||
return Boolean(hasValidImage || hasTextInputs);
|
||||
}, [images.length, name, description]);
|
||||
|
||||
const disabled = useMemo(() => !canGenerate, [canGenerate]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (disabled || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLogs((prev) => [...prev, "Iniciando geração com IA..."]);
|
||||
|
||||
try {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Payload: nome='${name.trim()}', escopo='${scope}', imagens=${images.length}, servidor='${scope === "server" ? server : "(global)"}'`,
|
||||
]);
|
||||
const response = await fetch("/api/admin/items/analyze", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
@@ -73,13 +86,57 @@ export function ItemAiAssistant({
|
||||
const data = await response.json();
|
||||
const structure = data?.structure ?? data;
|
||||
const columns = structure?.columns;
|
||||
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
|
||||
|
||||
setRawJson(JSON.stringify(structure, null, 2));
|
||||
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
|
||||
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
|
||||
if (!Array.isArray(imgs) || imgs.length === 0) return [];
|
||||
const first = imgs[0];
|
||||
const base = first.originalName
|
||||
.replace(/\.[a-zA-Z0-9]+$/, "")
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, "-");
|
||||
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
|
||||
const orientation = first.height > first.width ? "retrato" : "paisagem";
|
||||
const suggestions: string[] = [];
|
||||
if (base && !common.has(base)) suggestions.push(base);
|
||||
suggestions.push(`${orientation}-1`);
|
||||
suggestions.push("foto-1");
|
||||
return Array.from(new Set(suggestions)).slice(0, 3);
|
||||
};
|
||||
|
||||
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
|
||||
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
|
||||
|
||||
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
|
||||
|
||||
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
|
||||
|
||||
// Estrutura final com campos obrigatórios, origem e sugestões
|
||||
const finalStructure = {
|
||||
...structure,
|
||||
columns: {
|
||||
...columns,
|
||||
origem_dados: origemDados,
|
||||
confirmar_nome: autoNameDetected ? false : undefined,
|
||||
mensagens: {
|
||||
obrigatorio: ["nome"],
|
||||
dicas: autoNameDetected
|
||||
? "Confirme o nome sugerido ou ajuste se necessário."
|
||||
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
|
||||
},
|
||||
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
setRawJson(JSON.stringify(finalStructure, null, 2));
|
||||
|
||||
const prefill: ItemAiPrefill = {
|
||||
name: typeof columns?.name === "string" ? columns.name : undefined,
|
||||
description: typeof columns?.description === "string" ? columns.description : undefined,
|
||||
metadata: typeof columns === "object" && columns !== null ? columns : undefined,
|
||||
name: autoNameDetected ? (columns!.name as string) : undefined,
|
||||
description: autoDescDetected ? (columns!.description as string) : undefined,
|
||||
metadata: typeof columns === "object" && columns !== null
|
||||
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
|
||||
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
|
||||
};
|
||||
|
||||
if (columns) {
|
||||
@@ -103,12 +160,15 @@ export function ItemAiAssistant({
|
||||
}
|
||||
}
|
||||
|
||||
onGenerated({ json: structure, prefill });
|
||||
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
|
||||
onGenerated({ json: finalStructure, prefill });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
|
||||
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLogs((prev) => [...prev, "Processo de geração concluído."]);
|
||||
}
|
||||
}, [
|
||||
categories,
|
||||
@@ -199,6 +259,9 @@ export function ItemAiAssistant({
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Prints / evidências visuais</label>
|
||||
<ItemImageCapture onImagesChange={setImages} />
|
||||
{images.length > 0 && (
|
||||
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -213,7 +276,7 @@ export function ItemAiAssistant({
|
||||
Gerar com IA
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A IA retornará um JSON com sugestões de campos. Eles serão aplicados automaticamente no formulário.
|
||||
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,6 +296,16 @@ export function ItemAiAssistant({
|
||||
campos do formulário.
|
||||
</p>
|
||||
)}
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
|
||||
<div className="rounded-md border bg-muted/30 p-2">
|
||||
{logs.map((line, idx) => (
|
||||
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -202,6 +202,10 @@ export function ItemImageCapture({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-emerald-600/90 px-2 py-0.5 text-[11px] text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
Pronta
|
||||
</span>
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={image.originalName}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MagicCard } from "@/components/ui/magic-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PriceDisplay } from "@/components/ui/price-display";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
getRarityBgColor,
|
||||
getRarityGlow,
|
||||
} from "@/lib/currency-utils";
|
||||
import { formatNumber } from "@/lib/utils/formatters";
|
||||
|
||||
interface ItemCardProps {
|
||||
item: TibiaItem;
|
||||
@@ -47,6 +48,7 @@ interface ItemCardProps {
|
||||
|
||||
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
const t = useTranslations("home.item");
|
||||
const locale = useLocale();
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const formatVocation = (v: string | undefined): string | undefined => {
|
||||
@@ -659,21 +661,21 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span className="font-medium">{t("highest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.highest, locale)} gp</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-red-600">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
<span className="font-medium">{t("lowest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.lowest, locale)} gp</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span className="font-medium">{t("average")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.average, locale)} gp</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
# Itens sem valores associados
|
||||
|
||||
Esta entidade (`Item`) foi projetada para armazenar somente dados estáticos do item (nome, categoria, atributos e metadados) e **não** deve conter qualquer informação de preço/valor. Valores de compra, venda, lance, histórico de preços e afins pertencem ao modelo `Listing`.
|
||||
|
||||
## Estrutura do Item (obrigatórios e opcionais)
|
||||
|
||||
- `item_name` (obrigatório): Nome do item.
|
||||
- `category` (obrigatório): Categoria normalizada (ex.: `weapon`, `armor`, `helmet`, `accessory`, etc.).
|
||||
- `image_local_path` (opcional)
|
||||
- `image_url` (opcional)
|
||||
- `rarity` (opcional)
|
||||
- `level` (opcional)
|
||||
- `weight` (opcional)
|
||||
- `slots` (opcional)
|
||||
- `skill` (opcional)
|
||||
- `metadata` (opcional): Campos dinâmicos e específicos do item (atributos, servidores, tags, descrição). **Nunca inclua campos de valor aqui.**
|
||||
|
||||
## Validação contra campos de valor
|
||||
|
||||
Qualquer tentativa de incluir campos de valor/preço no `metadata` do item é **rejeitada** pela validação no servidor.
|
||||
|
||||
Chaves bloqueadas (exemplos):
|
||||
|
||||
- `price`, `prices`, `valor`, `value`, `sell_value`, `sellPrice`, `buy`, `buy_price`
|
||||
- Chaves de `Listing`: `price_pix`, `price_tibia_coins`, `price_gold_coins`, `initial_price_*`, `current_bid_amount`
|
||||
|
||||
Mensagem retornada: _"Itens não possuem campos de valor/preço no cadastro. Use listings para preços."_
|
||||
|
||||
## Por que separar?
|
||||
|
||||
- Consistência: O mesmo item pode ter múltiplos anúncios com preços diferentes.
|
||||
- Integridade: Evita confusão e mistura de responsabilidades entre `Item` e `Listing`.
|
||||
- Evolução: Permite histórico de preços e leilões sem alterar o cadastro estático do item.
|
||||
|
||||
## Fluxo recomendado
|
||||
|
||||
1. **Cadastre o Item** com seus atributos e metadados (sem preços).
|
||||
2. **Crie Listings** para anunciar o item com preço, quantidade, leilão, etc.
|
||||
|
||||
---
|
||||
|
||||
Documentação criada para clarificar que `Item` é livre de valores associados e que as funções de criação/atualização validam esse contrato.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
import { buildGeminiParts, buildItemPrompt, InlineImageData } from "@/lib/ai/item-prompt";
|
||||
import { listCategoryTemplates } from "@/lib/items/sqlite-manager";
|
||||
|
||||
interface GenerateItemStructureInput {
|
||||
name: string;
|
||||
@@ -17,6 +18,12 @@ interface GeminiResponse {
|
||||
columns: Record<string, unknown>;
|
||||
};
|
||||
raw: unknown;
|
||||
usage?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
model?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_STRUCTURE = {
|
||||
@@ -24,7 +31,7 @@ const FALLBACK_STRUCTURE = {
|
||||
columns: {
|
||||
name: "Final Fate",
|
||||
description:
|
||||
"Item lendário utilizado no slot de tocha. Oferece bônus massivos em skills mágicas, velocidade e efeitos de leech.",
|
||||
"Usado no slot de tocha. Bônus em leech, velocidade e proteção física.",
|
||||
attributes: {
|
||||
shielding: 20,
|
||||
death_magic: 120,
|
||||
@@ -52,8 +59,37 @@ export async function generateItemStructure(
|
||||
}
|
||||
|
||||
const url =
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
|
||||
apiKey;
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
|
||||
|
||||
// Coleta estrutura do SQLite (somente nomes de tabelas, colunas e tipos) e injeta no prompt.
|
||||
let sqliteSchemaText = "- Nenhuma tabela encontrada";
|
||||
try {
|
||||
const templates = listCategoryTemplates();
|
||||
if (templates.length > 0) {
|
||||
const lines: string[] = ["Estruturas atuais no SQLite:"];
|
||||
templates.forEach((tpl) => {
|
||||
lines.push(`- Tabela: ${tpl.name}`);
|
||||
tpl.columns.forEach((col) => {
|
||||
lines.push(` - ${col.name}: ${col.type}`);
|
||||
});
|
||||
});
|
||||
sqliteSchemaText = lines.join("\n");
|
||||
}
|
||||
} catch (e) {
|
||||
sqliteSchemaText = "[Falha ao listar SQLite]";
|
||||
}
|
||||
|
||||
const extra = [
|
||||
// Instruções específicas para exigência de saída fixa
|
||||
"Inclua SEMPRE o campo fixo 'schemaSuggestion' com a melhor tabela existente ou a sugestão de nova, e a lista de colunas com tipos (TEXT|INTEGER|REAL|BOOLEAN).",
|
||||
"Nunca inclua dados reais do SQLite; use somente a estrutura listada.",
|
||||
// Reforços de consistência e proibições
|
||||
"Para escopo global, a tabela deve ser 'items_all'. Para escopo de servidor, use 'items_<slug_do_servidor>' com slug minúsculo, '_' como separador, sem caracteres não alfanuméricos.",
|
||||
"Servidores em 'columns.servers' DEVEM pertencer à lista fornecida no contexto; prefira um array de strings.",
|
||||
"Em 'columns.metadata', insira apenas atributos descritivos. É proibido incluir campos de valor/preço/mercado: price, value, buy, sell, currency, npc_price, marketPrice, minPrice, maxPrice, avgPrice.",
|
||||
"Garanta que 'columns.description' tenha no máximo 100 caracteres; se maior, recorte exatamente os primeiros 100 sem sufixos.",
|
||||
sqliteSchemaText,
|
||||
].join("\n");
|
||||
|
||||
const prompt = await buildItemPrompt(
|
||||
{
|
||||
@@ -64,18 +100,24 @@ export async function generateItemStructure(
|
||||
categories: input.categories,
|
||||
servers: input.servers,
|
||||
},
|
||||
input.images && input.images.length > 0
|
||||
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
|
||||
: undefined,
|
||||
[
|
||||
input.images && input.images.length > 0
|
||||
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
|
||||
: null,
|
||||
extra,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
const parts = await buildGeminiParts(prompt, input.images);
|
||||
|
||||
try {
|
||||
async function callOnce(): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
@@ -91,11 +133,46 @@ export async function generateItemStructure(
|
||||
}),
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
const bodyJson = (() => {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return { raw: bodyText };
|
||||
}
|
||||
})();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini retornou status ${response.status}`);
|
||||
const status = response.status;
|
||||
const msg = bodyJson?.error?.message || `Gemini retornou status ${status}`;
|
||||
const err = new Error(msg);
|
||||
(err as any).status = status;
|
||||
(err as any).response = bodyJson;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return bodyJson;
|
||||
}
|
||||
|
||||
try {
|
||||
// Retry simples para 429 (rate limit): até 2 tentativas com backoff.
|
||||
let attempts = 0;
|
||||
let data: any;
|
||||
while (true) {
|
||||
try {
|
||||
data = await callOnce();
|
||||
break;
|
||||
} catch (e) {
|
||||
const status = (e as any)?.status;
|
||||
attempts++;
|
||||
if (status === 429 && attempts <= 2) {
|
||||
const delayMs = 500 * attempts;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const text =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
|
||||
data?.candidates?.[0]?.output_text;
|
||||
@@ -114,15 +191,21 @@ export async function generateItemStructure(
|
||||
return {
|
||||
structure: parsed,
|
||||
raw: data,
|
||||
usage: data?.usageMetadata,
|
||||
model: "gemini-2.5-flash",
|
||||
};
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Falha ao gerar estrutura via Gemini", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
status: (error as any)?.status,
|
||||
response: (error as any)?.response,
|
||||
});
|
||||
|
||||
return {
|
||||
structure: FALLBACK_STRUCTURE,
|
||||
raw: FALLBACK_STRUCTURE,
|
||||
usage: undefined,
|
||||
model: "gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+34
-10
@@ -56,7 +56,7 @@ Front-end fields (AdminItemForm):
|
||||
- skill: string opcional
|
||||
- servers: lista de servidores (separados por vírgula)
|
||||
- tags: lista de tags (texto)
|
||||
- description: texto longo
|
||||
- description: texto (máximo 100 caracteres)
|
||||
- dynamic metadata: campos variáveis definidos por categoria (servidores, atributos, etc)
|
||||
`.trim();
|
||||
|
||||
@@ -79,7 +79,9 @@ Você é um assistente que prepara payloads JSON para cadastro de itens no banco
|
||||
INSTRUÇÕES IMPORTANTES:
|
||||
- Responda SOMENTE com JSON válido. Nenhum texto fora do JSON.
|
||||
- Se não tiver certeza, retorne null nos campos desconhecidos.
|
||||
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
|
||||
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
|
||||
- Você receberá somente a estrutura das tabelas SQLite (nomes, colunas e tipos). Use esse esquema para escolher uma tabela existente adequada ou sugerir uma nova. Nunca inclua dados reais.
|
||||
- Campo "description": limite máximo de 100 caracteres. Se a descrição original exceder 100 caracteres, retorne apenas os primeiros 100 caracteres sem acrescentar sufixos (sem "..."), e nunca gere texto além desse limite.
|
||||
|
||||
Contexto fornecido pelo usuário:
|
||||
- Nome: ${context.name}
|
||||
@@ -103,19 +105,41 @@ Formato obrigatório da resposta:
|
||||
{
|
||||
"table": "nome_da_tabela",
|
||||
"columns": {
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"rarity": "...",
|
||||
"metadata": {...},
|
||||
"outros_campos": ...
|
||||
"name": string | null,
|
||||
"description": string | null,
|
||||
"category": string | null,
|
||||
"rarity": string | null,
|
||||
"image_url": string | null,
|
||||
"image_local_path": string | null,
|
||||
"level": number | null,
|
||||
"weight": number | null,
|
||||
"slots": number | null,
|
||||
"skill": string | null,
|
||||
"servers": string[] | null,
|
||||
"tags": string[] | null,
|
||||
"metadata": object | null
|
||||
},
|
||||
"schemaSuggestion": {
|
||||
"table": "nome_da_tabela_existente_ou_nova",
|
||||
"columns": [
|
||||
{ "name": "coluna_1", "type": "TEXT|INTEGER|REAL|BOOLEAN" },
|
||||
{ "name": "coluna_2", "type": "TEXT|INTEGER|REAL|BOOLEAN" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Consistência:
|
||||
- Escolha a tabela coerente com o escopo. Para global, use "items" ou equivalente.
|
||||
- Escolha a tabela coerente com o escopo. Para global, use "items_all".
|
||||
- Para escopo de servidor, use "items_<slug_do_servidor>", onde <slug> é o nome do servidor em minúsculas, com espaços transformados em "_" e removendo caracteres não alfanuméricos.
|
||||
- Inclua sempre os campos básicos: name, description.
|
||||
- Aplique outros campos conforme a descrição (ex.: atributos, requisitos, bônus).
|
||||
- Se houver informações que encaixem em metadados dinâmicos, use um objeto "metadata".
|
||||
- Nunca produza "description" com mais de 100 caracteres.
|
||||
|
||||
Regras adicionais:
|
||||
- Servidores ("servers"): utilize SOMENTE nomes presentes na lista fornecida no contexto. Prefira o formato array de strings (ex.: ["Belobra", "Antica"]). Se não aplicável, use null.
|
||||
- Metadata: inclua apenas atributos descritivos (ex.: "attributes", "requirements", "protections", "flags"). É PROIBIDO incluir qualquer campo de valor/preço/mercado. Não utilize chaves: "price", "value", "buy", "sell", "currency", "npc_price", "marketPrice", "minPrice", "maxPrice", "avgPrice".
|
||||
- Tipos: números devem ser números (sem aspas). Se o valor for desconhecido, retorne null. Não invente dados.
|
||||
|
||||
${extraInstructions ?? ""}
|
||||
`.trim();
|
||||
@@ -127,13 +151,13 @@ export interface InlineImageData {
|
||||
}
|
||||
|
||||
export async function buildGeminiParts(prompt: string, images: InlineImageData[] = []) {
|
||||
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [
|
||||
const parts: Array<{ text?: string; inline_data?: { mimeType: string; data: string } }> = [
|
||||
{ text: prompt },
|
||||
];
|
||||
|
||||
images.slice(0, 2).forEach((image) => {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
inline_data: {
|
||||
mimeType: image.mimeType,
|
||||
data: image.base64Data,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Utilitário de preços de tokens do Gemini e cálculo de custo por requisição
|
||||
* Baseado em preços públicos aproximados para o modelo `gemini-2.5-flash`.
|
||||
* Valores podem variar; mantenha sob revisão se houver mudança na tabela oficial.
|
||||
*/
|
||||
|
||||
export interface UsageMetadata {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
}
|
||||
|
||||
export interface TokenCostBreakdown {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
inputUsd: number;
|
||||
outputUsd: number;
|
||||
totalUsd: number;
|
||||
}
|
||||
|
||||
type PricingTable = {
|
||||
[model: string]: {
|
||||
inputPerMillionTextImageVideo: number; // USD por 1M tokens de entrada (text/image/video)
|
||||
outputPerMillion: number; // USD por 1M tokens de saída
|
||||
};
|
||||
};
|
||||
|
||||
// Tabela simplificada; ajuste se usar outros modelos no futuro
|
||||
export const TOKEN_PRICING: PricingTable = {
|
||||
"gemini-2.5-flash": {
|
||||
inputPerMillionTextImageVideo: 0.15, // USD
|
||||
outputPerMillion: 1.25, // USD
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcula custo em USD baseado em usageMetadata retornado pela API Gemini.
|
||||
*/
|
||||
export function calculateTokenCost(
|
||||
model: keyof typeof TOKEN_PRICING,
|
||||
usage: UsageMetadata | null | undefined,
|
||||
): TokenCostBreakdown {
|
||||
const pricing = TOKEN_PRICING[model] ?? TOKEN_PRICING["gemini-2.5-flash"];
|
||||
const inputTokens = Math.max(0, Number(usage?.promptTokenCount || 0));
|
||||
const outputTokens = Math.max(0, Number(usage?.candidatesTokenCount || 0));
|
||||
const totalTokens = Math.max(0, Number(usage?.totalTokenCount || inputTokens + outputTokens));
|
||||
|
||||
const inputUsd = (inputTokens / 1_000_000) * pricing.inputPerMillionTextImageVideo;
|
||||
const outputUsd = (outputTokens / 1_000_000) * pricing.outputPerMillion;
|
||||
const totalUsd = inputUsd + outputUsd;
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
inputUsd,
|
||||
outputUsd,
|
||||
totalUsd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getRedisClient } from "../redis";
|
||||
|
||||
export interface TokenUsageRecord {
|
||||
id: string; // uuid/id da requisição (pode ser requestId)
|
||||
timestamp: number;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
context?: Record<string, unknown>; // dados opcionais (ex.: rota, itemId)
|
||||
}
|
||||
|
||||
const KEY_PREFIX = "ai:usage";
|
||||
|
||||
export async function recordTokenUsage(record: TokenUsageRecord): Promise<void> {
|
||||
const redis = await getRedisClient();
|
||||
const dayKey = `${KEY_PREFIX}:day:${new Date(record.timestamp).toISOString().slice(0, 10)}`; // YYYY-MM-DD
|
||||
const listKey = `${KEY_PREFIX}:records`;
|
||||
|
||||
// Armazenar agregado diário
|
||||
const pipeline = redis.multi();
|
||||
pipeline.hIncrBy(dayKey, "inputTokens", record.inputTokens);
|
||||
pipeline.hIncrBy(dayKey, "outputTokens", record.outputTokens);
|
||||
pipeline.hIncrBy(dayKey, "totalTokens", record.totalTokens);
|
||||
pipeline.hIncrByFloat(dayKey, "costUsd", record.costUsd);
|
||||
|
||||
// Armazenar registro detalhado (para relatórios)
|
||||
pipeline.lPush(listKey, JSON.stringify(record));
|
||||
// Manter os últimos 10k registros para não crescer indefinidamente (ajuste conforme necessidade)
|
||||
pipeline.lTrim(listKey, 0, 9999);
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
@@ -211,11 +211,38 @@ export interface UpsertItemInput {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function ensureServerColumn(category: string) {
|
||||
const db = getDatabase();
|
||||
const normalized = category.trim().toLowerCase();
|
||||
validateCategoryName(normalized);
|
||||
|
||||
const pragma = db.prepare(`PRAGMA table_info(${normalized})`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
pk: number;
|
||||
}>;
|
||||
|
||||
const hasServer = pragma.some((col) => col.name === "server");
|
||||
if (!hasServer) {
|
||||
// Adiciona coluna 'server' como TEXT opcional
|
||||
addColumnToCategory({
|
||||
category: normalized,
|
||||
column: { name: "server", type: "TEXT", notNull: false, editable: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function insertCategoryItem(input: UpsertItemInput): number {
|
||||
const db = getDatabase();
|
||||
const category = input.category.trim().toLowerCase();
|
||||
validateCategoryName(category);
|
||||
|
||||
// Garante que a coluna 'server' exista na tabela de destino.
|
||||
try {
|
||||
ensureServerColumn(category);
|
||||
} catch {}
|
||||
|
||||
const data = { ...input.data };
|
||||
delete data.id;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../app";
|
||||
import { ensureItemsSeeded } from "../../items/import-from-sqlite";
|
||||
import { secureLogger } from "../../secure-logger";
|
||||
|
||||
export type ItemSort =
|
||||
| "name"
|
||||
@@ -193,6 +194,53 @@ function buildMetadata(
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Campos relacionados a valores/preços que NÃO pertencem ao modelo Item.
|
||||
// Qualquer presença deles em metadata deve ser rejeitada.
|
||||
const FORBIDDEN_VALUE_KEYS = new Set<string>([
|
||||
// Geral
|
||||
"price",
|
||||
"prices",
|
||||
"valor",
|
||||
"value",
|
||||
"sell_value",
|
||||
"sellPrice",
|
||||
"buy",
|
||||
"buy_price",
|
||||
// Chaves usadas em Listings
|
||||
"price_pix",
|
||||
"price_tibia_coins",
|
||||
"price_gold_coins",
|
||||
"initial_price_pix",
|
||||
"initial_price_tibia_coins",
|
||||
"initial_price_gold_coins",
|
||||
"current_bid_amount",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Verifica recursivamente se há campos proibidos relacionados a valores/preços.
|
||||
* Lança erro se encontrar.
|
||||
*/
|
||||
export function assertNoValueFieldsInMetadata(meta: Record<string, any> | undefined | null, context?: string): void {
|
||||
if (!meta || typeof meta !== "object") return;
|
||||
|
||||
const stack: Array<{ obj: Record<string, any>; path: string }> = [{ obj: meta as Record<string, any>, path: "metadata" }];
|
||||
while (stack.length) {
|
||||
const { obj, path } = stack.pop()!;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const nextPath = `${path}.${key}`;
|
||||
if (FORBIDDEN_VALUE_KEYS.has(key)) {
|
||||
const msg = `Itens não possuem campos de valor/preço no cadastro. Campo proibido: ${nextPath}. Use listings para preços.`;
|
||||
secureLogger.warn(msg, { context });
|
||||
throw new Error(msg);
|
||||
}
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) {
|
||||
stack.push({ obj: val as Record<string, any>, path: nextPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({ where: { item_id: itemId } });
|
||||
if (!item) {
|
||||
@@ -322,6 +370,8 @@ export async function searchItems(params: SearchItemsParams = {}): Promise<{ ite
|
||||
|
||||
export async function createAdminItem(input: ItemUpsertInput): Promise<ItemWithMeta> {
|
||||
const now = Date.now();
|
||||
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
|
||||
assertNoValueFieldsInMetadata(input.metadata, "createAdminItem");
|
||||
const data: Prisma.ItemCreateInput = {
|
||||
source_table_id: input.source_table_id ?? now,
|
||||
source_table_name: input.source_table_name ?? "custom",
|
||||
@@ -353,6 +403,8 @@ export async function updateAdminItem(itemId: number, input: ItemUpsertInput): P
|
||||
}
|
||||
|
||||
const existingMeta = parseMetadata(existing.metadata);
|
||||
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
|
||||
assertNoValueFieldsInMetadata(input.metadata, "updateAdminItem");
|
||||
const metadata = buildMetadata(existingMeta, {
|
||||
servers: input.servers ?? extractServers(existingMeta),
|
||||
tags: input.tags ?? extractTags(existingMeta),
|
||||
|
||||
@@ -446,3 +446,31 @@ export function validatePasswordRequirements(password: string): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeia o locale da aplicação (ex: "pt", "en") para um BCP 47 válido
|
||||
* para APIs de formatação (ex: "pt-BR", "en-US").
|
||||
*/
|
||||
export function resolveBCP47Locale(locale?: string): string {
|
||||
switch (locale) {
|
||||
case "pt":
|
||||
return "pt-BR";
|
||||
case "en":
|
||||
return "en-US";
|
||||
default:
|
||||
// Fallback consistente para evitar divergência SSR/cliente
|
||||
return "pt-BR";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata números com locale consistente, evitando mismatch de hidratação
|
||||
* entre SSR e cliente.
|
||||
*/
|
||||
export function formatNumber(value: number, locale?: string): string {
|
||||
try {
|
||||
return value.toLocaleString(resolveBCP47Locale(locale));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"db:test": "tsx lib/db/test-connection.ts",
|
||||
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
|
||||
"prisma:studio": "prisma studio --schema=prisma/schema.prisma",
|
||||
"postinstall": "prisma generate --schema=prisma/schema.prisma"
|
||||
"postinstall": "prisma generate --schema=prisma/schema.prisma",
|
||||
"test": "tsx tests/run-tests.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"tibia marketplace",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertNoValueFieldsInMetadata } from "../lib/prisma/queries/items";
|
||||
|
||||
type TestCase = { name: string; fn: () => Promise<void> | void };
|
||||
const tests: TestCase[] = [];
|
||||
|
||||
function test(name: string, fn: () => Promise<void> | void) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
|
||||
// Teste 1: criação sem valores (validação não lança)
|
||||
test("Validação permite metadata sem campos de valor", () => {
|
||||
const cleanMeta = {
|
||||
attributes: { protection_physical: 0, speed: 500 },
|
||||
servers: ["Antica"],
|
||||
tags: ["relic"],
|
||||
};
|
||||
assert.doesNotThrow(() => assertNoValueFieldsInMetadata(cleanMeta, "test1"));
|
||||
});
|
||||
|
||||
// Teste 2: rejeita tentativas de incluir valores
|
||||
test("Validação rejeita metadata com campos de preço/valor", () => {
|
||||
const badMeta = {
|
||||
attributes: { speed: 500 },
|
||||
price: 100000,
|
||||
};
|
||||
assert.throws(() => assertNoValueFieldsInMetadata(badMeta, "test2"));
|
||||
});
|
||||
|
||||
// Teste 3: Consistência do schema do Item (sem campos de valor)
|
||||
test("Schema Prisma do Item não possui campos de preço/valor", () => {
|
||||
const schemaPath = path.resolve(process.cwd(), "prisma", "schema.prisma");
|
||||
const content = fs.readFileSync(schemaPath, "utf8");
|
||||
const itemBlockMatch = content.match(/model\s+Item\s+\{[\s\S]*?\}/);
|
||||
assert.ok(itemBlockMatch, "Bloco model Item não encontrado no schema.prisma");
|
||||
const itemBlock = itemBlockMatch![0];
|
||||
const forbidden = [/price/i, /value/i, /sell_value/i, /buy_price/i];
|
||||
for (const re of forbidden) {
|
||||
assert.ok(!re.test(itemBlock), `Campo proibido detectado no Item: ${re}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function run() {
|
||||
let passed = 0;
|
||||
for (const t of tests) {
|
||||
try {
|
||||
await t.fn();
|
||||
passed++;
|
||||
console.log(`✓ ${t.name}`);
|
||||
} catch (e) {
|
||||
console.error(`✗ ${t.name}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed}/${tests.length} testes passaram.`);
|
||||
}
|
||||
|
||||
run();
|
||||
Reference in new issue
Block a user