512 lines
12 KiB
Vue
512 lines
12 KiB
Vue
<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.inventoryGroupCode}-${item.skuCode}`"
|
||
:value="item.skuCode"
|
||
:label="`${item.skuCode} · ${item.inventoryGroupCode || '未分组'} · 可用 ${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>
|