Route: /dashboard
Source Frontend: admin/src/views/dashboard1/
Source Backend: api/internal/campaign/
Database: MySQL (bảng campaigns, projects, project_details)
Module Thống kê Hệ thống (Dashboard1) là nơi quản lý và giám sát toàn bộ các chiến dịch traffic đang chạy trong hệ thống. Nó chia làm ba tab tương ứng với ba mục tiêu nghiệp vụ khác nhau, mỗi tab được lọc bởi một tổ hợp flag khác nhau ở tầng database.
Ba tab:
Mỗi tab truy vấn một tập hợp con khác nhau của cùng một bảng projects, phân biệt bằng các cột flag: only_ads, is_kill, traffic_type.
Đây là điểm quan trọng nhất của toàn bộ module. Ba tab không phải ba loại campaign khác nhau — tất cả đều dùng chung bảng projects. Sự phân loại chỉ dựa vào flag của từng project.
| Trường | Click Quảng Cáo | Negative SEO | Traffic Tự Nhiên |
|---|---|---|---|
only_ads | true | false | false |
is_kill | false | true | false |
traffic_type | 1 | 1 | 1 |
organic_engine | 0 hoặc 1 | 0 hoặc 1 | 0 hoặc 1 |
| Mục tiêu | Click vào Ads | Phá traffic đối thủ | Tăng organic traffic |
| Hiển thị ở tab | Getlist (/campaign/) | /get-negative-seo-data | /get-home-traffic-data |
Giải thích cụ thể:
only_ads = true, is_kill = false: Worker sẽ tìm kiếm từ khóa, cuộn đến phần Ads được đánh dấu "Sponsored", click vào đúng URL của domain target. Đây là loại tốn tiền nhất cho đối thủ.
only_ads = false, is_kill = true: Worker click ngẫu nhiên vào nhiều link tìm kiếm, kể cả link đối thủ, để tạo ra lượng traffic không có giá trị chuyển đổi. Mục đích làm tăng tỷ lệ bounce, giảm Quality Score của Ads đối thủ.
only_ads = false, is_kill = false: Worker mô phỏng người dùng thật, tìm kiếm từ khóa, click vào kết quả organic của domain target, cuộn trang, ở lại đủ lâu. Mục đích gửi tín hiệu tích cực đến Google để tăng rank SEO.
File: admin/src/views/dashboard1/IndexView.vue
IndexView là container đơn giản với ba RouterLink và một <RouterView>. Nó không có state, không fetch data. Mỗi sub-tab tự quản lý toàn bộ vòng đời của mình.
Thanh tab pill navigation dùng CSS class .tab-item với background-color: #0a4e4f khi active. Không có kiểm soát phân quyền theo user ở tab này — tất cả user đều thấy cả ba tab.
Routes tương ứng:
RouteName.dashboard.click_ads → ClickAds.vueRouteName.dashboard.negative_seo → NegativeSeo.vueRouteName.dashboard.traffic_natural → TrafficNatural.vueFile: admin/src/views/dashboard1/click-ads/ClickAds.vue
Khi component mount (onMounted):
urlParams.start_date và urlParams.end_date từ dateRange hiện tại (mặc định = ngày hôm nay).state.isPending = true.fetchData() và locationStore.fetchData() (để load danh sách địa điểm cho modal tạo project).state.isPending = false.Khi component unmount (onUnmounted):
clickAdsStore.$reset() — xóa toàn bộ state về giá trị mặc định ban đầu.Hàm fetchData:
const fetchData = async () => {
await clickAdsStore.fetchData()
if (clickAdsStore.listData?.data) {
// Lưu bản sao deep clone để làm dữ liệu gốc cho filter
defaultCampaigns.value = JSON.parse(JSON.stringify(clickAdsStore.listData.data))
// Khôi phục lại các filter đã lưu trong store cho từng campaign
clickAdsStore.listData.data.forEach((item) => {
const savedFilters = clickAdsStore.campaignFilters[item.id]
if (savedFilters?.dateRange !== null) {
filtering.onChangeFilterProject(item.id, 'dateRange', savedFilters.dateRange)
}
if (savedFilters?.status?.length > 0) {
filtering.onChangeFilterProject(item.id, 'status', savedFilters.status)
}
})
}
}
Quan trọng: Sau khi fetch, hệ thống luôn khôi phục lại các filter trước đó (tìm kiếm theo domain, lọc theo status) cho từng campaign item. Điều này giúp người dùng không mất filter khi dữ liệu được refresh.
dateRange là ref<[number, number] | null> với giá trị mặc định là ngày hôm nay:
const now = new Date()
const dateRange = ref<[number, number] | null>([now.getTime(), now.getTime()])
Khi dateRange thay đổi (watch):
fetchData() với date range mới.Hàm onChangeFilter:
const onChangeFilter = (newValue: [number, number] | null) => {
if (!newValue) {
clickAdsStore.urlParams.start_date = null
clickAdsStore.urlParams.end_date = null
return
}
clickAdsStore.urlParams.start_date = dateFns.format(dateRange.value[0], 'yyyy-MM-dd')
clickAdsStore.urlParams.end_date = dateFns.format(dateRange.value[1], 'yyyy-MM-dd')
}
Params gửi lên API có format yyyy-MM-dd (ví dụ: 2026-03-21).
File: admin/src/views/dashboard1/click-ads/components/CampaignItem.vue
Hiển thị một Campaign với toàn bộ danh sách project bên dưới.
Header của CampaignItem:
campaign.name) — Truncate ở 160px, hover show tooltip đầy đủ.campaign.projects.length (tổng số project trong campaign, không phân trang).useCampaignOperations.totalTraffic(item).apiProjects.bulkUpdate({ ids, fields: { status: 1 } }) với tất cả project ID trong campaign.status: 0. Cả hai đều hiển thị window.$dialog.warning để xác nhận trước.create-project lên ClickAds.vue, set clickAdsStore.campaignId = campaign.id.number[].export, gọi API xuất Excel cho campaign cụ thể.Hàm handleToggleAll:
const handleToggleAll = (status: number) => {
window.$dialog.warning({
title: `Xác nhận ${status === 1 ? 'Bật' : 'Tắt'} tất cả`,
content: `Bạn có chắc chắn muốn ... ${props.campaign.projects.length} dự án?`,
onPositiveClick: async () => {
const ids = props.campaign.projects.map((p) => p.id)
await apiProjects.bulkUpdate({ ids, fields: { status } })
await clickAdsStore.fetchData() // Refresh toàn bộ trang
}
})
}
File: admin/src/views/dashboard1/click-ads/components/ListGroup.vue
Đây là bảng dữ liệu thực sự hiển thị các project trong một campaign.
Pagination phía client:
currentPage và pageSize (mặc định 20) là ref local — không yêu cầu thêm API call khi chuyển trang.paginatedData = computed(() => projects.value.slice((currentPage-1)*pageSize, currentPage*pageSize))projects.length thay đổi: Reset về trang 1 và xóa selection.Bulk Selection:
useBulkSelection<Task.Item>.apiProjects.bulkUpdate({ ids, fields: { status: 1 } })apiProjects.bulkUpdate({ ids, fields: { status: 0 } })apiProjects.bulkDelete(uniqueIds) — lấy project_id từ task, dedup bằng Set.Lưu ý về ID trong bulk delete: Bảng hiển thị Task.Item, mỗi task có project_id. Khi xóa, hệ thống lấy project_id từ các task đã chọn, loại trùng lặp bằng [...new Set(ids)], sau đó gọi bulkDelete.
File: admin/src/views/dashboard1/click-ads/composables/useDataTableColumns.ts
| Cột | Key | Mô tả | Sortable |
|---|---|---|---|
| Selection | - | Checkbox chọn nhiều, fixed left | - |
| STT | id | Số thứ tự (tính theo currentPage×pageSize) | - |
| Tên miền | links | Lấy links[0], sort alphabetical | Có |
| Từ khóa | search_keyword | Truncate + tooltip | Có |
| Vị trí | latest_rank | Rank hiện tại, sort numeric | Có |
| Ngày tạo | created_at | Format dateTime, sort by timestamp | Có |
| Thời gian chết | state_time | Thời điểm project chuyển trạng thái | Có |
| Tình trạng | domain_type | Render badge màu theo state | - |
| Traffic | total_process | Hiển thị total_success / total_process | Có |
| Chi tiết | actions | Nút action (edit, chart,...), fixed right | - |
| Trạng thái | domain_type | Toggle on/off | - |
Cột Traffic:
render: (row: any) => {
return h('span', { class: 'font-medium' }, [
h('span', { class: 'text-[#18A058]' }, $n(row.total_success)), // Màu xanh
' / ',
h('span', {}, $n(row.total_process)) // Màu đen
])
}
Ý nghĩa: total_success là số lượt traffic đã thực hiện thành công, total_process là budget tổng được phân bổ cho project.
Cột Tình trạng:
Hiển thị badge với màu sắc dựa theo state và status của project:
state = State_Kill (1) → Badge "Giết" màu đỏstate = State_Dead (2) → Badge "Đã chết" màu xámstatus = 0, state = State_Unknown → Badge "Không hoạt động"status = 1, state = State_Unknown → Badge "Hoạt động" màu xanhTrong ClickAds.vue có 6 modal:
CampaignItem, set clickAdsStore.campaignId = id.admin/src/views/task/components/CreateModal.vue. Khi mở, clickAdsStore.campaignId phải đã được set.clickAdsStore.trafficChartData.deathTime).Mở ChartDetailRanking:
stateModal.isShowChartDetailRanking = truetrafficRankingData:
dates: Mảng ngày trên trục X.ranking: Vị trí SEO theo ngày.deathTime: Thời điểm project bị đánh dấu Dead.total_success: Mảng tổng traffic thành công theo ngày.File: admin/src/views/dashboard1/click-ads/stores/clickAdsStore.ts
State quan trọng:
{
listData: Pagination<Campaign.Item> | undefined, // Dữ liệu hiển thị chính
listDataFilter: Pagination<Campaign.Item> | undefined, // Dữ liệu sau khi filter client-side
urlParams: {
page: 1,
limit: 20, // Số campaign mỗi trang
keywords: '', // Tìm kiếm theo tên campaign
start_date: null, // yyyy-MM-dd
end_date: null
},
campaignId: null, // Campaign đang được chọn (để tạo/sửa)
campaignFilters: {}, // Lưu filter của từng campaign: { [campaignId]: { dateRange, status } }
campaignFiltersSearch: {}, // Lưu search text của từng campaign: { [campaignId]: { searchText } }
trafficRankingData: {
dates: [],
ranking: [],
deathTime: [],
total_success: []
},
state: {
isFetching: true,
isFetchingTrafficChart: false,
isFetchingTrafficChartDetail: false
}
}
campaignFilters và campaignFiltersSearch:
Đây là cơ chế lưu trữ filter riêng cho từng campaign item. Mỗi campaign có thể có một bộ filter độc lập. Khi user filter theo status ở Campaign A, Campaign B giữ nguyên trạng thái filter của mình.
File: admin/src/views/dashboard1/negative-seo/NegativeSeo.vue
Negative SEO có kiến trúc UI hoàn toàn khác với Click Ads:
Sở dĩ cấu trúc khác vì trong Negative SEO, quan tâm chính không phải campaign nào đang chạy, mà là domain nào đang bị tấn công.
API endpoint cũng khác: /campaign/get-negative-seo-data thay vì /campaign/ (list campaign).
Mỗi item trong negativeStore.listData là một domain đang bị tấn công:
interface NegativeSeo.Item {
domain: string // Domain đối thủ (lấy từ links[0] của project)
data_keywords: NegativeSeo.dataKeywords[] // Danh sách keyword đang bắn vào domain này
}
interface NegativeSeo.dataKeywords {
project_id: number // ID project MySQL
campaign_id: number
search_keyword: string // Từ khóa tìm kiếm
ranking: number // Vị trí tốt nhất tìm thấy (MIN(pd.rank))
total_process: number // SUM(pd.total_success) — traffic đã bắn
keyword_created: string // Ngày tạo project
status: number
state: number
}
Query SQL tạo ra dữ liệu này (từ get_negative_seo.go):
SELECT
p.id as project_id,
p.campaign_id,
p.search_keyword,
REPLACE(JSON_EXTRACT(p.links, '$[0]'), '"', '') AS domain,
MIN(pd.rank) as ranking,
COALESCE(SUM(pd.total_success), 0) as total_process,
p.created_at as keyword_created,
p.status,
p.state
FROM projects p
JOIN project_details pd ON pd.project_id = p.id
WHERE pd.date >= ? AND pd.date <= ?
AND p.organic_engine IN (0, 1)
AND p.only_ads = false
AND p.is_kill = true -- ← Flag bắt buộc để vào tab này
AND p.traffic_type = 1
GROUP BY p.id
ORDER BY p.created_at ASC
Backend sau đó gọi service để group kết quả theo domain, không phải theo project_id.
Cột domain được lấy từ JSON trong MySQL:
REPLACE(JSON_EXTRACT(p.links, '$[0]'), '"', '') AS domain
Cột links lưu dạng JSON array ["https://domain.com"]. JSON_EXTRACT lấy phần tử đầu tiên, REPLACE loại bỏ dấu nháy kép.
File: admin/src/views/dashboard1/negative-seo/stores/negativeStore.ts
Khác với clickAdsStore, store này không có client-side filter phức tạp. Hàm fetchData đơn giản hơn:
async fetchData() {
this.state.isFetching = true
const res = await apiNegativeSeo.getList({ ...this.urlParams })
const dataRes = res.data.data
// Thiết lập default expanded cho NCollapse
this.collapseDefault.parent = dataRes.map((item) => item.domain) // Key theo domain
this.collapseDefault.childrend = dataRes.flatMap((item) =>
(item.data_keywords ?? []).map((kw) => kw.project_id)
)
this.listData = dataRes
}
Lưu ý collapseDefault.childrend lưu project_id (number), trong khi Click Ads lưu id (number). Đây là sự không nhất quán trong naming nhưng không ảnh hưởng runtime.
File: admin/src/views/dashboard1/traffic-natural/TrafficNatural.vue
Cấu trúc gần như giống hệt Negative SEO — tổ chức theo Domain, dùng NCollapse. Các điểm khác biệt duy nhất:
useTrafficNaturalStore (tách biệt hoàn toàn với negativeStore)./campaign/get-home-traffic-data.is_kill = false thay vì true.SQL query tương ứng (từ get_home_traffic.go):
WHERE p.organic_engine IN (0, 1)
AND p.only_ads = false -- Giống Negative SEO
AND p.is_kill = false -- ← Điểm khác biệt duy nhất với Negative SEO
AND p.traffic_type = 1
Có nghĩa là: Nếu một project được tạo với only_ads=false, is_kill=false thì sẽ hiện ở tab Traffic Tự Nhiên. Nếu đổi is_kill=true thì project đó sẽ chuyển sang tab Negative SEO (không còn hiện ở Traffic Tự Nhiên nữa).
File: api/internal/campaign/handler/handler.go
Base route: /campaign (prefix)
| Method | Path | Handler | Mô tả |
|---|---|---|---|
| GET | /campaign/ | GetListWithPagination | Dữ liệu tab Click Ads |
| GET | /campaign/get-negative-seo-data | GetNegativeSeoData | Dữ liệu tab Negative SEO |
| GET | /campaign/get-home-traffic-data | GetHomeTrafficData | Dữ liệu tab Traffic Tự Nhiên |
| GET | /campaign/export | ExportCampaign | Xuất Excel Click Ads |
| GET | /campaign/export-negative-seo-data | ExportNegativeSeoData | Xuất Excel Negative SEO (tất cả) |
| GET | /campaign/export-negative-seo-data-by-ids | ExportNegativeSeoByIds | Xuất Excel Negative SEO theo ID |
| GET | /campaign/export-home-traffic-data | ExportHomeTraffic | Xuất Excel Home Traffic |
| GET | /campaign/export-home-traffic-data-by-ids | ExportHomeTrafficByIds | Xuất theo ID |
| GET | /campaign/summary-data | SummaryData | Tổng hợp số liệu |
| POST | /campaign/ | Create | Tạo campaign |
| GET | /campaign/:id | GetDetail | Chi tiết campaign |
| PUT | /campaign/:id | Update | Cập nhật campaign |
| DELETE | /campaign/:id | Delete | Xóa campaign |
| GET | /campaign/fired/:type | GetFired | Lấy campaign dùng cho ConfirmFire (type: gg/cc) |
| GET | /campaign/get-all | GetList | Lấy tất cả campaign (không phân trang) |
File: api/internal/campaign/repository/get_list_with_pagination.go
Đây là query phức tạp nhất trong toàn bộ hệ thống, phục vụ tab Click Ads.
Tổng quan cấu trúc:
keywords).start_date/end_date, thực hiện hai nhánh query khác nhau.Khi params.StartDate == nil || params.EndDate == nil:
WITH ProjectStats AS (
SELECT
project_id,
SUM(total_success) as total_success,
COUNT(*) as detail_count
FROM project_details
GROUP BY project_id -- Tính tổng toàn thời gian
)
SELECT
p.*,
COALESCE(ps.total_success, 0) as total_success
FROM projects p
LEFT JOIN ProjectStats ps ON ps.project_id = p.id
WHERE p.campaign_id IN (...) -- Chỉ lấy project thuộc các campaign hiện tại
{statusFilter} -- Filter thêm theo status/state nếu có
ORDER BY p.id DESC
Sau đó, với mỗi project, lấy toàn bộ project_details không filter ngày.
Khi có start_date và end_date:
WITH ProjectStats AS (
SELECT
project_id,
SUM(total_success) as total_success
FROM project_details
WHERE date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) -- Chỉ tính trong khoảng ngày
GROUP BY project_id
)
SELECT p.*, COALESCE(ps.total_success, 0) as total_success
FROM projects p
LEFT JOIN ProjectStats ps ON ps.project_id = p.id
WHERE p.campaign_id IN (...)
AND (
ps.project_id IS NOT NULL -- Có data trong khoảng ngày này
OR p.status = 1 -- Hoặc đang chạy
OR (DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?) -- Hoặc được tạo trong khoảng
)
{statusFilter}
ORDER BY p.id DESC
Điều kiện OR quan trọng: Một project hiển thị nếu thỏa ít nhất một trong:
Sau khi lấy project, query project_details batch theo project_id IN (?) và filter thêm date BETWEEN ? AND ?.
Cơ chế Map dữ liệu:
// Map details vào projects
detailsByProjectID := make(map[uint][]campaign.ProjectDetails)
for _, detail := range allDetails {
detailsByProjectID[detail.ProjectID] = append(...)
}
for i := range projects {
projects[i].ProjectDetails = detailsByProjectID[projects[i].ID]
}
// Map projects vào campaigns
projectsByCampaignID := make(map[uint][]campaign.Project)
for _, p := range projects {
projectsByCampaignID[p.CampaignID] = append(...)
}
for i, c := range records {
records[i].Projects = projectsByCampaignID[c.ID]
}
Đây là pattern aggregate bằng Go Map thay vì SQL JOIN để tránh row explosion.
func buildStatusStateFilter(statusStr, stateStr string) string {
// statusStr có thể là: "1" hoặc "0,1,2,3" (nhiều giá trị cách nhau dấu phẩy)
// Validate từng giá trị phải là số nguyên (chống injection)
// Kết quả: "AND (p.status = 0 OR p.status = 1) AND (p.state = 2)"
}
Các giá trị status hợp lệ: 0 (dừng), 1 (chạy).
Các giá trị state hợp lệ: 0 (unknown), 1 (kill), 2 (dead).
Ở Frontend, status options trong ClickAds.vue:
const statusOptions = [
{ label: 'Hoạt động', value: 1 },
{ label: 'Không hoạt động', value: 0 },
{ label: 'Giết', value: 2 }, // state = 1 (Kill)
{ label: 'Đã chết', value: 3 }, // state = 2 (Dead)
{ label: 'Chưa rõ', value: 4 } // state = 0 (Unknown)
]
Files: api/internal/campaign/handler/negative_seo_data.go và get_home_traffic.go
// File: api/internal/campaign/repository/get_negative_seo.go
query = query.Where("p.organic_engine IN (0, 1)")
query = query.Where("p.only_ads = ?", false)
query = query.Where("p.is_kill = ?", true) // BẮT BUỘC phải true
query = query.Where("p.traffic_type = ?", 1)
query = query.Group("p.id")
query = query.Order("p.created_at ASC")
// File: api/internal/campaign/repository/get_home_traffic.go
query = query.Where("p.organic_engine IN (0, 1)")
query = query.Where("p.only_ads = ?", false)
query = query.Where("p.is_kill = ?", false) // BẮT BUỘC phải false
query = query.Where("p.traffic_type = ?", 1)
query = query.Group("p.id")
query = query.Order("p.created_at ASC")
Cả hai handler đều tạo context riêng với timeout 180 giây:
reqCtx, cancel := context.WithTimeout(baseCtx, 180*time.Second)
defer cancel()
Timeout này dài hơn nhiều so với các endpoint thông thường (thường 30-60s). Nguyên nhân: Query join projects với project_details có thể xử lý hàng triệu bản ghi khi date range rộng.
Xử lý ngày mặc định khi không có input:
start_date không truyền → Mặc định 2023-01-01 (lấy từ đầu hệ thống).end_date không truyền → Mặc định ngày hiện tại.start_date > end_date → Trả lỗi validation.File: api/internal/campaign/repository/get_list_and_export.go
Hàm xuất dữ liệu dùng GORM Preload thay vì Raw SQL:
query = query.Preload("User")
query = query.Preload("Projects", func(q *gorm.DB) *gorm.DB {
subQuery := q.Preload("ProjectDetails", func(q *gorm.DB) *gorm.DB {
if hasDateRange {
return q.Where("project_details.date BETWEEN ? AND ?", startDate, endDate).Order("hour DESC")
}
return q.Where("project_details.date = ?", today).Order("hour DESC")
})
if hasDateRange {
subQuery = subQuery.Where("EXISTS (...)")
}
return subQuery
})
Cách tiếp cận này khác với GetListWithPagination (dùng Raw SQL + Map). Preload đơn giản hơn nhưng có thể sinh ra N+1 query nếu số lượng project lớn.
Filter theo ID là campaign ID đơn lẻ để xuất file một campaign cụ thể.
Bảng này giúp xác định nhanh một project có only_ads và is_kill là bao nhiêu thì hiện ở tab nào.
only_ads | is_kill | traffic_type | Hiển thị tại tab |
|---|---|---|---|
true | false | 1 | Click Quảng Cáo |
false | true | 1 | Negative SEO |
false | false | 1 | Traffic Tự Nhiên |
true | true | 1 | Không hợp lệ — không hiển thị ở bất kỳ tab nào |
false | false | 0 | Không hiện ở tab nào (traffic_type=0 không được query) |
Click Ads lấy data qua /campaign/ (list campaign), không filter only_ads/is_kill ở tầng repository. Toàn bộ project đều hiện trong tab Click Ads nếu thuộc về campaign đó, bất kể flag nào. Sự phân loại ở tầng frontend dựa vào việc project đó nằm trong campaign nào.
1. Tạo project với only_ads=false, is_kill=false nhưng muốn nó vào tab Negative SEO:
get_negative_seo.go lọc is_kill = true, project này không thỏa.is_kill = 1 cho project đó.2. Tạo project với only_ads=true, is_kill=true:
only_ads = true → Project có thể hiện ở tab Click Ads (vì Click Ads lấy theo campaign, không filter flag).only_ads = false → Không hiện.only_ads = false → Không hiện.only_ads=true nghĩa là click Ads, is_kill=true nghĩa là tấn công. Không có tab phù hợp cho trường hợp này.3. Không set start_date và end_date khi gọi GetListWithPagination:
project_details của mọi thời điểm.total_success sẽ là tổng cộng dồn từ ngày đầu đến hiện tại.4. State = Dead nhưng Status = 1:
isProjectRunning trong service kiểm tra p.State != State_Dead AND p.TotalProcess > 0.Khi Admin nhấn "Thêm từ khóa" trong tab Click Ads:
clickAdsStore.campaignId = campaign.id và clickAdsStore.campaignName = campaign.name.CreateModal (tại admin/src/views/task/components/CreateModal.vue).campaignId từ store để pre-set campaign dropdown.POST /tasks hoặc POST /projects để tạo project.success → ClickAds.vue gọi lại fetchData().Trong tab Negative SEO và Traffic Tự Nhiên:
Cũng dùng cùng CreateModal, nhưng store tương ứng là negativeStore hoặc trafficNaturalStore. Mỗi store có campaignId riêng để đảm bảo project được tạo đúng campaign.
Hệ thống hỗ trợ hai cấp độ thao tác hàng loạt:
Cấp Campaign (toàn bộ project trong 1 campaign):
CampaignItem.apiProjects.bulkUpdate({ ids: campaign.projects.map(p=>p.id), fields: { status } }).Cấp Project (các project được chọn qua checkbox):
apiProjects.bulkUpdate({ ids: selectedRows.map(r=>r.id), fields: { status } }).apiProjects.bulkDelete(uniqueProjectIds).Lưu ý về bulkDelete:
const ids = rows.map((row) => row.project_id) // Task có project_id
const uniqueIds = [...new Set(ids)] // Loại trùng (nhiều task cùng project)
await apiProjects.bulkDelete(uniqueIds)
Đây là cột quan trọng nhất để đánh giá hiệu quả của một project.
total_process — Ngân sách traffic được phân bổ cho project. Được tính theo giờ qua project_details. Tổng hợp phụ thuộc vào date range:
ProjectStats CTE tính SUM(total_success) toàn thời gian.total_success — Số lần Worker thực sự chạy thành công cho project này trong cùng khoảng thời gian.
Hiển thị: total_success / total_process với màu xanh cho success và màu đen cho process.
Nếu total_success = total_process: Project đã dùng hết ngân sách.
Nếu total_success << total_process: Project đang bị tắc — có thể do Proxy chết, Captcha, hoặc Worker node bận.
Nếu total_process = 0: Project này vừa bị tạo hoặc bị reset, chưa có budget nào.
File: admin/src/views/dashboard1/click-ads/composables/useCampaignFiltering.ts
Đây là composable xử lý toàn bộ logic filter phía client cho tab Click Ads. Không gọi API khi filter — thay vào đó filter trực tiếp trên defaultCampaigns đã được clone sẵn.
Hai hàm chính:
onSearch(campaignId, value)Tìm kiếm domain/keyword trong campaign cụ thể:
const onSearch = (campaignId: number, value: string | undefined) => {
// Lưu search text vào store để preserve qua reload
clickAdsStore.updateCampaignFilterSearch(campaignId, 'searchText', value)
// Khôi phục từ defaultCampaigns (bản gốc chưa filter)
campaign.projects = JSON.parse(JSON.stringify(defaultCampaign.projects))
// Nếu không có search text và không có filter nào khác → reset
if (!value && noOtherFilters) return
// Filter theo search text
if (value) {
const searchLower = value.toLowerCase().trim()
campaign.projects = campaign.projects.filter((project) => {
const inKeyword = project.search_keyword?.toLowerCase().includes(searchLower)
const inLinks = project.links?.some((link) => link.toLowerCase().includes(searchLower))
return inKeyword || inLinks
})
}
// Sau đó apply thêm status filter nếu có
applyStatusFilter(campaign, campaignId)
}
onChangeFilterProject(campaignId, type, value)Xử lý filter theo status/state hoặc date range:
Filter theo date range:
const dateRanges: Record<number, [number, number]> = {
0: [-1, -1], // Hôm qua: từ -1 ngày đến -1 ngày
1: [0, 0], // Hôm nay: từ hôm nay đến hôm nay
2: [-6, 0], // 7 ngày gần nhất: từ -6 ngày đến hôm nay
3: [-29, 0] // 30 ngày: từ -29 ngày đến hôm nay
}
// Dùng timestamp để tính: today.getTime() + startDays * 86400000
Filter theo status (UI value → DB value mapping):
// selectedStatuses là mảng giá trị từ statusOptions UI
// value=2 (Giết) → project.state === 1
// value=3 (Đã chết) → project.state === 2
// value=4 (Chưa rõ) → project.state === 0
// value=0 (Không hoạt động) → project.status === 0
// value=1 (Hoạt động) → project.status === 1
Logic filter kết hợp State + Status:
Nếu user chọn cả "Giết" (state-based) và "Hoạt động" (status-based):
if block riêng biệt, không else if).Nguyên tắc filter chung:
defaultCampaigns (dữ liệu gốc từ server).campaign.projects.fetchData lại.File: api/internal/campaign/service/get_negative_seo.go
Service này nhận flat list từ repository (mỗi row là một project), sau đó group theo domain:
func (s *Service) GetNegativeSeoData(ctx context.Context, startDate, endDate time.Time) ([]*dto.NegativeSeoDataRes, error) {
// Điều chỉnh endDate về cuối ngày (23:59:59) để bao gồm toàn bộ ngày cuối
endDate = endDate.Add(time.Hour*23 + time.Minute*59 + time.Second*59)
getNegativeSeoData, err := s.repository.GetNegativeSeoData(ctx, startDate, endDate)
negativeSeoDataRes := make([]*dto.NegativeSeoDataRes, 0)
for _, data := range *getNegativeSeoData {
var foundDomain bool
// Tìm xem domain đã tồn tại trong kết quả chưa
for _, res := range negativeSeoDataRes {
if res.Domain == data.Domain {
foundDomain = true
// Append keyword vào domain đã tồn tại
res.DataKeywords = append(res.DataKeywords, dto.DataKeywords{
CampaignId: data.CampaignId,
ProjectId: data.ProjectId,
SearchKeyword: data.SearchKeyword,
Ranking: data.Ranking,
TotalProcess: data.TotalProcess,
Status: data.Status,
KeywordCreated: data.KeywordCreated.Format("02/01/2006"), // Vietnamese date format
})
break
}
}
if !foundDomain {
// Domain mới → tạo entry mới
negativeSeoDataRes = append(negativeSeoDataRes, &dto.NegativeSeoDataRes{
Domain: data.Domain,
DataKeywords: []dto.DataKeywords{{...}},
})
}
}
return negativeSeoDataRes, nil
}
Lưu ý về grouping algorithm:
"02/01/2006" (ngày/tháng/năm) — định dạng Việt Nam, khác với Go standard "2006-01-02".File: api/internal/campaign/repository/get_fired_by_id.go
Endpoint /campaign/fired/:type (type: gg hoặc cc) được Auto Search module dùng để lấy thông tin campaign mặc định khi thực hiện Confirm Fire.
Query dùng GORM Preload với subquery lấy latest_rank:
query.Preload("Projects", func(db *gorm.DB) *gorm.DB {
return db.Table("projects p").
Select(`
p.id,
p.campaign_id,
p.name,
p.status,
p.search_keyword,
JSON_UNQUOTE(JSON_EXTRACT(p.links, '$[0]')) AS links,
(
SELECT pd.rank
FROM project_details pd
WHERE pd.project_id = p.id
ORDER BY pd.date DESC, pd.hour DESC
LIMIT 1
) as latest_rank
`).
Where("p.status = ?", 1) // Chỉ lấy project đang active
})
Trường latest_rank là rank mới nhất của domain theo project_details. Dùng subquery correlated thay vì JOIN để lấy đúng một record mới nhất theo date DESC, hour DESC.
File: admin/src/views/dashboard1/click-ads/stores/actions/useClickAdsDataActions.ts
const fetchData = async () => {
store.state.isFetching = true
const params: any = {
page: String(store.urlParams.page ?? 1),
limit: String(store.urlParams.limit ?? 20)
}
// Chỉ đưa params vào nếu có giá trị thực, tránh gửi null/empty
if (store.urlParams.keywords?.trim()) params.keywords = store.urlParams.keywords
if (store.urlParams.start_date?.trim()) params.start_date = store.urlParams.start_date
if (store.urlParams.end_date?.trim()) params.end_date = store.urlParams.end_date
const res = await apiCampaigns.getList(params)
const dataRes = res.data.data
// Guard page overflow: nếu current page > 1 mà không có data → về trang trước
if (dataRes.paginate.page > 1 && !dataRes.data?.length) {
store.onChangePage(dataRes.paginate.page - 1)
}
// Cập nhật collapseDefault để NCollapse biết cần mở rộng gì
store.collapseDefault.parent = dataRes.data.map((c) => c.id)
store.collapseDefault.childrend = dataRes.data.flatMap((c) =>
(c.projects ?? []).map((task) => task.id)
)
// Mark campaign có thể xóa nếu không có project
dataRes.data.forEach((campaign) => {
campaign.isDelete = (campaign.projects?.length || 0) === 0
})
store.listData = dataRes
}
const fetchSummaryDetail = async (id: number) => {
// Gọi API lấy lịch sử rank và traffic của một project
const response = await apiDashBoard.getSummaryDetail(id, store.trafficRankingParams)
const data = response.data.data
const startDate = new Date(store.trafficRankingParams.start_date)
const endDate = new Date(store.trafficRankingParams.end_date)
const isSameDay = startDate.getDate() === endDate.getDate()
const dates = generateTimeArray(startDate, endDate)
store.trafficRankingData = {
dates: dates,
ranking: mapRankingData(dates, data, isSameDay), // Rank theo ngày/giờ
deathTime: mapDeathTimeData(dates, data, isSameDay), // Thời điểm state=Dead
total_success: mapTrafficData(dates, data, isSameDay) // Traffic thành công
}
}
generateTimeArray: Tạo mảng timestamp từ startDate đến endDate. Nếu isSameDay = true, tạo theo giờ (24 điểm). Nếu khác ngày, tạo theo ngày.
projects| Cột | Kiểu | Mô tả |
|---|---|---|
id | uint | Primary key |
campaign_id | uint | FK → campaigns.id |
user_id | int | Người tạo |
name | varchar | Tên dự án |
status | tinyint | 0=dừng, 1=chạy |
state | tinyint | 0=unknown, 1=kill, 2=dead |
state_time | datetime | Thời điểm thay đổi state |
dead_time | datetime | Thời điểm bị dead |
total_process | int | Ngân sách traffic |
traffic_type | tinyint | 0=direct, 1=search |
traffic_device | tinyint | 0=desktop, 1=mobile |
traffic_device_os | tinyint | 0=win/android, 1=mac/ios |
organic_engine | tinyint | 0=GGvn, 1=GG, 2=CC |
search_keyword | varchar | Từ khóa tìm kiếm |
links | json | Mảng URL target ["https://..."] |
only_ads | tinyint(1) | Chỉ click Ads |
is_kill | tinyint(1) | Traffic Kill mode |
is_traffic_fixed | tinyint(1) | Cố định số traffic/ngày |
keyword_id | varchar | MongoDB ObjectID liên kết |
sort | int | Priority (1-5) |
geolocation | json | {"lat": ..., "lng": ...} |
attribute | json | Time frame và time_on_page config |
created_at | datetime | - |
updated_at | datetime | - |
project_detailsRecord này được Cron Job tạo mỗi giờ cho mỗi project đang active.
| Cột | Kiểu | Mô tả |
|---|---|---|
id | uint | Primary key |
project_id | uint | FK → projects.id |
date | date | Ngày (YYYY-MM-DD) |
hour | tinyint | Giờ (0-23) |
total_process | int | Số lượt được phân bổ trong giờ này |
total_success | int | Số lượt thành công trong giờ này |
total_fail | int | Số lượt thất bại |
rank | int | Vị trí SEO tốt nhất scan được |
created_at | datetime | - |
Aggregate patterns được dùng:
SUM(total_success) → Tổng traffic thực tếMIN(rank) → Vị trí SEO tốt nhất trong khoảng thời gianMAX(date), MAX(hour) → Lần cập nhật cuốicampaigns| Cột | Kiểu | Mô tả |
|---|---|---|
id | uint | - |
name | varchar | Tên chiến dịch (hiển thị trên UI) |
description | text | Mô tả chi tiết (tooltip) |
user_id | int | Người tạo |
created_at | datetime | - |
status | state | Tên hiển thị | Màu badge | Hành động tiếp theo |
|---|---|---|---|---|
1 | 0 (Unknown) | Hoạt động | Xanh lá | Worker tiếp tục tạo task |
0 | 0 (Unknown) | Không hoạt động | Xám | Worker không tạo task |
1 | 1 (Kill) | Giết | Đỏ | Worker xử lý Kill logic |
1 | 2 (Dead) | Đã chết | Xám đậm | Worker bỏ qua project này |
0 | 2 (Dead) | Đã chết (tắt) | Xám | - |
State_Dead là cờ hệ thống: Worker tự động đặt state = 2 khi phát hiện domain target không còn chạy Ads/không còn trong top kết quả. Người dùng không thể set state này thủ công từ UI — chỉ có thể xóa project và tạo lại.
dataCampaignAdd trong clickAdsStoredataCampaignAdd: undefined as Campaign.Item | undefined
Field này được dùng để trigger re-fetch sau khi user thêm project mới vào campaign:
// Trong ClickAds.vue:
watch(
() => clickAdsStore.dataCampaignAdd,
(newData) => {
if (newData && (newData.dateRange !== null || newData.status !== null || newData.searchText !== undefined)) {
fetchData()
}
},
{ deep: true }
)
Khi CreateModal emit success, nó set dataCampaignAdd vào campaign vừa thêm project. Watch này phát hiện thay đổi và tự động re-fetch để UI cập nhật ngay mà không cần user refresh tay.
Module này có hai cấp phân trang với logic khác nhau hoàn toàn:
Cấp 1: Phân trang Campaign (phía server):
urlParams.page và urlParams.limit = 20paginate.total_pages<NPagination> phía dưới danh sách campaignCấp 2: Phân trang Project trong Campaign (phía client):
currentPage và pageSize = 20 là ref local trong ListGroup.vueprojects.length thay đổi<CPagination> phía dưới bảng projectHiển thị phụ chú: Hiển thị từ {(currentPage-1)*pageSize+1} đến {min(currentPage*pageSize, total)} trên tổng {total}
export với campaign = undefined.useCampaignOperations.exportData() gọi GET /campaign/export.CampaignItem → emit export với campaign cụ thể.useCampaignOperations.exportCampaignData(campaign) gọi GET /campaign/export?id={campaign.id}.id = campaign.id → Chỉ trả về 1 campaign.GET /campaign/export-negative-seo-dataGET /campaign/export-negative-seo-data-by-ids?ids=1,2,3
project_id → gửi dưới dạng ids.1. listDataFilter vs listData trong clickAdsStore:
listData — Dữ liệu gốc từ server, không được sửa đổi.listDataFilter — Dữ liệu sau khi filter, được dùng để render. Tuy nhiên, thực tế trong code hiện tại, filter được thực hiện trực tiếp trên listData.data[i].projects thay vì qua listDataFilter. listDataFilter tồn tại trong state nhưng không được sử dụng đồng nhất.2. collapseDefault.childrend (typo):
Cả clickAdsStore và negativeStore đều có coldrend thay vì children. Đây là typo nhất quán trong codebase. Không nên sửa vì đã được tham chiếu ở nhiều nơi.
3. State management khác nhau giữa ba tab:
4. only_ads không dùng để filter ở tab Click Ads:
Mặc dù only_ads = true là đặc trưng của Click Ads project, backend /campaign/ endpoint không filter theo only_ads. Tất cả project thuộc campaign đều được trả về. Điều này có nghĩa là nếu ai đó vô tình tạo project only_ads=false vào campaign "Click Ads", nó vẫn hiện trong tab này.
5. organic_engine IN (0, 1) — Tại sao không có engine 2 (CocCoc)?
organic_engine IN (0, 1) — chỉ Google.organic_engine = 2) không hiện trong hai tab này.