khhao 自动同步
This commit is contained in:
@@ -3,5 +3,11 @@
|
|||||||
"baseUrl": "https://admin.khhao.com",
|
"baseUrl": "https://admin.khhao.com",
|
||||||
"username": "order_check",
|
"username": "order_check",
|
||||||
"password": "order_check_test1",
|
"password": "order_check_test1",
|
||||||
"maxCaptchaAttempts": 3
|
"maxCaptchaAttempts": 3,
|
||||||
|
"autoSync": {
|
||||||
|
"enabled": true,
|
||||||
|
"intervalMinutes": 3,
|
||||||
|
"pages": 2,
|
||||||
|
"pageSize": 20
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"syncFromCreatedAt": "2026-05-02T10:53:28.550Z",
|
||||||
|
"lastRunStartedAt": "2026-05-02T12:36:55.519Z",
|
||||||
|
"lastRunFinishedAt": "2026-05-02T12:36:56.704Z",
|
||||||
|
"lastRunStatus": "success",
|
||||||
|
"lastErrorMessage": "",
|
||||||
|
"fetchedCount": 40,
|
||||||
|
"syncedCount": 0,
|
||||||
|
"ignoredCount": 40,
|
||||||
|
"lastOrderCreatedAt": "2026-05-02T12:36:45.000Z"
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import claimsRouter from './routes/claims.js'
|
|||||||
import webhooksRouter from './routes/webhooks.js'
|
import webhooksRouter from './routes/webhooks.js'
|
||||||
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
||||||
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
||||||
|
import { startKhhaoAutoSyncLoop, stopKhhaoAutoSyncLoop } from './services/platforms/khhao/auto-sync-service.js'
|
||||||
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js'
|
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js'
|
||||||
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
||||||
import { buildSuccessPayload } from './utils/http.js'
|
import { buildSuccessPayload } from './utils/http.js'
|
||||||
@@ -160,6 +161,7 @@ async function bootstrapCoreServices() {
|
|||||||
attempt: startupState.core.attemptCount,
|
attempt: startupState.core.attemptCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
startKhhaoAutoSyncLoop()
|
||||||
void bootstrapBrowser()
|
void bootstrapBrowser()
|
||||||
void bootstrapOcr()
|
void bootstrapOcr()
|
||||||
break
|
break
|
||||||
@@ -368,6 +370,12 @@ async function shutdown(signal) {
|
|||||||
logError('[shutdown]', 'failed to close local OCR worker', error)
|
logError('[shutdown]', 'failed to close local OCR worker', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stopKhhaoAutoSyncLoop()
|
||||||
|
} catch (error) {
|
||||||
|
logError('[shutdown]', 'failed to stop khhao auto sync loop', error)
|
||||||
|
}
|
||||||
|
|
||||||
if (!server.listening) {
|
if (!server.listening) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { mapKhhaoOrderPreviewList } from '../platforms/khhao/order-mapper-servic
|
|||||||
import { queryKhhaoOrderList } from '../platforms/khhao/order-query-service.js'
|
import { queryKhhaoOrderList } from '../platforms/khhao/order-query-service.js'
|
||||||
import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js'
|
import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js'
|
||||||
import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js'
|
import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js'
|
||||||
|
import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../platforms/khhao/sync-state-service.js'
|
||||||
import { loginKhhaoSession } from '../platforms/khhao/session-service.js'
|
import { loginKhhaoSession } from '../platforms/khhao/session-service.js'
|
||||||
import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-service.js'
|
import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-service.js'
|
||||||
import {
|
import {
|
||||||
@@ -186,15 +187,33 @@ function applyOptionalStringField(target, key, source) {
|
|||||||
|
|
||||||
export function getAdminKhhaoSourceConfig() {
|
export function getAdminKhhaoSourceConfig() {
|
||||||
const config = getKhhaoSourceConfig()
|
const config = getKhhaoSourceConfig()
|
||||||
|
const syncState = getKhhaoSyncState()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath: getKhhaoSourcesFilePath(),
|
filePath: getKhhaoSourcesFilePath(),
|
||||||
|
stateFilePath: getKhhaoSyncStateFilePath(),
|
||||||
source: {
|
source: {
|
||||||
enabled: config.enabled !== false,
|
enabled: config.enabled !== false,
|
||||||
baseUrl: String(config.baseUrl || '').trim(),
|
baseUrl: String(config.baseUrl || '').trim(),
|
||||||
username: String(config.username || '').trim(),
|
username: String(config.username || '').trim(),
|
||||||
password: String(config.password || '').trim(),
|
password: String(config.password || '').trim(),
|
||||||
maxCaptchaAttempts: Number(config.maxCaptchaAttempts || 3) || 3,
|
maxCaptchaAttempts: Number(config.maxCaptchaAttempts || 3) || 3,
|
||||||
|
autoSync: {
|
||||||
|
enabled: config.autoSync?.enabled === true,
|
||||||
|
intervalMinutes: Number(config.autoSync?.intervalMinutes || 3) || 3,
|
||||||
|
pages: Number(config.autoSync?.pages || 2) || 2,
|
||||||
|
pageSize: Number(config.autoSync?.pageSize || 20) || 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syncState: {
|
||||||
|
syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(),
|
||||||
|
lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(),
|
||||||
|
lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(),
|
||||||
|
lastRunStatus: String(syncState.lastRunStatus || '').trim(),
|
||||||
|
lastErrorMessage: String(syncState.lastErrorMessage || '').trim(),
|
||||||
|
fetchedCount: Number(syncState.fetchedCount || 0),
|
||||||
|
syncedCount: Number(syncState.syncedCount || 0),
|
||||||
|
ignoredCount: Number(syncState.ignoredCount || 0),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,16 +226,40 @@ export function updateAdminKhhaoSourceConfig(payload = /** @type {AdminKhhaoSour
|
|||||||
username: String(payload.username || '').trim(),
|
username: String(payload.username || '').trim(),
|
||||||
password: String(payload.password || '').trim(),
|
password: String(payload.password || '').trim(),
|
||||||
maxCaptchaAttempts: Number(payload.maxCaptchaAttempts || 3) || 3,
|
maxCaptchaAttempts: Number(payload.maxCaptchaAttempts || 3) || 3,
|
||||||
|
autoSync: {
|
||||||
|
enabled: payload.autoSync?.enabled === true,
|
||||||
|
intervalMinutes: Number(payload.autoSync?.intervalMinutes || 3) || 3,
|
||||||
|
pages: Number(payload.autoSync?.pages || 2) || 2,
|
||||||
|
pageSize: Number(payload.autoSync?.pageSize || 20) || 20,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
const syncState = saved.autoSync?.enabled ? ensureKhhaoSyncBaseline() : getKhhaoSyncState()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath: getKhhaoSourcesFilePath(),
|
filePath: getKhhaoSourcesFilePath(),
|
||||||
|
stateFilePath: getKhhaoSyncStateFilePath(),
|
||||||
source: {
|
source: {
|
||||||
enabled: saved.enabled !== false,
|
enabled: saved.enabled !== false,
|
||||||
baseUrl: String(saved.baseUrl || '').trim(),
|
baseUrl: String(saved.baseUrl || '').trim(),
|
||||||
username: String(saved.username || '').trim(),
|
username: String(saved.username || '').trim(),
|
||||||
password: String(saved.password || '').trim(),
|
password: String(saved.password || '').trim(),
|
||||||
maxCaptchaAttempts: Number(saved.maxCaptchaAttempts || 3) || 3,
|
maxCaptchaAttempts: Number(saved.maxCaptchaAttempts || 3) || 3,
|
||||||
|
autoSync: {
|
||||||
|
enabled: saved.autoSync?.enabled === true,
|
||||||
|
intervalMinutes: Number(saved.autoSync?.intervalMinutes || 3) || 3,
|
||||||
|
pages: Number(saved.autoSync?.pages || 2) || 2,
|
||||||
|
pageSize: Number(saved.autoSync?.pageSize || 20) || 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syncState: {
|
||||||
|
syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(),
|
||||||
|
lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(),
|
||||||
|
lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(),
|
||||||
|
lastRunStatus: String(syncState.lastRunStatus || '').trim(),
|
||||||
|
lastErrorMessage: String(syncState.lastErrorMessage || '').trim(),
|
||||||
|
fetchedCount: Number(syncState.fetchedCount || 0),
|
||||||
|
syncedCount: Number(syncState.syncedCount || 0),
|
||||||
|
ignoredCount: Number(syncState.ignoredCount || 0),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { getKhhaoSourceConfig } from './source-config-service.js'
|
||||||
|
import { ensureKhhaoSyncBaseline, getKhhaoSyncState, saveKhhaoSyncState } from './sync-state-service.js'
|
||||||
|
import { syncKhhaoOrders } from './order-sync-service.js'
|
||||||
|
import { logError, logInfo, logWarn } from '../../../utils/logger.js'
|
||||||
|
import { nowIso } from '../../../utils/time.js'
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
export function startKhhaoAutoSyncLoop() {
|
||||||
|
scheduleNextRun(5_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopKhhaoAutoSyncLoop() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runKhhaoAutoSyncCycle() {
|
||||||
|
const source = getKhhaoSourceConfig()
|
||||||
|
|
||||||
|
if (!source.enabled || !source.autoSync?.enabled) {
|
||||||
|
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
logWarn('[khhao/auto-sync]', '检测到上一次自动同步仍在运行,本轮跳过')
|
||||||
|
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(source.username || '').trim() || !String(source.password || '').trim()) {
|
||||||
|
logWarn('[khhao/auto-sync]', 'khhao 自动同步已启用,但缺少账号或密码')
|
||||||
|
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
running = true
|
||||||
|
const baselineState = ensureKhhaoSyncBaseline()
|
||||||
|
const startedAt = nowIso()
|
||||||
|
|
||||||
|
saveKhhaoSyncState({
|
||||||
|
...baselineState,
|
||||||
|
lastRunStartedAt: startedAt,
|
||||||
|
lastRunStatus: 'running',
|
||||||
|
lastErrorMessage: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncKhhaoOrders({
|
||||||
|
baseUrl: source.baseUrl,
|
||||||
|
username: source.username,
|
||||||
|
password: source.password,
|
||||||
|
maxCaptchaAttempts: source.maxCaptchaAttempts,
|
||||||
|
pages: source.autoSync.pages,
|
||||||
|
limit: source.autoSync.pageSize,
|
||||||
|
createdAfter: baselineState.syncFromCreatedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestCreatedAt = resolveLatestOrderCreatedAt(result.results)
|
||||||
|
|
||||||
|
saveKhhaoSyncState({
|
||||||
|
...getKhhaoSyncState(),
|
||||||
|
syncFromCreatedAt: baselineState.syncFromCreatedAt,
|
||||||
|
lastRunStartedAt: startedAt,
|
||||||
|
lastRunFinishedAt: nowIso(),
|
||||||
|
lastRunStatus: 'success',
|
||||||
|
lastErrorMessage: '',
|
||||||
|
fetchedCount: result.fetchedCount,
|
||||||
|
syncedCount: result.syncedCount,
|
||||||
|
ignoredCount: result.ignoredCount,
|
||||||
|
lastOrderCreatedAt: latestCreatedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo('[khhao/auto-sync]', 'khhao 自动同步完成', {
|
||||||
|
fetchedCount: result.fetchedCount,
|
||||||
|
syncedCount: result.syncedCount,
|
||||||
|
ignoredCount: result.ignoredCount,
|
||||||
|
syncFromCreatedAt: baselineState.syncFromCreatedAt,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || 'khhao 自动同步失败')
|
||||||
|
saveKhhaoSyncState({
|
||||||
|
...getKhhaoSyncState(),
|
||||||
|
syncFromCreatedAt: baselineState.syncFromCreatedAt,
|
||||||
|
lastRunStartedAt: startedAt,
|
||||||
|
lastRunFinishedAt: nowIso(),
|
||||||
|
lastRunStatus: 'failed',
|
||||||
|
lastErrorMessage: message,
|
||||||
|
})
|
||||||
|
logError('[khhao/auto-sync]', 'khhao 自动同步失败', error)
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextRun(delayMs) {
|
||||||
|
stopKhhaoAutoSyncLoop()
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void runKhhaoAutoSyncCycle()
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIntervalMs(value) {
|
||||||
|
const minutes = Number(value)
|
||||||
|
const normalized = Number.isInteger(minutes) && minutes > 0 ? minutes : 3
|
||||||
|
return normalized * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLatestOrderCreatedAt(results = []) {
|
||||||
|
let latestText = ''
|
||||||
|
let latestTime = 0
|
||||||
|
|
||||||
|
for (const item of Array.isArray(results) ? results : []) {
|
||||||
|
const text = String(item?.rawOrderCreatedAt || item?.orderCreatedAt || '').trim()
|
||||||
|
const timestamp = parseKhhaoOrderCreatedAt(text)
|
||||||
|
if (Number.isFinite(timestamp) && timestamp > latestTime) {
|
||||||
|
latestTime = timestamp
|
||||||
|
latestText = new Date(timestamp).toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestText
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKhhaoOrderCreatedAt(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.parse(`${text.replace(' ', 'T')}+08:00`)
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ import { loginKhhaoSession } from './session-service.js'
|
|||||||
* password?: string
|
* password?: string
|
||||||
* page?: number | string
|
* page?: number | string
|
||||||
* limit?: number | string
|
* limit?: number | string
|
||||||
|
* pages?: number | string
|
||||||
* maxCaptchaAttempts?: number | string
|
* maxCaptchaAttempts?: number | string
|
||||||
|
* createdAfter?: string
|
||||||
* }} [payload]
|
* }} [payload]
|
||||||
*/
|
*/
|
||||||
export async function syncKhhaoOrders(payload = {}) {
|
export async function syncKhhaoOrders(payload = {}) {
|
||||||
@@ -26,15 +28,47 @@ export async function syncKhhaoOrders(payload = {}) {
|
|||||||
|
|
||||||
const queryResult = await queryKhhaoOrderList({
|
const queryResult = await queryKhhaoOrderList({
|
||||||
baseUrl: payload.baseUrl,
|
baseUrl: payload.baseUrl,
|
||||||
page: payload.page,
|
page: 1,
|
||||||
limit: payload.limit,
|
limit: resolvePageSize(payload.limit),
|
||||||
session,
|
session,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createdAfter = String(payload.createdAfter || '').trim()
|
||||||
|
const pages = Math.max(1, Math.min(Number(payload.pages || payload.page || 1) || 1, 5))
|
||||||
const results = []
|
const results = []
|
||||||
|
const allItems = [...queryResult.items]
|
||||||
|
|
||||||
for (const item of queryResult.items) {
|
for (let page = 2; page <= pages; page += 1) {
|
||||||
|
const nextPage = await queryKhhaoOrderList({
|
||||||
|
baseUrl: payload.baseUrl,
|
||||||
|
page,
|
||||||
|
limit: resolvePageSize(payload.limit),
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
allItems.push(...nextPage.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of allItems) {
|
||||||
const preview = mapKhhaoOrderPreview(item)
|
const preview = mapKhhaoOrderPreview(item)
|
||||||
|
|
||||||
|
if (createdAfter && !isKhhaoOrderAfter(preview.orderCreatedAt, createdAfter)) {
|
||||||
|
results.push({
|
||||||
|
platformOrderId: preview.platformOrderId,
|
||||||
|
provider: 'khhao',
|
||||||
|
platform: preview.platform || 'unknown',
|
||||||
|
shopId: preview.shopId,
|
||||||
|
skuCode: preview.skuCode,
|
||||||
|
itemTitle: preview.itemTitle,
|
||||||
|
rawOrderCreatedAt: preview.orderCreatedAt,
|
||||||
|
ignored: true,
|
||||||
|
ignoreReason: 'before_sync_baseline',
|
||||||
|
orderId: null,
|
||||||
|
orderItemCount: 0,
|
||||||
|
taskCount: 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const sourceEvent = mapKhhaoOrderToSourceEvent(item)
|
const sourceEvent = mapKhhaoOrderToSourceEvent(item)
|
||||||
const upsertResult = await upsertOrderFromSource(sourceEvent, { sourceLabel: 'khhao-sync' })
|
const upsertResult = await upsertOrderFromSource(sourceEvent, { sourceLabel: 'khhao-sync' })
|
||||||
|
|
||||||
@@ -45,6 +79,7 @@ export async function syncKhhaoOrders(payload = {}) {
|
|||||||
shopId: sourceEvent.shopId,
|
shopId: sourceEvent.shopId,
|
||||||
skuCode: preview.skuCode,
|
skuCode: preview.skuCode,
|
||||||
itemTitle: preview.itemTitle,
|
itemTitle: preview.itemTitle,
|
||||||
|
rawOrderCreatedAt: preview.orderCreatedAt,
|
||||||
ignored: Boolean(upsertResult.ignored),
|
ignored: Boolean(upsertResult.ignored),
|
||||||
ignoreReason: String(upsertResult.ignoreReason || '').trim(),
|
ignoreReason: String(upsertResult.ignoreReason || '').trim(),
|
||||||
orderId: Number(upsertResult.order?.id || 0) || null,
|
orderId: Number(upsertResult.order?.id || 0) || null,
|
||||||
@@ -55,12 +90,42 @@ export async function syncKhhaoOrders(payload = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: session.baseUrl,
|
baseUrl: session.baseUrl,
|
||||||
page: queryResult.page,
|
page: 1,
|
||||||
limit: queryResult.limit,
|
limit: resolvePageSize(payload.limit),
|
||||||
total: queryResult.total,
|
total: queryResult.total,
|
||||||
fetchedCount: queryResult.items.length,
|
fetchedCount: allItems.length,
|
||||||
syncedCount: results.filter((item) => !item.ignored && item.orderId).length,
|
syncedCount: results.filter((item) => !item.ignored && item.orderId).length,
|
||||||
ignoredCount: results.filter((item) => item.ignored).length,
|
ignoredCount: results.filter((item) => item.ignored).length,
|
||||||
results,
|
results,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePageSize(value) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(parsed, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKhhaoOrderAfter(orderCreatedAt, baselineIso) {
|
||||||
|
const orderTime = parseKhhaoOrderCreatedAt(orderCreatedAt)
|
||||||
|
const baselineTime = Date.parse(String(baselineIso || '').trim())
|
||||||
|
|
||||||
|
if (!Number.isFinite(orderTime) || !Number.isFinite(baselineTime)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderTime > baselineTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKhhaoOrderCreatedAt(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoText = text.replace(' ', 'T')
|
||||||
|
return Date.parse(`${isoText}+08:00`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function normalizeKhhaoSourceConfig(rawValue) {
|
|||||||
username: String(source.username || '').trim(),
|
username: String(source.username || '').trim(),
|
||||||
password: String(source.password || '').trim(),
|
password: String(source.password || '').trim(),
|
||||||
maxCaptchaAttempts: normalizePositiveInteger(source.maxCaptchaAttempts, 3),
|
maxCaptchaAttempts: normalizePositiveInteger(source.maxCaptchaAttempts, 3),
|
||||||
|
autoSync: normalizeAutoSyncConfig(source.autoSync),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,18 @@ function createDefaultKhhaoSourceConfig() {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
maxCaptchaAttempts: 3,
|
maxCaptchaAttempts: 3,
|
||||||
|
autoSync: normalizeAutoSyncConfig({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoSyncConfig(rawValue) {
|
||||||
|
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: typeof source.enabled === 'boolean' ? source.enabled : false,
|
||||||
|
intervalMinutes: normalizePositiveInteger(source.intervalMinutes, 3),
|
||||||
|
pages: normalizePositiveInteger(source.pages, 2),
|
||||||
|
pageSize: normalizePositiveInteger(source.pageSize, 20),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { PROJECT_ROOT } from '../../../config/runtime.js'
|
||||||
|
import { nowIso } from '../../../utils/time.js'
|
||||||
|
|
||||||
|
const KHHAO_SYNC_STATE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'khhao-sync-state.json')
|
||||||
|
|
||||||
|
export function getKhhaoSyncStateFilePath() {
|
||||||
|
return KHHAO_SYNC_STATE_FILE_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKhhaoSyncState() {
|
||||||
|
return loadKhhaoSyncStateFromFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveKhhaoSyncState(rawValue) {
|
||||||
|
const normalized = normalizeKhhaoSyncState(rawValue)
|
||||||
|
fs.mkdirSync(path.dirname(KHHAO_SYNC_STATE_FILE_PATH), { recursive: true })
|
||||||
|
fs.writeFileSync(KHHAO_SYNC_STATE_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureKhhaoSyncBaseline() {
|
||||||
|
const current = getKhhaoSyncState()
|
||||||
|
if (String(current.syncFromCreatedAt || '').trim()) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveKhhaoSyncState({
|
||||||
|
...current,
|
||||||
|
syncFromCreatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKhhaoSyncStateFromFile() {
|
||||||
|
if (!fs.existsSync(KHHAO_SYNC_STATE_FILE_PATH)) {
|
||||||
|
return createDefaultKhhaoSyncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeKhhaoSyncState(JSON.parse(fs.readFileSync(KHHAO_SYNC_STATE_FILE_PATH, 'utf8')))
|
||||||
|
} catch {
|
||||||
|
return createDefaultKhhaoSyncState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultKhhaoSyncState() {
|
||||||
|
return {
|
||||||
|
syncFromCreatedAt: '',
|
||||||
|
lastRunStartedAt: '',
|
||||||
|
lastRunFinishedAt: '',
|
||||||
|
lastRunStatus: 'idle',
|
||||||
|
lastErrorMessage: '',
|
||||||
|
fetchedCount: 0,
|
||||||
|
syncedCount: 0,
|
||||||
|
ignoredCount: 0,
|
||||||
|
lastOrderCreatedAt: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKhhaoSyncState(rawValue) {
|
||||||
|
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncFromCreatedAt: String(source.syncFromCreatedAt || '').trim(),
|
||||||
|
lastRunStartedAt: String(source.lastRunStartedAt || '').trim(),
|
||||||
|
lastRunFinishedAt: String(source.lastRunFinishedAt || '').trim(),
|
||||||
|
lastRunStatus: normalizeStatus(source.lastRunStatus),
|
||||||
|
lastErrorMessage: String(source.lastErrorMessage || '').trim(),
|
||||||
|
fetchedCount: normalizeNonNegativeInteger(source.fetchedCount),
|
||||||
|
syncedCount: normalizeNonNegativeInteger(source.syncedCount),
|
||||||
|
ignoredCount: normalizeNonNegativeInteger(source.ignoredCount),
|
||||||
|
lastOrderCreatedAt: String(source.lastOrderCreatedAt || '').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return normalized || 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeInteger(value) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]'
|
||||||
|
}
|
||||||
@@ -74,6 +74,12 @@ export {}
|
|||||||
* username?: string
|
* username?: string
|
||||||
* password?: string
|
* password?: string
|
||||||
* maxCaptchaAttempts?: number | string
|
* maxCaptchaAttempts?: number | string
|
||||||
|
* autoSync?: {
|
||||||
|
* enabled?: boolean
|
||||||
|
* intervalMinutes?: number | string
|
||||||
|
* pages?: number | string
|
||||||
|
* pageSize?: number | string
|
||||||
|
* }
|
||||||
* }} AdminKhhaoSourceConfigInput
|
* }} AdminKhhaoSourceConfigInput
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AdminKhhaoLoginTestResult,
|
AdminKhhaoLoginTestResult,
|
||||||
AdminKhhaoOrderQueryResult,
|
AdminKhhaoOrderQueryResult,
|
||||||
AdminKhhaoSourceConfig,
|
AdminKhhaoSourceConfig,
|
||||||
|
AdminKhhaoSyncState,
|
||||||
AdminKhhaoOrderSyncResult,
|
AdminKhhaoOrderSyncResult,
|
||||||
AdminInventoryItemListItem,
|
AdminInventoryItemListItem,
|
||||||
AdminInventorySkuSuggestion,
|
AdminInventorySkuSuggestion,
|
||||||
@@ -98,6 +99,8 @@ export function fetchAdminKhhaoSourceConfig() {
|
|||||||
return apiGet<{
|
return apiGet<{
|
||||||
filePath: string
|
filePath: string
|
||||||
source: AdminKhhaoSourceConfig
|
source: AdminKhhaoSourceConfig
|
||||||
|
stateFilePath: string
|
||||||
|
syncState: AdminKhhaoSyncState
|
||||||
}>('/api/v1/admin/platform-config/khhao-source')
|
}>('/api/v1/admin/platform-config/khhao-source')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +108,8 @@ export function saveAdminKhhaoSourceConfig(payload: AdminKhhaoSourceConfig) {
|
|||||||
return apiPost<{
|
return apiPost<{
|
||||||
filePath: string
|
filePath: string
|
||||||
source: AdminKhhaoSourceConfig
|
source: AdminKhhaoSourceConfig
|
||||||
|
stateFilePath: string
|
||||||
|
syncState: AdminKhhaoSyncState
|
||||||
}>('/api/v1/admin/platform-config/khhao-source', payload as unknown as Record<string, unknown>)
|
}>('/api/v1/admin/platform-config/khhao-source', payload as unknown as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,23 @@ export interface AdminKhhaoSourceConfig {
|
|||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
maxCaptchaAttempts: number
|
maxCaptchaAttempts: number
|
||||||
|
autoSync: {
|
||||||
|
enabled: boolean
|
||||||
|
intervalMinutes: number
|
||||||
|
pages: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminKhhaoSyncState {
|
||||||
|
syncFromCreatedAt: string
|
||||||
|
lastRunStartedAt: string
|
||||||
|
lastRunFinishedAt: string
|
||||||
|
lastRunStatus: string
|
||||||
|
lastErrorMessage: string
|
||||||
|
fetchedCount: number
|
||||||
|
syncedCount: number
|
||||||
|
ignoredCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminKhhaoOrderPreview {
|
export interface AdminKhhaoOrderPreview {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
AdminAgisoShopConfigItem,
|
AdminAgisoShopConfigItem,
|
||||||
AdminKhhaoLoginTestResult,
|
AdminKhhaoLoginTestResult,
|
||||||
AdminKhhaoOrderQueryResult,
|
AdminKhhaoOrderQueryResult,
|
||||||
|
AdminKhhaoSyncState,
|
||||||
AdminKhhaoOrderSyncResult,
|
AdminKhhaoOrderSyncResult,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
@@ -47,6 +48,7 @@ const activePlatform = ref<PlatformTab>('agiso')
|
|||||||
|
|
||||||
const agisoFilePath = ref('')
|
const agisoFilePath = ref('')
|
||||||
const khhaoFilePath = ref('')
|
const khhaoFilePath = ref('')
|
||||||
|
const khhaoStateFilePath = ref('')
|
||||||
const defaults = ref<EditableDefaults>(createEmptyDefaults())
|
const defaults = ref<EditableDefaults>(createEmptyDefaults())
|
||||||
const shops = ref<EditableShop[]>([])
|
const shops = ref<EditableShop[]>([])
|
||||||
const observedShops = ref<AdminAgisoObservedShopItem[]>([])
|
const observedShops = ref<AdminAgisoObservedShopItem[]>([])
|
||||||
@@ -59,6 +61,10 @@ const khhaoForm = ref({
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
maxCaptchaAttempts: 3,
|
maxCaptchaAttempts: 3,
|
||||||
|
autoSyncEnabled: false,
|
||||||
|
autoSyncIntervalMinutes: 3,
|
||||||
|
autoSyncPages: 2,
|
||||||
|
autoSyncPageSize: 20,
|
||||||
})
|
})
|
||||||
const khhaoTesting = ref(false)
|
const khhaoTesting = ref(false)
|
||||||
const khhaoQuerying = ref(false)
|
const khhaoQuerying = ref(false)
|
||||||
@@ -67,6 +73,7 @@ const khhaoResultError = ref('')
|
|||||||
const khhaoLoginResult = ref<AdminKhhaoLoginTestResult | null>(null)
|
const khhaoLoginResult = ref<AdminKhhaoLoginTestResult | null>(null)
|
||||||
const khhaoQueryResult = ref<AdminKhhaoOrderQueryResult | null>(null)
|
const khhaoQueryResult = ref<AdminKhhaoOrderQueryResult | null>(null)
|
||||||
const khhaoSyncResult = ref<AdminKhhaoOrderSyncResult | null>(null)
|
const khhaoSyncResult = ref<AdminKhhaoOrderSyncResult | null>(null)
|
||||||
|
const khhaoAutoSyncState = ref<AdminKhhaoSyncState | null>(null)
|
||||||
|
|
||||||
const agisoStats = computed(() => ({
|
const agisoStats = computed(() => ({
|
||||||
configuredShopCount: shops.value.length,
|
configuredShopCount: shops.value.length,
|
||||||
@@ -77,8 +84,8 @@ const agisoStats = computed(() => ({
|
|||||||
const khhaoStats = computed(() => ({
|
const khhaoStats = computed(() => ({
|
||||||
hasCredential: Boolean(khhaoForm.value.username.trim() && khhaoForm.value.password.trim()),
|
hasCredential: Boolean(khhaoForm.value.username.trim() && khhaoForm.value.password.trim()),
|
||||||
queryCount: khhaoQueryResult.value?.itemCount || 0,
|
queryCount: khhaoQueryResult.value?.itemCount || 0,
|
||||||
syncedCount: khhaoSyncResult.value?.syncedCount || 0,
|
syncedCount: khhaoAutoSyncState.value?.syncedCount || khhaoSyncResult.value?.syncedCount || 0,
|
||||||
ignoredCount: khhaoSyncResult.value?.ignoredCount || 0,
|
ignoredCount: khhaoAutoSyncState.value?.ignoredCount || khhaoSyncResult.value?.ignoredCount || 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function createEmptyDefaults(): EditableDefaults {
|
function createEmptyDefaults(): EditableDefaults {
|
||||||
@@ -141,6 +148,8 @@ async function loadConfigs() {
|
|||||||
expandedShopIds.value = []
|
expandedShopIds.value = []
|
||||||
|
|
||||||
khhaoFilePath.value = khhaoResponse.data.filePath
|
khhaoFilePath.value = khhaoResponse.data.filePath
|
||||||
|
khhaoStateFilePath.value = khhaoResponse.data.stateFilePath
|
||||||
|
khhaoAutoSyncState.value = khhaoResponse.data.syncState
|
||||||
khhaoForm.value = {
|
khhaoForm.value = {
|
||||||
baseUrl: khhaoResponse.data.source.baseUrl || 'https://admin.khhao.com',
|
baseUrl: khhaoResponse.data.source.baseUrl || 'https://admin.khhao.com',
|
||||||
username: khhaoResponse.data.source.username || '',
|
username: khhaoResponse.data.source.username || '',
|
||||||
@@ -148,6 +157,10 @@ async function loadConfigs() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
maxCaptchaAttempts: khhaoResponse.data.source.maxCaptchaAttempts || 3,
|
maxCaptchaAttempts: khhaoResponse.data.source.maxCaptchaAttempts || 3,
|
||||||
|
autoSyncEnabled: khhaoResponse.data.source.autoSync?.enabled === true,
|
||||||
|
autoSyncIntervalMinutes: khhaoResponse.data.source.autoSync?.intervalMinutes || 3,
|
||||||
|
autoSyncPages: khhaoResponse.data.source.autoSync?.pages || 2,
|
||||||
|
autoSyncPageSize: khhaoResponse.data.source.autoSync?.pageSize || 20,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : '读取平台配置失败'
|
errorMessage.value = error instanceof Error ? error.message : '读取平台配置失败'
|
||||||
@@ -294,8 +307,16 @@ async function handleKhhaoSaveSource() {
|
|||||||
username: khhaoForm.value.username.trim(),
|
username: khhaoForm.value.username.trim(),
|
||||||
password: khhaoForm.value.password.trim(),
|
password: khhaoForm.value.password.trim(),
|
||||||
maxCaptchaAttempts: Number(khhaoForm.value.maxCaptchaAttempts || 3),
|
maxCaptchaAttempts: Number(khhaoForm.value.maxCaptchaAttempts || 3),
|
||||||
|
autoSync: {
|
||||||
|
enabled: Boolean(khhaoForm.value.autoSyncEnabled),
|
||||||
|
intervalMinutes: Number(khhaoForm.value.autoSyncIntervalMinutes || 3),
|
||||||
|
pages: Number(khhaoForm.value.autoSyncPages || 2),
|
||||||
|
pageSize: Number(khhaoForm.value.autoSyncPageSize || 20),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
khhaoFilePath.value = response.data.filePath
|
khhaoFilePath.value = response.data.filePath
|
||||||
|
khhaoStateFilePath.value = response.data.stateFilePath
|
||||||
|
khhaoAutoSyncState.value = response.data.syncState
|
||||||
showSuccess('khhao 来源配置已保存')
|
showSuccess('khhao 来源配置已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
khhaoResultError.value = error instanceof Error ? error.message : 'khhao 来源配置保存失败'
|
khhaoResultError.value = error instanceof Error ? error.message : 'khhao 来源配置保存失败'
|
||||||
@@ -610,6 +631,10 @@ onMounted(loadConfigs)
|
|||||||
<span class="meta-label">配置文件</span>
|
<span class="meta-label">配置文件</span>
|
||||||
<code>{{ khhaoFilePath || '-' }}</code>
|
<code>{{ khhaoFilePath || '-' }}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">状态文件</span>
|
||||||
|
<code>{{ khhaoStateFilePath || '-' }}</code>
|
||||||
|
</div>
|
||||||
<div class="meta-line">
|
<div class="meta-line">
|
||||||
<span class="meta-label">职责</span>
|
<span class="meta-label">职责</span>
|
||||||
<span>管理 khhao 的来源地址、登录账号、密码与验证码重试次数,并执行测试登录、查询订单与手动同步。</span>
|
<span>管理 khhao 的来源地址、登录账号、密码与验证码重试次数,并执行测试登录、查询订单与手动同步。</span>
|
||||||
@@ -660,6 +685,29 @@ onMounted(loadConfigs)
|
|||||||
<span>验证码重试次数</span>
|
<span>验证码重试次数</span>
|
||||||
<input v-model.number="khhaoForm.maxCaptchaAttempts" class="text-input" type="number" min="1" max="5" />
|
<input v-model.number="khhaoForm.maxCaptchaAttempts" class="text-input" type="number" min="1" max="5" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>自动同步开关</span>
|
||||||
|
<label class="checkbox-line checkbox-box">
|
||||||
|
<input v-model="khhaoForm.autoSyncEnabled" type="checkbox" />
|
||||||
|
<span>启用自动同步</span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>同步间隔(分钟)</span>
|
||||||
|
<input v-model.number="khhaoForm.autoSyncIntervalMinutes" class="text-input" type="number" min="1" max="60" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>同步页数</span>
|
||||||
|
<input v-model.number="khhaoForm.autoSyncPages" class="text-input" type="number" min="1" max="5" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field-block">
|
||||||
|
<span>每页条数</span>
|
||||||
|
<input v-model.number="khhaoForm.autoSyncPageSize" class="text-input" type="number" min="1" max="50" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="khhaoResultError" class="error-copy">{{ khhaoResultError }}</p>
|
<p v-if="khhaoResultError" class="error-copy">{{ khhaoResultError }}</p>
|
||||||
@@ -689,6 +737,33 @@ onMounted(loadConfigs)
|
|||||||
<p v-else>完成履约配置后可在这里手动同步。</p>
|
<p v-else>完成履约配置后可在这里手动同步。</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="khhaoAutoSyncState" class="meta-card compact-meta">
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">自动同步状态</span>
|
||||||
|
<span>{{ khhaoAutoSyncState.lastRunStatus || 'idle' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">同步基线</span>
|
||||||
|
<span>{{ formatAdminDateTime(khhaoAutoSyncState.syncFromCreatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">最近开始</span>
|
||||||
|
<span>{{ formatAdminDateTime(khhaoAutoSyncState.lastRunStartedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">最近完成</span>
|
||||||
|
<span>{{ formatAdminDateTime(khhaoAutoSyncState.lastRunFinishedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">最近结果</span>
|
||||||
|
<span>拉取 {{ khhaoAutoSyncState.fetchedCount }} 条,成功 {{ khhaoAutoSyncState.syncedCount }} 条,忽略 {{ khhaoAutoSyncState.ignoredCount }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="khhaoAutoSyncState.lastErrorMessage" class="meta-line">
|
||||||
|
<span class="meta-label">最近错误</span>
|
||||||
|
<span class="error-copy">{{ khhaoAutoSyncState.lastErrorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="khhaoQueryResult" class="table-card">
|
<section v-if="khhaoQueryResult" class="table-card">
|
||||||
@@ -1036,6 +1111,14 @@ onMounted(loadConfigs)
|
|||||||
color: #475467;
|
color: #475467;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-box {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.result-grid {
|
.result-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1068,6 +1151,10 @@ onMounted(loadConfigs)
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-meta {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
Reference in New Issue
Block a user