盛果智能应用软件 V1.0 源代码文档

软件全称：盛果智能应用软件
软件简称：盛果
版本号：V1.0

# 前端源代码

// ====== pages/create/index.vue ======
<template>
  <view class="page">
    <!-- 主内容区：只有文本输入 -->
    <view class="main-content">
      <!-- 文本输入 -->
      <view class="input-area">
        <textarea
          v-model="text"
          class="text-input"
          placeholder="请输入要转换的文本内容..."
          :maxlength="2000"
          auto-height
        />
        <!-- 字数统计 -->
        <text class="word-count" :class="{ warning: text.length > 1800, error: text.length >= 2000 }">
          {{ text.length }}/2000
        </text>

        <!-- 已上传文档信息 -->
        <view v-if="uploadedDoc.fileName && !uploading" class="doc-info-bar">
          <text class="doc-format-badge">{{ uploadedDoc.format?.toUpperCase() || 'DOC' }}</text>
          <text class="doc-filename">{{ uploadedDoc.fileName }}</text>
          <text class="doc-meta">{{ uploadedDoc.wordCount }} 字</text>
          <text v-if="uploadedDoc.truncated" class="doc-truncated-hint">⚠ 已截取</text>
          <text class="doc-remove-btn" @click="clearDoc">✕</text>
        </view>
        <!-- 上传中 -->
        <view v-if="uploading" class="doc-uploading-bar">
          <text>正在解析文档，请稍候...</text>
        </view>

        <!-- 工具按钮行 -->
        <view class="tool-row">
          <view class="tool-btn" @click="handleUploadDoc">
            <text>{{ uploadedDoc.fileName ? '📄 重新上传' : '📄 上传文档' }}</text>
          </view>
          <view class="tool-btn" @click="goToAIGenerate">
            <text>🤖 AI 生成</text>
          </view>
          <view class="tool-btn" @click="pasteText">
            <text>📋 粘贴</text>
          </view>
          <view class="tool-btn" @click="clearText">
            <text>🗑 清空</text>
          </view>
        </view>

        <!-- 专辑选择（必填） -->
        <view class="album-selector" @click="showAlbumListPanel = true">
          <text class="album-selector-label">专辑</text>
          <text class="album-selector-value" :class="{ placeholder: !selectedAlbum }">
            {{ selectedAlbum ? selectedAlbum.title : '请选择专辑（必填）' }}
          </text>
          <text class="album-selector-arrow">▼</text>
        </view>
      </view>
    </view>

    <!-- 情感检测预览卡片（文本长度≥10时显示） -->
    <view v-if="text.length >= 10" class="emotion-card">
      <view class="emotion-card-header">
        <text class="emotion-card-icon">🎭</text>
        <text class="emotion-card-title">内容分析</text>
        <text v-if="detecting" class="emotion-detecting">检测中...</text>
      </view>

      <!-- 检测成功：显示推荐 -->
      <view v-if="detectedEmotion && !detecting" class="emotion-result">
        <view class="emotion-row">
          <text class="emotion-label">情感</text>
          <text class="emotion-value">{{ detectedEmotionLabel }}</text>
        </view>
        <view v-if="detectedVoiceName" class="emotion-row">
          <text class="emotion-label">推荐音色</text>
          <text class="emotion-value voice-value">{{ detectedVoiceName }}</text>
        </view>
        <view v-if="detectedScene" class="emotion-row">
          <text class="emotion-label">场景</text>
          <text class="emotion-value scene-value">{{ detectedScene }}</text>
        </view>
        <view v-if="detectedIdentity" class="emotion-row">
          <text class="emotion-label">身份</text>
          <text class="emotion-value identity-value">{{ detectedIdentity }}</text>
        </view>
        <view v-if="detectedRole" class="emotion-row">
          <text class="emotion-label">角色</text>
          <text class="emotion-value role-value">{{ detectedRole }}</text>
        </view>

        <!-- 情感覆盖选择器 -->
        <view class="emotion-override">
          <text class="emotion-override-label">手动调整：</text>
          <scroll-view scroll-x class="emotion-scroll">
            <view class="emotion-chips">
              <view
                v-for="emo in emotionOptions"
                :key="emo.key"
                class="emotion-chip"
                :class="{ active: selectedEmotion === emo.key }"
                @click="selectEmotion(emo.key)"
              >
                <text>{{ emo.label }}</text>
              </view>
            </view>
          </scroll-view>
        </view>
      </view>

      <!-- 未检测到特征：仅显示兜底情感 -->
      <view v-if="!detectedEmotion && !detecting && emotionDetectAttempted" class="emotion-result">
        <view class="emotion-row">
          <text class="emotion-label">情感</text>
          <text class="emotion-value emotion-fallback">{{ detectedEmotionLabel || '中性' }}</text>
        </view>
        <view v-if="detectedScene" class="emotion-row">
          <text class="emotion-label">场景</text>
          <text class="emotion-value scene-value">{{ detectedScene }}</text>
        </view>
        <text class="emotion-hint">未匹配到特定内容类型，使用通用音色</text>

        <!-- 情感覆盖选择器 -->
        <view class="emotion-override">
          <text class="emotion-override-label">手动调整：</text>
          <scroll-view scroll-x class="emotion-scroll">
            <view class="emotion-chips">
              <view
                v-for="emo in emotionOptions"
                :key="emo.key"
                class="emotion-chip"
                :class="{ active: selectedEmotion === emo.key }"
                @click="selectEmotion(emo.key)"
              >
                <text>{{ emo.label }}</text>
              </view>
            </view>
          </scroll-view>
        </view>
      </view>
    </view>

    <!-- 底部固定区：Tab 工具栏 -->
    <view class="bottom-bar">
      <!-- 主操作行：生成按钮 + 快捷信息 -->
      <view class="bottom-action-row">
        <view class="quick-info">
          <text class="quick-info-text">🎤 {{ currentVoiceName || '默认音色' }}</text>
        </view>
        <view class="generate-btn-tab" :class="{ disabled: !canGenerate }" @click="handleGenerate">
          <text class="generate-btn-tab-text">{{ generating ? '提交中...' : '开始生成' }}</text>
        </view>
      </view>

      <!-- 图标 Tab 栏（等宽分布，图标+文字居中） -->
      <view class="tab-toolbar">
        <view class="tab-item" :class="{ active: showVoicePanel }" @click="showVoicePanel = true">
          <text class="tab-icon">🎤</text>
          <text class="tab-text">音色</text>
        </view>
        <view class="tab-item" @click="showParamsPanel = true">
          <text class="tab-icon">⚙️</text>
          <text class="tab-text">参数</text>
        </view>
      </view>
    </view>

    <!-- 音色面板（暗色主题） -->
    <view v-if="showVoicePanel" class="bottom-panel-overlay" @click="showVoicePanel = false">
      <view class="bottom-panel voice-panel-dark" @click.stop>
        <!-- 分类标签栏 -->
        <view class="voice-tabs">
          <scroll-view scroll-x :show-scrollbar="false" class="voice-tabs-scroll">
            <view class="voice-tabs-inner">
              <text
                v-for="(cat, idx) in voiceCategories"
                :key="cat.key"
                class="voice-tab"
                :class="{ active: voiceCategory === cat.key }"
                @click="voiceCategory = cat.key"
              >{{ cat.label }}</text>
            </view>
          </scroll-view>
        </view>

        <!-- 音色网格 -->
        <scroll-view scroll-y class="voice-grid-scroll">
          <view class="voice-card-grid">
            <view
              v-for="voice in filteredVoices"
              :key="voice.id"
              class="voice-card"
              :class="{ active: selectedVoice === voice.id }"
              @click="selectVoiceAndClose(voice.id)"
            >
              <view class="voice-card-icon">
                <text>{{ voice.gender === 'female' ? '👩' : '👨' }}</text>
              </view>
              <text class="voice-card-name">{{ voice.name }}</text>
              <view v-if="selectedVoice === voice.id" class="voice-card-check">✓</view>
              <view class="voice-card-play" @click.stop="previewVoice(voice.id)">
                <text>{{ previewingVoice === voice.id ? '⏹' : '▶' }}</text>
              </view>
            </view>
          </view>
        </scroll-view>

        <!-- 关闭按钮 -->
        <view class="panel-close-bar" @click="showVoicePanel = false">
          <view class="panel-close-bar-line"></view>
        </view>
      </view>
    </view>

    <!-- 参数面板 -->
    <view v-if="showParamsPanel" class="bottom-panel-overlay" @click="showParamsPanel = false">
      <view class="bottom-panel" @click.stop>
        <view class="panel-header">
          <text class="panel-title">参数调节</text>
          <text class="panel-close" @click="showParamsPanel = false">✕</text>
        </view>
        <scroll-view scroll-y class="panel-body">
          <view class="param-item">
            <view class="param-header">
              <text class="param-label">语速</text>
              <text class="param-value">{{ voiceParams.speed.toFixed(1) }}x</text>
            </view>
            <slider
              :value="(voiceParams.speed - 0.5) * 100 / 1.5"
              :min="0" :max="100"
              @changing="(e: any) => voiceParams.speed = Number((0.5 + (e.detail.value / 100) * 1.5).toFixed(1))"
              activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
            />
            <view class="param-range"><text>0.5x</text><text>2.0x</text></view>
          </view>
          <view class="param-item">
            <view class="param-header">
              <text class="param-label">音调</text>
              <text class="param-value">{{ voiceParams.pitch > 0 ? '+' : '' }}{{ voiceParams.pitch }}</text>
            </view>
            <slider
              :value="(voiceParams.pitch + 500) / 10"
              :min="0" :max="100"
              @changing="(e: any) => voiceParams.pitch = Math.round(e.detail.value * 10 - 500)"
              activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
            />
            <view class="param-range"><text>-500</text><text>+500</text></view>
          </view>
          <view class="param-item">
            <view class="param-header">
              <text class="param-label">音量</text>
              <text class="param-value">{{ voiceParams.volume }}%</text>
            </view>
            <slider
              :value="voiceParams.volume"
              :min="0" :max="100"
              @changing="(e: any) => voiceParams.volume = e.detail.value"
              activeColor="#4F46E5" backgroundColor="#e5e7eb" block-size="20"
            />
            <view class="param-range"><text>0%</text><text>100%</text></view>
          </view>
        </scroll-view>
      </view>
    </view>

    <!-- 额度用尽弹窗 -->
    <view v-if="showQuotaModal" class="quota-modal" @click="showQuotaModal = false">
      <view class="quota-content" @click.stop>
        <view class="quota-icon">👑</view>
        <text class="quota-title">今日免费额度已用完</text>
        <text class="quota-desc">开通会员解锁更多生成次数</text>
        <view class="quota-actions">
          <button class="quota-btn primary" @click="goToMember"><text>开通会员</text></button>
          <button class="quota-btn secondary" @click="showQuotaModal = false"><text>稍后再说</text></button>
        </view>
      </view>
    </view>

    <!-- 专辑列表面板 -->
    <view v-if="showAlbumListPanel" class="bottom-panel-overlay" @click="showAlbumListPanel = false">
      <view class="bottom-panel" @click.stop>
        <view class="panel-header">
          <text class="panel-title">选择专辑</text>
          <text class="panel-close" @click="showAlbumListPanel = false">✕</text>
        </view>
        <scroll-view scroll-y class="panel-body">
          <view
            v-for="album in albums" :key="album.id"
            class="album-item" :class="{ active: selectedAlbum?.id === album.id }"
            @click="selectAlbum(album)"
          >
            <view class="album-item-content">
              <text class="album-item-title">{{ album.title }}</text>
              <text class="album-item-desc">{{ album.totalChapters }} 个章节</text>
            </view>
            <text v-if="selectedAlbum?.id === album.id" class="album-check">✓</text>
          </view>
          <view v-if="albums.length === 0" class="album-empty"><text>暂无专辑</text></view>
        </scroll-view>
        <button class="panel-action-btn" @click="showAlbumListPanel = false; showAlbumPanel = true">
          <text>+ 新建专辑</text>
        </button>
      </view>
    </view>

    <!-- 新建专辑面板 -->
    <view v-if="showAlbumPanel" class="bottom-panel-overlay" @click="showAlbumPanel = false">
      <view class="bottom-panel" @click.stop>
        <view class="panel-header">
          <text class="panel-title">新建专辑</text>
          <text class="panel-close" @click="showAlbumPanel = false">✕</text>
        </view>
        <view class="panel-body">
          <view class="form-item">
            <text class="form-label">专辑名称 *</text>
            <input v-model="newAlbumTitle" class="form-input" placeholder="例如：我的有声书" maxlength="50" />
          </view>
          <view class="form-item">
            <text class="form-label">描述（可选）</text>
            <textarea v-model="newAlbumDescription" class="form-textarea" placeholder="简单描述这个专辑..." maxlength="200" />
          </view>
          <button class="panel-action-btn" :disabled="!newAlbumTitle.trim()" @click="createAlbum">
            <text>创建专辑</text>
          </button>
        </view>
      </view>
    </view>
    <!-- 全局底部 Tab Bar -->
    <CustomTabBar />
  </view>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { useUserStore } from '../../store/user';
import { useAudioStore } from '../../store/audio';
import { get, post, uploadFile } from '../../utils/request';
import type { VoiceParams } from '../../types';
import CustomTabBar from '../../components/CustomTabBar.vue';

const userStore = useUserStore();
const audioStore = useAudioStore();

const text = ref('');
const selectedVoice = ref('voice_01');
const voiceParams = ref<VoiceParams>({ speed: 1.0, pitch: 0, volume: 50 });
const generating = ref(false);
const previewingVoice = ref<string | null>(null);
let previewAudio: HTMLAudioElement | null = null;

// ============ 情感检测 ============
const detecting = ref(false);
const detectedEmotion = ref(false);
const detectedEmotionLabel = ref('');
const detectedVoiceName = ref('');
const detectedScene = ref('');      // 检测到的场景
const detectedRole = ref('');       // 检测到的角色
const detectedIdentity = ref('');   // 检测到的身份
const emotionDetectAttempted = ref(false);
const selectedEmotion = ref(''); // 用户手动选择的情感，空=不覆盖

const emotionOptions = [
  { key: 'neutral', label: '😐 中性' },
  { key: 'happy', label: '😊 快乐' },
  { key: 'sad', label: '😢 悲伤' },
  { key: 'angry', label: '😠 愤怒' },
  { key: 'fearful', label: '😨 恐惧' },
  { key: 'surprised', label: '😲 惊喜' },
  { key: 'disgusted', label: '🤢 厌恶' },
];

let detectTimer: ReturnType<typeof setTimeout> | null = null;

function selectEmotion(key: string) {
  selectedEmotion.value = selectedEmotion.value === key ? '' : key;
}

// 防抖检测文本情感
function debouncedDetect() {
  if (detectTimer) clearTimeout(detectTimer);
  detectTimer = setTimeout(async () => {
    if (text.value.trim().length < 10) {
      detectedEmotion.value = false;
      detectedEmotionLabel.value = '';
      detectedVoiceName.value = '';
      emotionDetectAttempted.value = false;
      return;
    }
    detecting.value = true;
    emotionDetectAttempted.value = true;
    try {
      const res = await post<{
        detected: boolean;
        voiceId?: string;
        voiceName?: string;
        emotion?: string;
        emotionLabel?: string;
        hint?: string;
        scene?: string;
        role?: string | null;
        identity?: string | null;
      }>('/tts/detect-emotion', { text: text.value });
      detectedEmotion.value = res.detected || false;
      detectedEmotionLabel.value = res.emotionLabel || '';
      detectedVoiceName.value = res.voiceName || '';
      detectedScene.value = res.scene || '';
      detectedRole.value = res.role || '';
      detectedIdentity.value = res.identity || '';
      if (res.emotion && !selectedEmotion.value) {
        // 首次检测到情感时，自动选中（但不覆盖用户手动选择）
      }
    } catch (e) {
      /* 静默失败 */
    } finally {
      detecting.value = false;
    }
  }, 800);
}

// 监听文本变化，触发检测
watch(text, () => {
  selectedEmotion.value = ''; // 文本变化时重置手动选择
  debouncedDetect();
});


// 文档上传
interface UploadedDoc { fileName: string; wordCount: number; fileSize: number; format: string; truncated: boolean; }
const uploadedDoc = ref<UploadedDoc>({ fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false });
const uploading = ref(false);

// 面板状态
const showVoicePanel = ref(false);
const showParamsPanel = ref(false);
const voiceCategory = ref('all');
const voiceCategories = [
  { key: 'all', label: '全部' },
  { key: 'hot', label: '热门' },
  { key: 'female', label: '女声' },
  { key: 'male', label: '男声' },
];

// 专辑
const selectedAlbum = ref<{ id: string; title: string } | null>(null);
const albums = ref<any[]>([]);
const showAlbumListPanel = ref(false);
const showAlbumPanel = ref(false);
const newAlbumTitle = ref('');
const newAlbumDescription = ref('');

const showQuotaModal = ref(false);

const canGenerate = computed(() => text.value.trim().length > 0 && !!selectedAlbum.value && !generating.value);
const currentVoiceName = computed(() => {
  const v = audioStore.voices.find(item => item.id === selectedVoice.value);
  return v ? v.name : '';
});
const filteredVoices = computed(() => {
  if (voiceCategory.value === 'all') return audioStore.voices;
  if (voiceCategory.value === 'female') return audioStore.voices.filter(v => v.gender === 'female');
  if (voiceCategory.value === 'male') return audioStore.voices.filter(v => v.gender === 'male');
  // hot: return all for now (could be flagged by backend)
  return audioStore.voices;
});
function selectVoiceAndClose(id: string) { selectedVoice.value = id; showVoicePanel.value = false; }

async function fetchAlbums() {
  try {
    const result = await get<{ albums: any[] }>('/book-generator/books');
    albums.value = result.albums || [];
  } catch (error) { console.error('获取专辑列表失败:', error); }
}

function selectAlbum(album: any) { selectedAlbum.value = { id: album.id, title: album.title }; showAlbumListPanel.value = false; }

async function createAlbum() {
  if (!newAlbumTitle.value.trim()) return;
  try {
    const result = await post<{ id: number | string; title: string }>('/book-generator/books', { title: newAlbumTitle.value.trim(), description: newAlbumDescription.value.trim() });
    if (result && result.id) {
      selectedAlbum.value = { id: String(result.id), title: result.title };
      await fetchAlbums();
      showAlbumPanel.value = false;
      newAlbumTitle.value = '';
      newAlbumDescription.value = '';
      uni.showToast({ title: '专辑创建成功', icon: 'success' });
    }
  } catch (error) { uni.showToast({ title: '创建失败', icon: 'none' }); }
}

async function loadUserPreferences() {
  try {
    const localPrefs = uni.getStorageSync('userPreferences');
    if (localPrefs) {
      if (localPrefs.defaultVoiceId) selectedVoice.value = localPrefs.defaultVoiceId;
      if (localPrefs.defaultVolume !== undefined) voiceParams.value.volume = localPrefs.defaultVolume;
    }
    const result = await get<any>('/user/preferences');
    if (result) {
      if (result.defaultVoiceId) selectedVoice.value = result.defaultVoiceId;
      if (result.defaultVolume !== undefined) voiceParams.value.volume = result.defaultVolume;
      uni.setStorageSync('userPreferences', result);
    }
  } catch (error) { /* ignore */ }
}

onMounted(async () => {
  await audioStore.fetchVoices();
  await fetchAlbums();
  await loadUserPreferences();
});

function goToAIGenerate() { uni.navigateTo({ url: '/pages/ai-generate/index' }); }

onShow(() => {
  const aiText = uni.getStorageSync('ai_generated_text');
  if (aiText) { text.value = aiText; uni.removeStorageSync('ai_generated_text'); uni.showToast({ title: '已填充 AI 生成的文本', icon: 'success' }); }
});

function clearText() { text.value = ''; }
async function pasteText() { const content = await uni.getClipboardData(); if (content.data) text.value = content.data; }

// 上传文档（保持原逻辑）
async function handleUploadDoc() {
  if (uploading.value) return;
  try {
    // #ifdef H5
    const input = document.createElement('input'); input.type = 'file'; input.accept = '.txt,.docx,.pdf';
    input.onchange = async (e: any) => { const file = e.target.files?.[0]; if (!file) return; await processUploadedFile(file); };
    input.click(); return;
    // #endif
    // #ifdef APP-PLUS
    const result = await (uni as any).chooseFile({ extension: ['.txt', '.docx', '.pdf'], type: 'file' });
    if (result?.tempFiles?.[0]) await processUploadedFile(result.tempFiles[0]);
    // #endif
    // #ifdef MP-WEIXIN
    uni.showToast({ title: '小程序暂不支持文档上传', icon: 'none' });
    // #endif
  } catch (error: any) { if (error.message && !error.message.includes('cancel')) uni.showToast({ title: '选择文件失败', icon: 'none' }); }
}

async function processUploadedFile(file: any) {
  uploading.value = true;
  if (file.size && file.size > 20 * 1024 * 1024) { uni.showToast({ title: '文件过大，最大支持20MB', icon: 'none' }); uploading.value = false; return; }
  const name = (file.name || '').toLowerCase();
  if (!name.endsWith('.txt') && !name.endsWith('.docx') && !name.endsWith('.pdf')) { uni.showToast({ title: '不支持的格式', icon: 'none' }); uploading.value = false; return; }
  uni.showLoading({ title: '解析文档中...', mask: true });
  try {
    let result: any;
    // #ifdef H5
    const formData = new FormData(); formData.append('file', file);
    const baseUrl = (await import('../../utils/config')).getApiBaseUrl();
    const token = uni.getStorageSync('token');
    const headers: Record<string, string> = {}; if (token) headers['Authorization'] = `Bearer ${token}`;
    const response = await fetch(baseUrl + '/tts/upload-document', { method: 'POST', headers, body: formData });
    const json = await response.json();
    if (json.code === 0) result = json.data; else throw new Error(json.message || '解析失败');
    // #endif
    // #ifdef APP-PLUS
    result = await uploadFile('/tts/upload-document', file.path || file.tempFilePath || file);
    // #endif
    if (result && result.text) {
      uploadedDoc.value = { fileName: result.fileName || file.name, wordCount: result.wordCount || result.text.length, fileSize: result.fileSize || file.size || 0, format: result.format || '', truncated: result.truncated || false };
      let displayText = result.text;
      if (displayText.length > 2000) { displayText = displayText.substring(0, 2000); uploadedDoc.value.truncated = true; }
      text.value = displayText;
      uni.hideLoading();
      uni.showToast({ title: `解析成功，${uploadedDoc.value.wordCount}字`, icon: 'success' });
    } else { throw new Error('文档内容为空'); }
  } catch (error: any) { uni.hideLoading(); uni.showToast({ title: error.message || '文档解析失败', icon: 'none', duration: 3000 }); }
  finally { uploading.value = false; }
}

function clearDoc() { uploadedDoc.value = { fileName: '', wordCount: 0, fileSize: 0, format: '', truncated: false }; }

async function previewVoice(voiceId: string) {
  if (previewingVoice.value === voiceId) { stopPreview(); return; }
  stopPreview();
  try {
    previewingVoice.value = voiceId; uni.showLoading({ title: '生成试听音频...' });
    const res = await post<{ audioUrl: string }>('/tts/preview', { voiceId, voiceParams: voiceParams.value });
    uni.hideLoading();
    if (res.audioUrl) {
      // #ifdef H5
      previewAudio = new Audio(res.audioUrl); previewAudio.play();
      previewAudio.onended = () => { previewingVoice.value = null; previewAudio = null; };
      // #endif
      // #ifndef H5
      uni.showToast({ title: '试听已播放', icon: 'success' }); setTimeout(() => { previewingVoice.value = null; }, 2000);
      // #endif
    }
  } catch (error: any) { uni.hideLoading(); uni.showToast({ title: '试听生成失败', icon: 'none' }); previewingVoice.value = null; }
}

function stopPreview() { if (previewAudio) { previewAudio.pause(); previewAudio = null; } previewingVoice.value = null; }

async function handleGenerate() {
  if (!canGenerate.value) return;
  const quota = userStore.memberStatus?.quota;
  if (quota && quota.dailyRemaining !== -1 && quota.dailyRemaining <= 0) { showQuotaModal.value = true; return; }
  const bookId = selectedAlbum.value?.id;
  generating.value = true;
  try {
    // 如果用户手动选择了情感，注入到 voiceParams
    const params = { ...voiceParams.value };
    if (selectedEmotion.value) {
      (params as any).emotion = selectedEmotion.value;
    }
    const result = await audioStore.generateAudio(text.value, selectedVoice.value, params, { bookId });
    text.value = '';
    uni.showToast({ title: '任务已提交', icon: 'success' });
    setTimeout(() => { uni.navigateTo({ url: `/pages/book-generator/detail?id=${result.bookId}` }); }, 800);
  } catch (error: any) {
    if (error.message?.includes('额度') || error.message?.includes('quota')) showQuotaModal.value = true;
    else uni.showToast({ title: error.message || '提交失败，请重试', icon: 'none' });
  } finally { generating.value = false; }
}

function goToMember() { showQuotaModal.value = false; uni.navigateTo({ url: '/pages/member/index' }); }
</script>

<style scoped>
.page { min-height: 100vh; background: #f5f5f5; display: flex; flex-direction: column; }

/* 主内容区 */
.main-content { flex: 1; padding: 16rpx 24rpx 0; overflow-y: auto; }
.input-area { background: #ffffff; border-radius: 16rpx; padding: 24rpx; }

/* 情感检测卡片 */
.emotion-card {
  margin: 16rpx 0;
  padding: 20rpx 24rpx;
  background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
  border-radius: 16rpx;
  border: 1rpx solid #e0e7ff;
}
.emotion-card-header {
  display: flex;
  align-items: center;
  gap: 8rpx;
  margin-bottom: 12rpx;
}
.emotion-card-icon { font-size: 28rpx; }
.emotion-card-title { font-size: 24rpx; font-weight: 600; color: #4f46e5; }
.emotion-detecting { font-size: 20rpx; color: #9ca3af; margin-left: auto; }

.emotion-result {}
.emotion-row {
  display: flex;
  align-items: center;
  gap: 16rpx;
  padding: 8rpx 0;
}
.emotion-label { font-size: 22rpx; color: #6b7280; width: 120rpx; flex-shrink: 0; }
.emotion-value { font-size: 26rpx; font-weight: 600; color: #1f2937; }
.emotion-value.voice-value { color: #4f46e5; }
.emotion-value.emotion-fallback { color: #9ca3af; }
.emotion-value.scene-value { color: #0891b2; }
.emotion-value.identity-value { color: #7c3aed; }
.emotion-value.role-value { color: #059669; }
.emotion-hint { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }

/* 情感覆盖选择器 */
.emotion-override {
  margin-top: 16rpx;
  padding-top: 12rpx;
  border-top: 1rpx solid #e0e7ff;
}
.emotion-override-label { font-size: 22rpx; color: #6b7280; }
.emotion-scroll { white-space: nowrap; margin-top: 8rpx; }
.emotion-chips { display: flex; gap: 10rpx; }
.emotion-chip {
  flex-shrink: 0;
  padding: 8rpx 16rpx;
  background: #ffffff;
  border: 1rpx solid #d1d5db;
  border-radius: 20rpx;
  font-size: 22rpx;
  color: #374151;
  transition: all 0.2s;
}
.emotion-chip.active {
  background: #4f46e5;
  border-color: #4f46e5;
  color: #ffffff;
  font-weight: 600;
}

/* 专辑选择器（页面内下拉） */
.album-selector {
  display: flex;
  align-items: center;
  gap: 12rpx;
  padding: 20rpx 24rpx;
  background: #f3f4f6;
  border-radius: 12rpx;
  border: 2rpx solid #e5e7eb;
  margin-top: 20rpx;
}
.album-selector:active { opacity: 0.7; }
.album-selector-label { font-size: 26rpx; color: #6b7280; flex-shrink: 0; }
.album-selector-value {
  flex: 1;
  font-size: 26rpx;
  color: #1f2937;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.album-selector-value.placeholder { color: #9ca3af; font-weight: 400; }
.album-selector-arrow { font-size: 20rpx; color: #9ca3af; flex-shrink: 0; }

.text-input { width: 100%; min-height: 600rpx; font-size: 30rpx; color: #1f2937; line-height: 1.7; }
.word-count { display: block; text-align: right; font-size: 22rpx; color: #9ca3af; margin-top: 8rpx; }
.word-count.warning { color: #f59e0b; }
.word-count.error { color: #ef4444; font-weight: 600; }

/* 文档信息 */
.doc-info-bar { display: flex; align-items: center; gap: 12rpx; padding: 12rpx 16rpx; background: #f0fdf4; border-radius: 10rpx; border: 1rpx solid #86efac; margin-top: 12rpx; }
.doc-format-badge { flex-shrink: 0; font-size: 20rpx; font-weight: 700; color: #fff; background: #4f46e5; padding: 4rpx 10rpx; border-radius: 6rpx; }
.doc-filename { flex: 1; font-size: 24rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-meta { font-size: 20rpx; color: #6b7280; flex-shrink: 0; }
.doc-truncated-hint { font-size: 18rpx; color: #f59e0b; flex-shrink: 0; }
.doc-remove-btn { flex-shrink: 0; width: 36rpx; height: 36rpx; border-radius: 50%; background: #fef2f2; color: #ef4444; font-size: 24rpx; display: flex; align-items: center; justify-content: center; }
.doc-uploading-bar { padding: 16rpx; text-align: center; background: #fefce8; border-radius: 10rpx; font-size: 24rpx; color: #a16207; margin-top: 12rpx; }

/* 工具按钮行 */
.tool-row { display: flex; gap: 12rpx; margin-top: 16rpx; }
.tool-btn { flex: 1; font-size: 24rpx; color: #6b7280; background: #f3f4f6; padding: 14rpx 0; border-radius: 10rpx; text-align: center; }
.tool-btn:active { opacity: 0.7; }

/* 底部固定区：Tab 工具栏风格 */
.bottom-bar { background: #ffffff; box-shadow: 0 -2rpx 16rpx rgba(0,0,0,0.08); padding-bottom: calc(env(safe-area-inset-bottom) + 120rpx); }

/* 主操作行：左侧快捷信息 + 右侧生成按钮 */
.bottom-action-row { display: flex; align-items: center; gap: 16rpx; padding: 16rpx 24rpx; }
.quick-info { flex: 1; min-width: 0; }
.quick-info-text { font-size: 24rpx; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }

/* 生成按钮（紧凑 Tab 风格） */
.generate-btn-tab { flex-shrink: 0; padding: 18rpx 36rpx; background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); border-radius: 40rpx; }
.generate-btn-tab:active { opacity: 0.8; transform: scale(0.96); }
.generate-btn-tab.disabled { opacity: 0.35; pointer-events: none; }
.generate-btn-tab-text { font-size: 28rpx; font-weight: 700; color: #ffffff; white-space: nowrap; }

/* 图标 Tab 栏（等宽分布，居中） */
.tab-toolbar { display: flex; border-top: 1rpx solid #f3f4f6; padding: 10rpx 0 16rpx; }
.tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4rpx; padding: 6rpx 0; border-radius: 12rpx; transition: all 0.15s; }
.tab-item:active { background: #f9fafb; }
.tab-item.active .tab-icon { transform: scale(1.15); }
.tab-item.active .tab-text { color: #4f46e5; font-weight: 600; }
.tab-icon { font-size: 40rpx; line-height: 1; transition: transform 0.15s; }
.tab-text { font-size: 22rpx; color: #9ca3af; transition: color 0.15s; }

/* 底部面板 */
.bottom-panel-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: flex-end; }
.bottom-panel { width: 100%; max-height: 70vh; background: #ffffff; border-radius: 24rpx 24rpx 0 0; display: flex; flex-direction: column; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 32rpx 32rpx 16rpx; }
.panel-title { font-size: 34rpx; font-weight: 600; color: #1f2937; }
.panel-close { font-size: 36rpx; color: #9ca3af; padding: 8rpx; }
.panel-body { padding: 0 32rpx 32rpx; max-height: 55vh; }

/* 音色面板（亮色，与整体风格统一） */
.voice-panel-dark { background: #ffffff; border-radius: 24rpx 24rpx 0 0; max-height: 75vh; }

/* 分类标签栏 */
.voice-tabs { padding: 24rpx 28rpx 8rpx; border-bottom: 1rpx solid #f3f4f6; }
.voice-tabs-scroll { white-space: nowrap; }
.voice-tabs-inner { display: inline-flex; gap: 16rpx; }
.voice-tab { font-size: 26rpx; color: #6b7280; padding: 10rpx 22rpx; border-radius: 20rpx; white-space: nowrap; flex-shrink: 0; background: #f9fafb; }
.voice-tab.active { color: #ffffff; background: #4f46e5; font-weight: 600; }

/* 音色网格（5列紧凑卡片） */
.voice-grid-scroll { max-height: 56vh; }
.voice-card-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12rpx; padding: 20rpx 24rpx 32rpx; }
.voice-card { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 18rpx 4rpx 14rpx; border-radius: 14rpx; background: #f9fafb; border: 2rpx solid transparent; min-height: 120rpx; }
.voice-card.active { background: #eef2ff; border-color: #4f46e5; }
.voice-card:active { opacity: 0.7; transform: scale(0.96); }
.voice-card-icon { width: 64rpx; height: 64rpx; border-radius: 50%; background: #f3f4f6; display: flex; align-items: center; justify-content: center; margin-bottom: 6rpx; font-size: 30rpx; }
.voice-card.active .voice-card-icon { background: rgba(79,70,229,0.12); }
.voice-card-name { font-size: 20rpx; color: #374151; text-align: center; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.voice-card.active .voice-card-name { color: #4f46e5; font-weight: 600; }
.voice-card-check { position: absolute; top: 6rpx; right: 6rpx; font-size: 20rpx; color: #4f46e5; font-weight: 700; }
.voice-card-play { position: absolute; top: 4rpx; right: 4rpx; width: 36rpx; height: 36rpx; border-radius: 50%; background: rgba(79,70,229,0.1); display: flex; align-items: center; justify-content: center; font-size: 16rpx; color: #4f46e5; }
.voice-card.active .voice-card-play { display: none; }

/* 面板底部关闭条 */
.panel-close-bar { padding: 16rpx 0 28rpx; display: flex; justify-content: center; }
.panel-close-bar-line { width: 80rpx; height: 6rpx; border-radius: 3rpx; background: #e5e7eb; }

/* 参数面板 */
.param-item { margin-bottom: 32rpx; }
.param-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.param-label { font-size: 28rpx; color: #374151; }
.param-value { font-size: 28rpx; font-weight: 600; color: #4f46e5; background: #eef2ff; padding: 4rpx 16rpx; border-radius: 8rpx; }
.param-range { display: flex; justify-content: space-between; margin-top: 8rpx; }
.param-range text { font-size: 22rpx; color: #9ca3af; }

/* 专辑 */
.album-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 24rpx; border-radius: 14rpx; background: #f9fafb; margin-bottom: 12rpx; }
.album-item.active { background: #eef2ff; border: 2rpx solid #4f46e5; }
.album-item-content { flex: 1; }
.album-item-title { display: block; font-size: 28rpx; font-weight: 600; color: #1f2937; margin-bottom: 6rpx; }
.album-item-desc { display: block; font-size: 24rpx; color: #6b7280; }
.album-check { font-size: 32rpx; color: #4f46e5; }
.album-empty { text-align: center; padding: 60rpx 0; color: #9ca3af; font-size: 28rpx; }

.panel-action-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%); border-radius: 16rpx; border: none; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 600; color: #ffffff; margin-top: 16rpx; }
.panel-action-btn::after { border: none; }
.panel-action-btn[disabled] { opacity: 0.4; }

/* 表单 */
.form-item { margin-bottom: 24rpx; }
.form-label { display: block; font-size: 28rpx; color: #1f2937; font-weight: 500; margin-bottom: 12rpx; }
.form-input { width: 100%; height: 88rpx; background: #f9fafb; border-radius: 14rpx; padding: 0 24rpx; font-size: 28rpx; color: #1f2937; }
.form-textarea { width: 100%; min-height: 140rpx; background: #f9fafb; border-radius: 14rpx; padding: 20rpx 24rpx; font-size: 28rpx; color: #1f2937; line-height: 1.6; }

/* 额度弹窗 */
.quota-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 3000; display: flex; align-items: center; justify-content: center; }
.quota-content { width: 600rpx; background: #ffffff; border-radius: 32rpx; padding: 60rpx 40rpx; display: flex; flex-direction: column; align-items: center; }
.quota-icon { font-size: 100rpx; margin-bottom: 24rpx; }
.quota-title { font-size: 36rpx; font-weight: 600; color: #1f2937; margin-bottom: 12rpx; }
.quota-desc { font-size: 28rpx; color: #6b7280; margin-bottom: 40rpx; }
.quota-actions { width: 100%; display: flex; flex-direction: column; gap: 16rpx; }
.quota-btn { width: 100%; height: 88rpx; border-radius: 44rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; border: none; }
.quota-btn::after { border: none; }
.quota-btn.primary { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #fff; }
.quota-btn.secondary { background: #f3f4f6; color: #6b7280; }
</style>

// ====== pages/player/index.vue ======
<template>
  <view class="page">
    <!-- 顶部导航 -->
    <view class="nav-bar">
      <view class="nav-btn" @click="goBack">
        <text class="nav-icon">⋁</text>
      </view>
      <view class="nav-center">
        <text class="nav-title">{{ audio?.title || '正在播放' }}</text>
        <view v-if="sleepTimer" class="sleep-timer-badge" @click="showSleepTimerPicker = true">
          <text class="sleep-timer-text">{{ formatSleepTimer(sleepTimer.remaining) }}</text>
        </view>
      </view>
      <view class="nav-actions">
        <view class="nav-btn" @click="showSleepTimerPicker = true">
          <text class="nav-icon">⏰</text>
        </view>
        <view class="nav-btn nav-btn-lyrics" @click="showLyrics = !showLyrics">
          <text class="nav-icon">{{ showLyrics ? '📖' : '📕' }}</text>
          <text class="nav-btn-label">{{ showLyrics ? '隐藏歌词' : '显示歌词' }}</text>
        </view>
      </view>
    </view>

    <!-- 无音频提示 -->
    <view v-if="!audio?.audioUrl" class="empty-state">
      <text class="empty-icon">🎵</text>
      <text class="empty-title">暂无音频</text>
      <text class="empty-desc">该章节还没有生成音频</text>
      <button class="empty-btn" @click="goBack">返回</button>
    </view>

    <!-- 正常播放界面 -->
    <template v-else>

    <!-- 封面和标题（始终显示，不受歌词开关影响） -->
    <view class="cover-section">
      <view class="cover">
        <text class="cover-icon">🎵</text>
      </view>
      <text class="title">{{ audio?.title || '未知标题' }}</text>
    </view>

    <!-- 文本预览（歌词同步） -->
    <scroll-view
      v-show="showLyrics"
      class="text-preview"
      scroll-y
      :style="{ maxHeight: '550rpx' }"
      :scroll-into-view="scrollIntoViewId"
      scroll-with-animation
    >
      <view class="lyrics-container">
        <view
          v-for="(line, index) in lyrics"
          :key="index"
          :id="'lyric-' + index"
          class="lyric-line"
          :class="{ active: index === currentLyricIndex }"
        >
          <text class="lyric-text">{{ line.text }}</text>
        </view>
      </view>
    </scroll-view>

    <!-- 进度条 -->
    <view class="progress-section">
      <text class="time">{{ formatDuration(currentTime) }}</text>
      <slider
        :value="currentTime"
        :max="duration || 100"
        @change="handleSeek"
        activeColor="#4F46E5"
        backgroundColor="#e5e7eb"
        block-size="16"
        class="progress-slider"
      />
      <text class="time">{{ formatDuration(duration) }}</text>
    </view>

    <!-- 播放控制 -->
    <view class="controls">
      <!-- 左侧按钮：播放速度、下载 -->
      <view class="controls-side">
        <view class="control-btn speed-display-btn" @click="showRatePicker = true">
          <text class="speed-label">倍速</text>
          <text class="speed-value">{{ playRate }}x</text>
        </view>
        <AudioDownload
          :url="getFullUrl(audio?.audioUrl || '')"
          :filename="`${audio?.title || 'audio'}.mp3`"
          :audio-id="String(audio?.id || '')"
        >
          <view class="control-btn">
            <text class="control-icon">⬇</text>
          </view>
        </AudioDownload>
      </view>

      <!-- 中间按钮：上一首、播放/暂停、下一首 -->
      <view class="controls-center">
        <view class="control-btn" :class="{ disabled: !hasPrev }" @click="playPrev">
          <text class="control-icon">⏮</text>
        </view>

        <view class="play-btn" @click="togglePlay">
          <text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
        </view>

        <view class="control-btn" :class="{ disabled: !hasNext }" @click="playNext">
          <text class="control-icon">⏭</text>
        </view>
      </view>

      <!-- 右侧按钮：收藏、分享 -->
      <view class="controls-side">
        <view class="control-btn" @click="toggleFavorite">
          <text class="control-icon">{{ audio?.isFavorite ? '❤️' : '🤍' }}</text>
        </view>
        <view class="control-btn share-btn" @click="handleShare">
          <text class="control-icon">📤</text>
          <text class="share-label">分享得时长</text>
        </view>
      </view>
    </view>

    <!-- 播放列表 - 移动端设计，直接在界面中显示 -->
    <view class="playlist-section-mobile">
      <view class="playlist-header">
        <view class="playlist-header-left">
          <text class="playlist-title">播放列表</text>
          <text class="playlist-count">{{ playlist.length }}首</text>
        </view>
        <view class="playlist-header-right" @click="togglePlayMode">
          <text class="playlist-mode-icon">{{ getPlayModeIcon() }}</text>
          <text class="playlist-mode-text">{{ getPlayModeText() }}</text>
        </view>
      </view>
      <scroll-view class="playlist-list-mobile" scroll-y>
        <view
          v-for="(item, index) in playlist"
          :key="item.id"
          :class="['playlist-item', { active: isCurrentTrack(item) }]"
          @click="playFromPlaylist(index)"
        >
          <view class="playlist-index">{{ index + 1 }}</view>
          <view class="playlist-info">
            <text class="playlist-item-title">{{ item.title }}</text>
            <text class="playlist-item-meta">{{ formatDuration(item.audioDuration || 0) }}</text>
          </view>
          <view v-if="isCurrentTrack(item)" class="playing-icon">▶</view>
        </view>
      </scroll-view>
    </view>
    </template>

    <!-- 播放速度选择器 -->
    <view v-if="showRatePicker" class="rate-picker" @click="showRatePicker = false">
      <view class="rate-content" @click.stop>
        <text class="rate-title">播放速度</text>
        <view class="rate-options">
          <view
            v-for="rate in [0.5, 0.75, 0.9, 1, 1.25, 1.5, 2]"
            :key="rate"
            class="rate-item"
            :class="{ active: playRate === rate }"
            @click="handleSetRate(rate)"
          >
            <text>{{ rate }}x</text>
          </view>
        </view>
      </view>
    </view>

    <!-- 睡眠定时选择器 -->
    <view v-if="showSleepTimerPicker" class="rate-picker" @click="showSleepTimerPicker = false">
      <view class="rate-content" @click.stop>
        <text class="rate-title">睡眠定时</text>
        <view class="rate-options">
          <view
            v-for="option in sleepTimerOptions"
            :key="option.value"
            class="rate-item"
            :class="{ active: sleepTimer && sleepTimer.total === option.value }"
            @click="handleSetSleepTimer(option.value)"
          >
            <text>{{ option.label }}</text>
          </view>
        </view>
        <view v-if="sleepTimer" class="sleep-timer-cancel" @click="cancelSleepTimer">
          <text>取消定时</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onLoad } from '@dcloudio/uni-app';
import { useAudioStore } from '../../store/audio';
import { storeToRefs } from 'pinia';
import { get, put, post, getFullUrl } from '../../utils/request';
import { trackEvent, AnalyticsEvents } from '../../utils/analytics';
import type { AudioItem } from '../../types';
import AudioDownload from '../../components/AudioDownload.vue';

// 分享数据接口
interface ShareCardData {
  title: string;
  description: string;
  imageUrl: string;
  shareUrl: string;
  shareCode: string;
  chapterId: number;
  wordCount: number;
  audioDuration: number;
}

const audioStore = useAudioStore();

// 状态
const audioId = ref('');
const audio = ref<AudioItem | null>(null);
const showRatePicker = ref(false);
const showSleepTimerPicker = ref(false);
const showLyrics = ref(true);
const lyrics = ref<{ text: string; start: number; end: number }[]>([]);
const scrollIntoViewId = ref('');

// 分享数据
const shareData = ref<ShareCardData | null>(null);

// 睡眠定时
const sleepTimer = ref<{ remaining: number; total: number } | null>(null);
let sleepTimerInterval: ReturnType<typeof setInterval> | null = null;

// 睡眠定时选项
const sleepTimerOptions = [
  { label: '15分钟', value: 15 * 60 },
  { label: '30分钟', value: 30 * 60 },
  { label: '60分钟', value: 60 * 60 },
  { label: '当前播放结束', value: -1 },
];

// 解析 LRC 格式歌词（兼容换行分隔和单行连续两种格式）
function parseLrc(lrcText: string): { text: string; start: number; end: number }[] {
  if (!lrcText) return [];

  const timeline: { text: string; start: number; end: number }[] = [];

  // 优先按换行分割，如果只有1行则用全局匹配提取所有时间戳
  const lines = lrcText.split('\n');
  if (lines.length <= 1) {
    // 单行格式：用正则提取所有 [MM:SS.XX]text 对
    const matches = lrcText.matchAll(/\[(\d{2}):(\d{2})\.(\d{2})\]\s*([^[]*)/g);
    for (const m of matches) {
      const minutes = parseInt(m[1]);
      const seconds = parseInt(m[2]);
      const cs = parseInt(m[3]);
      const text = m[4].trim();
      if (text) {
        timeline.push({ text, start: minutes * 60 + seconds + cs / 100, end: 0 });
      }
    }
  } else {
    // 标准格式：按行处理
    for (const line of lines) {
      const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
      if (match) {
        const text = match[4].trim();
        if (text) {
          timeline.push({
            text,
            start: parseInt(match[1]) * 60 + parseInt(match[2]) + parseInt(match[3]) / 100,
            end: 0,
          });
        }
      }
    }
  }

  // 计算 end 时间（下一句的开始时间）
  for (let i = 0; i < timeline.length - 1; i++) {
    timeline[i].end = timeline[i + 1].start;
  }
  if (timeline.length > 0) {
    const last = timeline[timeline.length - 1];
    last.end = last.start + 5;
  }

  return timeline;
}

// 生成歌词时间线（按句子切割，实现逐句高亮跟随）
function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
  if (!text || totalDuration <= 0) return [];

  // 按 Markdown 结构分段
  const sections: string[] = [];
  const h1Parts = text.split(/(?=^# )/m);
  for (const h1Part of h1Parts) {
    if (!h1Part.trim()) continue;
    const h2Parts = h1Part.split(/(?=^## )/m);
    for (const h2Part of h2Parts) {
      const trimmed = h2Part.trim();
      if (!trimmed) continue;
      const isHeading = /^#{1,2}\s/.test(trimmed);
      if (isHeading) {
        // 提取标题行和正文，分别处理（避免将标题+正文当作一整条歌词）
        const titleMatch = trimmed.match(/^(#{1,2}\s+.+?)(\n|$)/);
        if (titleMatch) {
          sections.push(titleMatch[1].trim());
          const rest = trimmed.substring(titleMatch[0].length).trim();
          if (rest) {
            const paras = rest.split(/\n{2,}/).filter(p => p.trim());
            for (const para of paras) {
              const sentences = para.split(/(?<=[。！？；])/).filter(s => s.trim());
              sections.push(...sentences);
            }
          }
        } else {
          sections.push(trimmed);
        }
      } else {
        // 先按段落切，再按句子切 → 实现逐句级歌词跟随
        const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
        for (const para of paras) {
          const sentences = para.split(/(?<=[。！？；])/).filter(s => s.trim());
          sections.push(...sentences);
        }
      }
    }
  }
  // 降级：纯文本没有标题时，直接按句子分段
  if (sections.length <= 1) {
    const sentences = text.split(/(?<=[。！？；])/).filter(s => s.trim());
    sections.length = 0;
    sections.push(...sentences);
  }

  // 权重分配：标题 0.3x，正文 1.0x
  const weights = sections.map(s => /^#{1,2}\s/.test(s) ? 0.3 : 1.0);
  const weightedChars = sections.map((s, i) => {
    // 统计所有可见字符（含标点、数字、符号），不只汉字字母
    const chars = s.replace(/\s/g, '').length;
    return chars * weights[i];
  });
  const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
  if (totalWeighted === 0) return [];

  const timeline: { text: string; start: number; end: number }[] = [];
  let currentTime = 0;
  for (let i = 0; i < sections.length; i++) {
    const ratio = weightedChars[i] / totalWeighted;
    const segDuration = ratio * totalDuration;
    const start = currentTime;
    const end = currentTime + segDuration;
    timeline.push({ text: sections[i], start, end });
    currentTime += segDuration;
    // 标题后加停顿
    if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
      currentTime = Math.min(currentTime + 0.5, totalDuration);
    }
  }
  // 修正最后一段
  if (timeline.length > 0) {
    timeline[timeline.length - 1].end = totalDuration;
  }
  return timeline;
}

// 当前高亮的歌词索引
const currentLyricIndex = ref(-1);

// 直接监听 store 中的 currentTime，驱动高亮和滚动
watch(() => audioStore.currentTime, (time) => {
  if (!lyrics.value.length) return;
  const index = lyrics.value.findIndex(l => time >= l.start && time < l.end);
  currentLyricIndex.value = index;

  if (index >= 0) {
    // 滚动到前一行（最小为 0），使高亮行上下都有可见文本作为缓冲
    const scrollIndex = Math.max(0, index - 1);
    const targetId = 'lyric-' + scrollIndex;
    if (scrollIntoViewId.value !== targetId) {
      scrollIntoViewId.value = targetId;
    }
  }
});

// 从 store 获取状态
const { isPlaying, currentTime, duration, playRate, hasPrev, hasNext, playlist } = storeToRefs(audioStore);

// 页面加载
onMounted(async () => {
  const pages = getCurrentPages();
  const currentPage = pages[pages.length - 1] as any;
  // #ifdef APP-PLUS
  audioId.value = currentPage?.$page?.options?.id || '';
  // #endif
  // #ifndef APP-PLUS
  audioId.value = currentPage?.options?.id || '';
  // #endif

  if (audioId.value) {
    await fetchAudio();
  } else {
    uni.showToast({ title: '没有音频ID参数', icon: 'none' });
  }
});

// 页面显示时刷新音频状态
onShow(() => {
  // 刷新 store 中的当前音频信息
  if (audioStore.currentAudio) {
    audio.value = audioStore.currentAudio;
    // currentTime/duration 是 computed，自动从 store 读取，无需手动赋值
  }
});

// 监听 store 当前音频变化（自动切歌时同步标题和歌词）
watch(() => audioStore.currentAudio, (newAudio) => {
  if (newAudio && newAudio.id !== audio.value?.id) {
    audio.value = newAudio;
    updateLyricsForAudio(newAudio);
  }
});

// 页面卸载
onUnmounted(() => {
  // 清除睡眠定时器
  if (sleepTimerInterval) {
    clearInterval(sleepTimerInterval);
  }
  // 不销毁音频上下文，保持后台播放
});

// 获取音频详情
async function fetchAudio() {
  try {
    // 获取音频详情
    const result = await get<AudioItem>(`/player/audio/${audioId.value}`);
    audio.value = result;

    // 检查是否有音频URL
    if (!result.audioUrl) {
      uni.showToast({
        title: '该章节没有音频，请先生成音频',
        icon: 'none',
        duration: 2000
      });
      return;
    }

    // 获取播放列表（使用专辑章节列表）
    await fetchAlbumPlaylist(result);

    // 生成歌词时间线（始终使用前端按句子切割，比后端 LRC 更可靠）
    const audioDuration = result.audioDuration || audioStore.duration || 60;
    lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
    // 在歌词列表前插入空行作为视觉缓冲，确保高亮当前行时前后文都可见
    lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];

    // 开始播放（store 中已初始化 audioContext）
    audioStore.play(result);
  } catch (error: any) {
    console.error('获取音频失败:', error);
    uni.showToast({ title: error.message || '音频不存在', icon: 'none' });
  }
}

// 获取专辑播放列表
async function fetchAlbumPlaylist(currentAudio: AudioItem) {
  try {
    // 从音频信息中获取albumId（书籍ID） - 优先从音频对象获取
    let albumId = (currentAudio as any).albumId || (currentAudio as any).bookId;

    // 如果当前音频没有albumId，则尝试从章节信息获取
    if (!albumId) {
      // 由于 we're using chapter ID as audioId, try to get album info from chapters API
      // We'll use the current audioId to get chapter details and then album ID
      try {
        const chapterDetail = await get<any>(`/book-generator/books/chapters/${audioId.value}`);
        albumId = chapterDetail?.bookId; // The chapter detail now includes bookId
      } catch (err) {
        console.warn('无法通过章节ID获取专辑信息:', err);
      }
    }

    if (!albumId) {
      // 如果还是没有albumId，使用通用播放列表
      await fetchGenericPlaylist();
      return;
    }

    // 获取专辑章节列表（章→节→小节 三层嵌套）
    const data = await get<{ chapters: any[] }>(`/book-generator/books/${albumId}/chapters`);
    const chapters = data?.chapters || [];

    // 展平为线性播放列表：按目录顺序（深度优先，父→子）输出所有可播放叶子。
    // - level=1 章：若有直接音频则播放章；若下面有可播放的节/小节，则章本身不入列表（避免重复项）
    // - level=2 节：若自己挂音频则入列表；若只有 level=3 子小节有音频，则节本身不入
    // - level=3 小节：有音频则入列表
    const flatItems: any[] = [];
    const collectLeaves = (nodes: any[]): any[] => {
      const out: any[] = [];
      nodes.forEach((n: any) => {
        const children = (n.subsections || []).filter((c: any) => c);
        const childLeaves = collectLeaves(children);
        const hasOwnAudio = !!(n.audioUrl && String(n.audioUrl).trim() !== '');
        if (childLeaves.length > 0) {
          out.push(...childLeaves);
        } else if (hasOwnAudio && n.level !== undefined) {
          out.push(n);
        }
      });
      return out;
    };
    // 但 level=1 章本身若有音频且无子项，collectLeaves 会返回该章，已涵盖。
    // 如果某章 level=1 自己有音频，同时 level=2 子节也有音频，按"只取叶子"会把子节列出、章不列，符合预期。
    flatItems.push(...collectLeaves(chapters));

    // 转换为播放列表格式
    const playlist = flatItems.map((c: any) => ({
      _id: c.id,
      id: c.id,
      title: c.title,
      audioUrl: c.audioUrl,
      audioDuration: c.audioDuration || 0,
      wordCount: c.wordCount || 0,
    }));

    // 找到当前音频在列表中的位置（audioId 可能是数字或字符串）
    const targetId = String(audioId.value);
    const currentIndex = playlist.findIndex(item => String(item.id) === targetId);

    if (playlist.length > 0) {
      audioStore.setPlaylist(playlist, currentIndex >= 0 ? currentIndex : 0, true);
    }
  } catch (error) {
    console.error('获取专辑播放列表失败:', error);
    // 失败时使用通用播放列表
    await fetchGenericPlaylist();
  }
}

// 获取通用播放列表
async function fetchGenericPlaylist() {
  try {
    const result = await get<{ list: AudioItem[] }>('/player/audio/list', {
      page: 1,
      pageSize: 100,
    });

    const index = result.list.findIndex(item => item._id === audioId.value);
    if (index >= 0) {
      audioStore.setPlaylist(result.list, index, true);
    }
  } catch (error) {
    console.error('获取播放列表失败:', error);
  }
}

// 格式化时长
function formatDuration(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs.toString().padStart(2, '0')}`;
}

// 判断播放列表项是否为当前播放
function isCurrentTrack(item: AudioItem): boolean {
  const currentId = String(audio.value?._id || audio.value?.id);
  return String(item.id) === currentId || String(item._id) === currentId;
}

/**
 * 获取分享卡片数据（从后端）
 */
async function fetchShareCard() {
  if (!audio.value?._id) return;
  try {
    const res = await get<ShareCardData>(`/share/card/${audio.value._id}`);
    shareData.value = res;
  } catch (e) {
    console.error('获取分享卡片失败', e);
  }
}

/**
 * 处理分享（增强版）
 */
async function handleShare() {
  trackEvent(AnalyticsEvents.SHARE_CLICK, { audioId: audio.value?._id });

  // 先获取最新的分享卡片数据
  await fetchShareCard();

  const shareTitle = shareData.value?.title || '声工坊有声作品';
  const shareDesc = shareData.value?.description || '用AI生成的高品质有声书，快来听！';
  const shareUrl = shareData.value?.shareUrl || copyShareLink();

  // #ifdef H5
  if (navigator.share) {
    try {
      await navigator.share({
        title: shareTitle,
        text: shareDesc,
        url: shareUrl,
      });
      await trackShareComplete('h5_share_api');
    } catch (e) {
      // 用户取消分享
      if ((e as Error).name !== 'AbortError') {
        await copyToClipboard(shareUrl);
      }
    }
    return;
  }
  // #endif

  // 小程序/App：复制链接
  await copyToClipboard(shareUrl);
}

/**
 * 向后端上报分享完成
 */
async function trackShareComplete(platform: string) {
  try {
    await post('/share/track', {
      audioId: audio.value?._id,
      platform,
      shareCode: shareData.value?.shareCode,
    });
    trackEvent(AnalyticsEvents.SHARE_COMPLETE, {
      audioId: audio.value?._id,
      platform,
    });
  } catch (e) {
    // 静默失败
  }
}

/**
 * 复制到剪贴板（带分享码链接）
 */
async function copyToClipboard(url: string) {
  try {
    await uni.setClipboardData({ data: url });
    uni.showToast({ title: '链接已复制，分享给朋友吧！', icon: 'success' });
    await trackShareComplete('copy_link');
  } catch (e) {
    uni.showToast({ title: '请截图分享', icon: 'none' });
  }
}

// 页面加载时，检测分享码来源
onLoad((options: any) => {
  const shareCode = options?.sc;
  if (shareCode) {
    // 上报分享访问
    get(`/share/visit?code=${shareCode}`).catch(() => {});
    trackEvent(AnalyticsEvents.INVITE_ACCEPT, { shareCode });
    // 存储分享码，用于后续注册绑定
    uni.setStorageSync('invite_code', shareCode);
  }
});

// 返回
function goBack() {
  const pages = getCurrentPages();
  if (pages.length > 1) {
    uni.navigateBack();
  } else {
    uni.switchTab({ url: '/pages/index/index' });
  }
}

// 跳转进度
function handleSeek(e: any) {
  audioStore.seek(e.detail.value);
}

// 切换播放/暂停
function togglePlay() {
  audioStore.togglePlay();
}

// 播放模式
function togglePlayMode() {
  audioStore.togglePlayMode();
}

function getPlayModeIcon() {
  const icons = {
    sequence: '🔂',
    loop: '🔁',
    single: '🔂',
    random: '🔀',
  };
  return icons[audioStore.playMode] || '🔂';
}

function getPlayModeText() {
  const texts = {
    sequence: '顺序',
    loop: '循环',
    single: '单曲',
    random: '随机',
  };
  return texts[audioStore.playMode] || '顺序';
}

// 更新歌词文本（切换章节时调用）
async function updateLyricsForAudio(audioItem: AudioItem) {
  let text = audioItem?.text || '';
  let lrc = (audioItem as any)?.lrcLyrics || '';
  const ad = audioItem?.audioDuration || audioStore.duration || 60;

  if (!text && !lrc) {
    try {
      const detail = await get<any>(`/book-generator/books/chapters/${audioItem.id}`);
      text = detail?.content || '';
      lrc = detail?.lrcLyrics || '';
    } catch (e) {
      console.warn('获取章节文本失败:', e);
    }
  }

  lyrics.value = generateLyricsTimeline(text, ad);
  lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
}

// 上一首
function playPrev() {
  if (hasPrev.value) {
    audioStore.playPrev();
    audio.value = audioStore.currentAudio;
    if (audio.value) updateLyricsForAudio(audio.value);
  }
}

// 下一首
function playNext() {
  if (hasNext.value) {
    audioStore.playNext();
    audio.value = audioStore.currentAudio;
    if (audio.value) updateLyricsForAudio(audio.value);
  }
}

// 从播放列表选择
function playFromPlaylist(index: number) {
  audioStore.playAt(index);
  audio.value = playlist.value[index];
  if (audio.value) updateLyricsForAudio(audio.value);
}

// 设置播放速度
function handleSetRate(rate: number) {
  audioStore.setPlayRate(rate);
  showRatePicker.value = false;
}

// 睡眠定时格式化
function formatSleepTimer(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${mins}:${secs.toString().padStart(2, '0')}`;
}

// 设置睡眠定时
function handleSetSleepTimer(seconds: number) {
  showSleepTimerPicker.value = false;

  if (seconds === -1) {
    // 当前播放结束
    const remaining = duration.value - currentTime.value;
    if (remaining > 0) {
      startSleepTimer(Math.ceil(remaining));
    } else {
      uni.showToast({ title: '当前播放即将结束', icon: 'none' });
      return;
    }
  } else {
    startSleepTimer(seconds);
  }
}

// 开始睡眠定时
function startSleepTimer(seconds: number) {
  // 清除之前的定时器
  if (sleepTimerInterval) {
    clearInterval(sleepTimerInterval);
  }

  sleepTimer.value = { remaining: seconds, total: seconds };
  sleepTimerInterval = setInterval(() => {
    if (sleepTimer.value) {
      sleepTimer.value.remaining--;
      if (sleepTimer.value.remaining <= 0) {
        clearInterval(sleepTimerInterval!);
        sleepTimerInterval = null;
        // 暂停播放（currentTime已保存，下次打开可继续）
        audioStore.pause();
        sleepTimer.value = null;
        uni.showToast({ title: '睡眠定时已到，自动暂停', icon: 'none' });
      }
    }
  }, 1000);
}

// 取消睡眠定时
function cancelSleepTimer() {
  showSleepTimerPicker.value = false;
  if (sleepTimerInterval) {
    clearInterval(sleepTimerInterval);
    sleepTimerInterval = null;
  }
  sleepTimer.value = null;
  uni.showToast({ title: '已取消睡眠定时', icon: 'none' });
}

// 切换收藏
async function toggleFavorite() {
  if (!audio.value) return;

  try {
    const result = await put<{ isFavorite: boolean }>(`/audio/${audio.value._id}/favorite`);
    audio.value.isFavorite = result.isFavorite;
    uni.showToast({
      title: result.isFavorite ? '已收藏' : '已取消收藏',
      icon: 'success',
    });
  } catch (error) {
    console.error('操作失败:', error);
  }
}

/**
 * 复制分享链接（旧版本兼容）
 */
function copyShareLink(): string {
  if (!audio.value) return '';

  // #ifdef H5
  const shareUrl = `${window.location.origin}/#/pages/player/index?id=${audio.value._id}`;
  // #endif

  // #ifndef H5
  // App 端使用 pages.json 中配置的原生分享或直接显示页面路径
  const shareUrl = `pages/player/index?id=${audio.value._id}`;
  // #endif

  return shareUrl;
}
</script>

<style scoped>
.page {
  height: 100vh;
  background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
  width: 100%;
  box-sizing: border-box;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* 空状态 */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 200rpx 40rpx;
  text-align: center;
}

.empty-icon {
  font-size: 120rpx;
  margin-bottom: 32rpx;
  opacity: 0.5;
}

.empty-title {
  font-size: 36rpx;
  font-weight: 600;
  color: #ffffff;
  margin-bottom: 16rpx;
}

.empty-desc {
  font-size: 28rpx;
  color: #9ca3af;
  margin-bottom: 48rpx;
}

.empty-btn {
  padding: 24rpx 64rpx;
  background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
  color: #ffffff;
  border-radius: 48rpx;
  font-size: 28rpx;
  border: none;
}

.nav-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20rpx 32rpx 12rpx;
}

.nav-center {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.nav-actions {
  display: flex;
  align-items: center;
  gap: 8rpx;
}

.nav-btn {
  width: 64rpx;
  height: 64rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.15s ease;
}

.nav-btn:active {
  transform: scale(0.85);
}

.sleep-timer-badge {
  display: flex;
  align-items: center;
  background: rgba(79, 70, 229, 0.3);
  padding: 8rpx 16rpx;
  border-radius: 24rpx;
  margin-top: 8rpx;
}

.sleep-timer-icon {
  font-size: 20rpx;
  margin-right: 6rpx;
}

.sleep-timer-text {
  font-size: 22rpx;
  color: #a5b4fc;
  font-weight: 500;
}

.nav-icon {
  font-size: 36rpx;
  color: #ffffff;
}

.nav-btn-lyrics {
  width: auto;
  padding: 0 16rpx;
  gap: 4rpx;
}

.nav-btn-label {
  font-size: 20rpx;
  color: #a5b4fc;
  white-space: nowrap;
}

.nav-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #ffffff;
}

.cover-section {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 24rpx 0 16rpx;
}

.cover {
  width: 180rpx;
  height: 180rpx;
  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  border-radius: 24rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 16rpx;
  box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.4);
}

.cover-icon {
  font-size: 64rpx;
}

.title {
  font-size: 32rpx;
  font-weight: 600;
  color: #ffffff;
  text-align: center;
  margin-bottom: 8rpx;
  max-width: 600rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.meta {
  font-size: 24rpx;
  color: #9ca3af;
}

.text-preview {
  margin: 0 32rpx;
  padding: 24rpx;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 16rpx;
  width: calc(100% - 64rpx);
  box-sizing: border-box;
  overflow: hidden;
}

.lyrics-container {
  display: flex;
  flex-direction: column;
  width: 100%;
  box-sizing: border-box;
}

.lyric-line {
  padding: 8rpx 16rpx;
  border-radius: 12rpx;
  opacity: 0.5;
  width: 100%;
  box-sizing: border-box;
  margin-bottom: 8rpx;
}

.lyric-line.active {
  background: rgba(79, 70, 229, 0.2);
  opacity: 1;
}

.lyric-text {
  font-size: 24rpx;
  color: #d1d5db;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
  width: 100%;
  box-sizing: border-box;
}

.lyric-line.active .lyric-text {
  color: #ffffff;
  font-weight: 500;
}

.progress-section {
  display: flex;
  align-items: center;
  padding: 32rpx;
}

.time {
  font-size: 22rpx;
  color: #9ca3af;
  width: 80rpx;
  text-align: center;
}

.progress-slider {
  flex: 1;
  margin: 0 16rpx;
}

.controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24rpx 32rpx;
}

.controls-side,
.controls-center {
  display: flex;
  align-items: center;
}

.controls-side {
  margin-right: 16rpx;
  gap: 12rpx;
}

.controls-center {
  margin-right: 24rpx;
}

.control-btn {
  width: 70rpx;
  height: 70rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.15);
}

.control-btn.disabled {
  opacity: 0.4;
}

.control-btn.share-btn {
  flex-direction: column;
  width: 100rpx;
  height: 80rpx;
  background: linear-gradient(135deg, rgba(79, 70, 229, 0.3) 0%, rgba(129, 140, 248, 0.3) 100%);
}

.share-label {
  font-size: 18rpx;
  color: #a5b4fc;
  margin-top: 4rpx;
}

.control-text {
  font-size: 24rpx;
  color: #ffffff;
  font-weight: 600;
}

/* 播放速度显示按钮 */
.speed-display-btn {
  width: auto;
  min-width: 120rpx;
  height: 70rpx;
  padding: 0 20rpx;
  border-radius: 35rpx;
  gap: 6rpx;
  background: linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(129, 140, 248, 0.25) 100%);
  border: 2rpx solid rgba(129, 140, 248, 0.3);
}

.speed-label {
  font-size: 20rpx;
  color: #a5b4fc;
  font-weight: 500;
}

.speed-value {
  font-size: 28rpx;
  color: #ffffff;
  font-weight: 700;
}

.control-icon {
  font-size: 36rpx;
  color: #ffffff;
}

.play-btn {
  width: 110rpx;
  height: 110rpx;
  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8rpx 24rpx rgba(79, 70, 229, 0.5);
  border: 4rpx solid rgba(255, 255, 255, 0.3);
}

.play-icon {
  font-size: 44rpx;
  color: #ffffff;
  margin-left: 6rpx;
}

.bottom-actions {
  display: flex;
  justify-content: center;
  padding: 48rpx;
}

.bottom-actions .action-btn {
  margin: 0 40rpx;
}

.action-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: transparent;
  border: none;
  padding: 0;
  border-radius: 50%;
  padding: 16rpx;
  background: rgba(255, 255, 255, 0.1);
}

.action-icon {
  font-size: 48rpx;
  margin-bottom: 8rpx;
}

.action-text {
  font-size: 24rpx;
  color: #9ca3af;
}

.rate-picker {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: flex-end;
  z-index: 1000;
}

.rate-content {
  width: 100%;
  background: #1f2937;
  border-radius: 32rpx 32rpx 0 0;
  padding: 48rpx;
}

.rate-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #ffffff;
  text-align: center;
  margin-bottom: 32rpx;
}

.rate-options {
  display: flex;
  flex-wrap: wrap;
}

.rate-item {
  flex: 1;
  min-width: 180rpx;
  height: 80rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #374151;
  border-radius: 16rpx;
  border: 2rpx solid transparent;
  margin: 12rpx;
}

.rate-item.active {
  background: #4f46e5;
  border-color: #6366f1;
}

.rate-item text {
  font-size: 28rpx;
  color: #ffffff;
}

.sleep-timer-cancel {
  margin-top: 24rpx;
  padding: 24rpx;
  background: rgba(239, 68, 68, 0.2);
  border-radius: 16rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.sleep-timer-cancel text {
  font-size: 28rpx;
  color: #fca5a5;
}

/* 移动端播放列表设计 - 直接嵌入界面 */
.playlist-section-mobile {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: rgba(31, 41, 55, 0.8);
  border-top-left-radius: 32rpx;
  border-top-right-radius: 32rpx;
  padding: 20rpx 0 0;
  box-shadow: 0 -8rpx 16rpx rgba(0, 0, 0, 0.1);
}

.playlist-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 32rpx 20rpx;
  border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
  margin-bottom: 16rpx;
}

.playlist-header-left {
  display: flex;
  align-items: center;
}

.playlist-header-left .playlist-mode-icon,
.playlist-header-left .playlist-count {
  margin-left: 16rpx;
}

.playlist-header-right {
  display: flex;
  align-items: center;
  padding: 12rpx 20rpx;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 40rpx;
}

.playlist-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #ffffff;
}

.playlist-count {
  font-size: 24rpx;
  color: #9ca3af;
}

.playlist-mode-icon {
  font-size: 28rpx;
}

.playlist-mode-text {
  font-size: 24rpx;
  color: #ffffff;
  font-weight: 500;
}

.playlist-list-mobile {
  flex: 1;
  min-height: 0;
  max-height: 60vh;
  padding: 0 16rpx 16rpx;
  box-sizing: border-box;
}

.playlist-item {
  display: flex;
  align-items: center;
  padding: 24rpx 32rpx;
  border-radius: 16rpx;
}

.playlist-item .playlist-info {
  margin-left: 20rpx;
}

.playlist-item .playing-icon {
  margin-left: auto;
}

.playlist-item.active {
  background: rgba(79, 70, 229, 0.2);
}

.playlist-index {
  width: 50rpx;
  height: 50rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.15); /* 更明显的背景 */
  border-radius: 50%;
  font-size: 24rpx;
  color: #ffffff; /* 白色文字 */
  flex-shrink: 0;
  font-weight: 600; /* 加粗 */
}

.playlist-item.active .playlist-index {
  background: #4f46e5;
  color: #ffffff;
}

.playlist-info {
  flex: 1;
  min-width: 0;
}

.playlist-item-title {
  font-size: 28rpx;
  color: #ffffff;
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-bottom: 6rpx;
  font-weight: 500;
}

.playlist-item-meta {
  font-size: 24rpx;
  color: #9ca3af;
}

.playing-icon {
  width: 50rpx;
  height: 50rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24rpx;
  color: #4f46e5;
  background: rgba(79, 70, 229, 0.2); /* 背景使其更明显 */
  border-radius: 50%; /* 圆形背景 */
  flex-shrink: 0;
}
</style>
// ====== pages/payment-confirm/index.vue ======
<template>
  <view class="page">
    <!-- 顶部导航栏 -->
    <view class="nav-bar">
      <view class="nav-content">
        <view class="nav-left" @click="goBack">
          <text class="back-icon">←</text>
        </view>
        <text class="page-title">确认支付</text>
      </view>
    </view>

    <!-- 支付内容 -->
    <view class="content">
      <!-- 订单信息 -->
      <view class="order-card">
        <text class="order-title">订单信息</text>
        <view class="order-info">
          <text class="info-label">商品</text>
          <text class="info-value">{{ orderInfo.planName || '-' }}</text>
        </view>
        <view class="order-info">
          <text class="info-label">订单号</text>
          <text class="info-value">{{ orderInfo.orderNo || '-' }}</text>
        </view>
        <view class="order-info highlight">
          <text class="info-label">应付金额</text>
          <text class="info-value price">¥{{ orderInfo.amount || 0 }}</text>
        </view>
      </view>

      <!-- 支付方式选择 -->
      <view class="payment-section">
        <text class="section-title">选择支付方式</text>
        <!-- 公众号环境提示 -->
        <view class="wechat-notice" v-if="inWechat">
          <text class="wechat-notice-icon">💚</text>
          <text class="wechat-notice-text">检测到微信环境，将自动使用微信支付</text>
        </view>
        <view class="payment-methods">
          <view
            class="payment-method"
            :class="{ active: paymentMethod === 'alipay' }"
            @click="paymentMethod = 'alipay'"
          >
            <view class="method-radio">
              <view class="radio-inner" v-if="paymentMethod === 'alipay'"></view>
            </view>
            <text class="method-icon">💙</text>
            <text class="method-name">支付宝</text>
          </view>
          <view
            class="payment-method"
            :class="{ active: paymentMethod === 'wechat' }"
            @click="paymentMethod = 'wechat'"
          >
            <view class="method-radio">
              <view class="radio-inner" v-if="paymentMethod === 'wechat'"></view>
            </view>
            <text class="method-icon">🟢</text>
            <text class="method-name">微信支付</text>
            <text class="method-badge" v-if="inWechat">推荐</text>
          </view>
        </view>
      </view>

      <!-- 操作按钮 -->
      <view class="actions">
        <button class="btn-secondary" @click="goBack">取消</button>
        <button class="btn-primary" @click="handlePay">确认支付</button>
      </view>

      <!-- 提示 -->
      <view class="tips">
        <text class="tips-title">温馨提示</text>
        <text class="tips-text">• 请在24小时内完成支付</text>
        <text class="tips-text">• 支付成功后权益将自动到账</text>
        <text class="tips-text">• 如支付遇到问题，请联系客服</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { post, get } from '../../utils/request';

// ==================== 微信浏览器检测 ====================
function isInWechat(): boolean {
  // #ifdef H5
  const ua = navigator.userAgent.toLowerCase();
  return ua.includes('micromessenger');
  // #endif
  // #ifndef H5
  return false;
  // #endif
}

// 获取不含 hash 的基础URL（用于OAuth redirect_uri）
function getBaseUrl(): string {
  // #ifdef H5
  return window.location.origin + window.location.pathname;
  // #endif
  return '';
}

// 保存当前页面上下文到 sessionStorage（OAuth 来回会丢失 hash 路由状态）
function savePageContext() {
  try {
    sessionStorage.setItem('wx_oauth_return', window.location.href);
  } catch (_) {}
}

// 恢复页面上下文
function restorePageContext(): string | null {
  try {
    return sessionStorage.getItem('wx_oauth_return');
  } catch (_) { return null; }
}

const orderInfo = ref<{
  orderNo: string;
  amount: number;
  planName: string;
}>({
  orderNo: '',
  amount: 0,
  planName: ''
});

const planId = ref<number>(0);
const productType = ref<string>('');  // 'subscription' | 'token-pack'
const tokenPackQuantity = ref<number>(0);
const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
const inWechat = ref(false);
const openid = ref('');
const paying = ref(false);
const lastPayClick = ref(0); // 防重：记录上次点击时间
let checkTimer: ReturnType<typeof setInterval> | null = null;

// ==================== 微信 OAuth 处理 ====================
async function handleWechatOAuth() {
  // #ifdef H5
  // OAuth 回调已在 App.vue 全局处理，这里只需读取缓存的 openid
  const cachedOpenid = localStorage.getItem('wx_openid');
  if (cachedOpenid) {
    openid.value = cachedOpenid;
    console.log('[WeChat Pay] 使用缓存的 openid');
    return;
  }

  // 双重保险：检查 URL search 中的 code（如果 App.vue 未拦截到）
  const searchParams = new URLSearchParams(window.location.search);
  const code = searchParams.get('code');
  if (code) {
    uni.showLoading({ title: '授权中...' });
    try {
      const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
      openid.value = result.openid;
      localStorage.setItem('wx_openid', result.openid);
      console.log('[WeChat Pay] openid 获取成功，自动重新发起支付');

      // 清除 URL 中的 code/state
      searchParams.delete('code');
      searchParams.delete('state');
      const newSearch = searchParams.toString();
      const newUrl = window.location.origin + window.location.pathname
        + (newSearch ? '?' + newSearch : '');
      window.history.replaceState({}, '', newUrl);

      // 自动重新发起支付（不用用户再点一次）
      setTimeout(() => handleWechatJsapiPay(), 100);
    } catch (e) {
      console.error('[WeChat Pay] 获取 openid 失败:', e);
    } finally {
      uni.hideLoading();
    }
  }
  // #endif
}

// 跳转到微信 OAuth 授权
async function redirectToWechatOAuth() {
  // #ifdef H5
  try {
    // 保存当前完整 URL（含 hash 路由参数），OAuth 回来时恢复
    savePageContext();
    // 用不含 # 的 baseUrl 作为 redirect_uri，避免微信报"非法字符"
    const baseUrl = getBaseUrl();
    const result = await get<{ oauthUrl: string; state: string }>('/payment/wechat/oauth-url', {
      redirect: baseUrl
    });
    window.location.href = result.oauthUrl;
  } catch (e) {
    console.error('[WeChat Pay] 获取OAuth链接失败:', e);
  }
  // #endif
}

onMounted(async () => {
  const pages = getCurrentPages();
  const currentPage = pages[pages.length - 1] as any;
  // #ifdef APP-PLUS
  const options = currentPage?.$page?.options || {};
  // #endif
  // #ifndef APP-PLUS
  const options = currentPage?.options || {};
  // #endif

  // 检查产品类型
  productType.value = options.productType || 'subscription';

  if (productType.value === 'token-pack') {
    // 积分包支付
    tokenPackQuantity.value = parseInt(options.quantity) || 1;
    orderInfo.value.planName = decodeURIComponent(options.planName || '积分包');
    orderInfo.value.amount = parseFloat(options.amount) || 0;
  } else if (options.planId) {
    planId.value = parseInt(options.planId);
    orderInfo.value.planName = decodeURIComponent(options.planName || '会员订阅');
    orderInfo.value.amount = parseFloat(options.amount) || 0;
  }

  // 检测是否在微信浏览器中
  if (isInWechat()) {
    inWechat.value = true;
    // 默认选中微信支付
    paymentMethod.value = 'wechat';
    // 处理 OAuth 回调
    await handleWechatOAuth();

    // 🔧 如果刚从 OAuth 回来 + openid 已缓存，自动触发支付
    try {
      const justDidOAuth = sessionStorage.getItem('wx_oauth_just_done');
      if (justDidOAuth === '1') {
        sessionStorage.removeItem('wx_oauth_just_done');
        const cachedOpenid = localStorage.getItem('wx_openid');
        if (cachedOpenid) {
          console.log('[WeChat Pay] 检测到 OAuth 刚完成，自动触发支付');
          // 延迟执行，确保页面已完全渲染
          setTimeout(() => {
            openid.value = cachedOpenid;
            handleWechatJsapiPay();
          }, 500);
        }
      }
    } catch (_) {}
    // 微信环境不预创建订单（JSAPI 支付时另外创建）
  } else {
    // 非微信环境：进入页面时直接创建订单
    if (productType.value === 'token-pack' || planId.value) {
      await createOrder();
    }
  }
});

async function createOrder() {
  try {
    uni.showLoading({ title: '创建订单...' });
    const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;

    let result: any;

    if (productType.value === 'token-pack') {
      // 积分包订单
      result = await post<{
        orderNo: string;
        amount: number;
        paymentUrl?: string;
        qrcode?: string;
        planName?: string;
      }>('/payment/token-packs/create', {
        quantity: tokenPackQuantity.value,
        paymentMethod: paymentMethod.value,
        returnUrl
      });
    } else {
      // 套餐订阅订单
      result = await post<{
        orderNo: string;
        amount: number;
        paymentUrl?: string;
        qrcode?: string;
        planName?: string;
      }>('/payment/create', {
        planId: planId.value,
        paymentMethod: paymentMethod.value,
        returnUrl
      });
    }

    orderInfo.value.orderNo = result.orderNo;
    uni.hideLoading();
  } catch (error) {
    uni.hideLoading();
    console.error('创建订单失败:', error);
  }
}

// 查询微信订单状态（用于支付后确认）
async function checkWechatOrderStatus(orderNo: string): Promise<string | null> {
  try {
    const result = await get<any>(`/payment/wechat/query/${orderNo}`);
    const state = result?.data?.trade_state || result?.trade_state || null;
    console.log('[WeChat Pay] 订单查询结果:', orderNo, state);
    return state;
  } catch (e) {
    console.error('[WeChat Pay] 订单查询失败:', e);
    return null;
  }
}

// ==================== 微信 JSAPI 支付（公众号内） ====================
// 使用 WeixinJSBridge.invoke 直接调起（官方 V3 推荐方式，无需 JSSDK 配置）
// 参考: https://pay.weixin.qq.com/doc/v3/merchant/4012791870
// 常见失败原因检查清单：
//   1. timeStamp 必须是字符串（不是数字） - 后端已修复
//   2. 微信商户平台需配置「支付授权目录」
//   3. 公众号后台需配置「网页授权域名」
//   4. 使用正确的公众号 appId（非小程序 appId）
let pendingPayResult: any = null; // 用于 WeixinJSBridge 异步注入场景

async function handleWechatJsapiPay() {
  // 如果没有 openid，需要先授权
  if (!openid.value) {
    console.log('[WeChat Pay] 无 openid，跳转 OAuth 授权');
    await redirectToWechatOAuth();
    return;
  }

  paying.value = true;
  uni.showLoading({ title: '创建订单...' });

  try {
    // 创建 JSAPI 支付订单
    let result: any;

    if (productType.value === 'token-pack') {
      result = await post<any>('/payment/wechat/jsapi', {
        productType: 'token-pack',
        quantity: tokenPackQuantity.value,
        openid: openid.value
      });
    } else {
      result = await post<any>('/payment/wechat/jsapi', {
        planId: planId.value,
        openid: openid.value
      });
    }

    // 注意：post 函数返回的是 response.data，即 { code, message, data: {...} }
    const responseData = result.data || result;
    orderInfo.value.orderNo = responseData.orderNo;

    // 🔧 保存待调用结果，用于 WeixinJSBridge 异步注入场景
    pendingPayResult = responseData;

    console.log('[WeChat Pay] 订单创建成功，准备调起支付');
    console.log('[WeChat Pay] payParams 详情:', JSON.stringify({
      appId: responseData.payParams?.appId,
      timeStamp: `${responseData.payParams?.timeStamp} (type:${typeof responseData.payParams?.timeStamp})`,
      nonceStr: responseData.payParams?.nonceStr,
      package: responseData.payParams?.package,
      signType: responseData.payParams?.signType,
      paySign_length: responseData.payParams?.paySign?.length,
    }));

    uni.hideLoading();
    uni.showLoading({ title: '拉起支付...' });

    // 调用 WeixinJSBridge（带自动重试）
    tryInvokeWeixinPay(responseData);

  } catch (error: any) {
    paying.value = false;
    uni.hideLoading();
    console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
    const errMsg = error?.message || String(error);

    if (errMsg.includes('openid')) {
      localStorage.removeItem('wx_openid');
      openid.value = '';
      await redirectToWechatOAuth();
      return;
    }

    // 更友好的错误提示
    let tip = '支付失败';
    if (errMsg.includes('appid')) tip = '微信AppID配置错误';
    else if (errMsg.includes('mchid')) tip = '商户号配置错误';
    else if (errMsg.includes('PARAM_ERROR')) tip = '支付参数错误，请检查配置';
    else if (errMsg.includes('SIGN_ERROR')) tip = '签名验证失败，请检查证书';
    else if (errMsg.includes('NOT_ENOUGH')) tip = '商户余额不足';
    else tip = errMsg.substring(0, 50);

    uni.showModal({
      title: '支付失败',
      content: `错误：${tip}\n\n请截图发给客服排查`,
      showCancel: false,
      confirmText: '知道了'
    });
  }
}

// 尝试调起微信支付（带 WeixinJSBridge 检测和重试）
function tryInvokeWeixinPay(payResult: any, retryCount: number = 0) {
  const MAX_RETRY = 30; // 最多等 3 秒（100ms × 30）
  const wjsBridge = (window as any).WeixinJSBridge;

  if (wjsBridge && typeof wjsBridge.invoke === 'function') {
    // ✅ WeixinJSBridge 已就绪，直接调起
    console.log('[WeChat Pay] WeixinJSBridge 就绪，调起支付');
    invokeWeixinJSBridge(payResult);
    return;
  }

  // WeixinJSBridge 未就绪，等待注入
  if (retryCount === 0) {
    console.log('[WeChat Pay] WeixinJSBridge 未就绪，开始等待注入...');

    // 方式 1：监听官方注入事件
    if (document.addEventListener) {
      document.addEventListener('WeixinJSBridgeReady', () => {
        console.log('[WeChat Pay] 收到 WeixinJSBridgeReady 事件');
        invokeWeixinJSBridge(pendingPayResult || payResult);
      }, { once: true });
    }

    // 方式 2：兼容旧版 IE
    if ((document as any).attachEvent) {
      (document as any).attachEvent('onWeixinJSBridgeReady', () => {
        console.log('[WeChat Pay] 收到 onWeixinJSBridgeReady 事件');
        invokeWeixinJSBridge(pendingPayResult || payResult);
      });
    }
  }

  // 方式 3：轮询检测（兜底机制，解决事件丢失问题）
  if (retryCount < MAX_RETRY) {
    setTimeout(() => tryInvokeWeixinPay(payResult, retryCount + 1), 100);
  } else {
    // 超时：WeixinJSBridge 始终未注入
    paying.value = false;
    uni.hideLoading();
    console.error('[WeChat Pay] ❌ WeixinJSBridge 注入超时（等待3秒）');
    uni.showModal({
      title: '环境异常',
      content: '请在微信内打开此页面进行支付\n\n提示：\n1. 确认在微信浏览器中访问\n2. 确认域名已在公众号配置',
      showCancel: false,
    });
  }
}

// 调用 WeixinJSBridge.invoke
function invokeWeixinJSBridge(payResult: any) {
  const payParams = payResult?.payParams;

  if (!payParams || !payParams.package) {
    console.error('[WeChat Pay] ❌ payParams 异常:', JSON.stringify(payResult));
    paying.value = false;
    uni.hideLoading();
    uni.showModal({
      title: '参数异常',
      content: '支付参数不完整，请重试',
      showCancel: false,
    });
    return;
  }

  // 🔧 关键校验：确保所有字段都是字符串类型
  const wxp = {
    appId: String(payParams.appId || ''),
    timeStamp: String(payParams.timeStamp || ''),  // ⚠️ 必须是字符串！
    nonceStr: String(payParams.nonceStr || ''),
    package: String(payParams.package || ''),       // prepay_id=wx...
    signType: String(payParams.signType || 'RSA'),
    paySign: String(payParams.paySign || ''),
  };

  // 校验必填字段
  if (!wxp.appId || !wxp.timeStamp || !wxp.nonceStr || !wxp.package || !wxp.paySign) {
    console.error('[WeChat Pay] ❌ 支付参数不完整:', JSON.stringify(wxp));
    paying.value = false;
    uni.hideLoading();
    uni.showModal({
      title: '参数不完整',
      content: `缺少字段：${[
        !wxp.appId && 'appId',
        !wxp.timeStamp && 'timeStamp',
        !wxp.nonceStr && 'nonceStr',
        !wxp.package && 'package',
        !wxp.paySign && 'paySign',
      ].filter(Boolean).join(', ')}`,
      showCancel: false,
    });
    return;
  }

  console.log('[WeChat Pay] 🚀 调用 WeixinJSBridge.invoke');
  console.log('[WeChat Pay] 参数:', JSON.stringify({
    ...wxp,
    paySign: wxp.paySign.substring(0, 30) + '...',
    package_length: wxp.package.length,
  }));

  (window as any).WeixinJSBridge.invoke(
    'getBrandWCPayRequest',
    wxp,
    async (res: any) => {
      uni.hideLoading();
      paying.value = false;
      console.log('[WeChat Pay] 回调结果:', JSON.stringify(res));

      // 处理回调
      await handleWechatPayCallback(res, payResult.orderNo);
    }
  );
}

// 统一处理微信支付回调结果
async function handleWechatPayCallback(res: any, orderNo: string) {
  // err_msg 可能的值：
  // get_brand_wcpay_request:ok        - 支付成功
  // get_brand_wcpay_request:cancel    - 用户取消
  // get_brand_wcpay_request:fail      - 支付失败
  const errMsg = res?.err_msg || res?.errMsg || '';

  if (errMsg.indexOf(':ok') !== -1) {
    // 前端返回成功，查询服务端确认
    console.log('[WeChat Pay] ✅ 前端返回成功，查询服务端确认...');
    const orderStatus = await checkWechatOrderStatus(orderNo);

    if (orderStatus === 'SUCCESS') {
      uni.showModal({
        title: '支付成功',
        content: productType.value === 'token-pack' ? '积分包购买成功！' : '恭喜您订阅成功！',
        showCancel: false,
        success: () => uni.switchTab({ url: '/pages/mine/index' })
      });
    } else {
      // 可能回调还没到服务端
      uni.showToast({ title: '订单确认中，请稍后查看', icon: 'none', duration: 3000 });
    }
    return;
  }

  // 支付未成功
  console.log('[WeChat Pay] ❌ 支付未成功:', errMsg);

  // 官方建议：无论如何都查询一次
  const orderStatus = await checkWechatOrderStatus(orderNo);
  if (orderStatus === 'SUCCESS') {
    uni.showModal({
      title: '支付成功',
      content: productType.value === 'token-pack' ? '积分包购买成功！' : '恭喜您订阅成功！',
      showCancel: false,
      success: () => uni.switchTab({ url: '/pages/mine/index' })
    });
    return;
  }

  // 真正失败 - 显示具体错误和解决建议
  const isCancel = errMsg.indexOf(':cancel') !== -1;
  if (isCancel) {
    uni.showToast({ title: '已取消支付', icon: 'none' });
    return;
  }

  // 🔍 构造详细错误诊断
  let diagnosis = '';
  if (errMsg.includes('no_balance')) diagnosis = '\n原因：商户余额不足';
  else if (errMsg.includes('bank_error')) diagnosis = '\n原因：银行卡异常';
  else if (errMsg.includes('not_sufficient_funds')) diagnosis = '\n原因：余额不足';
  else if (errMsg.includes('param_error') || errMsg.includes('sign')) diagnosis = '\n原因：支付参数错误（请检查配置）';
  else diagnosis = `\n错误码：${errMsg}`;

  uni.showModal({
    title: '支付失败',
    content: `支付未完成${diagnosis}\n\n如持续失败，请截图联系客服`,
    showCancel: true,
    confirmText: '重新支付',
    cancelText: '关闭',
    success: (modalRes: any) => {
      if (modalRes.confirm) {
        handleWechatJsapiPay();
      }
    }
  });
}

async function handlePay() {
  // 防重处理：2秒内禁止重复点击（官方要求按钮防抖）
  const now = Date.now();
  if (now - lastPayClick.value < 2000) {
    console.log('[Payment] 防重：忽略重复点击');
    return;
  }
  lastPayClick.value = now;

  if (!planId.value && productType.value !== 'token-pack') {
    uni.showToast({ title: '参数错误', icon: 'none' });
    return;
  }

  // 公众号内微信支付 → 走 JSAPI
  if (inWechat.value && paymentMethod.value === 'wechat') {
    await handleWechatJsapiPay();
    return;
  }

  try {
    uni.showLoading({ title: '创建订单...' });

    // #ifdef H5
    const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
    // #endif
    // #ifndef H5
    // App 端使用相对路径
    const returnUrl = `/#/pages/payment-result/index`;
    // #endif

    let orderResult: any;

    if (productType.value === 'token-pack') {
      orderResult = await post<any>('/payment/token-packs/create', {
        quantity: tokenPackQuantity.value,
        paymentMethod: paymentMethod.value,
        returnUrl
      });
    } else {
      orderResult = await post<any>('/payment/create', {
        planId: planId.value,
        paymentMethod: paymentMethod.value,
        returnUrl
      });
    }

    uni.hideLoading();

    orderInfo.value.orderNo = orderResult.orderNo;

    // 微信支付 - 显示二维码
    if (paymentMethod.value === 'wechat' && orderResult.qrcode) {
      uni.navigateTo({
        url: `/pages/alipay-qrcode/index?orderNo=${orderResult.orderNo}&amount=${orderResult.amount}&planName=${encodeURIComponent(orderResult.planName || orderInfo.value.planName)}&qrcode=${encodeURIComponent(orderResult.qrcode)}&method=wechat`
      });
      return;
    }

    // 支付宝 H5 跳转
    // #ifdef H5
    if (orderResult.paymentUrl) {
      uni.showToast({ title: '即将跳转支付宝...', icon: 'none' });
      setTimeout(() => {
        window.location.href = orderResult.paymentUrl!;
      }, 1000);
    }
    // #endif

    // 开发环境模拟
    // #ifndef H5
    uni.showModal({
      title: '模拟支付',
      content: `确认支付 ¥${orderResult.amount}？\n（开发环境模拟）`,
      success: async (res) => {
        if (res.confirm) {
          await mockPay(orderResult.orderNo);
        }
      },
    });
    // #endif
  } catch (error: any) {
    uni.hideLoading();
    console.error('创建订单失败:', error);
    uni.showToast({ title: error.message || '创建订单失败', icon: 'none' });
  }
}

async function mockPay(orderNo: string) {
  try {
    uni.showLoading({ title: '支付中...' });
    await post('/payment/mock', { orderNo });
    uni.hideLoading();
    uni.showModal({
      title: '支付成功',
      content: productType.value === 'token-pack' ? '积分包购买成功！' : '恭喜您订阅成功！',
      showCancel: false,
      success: () => {
        uni.switchTab({ url: '/pages/mine/index' });
      }
    });
  } catch (error) {
    uni.hideLoading();
    uni.showToast({ title: '支付失败', icon: 'none' });
  }
}

function goBack() {
  const pages = getCurrentPages();
  if (pages.length > 1) {
    uni.navigateBack();
  } else {
    uni.switchTab({ url: '/pages/member/index' });
  }
}
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f5f5;
}

.nav-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 88rpx;
  padding-top: env(safe-area-inset-top);
  background: #ffffff;
  border-bottom: 1px solid #e5e7eb;
  z-index: 100;
}

.nav-content {
  height: 100%;
  display: flex;
  align-items: center;
  padding: 0 24rpx;
}

.nav-left {
  width: 80rpx;
  display: flex;
  align-items: center;
}

.back-icon {
  font-size: 36rpx;
  color: #333;
}

.page-title {
  flex: 1;
  text-align: center;
  font-size: 32rpx;
  font-weight: 500;
  color: #333;
  margin-right: 80rpx;
}

.content {
  padding-top: calc(120rpx + env(safe-area-inset-top));
  padding-left: 32rpx;
  padding-right: 32rpx;
}

.order-card {
  background: #ffffff;
  border-radius: 16rpx;
  padding: 32rpx;
  margin-bottom: 32rpx;
}

.order-title {
  font-size: 28rpx;
  font-weight: 600;
  color: #1f2937;
  margin-bottom: 24rpx;
  display: block;
}

.order-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16rpx 0;
  border-bottom: 1px solid #f3f4f6;
}

.order-info:last-child {
  border-bottom: none;
}

.order-info.highlight {
  margin-top: 16rpx;
  padding-top: 24rpx;
  border-top: 2rpx dashed #e5e7eb;
  border-bottom: none;
}

.info-label {
  font-size: 26rpx;
  color: #9ca3af;
}

.info-value {
  font-size: 26rpx;
  color: #1f2937;
}

.info-value.price {
  font-size: 36rpx;
  font-weight: 700;
  color: #ef4444;
}

.payment-section {
  background: #ffffff;
  border-radius: 16rpx;
  padding: 32rpx;
  margin-bottom: 32rpx;
}

.section-title {
  font-size: 28rpx;
  font-weight: 600;
  color: #1f2937;
  margin-bottom: 24rpx;
  display: block;
}

.payment-methods {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
}

.payment-method {
  display: flex;
  align-items: center;
  padding: 24rpx;
  background: #f9fafb;
  border: 3rpx solid #e5e7eb;
  border-radius: 12rpx;
  transition: all 0.3s;
}

.payment-method.active {
  border-color: #667eea;
  background: #f0f2ff;
}

.method-radio {
  width: 40rpx;
  height: 40rpx;
  border: 3rpx solid #d1d5db;
  border-radius: 50%;
  margin-right: 16rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}

.payment-method.active .method-radio {
  border-color: #667eea;
}

.radio-inner {
  width: 24rpx;
  height: 24rpx;
  background: #667eea;
  border-radius: 50%;
}

.method-icon {
  font-size: 36rpx;
  margin-right: 12rpx;
}

.method-name {
  font-size: 28rpx;
  color: #1f2937;
  flex: 1;
}

.method-badge {
  font-size: 22rpx;
  color: #ffffff;
  background: #10b981;
  padding: 4rpx 12rpx;
  border-radius: 8rpx;
  margin-left: 8rpx;
}

.wechat-notice {
  display: flex;
  align-items: center;
  padding: 16rpx 20rpx;
  background: #ecfdf5;
  border: 1rpx solid #a7f3d0;
  border-radius: 10rpx;
  margin-bottom: 20rpx;
}

.wechat-notice-icon {
  font-size: 32rpx;
  margin-right: 12rpx;
}

.wechat-notice-text {
  font-size: 24rpx;
  color: #065f46;
}

.actions {
  display: flex;
  gap: 24rpx;
  margin-bottom: 32rpx;
}

.btn-primary,
.btn-secondary {
  flex: 1;
  height: 88rpx;
  border-radius: 44rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 30rpx;
  border: none;
}

.btn-primary {
  background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
  color: #ffffff;
}

.btn-secondary {
  background: #ffffff;
  color: #6b7280;
  border: 2rpx solid #e5e7eb;
}

.tips {
  background: #fef3c7;
  border-radius: 16rpx;
  padding: 24rpx;
  margin-bottom: 48rpx;
}

.tips-title {
  font-size: 24rpx;
  font-weight: 600;
  color: #92400e;
  display: block;
  margin-bottom: 12rpx;
}

.tips-text {
  font-size: 22rpx;
  color: #78350f;
  display: block;
  margin-bottom: 6rpx;
}
</style>
