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) => {
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);
}
);
});

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",
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();

View File

@ -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);

View File

@ -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}

View File

@ -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>
))}

View File

@ -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",
}
);

View File

@ -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",

View File

@ -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",

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 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);

View File

@ -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);

View File

@ -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',
);
}
}