Files
order_site/apps/frontend/src/views/admin/AdminTaskDetailView.vue
T
2026-04-14 17:30:36 +08:00

738 lines
29 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, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
import {
closeAdminTask,
confirmAdminTaskAssistedRole,
completeAdminTaskManualDispatch,
redeemAdminTaskAssisted,
fetchAdminTaskScreenshot,
fetchAdminTaskDetail,
markAdminTaskManualReview,
regenerateAdminTaskClaimLink,
releaseAdminTaskInventoryBinding,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatTaskEventType } from '@/utils/admin-display'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute()
const loading = ref(true)
const actionLoading = ref(false)
const errorMessage = ref('')
const detail = ref<AdminTaskDetail | null>(null)
const lastClaimUrl = ref('')
const screenshotPreviewUrl = ref('')
const manualDispatchForm = reactive({
deliveryReference: '',
deliveredCredential: '',
resultMessage: '',
})
const canOperateTasks = computed(() => hasAdminRole('operator'))
const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData))
const claimUrl = computed(() => lastClaimUrl.value || detail.value?.claimToken?.claimUrl || '')
const screenshotSectionTitle = computed(() => {
if (detail.value?.task.status === 'redeemed') {
return '结果截图'
}
return detail.value?.review.required ? '客服复核截图' : '结果截图'
})
async function loadDetail() {
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminTaskDetail(String(route.params.taskId || ''))
detail.value = response.data
syncManualDispatchForm(response.data)
await loadScreenshotPreview(response.data)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败'
} finally {
loading.value = false
}
}
async function runAction(
action: () => Promise<{ data: AdminTaskActionResponse }>,
successMessage: string,
confirmText: string,
) {
try {
await showConfirm(confirmText, '确认操作', {
type: 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
})
} catch {
return
}
actionLoading.value = true
try {
const response = await action()
if (response.data.claimUrl) {
lastClaimUrl.value = response.data.claimUrl
}
showSuccess(successMessage)
await loadDetail()
} catch (error) {
showError(error instanceof Error ? error.message : '操作失败')
} finally {
actionLoading.value = false
}
}
async function loadScreenshotPreview(taskDetail: AdminTaskDetail) {
clearScreenshotPreview()
if (!taskDetail.screenshotUrl) {
return
}
try {
const blob = await fetchAdminTaskScreenshot(taskDetail.task.taskId)
screenshotPreviewUrl.value = URL.createObjectURL(blob)
} catch {
screenshotPreviewUrl.value = ''
}
}
function clearScreenshotPreview() {
if (screenshotPreviewUrl.value) {
URL.revokeObjectURL(screenshotPreviewUrl.value)
screenshotPreviewUrl.value = ''
}
}
function syncManualDispatchForm(taskDetail: AdminTaskDetail) {
manualDispatchForm.deliveryReference = taskDetail.manualDispatch?.deliveryReference || ''
manualDispatchForm.deliveredCredential = taskDetail.manualDispatch?.deliveredCredential || ''
manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || ''
}
async function submitManualDispatch(outcome: 'delivered' | 'failed') {
if (!detail.value) {
return
}
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
const confirmText = outcome === 'failed'
? `确认把任务 ${detail.value.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
: `确认把任务 ${detail.value.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
await runAction(
() => completeAdminTaskManualDispatch(detail.value!.task.taskId, {
outcome,
resultMessage: manualDispatchForm.resultMessage,
deliveryReference: manualDispatchForm.deliveryReference,
deliveredCredential: manualDispatchForm.deliveredCredential,
}),
actionLabel,
confirmText,
)
}
function formatTaskEventPayload(payload: Record<string, unknown>) {
const parts = [
payload.outcome ? `结果 ${payload.outcome}` : '',
payload.resultCode ? `代码 ${payload.resultCode}` : '',
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
payload.codeMasked ? `凭据 ${payload.codeMasked}` : '',
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '',
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '',
payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : '',
payload.previousInventoryItemId ? `旧库存 #${payload.previousInventoryItemId}` : '',
payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : '',
payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
payload.trigger ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ''))}` : '',
payload.reason ? `原因 ${payload.reason}` : '',
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
payload.requestId ? `请求 ${payload.requestId}` : '',
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
].filter(Boolean)
if (parts.length > 0) {
return parts.join(' · ')
}
return JSON.stringify(payload || {})
}
function formatRedeemOutcomeLabel(value: string) {
const normalized = String(value || '').trim()
switch (normalized) {
case 'success':
return '兑换成功'
case 'code_used':
return '兑换码已使用'
case 'code_invalid':
return '兑换码错误'
case 'captcha_rejected':
return '验证码错误'
case 'failed':
return '兑换失败'
default:
return normalized || '-'
}
}
function formatRedeemResolutionStatus(value: string) {
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : (value || '-')
}
function formatAutoDeliveryTrigger(value: string) {
const normalized = String(value || '').trim()
switch (normalized) {
case 'claim_redeemed':
return '用户兑换完成后触发'
case 'manual_dispatch_completed':
return '人工履约完成后触发'
case 'task_delivered':
return '任务交付完成后触发'
default:
return normalized || '-'
}
}
function formatAutoDeliveryReason(value: string) {
const normalized = String(value || '').trim()
switch (normalized) {
case 'waiting_other_tasks':
return '订单下仍有任务未完成交付'
case 'already_sent':
return '该订单已经执行过自动发货'
case 'auto_delivery_disabled':
return '自动发货开关已关闭'
case 'missing_config':
return '缺少 Agiso 自动发货配置'
case 'request_failed':
return 'Agiso 接口返回失败'
case 'request_error':
return '请求 Agiso 接口时发生异常'
case 'delivery_not_confirmed':
return '接口返回成功,但订单状态未确认进入已发货'
case 'task_not_delivered':
return '当前任务尚未进入已交付状态'
case 'not_supported':
return '当前订单不支持 Agiso 咸鱼自动发货'
default:
return normalized || '-'
}
}
function resolveTaskAutoDeliveryStatus(taskDetail: AdminTaskDetail) {
if (taskDetail.task.agisoAutoDelivery?.status) {
return taskDetail.task.agisoAutoDelivery.status
}
return taskDetail.task.deliveryStatus === 'delivered' ? 'pending' : 'waiting'
}
function resolveTaskAutoDeliverySummary(taskDetail: AdminTaskDetail) {
const autoDelivery = taskDetail.task.agisoAutoDelivery
if (autoDelivery?.errorMessage) {
return autoDelivery.errorMessage
}
if (autoDelivery?.reason) {
return formatAutoDeliveryReason(autoDelivery.reason)
}
return taskDetail.task.deliveryStatus === 'delivered'
? '当前任务已交付,等待订单下其他任务完成后统一自动发货'
: '当前任务尚未完成交付,暂不会触发自动发货'
}
onMounted(loadDetail)
onBeforeUnmount(clearScreenshotPreview)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>任务详情</h1>
<p>查看任务全量信息并执行人工操作</p>
</div>
</header>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">任务详情加载中</div>
<template v-else-if="detail">
<section class="info-card">
<h2>{{ detail.task.taskNo }}</h2>
<p>状态<AdminStatusTag :status="detail.task.status" /> · 订单{{ detail.order?.platformOrderId || '-' }}</p>
<p>执行器{{ detail.task.executorKey || '-' }} · 履约<AdminStatusTag :status="detail.task.deliveryStatus" /></p>
<p>系统绑定<AdminStatusTag :status="detail.task.systemBindingStatus" /> · 完整绑定<AdminStatusTag :status="detail.task.userBindingStatus" /></p>
<p>角色{{ detail.task.roleName || '-' }} / {{ detail.task.roleId || '-' }}</p>
<p>最后错误{{ detail.task.lastError || '-' }}</p>
<p v-if="claimUrl">
领取链接
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
</p>
</section>
<section class="action-row">
<el-button
:disabled="!detail.operations.canRetry"
:loading="actionLoading"
round
type="primary"
@click="runAction(() => retryAdminTask(detail!.task.taskId), '任务已重试', `确认重试任务 ${detail!.task.taskNo} 吗?`)"
>
重试任务
</el-button>
<el-button
v-if="detail.operations.canRegenerateClaimLink"
:disabled="!detail.operations.canRegenerateClaimLink"
:loading="actionLoading"
round
@click="runAction(() => regenerateAdminTaskClaimLink(detail!.task.taskId), '领取链接已重新生成', `确认重新生成任务 ${detail!.task.taskNo} 的领取链接吗?旧链接会失效。`)"
>
重发链接
</el-button>
<el-button
v-if="detail.operations.canSupportConfirmRole"
:loading="actionLoading"
round
type="warning"
@click="runAction(
() => confirmAdminTaskAssistedRole(detail!.task.taskId),
'角色已确认',
`确认以客服身份锁定任务 ${detail!.task.taskNo} 当前识别到的角色信息吗?`
)"
>
客服确认角色
</el-button>
<el-button
v-if="detail.operations.canSupportRedeem"
:loading="actionLoading"
round
type="success"
@click="runAction(
() => redeemAdminTaskAssisted(detail!.task.taskId),
'兑换任务已启动',
`确认开始执行任务 ${detail!.task.taskNo} 的自动兑换吗?`
)"
>
客服开始兑换
</el-button>
<el-button
v-if="canOperateTasks"
:disabled="!detail.operations.canMarkManualReview"
:loading="actionLoading"
round
@click="runAction(() => markAdminTaskManualReview(detail!.task.taskId), '任务已转人工处理', `确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`)"
>
转人工
</el-button>
<el-button
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="success"
@click="submitManualDispatch('delivered')"
>
人工完成
</el-button>
<el-button
v-if="canOperateTasks && detail.operations.canCompleteManualDispatch"
:loading="actionLoading"
round
type="danger"
plain
@click="submitManualDispatch('failed')"
>
履约失败
</el-button>
<el-button
v-if="canOperateTasks"
:disabled="!detail.operations.canClose"
:loading="actionLoading"
round
type="danger"
@click="runAction(() => closeAdminTask(detail!.task.taskId), '任务已关闭', `确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`)"
>
关闭任务
</el-button>
</section>
<section class="table-card">
<h3>任务信息</h3>
<table class="data-table">
<tbody>
<tr><th>商品</th><td>{{ detail.orderItem?.skuName || '-' }}</td></tr>
<tr><th>SKU</th><td>{{ detail.orderItem?.skuCode || '-' }}</td></tr>
<tr><th>任务执行器</th><td>{{ detail.task.executorKey || '-' }}</td></tr>
<tr><th>履约状态</th><td><AdminStatusTag :status="detail.task.deliveryStatus || ''" /></td></tr>
<tr><th>结果代码</th><td>{{ detail.task.resultCode || '-' }}</td></tr>
<tr><th>结果说明</th><td>{{ detail.task.resultMessage || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>库存凭据</th><td>{{ detail.inventory?.displayValue || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>凭据类型</th><td>{{ detail.inventory?.credentialType || '-' }}</td></tr>
<tr v-if="canViewSensitiveTaskData"><th>库存状态</th><td><AdminStatusTag :status="detail.inventory?.status || ''" /></td></tr>
<tr v-if="canViewSensitiveTaskData"><th>Claim Token</th><td>{{ detail.claimToken?.token || '-' }}</td></tr>
<tr v-else><th>库存 / Token</th><td>客服账号不可查看库存凭据与原始领取 Token</td></tr>
<tr><th>Token 状态</th><td><AdminStatusTag :status="detail.claimToken?.status || ''" /></td></tr>
<tr><th>Token 过期</th><td>{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}</td></tr>
<tr>
<th>领取链接</th>
<td>
<a v-if="claimUrl" class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
<span v-else>-</span>
</td>
</tr>
<tr><th>浏览器会话</th><td>{{ detail.task.browserSessionId || '-' }}</td></tr>
<tr><th>用户打开链接</th><td>{{ formatAdminDateTime(detail.task.claimedAt) }}</td></tr>
<tr><th>用户提交绑定</th><td>{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}</td></tr>
<tr><th>完整绑定成功</th><td>{{ formatAdminDateTime(detail.task.redeemedAt) }}</td></tr>
<tr><th>截图路径</th><td>{{ detail.task.screenshotPath || '-' }}</td></tr>
</tbody>
</table>
</section>
<section
v-if="detail.order?.provider === 'agiso' && detail.order?.platform === 'xianyu'"
class="table-card"
>
<h3>咸鱼自动发货</h3>
<p class="section-copy">这里展示这条任务最近一次触发咸鱼自动发货的状态原因和接口回执摘要</p>
<div class="redeem-summary-grid">
<article class="redeem-summary-card">
<span>当前状态</span>
<div class="auto-delivery-tag-line">
<AdminStatusTag :status="resolveTaskAutoDeliveryStatus(detail)" />
</div>
</article>
<article class="redeem-summary-card">
<span>触发来源</span>
<strong>{{ formatAutoDeliveryTrigger(detail.task.agisoAutoDelivery?.trigger || '') }}</strong>
</article>
<article class="redeem-summary-card">
<span>最近执行</span>
<strong>{{ formatAdminDateTime(detail.task.agisoAutoDelivery?.updatedAt) }}</strong>
</article>
<article class="redeem-summary-card">
<span>接口回执</span>
<strong>
{{ detail.task.agisoAutoDelivery?.requestId || (detail.task.agisoAutoDelivery?.responseStatus ? `HTTP ${detail.task.agisoAutoDelivery.responseStatus}` : '-') }}
</strong>
</article>
</div>
<div class="auto-delivery-note">
<p>{{ resolveTaskAutoDeliverySummary(detail) }}</p>
<p v-if="detail.task.agisoAutoDelivery?.reason">原因{{ formatAutoDeliveryReason(detail.task.agisoAutoDelivery.reason) }}</p>
<p v-if="detail.task.agisoAutoDelivery?.responseStatus">HTTP{{ detail.task.agisoAutoDelivery.responseStatus }}</p>
<p v-if="detail.task.agisoAutoDelivery?.requestId">请求 ID{{ detail.task.agisoAutoDelivery.requestId }}</p>
</div>
</section>
<section v-if="detail.redeemResolution" class="table-card">
<h3>兑换重试链路</h3>
<p class="section-copy">这里记录自动兑换时每次使用过的 CDK判定结果以及是否切换到了新的同类型凭据</p>
<div class="redeem-summary-grid">
<article class="redeem-summary-card">
<span>处理结果</span>
<strong>{{ formatRedeemResolutionStatus(detail.redeemResolution.status) }}</strong>
</article>
<article class="redeem-summary-card">
<span>任务收口状态</span>
<strong>{{ detail.redeemResolution.taskStatus || detail.task.status || '-' }}</strong>
</article>
<article class="redeem-summary-card">
<span>更换新码次数</span>
<strong>{{ detail.redeemResolution.replacementCount }}</strong>
</article>
<article class="redeem-summary-card">
<span>完成时间</span>
<strong>{{ formatAdminDateTime(detail.redeemResolution.finishedAt) }}</strong>
</article>
</div>
<div v-if="detail.redeemResolution.attempts.length > 0" class="redeem-attempts">
<article
v-for="attempt in detail.redeemResolution.attempts"
:key="`${attempt.attempt}-${attempt.inventoryItemId || 'na'}`"
class="redeem-attempt-card"
>
<div class="redeem-attempt-head">
<strong> {{ attempt.attempt }} 次尝试</strong>
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
</div>
<div class="redeem-attempt-grid">
<div>
<span>库存项</span>
<strong>{{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }}</strong>
</div>
<div>
<span>凭据</span>
<strong>{{ attempt.codeMasked || '-' }}</strong>
</div>
<div>
<span>类型</span>
<strong>{{ attempt.credentialType || '-' }}</strong>
</div>
<div>
<span>结果码</span>
<strong>{{ attempt.resultCode || '-' }}</strong>
</div>
</div>
<p class="redeem-attempt-copy">{{ attempt.resultMessage || '-' }}</p>
</article>
</div>
</section>
<section class="table-card">
<h3>库存绑定</h3>
<p class="section-copy">这里展示任务当前和历史绑定过的库存项便于核对多库存任务的真实履约轨迹</p>
<table class="data-table">
<thead>
<tr>
<th>绑定</th>
<th>角色</th>
<th>库存项</th>
<th>状态</th>
<th>凭据内容</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="detail.inventoryBindings.length === 0">
<td colspan="7" class="table-empty">当前任务还没有库存绑定记录</td>
</tr>
<tr v-for="binding in detail.inventoryBindings" :key="binding.bindingId">
<td>
<div>#{{ binding.bindingId }}</div>
<div v-if="binding.isPrimary" class="binding-primary">主库存项</div>
</td>
<td>
<div>{{ binding.roleKey || '-' }}</div>
<div class="binding-meta">数量 {{ binding.quantity }}</div>
</td>
<td>
<div>ID {{ binding.inventoryItemId }}</div>
<div>{{ binding.skuCode || '-' }}</div>
<div class="binding-meta">{{ binding.credentialType || '-' }} · {{ binding.batchNo || '无批次' }}</div>
</td>
<td>
<div class="status-stack">
<AdminStatusTag :status="binding.bindingStatus" />
<AdminStatusTag :status="binding.inventoryStatus" />
</div>
<div v-if="binding.invalidReason" class="binding-meta">
{{ binding.inventoryStatus === 'invalid' ? '作废原因' : binding.inventoryStatus === 'consumed' ? '消耗原因' : '处理原因' }}{{ binding.invalidReason }}
</div>
</td>
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
<td>
<div>绑定 {{ formatAdminDateTime(binding.createdAt) }}</div>
<div class="binding-meta">消费 {{ formatAdminDateTime(binding.consumedAt) }}</div>
<div class="binding-meta">释放 {{ formatAdminDateTime(binding.releasedAt) }}</div>
</td>
<td>
<el-button
v-if="hasAdminRole('admin') && binding.canRelease"
:loading="actionLoading"
link
@click="runAction(
() => releaseAdminTaskInventoryBinding(detail!.task.taskId, binding.bindingId),
'库存绑定已释放',
`确认释放绑定 #${binding.bindingId} 对应的库存项 ${binding.displayValue || binding.inventoryItemId} 吗?`
)"
>
释放绑定
</el-button>
<span v-else class="binding-meta">-</span>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="detail.task.executorKey === 'manual_dispatch'" class="table-card">
<h3>人工履约回写</h3>
<div class="manual-grid">
<label class="field-block">
<span>外部流水 / 单号</span>
<el-input
v-model="manualDispatchForm.deliveryReference"
:disabled="!canOperateTasks"
maxlength="120"
placeholder="例如快递单号、平台消息回执号"
/>
</label>
<label class="field-block">
<span>已交付内容</span>
<el-input
v-model="manualDispatchForm.deliveredCredential"
:disabled="!canOperateTasks"
maxlength="500"
placeholder="可填写人工发送的卡密、链接或关键信息"
type="textarea"
:rows="3"
/>
</label>
<label class="field-block">
<span>处理备注</span>
<el-input
v-model="manualDispatchForm.resultMessage"
:disabled="!canOperateTasks"
maxlength="500"
placeholder="说明实际履约结果,失败时建议写清原因"
type="textarea"
:rows="4"
/>
</label>
</div>
<p class="manual-hint">回写后会直接更新任务终态不再走旧式领取链接流程</p>
<div v-if="detail.manualDispatch" class="manual-summary">
<p>最近回写<AdminStatusTag :status="detail.manualDispatch.outcome || detail.task.deliveryStatus" /> · {{ formatAdminDateTime(detail.manualDispatch.completedAt) }}</p>
<p>处理人{{ detail.manualDispatch.completedBy?.username || '-' }} / {{ detail.manualDispatch.completedBy?.role || '-' }}</p>
<p>流水号{{ detail.manualDispatch.deliveryReference || '-' }}</p>
<p>交付内容{{ detail.manualDispatch.deliveredCredential || '-' }}</p>
<p>备注{{ detail.manualDispatch.resultMessage || '-' }}</p>
</div>
</section>
<section v-if="detail.events.length > 0" class="table-card">
<h3>任务事件</h3>
<table class="data-table">
<tbody>
<tr v-for="event in detail.events" :key="event.eventId">
<th>{{ formatTaskEventType(event.eventType) }}</th>
<td>
<div class="event-copy">{{ formatTaskEventPayload(event.payload) }}</div>
<div class="event-time">{{ formatAdminDateTime(event.createdAt) }}</div>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="detail.screenshotUrl" class="table-card">
<h3>{{ screenshotSectionTitle }}</h3>
<p v-if="!screenshotPreviewUrl" class="screenshot-empty">截图加载中或当前不可读取</p>
<a v-else class="screenshot-link" :href="screenshotPreviewUrl" target="_blank" rel="noreferrer">打开原图</a>
<img v-if="screenshotPreviewUrl" class="screenshot-preview" :src="screenshotPreviewUrl" :alt="screenshotSectionTitle" />
</section>
</template>
</section>
</template>
<style scoped>
.admin-panel { display: grid; gap: 16px; }
.panel-header h1 { margin: 0; color: #1d3555; }
.panel-header p { margin: 8px 0 0; color: #64748b; }
.claim-link { color: #175cd3; text-decoration: none; word-break: break-all; }
.info-card,.table-card,.empty-block,.error-copy {
padding: 18px; border-radius: 20px; background: rgba(255,255,255,.94); border: 1px solid rgba(86,108,138,.1);
}
.error-copy { color: #b42318; }
.action-row { display: flex; flex-wrap: wrap; gap: 12px; }
.table-card h3 { margin: 0 0 12px; color: #1d3555; }
.section-copy { margin: 0 0 12px; color: #64748b; }
.auto-delivery-tag-line { display: flex; align-items: center; }
.auto-delivery-note {
margin-top: 14px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(248,250,252,.92);
border: 1px solid rgba(86,108,138,.08);
}
.auto-delivery-note p { margin: 0 0 8px; color: #334155; }
.auto-delivery-note p:last-child { margin-bottom: 0; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); }
.data-table th { width: 160px; color: #64748b; }
.status-stack { display: flex; flex-wrap: wrap; gap: 8px; }
.redeem-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.redeem-summary-card,
.redeem-attempt-card {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(86,108,138,.1);
background: rgba(248,250,252,.92);
}
.redeem-summary-card span,
.redeem-attempt-grid span {
display: block;
color: #64748b;
font-size: 12px;
margin-bottom: 6px;
}
.redeem-summary-card strong,
.redeem-attempt-grid strong {
color: #1d3555;
}
.redeem-attempts {
display: grid;
gap: 12px;
}
.redeem-attempt-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.redeem-outcome {
color: #175cd3;
font-size: 13px;
font-weight: 600;
}
.redeem-attempt-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-top: 12px;
}
.redeem-attempt-copy {
margin: 12px 0 0;
color: #334155;
line-height: 1.6;
}
.table-empty { text-align: center; color: #64748b; }
.binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; }
.binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }
.manual-grid { display: grid; gap: 14px; }
.field-block { display: grid; gap: 8px; color: #1d3555; font-weight: 600; }
.manual-hint { margin: 14px 0 0; color: #64748b; }
.manual-summary { margin-top: 16px; padding: 14px; border-radius: 16px; background: rgba(248,250,252,.9); }
.manual-summary p { margin: 0 0 8px; color: #334155; }
.manual-summary p:last-child { margin-bottom: 0; }
.event-copy { color: #334155; }
.event-time { margin-top: 6px; color: #64748b; font-size: 12px; }
.screenshot-link { display: inline-block; margin-bottom: 12px; color: #175cd3; text-decoration: none; }
.screenshot-empty { margin: 0 0 12px; color: #64748b; }
.screenshot-preview {
display: block;
width: min(100%, 720px);
border-radius: 18px;
border: 1px solid rgba(86,108,138,.12);
box-shadow: 0 20px 48px rgba(15,23,42,.08);
}
</style>