在路由系统中创建独立选择兑换文件即可,下面是代码!
优化了页面 UI
新增了卡密查询订单记录
新增了输入框失效自动格式化卡密
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<title>商品兑换 - 小氢云商城</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #f5f6fa;
--card: #fff;
--primary: #1890ff;
--primary-hover: #40a9ff;
--text: #333;
--text-muted: #666;
--border: #e8e8e8;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
--safe-top: env(safe-area-inset-top);
--safe-bottom: env(safe-area-inset-bottom);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: max(16px, var(--safe-top)) 16px max(24px, var(--safe-bottom));
-webkit-tap-highlight-color: transparent;
}
.page {
max-width: 640px;
margin: 0 auto;
}
.header {
text-align: center;
padding: 24px 0;
}
.header h1 {
font-size: 22px;
font-weight: 600;
color: var(--text);
}
/* 顶部导航栏(列表/详情页) */
.nav-bar {
display: flex;
align-items: center;
padding: 12px 0 16px;
margin-bottom: 8px;
}
.nav-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--text);
cursor: pointer;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.nav-back:hover {
color: var(--primary);
}
.nav-back svg {
width: 24px;
height: 24px;
}
.nav-title {
flex: 1;
font-size: 18px;
font-weight: 600;
color: var(--text);
text-align: center;
padding-right: 40px;
}
/* 卡密输入区 */
.card-section {
background: var(--card);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.card-section .input-wrap {
margin-bottom: 16px;
}
.card-section label {
display: block;
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
}
/* 输入框兼容:16px 防 iOS 缩放,inputmode 适配虚拟键盘 */
.form-input {
width: 100%;
padding: 12px 14px;
font-size: 16px;
line-height: 1.5;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
outline: none;
transition: border-color 0.2s;
-webkit-appearance: none;
appearance: none;
}
.form-input:focus {
border-color: var(--primary);
}
.form-input::placeholder {
color: #bbb;
}
.form-input:read-only,
.form-input:disabled {
background: #f5f5f5;
color: var(--text-muted);
}
.btn {
width: 100%;
padding: 12px 20px;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: var(--primary-hover);
}
.btn:disabled {
background: #bae7ff;
cursor: not-allowed;
}
.btn-sm {
width: auto;
padding: 8px 16px;
font-size: 14px;
}
.btn-outline {
background: #fff;
color: var(--primary);
border: 1px solid #91d5ff;
margin-top: 10px;
}
.btn-outline:hover {
background: #f0f8ff;
}
/* 商品列表 */
.goods-section {
display: none;
}
.goods-section.visible {
display: block;
}
/* 详情页(全页) */
.detail-page {
display: none;
background: var(--card);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.detail-page.visible {
display: block;
}
.goods-section h3 {
font-size: 16px;
margin-bottom: 16px;
color: var(--text);
}
.related-section {
display: none;
}
.related-section.visible {
display: block;
}
.related-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.related-item {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 14px;
border: 1px solid var(--border);
}
.related-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.related-no {
font-size: 14px;
font-weight: 600;
color: var(--text);
word-break: break-all;
}
.related-status {
font-size: 12px;
color: #1677ff;
background: #e6f4ff;
border-radius: 999px;
padding: 4px 10px;
white-space: nowrap;
}
.related-body {
display: flex;
align-items: center;
gap: 10px;
}
.related-img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 8px;
background: #f5f5f5;
flex-shrink: 0;
}
.related-main {
min-width: 0;
flex: 1;
}
.related-name {
font-size: 14px;
color: var(--text);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-time {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.goods-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.goods-item {
background: var(--card);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
cursor: pointer;
transition: transform 0.15s;
}
.goods-item:hover {
transform: translateY(-2px);
}
.goods-item:active {
transform: scale(0.98);
}
.goods-img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
background: #f0f0f0;
}
.goods-info {
padding: 12px;
}
.goods-name {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.goods-meta .remain {
color: #52c41a;
}
/* 弹窗 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-box {
width: 100%;
max-width: 500px;
max-height: 85vh;
background: var(--card);
border-radius: var(--radius) var(--radius) 0 0;
overflow-y: auto;
transform: translateY(100%);
transition: transform 0.3s;
}
.modal-overlay.active .modal-box {
transform: translateY(0);
}
.modal-header {
position: sticky;
top: 0;
background: var(--card);
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1;
}
.modal-title {
font-size: 17px;
font-weight: 600;
}
.modal-close {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--text-muted);
cursor: pointer;
}
.modal-body {
padding: 20px;
}
.detail-img {
width: 100%;
max-height: 200px;
object-fit: contain;
background: #f9f9f9;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.exchange-tag {
display: inline-block;
background: #f6ffed;
color: #52c41a;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
margin-bottom: 16px;
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
line-height: 1.4;
}
.detail-intro {
font-size: 14px;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 20px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.detail-intro img {
max-width: 100%;
height: auto;
}
.detail-intro p {
margin-bottom: 8px;
}
.detail-intro p:last-child {
margin-bottom: 0;
}
/* 表单项 */
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text);
}
.form-label .req {
color: #ff4d4f;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.radio-item {
display: flex;
align-items: center;
cursor: pointer;
}
.radio-item input {
margin-right: 6px;
width: 18px;
height: 18px;
accent-color: var(--primary);
}
.select-wrap {
position: relative;
}
.form-select {
width: 100%;
padding: 12px 14px;
font-size: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 36px;
}
.input-with-btn {
display: flex;
gap: 10px;
}
.input-with-btn .form-input {
flex: 1;
}
.input-with-btn .btn {
width: auto;
flex-shrink: 0;
}
.spec-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.spec-option {
padding: 8px 14px;
font-size: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.spec-option:hover {
border-color: var(--primary);
}
.spec-option.active {
border-color: var(--primary);
background: #e6f7ff;
color: var(--primary);
}
.spec-option.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.multi-date-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.date-row {
display: flex;
gap: 8px;
align-items: center;
}
.date-row .form-input {
flex: 1;
}
.date-row .btn-del {
width: 36px;
padding: 0;
background: #ff4d4f;
flex-shrink: 0;
}
.result-box {
margin-top: 20px;
padding: 16px;
border-radius: var(--radius-sm);
font-size: 14px;
word-break: break-all;
}
.result-box.success {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.result-box.error {
background: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.result-box .card-list {
margin-top: 12px;
}
.result-box .card-item {
padding: 8px 0;
border-bottom: 1px dashed #d9f7be;
}
.result-box .card-item:last-child {
border-bottom: none;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s;
}
.toast.show {
opacity: 1;
visibility: visible;
}
/* 课程/地址二级弹窗 */
.sub-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
.sub-modal .modal-box {
max-height: 80vh;
border-radius: var(--radius);
}
.course-item,
.addr-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.course-item:hover,
.addr-row:hover {
background: #f5f5f5;
}
.course-list,
.addr-form {
max-height: 300px;
overflow-y: auto;
}
/* 响应式:桌面端弹窗居中 */
@media (min-width: 600px) {
.modal-overlay {
align-items: center;
}
.modal-box {
max-height: 90vh;
border-radius: var(--radius);
}
}
</style>
</head>
<body>
<div id="app" class="page">
<!-- 兑换页:卡密输入 -->
<div v-show="view === 'exchange'">
<div class="header">
<h1>商品兑换</h1>
</div>
<div class="card-section">
<div class="input-wrap">
<label>兑换卡密</label>
<input type="text" class="form-input" v-model="cardSecret" placeholder="可粘贴完整兑换链接,将自动识别卡密" inputmode="text"
autocomplete="off" @blur="applyCardSecretFilter" @keyup.enter="verifyCard">
</div>
<button class="btn" :disabled="verifying" @click="verifyCard">
{{ verifying ? '验证中...' : '验证卡密' }}
</button>
<button class="btn btn-outline" :disabled="queryingOrders || verifying" @click="queryRelatedOrders">
{{ queryingOrders ? '查询中...' : '根据卡密查询关联订单' }}
</button>
</div>¬
</div>
<!-- 关联订单列表页 -->
<div class="related-section" :class="{ visible: view === 'related' }">
<div class="nav-bar">
<span class="nav-back" @click="goBack" title="返回兑换页">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</span>
<span class="nav-title">关联订单 <span v-if="relatedOrders.length">({{ relatedOrders.length }}条)</span></span>
</div>
<div class="card-section" style="margin-bottom:12px;">
<label>当前卡密</label>
<div class="form-input" style="background:#fafafa;">{{ relatedCardSecret || '-' }}</div>
</div>
<div v-if="!relatedOrders.length" class="card-section" style="text-align:center;color:#999;">未查询到关联订单</div>
<div v-else class="related-list">
<div class="related-item" v-for="item in relatedOrders" :key="item.out_trade_no">
<div class="related-head">
<span class="related-no">订单号:{{ item.out_trade_no }}</span>
<span class="related-status">{{ item.status || '未知状态' }}</span>
</div>
<div class="related-body">
<img class="related-img" :src="item.image || 'https://via.placeholder.com/80'" :alt="item.name || '商品'">
<div class="related-main">
<div class="related-name">{{ item.name || '未知商品' }}</div>
<div class="related-time">{{ item.addtime || '-' }}</div>
</div>
<button class="btn btn-sm" @click="openRelatedOrderDetail(item.out_trade_no)">查看详情</button>
</div>
</div>
</div>
</div>
<!-- 商品列表页 -->
<div class="goods-section" :class="{ visible: view === 'list' }">
<div class="nav-bar">
<span class="nav-back" @click="goBack" title="返回兑换页">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</span>
<span class="nav-title">可兑换商品 <span v-if="goodsTotal">({{ goodsTotal }} 个)</span></span>
</div>
<div class="goods-grid">
<div class="goods-item" v-for="item in goodsList" :key="item.id" @click="openDetail(item, true)">
<img :src="item.image || 'https://via.placeholder.com/200'" class="goods-img" :alt="item.name">
<div class="goods-info">
<div class="goods-name">{{ item.name }}</div>
<div class="goods-meta">
<span v-if="item.remaining_count !== undefined" class="remain">剩余 {{ item.remaining_count }}
次</span>
<span v-else>库存 {{ item.stock == -1 ? '充足' : item.stock }}</span>
</div>
</div>
</div>
</div>
<p v-if="view === 'list' && !goodsList.length" style="text-align:center;color:#999;padding:40px 0;">暂无可兑换商品
</p>
</div>
<!-- 商品详情页(全页,非弹窗) -->
<div class="detail-page" :class="{ visible: view === 'detail' }">
<div class="nav-bar">
<span class="nav-back" @click="goBack" :title="fromList ? '返回列表' : '返回兑换页'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</span>
<span class="nav-title">{{ currentGoods ? currentGoods.name : '商品详情' }}</span>
</div>
<div class="modal-body" v-if="currentGoods">
<img :src="currentGoods.image || 'https://via.placeholder.com/200'" class="detail-img"
:alt="currentGoods.name">
<h2 class="detail-name">{{ currentGoods.name }}</h2>
<div v-if="currentItem && currentItem.remaining_count !== undefined" class="exchange-tag">
剩余可兑换 {{ currentItem.remaining_count }} 次
</div>
<div v-if="currentGoods.docs" class="detail-intro" v-html="currentGoods.docs"></div>
<div v-for="(field, idx) in processedInputs" :key="idx" v-show="!field.hidden" class="form-group">
<label class="form-label">{{ field.label || '输入项' }}<span class="req">*</span></label>
<!-- types 1: 文本 -->
<input v-if="field.types === 1" type="text" class="form-input" v-model="field.value"
:placeholder="field.placeholder || '请输入'" :readonly="field.readonly" :disabled="field.readonly"
inputmode="text" autocomplete="off">
<!-- types 2: 单选 -->
<div v-else-if="field.types === 2" class="radio-group">
<label class="radio-item" v-for="opt in (field.data || [])" :key="opt.value">
<input type="radio" :name="'f'+idx" :value="opt.value" v-model="field.value">
{{ opt.label || opt.value }}
<span v-if="opt.price > 0" style="color:#ff4d4f;font-size:12px">+¥{{ opt.price }}</span>
</label>
</div>
<!-- types 3: 课程ID 只读 -->
<input v-else-if="field.types === 3" type="text" class="form-input" v-model="field.value"
placeholder="选择课程后自动填充" readonly>
<!-- types 4: 查课 -->
<div v-else-if="field.types === 4" class="input-with-btn">
<input type="text" class="form-input" v-model="field.value"
:placeholder="field.placeholder || '请输入账号'" inputmode="text"
@keyup.enter="handleQueryCourse(idx)">
<button class="btn btn-sm" :disabled="queryLoading === idx" @click="handleQueryCourse(idx)">
{{ queryLoading === idx ? '查询中' : '查课' }}
</button>
</div>
<!-- types 5: 倍数(卡密兑换固定1) -->
<input v-else-if="field.types === 5" type="number" class="form-input" v-model.number="field.value"
min="1" readonly style="background:#f5f5f5">
<!-- types 6: 邮箱 -->
<input v-else-if="field.types === 6" type="email" class="form-input" v-model="field.value"
:placeholder="field.placeholder || '请输入邮箱'" inputmode="email" autocomplete="email">
<!-- types 7: 单日期 -->
<input v-else-if="field.types === 7" type="date" class="form-input" v-model="field.value">
<!-- types 8: 多日期 -->
<div v-else-if="field.types === 8">
<div class="multi-date-list">
<div class="date-row" v-for="(d, di) in field.dateList" :key="di">
<input type="date" class="form-input" v-model="field.dateList[di]">
<button type="button" class="btn btn-sm btn-del"
@click="removeDate(field, di)">×</button>
</div>
</div>
<button type="button" class="btn btn-sm" style="margin-top:8px" @click="addDate(field)">+
添加日期</button>
</div>
<!-- types 9: 多规格 -->
<div v-else-if="field.types === 9 && field.attributes" class="spec-wrap">
<div v-for="(attr, ai) in field.attributes" :key="ai" style="margin-bottom:12px">
<div class="form-label">{{ attr.name }}</div>
<div class="spec-options">
<span v-for="v in (attr.values || [])" :key="v" class="spec-option"
:class="{ active: getSelectedAttr(field, ai) === v, disabled: !isAttrAvailable(field, ai, v) }"
@click="isAttrAvailable(field, ai, v) && selectSkuAttr(field, ai, v)">
{{ v }}
</span>
</div>
</div>
<div v-if="getSelectedSku(field)" style="margin-top:8px;font-size:13px;color:#666">
价格 ¥{{ getSelectedSku(field).price }} 库存 {{ getSelectedSku(field).stock }}
</div>
</div>
<!-- types 10: 地址 -->
<div v-else-if="field.types === 10" class="input-with-btn">
<input type="text" class="form-input" v-model="field.value"
:placeholder="field.placeholder || '请选择省市区'" readonly @click="openAddrPicker(idx)">
<button type="button" class="btn btn-sm" @click="openAddrPicker(idx)">选择</button>
</div>
<!-- types 11: 下拉 -->
<select v-else-if="field.types === 11" class="form-input form-select" v-model="field.value">
<option value="">请选择</option>
<option v-for="opt in (field.data || [])" :key="opt.value" :value="opt.value">
{{ opt.label || opt.value }}{{ opt.price > 0 ? ' (+¥' + opt.price + ')' : '' }}
</option>
</select>
<!-- types 12: 主机规格 -->
<div v-else-if="field.types === 12 && field.attributes" class="spec-wrap">
<div v-for="(attr, ai) in field.attributes" :key="ai" style="margin-bottom:12px">
<div class="form-label">{{ attr.name }}</div>
<div class="spec-options">
<span v-for="v in (attr.values || [])" :key="typeof v === 'string' ? v : v.value"
class="spec-option"
:class="{ active: getSelectedHostAttr(field, ai) === (typeof v === 'string' ? v : v.value) }"
@click="selectHostAttr(field, ai, typeof v === 'string' ? v : v.value)">
{{ typeof v === 'string' ? v : v.value }}
<span v-if="typeof v === 'object' && v.price > 0"
style="color:#ff4d4f;font-size:12px">+¥{{ v.price }}</span>
</span>
</div>
</div>
</div>
<!-- 默认文本 -->
<input v-else type="text" class="form-input" v-model="field.value"
:placeholder="field.placeholder || '请输入'" inputmode="text">
</div>
<div v-if="orderResult" class="result-box" :class="orderResult.success ? 'success' : 'error'">
<div v-html="orderResult.html"></div>
</div>
<button v-show="!orderResult || !orderResult.success" class="btn" style="margin-top:20px"
:disabled="submitting" @click="submitOrder">
{{ submitting ? '兑换中...' : '立即兑换' }}
</button>
</div>
</div>
<!-- 课程选择弹窗 -->
<div class="sub-modal" v-show="courseModal" @click.self="courseModal = false">
<div class="modal-box" style="width:100%;max-width:400px">
<div class="modal-header">
<span class="modal-title">选择课程</span>
<span class="modal-close" @click="courseModal = false">×</span>
</div>
<div class="course-list">
<div class="course-item" v-for="c in courseList" :key="c.id || c.code" @click="selectCourse(c)">
{{ c.name || c.title || c.value || JSON.stringify(c) }}
</div>
</div>
</div>
</div>
<!-- 地址填写弹窗 -->
<div class="sub-modal" v-show="addrModal" @click.self="addrModal = false">
<div class="modal-box" style="width:100%;max-width:400px">
<div class="modal-header">
<span class="modal-title">填写收货地址</span>
<span class="modal-close" @click="addrModal = false">×</span>
</div>
<div class="modal-body addr-form">
<div class="form-group">
<label class="form-label">收货人</label>
<input type="text" class="form-input" v-model="addrForm.name" placeholder="姓名" inputmode="text">
</div>
<div class="form-group">
<label class="form-label">手机号</label>
<input type="tel" class="form-input" v-model="addrForm.phone" placeholder="手机号" inputmode="tel">
</div>
<div class="form-group">
<label class="form-label">省市区</label>
<input type="text" class="form-input" v-model="addrForm.region" placeholder="如:广东省 深圳市 南山区"
inputmode="text">
</div>
<div class="form-group">
<label class="form-label">详细地址</label>
<input type="text" class="form-input" v-model="addrForm.detail" placeholder="街道门牌号"
inputmode="text">
</div>
<button class="btn" @click="confirmAddr">确认</button>
</div>
</div>
</div>
<div class="toast" :class="{ show: toastShow }">{{ toastMsg }}</div>
</div>
<script>
(function () {
const API_BASE = ''; // 同域留空,跨域填完整域名
new Vue({
el: '#app',
data: {
cardSecret: '',
verified: false,
verifying: false,
goodsList: [],
goodsTotal: 0,
view: 'exchange', // exchange | list | detail
fromList: false, // 是否从列表进入详情(用于返回时判断)
currentGoods: null,
currentItem: null,
processedInputs: [],
orderResult: null,
submitting: false,
queryLoading: -1,
queryingOrders: false,
relatedOrders: [],
relatedCardSecret: '',
courseModal: false,
courseList: [],
queryCourseIndex: -1,
addrModal: false,
addrForm: { name: '', phone: '', region: '', detail: '' },
addrInputIndex: -1,
toastMsg: '',
toastShow: false
},
mounted() {
// 整段地址里先按 CARD 规则抽卡密;没有再尝试常见 query 参数
let initial = this.extractCardSecretFromRaw(location.href);
if (!initial) {
const q = new URLSearchParams(location.search);
const secret = q.get('token') || q.get('card_secret') || q.get('cardSecret');
if (secret) {
try {
initial = this.extractCardSecretFromRaw(decodeURIComponent(secret));
} catch (e) {
initial = this.extractCardSecretFromRaw(secret);
}
}
}
if (initial) {
this.cardSecret = initial;
this.verifyCard();
}
},
methods: {
// 中文注释:从整段文本或链接提取卡密——全文匹配 CARD+字母数字;链接则遍历 query 各参数值再匹配;仍无则退回 token 等常见键;纯文本则去空白原样
extractCardSecretFromRaw(raw) {
let s = String(raw || '').trim();
if (!s) return '';
try {
s = decodeURIComponent(s);
} catch (e) { /* 非法编码时保持原样 */ }
const cardRe = /CARD[A-Za-z0-9]+/i;
let m = s.match(cardRe);
if (m) return m[0];
const isUrlLike = /^https?:\/\//i.test(s) || (s.includes('?') && (s.includes('=') || s.includes('/')));
if (isUrlLike) {
try {
const withProto = /^https?:\/\//i.test(s) ? s : ('https://' + s);
const u = new URL(withProto);
const q2 = new URLSearchParams(u.search);
for (const [, v] of q2) {
let dec = String(v);
try {
dec = decodeURIComponent(String(v).replace(/\+/g, ' '));
} catch (e2) { /* ignore */ }
m = dec.match(cardRe);
if (m) return m[0];
}
const legacyVal = q2.get('token') || q2.get('card_secret') || q2.get('cardSecret');
if (legacyVal) {
try {
return String(decodeURIComponent(String(legacyVal).replace(/\+/g, ' '))).trim();
} catch (e3) {
return String(legacyVal).trim();
}
}
return '';
} catch (e3) {
return '';
}
}
return s;
},
// 中文注释:失焦、验证、查单前调用,把识别结果写回输入框
applyCardSecretFilter() {
this.cardSecret = this.extractCardSecretFromRaw(this.cardSecret);
},
toast(msg) {
this.toastMsg = msg;
this.toastShow = true;
setTimeout(() => { this.toastShow = false; }, 2000);
},
async api(path, method, data) {
const opts = { method: method || 'GET', headers: { 'Content-Type': 'application/json' } };
if (method === 'GET' && data) {
path += '?' + new URLSearchParams(data).toString();
} else if (data && method !== 'GET') {
opts.body = JSON.stringify(data);
}
const res = await fetch((API_BASE || '') + path, opts);
return res.json();
},
async verifyCard() {
this.applyCardSecretFilter();
const s = (this.cardSecret || '').trim();
if (!s) { this.toast('请输入卡密'); return; }
this.verifying = true;
try {
const res = await this.api('/api/verifyCardSecret', 'GET', { cardSecret: s, page: 1, size: 50 });
if (res && res.code === 200) {
this.verified = true;
this.goodsList = res.data && res.data.goods_list ? res.data.goods_list : (Array.isArray(res.data) ? res.data : []);
this.goodsTotal = (res.data && res.data.total) || this.goodsList.length;
this.toast('验证成功');
if (this.goodsList.length === 1) {
this.fromList = false;
await this.openDetail(this.goodsList[0], false);
} else {
this.view = 'list';
}
} else {
this.toast(res ? res.msg : '验证失败');
}
} catch (e) {
this.toast('网络请求失败');
} finally {
this.verifying = false;
}
},
goBack() {
if (this.view === 'list' || this.view === 'related') {
this.view = 'exchange';
} else if (this.view === 'detail') {
this.view = this.fromList ? 'list' : 'exchange';
this.currentGoods = null;
this.currentItem = null;
this.orderResult = null;
}
},
normalizeRelatedOrders(list) {
const arr = Array.isArray(list) ? list : [];
const seen = new Set();
return arr.map(item => {
const orderNo = item && item.order_no ? String(item.order_no).trim() : '';
if (!orderNo || seen.has(orderNo)) return null;
seen.add(orderNo);
return {
out_trade_no: orderNo,
name: item.name || item.goods_name || '',
image: item.image || '',
status: item.status || '未知状态',
addtime: item.addtime || item.exchanged_at || ''
};
}).filter(Boolean);
},
async queryRelatedOrders() {
this.applyCardSecretFilter();
const s = (this.cardSecret || '').trim();
if (!s) {
this.toast('请先输入兑换卡密');
return;
}
this.queryingOrders = true;
try {
const res = await this.api('/api/queryOrderByCardSecret', 'POST', { cardSecret: s });
const list = this.normalizeRelatedOrders(res && res.data ? res.data.order_list : []);
if (res && res.code === 200 && list.length > 0) {
this.relatedCardSecret = s;
this.relatedOrders = list;
this.view = 'related';
this.toast('查询成功');
} else {
this.relatedCardSecret = s;
this.relatedOrders = [];
this.view = 'related';
this.toast((res && res.msg) || '未查询到关联订单');
}
} catch (e) {
this.toast('查询失败,请稍后重试');
} finally {
this.queryingOrders = false;
}
},
openRelatedOrderDetail(orderNo) {
if (!orderNo) return;
const url = '/static/pay-success.html?out_trade_no=' + encodeURIComponent(orderNo);
window.open(url, '_blank');
},
async openDetail(item, fromList) {
this.fromList = !!fromList;
this.currentItem = item;
this.applyCardSecretFilter();
const id = item.id || item.gid;
try {
const res = await this.api('/api/getGoodsById', 'GET', { id, cardSecret: (this.cardSecret || '').trim() });
if (res && res.code === 200 && res.data) {
this.currentGoods = res.data;
this.orderResult = null;
this.processInputs(res.data);
this.view = 'detail';
} else {
this.toast('获取商品详情失败');
}
} catch (e) {
this.toast('网络请求失败');
}
},
processInputs(detail) {
let list = detail.input || [];
if (!Array.isArray(list)) list = [];
this.processedInputs = list.map((item, i) => {
const t = item.types;
const label = item.label || (item.Data && (Array.isArray(item.Data) ? item.Data[0] : item.Data)) || '';
const base = {
...item,
label,
value: item.value !== undefined && item.value !== null && item.value !== '' ? item.value : (item.default_value || (t === 5 ? 1 : '')),
_index: i
};
if (t === 5) base.value = 1;
if (t === 8) {
const num = item.num || 1;
const arr = (base.value && String(base.value).split(',').filter(Boolean)) || [];
base.dateList = arr.length >= num ? arr : [...arr, ...Array(Math.max(0, num - arr.length)).fill('')];
}
if (t === 9 && item.attributes) {
base.selectedAttrs = (item.attributes || []).map(() => '');
if (base.value) {
const parts = String(base.value).split('_').filter(Boolean);
parts.forEach((p, j) => { if (base.selectedAttrs[j] !== undefined) base.selectedAttrs[j] = p; });
}
}
if (t === 12 && item.attributes) {
base.selectedHostAttrs = (item.attributes || []).map(() => '');
if (base.value) {
const parts = String(base.value).split('_').filter(Boolean);
parts.forEach((p, j) => { if (base.selectedHostAttrs[j] !== undefined) base.selectedHostAttrs[j] = p; });
}
}
return base;
});
},
getSelectedAttr(field, attrIndex) {
return (field.selectedAttrs && field.selectedAttrs[attrIndex]) || '';
},
getSelectedHostAttr(field, attrIndex) {
return (field.selectedHostAttrs && field.selectedHostAttrs[attrIndex]) || '';
},
isAttrAvailable(field, attrIndex, val) {
const skus = field.skus || [];
if (skus.length === 0) return true;
const attrs = [...(field.selectedAttrs || [])];
attrs[attrIndex] = val;
const key = attrs.join('_');
return skus.some(s => (s.attrs || []).join('_') === key && (s.stock || 0) > 0);
},
getSelectedSku(field) {
const key = (field.selectedAttrs || []).join('_');
return (field.skus || []).find(s => (s.attrs || []).join('_') === key);
},
selectSkuAttr(field, attrIndex, val) {
if (!field.selectedAttrs) field.selectedAttrs = [];
this.$set(field.selectedAttrs, attrIndex, val);
field.value = field.selectedAttrs.join('_');
},
selectHostAttr(field, attrIndex, val) {
if (!field.selectedHostAttrs) field.selectedHostAttrs = [];
this.$set(field.selectedHostAttrs, attrIndex, val);
field.value = field.selectedHostAttrs.join('_');
},
addDate(field) {
if (!field.dateList) field.dateList = [];
field.dateList.push('');
},
removeDate(field, i) {
if (field.dateList && field.dateList.length > 1) field.dateList.splice(i, 1);
},
async handleQueryCourse(idx) {
if (!this.currentGoods) return;
const params = { pt: this.currentGoods.id };
this.processedInputs.forEach((f, i) => {
if (f.types === 2 || f.types === 11) params[i] = f.value;
else if (f.types === 8) params[i] = (f.dateList || []).filter(Boolean).join(',');
else params[i] = f.value || '';
});
this.queryLoading = idx;
try {
const res = await this.api('/api/getWk', 'POST', params);
if (res && res.code === 200 && Array.isArray(res.data)) {
this.courseList = res.data;
this.queryCourseIndex = idx;
this.courseModal = true;
} else {
this.toast(res ? res.msg : '未找到课程');
}
} catch (e) {
this.toast('查询失败');
} finally {
this.queryLoading = -1;
}
},
selectCourse(course) {
const type3 = this.processedInputs.find(f => f.types === 3);
if (type3) {
const v = course.id || course.code || course.value || '';
type3.value = type3.value ? type3.value + ',' + v : v;
}
this.courseModal = false;
this.toast('已选择课程');
},
openAddrPicker(idx) {
this.addrInputIndex = idx;
const f = this.processedInputs[idx];
if (f && f.value) {
const v = String(f.value).trim();
if (v.includes(',')) {
const parts = v.split(',').map(s => s.trim());
this.addrForm = { name: parts[0] || '', phone: parts[1] || '', region: (parts[2] || '') + (parts[3] ? ' ' + parts[3] : ''), detail: parts.slice(4).join(' ').trim() || '' };
} else {
this.addrForm = { name: '', phone: '', region: v || '', detail: '' };
}
} else {
this.addrForm = { name: '', phone: '', region: '', detail: '' };
}
this.addrModal = true;
},
confirmAddr() {
const a = this.addrForm;
const full = [a.name, a.phone, a.region, a.detail].filter(Boolean).join(', ');
if (this.processedInputs[this.addrInputIndex]) {
this.processedInputs[this.addrInputIndex].value = full;
}
this.addrModal = false;
this.toast('地址已填写');
},
collectInputData() {
const data = {};
this.processedInputs.forEach((f, i) => {
if (f.hidden) return;
if (f.types === 8) {
data[i] = (f.dateList || []).filter(Boolean).join(',');
} else {
data[i] = f.value !== undefined && f.value !== null ? String(f.value) : '';
}
});
return data;
},
async submitOrder() {
if (!this.currentGoods) return;
const inputData = this.collectInputData();
const empty = Object.entries(inputData).some(([k, v]) => {
const f = this.processedInputs[parseInt(k)];
return f && !f.hidden && (v === '' || v === null || v === undefined);
});
if (empty) {
this.toast('请填写所有必填项');
return;
}
this.applyCardSecretFilter();
this.submitting = true;
const payload = {
pt: this.currentGoods.id,
num: 1,
pay: 'card_exchange',
type: 3,
money_token: (this.cardSecret || '').trim(),
...inputData
};
try {
const res = await this.api('/api/add', 'POST', payload);
if (res && (res.code === 200 || res.code === 3 || res.code === 4)) {
let html = '<strong>兑换成功!</strong>';
if (res.data && res.data.cards && Array.isArray(res.data.cards)) {
html += '<div class="card-list">';
res.data.cards.forEach((c, i) => {
html += '<div class="card-item">卡密' + (i + 1) + ':' + c + '</div>';
});
html += '</div>';
} else if (res.data && typeof res.data === 'string') {
html += '<div class="card-list"><div class="card-item">' + res.data + '</div></div>';
} else if (res.msg) {
html += '<br>' + res.msg;
}
this.orderResult = { success: true, html };
if (this.currentItem && this.currentItem.remaining_count !== undefined) {
this.currentItem.remaining_count = Math.max(0, this.currentItem.remaining_count - 1);
}
} else {
this.orderResult = { success: false, html: (res && res.msg) || '兑换失败,请重试' };
}
} catch (e) {
this.orderResult = { success: false, html: '网络请求失败' };
} finally {
this.submitting = false;
}
}
}
});
})();
</script>
</body>
</html>