增加发货记录查询功能-1
This commit is contained in:
@@ -21,6 +21,7 @@ apps/backend/data/*.db-shm
|
|||||||
apps/backend/data/*.db-wal
|
apps/backend/data/*.db-wal
|
||||||
apps/backend/data/browser-sessions
|
apps/backend/data/browser-sessions
|
||||||
apps/backend/data/redeem-screenshots
|
apps/backend/data/redeem-screenshots
|
||||||
|
apps/backend/data/cache
|
||||||
apps/backend/.npm-cache
|
apps/backend/.npm-cache
|
||||||
|
|
||||||
# deploy data (server only)
|
# deploy data (server only)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ apps/backend/data/*.json
|
|||||||
apps/backend/data/cloudtentacles-session.json
|
apps/backend/data/cloudtentacles-session.json
|
||||||
apps/backend/data/browser-sessions
|
apps/backend/data/browser-sessions
|
||||||
apps/backend/data/redeem-screenshots
|
apps/backend/data/redeem-screenshots
|
||||||
|
apps/backend/data/cache
|
||||||
apps/backend/data/logs
|
apps/backend/data/logs
|
||||||
|
|
||||||
.ace-tool
|
.ace-tool
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
|
|
||||||
import authRouter from "./admin/auth.js";
|
import authRouter from "./admin/auth.js";
|
||||||
import auditLogsRouter from "./admin/audit-logs.js";
|
import auditLogsRouter from "./admin/audit-logs.js";
|
||||||
|
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||||
import dashboardRouter from "./admin/dashboard.js";
|
import dashboardRouter from "./admin/dashboard.js";
|
||||||
import ordersRouter from "./admin/orders.js";
|
import ordersRouter from "./admin/orders.js";
|
||||||
import platformConfigRouter from "./admin/platform-config.js";
|
import platformConfigRouter from "./admin/platform-config.js";
|
||||||
@@ -20,6 +21,7 @@ router.use(auditLogsRouter);
|
|||||||
router.use(platformConfigRouter);
|
router.use(platformConfigRouter);
|
||||||
router.use(ordersRouter);
|
router.use(ordersRouter);
|
||||||
router.use(tasksRouter);
|
router.use(tasksRouter);
|
||||||
|
router.use(cloudtentaclesRecordsRouter);
|
||||||
|
|
||||||
router.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req));
|
res.status(404).json(buildNotFoundPayload(req));
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||||
|
import {
|
||||||
|
listAdminCloudtentaclesDeliveryRecords,
|
||||||
|
listAdminCloudtentaclesRecordSources,
|
||||||
|
} from '../../services/admin/platform-config/cloudtentacles/index.js'
|
||||||
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||||
|
import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
const RECORD_IMAGE_CACHE_DIR = path.join(PROJECT_ROOT, 'data', 'cache', 'cloudtentacles-record-images')
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/cloudtentacles-records/sources',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(() => listAdminCloudtentaclesRecordSources(), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取 cloudtentacles 账号列表失败',
|
||||||
|
scope: '[admin/cloudtentacles-records/sources]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/cloudtentacles-records',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
listAdminCloudtentaclesDeliveryRecords(
|
||||||
|
req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: 'cloudtentacles 发货记录查询成功',
|
||||||
|
errorMessage: 'cloudtentacles 发货记录查询失败',
|
||||||
|
scope: '[admin/cloudtentacles-records]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/cloudtentacles-records/image',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const imageUrl = normalizeAllowedImageUrl(req.query.url)
|
||||||
|
const cachedImage = await getCachedRecordImage(imageUrl)
|
||||||
|
res.setHeader('Content-Type', cachedImage.contentType)
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
res.send(cachedImage.buffer)
|
||||||
|
} catch (error) {
|
||||||
|
sendRouteError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
'读取 cloudtentacles 商品图片失败',
|
||||||
|
'[admin/cloudtentacles-records/image]',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
|
function normalizeAllowedImageUrl(value: unknown) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw createHttpError('缺少商品图片地址', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cloudtentacles_record_image_url_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(raw)
|
||||||
|
if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') {
|
||||||
|
throw createHttpError('商品图片地址不在允许范围内', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cloudtentacles_record_image_url_not_allowed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedRecordImage(imageUrl: string) {
|
||||||
|
const cacheMeta = buildRecordImageCacheMeta(imageUrl)
|
||||||
|
const cached = await readCachedRecordImage(cacheMeta)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloaded = await downloadRecordImage(imageUrl)
|
||||||
|
const filePath = cacheMeta.filePathForContentType(downloaded.contentType)
|
||||||
|
await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true })
|
||||||
|
await fs.writeFile(filePath, downloaded.buffer)
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCachedRecordImage(cacheMeta: ReturnType<typeof buildRecordImageCacheMeta>) {
|
||||||
|
for (const candidate of cacheMeta.candidateFilePaths) {
|
||||||
|
try {
|
||||||
|
const buffer = await fs.readFile(candidate.filePath)
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
contentType: candidate.contentType,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = String((error as NodeJS.ErrnoException).code || '')
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRecordImage(imageUrl: string) {
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
headers: {
|
||||||
|
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
referer: 'https://gp.playinjoy.com/',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'cloudtentacles_record_image_fetch_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
throw createHttpError('商品图片响应格式无效', {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'cloudtentacles_record_image_invalid_content_type',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(await response.arrayBuffer()),
|
||||||
|
contentType: normalizeImageContentType(contentType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordImageCacheMeta(imageUrl: string) {
|
||||||
|
const digest = crypto.createHash('sha256').update(imageUrl).digest('hex')
|
||||||
|
const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
return {
|
||||||
|
candidateFilePaths: candidateTypes.map((contentType) => ({
|
||||||
|
contentType,
|
||||||
|
filePath: path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`),
|
||||||
|
})),
|
||||||
|
filePathForContentType(contentType: string) {
|
||||||
|
return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageContentType(contentType: string) {
|
||||||
|
return String(contentType || 'image/jpeg').split(';')[0]?.trim().toLowerCase() || 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForContentType(contentType: string) {
|
||||||
|
const normalized = normalizeImageContentType(contentType)
|
||||||
|
if (normalized === 'image/png') return 'png'
|
||||||
|
if (normalized === 'image/webp') return 'webp'
|
||||||
|
if (normalized === 'image/gif') return 'gif'
|
||||||
|
return 'jpg'
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
useCloudtentaclesSku,
|
useCloudtentaclesSku,
|
||||||
} from "../../../platforms/cloudtentacles/catalog-service.js";
|
} from "../../../platforms/cloudtentacles/catalog-service.js";
|
||||||
import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js";
|
import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js";
|
||||||
|
import { listCloudtentaclesDeliveryRecords } from "../../../platforms/cloudtentacles/record-service.js";
|
||||||
import {
|
import {
|
||||||
appointCloudtentaclesVirtualNumber,
|
appointCloudtentaclesVirtualNumber,
|
||||||
backCloudtentaclesVirtualNumber,
|
backCloudtentaclesVirtualNumber,
|
||||||
@@ -59,6 +60,7 @@ import { createHttpError } from "../../../../utils/http.js";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminCloudtentaclesCatalogQueryInput,
|
AdminCloudtentaclesCatalogQueryInput,
|
||||||
|
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||||
AdminCloudtentaclesFullFlowInput,
|
AdminCloudtentaclesFullFlowInput,
|
||||||
AdminCloudtentaclesSendSmsCodeInput,
|
AdminCloudtentaclesSendSmsCodeInput,
|
||||||
AdminCloudtentaclesSkuBuyInput,
|
AdminCloudtentaclesSkuBuyInput,
|
||||||
@@ -93,6 +95,26 @@ export function listAdminCloudtentaclesSources() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAdminCloudtentaclesRecordSources() {
|
||||||
|
const list = listCloudtentaclesSources();
|
||||||
|
const sessions = getAllCloudtentaclesSessionStates();
|
||||||
|
const sources = Array.isArray(list.sources) ? list.sources : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: sources.map((source) => {
|
||||||
|
const key = String(source.key || "").trim();
|
||||||
|
const session = (sessions.sessions?.[key] || {}) as JsonObject;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: String(source.label || source.username || key || "未命名账号").trim(),
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
hasToken: Boolean(String(session.token || "").trim()),
|
||||||
|
loggedInAt: String(session.loggedInAt || "").trim(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function updateAdminCloudtentaclesSourceConfig(
|
export function updateAdminCloudtentaclesSourceConfig(
|
||||||
payload: AdminCloudtentaclesSourceConfigInput = {}
|
payload: AdminCloudtentaclesSourceConfigInput = {}
|
||||||
) {
|
) {
|
||||||
@@ -286,6 +308,57 @@ export async function getAdminCloudtentaclesKnapsack(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminCloudtentaclesDeliveryRecords(
|
||||||
|
payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}
|
||||||
|
) {
|
||||||
|
const context = resolveAdminCloudtentaclesSessionPayload(payload);
|
||||||
|
const query = {
|
||||||
|
...context,
|
||||||
|
...pickDefined({
|
||||||
|
page: payload.page,
|
||||||
|
size: payload.size,
|
||||||
|
startDate: payload.startDate,
|
||||||
|
endDate: payload.endDate,
|
||||||
|
recordCode: payload.recordCode,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const [records, skuList] = await Promise.all([
|
||||||
|
listCloudtentaclesDeliveryRecords(query),
|
||||||
|
listCloudtentaclesSku(context),
|
||||||
|
]);
|
||||||
|
const skuImageByName = new Map<string, {
|
||||||
|
skuId: number;
|
||||||
|
skuName: string;
|
||||||
|
skuImage: string;
|
||||||
|
}>();
|
||||||
|
for (const sku of Array.isArray(skuList.items) ? skuList.items : []) {
|
||||||
|
const name = normalizeSkuName(sku.name);
|
||||||
|
const skuImage = String(sku.image || "").trim();
|
||||||
|
if (!name || !skuImage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skuImageByName.set(name, {
|
||||||
|
skuId: Number(sku.id || 0),
|
||||||
|
skuName: String(sku.name || "").trim(),
|
||||||
|
skuImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordItems: JsonObject[] = Array.isArray(records.items) ? records.items : [];
|
||||||
|
return {
|
||||||
|
...records,
|
||||||
|
items: recordItems.map((record) => ({
|
||||||
|
...record,
|
||||||
|
...(skuImageByName.get(normalizeSkuName(record.name)) || {
|
||||||
|
skuId: 0,
|
||||||
|
skuName: "",
|
||||||
|
skuImage: "",
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAdminCloudtentaclesVirtualNumbers(
|
export async function listAdminCloudtentaclesVirtualNumbers(
|
||||||
payload: AdminCloudtentaclesVirtualNumberInput = {}
|
payload: AdminCloudtentaclesVirtualNumberInput = {}
|
||||||
) {
|
) {
|
||||||
@@ -373,3 +446,13 @@ export async function runAdminCloudtentaclesFullFlow(
|
|||||||
vnKey: payload.vnKey,
|
vnKey: payload.vnKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickDefined(values: JsonObject = {}) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([, value]) => typeof value !== "undefined")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkuName(value: unknown) {
|
||||||
|
return String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
import { cloudtentaclesRequest } from './http-client.js'
|
||||||
|
import { resolveCloudtentaclesConfig } from './helpers.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export type CloudtentaclesDeliveryRecordQuery = JsonObject & {
|
||||||
|
baseUrl?: string
|
||||||
|
timeoutMs?: number | string
|
||||||
|
token?: string
|
||||||
|
deviceId?: string
|
||||||
|
deviceType?: number | string
|
||||||
|
page?: number | string
|
||||||
|
size?: number | string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
recordCode?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCloudtentaclesDeliveryRecords(
|
||||||
|
payload: CloudtentaclesDeliveryRecordQuery = {},
|
||||||
|
) {
|
||||||
|
const config = resolveCloudtentaclesConfig({
|
||||||
|
...pickDefined({
|
||||||
|
baseUrl: payload.baseUrl,
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
deviceType: payload.deviceType,
|
||||||
|
}),
|
||||||
|
...(
|
||||||
|
typeof payload.timeoutMs === 'undefined'
|
||||||
|
? {}
|
||||||
|
: { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const page = normalizePositiveInteger(payload.page, 1)
|
||||||
|
const size = normalizePageSize(payload.size)
|
||||||
|
const startDate = normalizeDateInput(payload.startDate)
|
||||||
|
const endDate = normalizeDateInput(payload.endDate)
|
||||||
|
|
||||||
|
if (!String(payload.token || '').trim()) {
|
||||||
|
throw createHttpError('cloudtentacles 发货记录查询缺少 token', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'cloudtentacles_record_missing_token',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw createHttpError('cloudtentacles 发货记录查询需要选择开始和结束时间', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cloudtentacles_record_date_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cloudtentaclesRequest('/record/paging', {
|
||||||
|
...payload,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
record_code: normalizePositiveInteger(payload.recordCode, 4),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
},
|
||||||
|
businessErrorCode: 'cloudtentacles_record_query_failed',
|
||||||
|
timeoutMs: payload.timeoutMs || config.timeoutMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = isPlainObject(result.payload?.data) ? result.payload.data : {}
|
||||||
|
const values = Array.isArray(data.values) ? data.values : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
total: normalizeNonNegativeInteger(data.total, values.length),
|
||||||
|
items: values.map(mapCloudtentaclesDeliveryRecord),
|
||||||
|
raw: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCloudtentaclesDeliveryRecord(raw: JsonObject) {
|
||||||
|
return {
|
||||||
|
createdAt: String(raw.c_time || '').trim(),
|
||||||
|
recordId: String(raw.record_id || '').trim(),
|
||||||
|
virtualNumberId: normalizeNonNegativeInteger(raw.virtual_number_id, 0),
|
||||||
|
userId: normalizeNonNegativeInteger(raw.user_id, 0),
|
||||||
|
count: normalizeNonNegativeInteger(raw.count, 0),
|
||||||
|
cdk: String(raw.cdk || '').trim(),
|
||||||
|
exchangeUrl: String(raw.exchange_url || '').trim(),
|
||||||
|
name: String(raw.name || '').trim(),
|
||||||
|
phone: String(raw.phone || '').trim(),
|
||||||
|
status: normalizeNonNegativeInteger(raw.status, 0),
|
||||||
|
fulfillUser: String(raw.fulfill_user || '').trim(),
|
||||||
|
operatorName: String(raw.u_name || '').trim(),
|
||||||
|
raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageSize(value: unknown) {
|
||||||
|
const parsed = normalizePositiveInteger(value, 100)
|
||||||
|
return Math.min(Math.max(parsed, 100), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateInput(value: unknown) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(raw)
|
||||||
|
return Number.isNaN(date.getTime()) ? raw : date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is JsonObject {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDefined(values: JsonObject = {}) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([, value]) => typeof value !== 'undefined'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from './read-inputs.js'
|
} from './read-inputs.js'
|
||||||
import type {
|
import type {
|
||||||
AdminCloudtentaclesCatalogQueryInput,
|
AdminCloudtentaclesCatalogQueryInput,
|
||||||
|
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||||
AdminCloudtentaclesFullFlowInput,
|
AdminCloudtentaclesFullFlowInput,
|
||||||
AdminCloudtentaclesSendSmsCodeInput,
|
AdminCloudtentaclesSendSmsCodeInput,
|
||||||
AdminCloudtentaclesSkuBuyInput,
|
AdminCloudtentaclesSkuBuyInput,
|
||||||
@@ -42,6 +43,7 @@ export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSms
|
|||||||
export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput
|
export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput
|
||||||
export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput
|
export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput
|
||||||
export type AdminCloudtentaclesCatalogQueryRouteBody = AdminCloudtentaclesCatalogQueryInput
|
export type AdminCloudtentaclesCatalogQueryRouteBody = AdminCloudtentaclesCatalogQueryInput
|
||||||
|
export type AdminCloudtentaclesDeliveryRecordQueryRouteBody = AdminCloudtentaclesDeliveryRecordQueryInput
|
||||||
export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput
|
export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput
|
||||||
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
|
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
|
||||||
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
|
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ export type AdminCloudtentaclesCatalogQueryInput = {
|
|||||||
deviceType?: number | string
|
deviceType?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminCloudtentaclesDeliveryRecordQueryInput = AdminCloudtentaclesCatalogQueryInput & {
|
||||||
|
page?: number | string
|
||||||
|
size?: number | string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
recordCode?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminCloudtentaclesSkuBuyInput = {
|
export type AdminCloudtentaclesSkuBuyInput = {
|
||||||
sourceKey?: string
|
sourceKey?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
|||||||
Vendored
+1
@@ -24,6 +24,7 @@ declare module 'vue' {
|
|||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const router = createRouter({
|
|||||||
path: 'tasks/:taskId',
|
path: 'tasks/:taskId',
|
||||||
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'cloudtentacles-records',
|
||||||
|
component: () =>
|
||||||
|
import('@/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'platform-shops',
|
path: 'platform-shops',
|
||||||
meta: { allowedRoles: ['admin'] },
|
meta: { allowedRoles: ['admin'] },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||||
import type {
|
import type {
|
||||||
AdminCloudtentaclesLoginTestResult,
|
AdminCloudtentaclesLoginTestResult,
|
||||||
|
AdminCloudtentaclesDeliveryRecordListResult,
|
||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
AdminCloudtentaclesSendSmsResult,
|
AdminCloudtentaclesSendSmsResult,
|
||||||
AdminCloudtentaclesSourceConfigResponse,
|
AdminCloudtentaclesSourceConfigResponse,
|
||||||
@@ -158,6 +159,31 @@ export function fetchAdminCloudtentaclesKnapsack(payload: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminCloudtentaclesDeliveryRecords(payload: {
|
||||||
|
sourceKey?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}) {
|
||||||
|
return apiPost<AdminCloudtentaclesDeliveryRecordListResult>(
|
||||||
|
'/api/v1/admin/cloudtentacles-records',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminCloudtentaclesRecordSources() {
|
||||||
|
return apiGet<{
|
||||||
|
sources: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
hasToken: boolean
|
||||||
|
loggedInAt: string
|
||||||
|
}>
|
||||||
|
}>('/api/v1/admin/cloudtentacles-records/sources')
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminCloudtentaclesVnList(payload: {
|
export function fetchAdminCloudtentaclesVnList(payload: {
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
token?: string
|
token?: string
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type {
|
|||||||
AdminCloudtentaclesValidateSessionResult,
|
AdminCloudtentaclesValidateSessionResult,
|
||||||
AdminCloudtentaclesSkuItem,
|
AdminCloudtentaclesSkuItem,
|
||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
|
AdminCloudtentaclesDeliveryRecordItem,
|
||||||
|
AdminCloudtentaclesDeliveryRecordListResult,
|
||||||
AdminKuaishouCloudFulfillmentItem,
|
AdminKuaishouCloudFulfillmentItem,
|
||||||
AdminKuaishouCloudFulfillmentConfig,
|
AdminKuaishouCloudFulfillmentConfig,
|
||||||
} from './platform-config'
|
} from './platform-config'
|
||||||
|
|||||||
@@ -99,6 +99,34 @@ export interface AdminCloudtentaclesSkuListResult {
|
|||||||
rawItems: Record<string, unknown>[]
|
rawItems: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminCloudtentaclesDeliveryRecordItem {
|
||||||
|
createdAt: string
|
||||||
|
recordId: string
|
||||||
|
virtualNumberId: number
|
||||||
|
userId: number
|
||||||
|
count: number
|
||||||
|
cdk: string
|
||||||
|
exchangeUrl: string
|
||||||
|
name: string
|
||||||
|
skuId: number
|
||||||
|
skuName: string
|
||||||
|
skuImage: string
|
||||||
|
phone: string
|
||||||
|
status: number
|
||||||
|
fulfillUser: string
|
||||||
|
operatorName: string
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCloudtentaclesDeliveryRecordListResult {
|
||||||
|
baseUrl: string
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
items: AdminCloudtentaclesDeliveryRecordItem[]
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */
|
/** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */
|
||||||
export interface AdminCloudtentaclesSourceConfig {
|
export interface AdminCloudtentaclesSourceConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export type {
|
|||||||
AdminCloudtentaclesValidateSessionResult,
|
AdminCloudtentaclesValidateSessionResult,
|
||||||
AdminCloudtentaclesSkuItem,
|
AdminCloudtentaclesSkuItem,
|
||||||
AdminCloudtentaclesSkuListResult,
|
AdminCloudtentaclesSkuListResult,
|
||||||
|
AdminCloudtentaclesDeliveryRecordItem,
|
||||||
|
AdminCloudtentaclesDeliveryRecordListResult,
|
||||||
} from './cloudtentacles'
|
} from './cloudtentacles'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
+561
@@ -0,0 +1,561 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||||
|
import { showError, showSuccess } from '@/lib/feedback'
|
||||||
|
import {
|
||||||
|
fetchAdminCloudtentaclesDeliveryRecords,
|
||||||
|
fetchAdminCloudtentaclesRecordSources,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin'
|
||||||
|
import { getAdminToken } from '@/utils/admin-auth'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
type RecordSourceOption = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
hasToken: boolean
|
||||||
|
loggedInAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const sourceLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const sourceKey = ref('')
|
||||||
|
const dateRange = ref<[string, string]>(createDefaultDateRange())
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(100)
|
||||||
|
const total = ref(0)
|
||||||
|
const items = ref<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||||
|
const sourceOptions = ref<RecordSourceOption[]>([])
|
||||||
|
const previewVisible = ref(false)
|
||||||
|
const previewImageUrl = ref('')
|
||||||
|
const previewFileName = ref('')
|
||||||
|
|
||||||
|
const usableSourceOptions = computed(() =>
|
||||||
|
sourceOptions.value.filter((source) => source.enabled && source.hasToken),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSourceLabel = computed(() => {
|
||||||
|
const matched = sourceOptions.value.find((source) => source.key === sourceKey.value)
|
||||||
|
return matched?.label || sourceKey.value || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
sourceLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminCloudtentaclesRecordSources()
|
||||||
|
sourceOptions.value = response.data.sources || []
|
||||||
|
if (!sourceKey.value) {
|
||||||
|
sourceKey.value = usableSourceOptions.value[0]?.key || sourceOptions.value[0]?.key || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 账号失败'
|
||||||
|
} finally {
|
||||||
|
sourceLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(nextPage = page.value) {
|
||||||
|
if (!sourceKey.value) {
|
||||||
|
showError('请先选择 cloudtentacles 账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startDate, endDate] = dateRange.value || []
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
showError('请选择查询时间范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminCloudtentaclesDeliveryRecords({
|
||||||
|
sourceKey: sourceKey.value,
|
||||||
|
page: nextPage,
|
||||||
|
size: pageSize.value,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
})
|
||||||
|
page.value = response.data.page
|
||||||
|
pageSize.value = response.data.size
|
||||||
|
total.value = response.data.total
|
||||||
|
items.value = response.data.items || []
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '查询发货记录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
dateRange.value = createDefaultDateRange()
|
||||||
|
page.value = 1
|
||||||
|
pageSize.value = 100
|
||||||
|
void loadRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
void loadRecords(nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(nextSize: number) {
|
||||||
|
pageSize.value = nextSize
|
||||||
|
void loadRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await drawRecordImage(record)
|
||||||
|
previewImageUrl.value = dataUrl
|
||||||
|
previewFileName.value = buildRecordImageFileName(record)
|
||||||
|
previewVisible.value = true
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPreviewImage() {
|
||||||
|
if (!previewImageUrl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = previewImageUrl.value
|
||||||
|
link.download = previewFileName.value || 'cloudtentacles-delivery-record.png'
|
||||||
|
link.click()
|
||||||
|
showSuccess('发货记录图片已生成')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: number) {
|
||||||
|
if (status === 4) return '已完成'
|
||||||
|
if (status === 1) return '处理中'
|
||||||
|
if (status === 2) return '待处理'
|
||||||
|
if (status === 3) return '失败'
|
||||||
|
return `状态 ${status || '-'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusType(status: number) {
|
||||||
|
if (status === 4) return 'success'
|
||||||
|
if (status === 3) return 'danger'
|
||||||
|
if (status === 1) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultDateRange(): [string, string] {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setDate(start.getDate() - 7)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return [formatLocalDateTime(start), formatLocalDateTime(end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalDateTime(date: Date) {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||||
|
const productImage = await loadRecordProductImage(record.skuImage)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const scale = window.devicePixelRatio || 1
|
||||||
|
const width = 720
|
||||||
|
const height = 260
|
||||||
|
canvas.width = width * scale
|
||||||
|
canvas.height = height * scale
|
||||||
|
canvas.style.width = `${width}px`
|
||||||
|
canvas.style.height = `${height}px`
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('当前浏览器不支持图片生成')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
ctx.fillStyle = '#1f2937'
|
||||||
|
ctx.font = '20px sans-serif'
|
||||||
|
ctx.fillText('订单详情', 28, 38)
|
||||||
|
ctx.fillStyle = '#9ca3af'
|
||||||
|
ctx.font = '24px sans-serif'
|
||||||
|
ctx.fillText('×', width - 40, 38)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#111827'
|
||||||
|
ctx.font = '13px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88)
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
|
||||||
|
drawTicketCard(ctx, record, productImage)
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTicketCard(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
record: AdminCloudtentaclesDeliveryRecordItem,
|
||||||
|
productImage: HTMLImageElement | null,
|
||||||
|
) {
|
||||||
|
const x = 170
|
||||||
|
const y = 108
|
||||||
|
const width = 392
|
||||||
|
const height = 108
|
||||||
|
const imageWidth = 106
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + 18, y)
|
||||||
|
ctx.lineTo(x + width, y)
|
||||||
|
ctx.lineTo(x + width, y + height - 22)
|
||||||
|
ctx.lineTo(x + width - 18, y + height)
|
||||||
|
ctx.lineTo(x, y + height)
|
||||||
|
ctx.lineTo(x, y + 18)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fillStyle = '#f8fbff'
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = '#2f80d1'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height)
|
||||||
|
gradient.addColorStop(0, '#e7f1ff')
|
||||||
|
gradient.addColorStop(1, '#b8d7ff')
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.fillRect(x, y, imageWidth, height)
|
||||||
|
if (productImage) {
|
||||||
|
drawCoverImage(ctx, productImage, x, y, imageWidth, height)
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#355d91'
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(x + 34, y + 42, 38, 38)
|
||||||
|
ctx.strokeStyle = '#8ab6ef'
|
||||||
|
ctx.strokeRect(x + 34, y + 42, 38, 38)
|
||||||
|
ctx.fillStyle = '#2f80d1'
|
||||||
|
ctx.font = '20px sans-serif'
|
||||||
|
ctx.fillText('GP', x + imageWidth / 2, y + 69)
|
||||||
|
}
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
|
||||||
|
const contentX = x + imageWidth + 16
|
||||||
|
const orderText = `订单号:${record.virtualNumberId || record.recordId.slice(-6) || '-'}`
|
||||||
|
ctx.fillStyle = '#1d5ea8'
|
||||||
|
ctx.font = '16px sans-serif'
|
||||||
|
drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170)
|
||||||
|
ctx.fillStyle = '#3478c6'
|
||||||
|
ctx.font = '13px sans-serif'
|
||||||
|
ctx.textAlign = 'right'
|
||||||
|
ctx.fillText(orderText, x + width - 12, y + 30)
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#c8d8ec'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(contentX, y + 42)
|
||||||
|
ctx.lineTo(x + width - 12, y + 42)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f59e0b'
|
||||||
|
ctx.font = '14px sans-serif'
|
||||||
|
ctx.fillText(`购买机会 × ${record.count || 1}`, contentX, y + 66)
|
||||||
|
ctx.fillStyle = '#16a34a'
|
||||||
|
ctx.textAlign = 'right'
|
||||||
|
ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66)
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
|
||||||
|
ctx.fillStyle = '#3478c6'
|
||||||
|
ctx.font = '14px sans-serif'
|
||||||
|
drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordProductImage(imageUrl: string) {
|
||||||
|
const normalizedUrl = String(imageUrl || '').trim()
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent(
|
||||||
|
normalizedUrl,
|
||||||
|
)}`
|
||||||
|
return loadProtectedImage(proxyUrl).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProtectedImage(src: string) {
|
||||||
|
const token = getAdminToken()
|
||||||
|
const response = await fetch(src, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('商品图片加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob())
|
||||||
|
try {
|
||||||
|
return await loadImage(objectUrl)
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = () => resolve(image)
|
||||||
|
image.onerror = () => reject(new Error('商品图片加载失败'))
|
||||||
|
image.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCoverImage(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
image: HTMLImageElement,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight)
|
||||||
|
const drawWidth = image.naturalWidth * ratio
|
||||||
|
const drawHeight = image.naturalHeight * ratio
|
||||||
|
const drawX = x + (width - drawWidth) / 2
|
||||||
|
const drawY = y + (height - drawHeight) / 2
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.rect(x, y, width, height)
|
||||||
|
ctx.clip()
|
||||||
|
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEllipsisText(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
maxWidth: number,
|
||||||
|
) {
|
||||||
|
let output = text
|
||||||
|
while (output.length > 0 && ctx.measureText(output).width > maxWidth) {
|
||||||
|
output = `${output.slice(0, -2)}…`
|
||||||
|
}
|
||||||
|
ctx.fillText(output || '-', x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||||
|
const name = sanitizeFileName(record.name || '发货记录')
|
||||||
|
const id = sanitizeFileName(record.recordId.slice(0, 8) || String(record.virtualNumberId || 'record'))
|
||||||
|
return `${name}-${id}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(value: string) {
|
||||||
|
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSources()
|
||||||
|
if (sourceKey.value) {
|
||||||
|
await loadRecords(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cloud-records-page list-page">
|
||||||
|
<AdminPageHeader title="查询发货记录" description="按 cloudtentacles 账号和时间范围查询平台发货记录。">
|
||||||
|
<template #extra>
|
||||||
|
<span class="total-badge">共 {{ total }} 条记录</span>
|
||||||
|
</template>
|
||||||
|
</AdminPageHeader>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="section-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">查询条件</span>
|
||||||
|
<span class="card-desc">查询 cloudtentacles 的已发货记录,记录码默认使用平台发货记录。</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="filter-grid cloud-records-filter">
|
||||||
|
<el-select
|
||||||
|
v-model="sourceKey"
|
||||||
|
class="filter-control"
|
||||||
|
placeholder="cloudtentacles 账号"
|
||||||
|
:loading="sourceLoading"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="source in sourceOptions"
|
||||||
|
:key="source.key"
|
||||||
|
:label="`${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`"
|
||||||
|
:value="source.key"
|
||||||
|
:disabled="!source.enabled || !source.hasToken"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
class="filter-control cloud-records-date-range"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
/>
|
||||||
|
<div class="filter-buttons">
|
||||||
|
<el-button round @click="resetFilters">重置</el-button>
|
||||||
|
<el-button round type="primary" :loading="loading" @click="loadRecords(1)">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
:title="errorMessage"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-card
|
||||||
|
shadow="never"
|
||||||
|
class="section-card"
|
||||||
|
v-loading="loading"
|
||||||
|
element-loading-text="发货记录加载中"
|
||||||
|
>
|
||||||
|
<template v-if="items.length === 0 && !loading">
|
||||||
|
<el-empty description="当前筛选条件下暂无发货记录" :image-size="60" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<strong>发货记录</strong>
|
||||||
|
<span>账号 {{ currentSourceLabel }} · 第 {{ page }} 页,当前展示 {{ items.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="items" stripe size="small" class="data-table cloud-records-table">
|
||||||
|
<el-table-column label="商品 / 角色" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-stack">
|
||||||
|
<span class="cell-title" :title="row.name">{{ row.name || '-' }}</span>
|
||||||
|
<span class="cell-subline" :title="row.fulfillUser">
|
||||||
|
账号:{{ row.fulfillUser || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="虚拟号" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-stack">
|
||||||
|
<span class="cell-title">{{ row.phone || '-' }}</span>
|
||||||
|
<span class="cell-subline">VN {{ row.virtualNumberId || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)" effect="plain">
|
||||||
|
{{ getStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作人" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.operatorName || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发货时间" min-width="170">
|
||||||
|
<template #default="{ row }">{{ formatAdminDateTime(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="记录 ID" min-width="210">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-subline" :title="row.recordId">{{ row.recordId || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="136" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" plain @click="generateRecordImage(row)">
|
||||||
|
生成图片
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="cloud-records-pagination">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[100, 200, 500, 1000]"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="previewVisible" title="发货记录图片" width="760px" destroy-on-close>
|
||||||
|
<div class="record-preview">
|
||||||
|
<img v-if="previewImageUrl" :src="previewImageUrl" alt="发货记录图片预览" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="previewVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="downloadPreviewImage">下载图片</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '@/styles/admin-list-pages.css';
|
||||||
|
|
||||||
|
.cloud-records-filter {
|
||||||
|
grid-template-columns: minmax(180px, 0.34fr) minmax(360px, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-records-date-range {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-records-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-preview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 260px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.cloud-records-filter {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-records-pagination {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Expand,
|
Expand,
|
||||||
Fold,
|
Fold,
|
||||||
List,
|
List,
|
||||||
|
Memo,
|
||||||
Setting,
|
Setting,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
Tickets,
|
Tickets,
|
||||||
@@ -80,6 +81,7 @@ const navItems = computed(() => {
|
|||||||
items.push(
|
items.push(
|
||||||
{ to: '/admin/orders', label: '订单', icon: Document },
|
{ to: '/admin/orders', label: '订单', icon: Document },
|
||||||
{ to: '/admin/tasks', label: '任务', icon: List },
|
{ to: '/admin/tasks', label: '任务', icon: List },
|
||||||
|
{ to: '/admin/cloudtentacles-records', label: '查询发货记录', icon: Memo },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user