Files
order_site/apps/frontend/src/views/admin/AdminManualRedeemView.vue
T
2026-04-23 13:38:21 +08:00

512 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>