update selection fallback, update UI, update fillter server
|
|
@ -18,7 +18,7 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("New event:", data);
|
||||
queue.add(() => handlePublish(data));
|
||||
queue.add(() => handlePublish(data, 30000));
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
|
|
@ -27,7 +27,7 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||
})();
|
||||
|
||||
// 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) => {
|
||||
chrome.tabs.create(
|
||||
{ url: "https://www.facebook.com/marketplace/create/item", active: true },
|
||||
|
|
@ -35,6 +35,16 @@ async function handlePublish(data: any) {
|
|||
if (!tab?.id) return resolve();
|
||||
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) => {
|
||||
if (port.sender?.tab?.id === tabId) {
|
||||
port.postMessage({ type: "publist-event", payload: data });
|
||||
|
|
@ -45,11 +55,17 @@ async function handlePublish(data: any) {
|
|||
|
||||
const onTabClosed = (closedTabId: number) => {
|
||||
if (closedTabId === tabId) {
|
||||
chrome.tabs.onRemoved.removeListener(onTabClosed);
|
||||
resolve(); // Chỉ resolve khi tab này đã đóng
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
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",
|
||||
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:
|
||||
"/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:
|
||||
"/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: {
|
||||
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",
|
||||
|
|
@ -30,6 +36,8 @@ const selectors = {
|
|||
tags_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",
|
||||
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:
|
||||
"/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: {
|
||||
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:
|
||||
"/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:
|
||||
"/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:
|
||||
"/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",
|
||||
};
|
||||
|
||||
const getData = async () => {
|
||||
return new Promise((rev, rej) => {
|
||||
const url = chrome.runtime.getURL("data.json");
|
||||
// const getData = async () => {
|
||||
// return new Promise((rev, rej) => {
|
||||
// const url = chrome.runtime.getURL("data.json");
|
||||
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
rev(data as IItem[]);
|
||||
})
|
||||
.catch((err) => {
|
||||
rej(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
// fetch(url)
|
||||
// .then((res) => res.json())
|
||||
// .then((data) => {
|
||||
// rev(data as IItem[]);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// rej(err);
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
const uploadImages = async (item: IItem) => {
|
||||
// Tạo DataTransfer để giả lập FileList
|
||||
const dt: DataTransfer = new DataTransfer();
|
||||
|
||||
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) + "...");
|
||||
const file = thiefService.base64ToFile(
|
||||
image,
|
||||
base64,
|
||||
item.sku,
|
||||
thiefService.getImageExtension(image) || "jpg"
|
||||
);
|
||||
|
|
@ -106,15 +118,17 @@ const chooseSelect = async (
|
|||
thiefService.scrollToElement(el);
|
||||
thiefService.clickByPoint(el);
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
const container = await thiefService.getElementByXPath(xpaths.container);
|
||||
if (!container) throw new Error("Container xpath not found");
|
||||
|
||||
// Tìm phần tử con có nội dung giống value
|
||||
const matchingChild = Array.from(container.children).find(
|
||||
(child) =>
|
||||
child.textContent?.trim().toLocaleLowerCase() ===
|
||||
value.toLocaleLowerCase()
|
||||
const matchingChild = Array.from(container.children).find((child) =>
|
||||
child.textContent
|
||||
?.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/–/g, "-")
|
||||
.includes(value.toLocaleLowerCase())
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
|
||||
|
|
@ -127,13 +141,24 @@ const chooseSelect = async (
|
|||
|
||||
const chooseLocation = async (
|
||||
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");
|
||||
|
||||
thiefService.scrollToElement(container);
|
||||
|
|
@ -156,9 +181,11 @@ const chooseLocation = async (
|
|||
|
||||
const writeTags = async (
|
||||
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");
|
||||
|
||||
|
|
@ -166,7 +193,7 @@ const writeTags = async (
|
|||
|
||||
await delay(200);
|
||||
for (const tag of tags) {
|
||||
thiefService.writeToInput(tag, xpaths.input);
|
||||
await thiefService.writeToInput(tag, xpaths.input, xpaths?.input_falback);
|
||||
|
||||
await delay(200);
|
||||
|
||||
|
|
@ -218,60 +245,72 @@ const handle = async (item: IItem) => {
|
|||
// B1. Upload images
|
||||
await uploadImages(item);
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
// B2. Write title
|
||||
thiefService.writeToInput(item.title, selectors.title_input);
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
// B3. Write price
|
||||
thiefService.writeToInput(String(item.price), selectors.price_input);
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
// B4. Select category
|
||||
await chooseSelect(item.category, selectors.category_select);
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
// B5. Select condition
|
||||
await chooseSelect(item.condition, selectors.condition_select);
|
||||
|
||||
if (item.brand) {
|
||||
await delay(400);
|
||||
// B3. Write price
|
||||
thiefService.writeToInput(item.brand, selectors.brand_input);
|
||||
await delay(200);
|
||||
// B6. Write brand
|
||||
await thiefService.writeToInput(
|
||||
item.brand,
|
||||
selectors.brand_input,
|
||||
selectors.brand_input_fallback
|
||||
);
|
||||
}
|
||||
|
||||
await delay(400);
|
||||
// B3. Write price
|
||||
await delay(200);
|
||||
// B7. Write description
|
||||
await thiefService.writeToInput(
|
||||
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 delay(400);
|
||||
// B3. Write price
|
||||
thiefService.writeToInput(item.sku, selectors.sku_input);
|
||||
await delay(200);
|
||||
// B8. Write sku
|
||||
await thiefService.writeToInput(
|
||||
item.sku,
|
||||
selectors.sku_input,
|
||||
selectors.sku_input_fallback
|
||||
);
|
||||
|
||||
if (item?.location) {
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
|
||||
await chooseLocation(item.location, selectors.location_select);
|
||||
}
|
||||
|
||||
await delay(400);
|
||||
await delay(200);
|
||||
|
||||
await clickNext();
|
||||
|
||||
// await delay(400);
|
||||
if (import.meta.env.ENV === "prod") {
|
||||
await delay(200);
|
||||
await clickPublist();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const closeTab = async (data: IItem) => {
|
||||
await delay(2000);
|
||||
await delay(1000);
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: "close-tab",
|
||||
|
|
@ -292,7 +331,7 @@ port.onMessage.addListener(async (message) => {
|
|||
// data.images = ["images/1.png", "images/2.png"];
|
||||
|
||||
try {
|
||||
await delay(2000);
|
||||
await delay(500);
|
||||
await handle(data);
|
||||
} catch (error) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -34,15 +34,19 @@ class ThiefService {
|
|||
|
||||
async getElementByXPath(
|
||||
xpath: string,
|
||||
retryCount: number = 3,
|
||||
delay: number = 400
|
||||
{
|
||||
retryCount = 2,
|
||||
delay = 100,
|
||||
xpathFallback,
|
||||
}: { retryCount?: number; delay?: number; xpathFallback?: string } = {}
|
||||
): Promise<HTMLElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
let usingFallback = false;
|
||||
|
||||
const tryFind = () => {
|
||||
const el: Node | null = document.evaluate(
|
||||
xpath,
|
||||
usingFallback && xpathFallback ? xpathFallback : xpath,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
|
|
@ -58,7 +62,13 @@ class ThiefService {
|
|||
if (attempts < retryCount) {
|
||||
setTimeout(tryFind, delay);
|
||||
} 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 }));
|
||||
}
|
||||
|
||||
writeToInput = async (value: string, xpath: string) => {
|
||||
const el = (await this.getElementByXPath(xpath)) as HTMLInputElement;
|
||||
writeToInput = async (
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1673,7 +1673,7 @@ export function DataTable<T extends Record<string, any>>({
|
|||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Tìm kiếm..."
|
||||
placeholder="Search..."
|
||||
value={localSearchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export default function ProductModal({
|
|||
const watchedTags = form.watch("tags");
|
||||
|
||||
const conditions = ["New", "Used - like new", "Used - good", "Used - fair"];
|
||||
const categories = ["Tools"];
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
|
|
@ -356,7 +357,23 @@ export default function ProductModal({
|
|||
<FormItem>
|
||||
<FormLabel>Category *</FormLabel>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -379,7 +396,7 @@ export default function ProductModal({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conditions.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
<SelectItem key={c} value={c.toLowerCase()}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||
import _ from "lodash";
|
||||
import {
|
||||
Archive,
|
||||
Check,
|
||||
Download,
|
||||
HistoryIcon,
|
||||
Plus,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
Trash2,
|
||||
UploadCloud,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
|
@ -119,6 +121,11 @@ export default function List() {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "condition",
|
||||
label: "Condition",
|
||||
displayType: "badge",
|
||||
},
|
||||
{
|
||||
key: "brand",
|
||||
label: "Brand",
|
||||
|
|
@ -135,6 +142,26 @@ export default function List() {
|
|||
displayType: "datetime",
|
||||
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[] = [
|
||||
|
|
@ -144,9 +171,19 @@ export default function List() {
|
|||
type: "text" as const,
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Created at",
|
||||
type: "dateRange" as const,
|
||||
key: "sku",
|
||||
label: "Sku",
|
||||
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,
|
||||
action: handleBulkDelete,
|
||||
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
|
||||
{
|
||||
loading: "Loading...",
|
||||
success: (result) => `${result.name} toast has been added`,
|
||||
success: (result) => `${data?.title} toast has been added`,
|
||||
error: "Error",
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@
|
|||
"@nestjs/platform-express": "^11.1.5",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.0.1",
|
||||
"cacheable": "^1.10.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"file-type": "^21.0.0",
|
||||
"helmet": "^8.1.0",
|
||||
"image-size": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -5202,7 +5204,6 @@
|
|||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
|
|
@ -5226,6 +5227,17 @@
|
|||
"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": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
|
||||
|
|
@ -6012,7 +6024,6 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
|
|
@ -6363,7 +6374,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
|
|
@ -6618,7 +6628,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -7488,6 +7497,26 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
|
@ -7533,10 +7562,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
|
||||
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
|
||||
"dev": true,
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -7563,7 +7591,6 @@
|
|||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
|
|
@ -7573,7 +7600,6 @@
|
|||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
|
|
@ -7911,7 +7937,6 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
|
|
@ -10716,6 +10741,12 @@
|
|||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@
|
|||
"@nestjs/platform-express": "^11.1.5",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.0.1",
|
||||
"cacheable": "^1.10.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"file-type": "^21.0.0",
|
||||
"helmet": "^8.1.0",
|
||||
"image-size": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 956 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
|
@ -3,7 +3,8 @@ import { writeFile } from 'fs/promises';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import sizeOf from 'image-size';
|
||||
|
||||
import { promises } from 'fs';
|
||||
import axios from 'axios';
|
||||
@Injectable()
|
||||
export class MediasService {
|
||||
private ROOT_MEDIA_FOLDER = './public';
|
||||
|
|
@ -75,11 +76,97 @@ export class MediasService {
|
|||
};
|
||||
}
|
||||
|
||||
async removeAvatar(avatar: string): Promise<void> {
|
||||
if (!avatar) return;
|
||||
async saveBufferImage(
|
||||
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'
|
||||
const relativePath = path.join('medias/avatars', avatar); // ✅ chính xác từ input: khangpn/avatar-xxx.jpg
|
||||
// Tạo thư mục nếu chưa có
|
||||
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 rootPath = path.resolve(this.ROOT_MEDIA_FOLDER);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import { Product } from '@/entities/product.entity';
|
||||
import { PublistHistory } from '@/entities/publist-history.entity';
|
||||
import CoreService from '@/system/core/core-service';
|
||||
import { SystemLang } from '@/system/lang/system.lang';
|
||||
import AppResponse from '@/system/response/ktq-response';
|
||||
import { urlToBase64 } from '@/ultils/fn';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { isURL } from 'class-validator';
|
||||
import { fromEvent, map, Observable } from 'rxjs';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EventsService } from '../events/events.service';
|
||||
import { MediasService } from '../medias/medias.service';
|
||||
import { CreateProductDto } from './dtos/create-product.dto';
|
||||
import { PublistFinishDto } from './dtos/publist-finish.dto';
|
||||
import { PublistHistory } from '@/entities/publist-history.entity';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { urlToBase64 } from '@/ultils/fn';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService extends CoreService<Product> {
|
||||
|
|
@ -36,10 +38,14 @@ export class ProductsService extends CoreService<Product> {
|
|||
super(
|
||||
repo,
|
||||
{
|
||||
sortableColumns: ['id'],
|
||||
sortableColumns: ['id', 'description', 'title', 'sku', 'created_at'],
|
||||
defaultSortBy: [['id', 'DESC']],
|
||||
filterableColumns: {
|
||||
id: true,
|
||||
description: true,
|
||||
title: true,
|
||||
sku: true,
|
||||
created_at: true,
|
||||
},
|
||||
relations: {
|
||||
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> {
|
||||
// Tạo entity từ DTO
|
||||
const product = this.repo.create(data);
|
||||
|
||||
if (data.images && data.images.some((image) => image.startsWith('data'))) {
|
||||
if (data.images && data.images.length) {
|
||||
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) {
|
||||
|
|
@ -70,7 +104,6 @@ export class ProductsService extends CoreService<Product> {
|
|||
}
|
||||
}
|
||||
|
||||
// Lưu vào database
|
||||
await this.repo.save(product);
|
||||
|
||||
return AppResponse.toResponse(product);
|
||||
|
|
|
|||
|
|
@ -187,8 +187,9 @@ export class UsersService extends CoreService<User> {
|
|||
|
||||
if (entity.avatar) {
|
||||
// Nếu đã có avatar cũ, xóa avatar cũ
|
||||
await this.mediasService.removeAvatar(
|
||||
await this.mediasService.removeMediasFolder(
|
||||
`${entity.username}/${entity.avatar}` as string,
|
||||
'avatars',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -203,8 +204,9 @@ export class UsersService extends CoreService<User> {
|
|||
): Promise<void> {
|
||||
// Nếu avatar được cập nhật thành null, xóa avatar cũ
|
||||
if (entity.avatar && dataUpdate.avatar === null) {
|
||||
await this.mediasService.removeAvatar(
|
||||
await this.mediasService.removeMediasFolder(
|
||||
`${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) {
|
||||
// Xóa avatar nếu có
|
||||
if (data.avatar) {
|
||||
await this.mediasService.removeAvatar(
|
||||
await this.mediasService.removeMediasFolder(
|
||||
`${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
|
||||
for (const user of data) {
|
||||
if (user.avatar) {
|
||||
await this.mediasService.removeAvatar(
|
||||
await this.mediasService.removeMediasFolder(
|
||||
`${user.username}/${user.avatar}` as string,
|
||||
'avatars',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||