增加人工渠道
This commit is contained in:
@@ -27,6 +27,7 @@ const navItems = computed(() => {
|
||||
{ to: '/admin/dashboard', label: '概览' },
|
||||
{ to: '/admin/orders', label: '订单' },
|
||||
{ to: '/admin/tasks', label: '任务' },
|
||||
{ to: '/admin/manual-redeem', label: '人工兑换' },
|
||||
{ to: '/admin/message-deliveries', label: '消息发送' },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TencentAuthCard from '@/components/tencent/TencentAuthCard.vue'
|
||||
import TencentRedeemPanel from '@/components/tencent/TencentRedeemPanel.vue'
|
||||
import TencentResultPanel from '@/components/tencent/TencentResultPanel.vue'
|
||||
import { useAdminManualRedeemPage } from '@/composables/useAdminManualRedeemPage'
|
||||
|
||||
const {
|
||||
form,
|
||||
suggestions,
|
||||
detail,
|
||||
detailLoading,
|
||||
createLoading,
|
||||
sessionLoading,
|
||||
roleConfirmLoading,
|
||||
redeemLoading,
|
||||
closeLoading,
|
||||
loginType,
|
||||
loginTypeLabel,
|
||||
loginTabs,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
manualRequest,
|
||||
hasSession,
|
||||
qrImage,
|
||||
qrFigureStyle,
|
||||
qrPreviewWidth,
|
||||
statusLabel,
|
||||
session,
|
||||
sessionNotice,
|
||||
roleFacts,
|
||||
resultFacts,
|
||||
roleConfirmed,
|
||||
roleReady,
|
||||
canConfirmRole,
|
||||
canRedeem,
|
||||
redeemBlockedReason,
|
||||
redeemButtonLabel,
|
||||
initButtonLabel,
|
||||
scanInstruction,
|
||||
screenshotEmptyTitle,
|
||||
screenshotEmptyMessage,
|
||||
screenshotUrl,
|
||||
screenshotLoading,
|
||||
reviewReady,
|
||||
resultReady,
|
||||
canCreateTask,
|
||||
hasActiveTask,
|
||||
createTaskNow,
|
||||
createSessionFlow,
|
||||
refreshSessionFlow,
|
||||
closeSessionFlow,
|
||||
switchLoginType,
|
||||
confirmRoleNow,
|
||||
redeemNow,
|
||||
closeTaskNow,
|
||||
resetCurrentTask,
|
||||
handleQrImageLoad,
|
||||
copyQrImage,
|
||||
copyScreenshot,
|
||||
downloadScreenshot,
|
||||
} = useAdminManualRedeemPage()
|
||||
|
||||
const qrPreviewVisible = ref(false)
|
||||
const screenshotPreviewVisible = ref(false)
|
||||
|
||||
const currentLoginTabLabel = computed(
|
||||
() => loginTabs.find((tab) => tab.value === loginType.value)?.label ?? loginTabs[0].label,
|
||||
)
|
||||
|
||||
const reviewCardTitle = computed(() => (task.value?.status === 'redeemed' ? '兑换截图' : '角色复核图'))
|
||||
const canStartNextTask = computed(() => Boolean(task.value && ['redeemed', 'closed'].includes(task.value.status)))
|
||||
const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task.value.taskId}` : ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel manual-redeem-panel">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1>人工兑换</h1>
|
||||
<p>客服在后台创建唯一任务、发送二维码、复核角色并完成兑换。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<RouterLink v-if="taskDetailLink" :to="taskDetailLink" class="task-link">
|
||||
查看任务详情
|
||||
</RouterLink>
|
||||
<el-button
|
||||
v-if="hasActiveTask"
|
||||
:loading="closeLoading"
|
||||
plain
|
||||
type="danger"
|
||||
@click="closeTaskNow"
|
||||
>
|
||||
关闭当前任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canStartNextTask"
|
||||
plain
|
||||
type="primary"
|
||||
@click="resetCurrentTask"
|
||||
>
|
||||
开始下一单
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="create-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>1. 创建人工兑换任务</h2>
|
||||
<p>先录入唯一凭据并锁定库存,后续二维码、角色确认和兑换都基于这条任务继续。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<span>唯一凭据</span>
|
||||
<input
|
||||
v-model="form.proofValue"
|
||||
class="text-input"
|
||||
placeholder="输入订单号、客户名或其他唯一可追踪信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span>内部履约 SKU</span>
|
||||
<input
|
||||
v-model="form.skuCode"
|
||||
list="manual-redeem-sku-suggestions"
|
||||
class="text-input"
|
||||
placeholder="选择或输入内部履约 SKU"
|
||||
/>
|
||||
<datalist id="manual-redeem-sku-suggestions">
|
||||
<option
|
||||
v-for="item in suggestions"
|
||||
:key="`${item.credentialType}-${item.skuCode}`"
|
||||
:value="item.skuCode"
|
||||
:label="`${item.skuCode} · 可用 ${item.availableCount} / 共 ${item.totalCount}`"
|
||||
/>
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="field field-remark">
|
||||
<span>备注</span>
|
||||
<textarea
|
||||
v-model="form.remark"
|
||||
class="text-input text-area"
|
||||
placeholder="可选:记录客户补充信息、兑换要求或客服备注"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button
|
||||
:disabled="!canCreateTask"
|
||||
:loading="createLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="createTaskNow"
|
||||
>
|
||||
创建任务并预占库存
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detail" class="summary-card">
|
||||
<article>
|
||||
<span>唯一凭据</span>
|
||||
<strong>{{ manualRequest?.proofValue || order?.platformOrderId || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>任务号</span>
|
||||
<strong>{{ task?.taskNo || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>商品</span>
|
||||
<strong>{{ orderItem?.skuName || '-' }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>当前状态</span>
|
||||
<strong>{{ statusLabel }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="detailLoading && !detail" class="empty-block">
|
||||
人工兑换详情加载中
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail" class="layout-grid">
|
||||
<TencentAuthCard
|
||||
:current-login-tab-label="currentLoginTabLabel"
|
||||
:has-session="hasSession"
|
||||
:init-button-label="initButtonLabel"
|
||||
:login-tabs="loginTabs"
|
||||
:login-type="loginType"
|
||||
:login-type-label="loginTypeLabel"
|
||||
qr-copy-button-label="复制当前二维码"
|
||||
:qr-copy-enabled="true"
|
||||
:qr-figure-style="qrFigureStyle"
|
||||
:qr-image="qrImage"
|
||||
:scan-instruction="scanInstruction"
|
||||
:session-status="session?.status || ''"
|
||||
:session-id="session?.sessionId || ''"
|
||||
:session-loading="sessionLoading"
|
||||
:session-notice="sessionNotice"
|
||||
@create-session="createSessionFlow"
|
||||
@close-session="closeSessionFlow"
|
||||
@copy-qr="copyQrImage"
|
||||
@open-qr-preview="qrPreviewVisible = true"
|
||||
@qr-image-load="handleQrImageLoad"
|
||||
@reload-session="refreshSessionFlow"
|
||||
@switch-login-type="switchLoginType"
|
||||
/>
|
||||
|
||||
<div class="control-column">
|
||||
<TencentRedeemPanel
|
||||
:can-redeem="canRedeem"
|
||||
:can-confirm-role="canConfirmRole"
|
||||
confirm-action-label="确认当前角色"
|
||||
:confirm-action-loading="roleConfirmLoading"
|
||||
:confirm-action-visible="!roleConfirmed"
|
||||
:confirm-readonly="false"
|
||||
:hide-redeem-form="true"
|
||||
:login-type-label="loginTypeLabel"
|
||||
:max-attempts="6"
|
||||
:redeem-blocked-reason="redeemBlockedReason"
|
||||
:redeem-button-label="redeemButtonLabel"
|
||||
:redeem-code="''"
|
||||
:redeem-loading="redeemLoading"
|
||||
:role-confirmed="roleConfirmed"
|
||||
:role-facts="roleFacts"
|
||||
:role-ready="roleReady"
|
||||
:status-label="statusLabel"
|
||||
@confirm-role="confirmRoleNow"
|
||||
@redeem="redeemNow"
|
||||
@update:max-attempts="() => undefined"
|
||||
@update:redeem-code="() => undefined"
|
||||
@update:role-confirmed="() => undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="detail" class="review-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2>2. {{ reviewCardTitle }}</h2>
|
||||
<p v-if="reviewReady">
|
||||
客户登录后,系统会把当前角色截图回传到这里。客服复制后可直接发给客户做二次确认。
|
||||
</p>
|
||||
<p v-else-if="resultReady">
|
||||
最终兑换完成后,客服可以把结果截图直接发给客户确认。
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ screenshotEmptyMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
:disabled="!screenshotUrl || screenshotLoading"
|
||||
plain
|
||||
@click="copyScreenshot"
|
||||
>
|
||||
复制图片
|
||||
</el-button>
|
||||
<el-button
|
||||
:disabled="!screenshotUrl || screenshotLoading"
|
||||
plain
|
||||
@click="downloadScreenshot"
|
||||
>
|
||||
下载图片
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="screenshotUrl" class="review-stage">
|
||||
<button class="screenshot-button" type="button" @click="screenshotPreviewVisible = true">
|
||||
<img :src="screenshotUrl" alt="manual redeem screenshot" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-block">
|
||||
<strong>{{ screenshotEmptyTitle }}</strong>
|
||||
<p>{{ screenshotEmptyMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TencentResultPanel
|
||||
v-if="detail && task?.status === 'redeemed'"
|
||||
:result-facts="resultFacts"
|
||||
:screenshot-empty-message="screenshotEmptyMessage"
|
||||
:screenshot-empty-title="screenshotEmptyTitle"
|
||||
:screenshot-url="screenshotUrl"
|
||||
:show-screenshot="Boolean(screenshotUrl)"
|
||||
@open-screenshot-preview="screenshotPreviewVisible = true"
|
||||
/>
|
||||
|
||||
<section v-else-if="!detail && !detailLoading" class="empty-block">
|
||||
<strong>还没有进行中的人工兑换任务</strong>
|
||||
<p>先在上方输入唯一凭据和内部 SKU,系统会立即校验唯一性并预占库存。</p>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
:width="qrPreviewWidth"
|
||||
>
|
||||
<div class="qr-preview-body">
|
||||
<img
|
||||
class="preview-image qr-preview-image"
|
||||
:src="qrImage"
|
||||
:alt="`Admin Manual Redeem ${loginTypeLabel} QR Preview`"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="screenshotPreviewVisible"
|
||||
align-center
|
||||
class="preview-dialog"
|
||||
width="1080px"
|
||||
>
|
||||
<img class="preview-image" :src="screenshotUrl" alt="Manual redeem screenshot preview" />
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manual-redeem-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.header-actions,
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-link {
|
||||
color: #245db6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.create-card,
|
||||
.summary-card,
|
||||
.review-card {
|
||||
padding: 22px 24px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
box-shadow: 0 20px 60px rgba(30, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.card-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #203551;
|
||||
}
|
||||
|
||||
.card-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: #4a607f;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-remark {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #dbe4ee;
|
||||
background: #f8fbff;
|
||||
color: #21344d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
min-height: 96px;
|
||||
padding: 12px 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-card article {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #708096;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #203551;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 392px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-column {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.review-stage {
|
||||
margin-top: 18px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.screenshot-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.screenshot-button img,
|
||||
.preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
min-height: 180px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px dashed #d5dfec;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-block strong {
|
||||
color: #24384f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-block p {
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 1140px) {
|
||||
.summary-card,
|
||||
.layout-grid,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user