diff --git a/.dockerignore b/.dockerignore index 7166685e..09165d44 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,6 +21,7 @@ apps/backend/data/*.db-shm apps/backend/data/*.db-wal apps/backend/data/browser-sessions apps/backend/data/redeem-screenshots +apps/backend/data/cache apps/backend/.npm-cache # deploy data (server only) diff --git a/.gitignore b/.gitignore index 91f24627..890bd019 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ apps/backend/data/*.json apps/backend/data/cloudtentacles-session.json apps/backend/data/browser-sessions apps/backend/data/redeem-screenshots +apps/backend/data/cache apps/backend/data/logs .ace-tool diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 108acb37..26c36483 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import authRouter from "./admin/auth.js"; import auditLogsRouter from "./admin/audit-logs.js"; +import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js"; import dashboardRouter from "./admin/dashboard.js"; import ordersRouter from "./admin/orders.js"; import platformConfigRouter from "./admin/platform-config.js"; @@ -20,6 +21,7 @@ router.use(auditLogsRouter); router.use(platformConfigRouter); router.use(ordersRouter); router.use(tasksRouter); +router.use(cloudtentaclesRecordsRouter); router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)); diff --git a/apps/backend/src/routes/admin/cloudtentacles-records.ts b/apps/backend/src/routes/admin/cloudtentacles-records.ts new file mode 100644 index 00000000..1e98061d --- /dev/null +++ b/apps/backend/src/routes/admin/cloudtentacles-records.ts @@ -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) { + 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' +} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts index fa2b23dd..a7df2746 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -28,6 +28,7 @@ import { useCloudtentaclesSku, } from "../../../platforms/cloudtentacles/catalog-service.js"; import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js"; +import { listCloudtentaclesDeliveryRecords } from "../../../platforms/cloudtentacles/record-service.js"; import { appointCloudtentaclesVirtualNumber, backCloudtentaclesVirtualNumber, @@ -59,6 +60,7 @@ import { createHttpError } from "../../../../utils/http.js"; import type { AdminCloudtentaclesCatalogQueryInput, + AdminCloudtentaclesDeliveryRecordQueryInput, AdminCloudtentaclesFullFlowInput, AdminCloudtentaclesSendSmsCodeInput, 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( 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(); + 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( payload: AdminCloudtentaclesVirtualNumberInput = {} ) { @@ -373,3 +446,13 @@ export async function runAdminCloudtentaclesFullFlow( 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(); +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts new file mode 100644 index 00000000..54e60662 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts @@ -0,0 +1,132 @@ +import { createHttpError } from '../../../utils/http.js' +import { cloudtentaclesRequest } from './http-client.js' +import { resolveCloudtentaclesConfig } from './helpers.js' + +type JsonObject = Record + +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'), + ) +} diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index b256714d..22658f40 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -5,6 +5,7 @@ import type { } from './read-inputs.js' import type { AdminCloudtentaclesCatalogQueryInput, + AdminCloudtentaclesDeliveryRecordQueryInput, AdminCloudtentaclesFullFlowInput, AdminCloudtentaclesSendSmsCodeInput, AdminCloudtentaclesSkuBuyInput, @@ -42,6 +43,7 @@ export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSms export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput export type AdminCloudtentaclesCatalogQueryRouteBody = AdminCloudtentaclesCatalogQueryInput +export type AdminCloudtentaclesDeliveryRecordQueryRouteBody = AdminCloudtentaclesDeliveryRecordQueryInput export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index 8c5a2785..ca1905a5 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -175,6 +175,14 @@ export type AdminCloudtentaclesCatalogQueryInput = { deviceType?: number | string } +export type AdminCloudtentaclesDeliveryRecordQueryInput = AdminCloudtentaclesCatalogQueryInput & { + page?: number | string + size?: number | string + startDate?: string + endDate?: string + recordCode?: number | string +} + export type AdminCloudtentaclesSkuBuyInput = { sourceKey?: string baseUrl?: string diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index d04129c3..8bdc7fc8 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -24,6 +24,7 @@ declare module 'vue' { ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index c33d583f..bb7006a7 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -53,6 +53,11 @@ const router = createRouter({ path: 'tasks/:taskId', component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'), }, + { + path: 'cloudtentacles-records', + component: () => + import('@/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue'), + }, { path: 'platform-shops', meta: { allowedRoles: ['admin'] }, diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts index e325e5c5..01fc54a0 100644 --- a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts @@ -1,6 +1,7 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' import type { AdminCloudtentaclesLoginTestResult, + AdminCloudtentaclesDeliveryRecordListResult, AdminCloudtentaclesSkuListResult, AdminCloudtentaclesSendSmsResult, 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( + '/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: { baseUrl?: string token?: string diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 2e13be8f..d02c2884 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -64,6 +64,8 @@ export type { AdminCloudtentaclesValidateSessionResult, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSkuListResult, + AdminCloudtentaclesDeliveryRecordItem, + AdminCloudtentaclesDeliveryRecordListResult, AdminKuaishouCloudFulfillmentItem, AdminKuaishouCloudFulfillmentConfig, } from './platform-config' diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts index 5b90b085..572ba21d 100644 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -99,6 +99,34 @@ export interface AdminCloudtentaclesSkuListResult { rawItems: Record[] } +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 +} + +export interface AdminCloudtentaclesDeliveryRecordListResult { + baseUrl: string + page: number + size: number + total: number + items: AdminCloudtentaclesDeliveryRecordItem[] + raw: Record +} + /** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */ export interface AdminCloudtentaclesSourceConfig { enabled: boolean diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index bdded7ba..097e72d8 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -44,6 +44,8 @@ export type { AdminCloudtentaclesValidateSessionResult, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSkuListResult, + AdminCloudtentaclesDeliveryRecordItem, + AdminCloudtentaclesDeliveryRecordListResult, } from './cloudtentacles' export type { diff --git a/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue new file mode 100644 index 00000000..8e1eff51 --- /dev/null +++ b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue @@ -0,0 +1,561 @@ + + + + + diff --git a/apps/frontend/src/views/admin/layout/AdminLayout.vue b/apps/frontend/src/views/admin/layout/AdminLayout.vue index 671839d0..d04aeea8 100644 --- a/apps/frontend/src/views/admin/layout/AdminLayout.vue +++ b/apps/frontend/src/views/admin/layout/AdminLayout.vue @@ -10,6 +10,7 @@ import { Expand, Fold, List, + Memo, Setting, SwitchButton, Tickets, @@ -80,6 +81,7 @@ const navItems = computed(() => { items.push( { to: '/admin/orders', label: '订单', icon: Document }, { to: '/admin/tasks', label: '任务', icon: List }, + { to: '/admin/cloudtentacles-records', label: '查询发货记录', icon: Memo }, ) if (isAdmin.value) {