Aツ韩先生
Aツ韩先生
发布于 2026-03-25 / 0 阅读
0
0

小氢云商城系统独立兑换页代码

在路由系统中创建独立选择兑换文件即可,下面是代码!

  • 优化了页面 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">&times;</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">&times;</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>


评论