平台配置页改造

This commit is contained in:
yml2213
2026-05-14 19:05:08 +08:00
parent f7f3289d04
commit 7775525f30
15 changed files with 2351 additions and 1727 deletions
@@ -24,161 +24,252 @@ defineProps<Props>()
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">配置文件</span>
<code>{{ agisoFilePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">职责</span>
<span>管理 Agiso 默认消息模板店铺 AccessToken 以及店铺级模板覆盖</span>
</div>
</section>
<div class="platform-section">
<!-- 配置信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">
<code>{{ agisoFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="职责">
管理 Agiso 默认消息模板店铺 AccessToken 以及店铺级模板覆盖
</el-descriptions-item>
</el-descriptions>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>默认消息模板</h3>
<p>Agiso 店铺未单独配置时统一使用这里的领取消息与自动发货消息模板</p>
</div>
</div>
<div class="form-grid">
<label class="field-block field-wide">
<span>默认领取链接消息模板</span>
<textarea
v-model="defaults.messageTemplate"
class="text-area"
placeholder="例如:您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}"
/>
</label>
<label class="field-block field-wide">
<span>默认自动发货消息模板</span>
<textarea
v-model="defaults.autoDeliveryMessageTemplate"
class="text-area"
placeholder="例如:您的订单 {platformOrderId} 已完成自动发货,请注意查收。"
/>
</label>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>Agiso 店铺列表</h3>
<p>每家店铺可单独配置 AccessToken 和模板覆盖建议先用近期识别到的店铺导入再补 AccessToken</p>
</div>
<div class="section-actions">
<el-button :disabled="shops.length === 0" text @click="expandAllShops">全部展开</el-button>
<el-button :disabled="shops.length === 0" text @click="collapseAllShops">全部折叠</el-button>
<el-button :loading="saving" round type="primary" @click="saveAgisoConfigs">保存 Agiso 配置</el-button>
</div>
</div>
<div v-if="shops.length === 0" class="empty-inline">当前还没有 Agiso 店铺配置先新增一条</div>
<div v-for="shop in shops" :key="shop.id" class="shop-card">
<button type="button" class="shop-summary" @click="toggleShop(shop.id)">
<div class="shop-summary-main">
<strong>{{ shop.shopName || shop.shopId || '未命名店铺' }}</strong>
<span class="cell-subtle">ID: {{ shop.shopId || '未填写' }}</span>
<!-- 默认消息模板 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">默认消息模板</span>
<p class="card-desc">Agiso 店铺未单独配置时统一使用这里的领取消息与自动发货消息模板</p>
</div>
</div>
<div class="shop-summary-side">
<span class="shop-status" :class="{ 'is-disabled': !shop.enabled }">
{{ shop.enabled ? '已启用' : '已停用' }}
</span>
<span class="shop-summary-copy">{{ describeShop(shop) }}</span>
<span class="shop-toggle">{{ isShopExpanded(shop.id) ? '收起' : '展开' }}</span>
</template>
<el-form label-width="auto" label-position="top">
<el-form-item label="默认领取链接消息模板">
<el-input
v-model="defaults.messageTemplate"
type="textarea"
:rows="3"
placeholder="例如:您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}"
/>
</el-form-item>
<el-form-item label="默认自动发货消息模板">
<el-input
v-model="defaults.autoDeliveryMessageTemplate"
type="textarea"
:rows="3"
placeholder="例如:您的订单 {platformOrderId} 已完成自动发货,请注意查收。"
/>
</el-form-item>
</el-form>
</el-card>
<!-- 店铺列表 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">Agiso 店铺列表</span>
<p class="card-desc">每家店铺可单独配置 AccessToken 和模板覆盖建议先用"近期识别到的店铺"导入再补 AccessToken</p>
</div>
<div class="card-actions">
<el-button :disabled="shops.length === 0" @click="expandAllShops">全部展开</el-button>
<el-button :disabled="shops.length === 0" @click="collapseAllShops">全部折叠</el-button>
<el-button :loading="saving" type="primary" @click="saveAgisoConfigs">保存 Agiso 配置</el-button>
</div>
</div>
</button>
</template>
<div v-if="isShopExpanded(shop.id)" class="shop-body">
<div class="form-grid">
<label class="field-block">
<span>店铺 ID</span>
<input v-model="shop.shopId" class="text-input" placeholder="例如 693760716" />
</label>
<el-empty v-if="shops.length === 0" description="当前还没有 Agiso 店铺配置,先新增一条。" />
<label class="field-block">
<span>店铺名称</span>
<input v-model="shop.shopName" class="text-input" placeholder="例如 大锤商行" />
</label>
<label class="field-block field-wide">
<span>AccessToken</span>
<input v-model="shop.accessToken" class="text-input" placeholder="AldsIdle..." />
</label>
<label class="field-block field-wide">
<span>领取链接消息模板</span>
<textarea
v-model="shop.messageTemplate"
class="text-area"
placeholder="留空则沿用默认领取链接模板"
/>
</label>
<label class="field-block field-wide">
<span>自动发货消息模板</span>
<textarea
v-model="shop.autoDeliveryMessageTemplate"
class="text-area"
placeholder="留空则沿用默认自动发货模板"
/>
</label>
</div>
<div class="shop-actions">
<label class="checkbox-line">
<input v-model="shop.enabled" type="checkbox" />
<span>启用店铺级覆盖</span>
</label>
<el-button link type="danger" @click="removeShop(shop.id)">删除</el-button>
</div>
</div>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>近期识别到的 Agiso 店铺</h3>
<p> webhook 自动识别出的店铺可一键导入到上方店铺列表</p>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>店铺</th>
<th>Webhook 次数</th>
<th>最近出现时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in observedShops" :key="item.shopId">
<td>
<div class="cell-stack">
<strong>{{ item.displayShopName || item.shopId }}</strong>
<span class="cell-subtle">ID: {{ item.shopId }}</span>
<el-collapse
v-else
:model-value="shops.filter(s => isShopExpanded(s.id)).map(s => s.id)"
>
<el-collapse-item
v-for="shop in shops"
:key="shop.id"
:name="shop.id"
>
<template #title>
<div class="shop-title">
<strong>{{ shop.shopName || shop.shopId || '未命名店铺' }}</strong>
<span class="shop-subtitle">ID: {{ shop.shopId || '未填写' }}</span>
<el-tag
:type="shop.enabled ? 'success' : 'info'"
size="small"
style="margin-left: auto; margin-right: 12px"
>
{{ shop.enabled ? '已启用' : '已停用' }}
</el-tag>
<span class="shop-brief">{{ describeShop(shop) }}</span>
</div>
</td>
<td>{{ item.webhookEventCount }}</td>
<td>{{ formatAdminDateTime(item.latestSeenAt) }}</td>
<td>{{ item.configured ? '已配置' : '未配置' }}</td>
<td>
<el-button v-if="!item.configured" link type="primary" @click="importObservedShop(item)">导入到店铺</el-button>
<span v-else class="cell-subtle">已存在</span>
</td>
</tr>
<tr v-if="observedShops.length === 0">
<td colspan="5" class="empty-inline">最近还没有识别到可用店铺</td>
</tr>
</tbody>
</table>
</section>
</template>
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="店铺 ID">
<el-input v-model="shop.shopId" placeholder="例如 693760716" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺名称">
<el-input v-model="shop.shopName" placeholder="例如 大锤商行" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="AccessToken">
<el-input v-model="shop.accessToken" placeholder="AldsIdle..." />
</el-form-item>
<el-form-item label="领取链接消息模板">
<el-input
v-model="shop.messageTemplate"
type="textarea"
:rows="2"
placeholder="留空则沿用默认领取链接模板"
/>
</el-form-item>
<el-form-item label="自动发货消息模板">
<el-input
v-model="shop.autoDeliveryMessageTemplate"
type="textarea"
:rows="2"
placeholder="留空则沿用默认自动发货模板"
/>
</el-form-item>
</el-form>
<div class="shop-footer">
<el-switch v-model="shop.enabled" active-text="启用店铺级覆盖" />
<el-button type="danger" size="small" @click="removeShop(shop.id)">删除</el-button>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
<!-- 近期识别到的店铺 -->
<el-card class="section-card">
<template #header>
<div>
<span class="card-title">近期识别到的 Agiso 店铺</span>
<p class="card-desc">从 webhook 自动识别出的店铺,可一键导入到上方店铺列表。</p>
</div>
</template>
<el-table :data="observedShops" stripe size="small">
<el-table-column label="店铺" min-width="180">
<template #default="{ row }">
<div>
<div>{{ row.displayShopName || row.shopId }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">ID: {{ row.shopId }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="webhookEventCount" label="Webhook 次数" width="120" align="center" />
<el-table-column label="最近出现时间" width="180">
<template #default="{ row }">{{ formatAdminDateTime(row.latestSeenAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.configured ? 'success' : 'warning'" size="small">
{{ row.configured ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button v-if="!row.configured" size="small" type="primary" @click="importObservedShop(row)">
导入到店铺
</el-button>
<span v-else class="text-muted">已存在</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="最近还没有识别到可用店铺" :image-size="60" />
</template>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.shop-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
padding-right: var(--space-4);
}
.shop-title strong {
white-space: nowrap;
}
.shop-subtitle {
font-size: var(--text-xs);
color: var(--text-muted);
}
.shop-brief {
font-size: var(--text-xs);
color: var(--text-muted);
margin-left: auto;
}
.shop-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-default);
}
.text-muted {
color: var(--text-muted);
}
</style>
@@ -69,183 +69,265 @@ defineProps<Props>()
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">配置文件</span>
<code>{{ cloudtentaclesFilePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">会话文件</span>
<code>{{ cloudtentaclesSessionFilePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">职责</span>
<span>管理 cloudtentacles 外部履约平台的账号短信验证码登录与 token 探活该区域不处理订单来源同步</span>
</div>
<div class="meta-line">
<span class="meta-label">已保存 token</span>
<span>
{{ cloudtentaclesPersistedSession?.hasToken ? `${cloudtentaclesPersistedSession.tokenMasked} / ${formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt)}` : '未保存' }}
</span>
</div>
</section>
<div class="platform-section">
<!-- 配置信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">
<code>{{ cloudtentaclesFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="会话文件">
<code>{{ cloudtentaclesSessionFilePath || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="职责">
管理 cloudtentacles 外部履约平台的账号短信验证码登录与 token 探活该区域不处理订单来源同步
</el-descriptions-item>
<el-descriptions-item label="已保存 token">
{{ cloudtentaclesPersistedSession?.hasToken ? `${cloudtentaclesPersistedSession.tokenMasked} / ${formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt)}` : '未保存' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>cloudtentacles 履约平台配置</h3>
<p>先保存账号密码手机号和设备头再发送短信验证码最后执行登录测试或手动校验 token</p>
<!-- 履约平台配置 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">cloudtentacles 履约平台配置</span>
<p class="card-desc">先保存账号密码手机号和设备头再发送短信验证码最后执行登录测试或手动校验 token</p>
</div>
<div class="card-actions">
<el-button :loading="cloudtentaclesSaving" @click="handleCloudtentaclesSaveSource">保存履约配置</el-button>
<el-button :loading="cloudtentaclesSendingSms" @click="handleCloudtentaclesSendSmsCode">发送短信验证码</el-button>
<el-button :loading="cloudtentaclesTesting" type="primary" @click="handleCloudtentaclesTestLogin">测试登录</el-button>
<el-button :loading="cloudtentaclesValidating" @click="handleCloudtentaclesValidateSession">校验 token</el-button>
</div>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-form-item label="Base URL">
<el-input v-model="cloudtentaclesForm.baseUrl" placeholder="https://123.207.217.176" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="账号">
<el-input v-model="cloudtentaclesForm.username" placeholder="cloudtentacles 登录账号" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="密码">
<el-input v-model="cloudtentaclesForm.password" type="password" show-password placeholder="cloudtentacles 登录密码" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="手机号">
<el-input v-model="cloudtentaclesForm.phone" placeholder="接收短信验证码的手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="短信验证码">
<el-input v-model="cloudtentaclesForm.code" placeholder="收到验证码后填写" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="VN Key">
<el-input v-model="cloudtentaclesForm.vnKey" placeholder="例如 1" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="VN ID">
<el-input-number v-model="cloudtentaclesForm.vnId" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="SKU ID">
<el-input-number v-model="cloudtentaclesForm.skuId" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="购买数量">
<el-input-number v-model="cloudtentaclesForm.skuCount" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="deviceId">
<el-input v-model="cloudtentaclesForm.deviceId" placeholder="-" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="deviceType">
<el-input-number v-model="cloudtentaclesForm.deviceType" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="token(可手动粘贴)">
<el-input v-model="cloudtentaclesForm.token" placeholder="登录成功后可自动回填,也可手动粘贴" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-alert
v-if="cloudtentaclesResultError"
:title="cloudtentaclesResultError"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
<div class="result-grid" style="margin-top: 16px">
<AdminResultCard label="短信发送" :value="cloudtentaclesSmsResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesSmsResult">
{{ cloudtentaclesSmsResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesSmsResult.sentAt) }}
</p>
<p v-else>先发送短信验证码确认手机号可用</p>
</AdminResultCard>
<AdminResultCard label="登录结果" :value="cloudtentaclesLoginResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesLoginResult">
{{ cloudtentaclesLoginResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt) }}
</p>
<p v-else>填写短信验证码后可执行测试登录</p>
</AdminResultCard>
<AdminResultCard label="会话校验" :value="cloudtentaclesValidateResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesValidateResult">
权限 {{ cloudtentaclesValidateResult.session.permissionCount }} 余额 {{ cloudtentaclesValidateResult.asset }}
</p>
<p v-else>登录成功后可继续校验 token 会话</p>
</AdminResultCard>
</div>
<div class="section-actions">
<el-button :loading="cloudtentaclesSaving" round @click="handleCloudtentaclesSaveSource">保存履约配置</el-button>
<el-button :loading="cloudtentaclesSendingSms" round @click="handleCloudtentaclesSendSmsCode">发送短信验证码</el-button>
<el-button :loading="cloudtentaclesTesting" round type="primary" @click="handleCloudtentaclesTestLogin">测试登录</el-button>
<el-button :loading="cloudtentaclesValidating" round @click="handleCloudtentaclesValidateSession">校验 token</el-button>
</el-card>
<!-- 开发调试工具 -->
<el-card class="section-card">
<template #header>
<div>
<span class="card-title">cloudtentacles 开发调试工具</span>
<p class="card-desc">仅开发模式展示用于调试分类SKU购买发货虚拟号兑换链接和结束退还链路</p>
</div>
</template>
<div class="debug-buttons">
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchAsset">余额</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchCategories">分类</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchSkuList">SKU</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku">购买</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="primary" @click="handleCloudtentaclesUseSku">发货</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchKnapsack">背包</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnList">虚拟号列表</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesAppointVn">申请虚拟号</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesGenerateVnLoginCode">生成登录码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchVnCode">获取验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesVerifyVnCode">校验验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesFetchBindUrl">兑换链接</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="warning" @click="handleCloudtentaclesBackVn">退还号码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" type="success" @click="handleCloudtentaclesRunFullFlow">完整流程</el-button>
</div>
</div>
<div class="form-grid">
<label class="field-block field-wide">
<span>Base URL</span>
<input v-model="cloudtentaclesForm.baseUrl" class="text-input" placeholder="https://123.207.217.176" />
</label>
<pre v-if="cloudtentaclesDebugResult" class="debug-pre" style="margin-top: 16px">{{ cloudtentaclesDebugResult }}</pre>
<el-empty v-else description="执行一次调试动作后,这里会显示原始返回数据。" :image-size="60" />
</el-card>
<label class="field-block">
<span>账号</span>
<input v-model="cloudtentaclesForm.username" class="text-input" placeholder="cloudtentacles 登录账号" />
</label>
<!-- 调试结果 -->
<el-card v-if="cloudtentaclesLoginResult || cloudtentaclesValidateResult" class="section-card">
<template #header>
<div>
<span class="card-title">cloudtentacles 调试结果</span>
<p class="card-desc">用于确认当前账号会话权限和用户信息后续发货执行器会复用这里的登录能力</p>
</div>
</template>
<label class="field-block">
<span>密码</span>
<input v-model="cloudtentaclesForm.password" class="text-input" type="password" placeholder="cloudtentacles 登录密码" />
</label>
<label class="field-block">
<span>手机号</span>
<input v-model="cloudtentaclesForm.phone" class="text-input" placeholder="接收短信验证码的手机号" />
</label>
<label class="field-block">
<span>短信验证码</span>
<input v-model="cloudtentaclesForm.code" class="text-input" placeholder="收到验证码后填写" />
</label>
<label class="field-block">
<span>VN Key</span>
<input v-model="cloudtentaclesForm.vnKey" class="text-input" placeholder="例如 1" />
</label>
<label class="field-block">
<span>VN ID</span>
<input v-model.number="cloudtentaclesForm.vnId" class="text-input" type="number" min="0" />
</label>
<label class="field-block">
<span>SKU ID</span>
<input v-model.number="cloudtentaclesForm.skuId" class="text-input" type="number" min="0" />
</label>
<label class="field-block">
<span>购买数量</span>
<input v-model.number="cloudtentaclesForm.skuCount" class="text-input" type="number" min="1" />
</label>
<label class="field-block">
<span>deviceId</span>
<input v-model="cloudtentaclesForm.deviceId" class="text-input" placeholder="-" />
</label>
<label class="field-block">
<span>deviceType</span>
<input v-model.number="cloudtentaclesForm.deviceType" class="text-input" type="number" />
</label>
<label class="field-block field-wide">
<span>token可手动粘贴</span>
<input v-model="cloudtentaclesForm.token" class="text-input" placeholder="登录成功后可自动回填,也可手动粘贴" />
</label>
</div>
<p v-if="cloudtentaclesResultError" class="error-copy">{{ cloudtentaclesResultError }}</p>
<div class="result-grid">
<AdminResultCard label="短信发送" :value="cloudtentaclesSmsResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesSmsResult">
{{ cloudtentaclesSmsResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesSmsResult.sentAt) }}
</p>
<p v-else>先发送短信验证码确认手机号可用</p>
</AdminResultCard>
<AdminResultCard label="登录结果" :value="cloudtentaclesLoginResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesLoginResult">
{{ cloudtentaclesLoginResult.phoneMasked }}{{ formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt) }}
</p>
<p v-else>填写短信验证码后可执行测试登录</p>
</AdminResultCard>
<AdminResultCard label="会话校验" :value="cloudtentaclesValidateResult ? '成功' : '未执行'">
<p v-if="cloudtentaclesValidateResult">
权限 {{ cloudtentaclesValidateResult.session.permissionCount }} 余额 {{ cloudtentaclesValidateResult.asset }}
</p>
<p v-else>登录成功后可继续校验 token 会话</p>
</AdminResultCard>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>cloudtentacles 开发调试工具</h3>
<p>仅开发模式展示用于调试分类SKU购买发货虚拟号兑换链接和结束退还链路</p>
<div class="result-grid">
<AdminResultCard
label="当前 token"
:value="cloudtentaclesValidateResult?.session.tokenMasked || cloudtentaclesLoginResult?.session.tokenMasked || '-'"
>
<p>原始 token 已写入上方输入框可继续用于校验或后续调试</p>
</AdminResultCard>
<AdminResultCard
label="权限"
:value="`${cloudtentaclesValidateResult?.session.permissionCount || cloudtentaclesLoginResult?.session.permissionCount || 0} 项`"
>
<p>{{ (cloudtentaclesValidateResult?.session.permissions || cloudtentaclesLoginResult?.session.permissions || []).join('、') || '暂无' }}</p>
</AdminResultCard>
<AdminResultCard label="余额" :value="cloudtentaclesValidateResult?.asset || cloudtentaclesLoginResult?.asset || 0">
<p>可用于确认当前履约账号状态</p>
</AdminResultCard>
</div>
<div class="section-actions">
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchAsset">余额</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchCategories">分类</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchSkuList">SKU</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesBuySku">购买</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round type="primary" @click="handleCloudtentaclesUseSku">发货</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchKnapsack">背包</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchVnList">虚拟号列表</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesAppointVn">申请虚拟号</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesGenerateVnLoginCode">生成登录码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchVnCode">获取验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesVerifyVnCode">校验验证码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round @click="handleCloudtentaclesFetchBindUrl">兑换链接</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round type="warning" @click="handleCloudtentaclesBackVn">退还号码</el-button>
<el-button :loading="cloudtentaclesDebugLoading" round type="success" @click="handleCloudtentaclesRunFullFlow">完整流程</el-button>
</div>
</div>
<pre v-if="cloudtentaclesDebugResult" class="debug-result">{{ cloudtentaclesDebugResult }}</pre>
<div v-else class="empty-inline">执行一次调试动作后这里会显示原始返回数据</div>
</section>
<section v-if="cloudtentaclesLoginResult || cloudtentaclesValidateResult" class="table-card">
<div class="section-title-row">
<div>
<h3>cloudtentacles 调试结果</h3>
<p>用于确认当前账号会话权限和用户信息后续发货执行器会复用这里的登录能力</p>
</div>
</div>
<div class="result-grid">
<AdminResultCard
label="当前 token"
:value="cloudtentaclesValidateResult?.session.tokenMasked || cloudtentaclesLoginResult?.session.tokenMasked || '-'"
>
<p>原始 token 已写入上方输入框可继续用于校验或后续调试</p>
</AdminResultCard>
<AdminResultCard
label="权限"
:value="`${cloudtentaclesValidateResult?.session.permissionCount || cloudtentaclesLoginResult?.session.permissionCount || 0} 项`"
>
<p>{{ (cloudtentaclesValidateResult?.session.permissions || cloudtentaclesLoginResult?.session.permissions || []).join('、') || '暂无' }}</p>
</AdminResultCard>
<AdminResultCard label="余额" :value="cloudtentaclesValidateResult?.asset || cloudtentaclesLoginResult?.asset || 0">
<p>可用于确认当前履约账号状态</p>
</AdminResultCard>
</div>
</section>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.debug-buttons {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.result-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.debug-pre {
margin: 0;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: #0f172a;
color: #e2e8f0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: var(--text-xs);
line-height: 1.6;
font-family: var(--font-mono);
}
</style>
@@ -1,11 +1,11 @@
<script setup lang="ts">
import AdminResultCard from '@/components/admin/AdminResultCard.vue'
import type { EditableKuaishouEticketShop } from '@/composables/admin/platform-shops/types'
import type {
AdminKuaishouEticketConsumeResult,
AdminKuaishouEticketDetailResult,
AdminKuaishouEticketShopInfoResult,
} from '@/types/admin'
import AdminResultCard from '@/components/admin/AdminResultCard.vue'
type KuaishouEticketForm = {
baseUrl: string
@@ -59,276 +59,457 @@ defineProps<Props>()
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">配置文件</span>
<code>{{ kuaishouEticketFilePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">职责</span>
<span>这是独立的新流程只负责快手小店券核销调试与配置不接入当前已完成的订单来源和履约主链路</span>
</div>
<div class="meta-line">
<span class="meta-label">建议流程</span>
<span>先为每个店铺录入 `kshopName + Cookie`可用 info 接口自动识别店铺名与店铺 ID保存后选择目标店铺再查询券码并执行核销</span>
</div>
</section>
<div class="platform-section">
<!-- 说明信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">说明信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="配置文件">{{ kuaishouEticketFilePath || '-' }}</el-descriptions-item>
<el-descriptions-item label="职责">
这是独立的新流程只负责快手小店券核销调试与配置不接入当前已完成的订单来源和履约主链路
</el-descriptions-item>
<el-descriptions-item label="建议流程">
先为每个店铺录入 kshopName + Cookie可用 info 接口自动识别店铺名与店铺 ID保存后选择目标店铺再查询券码并执行核销
</el-descriptions-item>
</el-descriptions>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>快手小店店铺配置</h3>
<p>支持多店铺配置建议先粘贴 Cookie再用 info 接口自动识别 `kshopName / shopId`最后统一保存到文件</p>
</div>
<div class="section-actions">
<el-button :disabled="kuaishouEticketShops.length === 0" text @click="expandAllKuaishouEticketShops">全部展开</el-button>
<el-button :disabled="kuaishouEticketShops.length === 0" text @click="collapseAllKuaishouEticketShops">全部折叠</el-button>
<el-button round @click="addKuaishouEticketShop">新增店铺</el-button>
<el-button :loading="kuaishouEticketSaving" round type="primary" @click="handleKuaishouEticketSaveSource">保存配置</el-button>
</div>
</div>
<div class="kuaishou-workspace">
<div class="kuaishou-shop-stack">
<div class="form-grid kuaishou-base-grid">
<label class="field-block field-wide">
<span>Base URL</span>
<input v-model="kuaishouEticketForm.baseUrl" class="text-input" placeholder="https://s.kwaixiaodian.com" />
</label>
</div>
<div v-if="kuaishouEticketShops.length === 0" class="empty-inline">当前还没有快手小店店铺配置先新增一条</div>
<article
v-for="shop in kuaishouEticketShops"
:key="shop.id"
:class="['shop-card', 'kuaishou-shop-card', {
'is-active': kuaishouEticketForm.selectedShopId === shop.id,
'is-expanded': isKuaishouEticketShopExpanded(shop.id),
}]"
>
<button type="button" class="shop-summary kuaishou-shop-summary" @click="toggleKuaishouEticketShop(shop.id)">
<div class="kuaishou-shop-head">
<div class="shop-avatar">
<img v-if="shop.userAvatar" :src="shop.userAvatar" :alt="shop.kshopName || shop.shopId || '店铺头像'" />
<span v-else>{{ getKuaishouEticketShopInitial(shop) }}</span>
</div>
<div class="shop-summary-main">
<div class="shop-title-row">
<strong>{{ shop.kshopName || shop.shopId || '未命名店铺' }}</strong>
<span class="shop-status" :class="{ 'is-disabled': !shop.enabled }">
{{ shop.enabled ? '已启用' : '已停用' }}
</span>
</div>
<span class="cell-subtle">店铺 ID{{ shop.shopId || '等待 info 自动识别' }}</span>
<div class="shop-tag-row">
<span class="shop-mini-tag" :class="{ 'is-ready': Boolean(shop.cookie.trim()) }">
{{ shop.cookie.trim() ? 'Cookie 已粘贴' : '待补 Cookie' }}
</span>
<span class="shop-mini-tag" :class="{ 'is-ready': Boolean(shop.userAvatar || shop.shopId.trim()) }">
{{ shop.userAvatar || shop.shopId.trim() ? '店铺信息已识别' : '未读取店铺信息' }}
</span>
<span v-if="kuaishouEticketForm.selectedShopId === shop.id" class="shop-mini-tag is-highlight">当前调试店铺</span>
</div>
</div>
</div>
<div class="shop-summary-side">
<span class="shop-summary-copy">{{ describeKuaishouEticketShop(shop) }}</span>
<span class="shop-toggle">{{ isKuaishouEticketShopExpanded(shop.id) ? '收起详情' : '展开配置' }}</span>
</div>
</button>
<div v-if="isKuaishouEticketShopExpanded(shop.id)" class="shop-body kuaishou-shop-body">
<div class="form-grid">
<label class="field-block">
<span>kshopName</span>
<input v-model="shop.kshopName" class="text-input" placeholder="例如 稚嫩游戏交易店" />
</label>
<label class="field-block">
<span>店铺 ID</span>
<input v-model="shop.shopId" class="text-input" readonly placeholder="通过 info 接口自动识别" />
</label>
<label class="field-block field-wide">
<span>Cookie</span>
<textarea
v-model="shop.cookie"
class="text-area kuaishou-cookie-area"
placeholder="直接粘贴该店铺请求头中的完整 Cookie"
/>
</label>
</div>
<div class="shop-actions kuaishou-shop-actions">
<label class="checkbox-line">
<input v-model="shop.enabled" type="checkbox" />
<span>启用该店铺</span>
</label>
<span class="cell-subtle">{{ shop.userAvatar ? '已识别头像,可直接作为调试店铺' : '建议先读取店铺信息确认 Cookie 归属' }}</span>
<el-button
:loading="kuaishouEticketResolvingShopId === shop.id"
link
type="primary"
@click="handleKuaishouEticketResolveShopInfo(shop)"
>
读取店铺信息
</el-button>
<el-button
link
:type="kuaishouEticketForm.selectedShopId === shop.id ? 'success' : 'primary'"
@click="setKuaishouEticketDebugShop(shop.id)"
>
{{ kuaishouEticketForm.selectedShopId === shop.id ? '当前调试店铺' : '设为调试店铺' }}
</el-button>
<el-button link type="danger" @click="removeKuaishouEticketShop(shop.id)">删除</el-button>
</div>
</div>
</article>
</div>
<aside class="kuaishou-side-panel">
<AdminResultCard label="店铺总数" :value="kuaishouEticketStats.configuredShopCount" class="kuaishou-side-card">
<p>其中 Cookie 就绪 {{ kuaishouEticketStats.cookieReadyCount }} </p>
</AdminResultCard>
<AdminResultCard
label="当前调试店铺"
:value="activeKuaishouEticketShop?.kshopName || activeKuaishouEticketShop?.shopId || '未选择'"
class="kuaishou-side-card"
>
<p>{{ kuaishouEticketStats.selectedShopReady ? '已具备调试条件,可直接查询核销信息。' : '请选择店铺并补全 Cookie。' }}</p>
</AdminResultCard>
<AdminResultCard
label="最近识别结果"
:value="kuaishouEticketShopInfoResult?.shop.kshopName || kuaishouEticketShopInfoResult?.shop.shopId || '未执行'"
class="kuaishou-side-card"
>
<p v-if="kuaishouEticketShopInfoResult">结算状态 {{ kuaishouEticketShopInfoResult.shop.settleStatus }}</p>
<p v-else>可用读取店铺信息校验 Cookie 是否属于预期店铺</p>
</AdminResultCard>
<AdminResultCard label="建议顺序" value="先配置,再调试" class="kuaishou-side-card kuaishou-guide-card">
<p>1. 新增店铺并粘贴 Cookie</p>
<p>2. 读取店铺信息确认 kshopName / shopId</p>
<p>3. 设为调试店铺后查询券码并执行核销</p>
</AdminResultCard>
</aside>
</div>
<p v-if="kuaishouEticketResultError" class="error-copy">{{ kuaishouEticketResultError }}</p>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>快手小店核销调试</h3>
<p>这里使用当前调试店铺 Cookie 执行查询与核销`eTicketId / oid / formToken` 都来自查询结果</p>
</div>
<div class="section-actions">
<el-button :loading="kuaishouEticketDetailing" round @click="handleKuaishouEticketQueryDetail">查询核销信息</el-button>
<el-button :loading="kuaishouEticketConsuming" round type="primary" @click="handleKuaishouEticketConsume">执行核销</el-button>
</div>
</div>
<div class="kuaishou-debug-layout">
<div class="kuaishou-debug-panel">
<div class="form-grid">
<label class="field-block">
<span>调试店铺</span>
<select v-model="kuaishouEticketForm.selectedShopId" class="text-input">
<option value="">请选择店铺</option>
<option v-for="shop in kuaishouEticketShops" :key="shop.id" :value="shop.id">
{{ shop.kshopName || shop.shopId || '未命名店铺' }}
</option>
</select>
</label>
<label class="field-block">
<span>查询券码</span>
<input v-model="kuaishouEticketForm.queryTicketCode" class="text-input" placeholder="用于查询核销信息" />
</label>
<label class="field-block">
<span>核销数量</span>
<input v-model.number="kuaishouEticketForm.num" class="text-input" type="number" min="1" />
</label>
<label class="field-block">
<span>storeId</span>
<input v-model="kuaishouEticketForm.storeId" class="text-input" placeholder="默认 0" />
</label>
</div>
</div>
<div class="kuaishou-debug-panel kuaishou-debug-panel-soft">
<div class="kuaishou-debug-head">
<!-- 店铺配置 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="result-label">自动回填参数</span>
<strong>查询后生成</strong>
<span class="card-title">快手小店店铺配置</span>
<p class="card-desc">支持多店铺配置建议先粘贴 Cookie再用 info 接口自动识别 kshopName / shopId最后统一保存到文件</p>
</div>
<span class="cell-subtle">这些字段不需要手动填写</span>
<div class="card-actions">
<el-button :disabled="kuaishouEticketShops.length === 0" @click="expandAllKuaishouEticketShops">全部展开</el-button>
<el-button :disabled="kuaishouEticketShops.length === 0" @click="collapseAllKuaishouEticketShops">全部折叠</el-button>
<el-button @click="addKuaishouEticketShop">新增店铺</el-button>
<el-button :loading="kuaishouEticketSaving" type="primary" @click="handleKuaishouEticketSaveSource">保存配置</el-button>
</div>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-form-item label="Base URL">
<el-input v-model="kuaishouEticketForm.baseUrl" placeholder="https://s.kwaixiaodian.com" />
</el-form-item>
</el-form>
<el-empty v-if="kuaishouEticketShops.length === 0" description="当前还没有快手小店店铺配置,先新增一条。" />
<div v-else class="shop-panels">
<div class="shop-list">
<el-collapse
:model-value="kuaishouEticketShops.filter(s => isKuaishouEticketShopExpanded(s.id)).map(s => s.id)"
>
<el-collapse-item
v-for="shop in kuaishouEticketShops"
:key="shop.id"
:name="shop.id"
:class="['shop-panel', {
'is-debug': kuaishouEticketForm.selectedShopId === shop.id,
}]"
>
<template #title>
<div class="shop-title-bar">
<div class="shop-avatar">
<img v-if="shop.userAvatar" :src="shop.userAvatar" :alt="shop.kshopName || shop.shopId || '店铺头像'" />
<span v-else>{{ getKuaishouEticketShopInitial(shop) }}</span>
</div>
<div class="shop-info">
<div class="shop-name-row">
<strong>{{ shop.kshopName || shop.shopId || '未命名店铺' }}</strong>
<el-tag :type="shop.enabled ? 'success' : 'info'" size="small">{{ shop.enabled ? '已启用' : '已停用' }}</el-tag>
<el-tag v-if="kuaishouEticketForm.selectedShopId === shop.id" type="primary" size="small">当前调试</el-tag>
</div>
<div class="shop-meta">
<span>店铺 ID{{ shop.shopId || '等待 info 自动识别' }}</span>
<el-tag :type="Boolean(shop.cookie.trim()) ? 'success' : 'warning'" size="small">
{{ shop.cookie.trim() ? 'Cookie 已粘贴' : '待补 Cookie' }}
</el-tag>
<el-tag :type="Boolean(shop.userAvatar || shop.shopId.trim()) ? 'success' : 'info'" size="small">
{{ shop.userAvatar || shop.shopId.trim() ? '信息已识别' : '未读取信息' }}
</el-tag>
</div>
</div>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="kshopName">
<el-input v-model="shop.kshopName" placeholder="例如 稚嫩游戏交易店" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺 ID">
<el-input v-model="shop.shopId" readonly placeholder="通过 info 接口自动识别" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Cookie">
<el-input
v-model="shop.cookie"
type="textarea"
:rows="4"
placeholder="直接粘贴该店铺请求头中的完整 Cookie"
/>
</el-form-item>
</el-form>
<div class="shop-footer">
<div class="footer-left">
<el-switch v-model="shop.enabled" active-text="启用该店铺" />
<span v-if="shop.userAvatar" class="text-muted">已识别头像,可直接作为调试店铺</span>
<span v-else class="text-muted">建议先读取店铺信息确认 Cookie 归属</span>
</div>
<div class="footer-actions">
<el-button
:loading="kuaishouEticketResolvingShopId === shop.id"
size="small"
@click="handleKuaishouEticketResolveShopInfo(shop)"
>
读取店铺信息
</el-button>
<el-button
size="small"
:type="kuaishouEticketForm.selectedShopId === shop.id ? 'success' : 'primary'"
@click="setKuaishouEticketDebugShop(shop.id)"
>
{{ kuaishouEticketForm.selectedShopId === shop.id ? '当前调试店铺' : '设为调试店铺' }}
</el-button>
<el-button size="small" type="danger" @click="removeKuaishouEticketShop(shop.id)">删除</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div class="form-grid">
<label class="field-block">
<span>eTicketId</span>
<input :value="kuaishouEticketForm.eTicketId" class="text-input" readonly placeholder="查询后自动回填" />
</label>
<label class="field-block">
<span>oid</span>
<input :value="kuaishouEticketForm.oid" class="text-input" readonly placeholder="查询后自动回填" />
</label>
<label class="field-block field-wide">
<span>formToken</span>
<input :value="kuaishouEticketForm.formToken" class="text-input" readonly placeholder="查询后自动回填" />
</label>
<div class="side-panel">
<AdminResultCard label="店铺总数" :value="kuaishouEticketStats.configuredShopCount">
<p>其中 Cookie 就绪 {{ kuaishouEticketStats.cookieReadyCount }} 家。</p>
</AdminResultCard>
<AdminResultCard
label="当前调试店铺"
:value="activeKuaishouEticketShop?.kshopName || activeKuaishouEticketShop?.shopId || '未选择'"
>
<p>{{ kuaishouEticketStats.selectedShopReady ? '已具备调试条件,可直接查询核销信息。' : '请选择店铺并补全 Cookie。' }}</p>
</AdminResultCard>
<AdminResultCard
label="最近识别结果"
:value="kuaishouEticketShopInfoResult?.shop.kshopName || kuaishouEticketShopInfoResult?.shop.shopId || '未执行'"
>
<p v-if="kuaishouEticketShopInfoResult">结算状态 {{ kuaishouEticketShopInfoResult.shop.settleStatus }}</p>
<p v-else>可用"读取店铺信息"校验 Cookie 是否属于预期店铺。</p>
</AdminResultCard>
<AdminResultCard label="建议顺序" value="先配置再调试">
<p>1. 新增店铺并粘贴 Cookie</p>
<p>2. 读取店铺信息确认 kshopName / shopId</p>
<p>3. 设为调试店铺后查询券码并执行核销</p>
</AdminResultCard>
</div>
</div>
</div>
<div class="result-grid">
<AdminResultCard label="详情查询" :value="kuaishouEticketDetailResult ? (kuaishouEticketDetailResult.ok ? '成功' : '已返回') : '未执行'">
<p v-if="kuaishouEticketDetailResult?.detail">
{{ kuaishouEticketDetailResult.goods?.itemTitle || kuaishouEticketDetailResult.goods?.skuDesc || '-' }}
剩余 {{ kuaishouEticketDetailResult.detail.leftCount }} / {{ kuaishouEticketDetailResult.detail.totalCount }}
</p>
<p v-else>先通过查询券码获取核销详情并自动带出核销参数</p>
</AdminResultCard>
<el-alert
v-if="kuaishouEticketResultError"
:title="kuaishouEticketResultError"
type="error"
show-icon
:closable="false"
style="margin-top: 16px"
/>
</el-card>
<AdminResultCard label="核销执行" :value="kuaishouEticketConsumeResult ? (kuaishouEticketConsumeResult.consumed ? '已处理' : '已返回') : '未执行'">
<p v-if="kuaishouEticketConsumeResult">
{{ kuaishouEticketConsumeResult.errorMessage || (kuaishouEticketConsumeResult.ok ? '核销成功' : '接口已返回结果') }}
</p>
<p v-else>查询详情后再执行核销会更稳妥</p>
</AdminResultCard>
</div>
</section>
<!-- 核销调试 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">快手小店核销调试</span>
<p class="card-desc">这里使用"当前调试店铺"的 Cookie 执行查询与核销;eTicketId / oid / formToken 都来自查询结果。</p>
</div>
<div class="card-actions">
<el-button :loading="kuaishouEticketDetailing" @click="handleKuaishouEticketQueryDetail">查询核销信息</el-button>
<el-button :loading="kuaishouEticketConsuming" type="primary" @click="handleKuaishouEticketConsume">执行核销</el-button>
</div>
</div>
</template>
<section v-if="kuaishouEticketDetailResult" class="table-card">
<div class="section-title-row">
<div>
<h3>核销详情返回</h3>
<p>成功查询后会自动回填 `eTicketId``oid``formToken` 和建议核销数量</p>
<div class="debug-layout">
<div class="debug-main">
<el-form label-width="auto" label-position="top">
<el-form-item label="调试店铺">
<el-select v-model="kuaishouEticketForm.selectedShopId" placeholder="请选择店铺" style="width: 100%">
<el-option v-for="shop in kuaishouEticketShops" :key="shop.id" :label="shop.kshopName || shop.shopId || '未命名店铺'" :value="shop.id" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="查询券码">
<el-input v-model="kuaishouEticketForm.queryTicketCode" placeholder="用于查询核销信息" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="核销数量">
<el-input-number v-model="kuaishouEticketForm.num" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="storeId">
<el-input v-model="kuaishouEticketForm.storeId" placeholder="默认 0" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<el-card shadow="never" class="auto-fill-card">
<template #header>
<div>
<span class="card-title">自动回填参数</span>
<span class="text-muted" style="margin-left: 8px">查询后生成</span>
</div>
</template>
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="eTicketId">
<el-input :model-value="kuaishouEticketForm.eTicketId" readonly placeholder="查询后自动回填" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="oid">
<el-input :model-value="kuaishouEticketForm.oid" readonly placeholder="查询后自动回填" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="formToken">
<el-input :model-value="kuaishouEticketForm.formToken" readonly placeholder="查询后自动回填" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</div>
<pre class="debug-result">{{ JSON.stringify(kuaishouEticketDetailResult.raw, null, 2) }}</pre>
</section>
<section v-if="kuaishouEticketConsumeResult" class="table-card">
<div class="section-title-row">
<div>
<h3>核销执行返回</h3>
<p>这里保留接口原始响应方便核对 `result``error_msg` `requestId`</p>
<div class="result-grid" style="margin-top: 16px">
<AdminResultCard label="详情查询" :value="kuaishouEticketDetailResult ? (kuaishouEticketDetailResult.ok ? '成功' : '已返回') : '未执行'">
<p v-if="kuaishouEticketDetailResult?.detail">
{{ kuaishouEticketDetailResult.goods?.itemTitle || kuaishouEticketDetailResult.goods?.skuDesc || '-' }}
剩余 {{ kuaishouEticketDetailResult.detail.leftCount }} / {{ kuaishouEticketDetailResult.detail.totalCount }}
</p>
<p v-else>先通过查询券码获取核销详情并自动带出核销参数。</p>
</AdminResultCard>
<AdminResultCard label="核销执行" :value="kuaishouEticketConsumeResult ? (kuaishouEticketConsumeResult.consumed ? '已处理' : '已返回') : '未执行'">
<p v-if="kuaishouEticketConsumeResult">
{{ kuaishouEticketConsumeResult.errorMessage || (kuaishouEticketConsumeResult.ok ? '核销成功' : '接口已返回结果') }}
</p>
<p v-else>查询详情后再执行核销会更稳妥。</p>
</AdminResultCard>
</div>
</div>
</el-card>
<pre class="debug-result">{{ JSON.stringify(kuaishouEticketConsumeResult.raw, null, 2) }}</pre>
</section>
<!-- 详情结果 -->
<el-card v-if="kuaishouEticketDetailResult" class="section-card">
<template #header>
<div>
<span class="card-title">核销详情返回</span>
<p class="card-desc">成功查询后会自动回填 eTicketId、oid、formToken 和建议核销数量。</p>
</div>
</template>
<pre class="debug-pre">{{ JSON.stringify(kuaishouEticketDetailResult.raw, null, 2) }}</pre>
</el-card>
<el-card v-if="kuaishouEticketConsumeResult" class="section-card">
<template #header>
<div>
<span class="card-title">核销执行返回</span>
<p class="card-desc">这里保留接口原始响应,方便核对 result、error_msg 和 requestId。</p>
</div>
</template>
<pre class="debug-pre">{{ JSON.stringify(kuaishouEticketConsumeResult.raw, null, 2) }}</pre>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.shop-panels {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 0.9fr);
gap: var(--space-4);
margin-top: var(--space-4);
}
.shop-list {
min-width: 0;
}
.side-panel {
display: grid;
gap: var(--space-3);
align-content: start;
}
.shop-panel.is-debug {
border: 1px solid var(--border-hover);
border-radius: var(--radius-md);
}
.shop-title-bar {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
padding-right: var(--space-4);
}
.shop-avatar {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
overflow: hidden;
background: linear-gradient(135deg, #dbeafe, #eff6ff);
color: var(--color-primary-dark);
font-size: 18px;
font-weight: 800;
display: grid;
place-items: center;
flex-shrink: 0;
border: 1px solid var(--border-hover);
}
.shop-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.shop-info {
min-width: 0;
}
.shop-name-row {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.shop-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: 2px;
}
.shop-footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-default);
}
.footer-left {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.footer-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.debug-layout {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 0.9fr);
gap: var(--space-4);
}
.auto-fill-card {
background: var(--bg-subtle);
}
.result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.debug-pre {
margin: 0;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: #0f172a;
color: #e2e8f0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: var(--text-xs);
line-height: 1.6;
font-family: var(--font-mono);
}
.text-muted {
font-size: var(--text-xs);
color: var(--text-muted);
}
@media (max-width: 900px) {
.shop-panels,
.debug-layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -25,95 +25,155 @@ defineProps<Props>()
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">职责</span>
<span>接收 91卡券推送订单未命中履约配置时先进入待补全队列补完商品规则后可手动重试生成任务</span>
</div>
<div class="meta-line">
<span class="meta-label">处理建议</span>
<span>先到快手 Cloud 新履约页为 91 productNo 配置规则再回到这里点击重试</span>
</div>
</section>
<div class="platform-section">
<!-- 说明信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">说明信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="职责">
接收 91卡券推送订单未命中履约配置时先进入待补全队列补完商品规则后可手动重试生成任务
</el-descriptions-item>
<el-descriptions-item label="处理建议">
先到"快手 Cloud 新履约"页为 91 productNo 配置规则再回到这里点击重试
</el-descriptions-item>
</el-descriptions>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>91卡券订单队列</h3>
<p>当前筛选 {{ ninetyoneStats.currentCount }} 总计 {{ ninetyoneStats.total }} 已生成任务 {{ ninetyoneStats.taskReadyCount }} </p>
</div>
<div class="section-actions">
<el-button-group>
<el-button :type="ninetyoneStatus === 'pending_config' ? 'primary' : 'default'" round @click="handleNinetyoneStatusChange('pending_config')">待补全</el-button>
<el-button :type="ninetyoneStatus === 'manual_failed' ? 'primary' : 'default'" round @click="handleNinetyoneStatusChange('manual_failed')">失败</el-button>
<el-button :type="ninetyoneStatus === 'all' ? 'primary' : 'default'" round @click="handleNinetyoneStatusChange('all')">全部</el-button>
</el-button-group>
<el-button :loading="ninetyoneLoading" round @click="loadNinetyoneOrders(ninetyoneOrders.page)">刷新</el-button>
</div>
</div>
<!-- 订单队列 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">91卡券订单队列</span>
<p class="card-desc">
当前筛选 {{ ninetyoneStats.currentCount }} 总计 {{ ninetyoneStats.total }} 已生成任务 {{ ninetyoneStats.taskReadyCount }}
</p>
</div>
<div class="card-actions">
<el-radio-group
:model-value="ninetyoneStatus"
size="small"
@update:model-value="(v: string | number | boolean | undefined) => handleNinetyoneStatusChange(v as 'pending_config' | 'all' | 'manual_failed')"
>
<el-radio-button value="pending_config">待补全</el-radio-button>
<el-radio-button value="manual_failed">失败</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<el-button :loading="ninetyoneLoading" @click="loadNinetyoneOrders(ninetyoneOrders.page)">刷新</el-button>
</div>
</div>
</template>
<p v-if="ninetyoneResultError" class="error-copy">{{ ninetyoneResultError }}</p>
<div v-if="ninetyoneLoading" class="empty-inline">正在读取 91卡券订单</div>
<el-alert
v-if="ninetyoneResultError"
:title="ninetyoneResultError"
type="error"
show-icon
:closable="false"
style="margin-bottom: 16px"
/>
<table v-else class="data-table">
<thead>
<tr>
<th>订单</th>
<th>商品</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in ninetyoneOrders.items" :key="item.orderId">
<td>
<div class="cell-stack">
<strong>{{ item.orderNo }}</strong>
<span class="cell-subtle">{{ item.outTradeNo || '-' }}</span>
<el-table
:data="ninetyoneOrders.items"
stripe
size="small"
v-loading="ninetyoneLoading"
element-loading-text="正在读取 91卡券订单..."
>
<el-table-column label="订单" min-width="180">
<template #default="{ row }">
<div>
<div style="font-weight: 600">{{ row.orderNo }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">{{ row.outTradeNo || '-' }}</div>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ item.productName || item.productNo || '-' }}</strong>
<span class="cell-subtle">productNo: {{ item.productNo || '-' }}</span>
<span class="cell-subtle">数量{{ item.buyNum || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="商品" min-width="180">
<template #default="{ row }">
<div>
<div style="font-weight: 600">{{ row.productName || row.productNo || '-' }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">productNo: {{ row.productNo || '-' }}</div>
<div style="font-size: var(--text-xs); color: var(--text-muted)">数量:{{ row.buyNum || 0 }}</div>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ item.orderStatus }}</strong>
<span class="cell-subtle">任务 {{ item.taskCount }}</span>
<span v-if="item.failReason" class="cell-subtle">{{ item.failReason }}</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<div>
<el-tag size="small">{{ row.orderStatus }}</el-tag>
<div style="font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px">任务 {{ row.taskCount }}</div>
<div v-if="row.failReason" style="font-size: var(--text-xs); color: var(--color-danger)">{{ row.failReason }}</div>
</div>
</td>
<td>{{ formatAdminDateTime(item.updatedAt || item.createdAt) }}</td>
<td>
<div class="row-actions">
<el-button
:loading="ninetyoneActionLoadingId === item.orderId"
link
type="primary"
@click="handleNinetyoneRetryOrder(item)"
>
重试生成任务
</el-button>
<el-button
v-if="item.orderStatus !== 'manual_failed'"
:loading="ninetyoneActionLoadingId === item.orderId"
link
type="danger"
@click="handleNinetyoneFailOrder(item)"
>
标记失败
</el-button>
</div>
</td>
</tr>
<tr v-if="ninetyoneOrders.items.length === 0">
<td colspan="5" class="empty-inline">当前没有 91卡券订单</td>
</tr>
</tbody>
</table>
</section>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatAdminDateTime(row.updatedAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button
:loading="ninetyoneActionLoadingId === row.orderId"
size="small"
type="primary"
@click="handleNinetyoneRetryOrder(row)"
>
重试生成任务
</el-button>
<el-button
v-if="row.orderStatus !== 'manual_failed'"
:loading="ninetyoneActionLoadingId === row.orderId"
size="small"
type="danger"
@click="handleNinetyoneFailOrder(row)"
>
标记失败
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="当前没有 91卡券订单" :image-size="60" />
</template>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
</style>
@@ -53,10 +53,7 @@ const timeUnitOptions = [
]
function formatJobType(type: string) {
if (type === 'cloudtentacles_health') {
return 'cloudtentacles 健康检查'
}
if (type === 'cloudtentacles_health') return 'cloudtentacles 健康检查'
return type || '-'
}
@@ -65,11 +62,9 @@ function resolveTimeValue(seconds: number) {
if (normalized >= 3600 && normalized % 3600 === 0) {
return { amount: normalized / 3600, unit: 3600 }
}
if (normalized >= 60 && normalized % 60 === 0) {
return { amount: normalized / 60, unit: 60 }
}
return { amount: normalized || 1, unit: 1 }
}
@@ -89,7 +84,7 @@ function bindDuration(job: EditableScheduledJob, key: 'intervalSeconds' | 'coold
;(job[unitKey] as number) = resolved.unit
}
return computed({
return computed<number>({
get: () => Number(job[amountKey] || 1),
set: (value: number) => {
;(job[amountKey] as number) = Math.max(1, Number(value || 1))
@@ -108,7 +103,7 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
;(job[unitKey] as number) = resolved.unit
}
return computed({
return computed<number>({
get: () => Number(job[unitKey] || 1),
set: (value: number) => {
;(job[unitKey] as number) = Math.max(1, Number(value || 1))
@@ -119,235 +114,350 @@ function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'c
</script>
<template>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">职责</span>
<span>内部运营通知仅发送给后台管理员客服或值班人员</span>
</div>
<div class="meta-line">
<span class="meta-label">配置文件</span>
<span>{{ notificationFilePath || '-' }}</span>
</div>
<div class="meta-line">
<span class="meta-label">监控任务</span>
<span>{{ scheduledJobsFilePath || '-' }}</span>
</div>
</section>
<div class="platform-section">
<!-- 配置信息 -->
<el-card class="section-card">
<template #header>
<span class="card-title">配置信息</span>
</template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="职责">内部运营通知仅发送给后台管理员客服或值班人员</el-descriptions-item>
<el-descriptions-item label="配置文件">{{ notificationFilePath || '-' }}</el-descriptions-item>
<el-descriptions-item label="监控任务">{{ scheduledJobsFilePath || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>Bark 通知</h3>
<p>已配置 {{ notificationStats.configuredRecipientCount }} 启用 {{ notificationStats.enabledRecipientCount }} </p>
</div>
<div class="section-actions">
<el-button round @click="addNotificationRecipient">新增接收人</el-button>
<el-button :loading="notificationSaving" round type="primary" @click="handleNotificationSaveConfig">保存配置</el-button>
</div>
</div>
<!-- Bark 通知配置 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">Bark 通知</span>
<p class="card-desc">已配置 {{ notificationStats.configuredRecipientCount }} 启用 {{ notificationStats.enabledRecipientCount }} </p>
</div>
<div class="card-actions">
<el-button @click="addNotificationRecipient">新增接收人</el-button>
<el-button :loading="notificationSaving" type="primary" @click="handleNotificationSaveConfig">保存配置</el-button>
</div>
</div>
</template>
<p v-if="notificationResultError" class="error-copy">{{ notificationResultError }}</p>
<el-alert
v-if="notificationResultError"
:title="notificationResultError"
type="error"
show-icon
:closable="false"
style="margin-bottom: 16px"
/>
<div class="form-grid">
<label class="checkbox-line">
<input v-model="notificationForm.enabled" class="checkbox-box" type="checkbox" />
<span>启用内部通知</span>
</label>
<label class="checkbox-line">
<input v-model="notificationForm.barkEnabled" class="checkbox-box" type="checkbox" />
<span>启用 Bark 通道</span>
</label>
<label class="field-block field-wide">
<span>Bark 服务地址</span>
<input v-model="notificationForm.barkServerUrl" class="text-input" placeholder="https://api.day.app" />
</label>
</div>
<el-form label-width="auto" label-position="top" inline>
<el-form-item><el-switch v-model="notificationForm.enabled" active-text="启用内部通知" /></el-form-item>
<el-form-item><el-switch v-model="notificationForm.barkEnabled" active-text="启用 Bark 通道" /></el-form-item>
<el-form-item label="Bark 服务地址" style="width: 100%">
<el-input v-model="notificationForm.barkServerUrl" placeholder="https://api.day.app" />
</el-form-item>
</el-form>
<table class="data-table">
<thead>
<tr>
<th>接收人</th>
<th>Device Key</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in notificationRecipients" :key="item.id">
<td>
<input v-model="item.name" class="text-input" placeholder="例如:客服A" />
</td>
<td>
<input v-model="item.deviceKey" class="text-input" placeholder="Bark device key" />
</td>
<td>
<label class="checkbox-line">
<input v-model="item.enabled" class="checkbox-box" type="checkbox" />
<span>{{ item.enabled ? '启用' : '停用' }}</span>
</label>
</td>
<td>
<el-button link type="danger" @click="removeNotificationRecipient(item.id)">删除</el-button>
</td>
</tr>
<tr v-if="notificationRecipients.length === 0">
<td colspan="4" class="empty-inline">还没有 Bark 接收人</td>
</tr>
</tbody>
</table>
</section>
<el-table :data="notificationRecipients" stripe size="small" style="margin-top: 16px">
<el-table-column label="接收人" min-width="150">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="例如:客服A" size="small" />
</template>
</el-table-column>
<el-table-column label="Device Key" min-width="200">
<template #default="{ row }">
<el-input v-model="row.deviceKey" placeholder="Bark device key" size="small" />
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.enabled" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" type="danger" text @click="removeNotificationRecipient(row.id)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="还没有 Bark 接收人。" :image-size="60" />
</template>
</el-table>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>监控任务</h3>
<p>间隔表示多久检查一次冷却表示同一个异常在这段时间内最多通知一次</p>
</div>
<div class="section-actions">
<el-button :loading="scheduledJobsSaving" round type="primary" @click="handleScheduledJobsSaveConfig">保存任务</el-button>
</div>
</div>
<!-- 监控任务 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">监控任务</span>
<p class="card-desc">间隔表示多久检查一次冷却表示同一个异常在这段时间内最多通知一次</p>
</div>
<div class="card-actions">
<el-button :loading="scheduledJobsSaving" type="primary" @click="handleScheduledJobsSaveConfig">保存任务</el-button>
</div>
</div>
</template>
<div class="form-grid">
<label class="checkbox-line">
<input v-model="scheduledJobsForm.enabled" class="checkbox-box" type="checkbox" />
<span>启用监控任务系统</span>
</label>
</div>
<el-form label-width="auto" label-position="top">
<el-form-item>
<el-switch v-model="scheduledJobsForm.enabled" active-text="启用监控任务系统" />
</el-form-item>
</el-form>
<table class="data-table">
<thead>
<tr>
<th>任务</th>
<th>参数</th>
<th>最近状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="job in scheduledJobs" :key="job.id">
<td>
<div class="cell-stack">
<strong>{{ formatJobType(job.type) }}</strong>
<label class="checkbox-line">
<input v-model="job.enabled" class="checkbox-box" type="checkbox" />
<span>{{ job.enabled ? '启用' : '停用' }}</span>
</label>
<el-table :data="scheduledJobs" stripe size="small" style="margin-top: 8px">
<el-table-column label="任务" min-width="200">
<template #default="{ row: job }">
<div>
<div style="font-weight: 600">{{ formatJobType(job.type) }}</div>
<el-switch v-model="job.enabled" size="small" active-text="启用" style="margin-top: 4px" />
</div>
</td>
<td>
<div class="form-grid">
<label class="field-block">
<span>检查间隔</span>
<div class="time-input-row">
<input v-model.number="bindDuration(job, 'intervalSeconds').value" class="text-input" type="number" min="1" />
<select v-model.number="bindDurationUnit(job, 'intervalSeconds').value" class="text-input time-unit-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
</select>
</div>
<span class="cell-subtle"> {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
</label>
<label class="field-block">
<span>通知冷却</span>
<div class="time-input-row">
<input v-model.number="bindDuration(job, 'cooldownSeconds').value" class="text-input" type="number" min="1" />
<select v-model.number="bindDurationUnit(job, 'cooldownSeconds').value" class="text-input time-unit-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
</select>
</div>
<span class="cell-subtle">同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span>
</label>
<label class="field-block">
<span>余额阈值</span>
<input v-model.number="job.assetThreshold" class="text-input" type="number" min="0" />
<span class="cell-subtle">低于该值时通知</span>
</label>
</template>
</el-table-column>
<el-table-column label="参数" min-width="320">
<template #default="{ row: job }">
<div class="job-params">
<div class="param-row">
<span class="param-label">检查间隔</span>
<input v-model.number="bindDuration(job, 'intervalSeconds').value" type="number" min="1" class="mini-input" />
<select v-model.number="bindDurationUnit(job, 'intervalSeconds').value" class="mini-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
</select>
<span class="param-hint"> {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
</div>
<div class="param-row">
<span class="param-label">通知冷却</span>
<input v-model.number="bindDuration(job, 'cooldownSeconds').value" type="number" min="1" class="mini-input" />
<select v-model.number="bindDurationUnit(job, 'cooldownSeconds').value" class="mini-select">
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
</select>
<span class="param-hint">同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span>
</div>
<div class="param-row">
<span class="param-label">余额阈值</span>
<input v-model.number="job.assetThreshold" type="number" min="0" class="mini-input" />
<span class="param-hint">低于该值时通知</span>
</div>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}</strong>
<span class="cell-subtle">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
<span class="cell-subtle">余额{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span>
<span class="cell-subtle">上次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '') }}</span>
<span class="cell-subtle">下次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '') }}</span>
</template>
</el-table-column>
<el-table-column label="最近状态" min-width="160">
<template #default="{ row: job }">
<div class="runtime-info">
<el-tag
:type="getScheduledJobRuntime(job.id)?.lastStatus === 'success' ? 'success' : 'info'"
size="small"
>
{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}
</el-tag>
<span class="param-hint">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
<span class="param-hint">余额{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span>
<span class="param-hint">上次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '') }}</span>
<span class="param-hint">下次{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '') }}</span>
</div>
</td>
<td>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="{ row: job }">
<el-button
:loading="scheduledJobRunningId === job.id"
round
size="small"
@click="handleScheduledJobRunNow(job.id)"
>
立即检查
</el-button>
</td>
</tr>
<tr v-if="scheduledJobs.length === 0">
<td colspan="4" class="empty-inline">暂无可配置的监控任务</td>
</tr>
</tbody>
</table>
</section>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无可配置的监控任务。" :image-size="60" />
</template>
</el-table>
</el-card>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>测试发送</h3>
<p>测试会发送给所有已启用且填写了 Device Key 的接收人</p>
</div>
<div class="section-actions">
<el-button :disabled="!notificationStats.barkReady" :loading="notificationTesting" round type="primary" @click="handleNotificationTest">发送测试</el-button>
</div>
</div>
<!-- 测试发送 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<div>
<span class="card-title">测试发送</span>
<p class="card-desc">测试会发送给所有已启用且填写了 Device Key 的接收人</p>
</div>
<div class="card-actions">
<el-button
:disabled="!notificationStats.barkReady"
:loading="notificationTesting"
type="primary"
@click="handleNotificationTest"
>
发送测试
</el-button>
</div>
</div>
</template>
<div class="form-grid">
<label class="field-block">
<span>标题</span>
<input v-model="notificationForm.testTitle" class="text-input" />
</label>
<label class="field-block field-wide">
<span>内容</span>
<input v-model="notificationForm.testBody" class="text-input" />
</label>
<label class="field-block field-wide">
<span>跳转链接</span>
<input v-model="notificationForm.testUrl" class="text-input" placeholder="可选" />
</label>
</div>
<el-form label-width="auto" label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="标题">
<el-input v-model="notificationForm.testTitle" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="内容">
<el-input v-model="notificationForm.testBody" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="跳转链接">
<el-input v-model="notificationForm.testUrl" placeholder="可选" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-if="notificationTestResult" class="result-grid">
<div class="result-card">
<span class="result-label">成功</span>
<strong>{{ notificationTestResult.successCount }}</strong>
<div v-if="notificationTestResult" class="result-grid">
<el-card shadow="never" class="stat-card">
<div class="stat-label">成功</div>
<div class="stat-value">{{ notificationTestResult.successCount }}</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">失败</div>
<div class="stat-value">{{ notificationTestResult.failedCount }}</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">通道</div>
<div class="stat-value">{{ notificationTestResult.channel || '-' }}</div>
</el-card>
</div>
<div class="result-card">
<span class="result-label">失败</span>
<strong>{{ notificationTestResult.failedCount }}</strong>
</div>
<div class="result-card">
<span class="result-label">通道</span>
<strong>{{ notificationTestResult.channel || '-' }}</strong>
</div>
</div>
<table v-if="notificationTestResult" class="data-table">
<thead>
<tr>
<th>接收人</th>
<th>结果</th>
<th>状态码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="item in notificationTestResult.results" :key="item.recipientId || item.recipientKeyMasked">
<td>{{ item.recipientName || item.recipientKeyMasked || '-' }}</td>
<td>{{ item.ok ? '成功' : '失败' }}</td>
<td>{{ item.status || '-' }}</td>
<td>{{ item.errorMessage || '-' }}</td>
</tr>
</tbody>
</table>
</section>
<el-table v-if="notificationTestResult" :data="notificationTestResult.results" stripe size="small" style="margin-top: 16px">
<el-table-column label="接收人">
<template #default="{ row }">{{ row.recipientName || row.recipientKeyMasked || '-' }}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{ row.ok ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态码" width="80" align="center" />
<el-table-column label="说明">
<template #default="{ row }">{{ row.errorMessage || '-' }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.platform-section {
display: grid;
gap: var(--space-4);
}
.section-card {
border-radius: var(--radius-lg);
}
.card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
margin: var(--space-1) 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
.card-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.job-params {
display: grid;
gap: var(--space-2);
}
.param-row {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.param-label {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: 600;
min-width: 56px;
}
.mini-input {
width: 100px;
height: 32px;
padding: 0 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
background: var(--bg-surface);
font-size: var(--text-sm);
text-align: center;
}
.mini-select {
width: 80px;
height: 32px;
padding: 0 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
background: var(--bg-surface);
font-size: var(--text-sm);
}
.param-hint {
font-size: var(--text-xs);
color: var(--text-muted);
}
.runtime-info {
display: grid;
gap: 2px;
font-size: var(--text-xs);
}
.result-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-4);
}
.stat-card {
text-align: center;
}
.stat-label {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: 700;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin-top: var(--space-1);
}
</style>
@@ -12,3 +12,31 @@ defineProps<{
<slot />
</article>
</template>
<style scoped>
.result-card {
display: grid;
gap: 6px;
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--bg-subtle);
border: 1px solid var(--border-default);
}
.result-label {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
}
.result-card strong {
color: var(--text-primary);
font-size: 22px;
}
.result-card :deep(p) {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
</style>