拆分feifei配置并修复商品查询

This commit is contained in:
yml2213
2026-07-07 14:59:22 +08:00
parent 83d501dc2d
commit 6af2fe2e23
13 changed files with 1317 additions and 221 deletions
@@ -1,8 +1,11 @@
import { Router } from "express";
import {
createAdminKuaishouFeifeiTestOrder,
getAdminKuaishouFeifeiConfig,
listAdminKuaishouFeifeiProducts,
matchAdminKuaishouFeifeiProduct,
queryAdminKuaishouFeifeiTestOrder,
updateAdminKuaishouFeifeiConfig,
} from "../../../services/admin/platform-config/kuaishou-feifei-service.js";
import { createJsonHandler } from "../session.js";
@@ -59,4 +62,55 @@ router.post(
)
);
router.post(
"/kuaishou-feifei/products",
createJsonHandler(
(req) => listAdminKuaishouFeifeiProducts(req.body as JsonRecord),
{
successMessage: "ok",
errorMessage: "查询 kuaishou-feifei 商品失败",
scope: "[admin/platform-config/kuaishou-feifei/products]",
}
)
);
router.post(
"/kuaishou-feifei/test-order",
createJsonHandler(
(req) => createAdminKuaishouFeifeiTestOrder(req.body as JsonRecord),
{
successMessage: "kuaishou-feifei 测试单已创建",
errorMessage: "创建 kuaishou-feifei 测试单失败",
scope: "[admin/platform-config/kuaishou-feifei/test-order]",
audit: (_req, data) => {
const result = data as JsonRecord;
return {
action: "platform_kuaishou_feifei_test_order_created",
targetType: "platform_config",
targetId: "kuaishou_feifei",
data: {
orderNo: String(result.orderNo || "").trim(),
platformOrderNo: String(result.platformOrderNo || "").trim(),
productCode: String(result.productCode || "").trim(),
rechargeStatus: Number(result.rechargeStatus || 0) || 0,
},
};
},
}
)
);
router.post(
"/kuaishou-feifei/query-order",
createJsonHandler(
(req) => queryAdminKuaishouFeifeiTestOrder(req.body as JsonRecord),
{
successMessage: "ok",
errorMessage: "查询 kuaishou-feifei 订单失败",
scope: "[admin/platform-config/kuaishou-feifei/query-order]",
}
)
);
export default router;
@@ -1,4 +1,9 @@
import { getKuaishouFeifeiConfig } from '../../platforms/kuaishou-feifei/config.js'
import {
createKuaishouFeifeiOrder,
listKuaishouFeifeiProducts,
queryKuaishouFeifeiOrder,
} from '../../platforms/kuaishou-feifei/order-service.js'
import { resolveKuaishouFeifeiProductByName } from '../../platforms/kuaishou-feifei/product-rule-service.js'
import {
getKuaishouFeifeiConfigFilePath,
@@ -8,6 +13,7 @@ import {
saveKuaishouFeifeiSourceConfig,
} from '../../platforms/kuaishou-feifei/source-config-service.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
import { createHttpError } from '../../../utils/http.js'
type JsonObject = Record<string, any>
@@ -49,6 +55,70 @@ export function matchAdminKuaishouFeifeiProduct(payload: JsonObject = {}) {
}
}
export async function listAdminKuaishouFeifeiProducts(payload: JsonObject = {}) {
return listKuaishouFeifeiProducts({
page: Number(payload.page || 1) || 1,
perPage: Number(payload.perPage || payload.per_page || 20) || 20,
status: String(payload.status || 'on_sale').trim(),
supplyProductName: String(payload.supplyProductName || payload.supply_product_name || '').trim(),
})
}
export async function createAdminKuaishouFeifeiTestOrder(payload: JsonObject = {}) {
const platformOrderNo = String(payload.platformOrderNo || payload.platform_order_no || '').trim()
const productCode = String(payload.productCode || payload.product_code || '').trim()
if (!platformOrderNo) {
throw createHttpError('测试业务单号不能为空', {
statusCode: 400,
errorCode: 'kuaishou_feifei_missing_platform_order_no',
})
}
if (!productCode) {
throw createHttpError('feifei 商品编码不能为空', {
statusCode: 400,
errorCode: 'kuaishou_feifei_missing_product_code',
})
}
const orderInput: Parameters<typeof createKuaishouFeifeiOrder>[0] = {
platformOrderNo,
productCode,
platformBuyNum: Number(payload.platformBuyNum || payload.platform_buy_num || 1) || 1,
playerAccount: String(payload.playerAccount || payload.player_account || '').trim(),
playerGameRegion: String(payload.playerGameRegion || payload.player_game_region || '').trim(),
playerGameSrv: String(payload.playerGameSrv || payload.player_game_srv || '').trim(),
playerGameRole: String(payload.playerGameRole || payload.player_game_role || '').trim(),
submitPlayer: payload.submitPlayer === true || payload.submit_player === true,
notifyUrl: String(payload.notifyUrl || payload.notify_url || '').trim(),
}
const platformAmount = normalizeOptionalNumber(payload.platformAmount ?? payload.platform_amount)
if (platformAmount !== undefined) {
orderInput.platformAmount = platformAmount
}
return createKuaishouFeifeiOrder(orderInput)
}
export async function queryAdminKuaishouFeifeiTestOrder(payload: JsonObject = {}) {
const platformOrderNo = String(payload.platformOrderNo || payload.platform_order_no || '').trim()
const orderNo = String(payload.orderNo || payload.order_no || '').trim()
if (!platformOrderNo && !orderNo) {
throw createHttpError('请填写 feifei 平台单号或测试业务单号', {
statusCode: 400,
errorCode: 'kuaishou_feifei_missing_order_query',
})
}
return queryKuaishouFeifeiOrder({
platformOrderNo,
orderNo,
})
}
function getAdminEditableKuaishouFeifeiConfig() {
if (hasKuaishouFeifeiConfigFile()) {
return getKuaishouFeifeiSourceConfig()
@@ -68,3 +138,12 @@ function mapEffectiveKuaishouFeifeiConfig(config: ReturnType<typeof getKuaishouF
productRuleCount: config.productRules.length,
}
}
function normalizeOptionalNumber(value: unknown) {
if (value == null || String(value).trim() === '') {
return undefined
}
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : undefined
}
@@ -2,7 +2,10 @@ import crypto from 'node:crypto'
import test from 'node:test'
import assert from 'node:assert/strict'
import { signKuaishouFeifeiPayload } from './http-client.js'
import {
isKuaishouFeifeiSuccessResponse,
signKuaishouFeifeiPayload,
} from './http-client.js'
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
const input = {
@@ -19,3 +22,10 @@ test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC
assert.equal(signKuaishouFeifeiPayload(input), expected)
})
test('isKuaishouFeifeiSuccessResponse accepts documented and actual success codes', () => {
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 0, message: 'success' }), true)
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: 'success' }), true)
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '商品不存在' }), false)
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 1, message: 'success' }), false)
})
@@ -44,7 +44,7 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec
})
}
if (Number(json.code || 0) !== 0) {
if (!isKuaishouFeifeiSuccessResponse(json)) {
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
statusCode: 502,
errorCode: 'kuaishou_feifei_business_failed',
@@ -85,6 +85,13 @@ export function signKuaishouFeifeiPayload({
.toLowerCase()
}
export function isKuaishouFeifeiSuccessResponse(json: JsonObject) {
const code = Number(json.code ?? 0)
const message = String(json.message || json.msg || '').trim().toLowerCase()
return code === 0 || (code === 10000 && message === 'success')
}
function parseJsonObject(text: string): JsonObject {
try {
const parsed = JSON.parse(text || '{}')
@@ -3,6 +3,30 @@ import { kuaishouFeifeiRequest } from './http-client.js'
type JsonObject = Record<string, any>
export async function listKuaishouFeifeiProducts(input: {
page?: number
perPage?: number
status?: string
supplyProductName?: string
} = {}) {
const page = Math.max(1, Number(input.page || 1) || 1)
const perPage = Math.min(100, Math.max(1, Number(input.perPage || 20) || 20))
const filters: JsonObject = {}
const status = String(input.status || 'on_sale').trim()
const supplyProductName = String(input.supplyProductName || '').trim()
if (status) filters.status = status
if (supplyProductName) filters.supply_product_name = supplyProductName
const response = await kuaishouFeifeiRequest('/api/open/v1/products/index', {
page,
per_page: perPage,
filters,
})
return mapKuaishouFeifeiProductList(response.data)
}
export async function createKuaishouFeifeiOrder(input: {
platformOrderNo: string
productCode: string
@@ -73,3 +97,40 @@ export function mapKuaishouFeifeiOrder(value: unknown) {
raw: source,
}
}
export function mapKuaishouFeifeiProductList(value: unknown) {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const list = Array.isArray(source.list) ? source.list : []
return {
items: list.map((item) => mapKuaishouFeifeiProduct(item)),
total: Number(source.total || list.length) || 0,
page: Number(source.page || 1) || 1,
perPage: Number(source.per_page || source.perPage || list.length || 20) || 20,
raw: source,
}
}
export function mapKuaishouFeifeiProduct(value: unknown) {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
return {
productCode: String(source.product_code || '').trim(),
name: String(source.name || '').trim(),
channel: String(source.channel || '').trim(),
externalProductId: String(source.external_product_id || '').trim(),
imageUrl: String(source.image_url || '').trim(),
status: String(source.status || '').trim(),
supplyStatus: String(source.supply_status || '').trim(),
supplyPricePoints: Number(source.supply_price_points || 0) || 0,
feePoints: Number(source.fee_points || 0) || 0,
unitCostPoints: Number(source.unit_cost_points || 0) || 0,
maxOrderQuantity: Number(source.max_order_quantity || 0) || 0,
salePricePoints: source.sale_price_points == null ? null : Number(source.sale_price_points || 0) || 0,
raw: source,
}
}
+1
View File
@@ -33,6 +33,7 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -3,6 +3,8 @@ import type {
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiOrderResult,
AdminKuaishouFeifeiProductListResult,
} from '@/types/admin'
export function fetchAdminKuaishouFeifeiConfig() {
@@ -24,3 +26,43 @@ export function matchAdminKuaishouFeifeiProduct(productName: string) {
{ productName },
)
}
export function fetchAdminKuaishouFeifeiProducts(payload: {
page?: number
perPage?: number
status?: string
supplyProductName?: string
}) {
return apiPost<AdminKuaishouFeifeiProductListResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/products',
payload,
)
}
export function createAdminKuaishouFeifeiTestOrder(payload: {
platformOrderNo: string
productCode: string
platformBuyNum?: number
platformAmount?: number | null
playerAccount?: string
playerGameRegion?: string
playerGameSrv?: string
playerGameRole?: string
submitPlayer?: boolean
notifyUrl?: string
}) {
return apiPost<AdminKuaishouFeifeiOrderResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/test-order',
payload,
)
}
export function queryAdminKuaishouFeifeiOrder(payload: {
platformOrderNo?: string
orderNo?: string
}) {
return apiPost<AdminKuaishouFeifeiOrderResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/query-order',
payload,
)
}
+3
View File
@@ -56,6 +56,9 @@ export type {
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiProductMatch,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductItem,
AdminKuaishouFeifeiProductListResult,
AdminKuaishouFeifeiOrderResult,
AdminCloudtentaclesSourceItem,
AdminCloudtentaclesSourcesConfig,
AdminCloudtentaclesSourceConfigResponse,
@@ -37,6 +37,9 @@ export type {
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiProductMatch,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductItem,
AdminKuaishouFeifeiProductListResult,
AdminKuaishouFeifeiOrderResult,
} from './kuaishou-feifei'
export type {
@@ -48,3 +48,48 @@ export interface AdminKuaishouFeifeiMatchResult {
matched: boolean
match: AdminKuaishouFeifeiProductMatch | null
}
export interface AdminKuaishouFeifeiProductItem {
productCode: string
name: string
channel: string
externalProductId: string
imageUrl: string
status: string
supplyStatus: string
supplyPricePoints: number
feePoints: number
unitCostPoints: number
maxOrderQuantity: number
salePricePoints: number | null
raw: Record<string, unknown>
}
export interface AdminKuaishouFeifeiProductListResult {
items: AdminKuaishouFeifeiProductItem[]
total: number
page: number
perPage: number
raw: Record<string, unknown>
}
export interface AdminKuaishouFeifeiOrderResult {
orderNo: string
platformOrderNo: string
productCode: string
productName: string
rechargeStatus: number
rechargeStatusLabel: string
pointsCharged: number
playerAccount: string
platformBuyNum: number
rechargeResultMessage: string
createdAt: string
updatedAt: string
rechargeFinishAt: string
h5: {
entryUrl: string
rechargeUrl: string
}
raw: Record<string, unknown>
}
@@ -11,7 +11,6 @@ import {
import type {
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiEffectiveConfig,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductRule,
} from '@/types/admin'
@@ -26,16 +25,7 @@ const errorMessage = ref('')
const filePath = ref('')
const matchInput = ref('')
const matchResult = ref<AdminKuaishouFeifeiMatchResult | null>(null)
const effective = ref<AdminKuaishouFeifeiEffectiveConfig>({
enabled: true,
baseUrl: '',
timeoutMs: 10000,
notifyUrl: '',
hasAppKey: false,
hasAppSecret: false,
productRuleCount: 0,
})
const form = reactive<AdminKuaishouFeifeiConfig>({
const sourceConfig = reactive<AdminKuaishouFeifeiConfig>({
enabled: true,
baseUrl: '',
appKey: '',
@@ -47,8 +37,27 @@ const form = reactive<AdminKuaishouFeifeiConfig>({
const rules = ref<EditableRule[]>([])
const enabledRuleCount = computed(() => rules.value.filter((rule) => rule.enabled !== false).length)
const credentialReady = computed(() => Boolean(form.appKey.trim() && form.appSecret.trim()))
const effectiveReady = computed(() => effective.value.enabled && effective.value.hasAppKey && effective.value.hasAppSecret)
const configuredRuleCount = computed(
() => rules.value.filter((rule) => rule.productName.trim() && rule.productCode.trim()).length,
)
const incompleteRuleCount = computed(() => Math.max(0, rules.value.length - configuredRuleCount.value))
const metrics = computed(() => [
{
label: '启用规则',
value: String(enabledRuleCount.value),
detail: `${rules.value.length}`,
},
{
label: '已配置映射',
value: String(configuredRuleCount.value),
detail: incompleteRuleCount.value > 0 ? `待补全 ${incompleteRuleCount.value}` : '无缺失',
},
{
label: '配置文件',
value: filePath.value ? 'GUI' : '默认',
detail: filePath.value || 'data/kuaishou-feifei-config.json',
},
])
onMounted(loadConfig)
@@ -65,29 +74,29 @@ async function loadConfig() {
const response = await fetchAdminKuaishouFeifeiConfig()
hydrateConfig(response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取 kuaishou-feifei 配置失败'
errorMessage.value = error instanceof Error ? error.message : '读取 kuaishou-feifei 履约配置失败'
} finally {
loading.value = false
}
}
async function saveConfig() {
async function saveRules() {
saving.value = true
errorMessage.value = ''
try {
const response = await saveAdminKuaishouFeifeiConfig(buildPayload())
hydrateConfig(response.data)
showSuccess(`kuaishou-feifei 配置已保存,启用规则 ${enabledRuleCount.value}`)
showSuccess(`kuaishou-feifei 履约规则已保存,启用 ${enabledRuleCount.value}`)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '保存 kuaishou-feifei 配置失败'
errorMessage.value = error instanceof Error ? error.message : '保存 kuaishou-feifei 履约配置失败'
showError(errorMessage.value)
} finally {
saving.value = false
}
}
async function runMatchTest() {
async function previewMatch() {
const productName = matchInput.value.trim()
if (!productName) {
showError('请输入 91 商品名')
@@ -101,7 +110,7 @@ async function runMatchTest() {
const response = await matchAdminKuaishouFeifeiProduct(productName)
matchResult.value = response.data
} catch (error) {
showError(error instanceof Error ? error.message : '匹配 kuaishou-feifei 商品失败')
showError(error instanceof Error ? error.message : '预览 kuaishou-feifei 映射失败')
} finally {
matching.value = false
}
@@ -109,26 +118,24 @@ async function runMatchTest() {
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
filePath.value = data.filePath || ''
effective.value = data.effective
form.enabled = data.source.enabled !== false
form.baseUrl = data.source.baseUrl || ''
form.appKey = data.source.appKey || ''
form.appSecret = data.source.appSecret || ''
form.timeoutMs = Number(data.source.timeoutMs || 10000) || 10000
form.notifyUrl = data.source.notifyUrl || ''
rules.value = Array.isArray(data.source.productRules)
? data.source.productRules.map((rule) => normalizeEditableRule(rule))
: []
sourceConfig.enabled = data.source.enabled !== false
sourceConfig.baseUrl = data.source.baseUrl || ''
sourceConfig.appKey = data.source.appKey || ''
sourceConfig.appSecret = data.source.appSecret || ''
sourceConfig.timeoutMs = Number(data.source.timeoutMs || 10000) || 10000
sourceConfig.notifyUrl = data.source.notifyUrl || ''
sourceConfig.productRules = Array.isArray(data.source.productRules) ? data.source.productRules : []
rules.value = sourceConfig.productRules.map((rule) => normalizeEditableRule(rule))
}
function buildPayload(): AdminKuaishouFeifeiConfig {
return {
enabled: form.enabled,
baseUrl: form.baseUrl.trim(),
appKey: form.appKey.trim(),
appSecret: form.appSecret.trim(),
timeoutMs: Number(form.timeoutMs || 10000) || 10000,
notifyUrl: form.notifyUrl.trim(),
enabled: sourceConfig.enabled,
baseUrl: sourceConfig.baseUrl,
appKey: sourceConfig.appKey,
appSecret: sourceConfig.appSecret,
timeoutMs: Number(sourceConfig.timeoutMs || 10000) || 10000,
notifyUrl: sourceConfig.notifyUrl,
productRules: rules.value.map((rule) => normalizeEditableRule(rule)),
}
}
@@ -172,217 +179,180 @@ function createRuleId() {
</script>
<template>
<section v-loading="loading" class="feifei-panel">
<el-alert
v-if="errorMessage"
type="error"
:title="errorMessage"
show-icon
:closable="false"
/>
<section v-loading="loading" class="feifei-fulfillment-page">
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以查看履约配置" />
<div class="summary-grid">
<div class="summary-item">
<span>运行状态</span>
<strong>{{ effectiveReady ? '已就绪' : '待配置' }}</strong>
<p>{{ form.enabled ? '启用中' : '已停用' }}</p>
</div>
<div class="summary-item">
<span>商品规则</span>
<strong>{{ enabledRuleCount }}</strong>
<p> {{ rules.length }} </p>
</div>
<div class="summary-item">
<span>凭据</span>
<strong>{{ credentialReady ? '已填写' : '缺失' }}</strong>
<p>生效配置 {{ effective.hasAppKey && effective.hasAppSecret ? '可用' : '待补全' }}</p>
</div>
</div>
<template v-else>
<el-alert
v-if="errorMessage"
type="error"
:title="errorMessage"
show-icon
:closable="false"
/>
<section class="config-section">
<div class="section-head">
<div>
<h3>平台连接</h3>
<p>{{ filePath || 'data/kuaishou-feifei-config.json' }}</p>
<section class="rule-overview">
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.detail }}</small>
</div>
<div class="section-actions">
<el-button :icon="RefreshRight" @click="loadConfig">刷新</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="saveConfig">
保存配置
<div class="overview-actions">
<el-button :icon="RefreshRight" :loading="loading" @click="loadConfig">刷新</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="saveRules">
保存规则
</el-button>
</div>
</div>
</section>
<el-form label-position="top" class="config-form">
<el-form-item label="启用">
<el-switch
v-model="form.enabled"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="form.baseUrl" placeholder="http://skin-exchange.yiquyou.icu" />
</el-form-item>
<el-form-item label="App Key">
<el-input v-model="form.appKey" placeholder="kuaishou-feifei app key" />
</el-form-item>
<el-form-item label="App Secret">
<el-input
v-model="form.appSecret"
type="password"
show-password
placeholder="kuaishou-feifei app secret"
/>
</el-form-item>
<el-form-item label="超时毫秒">
<el-input-number v-model="form.timeoutMs" :min="1000" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item label="通知地址">
<el-input v-model="form.notifyUrl" placeholder="可选,feifei 回调通知地址" />
</el-form-item>
</el-form>
</section>
<section class="config-section">
<div class="section-head">
<div>
<h3>商品规则</h3>
<p>91 商品名命中后会创建 kuaishou-feifei 履约任务</p>
</div>
<el-button type="primary" plain :icon="Plus" @click="addRule">新增规则</el-button>
</div>
<div class="match-bar">
<el-input
v-model="matchInput"
clearable
placeholder="输入 91 商品名,例如 套装-Alan Walker"
@keyup.enter="runMatchTest"
/>
<el-button :icon="Search" :loading="matching" @click="runMatchTest">匹配测试</el-button>
</div>
<div v-if="matchResult" class="match-result" :class="{ 'is-matched': matchResult.matched }">
<strong>{{ matchResult.matched ? '已命中' : '未命中' }}</strong>
<span v-if="matchResult.match">
{{ matchResult.match.productName }} -> {{ matchResult.match.productCode }}
</span>
<span v-else>{{ matchResult.normalizedProductName || matchResult.productName }}</span>
</div>
<el-empty v-if="rules.length === 0" description="暂无 kuaishou-feifei 商品规则" />
<div v-else class="rule-list">
<article v-for="(rule, index) in rules" :key="rule.id" class="rule-item">
<div class="rule-head">
<el-switch
v-model="rule.enabled"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
<el-button type="danger" plain :icon="Delete" @click="removeRule(index)">
删除
</el-button>
<section class="rule-panel">
<div class="section-head rule-head-title">
<div>
<h3>商品履约规则</h3>
<p>91 商品名命中后会创建 kuaishou-feifei 履约任务</p>
</div>
<el-button type="primary" plain :icon="Plus" @click="addRule">新增规则</el-button>
</div>
<el-form label-position="top" class="rule-form">
<el-form-item label="91 商品名">
<el-input v-model="rule.productName" placeholder="套装-Alan Walker" />
</el-form-item>
<el-form-item label="feifei 商品编码">
<el-input v-model="rule.productCode" placeholder="product_code" />
</el-form-item>
<el-form-item label="展示名称">
<el-input v-model="rule.skuName" placeholder="后台展示名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="rule.notes" placeholder="可选" />
</el-form-item>
</el-form>
</article>
</div>
</section>
<div class="match-checker">
<el-input
v-model="matchInput"
clearable
placeholder="91 商品名,例如 套装-Alan Walker"
@keyup.enter="previewMatch"
/>
<el-button :icon="Search" :loading="matching" @click="previewMatch">预览命中</el-button>
</div>
<div v-if="matchResult" class="match-result" :class="{ 'is-matched': matchResult.matched }">
<strong>{{ matchResult.matched ? '已命中' : '未命中' }}</strong>
<span v-if="matchResult.match">
{{ matchResult.match.productName }} -> {{ matchResult.match.productCode }}
</span>
<span v-else>{{ matchResult.normalizedProductName || matchResult.productName }}</span>
</div>
<el-empty v-if="rules.length === 0" description="暂无 kuaishou-feifei 商品规则" />
<div v-else class="rule-list">
<article v-for="(rule, index) in rules" :key="rule.id" class="rule-item">
<div class="rule-head">
<el-switch
v-model="rule.enabled"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
<el-button type="danger" plain :icon="Delete" @click="removeRule(index)">
删除
</el-button>
</div>
<el-form label-position="top" class="rule-form">
<el-form-item label="91 商品名">
<el-input v-model="rule.productName" placeholder="套装-Alan Walker" />
</el-form-item>
<el-form-item label="feifei 商品编码">
<el-input v-model="rule.productCode" placeholder="product_code" />
</el-form-item>
<el-form-item label="展示名称">
<el-input v-model="rule.skuName" placeholder="后台展示名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="rule.notes" placeholder="可选" />
</el-form-item>
</el-form>
</article>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.feifei-panel {
.feifei-fulfillment-page {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.summary-grid {
.rule-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(150px, auto);
gap: var(--space-3);
align-items: stretch;
}
.summary-item,
.config-section,
.metric-card,
.rule-panel,
.rule-item {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
min-width: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.summary-item {
.metric-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
}
.summary-item span,
.summary-item p,
.metric-card span,
.metric-card small,
.section-head p {
color: var(--text-secondary);
}
.summary-item strong {
display: block;
margin: 6px 0;
font-size: 22px;
.metric-card strong {
color: var(--text-primary);
font-size: var(--text-2xl);
line-height: 1.1;
}
.summary-item p,
.section-head p {
margin: 0;
}
.config-section {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.section-head h3 {
margin: 0 0 6px;
font-size: 16px;
}
.section-actions,
.match-bar,
.overview-actions,
.rule-head-title,
.match-checker,
.rule-head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.config-form,
.rule-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
.overview-actions {
justify-content: flex-end;
}
.match-bar {
.rule-panel,
.rule-item {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.section-head {
display: grid;
gap: var(--space-1);
}
.section-head h3 {
margin: 0;
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: 700;
}
.section-head p {
margin: 0;
font-size: var(--text-sm);
}
.rule-head-title {
justify-content: space-between;
align-items: flex-start;
}
.match-checker {
align-items: stretch;
}
@@ -391,7 +361,7 @@ function createRuleId() {
gap: var(--space-2);
align-items: center;
padding: 10px 12px;
border-radius: 6px;
border-radius: var(--radius-sm);
background: var(--el-fill-color-light);
color: var(--text-secondary);
}
@@ -406,25 +376,30 @@ function createRuleId() {
gap: var(--space-3);
}
.rule-item {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
}
.rule-head {
justify-content: space-between;
}
.rule-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.rule-form :deep(.el-input),
.rule-form :deep(.el-select) {
width: 100%;
}
@media (max-width: 900px) {
.summary-grid,
.config-form,
.rule-overview,
.rule-form {
grid-template-columns: 1fr;
}
.section-head,
.match-bar {
.overview-actions,
.rule-head-title,
.match-checker {
flex-direction: column;
align-items: stretch;
}
@@ -12,11 +12,11 @@ import {
fetchAdminScheduledJobsConfig,
} from '@/services/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import AdminKuaishouFeifeiFulfillmentView from '../fulfillment/kuaishou-feifei/AdminKuaishouFeifeiFulfillmentView.vue'
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
import AdminPlatformKuaishouFeifeiSection from './components/kuaishou-feifei/AdminPlatformKuaishouFeifeiSection.vue'
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
@@ -392,8 +392,10 @@ onMounted(loadConfigs)
:handle-kuaishou-eticket-consume="handleKuaishouEticketConsume"
/>
<AdminKuaishouFeifeiFulfillmentView
<AdminPlatformKuaishouFeifeiSection
v-else-if="activePlatform === 'kuaishouFeifei'"
@loaded="kuaishouFeifeiConfig = $event"
@saved="kuaishouFeifeiConfig = $event"
/>
<AdminPlatformCloudtentaclesSection
@@ -0,0 +1,814 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
import { showError, showSuccess } from '@/lib/feedback'
import {
createAdminKuaishouFeifeiTestOrder,
fetchAdminKuaishouFeifeiConfig,
fetchAdminKuaishouFeifeiProducts,
queryAdminKuaishouFeifeiOrder,
saveAdminKuaishouFeifeiConfig,
} from '@/services/admin'
import type {
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiEffectiveConfig,
AdminKuaishouFeifeiOrderResult,
AdminKuaishouFeifeiProductItem,
AdminKuaishouFeifeiProductRule,
} from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
const emit = defineEmits<{
loaded: [data: AdminKuaishouFeifeiConfigResponse]
saved: [data: AdminKuaishouFeifeiConfigResponse]
}>()
const loading = ref(true)
const saving = ref(false)
const productLoading = ref(false)
const orderCreating = ref(false)
const orderQuerying = ref(false)
const errorMessage = ref('')
const productErrorMessage = ref('')
const orderErrorMessage = ref('')
const filePath = ref('')
const products = ref<AdminKuaishouFeifeiProductItem[]>([])
const productTotal = ref(0)
const productRules = ref<AdminKuaishouFeifeiProductRule[]>([])
const lastOrder = ref<AdminKuaishouFeifeiOrderResult | null>(null)
const lastOrderAction = ref('')
const effective = ref<AdminKuaishouFeifeiEffectiveConfig>({
enabled: true,
baseUrl: '',
timeoutMs: 10000,
notifyUrl: '',
hasAppKey: false,
hasAppSecret: false,
productRuleCount: 0,
})
const form = reactive<Omit<AdminKuaishouFeifeiConfig, 'productRules'>>({
enabled: true,
baseUrl: '',
appKey: '',
appSecret: '',
timeoutMs: 10000,
notifyUrl: '',
})
const productQuery = reactive({
page: 1,
perPage: 20,
status: 'on_sale',
supplyProductName: '',
})
const testOrder = reactive({
platformOrderNo: createTestOrderNo(),
productCode: '',
platformBuyNum: 1,
platformAmount: null as number | null,
playerAccount: '',
playerGameRegion: '',
playerGameSrv: '',
playerGameRole: '',
submitPlayer: false,
notifyUrl: '',
})
const queryOrder = reactive({
platformOrderNo: '',
orderNo: '',
})
const credentialReady = computed(() => Boolean(form.appKey.trim() && form.appSecret.trim()))
const effectiveReady = computed(() => effective.value.enabled && effective.value.hasAppKey && effective.value.hasAppSecret)
const selectedProduct = computed(
() =>
products.value.find((product: AdminKuaishouFeifeiProductItem) => {
return product.productCode === testOrder.productCode
}) || null,
)
const lastOrderJson = computed(() => {
if (!lastOrder.value) {
return ''
}
return JSON.stringify(lastOrder.value.raw || lastOrder.value, null, 2)
})
const metrics = computed(() => [
{
label: '运行状态',
value: effectiveReady.value ? '已就绪' : '待配置',
detail: form.enabled ? '启用中' : '已停用',
},
{
label: 'API 凭据',
value: credentialReady.value ? '已填写' : '缺失',
detail: effective.value.hasAppKey && effective.value.hasAppSecret ? '生效配置可用' : '待补全',
},
{
label: '联调商品',
value: String(products.value.length),
detail: productTotal.value > 0 ? `总计 ${productTotal.value}` : '未加载',
},
{
label: '履约规则',
value: String(productRules.value.filter((rule) => rule.enabled !== false).length),
detail: `${productRules.value.length}`,
},
])
onMounted(loadConfig)
async function loadConfig() {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminKuaishouFeifeiConfig()
hydrateConfig(response.data)
emit('loaded', response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取 kuaishou-feifei 配置失败'
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
errorMessage.value = ''
try {
const response = await saveAdminKuaishouFeifeiConfig(buildPayload())
hydrateConfig(response.data)
emit('saved', response.data)
showSuccess('kuaishou-feifei 平台配置已保存')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '保存 kuaishou-feifei 配置失败'
showError(errorMessage.value)
} finally {
saving.value = false
}
}
async function loadProducts() {
productLoading.value = true
productErrorMessage.value = ''
try {
const response = await fetchAdminKuaishouFeifeiProducts({
page: productQuery.page,
perPage: productQuery.perPage,
status: productQuery.status,
supplyProductName: productQuery.supplyProductName.trim(),
})
products.value = Array.isArray(response.data.items) ? response.data.items : []
productTotal.value = Number(response.data.total || 0) || 0
} catch (error) {
products.value = []
productTotal.value = 0
productErrorMessage.value = error instanceof Error ? error.message : '查询 kuaishou-feifei 商品失败'
showError(productErrorMessage.value)
} finally {
productLoading.value = false
}
}
async function createTestOrder() {
if (!testOrder.platformOrderNo.trim()) {
showError('请输入测试业务单号')
return
}
if (!testOrder.productCode.trim()) {
showError('请输入 feifei 商品编码')
return
}
orderCreating.value = true
orderErrorMessage.value = ''
try {
const response = await createAdminKuaishouFeifeiTestOrder({
platformOrderNo: testOrder.platformOrderNo.trim(),
productCode: testOrder.productCode.trim(),
platformBuyNum: Number(testOrder.platformBuyNum || 1) || 1,
platformAmount: testOrder.platformAmount,
playerAccount: testOrder.playerAccount.trim(),
playerGameRegion: testOrder.playerGameRegion.trim(),
playerGameSrv: testOrder.playerGameSrv.trim(),
playerGameRole: testOrder.playerGameRole.trim(),
submitPlayer: testOrder.submitPlayer,
notifyUrl: testOrder.notifyUrl.trim(),
})
applyOrderResult(response.data, '创建结果')
showSuccess('kuaishou-feifei 测试单已创建')
} catch (error) {
orderErrorMessage.value = error instanceof Error ? error.message : '创建 kuaishou-feifei 测试单失败'
showError(orderErrorMessage.value)
} finally {
orderCreating.value = false
}
}
async function queryTestOrder() {
const platformOrderNo = queryOrder.platformOrderNo.trim() || testOrder.platformOrderNo.trim()
const orderNo = queryOrder.orderNo.trim()
if (!platformOrderNo && !orderNo) {
showError('请输入 feifei 平台单号或测试业务单号')
return
}
orderQuerying.value = true
orderErrorMessage.value = ''
try {
const response = await queryAdminKuaishouFeifeiOrder({
platformOrderNo,
orderNo,
})
applyOrderResult(response.data, '查询结果')
showSuccess('kuaishou-feifei 订单查询完成')
} catch (error) {
orderErrorMessage.value = error instanceof Error ? error.message : '查询 kuaishou-feifei 订单失败'
showError(orderErrorMessage.value)
} finally {
orderQuerying.value = false
}
}
function applyOrderResult(order: AdminKuaishouFeifeiOrderResult, action: string) {
lastOrder.value = order
lastOrderAction.value = action
queryOrder.platformOrderNo = order.platformOrderNo || testOrder.platformOrderNo
queryOrder.orderNo = order.orderNo || queryOrder.orderNo
}
function useProductForTest(product: AdminKuaishouFeifeiProductItem) {
testOrder.productCode = product.productCode
}
function resetTestOrderNo() {
testOrder.platformOrderNo = createTestOrderNo()
}
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
filePath.value = data.filePath || ''
effective.value = data.effective
form.enabled = data.source.enabled !== false
form.baseUrl = data.source.baseUrl || ''
form.appKey = data.source.appKey || ''
form.appSecret = data.source.appSecret || ''
form.timeoutMs = Number(data.source.timeoutMs || 10000) || 10000
form.notifyUrl = data.source.notifyUrl || ''
productRules.value = Array.isArray(data.source.productRules) ? data.source.productRules : []
if (!testOrder.notifyUrl) {
testOrder.notifyUrl = data.source.notifyUrl || ''
}
}
function buildPayload(): AdminKuaishouFeifeiConfig {
return {
enabled: form.enabled,
baseUrl: form.baseUrl.trim(),
appKey: form.appKey.trim(),
appSecret: form.appSecret.trim(),
timeoutMs: Number(form.timeoutMs || 10000) || 10000,
notifyUrl: form.notifyUrl.trim(),
productRules: productRules.value,
}
}
function formatPoints(value: unknown) {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue.toLocaleString('zh-CN') : '-'
}
function formatProductOption(product: AdminKuaishouFeifeiProductItem) {
return `${product.productCode} · ${product.name || '-'}`
}
function getProductTagType(product: AdminKuaishouFeifeiProductItem) {
return product.status === 'on_sale' && product.supplyStatus === 'on_sale' ? 'success' : 'warning'
}
function getOrderStatusType(status: unknown) {
const normalized = Number(status || 0)
if (normalized === 30) return 'success'
if ([40, 50, 60].includes(normalized)) return 'danger'
if ([17, 20].includes(normalized)) return 'warning'
return 'info'
}
function createTestOrderNo() {
const timestamp = new Date()
.toISOString()
.replace(/[-:TZ.]/g, '')
.slice(0, 14)
return `TEST-FEIFEI-${timestamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`
}
</script>
<template>
<section v-loading="loading" class="feifei-platform-panel">
<el-alert
v-if="errorMessage"
type="error"
:title="errorMessage"
show-icon
:closable="false"
/>
<section class="feifei-overview">
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.detail }}</small>
</div>
<div class="overview-actions">
<el-button :icon="RefreshRight" :loading="loading" @click="loadConfig">刷新</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="saveConfig">
保存平台配置
</el-button>
</div>
</section>
<section class="platform-layout">
<aside class="config-panel">
<div class="section-head">
<h3>平台连接</h3>
<p>{{ filePath || 'data/kuaishou-feifei-config.json' }}</p>
</div>
<el-form label-position="top" class="stack-form">
<el-form-item label="启用">
<el-switch
v-model="form.enabled"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="form.baseUrl" placeholder="http://skin-exchange.yiquyou.icu" />
</el-form-item>
<el-form-item label="App Key">
<el-input v-model="form.appKey" placeholder="kuaishou-feifei app key" />
</el-form-item>
<el-form-item label="App Secret">
<el-input
v-model="form.appSecret"
type="password"
show-password
placeholder="kuaishou-feifei app secret"
/>
</el-form-item>
<el-form-item label="超时毫秒">
<el-input-number
v-model="form.timeoutMs"
:min="1000"
:step="1000"
controls-position="right"
/>
</el-form-item>
<el-form-item label="通知地址">
<el-input v-model="form.notifyUrl" placeholder="可选,feifei 回调通知地址" />
</el-form-item>
</el-form>
</aside>
<main class="test-panel">
<div class="section-head">
<h3>API 联调</h3>
<p>{{ effective.baseUrl || form.baseUrl || '-' }}</p>
</div>
<el-alert
v-if="productErrorMessage"
:title="productErrorMessage"
type="error"
show-icon
:closable="false"
/>
<el-alert
v-if="orderErrorMessage"
:title="orderErrorMessage"
type="error"
show-icon
:closable="false"
/>
<div class="product-toolbar">
<el-input
v-model="productQuery.supplyProductName"
clearable
placeholder="供货商品名称"
@keyup.enter="loadProducts"
/>
<el-select v-model="productQuery.status" class="status-select">
<el-option label="在售" value="on_sale" />
<el-option label="下架" value="off_sale" />
<el-option label="草稿" value="draft" />
<el-option label="全部" value="all" />
</el-select>
<el-input-number
v-model="productQuery.perPage"
:min="1"
:max="100"
controls-position="right"
/>
<el-button :icon="Search" :loading="productLoading" @click="loadProducts">
查询商品
</el-button>
</div>
<el-table
v-loading="productLoading"
:data="products"
border
stripe
height="320"
empty-text="暂无商品"
class="product-table"
@row-click="useProductForTest"
>
<el-table-column label="商品" min-width="260">
<template #default="{ row }">
<div class="product-cell">
<strong>{{ row.name || '-' }}</strong>
<small>{{ row.productCode || '-' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="渠道" prop="channel" width="120" />
<el-table-column label="扣费积分" width="120">
<template #default="{ row }">{{ formatPoints(row.unitCostPoints) }}</template>
</el-table-column>
<el-table-column label="单次数量" prop="maxOrderQuantity" width="110" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="getProductTagType(row)" effect="light">
{{ row.status || '-' }} / {{ row.supplyStatus || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button size="small" plain @click.stop="useProductForTest(row)">选用</el-button>
</template>
</el-table-column>
</el-table>
<section class="order-grid">
<div class="order-panel">
<div class="section-head">
<h3>创建测试单</h3>
</div>
<el-form label-position="top" class="order-form">
<el-form-item label="测试业务单号">
<div class="inline-field">
<el-input v-model="testOrder.platformOrderNo" />
<el-button :icon="RefreshRight" @click="resetTestOrderNo" />
</div>
</el-form-item>
<el-form-item label="feifei 商品编码">
<el-select
v-model="testOrder.productCode"
filterable
allow-create
default-first-option
placeholder="product_code"
>
<el-option
v-for="product in products"
:key="product.productCode"
:label="formatProductOption(product)"
:value="product.productCode"
/>
</el-select>
</el-form-item>
<el-form-item label="数量">
<el-input-number
v-model="testOrder.platformBuyNum"
:min="1"
:max="selectedProduct?.maxOrderQuantity || 999"
controls-position="right"
/>
</el-form-item>
<el-form-item label="记录金额">
<el-input-number
v-model="testOrder.platformAmount"
:min="0"
:precision="2"
controls-position="right"
/>
</el-form-item>
<el-form-item label="玩家 UID">
<el-input v-model="testOrder.playerAccount" clearable />
</el-form-item>
<el-form-item label="区服-系统">
<el-input v-model="testOrder.playerGameRegion" clearable placeholder="安卓 / 苹果" />
</el-form-item>
<el-form-item label="区服-渠道">
<el-input v-model="testOrder.playerGameSrv" clearable placeholder="微信 / QQ" />
</el-form-item>
<el-form-item label="角色名">
<el-input v-model="testOrder.playerGameRole" clearable />
</el-form-item>
<el-form-item label="直接提交玩家信息">
<el-switch v-model="testOrder.submitPlayer" active-text="提交" inactive-text="不提交" />
</el-form-item>
<el-form-item label="通知地址">
<el-input v-model="testOrder.notifyUrl" clearable />
</el-form-item>
</el-form>
<el-button
type="primary"
:icon="Plus"
:loading="orderCreating"
@click="createTestOrder"
>
创建测试单
</el-button>
</div>
<div class="order-panel">
<div class="section-head">
<h3>查询订单</h3>
</div>
<el-form label-position="top" class="order-form">
<el-form-item label="测试业务单号">
<el-input v-model="queryOrder.platformOrderNo" clearable />
</el-form-item>
<el-form-item label="feifei 平台单号">
<el-input v-model="queryOrder.orderNo" clearable />
</el-form-item>
</el-form>
<el-button :icon="Search" :loading="orderQuerying" @click="queryTestOrder">
查询订单
</el-button>
<div v-if="lastOrder" class="order-result">
<div class="result-head">
<strong>{{ lastOrderAction || '订单结果' }}</strong>
<el-tag :type="getOrderStatusType(lastOrder.rechargeStatus)" effect="light">
{{ lastOrder.rechargeStatusLabel || lastOrder.rechargeStatus || '-' }}
</el-tag>
</div>
<dl>
<div>
<dt>平台单号</dt>
<dd>{{ lastOrder.orderNo || '-' }}</dd>
</div>
<div>
<dt>业务单号</dt>
<dd>{{ lastOrder.platformOrderNo || '-' }}</dd>
</div>
<div>
<dt>商品</dt>
<dd>{{ lastOrder.productName || lastOrder.productCode || '-' }}</dd>
</div>
<div>
<dt>扣除积分</dt>
<dd>{{ formatPoints(lastOrder.pointsCharged) }}</dd>
</div>
</dl>
<el-link
v-if="lastOrder.h5?.rechargeUrl"
:href="lastOrder.h5.rechargeUrl"
target="_blank"
type="primary"
>
打开领取链接
</el-link>
<pre v-if="lastOrderJson" class="json-preview">{{ lastOrderJson }}</pre>
</div>
</div>
</section>
</main>
</section>
</section>
</template>
<style scoped>
.feifei-platform-panel {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.feifei-overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(160px, auto);
gap: var(--space-3);
align-items: stretch;
}
.metric-card,
.config-panel,
.test-panel,
.order-panel {
min-width: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.metric-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
}
.metric-card span,
.metric-card small,
.section-head p,
.product-cell small,
.order-result dt {
color: var(--text-secondary);
}
.metric-card strong {
color: var(--text-primary);
font-size: var(--text-2xl);
line-height: 1.1;
}
.overview-actions,
.product-toolbar,
.inline-field,
.result-head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.overview-actions {
justify-content: flex-end;
}
.platform-layout {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
.config-panel,
.test-panel,
.order-panel {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.section-head {
display: grid;
gap: var(--space-1);
}
.section-head h3 {
margin: 0;
color: var(--text-primary);
font-size: var(--text-lg);
font-weight: 700;
}
.section-head p {
margin: 0;
font-size: var(--text-sm);
word-break: break-all;
}
.stack-form,
.order-form {
display: grid;
gap: var(--space-3);
}
.stack-form :deep(.el-input-number),
.order-form :deep(.el-input-number),
.order-form :deep(.el-select) {
width: 100%;
}
.product-toolbar,
.inline-field {
align-items: stretch;
}
.status-select {
width: 120px;
flex: 0 0 auto;
}
.product-table {
width: 100%;
}
.product-cell {
display: grid;
gap: 4px;
min-width: 0;
}
.product-cell strong,
.product-cell small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: var(--space-4);
align-items: start;
}
.order-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inline-field .el-button {
flex: 0 0 auto;
}
.order-result {
display: grid;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border-default);
}
.result-head {
justify-content: space-between;
}
.order-result dl {
display: grid;
gap: var(--space-2);
margin: 0;
}
.order-result dl div {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: var(--space-2);
}
.order-result dt,
.order-result dd {
margin: 0;
}
.order-result dd {
min-width: 0;
word-break: break-all;
}
.json-preview {
max-height: 220px;
margin: 0;
padding: var(--space-3);
overflow: auto;
border-radius: var(--radius-sm);
background: var(--el-fill-color-light);
color: var(--text-primary);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 1180px) {
.feifei-overview,
.platform-layout,
.order-grid {
grid-template-columns: 1fr;
}
.overview-actions {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.product-toolbar,
.overview-actions {
flex-direction: column;
align-items: stretch;
}
.status-select,
.order-form {
width: 100%;
grid-template-columns: 1fr;
}
}
</style>