update selection fallback, update UI, update fillter server

This commit is contained in:
Admin 2025-08-13 13:49:19 +07:00
parent 481bab8c17
commit cbb2e21bcd
29 changed files with 398 additions and 104 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@ chrome.runtime.onConnect.addListener((port) => {
evtSource.onmessage = (event) => { evtSource.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log("New event:", data); console.log("New event:", data);
queue.add(() => handlePublish(data)); queue.add(() => handlePublish(data, 30000));
}; };
evtSource.onerror = (err) => { evtSource.onerror = (err) => {
@ -27,7 +27,7 @@ chrome.runtime.onConnect.addListener((port) => {
})(); })();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handlePublish(data: any) { async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
chrome.tabs.create( chrome.tabs.create(
{ url: "https://www.facebook.com/marketplace/create/item", active: true }, { url: "https://www.facebook.com/marketplace/create/item", active: true },
@ -35,6 +35,16 @@ async function handlePublish(data: any) {
if (!tab?.id) return resolve(); if (!tab?.id) return resolve();
const tabId = tab.id; const tabId = tab.id;
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
chrome.runtime.onConnect.removeListener(onConnectListener);
chrome.tabs.onRemoved.removeListener(onTabClosed);
clearTimeout(timeoutId);
resolve();
};
const onConnectListener = (port: chrome.runtime.Port) => { const onConnectListener = (port: chrome.runtime.Port) => {
if (port.sender?.tab?.id === tabId) { if (port.sender?.tab?.id === tabId) {
port.postMessage({ type: "publist-event", payload: data }); port.postMessage({ type: "publist-event", payload: data });
@ -45,11 +55,17 @@ async function handlePublish(data: any) {
const onTabClosed = (closedTabId: number) => { const onTabClosed = (closedTabId: number) => {
if (closedTabId === tabId) { if (closedTabId === tabId) {
chrome.tabs.onRemoved.removeListener(onTabClosed); cleanup();
resolve(); // Chỉ resolve khi tab này đã đóng
} }
}; };
chrome.tabs.onRemoved.addListener(onTabClosed); chrome.tabs.onRemoved.addListener(onTabClosed);
// Timeout tùy chỉnh
const timeoutId = setTimeout(() => {
console.warn(`Tab ${tabId} timeout (${timeoutMs}ms) - auto resolve`);
if (tabId) chrome.tabs.remove(tabId);
cleanup();
}, timeoutMs);
} }
); );
}); });

View File

@ -11,10 +11,16 @@ const selectors = {
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[6]/div/div/div/label/div/input", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[6]/div/div/div/label/div/input",
brand_input: brand_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/input", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/input",
brand_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[2]/div/div/div/label/div/input",
description_input: description_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[3]/div/div/div/label/div/div/textarea", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[3]/div/div/div/label/div/div/textarea",
description_input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[3]/div/div/div/label/div/div/textarea",
sku_input: sku_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[6]/div/div/div[1]/label/div/input", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[6]/div/div/div[1]/label/div/input",
sku_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[6]/div/div/div[1]/label/div/input",
category_select: { category_select: {
wraper: wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/div/div/div/div", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/div/div/div/div",
@ -30,6 +36,8 @@ const selectors = {
tags_input: { tags_input: {
input: input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
plus_btn: plus_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]",
}, },
@ -37,10 +45,14 @@ const selectors = {
location_select: { location_select: {
input: input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
wraper: wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div",
container: container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
container_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
}, },
next_btn: next_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
@ -48,31 +60,31 @@ const selectors = {
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div", "/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
}; };
const getData = async () => { // const getData = async () => {
return new Promise((rev, rej) => { // return new Promise((rev, rej) => {
const url = chrome.runtime.getURL("data.json"); // const url = chrome.runtime.getURL("data.json");
fetch(url) // fetch(url)
.then((res) => res.json()) // .then((res) => res.json())
.then((data) => { // .then((data) => {
rev(data as IItem[]); // rev(data as IItem[]);
}) // })
.catch((err) => { // .catch((err) => {
rej(err); // rej(err);
}); // });
}); // });
}; // };
const uploadImages = async (item: IItem) => { const uploadImages = async (item: IItem) => {
// Tạo DataTransfer để giả lập FileList // Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer(); const dt: DataTransfer = new DataTransfer();
for (const image of item.images) { for (const image of item.images) {
// const base64 = await thiefService.imageLocalToBase64(image); const base64 = await thiefService.imageUrlToBase64(image);
console.log("Base64:", image.slice(0, 50) + "..."); console.log("Base64:", image.slice(0, 50) + "...");
const file = thiefService.base64ToFile( const file = thiefService.base64ToFile(
image, base64,
item.sku, item.sku,
thiefService.getImageExtension(image) || "jpg" thiefService.getImageExtension(image) || "jpg"
); );
@ -106,15 +118,17 @@ const chooseSelect = async (
thiefService.scrollToElement(el); thiefService.scrollToElement(el);
thiefService.clickByPoint(el); thiefService.clickByPoint(el);
await delay(400); await delay(200);
const container = await thiefService.getElementByXPath(xpaths.container); const container = await thiefService.getElementByXPath(xpaths.container);
if (!container) throw new Error("Container xpath not found"); if (!container) throw new Error("Container xpath not found");
// Tìm phần tử con có nội dung giống value // Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find( const matchingChild = Array.from(container.children).find((child) =>
(child) => child.textContent
child.textContent?.trim().toLocaleLowerCase() === ?.trim()
value.toLocaleLowerCase() .toLocaleLowerCase()
.replace(//g, "-")
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined; ) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`); if (!matchingChild) throw new Error(`No child found with text "${value}"`);
@ -127,13 +141,24 @@ const chooseSelect = async (
const chooseLocation = async ( const chooseLocation = async (
value: string, value: string,
{ input, ...xpaths }: { wraper: string; container: string; input: string } {
input,
...xpaths
}: {
wraper: string;
container: string;
container_fallback?: string;
input: string;
input_fallback?: string;
}
) => { ) => {
await thiefService.writeToInput(value, input); await thiefService.writeToInput(value, input, xpaths.input_fallback);
await delay(400); await delay(200);
const container = await thiefService.getElementByXPath(xpaths.container); const container = await thiefService.getElementByXPath(xpaths.container, {
xpathFallback: xpaths.container_fallback,
});
if (!container) throw new Error("Container xpath not found"); if (!container) throw new Error("Container xpath not found");
thiefService.scrollToElement(container); thiefService.scrollToElement(container);
@ -156,9 +181,11 @@ const chooseLocation = async (
const writeTags = async ( const writeTags = async (
tags: string[], tags: string[],
xpaths: { input: string; plus_btn: string } xpaths: { input: string; input_falback?: string; plus_btn: string }
) => { ) => {
const input = await thiefService.getElementByXPath(xpaths.input); const input = await thiefService.getElementByXPath(xpaths.input, {
xpathFallback: xpaths?.input_falback,
});
if (!input) throw new Error("Input is not found"); if (!input) throw new Error("Input is not found");
@ -166,7 +193,7 @@ const writeTags = async (
await delay(200); await delay(200);
for (const tag of tags) { for (const tag of tags) {
thiefService.writeToInput(tag, xpaths.input); await thiefService.writeToInput(tag, xpaths.input, xpaths?.input_falback);
await delay(200); await delay(200);
@ -218,60 +245,72 @@ const handle = async (item: IItem) => {
// B1. Upload images // B1. Upload images
await uploadImages(item); await uploadImages(item);
await delay(400); await delay(200);
// B2. Write title // B2. Write title
thiefService.writeToInput(item.title, selectors.title_input); thiefService.writeToInput(item.title, selectors.title_input);
await delay(400); await delay(200);
// B3. Write price // B3. Write price
thiefService.writeToInput(String(item.price), selectors.price_input); thiefService.writeToInput(String(item.price), selectors.price_input);
await delay(400); await delay(200);
// B4. Select category // B4. Select category
await chooseSelect(item.category, selectors.category_select); await chooseSelect(item.category, selectors.category_select);
await delay(400); await delay(200);
// B5. Select condition // B5. Select condition
await chooseSelect(item.condition, selectors.condition_select); await chooseSelect(item.condition, selectors.condition_select);
if (item.brand) { if (item.brand) {
await delay(400); await delay(200);
// B3. Write price // B6. Write brand
thiefService.writeToInput(item.brand, selectors.brand_input); await thiefService.writeToInput(
item.brand,
selectors.brand_input,
selectors.brand_input_fallback
);
} }
await delay(400); await delay(200);
// B3. Write price // B7. Write description
await thiefService.writeToInput( await thiefService.writeToInput(
item.description, item.description,
selectors.description_input selectors.description_input,
selectors.description_input_falback
); );
await delay(400); await delay(200);
await writeTags(item.tags, selectors.tags_input); await writeTags(item.tags, selectors.tags_input);
await delay(400); await delay(200);
// B3. Write price // B8. Write sku
thiefService.writeToInput(item.sku, selectors.sku_input); await thiefService.writeToInput(
item.sku,
selectors.sku_input,
selectors.sku_input_fallback
);
if (item?.location) { if (item?.location) {
await delay(400); await delay(200);
await chooseLocation(item.location, selectors.location_select); await chooseLocation(item.location, selectors.location_select);
} }
await delay(400); await delay(200);
await clickNext(); await clickNext();
// await delay(400); if (import.meta.env.ENV === "prod") {
await delay(200);
await clickPublist();
}
return true; return true;
}; };
const closeTab = async (data: IItem) => { const closeTab = async (data: IItem) => {
await delay(2000); await delay(1000);
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
type: "close-tab", type: "close-tab",
@ -292,7 +331,7 @@ port.onMessage.addListener(async (message) => {
// data.images = ["images/1.png", "images/2.png"]; // data.images = ["images/1.png", "images/2.png"];
try { try {
await delay(2000); await delay(500);
await handle(data); await handle(data);
} catch (error) { } catch (error) {
await finistPublist(data, { await finistPublist(data, {
@ -306,3 +345,15 @@ port.onMessage.addListener(async (message) => {
} }
} }
}); });
// async function init() {
// const { data } = await axios.get("products/27");
// if (!data.data) return;
// const item = data.data as IItem;
// await handle(item);
// }
// init();

View File

@ -34,15 +34,19 @@ class ThiefService {
async getElementByXPath( async getElementByXPath(
xpath: string, xpath: string,
retryCount: number = 3, {
delay: number = 400 retryCount = 2,
delay = 100,
xpathFallback,
}: { retryCount?: number; delay?: number; xpathFallback?: string } = {}
): Promise<HTMLElement | null> { ): Promise<HTMLElement | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
let attempts = 0; let attempts = 0;
let usingFallback = false;
const tryFind = () => { const tryFind = () => {
const el: Node | null = document.evaluate( const el: Node | null = document.evaluate(
xpath, usingFallback && xpathFallback ? xpathFallback : xpath,
document, document,
null, null,
XPathResult.FIRST_ORDERED_NODE_TYPE, XPathResult.FIRST_ORDERED_NODE_TYPE,
@ -58,7 +62,13 @@ class ThiefService {
if (attempts < retryCount) { if (attempts < retryCount) {
setTimeout(tryFind, delay); setTimeout(tryFind, delay);
} else { } else {
resolve(null); if (!usingFallback && xpathFallback) {
usingFallback = true;
attempts = 0;
setTimeout(tryFind, delay);
} else {
resolve(null);
}
} }
}; };
@ -151,8 +161,14 @@ class ThiefService {
el.dispatchEvent(new Event("change", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true }));
} }
writeToInput = async (value: string, xpath: string) => { writeToInput = async (
const el = (await this.getElementByXPath(xpath)) as HTMLInputElement; value: string,
xpath: string,
xpathFallback?: string
) => {
const el = (await this.getElementByXPath(xpath, {
xpathFallback,
})) as HTMLInputElement;
if (!el) throw new Error("Xpath is not found with value: " + value); if (!el) throw new Error("Xpath is not found with value: " + value);

View File

@ -1673,7 +1673,7 @@ export function DataTable<T extends Record<string, any>>({
<div className="relative w-full max-w-sm"> <div className="relative w-full max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Tìm kiếm..." placeholder="Search..."
value={localSearchTerm} value={localSearchTerm}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
onKeyPress={handleSearchKeyPress} onKeyPress={handleSearchKeyPress}

View File

@ -98,6 +98,7 @@ export default function ProductModal({
const watchedTags = form.watch("tags"); const watchedTags = form.watch("tags");
const conditions = ["New", "Used - like new", "Used - good", "Used - fair"]; const conditions = ["New", "Used - like new", "Used - good", "Used - fair"];
const categories = ["Tools"];
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
@ -356,7 +357,23 @@ export default function ProductModal({
<FormItem> <FormItem>
<FormLabel>Category *</FormLabel> <FormLabel>Category *</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Tools" {...field} /> {/* <Input placeholder="Tools" {...field} /> */}
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c.toLowerCase()}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -379,7 +396,7 @@ export default function ProductModal({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{conditions.map((c) => ( {conditions.map((c) => (
<SelectItem key={c} value={c}> <SelectItem key={c} value={c.toLowerCase()}>
{c} {c}
</SelectItem> </SelectItem>
))} ))}

View File

@ -4,6 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import _ from "lodash"; import _ from "lodash";
import { import {
Archive, Archive,
Check,
Download, Download,
HistoryIcon, HistoryIcon,
Plus, Plus,
@ -12,6 +13,7 @@ import {
Trash2, Trash2,
UploadCloud, UploadCloud,
UserPlus, UserPlus,
X,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
@ -119,6 +121,11 @@ export default function List() {
); );
}, },
}, },
{
key: "condition",
label: "Condition",
displayType: "badge",
},
{ {
key: "brand", key: "brand",
label: "Brand", label: "Brand",
@ -135,6 +142,26 @@ export default function List() {
displayType: "datetime", displayType: "datetime",
sortable: true, sortable: true,
}, },
{
key: "histories",
label: "Published",
displayType: "custom",
render(value, row) {
return (
<div className="w-full flex items-center justify-center">
{row.histories.some((item) => item.published) ? (
<Button size={"xs"} className="w-fit" variant={"ghost"}>
<Check size={14} className="text-green-500" />
</Button>
) : (
<Button size={"xs"} className="w-fit" variant={"ghost"}>
<X size={14} className="text-red-500" />
</Button>
)}
</div>
);
},
},
]; ];
const filterOptions: FilterOption[] = [ const filterOptions: FilterOption[] = [
@ -144,9 +171,19 @@ export default function List() {
type: "text" as const, type: "text" as const,
}, },
{ {
key: "created_at", key: "sku",
label: "Created at", label: "Sku",
type: "dateRange" as const, type: "text" as const,
},
{
key: "description",
label: "Description",
type: "text" as const,
},
{
key: "title",
label: "Title",
type: "text" as const,
}, },
]; ];
@ -158,7 +195,7 @@ export default function List() {
variant: "destructive" as const, variant: "destructive" as const,
action: handleBulkDelete, action: handleBulkDelete,
confirmMessage: confirmMessage:
"Are you sure you want to delete the selected users? This action cannot be undone.", "Are you sure you want to delete the selected products? This action cannot be undone.",
}, },
]; ];
@ -232,7 +269,7 @@ export default function List() {
publistMutation.mutateAsync(data), // gọi function để trả về promise publistMutation.mutateAsync(data), // gọi function để trả về promise
{ {
loading: "Loading...", loading: "Loading...",
success: (result) => `${result.name} toast has been added`, success: (result) => `${data?.title} toast has been added`,
error: "Error", error: "Error",
} }
); );

View File

@ -21,12 +21,14 @@
"@nestjs/platform-express": "^11.1.5", "@nestjs/platform-express": "^11.1.5",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.11.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager": "^7.0.1", "cache-manager": "^7.0.1",
"cacheable": "^1.10.2", "cacheable": "^1.10.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"file-type": "^21.0.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -5202,7 +5204,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
@ -5226,6 +5227,17 @@
"fastq": "^1.17.1" "fastq": "^1.17.1"
} }
}, },
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@ -6012,7 +6024,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@ -6363,7 +6374,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@ -6618,7 +6628,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -7488,6 +7497,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -7533,10 +7562,9 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@ -7563,7 +7591,6 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@ -7573,7 +7600,6 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
@ -7911,7 +7937,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@ -10716,6 +10741,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -37,12 +37,14 @@
"@nestjs/platform-express": "^11.1.5", "@nestjs/platform-express": "^11.1.5",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.11.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager": "^7.0.1", "cache-manager": "^7.0.1",
"cacheable": "^1.10.2", "cacheable": "^1.10.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"file-type": "^21.0.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -3,7 +3,8 @@ import { writeFile } from 'fs/promises';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import sizeOf from 'image-size'; import sizeOf from 'image-size';
import { promises } from 'fs';
import axios from 'axios';
@Injectable() @Injectable()
export class MediasService { export class MediasService {
private ROOT_MEDIA_FOLDER = './public'; private ROOT_MEDIA_FOLDER = './public';
@ -75,11 +76,97 @@ export class MediasService {
}; };
} }
async removeAvatar(avatar: string): Promise<void> { async saveBufferImage(
if (!avatar) return; buffer: Buffer,
filenamePrefix = 'image',
folder: string,
maxSizeInMB = 2,
mimeType: string = 'png',
): Promise<{
filename: string;
filepath: string;
url: string;
mimetype: string;
width?: number;
height?: number;
sizeInKB: number;
}> {
const sizeInBytes = buffer.length;
const sizeInMB = sizeInBytes / (1024 * 1024);
if (sizeInMB > maxSizeInMB) {
throw new BadRequestException(`Ảnh vượt quá giới hạn ${maxSizeInMB}MB`);
}
const sizeInKB = Math.round(sizeInBytes / 1024);
// Luôn xử lý path bắt đầu từ 'medias/avatars' // Tạo thư mục nếu chưa có
const relativePath = path.join('medias/avatars', avatar); // ✅ chính xác từ input: khangpn/avatar-xxx.jpg const fullPath = path.join(this.ROOT_MEDIA_FOLDER, folder);
if (!fs.existsSync(fullPath)) {
await promises.mkdir(fullPath, { recursive: true });
}
// Tạo tên file
const fileNameOnly = `${filenamePrefix}-${Date.now()}.${mimeType}`;
const filepath = path.join(fullPath, fileNameOnly);
// Ghi file
await writeFile(filepath, buffer);
// Lấy kích thước ảnh
let dimensions: { width?: number; height?: number } = {};
try {
dimensions = sizeOf(buffer);
} catch (_) {
// Không lấy được kích thước thì thôi
}
// Tạo url tương đối (giả sử serve public tại /uploads)
const relativeUrl = path.join(folder, fileNameOnly).replace(/\\/g, '/');
return {
filename: fileNameOnly,
filepath,
url: `/uploads/${relativeUrl}`,
mimetype: mimeType,
sizeInKB,
width: dimensions.width,
height: dimensions.height,
};
}
async downloadImageAndSave(
url: string,
folder: string,
path: string,
): Promise<{
filename: string;
filepath: string;
url: string;
mimetype: string;
width?: number;
height?: number;
sizeInKB: number;
}> {
try {
const response = await axios.get<ArrayBuffer>(url, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data);
const filename = await this.saveBufferImage(buffer, folder, path);
return filename;
} catch (error) {
console.warn('Failed to download image:', url, error);
throw error;
}
}
async removeMediasFolder(filepath: string, folder: string): Promise<void> {
if (!filepath) return;
// Luôn xử lý path bắt đầu từ 'medias/filepaths'
const relativePath = path.join('medias/' + folder, filepath); // ✅ chính xác từ input: khangpn/avatar-xxx.jpg
const fullPath = path.join(this.ROOT_MEDIA_FOLDER, relativePath); const fullPath = path.join(this.ROOT_MEDIA_FOLDER, relativePath);
const rootPath = path.resolve(this.ROOT_MEDIA_FOLDER); const rootPath = path.resolve(this.ROOT_MEDIA_FOLDER);

View File

@ -1,22 +1,24 @@
import { Product } from '@/entities/product.entity'; import { Product } from '@/entities/product.entity';
import { PublistHistory } from '@/entities/publist-history.entity';
import CoreService from '@/system/core/core-service'; import CoreService from '@/system/core/core-service';
import { SystemLang } from '@/system/lang/system.lang'; import { SystemLang } from '@/system/lang/system.lang';
import AppResponse from '@/system/response/ktq-response'; import AppResponse from '@/system/response/ktq-response';
import { urlToBase64 } from '@/ultils/fn';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { plainToClass } from 'class-transformer';
import { isURL } from 'class-validator';
import { fromEvent, map, Observable } from 'rxjs'; import { fromEvent, map, Observable } from 'rxjs';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { EventsService } from '../events/events.service'; import { EventsService } from '../events/events.service';
import { MediasService } from '../medias/medias.service'; import { MediasService } from '../medias/medias.service';
import { CreateProductDto } from './dtos/create-product.dto'; import { CreateProductDto } from './dtos/create-product.dto';
import { PublistFinishDto } from './dtos/publist-finish.dto'; import { PublistFinishDto } from './dtos/publist-finish.dto';
import { PublistHistory } from '@/entities/publist-history.entity'; import { Request } from 'express';
import { plainToClass } from 'class-transformer';
import { urlToBase64 } from '@/ultils/fn';
@Injectable() @Injectable()
export class ProductsService extends CoreService<Product> { export class ProductsService extends CoreService<Product> {
@ -36,10 +38,14 @@ export class ProductsService extends CoreService<Product> {
super( super(
repo, repo,
{ {
sortableColumns: ['id'], sortableColumns: ['id', 'description', 'title', 'sku', 'created_at'],
defaultSortBy: [['id', 'DESC']], defaultSortBy: [['id', 'DESC']],
filterableColumns: { filterableColumns: {
id: true, id: true,
description: true,
title: true,
sku: true,
created_at: true,
}, },
relations: { relations: {
histories: true, histories: true,
@ -49,20 +55,48 @@ export class ProductsService extends CoreService<Product> {
); );
} }
protected async afterDelete(
id: number,
data: Partial<Product>,
req: Request,
): Promise<void> {
// Xóa avatar nếu có
if (data.images?.length) {
for (const image of data.images) {
await this.mediasService.removeMediasFolder(
`${data.title.toLowerCase().replaceAll(' ', '-')}/${image}` as string,
'products',
);
}
}
}
async create(data: CreateProductDto): Promise<any> { async create(data: CreateProductDto): Promise<any> {
// Tạo entity từ DTO
const product = this.repo.create(data); const product = this.repo.create(data);
if (data.images && data.images.some((image) => image.startsWith('data'))) { if (data.images && data.images.length) {
const newImages = []; const newImages = [];
for (const image of data.images) {
const result = await this.mediasService.saveBase64Image(
image as string,
'product',
`medias/products/${data.title.toLowerCase().replaceAll(' ', '-')}`,
);
newImages.push(result.filename); for (const image of data.images) {
if (image.startsWith('data:')) {
const result = await this.mediasService.saveBase64Image(
image,
'product',
`medias/products/${data.title.toLowerCase().replaceAll(' ', '-')}`,
);
newImages.push(result.filename);
} else if (
isURL(image, { require_host: true, require_protocol: true })
) {
const result = await this.mediasService.downloadImageAndSave(
image,
'product',
`medias/products/${data.title.toLowerCase().replaceAll(' ', '-')}`,
);
newImages.push(result.filename);
} else {
newImages.push(image);
}
} }
if (newImages.length) { if (newImages.length) {
@ -70,7 +104,6 @@ export class ProductsService extends CoreService<Product> {
} }
} }
// Lưu vào database
await this.repo.save(product); await this.repo.save(product);
return AppResponse.toResponse(product); return AppResponse.toResponse(product);

View File

@ -187,8 +187,9 @@ export class UsersService extends CoreService<User> {
if (entity.avatar) { if (entity.avatar) {
// Nếu đã có avatar cũ, xóa avatar cũ // Nếu đã có avatar cũ, xóa avatar cũ
await this.mediasService.removeAvatar( await this.mediasService.removeMediasFolder(
`${entity.username}/${entity.avatar}` as string, `${entity.username}/${entity.avatar}` as string,
'avatars',
); );
} }
} }
@ -203,8 +204,9 @@ export class UsersService extends CoreService<User> {
): Promise<void> { ): Promise<void> {
// Nếu avatar được cập nhật thành null, xóa avatar cũ // Nếu avatar được cập nhật thành null, xóa avatar cũ
if (entity.avatar && dataUpdate.avatar === null) { if (entity.avatar && dataUpdate.avatar === null) {
await this.mediasService.removeAvatar( await this.mediasService.removeMediasFolder(
`${entity.username}/${entity.avatar}` as string, `${entity.username}/${entity.avatar}` as string,
'avatars',
); );
} }
@ -324,8 +326,9 @@ export class UsersService extends CoreService<User> {
protected async afterDelete(id: number, data: Partial<User>, req?: Request) { protected async afterDelete(id: number, data: Partial<User>, req?: Request) {
// Xóa avatar nếu có // Xóa avatar nếu có
if (data.avatar) { if (data.avatar) {
await this.mediasService.removeAvatar( await this.mediasService.removeMediasFolder(
`${data.username}/${data.avatar}` as string, `${data.username}/${data.avatar}` as string,
'avatars',
); );
} }
@ -340,8 +343,9 @@ export class UsersService extends CoreService<User> {
// Xóa avatar của tất cả người dùng bị xóa // Xóa avatar của tất cả người dùng bị xóa
for (const user of data) { for (const user of data) {
if (user.avatar) { if (user.avatar) {
await this.mediasService.removeAvatar( await this.mediasService.removeMediasFolder(
`${user.username}/${user.avatar}` as string, `${user.username}/${user.avatar}` as string,
'avatars',
); );
} }
} }