|
@@ -160,7 +160,7 @@
|
|
|
v-for="scale in bookScales"
|
|
v-for="scale in bookScales"
|
|
|
:key="scale.value"
|
|
:key="scale.value"
|
|
|
:class="['scale-option', { active: newBook.bookScale === scale.value }]"
|
|
:class="['scale-option', { active: newBook.bookScale === scale.value }]"
|
|
|
- @click="newBook.bookScale = scale.value"
|
|
|
|
|
|
|
+ @click="selectBookScale(scale.value)"
|
|
|
>
|
|
>
|
|
|
<text class="scale-words">{{ scale.words }}</text>
|
|
<text class="scale-words">{{ scale.words }}</text>
|
|
|
<text class="scale-label">{{ scale.label }}</text>
|
|
<text class="scale-label">{{ scale.label }}</text>
|
|
@@ -169,6 +169,46 @@
|
|
|
</view>
|
|
</view>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
|
|
+ <!-- 预估信息(选择规模后显示) -->
|
|
|
|
|
+ <view v-if="bookEstimate" class="estimate-card">
|
|
|
|
|
+ <view class="estimate-header">
|
|
|
|
|
+ <text class="estimate-title">📊 生成预估</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="estimate-row">
|
|
|
|
|
+ <text class="estimate-label">预估字数:</text>
|
|
|
|
|
+ <text class="estimate-value">{{ bookEstimate.words.min }}~{{ bookEstimate.words.max }}字</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="estimate-row">
|
|
|
|
|
+ <text class="estimate-label">预估时长:</text>
|
|
|
|
|
+ <text class="estimate-value">{{ bookEstimate.audioMinutes.min }}~{{ bookEstimate.audioMinutes.max }}分钟</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view class="estimate-row">
|
|
|
|
|
+ <text class="estimate-label">预估章节:</text>
|
|
|
|
|
+ <text class="estimate-value">约{{ bookEstimate.estimatedChapters }}章</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 配额检查结果 -->
|
|
|
|
|
+ <view v-if="quotaCheck" class="quota-check">
|
|
|
|
|
+ <view v-if="quotaCheck.allowed" class="quota-ok">
|
|
|
|
|
+ <text class="quota-icon">✅</text>
|
|
|
|
|
+ <text class="quota-text">额度充足,可生成</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-else class="quota-warning">
|
|
|
|
|
+ <text class="quota-icon">⚠️</text>
|
|
|
|
|
+ <text class="quota-text">{{ quotaCheck.reason }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 详细配额信息 -->
|
|
|
|
|
+ <view class="quota-detail">
|
|
|
|
|
+ <text class="quota-info">您当前额度:{{ quotaCheck.quota.remainingMinutes }}分钟 / {{ quotaCheck.quota.totalMinutes }}分钟</text>
|
|
|
|
|
+ <view v-if="quotaCheck.costEstimate.overageMinutes > 0" class="quota-overage">
|
|
|
|
|
+ <text>预计超出:{{ quotaCheck.costEstimate.overageMinutes }}分钟</text>
|
|
|
|
|
+ <text class="quota-price">额外费用:¥{{ quotaCheck.costEstimate.estimatedPrice }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
<view class="btn-group">
|
|
<view class="btn-group">
|
|
|
<button class="btn-cancel" @click="currentView = 'list'">取消</button>
|
|
<button class="btn-cancel" @click="currentView = 'list'">取消</button>
|
|
|
<button
|
|
<button
|
|
@@ -212,6 +252,25 @@
|
|
|
<view class="progress-bar-large">
|
|
<view class="progress-bar-large">
|
|
|
<view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
|
|
<view class="progress-fill" :style="{ width: currentBook.progress + '%' }"></view>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 实时配额显示 -->
|
|
|
|
|
+ <view v-if="currentBook.status === 'generating'" class="quota-monitor">
|
|
|
|
|
+ <text class="quota-monitor-title">🎧 额度监控</text>
|
|
|
|
|
+ <view class="quota-monitor-row">
|
|
|
|
|
+ <text>已用:{{ usedAudioMinutes }}分钟</text>
|
|
|
|
|
+ <text>剩余:{{ remainingAudioMinutes }}分钟</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="overageMinutes > 0" class="quota-overage-warning">
|
|
|
|
|
+ <text>⚠️ 已超出配额 {{ overageMinutes }} 分钟</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 中断提示 -->
|
|
|
|
|
+ <view v-if="currentBook.status === 'interrupted'" class="interrupted-tip">
|
|
|
|
|
+ <text class="interrupted-icon">⚠️</text>
|
|
|
|
|
+ <text class="interrupted-text">生成已中断:额度不足,已保存当前进度</text>
|
|
|
|
|
+ <text class="interrupted-hint">可升级套餐后继续生成</text>
|
|
|
|
|
+ </view>
|
|
|
</view>
|
|
</view>
|
|
|
|
|
|
|
|
<!-- 大纲展示 -->
|
|
<!-- 大纲展示 -->
|
|
@@ -412,7 +471,7 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, onMounted } from 'vue';
|
|
|
|
|
|
|
+import { ref, computed, onMounted, watch, nextTick } from 'vue';
|
|
|
import * as api from '../../utils/book-generator-api';
|
|
import * as api from '../../utils/book-generator-api';
|
|
|
import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
|
|
import type { Book, BookOutline, Chapter } from '../../utils/book-generator-api';
|
|
|
|
|
|
|
@@ -422,6 +481,16 @@ const BASE_URL = '/api';
|
|
|
// 视图状态
|
|
// 视图状态
|
|
|
const currentView = ref<'list' | 'create' | 'detail' | 'toc' | 'chapter-detail'>('list');
|
|
const currentView = ref<'list' | 'create' | 'detail' | 'toc' | 'chapter-detail'>('list');
|
|
|
|
|
|
|
|
|
|
+// 监听视图变化
|
|
|
|
|
+watch(currentView, (newView) => {
|
|
|
|
|
+ if (newView === 'create') {
|
|
|
|
|
+ // 打开创建视图时,加载默认规模的预估
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ selectBookScale(newBook.value.bookScale);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
// 书籍列表
|
|
// 书籍列表
|
|
|
const books = ref<Book[]>([]);
|
|
const books = ref<Book[]>([]);
|
|
|
|
|
|
|
@@ -446,6 +515,35 @@ const newBook = ref({
|
|
|
// 生成状态
|
|
// 生成状态
|
|
|
const generating = ref(false);
|
|
const generating = ref(false);
|
|
|
|
|
|
|
|
|
|
+// 书籍预估信息
|
|
|
|
|
+const bookEstimate = ref<{
|
|
|
|
|
+ scale: string;
|
|
|
|
|
+ words: { min: number; max: number; avg: number };
|
|
|
|
|
+ audioMinutes: { min: number; max: number; avg: number };
|
|
|
|
|
+ estimatedChapters: number;
|
|
|
|
|
+} | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+// 配额检查结果
|
|
|
|
|
+const quotaCheck = ref<{
|
|
|
|
|
+ allowed: boolean;
|
|
|
|
|
+ reason: string | null;
|
|
|
|
|
+ estimatedWords: { min: number; max: number; avg: number };
|
|
|
|
|
+ estimatedAudioMinutes: { min: number; max: number; avg: number };
|
|
|
|
|
+ quota: {
|
|
|
|
|
+ totalMinutes: number;
|
|
|
|
|
+ usedMinutes: number;
|
|
|
|
|
+ remainingMinutes: number;
|
|
|
|
|
+ overageEnabled: boolean;
|
|
|
|
|
+ overagePrice: number;
|
|
|
|
|
+ };
|
|
|
|
|
+ costEstimate: {
|
|
|
|
|
+ inQuotaMinutes: number;
|
|
|
|
|
+ overageMinutes: number;
|
|
|
|
|
+ estimatedPrice: number;
|
|
|
|
|
+ displayText: string;
|
|
|
|
|
+ };
|
|
|
|
|
+} | null>(null);
|
|
|
|
|
+
|
|
|
// 音频生成状态
|
|
// 音频生成状态
|
|
|
const generatingAudio = ref(false);
|
|
const generatingAudio = ref(false);
|
|
|
|
|
|
|
@@ -468,6 +566,10 @@ const bookScales = [
|
|
|
|
|
|
|
|
// 计算属性
|
|
// 计算属性
|
|
|
const canCreateBook = computed(() => {
|
|
const canCreateBook = computed(() => {
|
|
|
|
|
+ // 免费版需要检查配额
|
|
|
|
|
+ if (quotaCheck.value && !quotaCheck.value.allowed) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
return newBook.value.title.trim() && newBook.value.description.trim();
|
|
return newBook.value.title.trim() && newBook.value.description.trim();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -476,6 +578,68 @@ const completedChapters = computed(() => {
|
|
|
return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
|
|
return currentBook.value.chapters.filter((c) => c.status === 'completed').length;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+// 计算已使用的音频时长(基于已完成章节的字数)
|
|
|
|
|
+const usedAudioMinutes = computed(() => {
|
|
|
|
|
+ if (!currentBook.value) return 0;
|
|
|
|
|
+ const totalWords = currentBook.value.chapters
|
|
|
|
|
+ .filter((c) => c.status === 'completed')
|
|
|
|
|
+ .reduce((sum, c) => sum + (c.wordCount || 0), 0);
|
|
|
|
|
+ return Math.ceil(totalWords / 150); // 150字/分钟
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 计算剩余配额
|
|
|
|
|
+const remainingAudioMinutes = computed(() => {
|
|
|
|
|
+ if (!quotaInfo.value) return 0;
|
|
|
|
|
+ return Math.max(0, quotaInfo.value.totalMinutes - quotaInfo.value.usedMinutes);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 计算超出配额分钟数
|
|
|
|
|
+const overageMinutes = computed(() => {
|
|
|
|
|
+ if (!currentBook.value) return 0;
|
|
|
|
|
+ const used = usedAudioMinutes.value;
|
|
|
|
|
+ if (!quotaInfo.value) return 0;
|
|
|
|
|
+ return Math.max(0, used - quotaInfo.value.totalMinutes);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 配额信息(从API获取)
|
|
|
|
|
+const quotaInfo = ref<{
|
|
|
|
|
+ totalMinutes: number;
|
|
|
|
|
+ usedMinutes: number;
|
|
|
|
|
+ remainingMinutes: number;
|
|
|
|
|
+ overageEnabled: boolean;
|
|
|
|
|
+} | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+// 选择书籍规模时加载预估
|
|
|
|
|
+async function selectBookScale(scale: string) {
|
|
|
|
|
+ newBook.value.bookScale = scale;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await uni.request({
|
|
|
|
|
+ url: `/api/book-generator/langgraph/estimate?scale=${encodeURIComponent(scale)}&userId=1`,
|
|
|
|
|
+ method: 'GET'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const res = response.data as any;
|
|
|
|
|
+ if (res.code === 0 && res.data) {
|
|
|
|
|
+ bookEstimate.value = {
|
|
|
|
|
+ scale: res.data.scale,
|
|
|
|
|
+ words: res.data.words,
|
|
|
|
|
+ audioMinutes: res.data.audioMinutes,
|
|
|
|
|
+ estimatedChapters: res.data.estimatedChapters
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有配额检查结果
|
|
|
|
|
+ if (res.data.quotaCheck) {
|
|
|
|
|
+ quotaCheck.value = res.data.quotaCheck;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('加载预估失败:', e);
|
|
|
|
|
+ bookEstimate.value = null;
|
|
|
|
|
+ quotaCheck.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 方法
|
|
// 方法
|
|
|
function goBack() {
|
|
function goBack() {
|
|
|
uni.navigateBack();
|
|
uni.navigateBack();
|
|
@@ -563,11 +727,35 @@ async function openBook(book: Book) {
|
|
|
try {
|
|
try {
|
|
|
currentBook.value = await api.getBook(book.id);
|
|
currentBook.value = await api.getBook(book.id);
|
|
|
currentView.value = 'detail';
|
|
currentView.value = 'detail';
|
|
|
|
|
+ // 加载配额信息
|
|
|
|
|
+ loadQuotaInfo();
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
uni.showToast({ title: '加载失败', icon: 'none' });
|
|
uni.showToast({ title: '加载失败', icon: 'none' });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 加载用户配额信息
|
|
|
|
|
+async function loadQuotaInfo() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await uni.request({
|
|
|
|
|
+ url: '/api/subscription/audio-balance',
|
|
|
|
|
+ method: 'GET'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const res = response.data as any;
|
|
|
|
|
+ if (res.code === 0 && res.data) {
|
|
|
|
|
+ quotaInfo.value = {
|
|
|
|
|
+ totalMinutes: res.data.totalMinutes,
|
|
|
|
|
+ usedMinutes: res.data.usedMinutes,
|
|
|
|
|
+ remainingMinutes: res.data.remainingMinutes,
|
|
|
|
|
+ overageEnabled: res.data.overageEnabled
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('加载配额信息失败:', e);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 生成大纲
|
|
// 生成大纲
|
|
|
async function handleGenerateOutline() {
|
|
async function handleGenerateOutline() {
|
|
|
if (!currentBook.value) return;
|
|
if (!currentBook.value) return;
|
|
@@ -1263,6 +1451,104 @@ onMounted(() => {
|
|
|
text-align: right;
|
|
text-align: right;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/* 预估信息卡片 */
|
|
|
|
|
+.estimate-card {
|
|
|
|
|
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ padding: 24rpx;
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+ border: 1px solid #fcd34d;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.estimate-header {
|
|
|
|
|
+ margin-bottom: 16rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.estimate-title {
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #92400e;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.estimate-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 8rpx 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.estimate-label {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #78350f;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.estimate-value {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #92400e;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 配额检查结果 */
|
|
|
|
|
+.quota-check {
|
|
|
|
|
+ margin-top: 16rpx;
|
|
|
|
|
+ padding-top: 16rpx;
|
|
|
|
|
+ border-top: 1px dashed #fcd34d;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-ok, .quota-warning {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12rpx;
|
|
|
|
|
+ padding: 12rpx 16rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ margin-bottom: 12rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-ok {
|
|
|
|
|
+ background: rgba(16, 185, 129, 0.1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-warning {
|
|
|
|
|
+ background: rgba(245, 158, 11, 0.1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-icon {
|
|
|
|
|
+ font-size: 32rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-text {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-detail {
|
|
|
|
|
+ padding: 8rpx 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-info {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-overage {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ margin-top: 8rpx;
|
|
|
|
|
+ padding: 8rpx 12rpx;
|
|
|
|
|
+ background: rgba(239, 68, 68, 0.1);
|
|
|
|
|
+ border-radius: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-overage text {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #dc2626;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-price {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* 标签组 */
|
|
/* 标签组 */
|
|
|
.chip-group {
|
|
.chip-group {
|
|
|
display: flex;
|
|
display: flex;
|
|
@@ -1822,4 +2108,66 @@ onMounted(() => {
|
|
|
font-size: 20rpx;
|
|
font-size: 20rpx;
|
|
|
color: #f5576c;
|
|
color: #f5576c;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* 额度监控 */
|
|
|
|
|
+.quota-monitor {
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+ padding: 16rpx;
|
|
|
|
|
+ background: rgba(16, 185, 129, 0.1);
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-monitor-title {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #059669;
|
|
|
|
|
+ margin-bottom: 8rpx;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-monitor-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-overage-warning {
|
|
|
|
|
+ margin-top: 8rpx;
|
|
|
|
|
+ padding: 8rpx 12rpx;
|
|
|
|
|
+ background: rgba(239, 68, 68, 0.1);
|
|
|
|
|
+ border-radius: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.quota-overage-warning text {
|
|
|
|
|
+ font-size: 22rpx;
|
|
|
|
|
+ color: #dc2626;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 中断提示 */
|
|
|
|
|
+.interrupted-tip {
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+ padding: 20rpx;
|
|
|
|
|
+ background: rgba(245, 158, 11, 0.1);
|
|
|
|
|
+ border: 1px solid #fbbf24;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.interrupted-icon {
|
|
|
|
|
+ font-size: 36rpx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.interrupted-text {
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #92400e;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.interrupted-hint {
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ color: #b45309;
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|