[lisa-feat]feat: 页面优化以及更新

This commit is contained in:
chenlisha02
2025-10-10 17:27:11 +08:00
parent f20d0f5a91
commit 1d22472d83
15 changed files with 2594 additions and 76 deletions

View File

@@ -2,6 +2,8 @@
"pages": [
"pages/upload/upload",
"pages/result/result",
"pages/result_show/result_show",
"pages/assessment/assessment",
"pages/profile/profile",
"pages/index/index",
"pages/logs/logs",

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "口语评估",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f8f9fa",
"backgroundTextStyle": "light",
"enablePullDownRefresh": false,
"onReachBottomDistance": 50,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,297 @@
// assessment.ts
import { FILE_BASE_URL } from '../../utils/config'
import apiManager from '../../utils/api'
const recorderManager = wx.getRecorderManager()
interface IPageData {
imagePath: string
currentSentence: any
sentences: any[]
currentIndex: number
totalScore: number
accuracyScore: number
completenessScore: number
fluencyScore: number
circleProgressStyle: string
isRecording: boolean
recordStartTime: number
recordDuration: number
remainingTime: number // 剩余录音时间
hasScoreInfo: boolean // 是否有评分信息
}
type IPageMethods = {
updateCircleProgress: () => void
startRecording: () => void
stopRecording: () => void
prevSentence: () => void
nextSentence: () => void
handleRecordStart: () => void
handleRecordEnd: () => void
}
interface IPageInstance extends IPageMethods {
recordTimer?: number
data: IPageData
}
Page<IPageData, IPageInstance>({
data: {
imagePath: '', // 图片路径
currentSentence: '', // 当前显示的例句
sentences: [], // 例句列表
currentIndex: 0, // 当前例句索引
totalScore: 0, // 总分
accuracyScore: 0, // 准确性评分
completenessScore: 0, // 完整性评分
fluencyScore: 0, // 流利度评分
circleProgressStyle: '', // 圆形进度条样式
isRecording: false, // 是否正在录音
recordStartTime: 0, // 录音开始时间
recordDuration: 0, // 录音持续时间
remainingTime: 30, // 剩余录音时间默认30秒
hasScoreInfo: false, // 是否有评分信息
},
// 更新圆形进度条样式
updateCircleProgress() {
const { totalScore, hasScoreInfo } = this.data
if (!hasScoreInfo || totalScore < 0) {
this.setData({ circleProgressStyle: 'background: #f0f0f0' })
return
}
const style = `background: conic-gradient(#007AFF ${totalScore * 3.6}deg, #f0f0f0 0deg)`
this.setData({ circleProgressStyle: style })
},
// 切换到上一个例句
prevSentence() {
const { currentIndex, sentences } = this.data
if (currentIndex <= 0) return;
if (currentIndex > 0) {
const currentSentence = sentences[currentIndex - 1]
const assessmentResult = currentSentence?.details?.assessment?.result
const suggestedScore = assessmentResult?.SuggestedScore
const pronAccuracy = assessmentResult?.PronAccuracy
const pronCompletion = assessmentResult?.PronCompletion
const pronFluency = assessmentResult?.PronFluency
// 检查是否所有评分都为负数
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every(score => score < 0)
this.setData({
currentIndex: currentIndex - 1,
currentSentence: currentSentence,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number((pronCompletion * 100).toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0
})
this.updateCircleProgress()
}
},
// 切换到下一个例句
nextSentence() {
const { currentIndex, sentences } = this.data
if (currentIndex >= sentences.length - 1) return;
if (currentIndex < sentences.length - 1) {
const index = currentIndex + 1
const currentSentence = sentences[index]
const assessmentResult = currentSentence?.details?.assessment?.result
const suggestedScore = assessmentResult?.SuggestedScore
const pronAccuracy = assessmentResult?.PronAccuracy
const pronCompletion = assessmentResult?.PronCompletion
const pronFluency = assessmentResult?.PronFluency
// 检查是否所有评分都为负数
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every(score => score < 0)
this.setData({
currentIndex: index,
currentSentence: currentSentence,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number((pronCompletion * 100).toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0
})
this.updateCircleProgress()
}
},
// 开始录音
startRecording() {
const options: WechatMiniprogram.RecorderManagerStartOption = {
duration: 30000, // 最长录音时长,单位 ms
sampleRate: 16000, // 采样率
numberOfChannels: 1, // 录音通道数
encodeBitRate: 48000, // 编码码率
format: 'mp3' as 'mp3', // 音频格式
}
recorderManager.start(options)
this.setData({
isRecording: true,
recordStartTime: Date.now(),
recordDuration: 0
})
// 设置定时器更新录音时长和倒计时
this.recordTimer = setInterval(() => {
const duration = Date.now() - this.data.recordStartTime
const remaining = Math.max(0, 30 - Math.floor(duration / 1000))
this.setData({
recordDuration: duration,
remainingTime: remaining
})
// 时间到自动停止录音
if (remaining === 0) {
this.stopRecording()
}
}, 100)
},
// 停止录音
stopRecording() {
const duration = Date.now() - this.data.recordStartTime
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
if (duration < 3000) { // 小于3秒
wx.showToast({
title: '说话时间太短',
icon: 'none'
})
recorderManager.stop()
this.setData({
isRecording: false,
remainingTime: 30 // 重置倒计时
})
return
}
recorderManager.stop()
this.setData({ isRecording: false })
},
// 长按开始录音
handleRecordStart() {
this.startRecording()
},
// 松开结束录音
handleRecordEnd() {
this.stopRecording()
},
onLoad(options: Record<string, string>) {
//如果有图片ID调用接口获取文本和评分信息
if (options.index) {
this.setData({ currentIndex: Number(options.index) })
}
if (options.imageId) {
wx.showLoading({ title: '加载中...' })
apiManager.getImageTextInit(options.imageId)
.then(res => {
// 更新图片路径(如果有)
if (res.image_file_id) {
this.setData({ imagePath: `${FILE_BASE_URL}/${res.image_file_id}` })
}
// 更新例句和评分信息
if (res.assessments && res.assessments.length > 0) {
const sentences = res.assessments
const index = this.data.currentIndex || 0
const currentSentence = sentences[index]
const assessmentResult = currentSentence?.details?.assessment?.result
const suggestedScore = assessmentResult?.SuggestedScore ?? -1
const pronAccuracy = assessmentResult?.PronAccuracy ?? -1
const pronCompletion = assessmentResult?.PronCompletion ?? -1
const pronFluency = assessmentResult?.PronFluency ?? -1
// 检查是否所有评分都为负数
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every((score: number) => score < 0)
this.setData({
sentences,
currentSentence,
currentIndex: index,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number((pronCompletion * 100).toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0
})
}
// 初始化圆形进度条
this.updateCircleProgress()
})
.catch(err => {
console.error('获取图片文本和评分信息失败:', err)
wx.showToast({
title: '加载失败',
icon: 'none'
})
})
.finally(() => {
wx.hideLoading()
})
} else {
// 初始化圆形进度条
this.updateCircleProgress()
}
},
onReady() {
// 监听录音结束事件
recorderManager.onStop((res) => {
if (this.data.recordDuration >= 3000) { // 只有录音时长大于3秒才提示确认
wx.showModal({
title: '提示',
content: '录音完成,是否确认提交?',
success: (result) => {
if (result.confirm) {
console.log('录音文件路径:', res.tempFilePath)
apiManager.uploadFile(res.tempFilePath).then((fileId) => {
apiManager.getAssessmentResult(fileId, this.data.currentSentence.id).then((result) => {
console.log('口语评估结果:', result)
const assessmentResult = result.assessment_result.assessment.result
const suggestedScore = assessmentResult.SuggestedScore
const pronAccuracy = assessmentResult.PronAccuracy
const pronCompletion = assessmentResult.PronCompletion
const pronFluency = assessmentResult.PronFluency
// 检查是否所有评分都为负数
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every(score => score < 0)
// 更新评分信息
this.setData({
hasScoreInfo: !allNegative && !!assessmentResult,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number(pronCompletion.toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number(pronFluency.toFixed(2)) : 0
})
// 更新圆形进度条
this.updateCircleProgress()
})
})
}
}
})
}
})
// 监听录音错误事件
recorderManager.onError((res) => {
wx.showToast({
title: '录音失败',
icon: 'none'
})
})
}
})

View File

@@ -0,0 +1,86 @@
<!-- assessment.wxml - 评估页面 -->
<view class="assessment-container">
<!-- 顶部图片区域 -->
<view class="image-section">
<image class="assessment-image" src="{{imagePath}}" mode="widthFix" />
</view>
<!-- 中间例句区域 -->
<view class="sentence-section">
<view class="sentence-container">
<!-- 左箭头 -->
<view class="arrow-btn left {{currentIndex <= 0 ? 'disabled' : ''}}" bindtap="prevSentence">
<t-icon name="chevron-left" size="48rpx" />
</view>
<!-- 例句内容 -->
<view class="sentence-content">
<text class="sentence-text">{{currentSentence.content}}</text>
<text class="page-indicator">{{currentIndex + 1}}/{{sentences.length}}</text>
</view>
<!-- 右箭头 -->
<view class="arrow-btn right {{currentIndex >= sentences.length - 1 ? 'disabled' : ''}}" bindtap="nextSentence">
<t-icon name="chevron-right" size="48rpx" />
</view>
</view>
<!-- 话筒图标和倒计时 -->
<view class="microphone-container">
<t-icon name="microphone-1" class="microphone {{isRecording ? 'recording' : ''}}" size="100rpx" bind:longpress="handleRecordStart" bind:touchend="handleRecordEnd" bind:touchcancel="handleRecordEnd" />
<text wx:if="{{isRecording}}" class="countdown">{{remainingTime}}s</text>
<!-- 评分详解按钮 -->
<t-button wx:if="{{hasScoreInfo}}" class="score-detail-btn" theme="primary" size="small" variant="outline" bind:tap="handleScoreDetailClick">评分详解</t-button>
</view>
</view>
<!-- 底部评分结果区域 -->
<view class="score-section">
<block wx:if="{{hasScoreInfo}}">
<view class="score-container">
<view class="total-score">
<view class="circle-progress" style="{{circleProgressStyle}}">
<text class="total-score-value">{{totalScore}}</text>
<text class="total-score-label">总分</text>
</view>
</view>
<view class="score-details">
<view class="score-item">
<text class="score-label">准确性</text>
<view class="score-content">
<block wx:if="{{accuracyScore >= 0}}">
<view class="progress-bar">
<view class="progress-fill" style="width: {{accuracyScore}}%"></view>
</view>
<text class="score-value">{{accuracyScore}}</text>
</block>
<text wx:else class="no-score-text">暂无评分</text>
</view>
</view>
<view class="score-item">
<text class="score-label">完整性</text>
<view class="score-content">
<block wx:if="{{completenessScore >= 0}}">
<view class="progress-bar">
<view class="progress-fill" style="width: {{completenessScore}}%"></view>
</view>
<text class="score-value">{{completenessScore}}</text>
</block>
<text wx:else class="no-score-text">暂无评分</text>
</view>
</view>
<view class="score-item">
<text class="score-label">流利度</text>
<view class="score-content">
<block wx:if="{{fluencyScore >= 0}}">
<view class="progress-bar">
<view class="progress-fill" style="width: {{fluencyScore}}%"></view>
</view>
<text class="score-value">{{fluencyScore}}</text>
</block>
<text wx:else class="no-score-text">暂无评分</text>
</view>
</view>
</view>
</view>
</block>
<view wx:else class="no-score">
<text class="no-score-text">暂无评分</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,263 @@
.assessment-container {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 顶部图片区域 */
.image-section {
width: 100%;
position: relative;
overflow: hidden;
}
.assessment-image {
display: block;
width: 50%;
margin: 40rpx auto;
border-radius: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
}
/* 中间例句区域 */
.sentence-section {
flex: 1;
padding: 0 32rpx;
display: flex;
flex-direction: column;
}
.sentence-container {
display: flex;
align-items: center;
justify-content: space-between;
margin: 40rpx 0;
}
.arrow-btn {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border-radius: 50%;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
}
.arrow-btn:active {
background: #f0f0f0;
}
.arrow-btn.disabled {
background: #e0e0e0;
box-shadow: none;
pointer-events: none;
}
.arrow-btn.disabled t-icon {
color: #999999;
}
.sentence-content {
flex: 1;
padding: 0 12rpx;
text-align: center;
}
.sentence-text {
font-size: 32rpx;
color: #333333;
line-height: 1.6;
margin-bottom: 16rpx;
display: block;
}
.page-indicator {
font-size: 24rpx;
color: #666666;
display: block;
text-align: center;
}
.microphone-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 40rpx;
position: relative;
gap: 24rpx;
}
.score-detail-btn {
margin-top: 8rpx;
min-width: 160rpx;
}
.countdown {
font-size: 32rpx;
color: #ff3b30;
font-weight: bold;
margin-top: 16rpx;
animation: fadeInOut 1s infinite;
}
@keyframes fadeInOut {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.microphone {
width: 120rpx;
height: 120rpx;
padding: 20rpx;
background: #007AFF;
border-radius: 50%;
color: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3);
transition: all 0.3s ease;
}
.microphone.recording {
transform: scale(1.1);
background: #ff3b30;
box-shadow: 0 4rpx 16rpx rgba(255, 59, 48, 0.3);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1.1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1.1);
}
}
/* 底部评分结果区域 */
.score-section {
padding: 32rpx 24rpx;
background: #ffffff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.1);
}
.score-container {
display: flex;
align-items: center;
gap: 32rpx;
}
.total-score {
flex-shrink: 0;
}
.circle-progress {
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background: #f0f0f0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.circle-progress::before {
content: '';
position: absolute;
width: 150rpx;
height: 150rpx;
border-radius: 50%;
background: #ffffff;
}
.total-score-value {
font-size: 48rpx;
color: #007AFF;
font-weight: bold;
position: relative;
z-index: 1;
}
.total-score-label {
font-size: 24rpx;
color: #666666;
position: relative;
z-index: 1;
margin-top: 4rpx;
}
.score-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.score-item {
display: flex;
align-items: center;
gap: 16rpx;
}
.score-label {
font-size: 22rpx;
color: #666666;
width: 80rpx;
text-align: right;
}
.score-content {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
}
.score-value {
font-size: 24rpx;
color: #007AFF;
font-weight: bold;
min-width: 48rpx;
text-align: right;
}
.score-item .no-score-text {
font-size: 22rpx;
color: #999;
}
.progress-bar {
flex: 1;
height: 6rpx;
background: #f0f0f0;
border-radius: 3rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #007AFF;
border-radius: 3rpx;
transition: width 0.3s ease;
}
.no-score {
display: flex;
justify-content: center;
align-items: center;
height: 300rpx;
}
.no-score-text {
font-size: 32rpx;
color: #999;
}

View File

@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "识别结果",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f8f9fa",
"backgroundTextStyle": "light",
"enablePullDownRefresh": false,
"onReachBottomDistance": 50,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@@ -0,0 +1,332 @@
// result_show.ts - 结果展示页面(仅展示识别结果)
import { DictItem, IRecognitionResult, SenseItem } from 'miniprogram/types/app';
import apiManager from '../../utils/api'
import { FILE_BASE_URL } from '../../utils/config'
// 为DictItem添加辅助属性用于WXML中简化访问
interface ProcessedDictItem extends DictItem {
primaryWord: string; // 主要单词
ukPronunciation: string | null; // 英式发音
usPronunciation: string | null; // 美式发音
senses: SenseItem[];
}
Page({
data: {
imageId: '',
imagePath: '',
// 识别结果
recognitionResult: null as IRecognitionResult | null,
bubbleList: [] as Array<{id: string; word?: string | null}>,
// 单词详情缓存
wordDetailCache: {} as Record<string, any>,
// 显示内容
displayContent: [] as string[],
errorTip: '',
displayType: '', // description | word
currentWord: '',
currentWordDetail: null as any,
processedWordDetail: null as ProcessedDictItem[] | null, // 处理后的单词详情用于WXML简化访问
isLoadingContent: false,
},
onLoad(options: any) {
console.log('结果展示页面加载', options)
// 获取传入的图片ID
const imageId = options.imageId
if (!imageId) {
wx.showToast({
title: '参数错误',
icon: 'none'
})
this.goBack()
return
}
this.setData({ imageId })
// 获取识别结果
this.loadRecognitionResult(imageId)
},
// 加载识别结果
async loadRecognitionResult(imageId: string) {
try {
const result = await apiManager.getRecognizeImage(imageId)
// 设置识别结果
this.setData({ recognitionResult: result })
// 设置图片路径
this.setData({ imagePath: `${FILE_BASE_URL}/${result.file_id}` })
// 生成气泡数据
const refWords = result.res.ref_word || []
const bubbleList = this.generateBubbleData(refWords)
this.setData({
bubbleList,
displayType: 'description'
})
// 显示描述内容
this.showDescription()
} catch (error) {
console.error('获取识别结果失败:', error)
wx.showToast({
title: '获取结果失败',
icon: 'none'
})
this.goBack()
}
},
// 生成气泡数据
generateBubbleData(refWords: string[]) {
if (!refWords || !Array.isArray(refWords) || refWords.length === 0) {
return []
}
return refWords.map((word, index) => ({
id: `bubble_${index}`,
word: word
}))
},
// 点击气泡 - 显示单词详情
async handleBubbleClick(e: any) {
const { word } = e.currentTarget.dataset
if (!word) return
// 检查是否已经缓存了该单词的详情
if (this.data.wordDetailCache[word]) {
const wordDetail = this.data.wordDetailCache[word]
const processedData = this.processWordDetail(wordDetail)
this.setData({
currentWord: word,
currentWordDetail: wordDetail,
processedWordDetail: processedData,
displayType: 'word',
isLoadingContent: false
})
return
}
this.setData({
currentWord: word,
displayType: 'word',
isLoadingContent: true,
errorTip: '',
displayContent: []
})
try {
// 调用API获取单词详情
const wordDetail: any = await apiManager.getWordDetail(word)
// 处理单词详情数据简化WXML中的访问
const processedData = this.processWordDetail(wordDetail)
// 将单词详情缓存起来
const wordDetailCache = {
...this.data.wordDetailCache,
[word]: wordDetail
}
this.setData({
currentWordDetail: wordDetail,
processedWordDetail: processedData,
isLoadingContent: false,
wordDetailCache
})
} catch (error) {
console.error('获取单词详情失败:', error)
this.setData({
errorTip: '获取单词详情失败,请稍后重试',
isLoadingContent: false,
currentWordDetail: null,
processedWordDetail: null
})
}
},
// 处理单词详情数据简化WXML中的访问
processWordDetail(wordDetail: any): ProcessedDictItem[] | null {
if (!wordDetail.dict_list) return null;
return wordDetail.dict_list.map((dictItem: DictItem) => {
// 提取发音信息
let ukPronunciation = null;
let usPronunciation = null;
if (dictItem.pronunciations) {
ukPronunciation = dictItem.pronunciations.uk_ipa || null;
usPronunciation = dictItem.pronunciations.us_ipa || null;
}
// 处理 examples
dictItem.senses.forEach(sense => {
sense.examples = sense.examples || [];
sense.examples = sense.examples.map(example => {
example.en = example.en || '';
example.en = example.en.replace(/[^a-zA-Z\s]/g, '').trim();
return {
...example
};
});
});
return {
...dictItem,
primaryWord: this.data.currentWord,
ukPronunciation,
usPronunciation,
senses: dictItem.senses
} as ProcessedDictItem;
});
},
// 点击图片 - 显示描述
handleImageClick() {
this.showDescription()
},
// 点击描述文本跳转到 assessment 页面
handleDescriptionClick(e: WechatMiniprogram.BaseEvent) {
const index = e.currentTarget.dataset.index
wx.navigateTo({
url: `/pages/assessment/assessment?imageId=${this.data.imageId}&index=${index}`
});
},
// 显示描述内容
showDescription() {
const { recognitionResult } = this.data
if (!recognitionResult) {
console.error('recognitionResult 为空,无法显示描述')
return
}
// 防护性检查确保description存在且是数组
if (!recognitionResult.res.description || !Array.isArray(recognitionResult.res.description)) {
this.setData({
displayType: 'description',
errorTip: '暂无描述信息',
currentWord: '',
isLoadingContent: false
})
return
}
// 过滤空值并格式化描述内容
const validDescriptions = recognitionResult.res.description
.filter(desc => desc && typeof desc === 'string' && desc.trim().length > 0)
.map(desc => {
// 确保首字母大写
return desc.charAt(0).toUpperCase() + desc.slice(1)
})
if (validDescriptions.length) {
this.setData({
displayContent: validDescriptions,
errorTip: ''
})
console.log('--lisa-displayContent', this.data.displayContent)
} else {
this.setData({
errorTip: '暂无描述信息'
})
}
this.setData({
displayType: 'description',
currentWord: '',
isLoadingContent: false
})
},
// 返回上一页
goBack() {
if (getCurrentPages().length > 1) {
wx.navigateBack()
} else {
wx.switchTab({
url: '/pages/upload/upload'
})
}
},
// 播放音频
playAudio(e: any) {
const { audio } = e.currentTarget.dataset
if (audio) {
apiManager.playAudio(audio).catch(error => {
console.error('播放音频失败:', error)
if (error.message !== '音频正在播放中,请等待播放完成') {
wx.showToast({
title: '音频播放失败',
icon: 'none'
})
}
})
}
},
// 切换释义翻译显示
toggleDefTranslation(e: WechatMiniprogram.BaseEvent) {
const definition = e.currentTarget.dataset.definition;
if (!definition) return false;
const processedWordDetail = this.data.processedWordDetail ? [...this.data.processedWordDetail] : null;
if (processedWordDetail) {
for (const dictItem of processedWordDetail) {
if (dictItem.senses) {
for (const sense of dictItem.senses) {
if (sense.definitions) {
const def = sense.definitions.find(d => d.en === definition.en);
if (def) {
def.showTranslation = !def.showTranslation;
this.setData({ processedWordDetail });
return false;
}
}
}
}
}
}
return false;
},
// 切换例句翻译显示
toggleExampleTranslation(e: WechatMiniprogram.BaseEvent) {
const example = e.currentTarget.dataset.example;
if (!example) return false;
const processedWordDetail = this.data.processedWordDetail ? [...this.data.processedWordDetail] : null;
if (processedWordDetail) {
for (const dictItem of processedWordDetail) {
if (dictItem.senses) {
for (const sense of dictItem.senses) {
if (sense.examples) {
const ex = sense.examples.find(e => e.en === example.en);
if (ex) {
ex.showTranslation = !ex.showTranslation;
this.setData({ processedWordDetail });
return false;
}
}
}
}
}
}
return false;
},
// 阻止标签点击事件
preventTagChange() {
return false;
}
})

View File

@@ -0,0 +1,133 @@
<!-- result_show.wxml - 结果展示页面 -->
<view class="result-container">
<!-- 主要内容区域 -->
<view class="main-content">
<!-- 图片卡片容器(用于光晕效果) -->
<view class="image-card-container">
<!-- 图片显示 -->
<image class="result-image glow-effect" src="{{imagePath}}" mode="aspectFill" bindtap="handleImageClick" />
<!-- 结果气泡 -->
<view class="bubbles-container result-bubbles">
<view wx:for="{{bubbleList}}" wx:key="id" class="bubble-item bubble-interactive result-bubble result-bubble-{{index + 1}}" data-word="{{item.word}}" bindtap="handleBubbleClick" style="background: #007AFF; color: #fff;">
<text class="bubble-text">{{item.word}}</text>
</view>
</view>
</view>
<!-- 结果显示区域 -->
<view class="result-display-area display-area">
<!-- 内容区域 -->
<scroll-view class="display-content" scroll-y="true" enable-back-to-top="true" show-scrollbar="true" scroll-with-animation="true" style="flex: 1;">
<!-- 显示内容 -->
<view wx:if="{{!isLoadingContent}}" class="content-area">
<!-- 显示图片描述内容 -->
<view wx:if="{{displayType === 'description'}}" class="content-text">
<view wx:if="{{displayContent.length}}">
<view class="text-primary" wx:for="{{displayContent}}" wx:key="index" bindtap="handleDescriptionClick" data-index="{{index}}">
<text>{{item}}</text>
<t-icon name="microphone-1" class="microphone" size="40rpx" />
</view>
</view>
<text class="text-primary" wx:else>{{errorTip}}</text>
</view>
<!-- 显示单词详情内容 -->
<view wx:elif="{{displayType === 'word' && processedWordDetail}}" class="word-detail-container">
<!-- 遍历处理后的dict_list数组 -->
<view wx:for="{{processedWordDetail}}" wx:key="index" class="dict-item">
<!-- 标题栏 -->
<view class="word-header">
<view class="word-header-container">
<text class="word-title">{{currentWord}}</text>
<!-- 音标 -->
<view class="pronunciations">
<!-- 如果两种发音都存在 -->
<text wx:if="{{item.ukPronunciation && item.usPronunciation}}" class="ipa">
/{{item.ukPronunciation}} $ {{item.usPronunciation}}/
</text>
<!-- 只有 uk 发音 -->
<text wx:elif="{{item.ukPronunciation}}" class="ipa">
/{{item.ukPronunciation}}/
</text>
<!-- 只有 us 发音 -->
<text wx:elif="{{item.usPronunciation}}" class="ipa">
/{{item.usPronunciation}}/
</text>
<!-- 音频按钮 -->
<t-icon class="ipa-audio" wx:if="{{item.ukPronunciation && item.pronunciations && item.pronunciations.uk_audio}}" bind:click="playAudio" data-audio="{{item.pronunciations.uk_audio}}" name="sound" size="40rpx" data-name="{{item}}" />
<t-icon class="ipa-audio" wx:if="{{item.usPronunciation && item.pronunciations && item.pronunciations.us_audio}}" bind:click="playAudio" data-audio="{{item.pronunciations.us_audio}}" name="sound" size="40rpx" data-name="{{item}}" style="margin-left: 10rpx;" />
</view>
</view>
<!-- 频率标签 -->
<view class="frequency-tags">
<t-tag wx:if="{{item.transitive && item.transitive.length > 0}}" wx:for="{{item.transitive}}" wx:key="index" variant="light" theme="success" style="margin-right: 12rpx;">
{{item}}
</t-tag>
<t-tag wx:if="{{item.part_of_speech}}" variant="light" theme="success" style="margin-right: 12rpx;">
{{item.part_of_speech}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.level_tag}}" variant="light" theme="danger" style="margin-left: 10rpx;font-family:arial, helvetica;">
{{item.frequency.level_tag}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.spoken_tag}}" variant="light" theme="warning" style="margin-left: 10rpx;">
{{item.frequency.spoken_tag}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.written_tag}}" variant="light" theme="warning" style="margin-left: 10rpx;">
{{item.frequency.written_tag}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.level_tag}}" variant="light" theme="danger" style="margin-left: 10rpx;font-family:arial, helvetica;">
{{item.frequency.level_tag}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.spoken_tag}}" variant="light" theme="warning" style="margin-left: 10rpx;">
{{item.frequency.spoken_tag}}
</t-tag>
<t-tag wx:if="{{item.frequency && item.frequency.written_tag}}" variant="light" theme="warning" style="margin-left: 10rpx;">
{{item.frequency.written_tag}}
</t-tag>
</view>
</view>
<!-- senses内容 -->
<view wx:if="{{item.senses}}" class="senses-container">
<t-collapse wx:for="{{item.senses}}" wx:key="index" class="word-collapse" defaultValue="{{item.senses[0].id}}" expandIcon>
<t-collapse-panel value="{{item.id}}" expand-icon="{{item.examples && item.examples.length > 0}}" disabled="{{item.examples && item.examples.length < 1}}">
<view class="word-collapse-header" slot="header">
<view wx:if="{{item.countability && item.countability.length > 0}}" style="display: inline;">
<t-tag wx:for="{{item.countability}}" wx:key="index" variant="light" style="margin-right: 12rpx;">
{{item}}
</t-tag>
</view>
<view wx:if="{{item.signpost_en && item.signpost_cn}}" style="display: inline; margin-right: 12rpx;">
<t-check-tag variant="light" default-checked content="{{ [item.signpost_en, item.signpost_en + ' / ' + item.signpost_cn ] }}" catch:tap="preventTagChange" />
</view>
<text>{{item.definitions[0].en}}</text>
<view wx:if="{{item.definitions[0] && item.definitions[0].cn}}" style="display: inline-block; margin-left: 12rpx; vertical-align: middle;height: 46rpx;">
<t-icon wx:if="{{!item.definitions[0].showTranslation}}" bind:click="toggleDefTranslation" catch:tap="preventTagChange" data-definition="{{item.definitions[0]}}" name="translate" size="36rpx" />
</view>
<text wx:if="{{item.definitions[0].cn && item.definitions[0].showTranslation}}" style="margin-left: 12rpx;color: #007AFF;">
{{item.definitions[0].cn}}
</text>
</view>
<view wx:for="{{item.examples}}" wx:key="index" style="vertical-align: middle;padding: 8rpx 0;">
<text>{{item.en}}</text>
<view wx:if="{{item.audio}}" class="word-collapse-content-icon">
<t-icon bind:click="playAudio" data-audio="{{item.audio}}" name="sound" size="34rpx" data-name="{{item}}" />
</view>
<view wx:if="{{item.cn && !item.showTranslation}}" class="word-collapse-content-icon">
<t-icon bind:click="toggleExampleTranslation" data-example="{{item}}" name="translate" size="34rpx" />
</view>
<text wx:if="{{item.cn && item.showTranslation}}" style="color: #007AFF;">
{{item.cn}}
</text>
</view>
</t-collapse-panel>
</t-collapse>
</view>
</view>
</view>
<!-- 默认提示 -->
<view wx:else class="content-text">
<text class="text-primary">点击图片查看描述,点击气泡查看单词详情</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>

View File

@@ -0,0 +1,838 @@
/* result.wxss - 结果展示页面样式(扁平化设计) */
.result-container {
min-height: 100vh;
background-color: #f5f5f5;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content {
min-height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
}
/* 图片显示 */
.result-image {
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
object-fit: cover;
}
.glow-effect {
box-shadow: 0 8rpx 32rpx rgba(0, 122, 255, 0.3);
}
/* 图片卡片容器 */
.image-card-container {
z-index: 100;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 15%;
left: 50%;
transform: translate(-50%, -50%);
width: 300rpx;
height: 300rpx;
opacity: 1;
}
/* 通用气泡容器 */
.bubbles-container {
position: fixed;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 50;
}
/* 通用气泡项 */
.bubble-item {
position: absolute;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.8s ease;
}
.bubble-interactive {
position: absolute;
display: flex;
border-radius: 16rpx;
align-items: center;
justify-content: center;
color: white;
font-size: 24rpx;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
animation: showBubble .8s forwards ease-in-out,
bubbleJump 2s ease-in-out infinite !important;
pointer-events: auto;
padding: 8rpx 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3);
}
.bubble-interactive:active {
transform: scale(0.9);
background: #0056CC;
}
@keyframes bubbleJump {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10rpx) scale(1.05); }
}
@keyframes showBubble {
from {
opacity: 0;
transform: scale(0);
}
to {
opacity: 1;
transform: scale(1);
}
}
.result-bubble-1 {
left: -80rpx;
top: -25rpx;
animation-delay: 0s;
z-index: 101;
}
/* 位置5左侧中下方 */
.result-bubble-2 {
left: -80rpx;
top: 170rpx;
animation-delay: 0.4s;
z-index: 101;
}
/* 位置4右侧中上方 */
.result-bubble-3 {
right: -80rpx;
top: 80rpx;
animation-delay: 0.2s;
z-index: 101;
}
/* 位置8右侧最下方 */
.result-bubble-4 {
right: -80rpx;
top: 268rpx;
animation-delay: 0.6s;
z-index: 101;
}
.bubble-text {
font-size: 32rpx;
font-weight: 600;
text-align: center;
word-break: break-all;
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
.result-bubbles {
z-index: 50;
pointer-events: auto;
}
.bubble-interactive {
pointer-events: auto;
cursor: pointer;
}
.result-bubble {
width: auto;
height: auto;
min-width: 80rpx;
min-height: 40rpx;
padding: 8rpx 16rpx;
background: #007AFF;
color: #fff;
box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3);
}
.result-bubble:active {
transform: scale(0.9);
background: #0056CC;
}
@keyframes bubbleJump {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10rpx) scale(1.05); }
}
.image-card-container {
position: fixed;
top: 15%;
left: 50%;
transform: translate(-50%, -50%);
width: 300rpx;
height: 300rpx;
opacity: 1;
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
z-index: 100;
}
.result-image {
width: 100%;
height: 100%;
border-radius: 16rpx;
object-fit: cover;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.glow-effect {
box-shadow: 0 8rpx 32rpx rgba(0, 122, 255, 0.3);
}
/* 结果显示区域 */
.result-display-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
height: 60vh;
max-height: 60vh;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.1);
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1000;
}
.display-header {
padding: 32rpx 32rpx 0;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.display-tabs {
display: flex;
gap: 16rpx;
}
.tab-btn {
flex: 1;
background: none;
border: none;
padding: 20rpx;
border-radius: 12rpx;
font-size: 28rpx;
color: #666666;
transition: all 0.3s ease;
}
.tab-btn.active {
background-color: #007AFF;
color: #ffffff;
}
.tab-word {
font-size: 24rpx;
opacity: 0.8;
}
.display-content {
flex: 1;
height: auto;
box-sizing: border-box;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.content-area {
height: 100%;
width: 100%;
box-sizing: border-box;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 24rpx;
}
.content-text {
font-size: 28rpx;
color: #333333;
line-height: 1.6;
padding: 24rpx;
}
/* 单词详情样式 */
.word-detail-container {
flex: 1;
overflow-y: auto;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: 24rpx;
}
.dict-item {
margin-bottom: 40rpx;
border-radius: 16rpx;
width: 100%;
box-sizing: border-box;
}
.word-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #e9ecef;
}
.word-header-container {
display: flex;
align-items: center;
justify-content: flex-start;
}
.word-title {
font-size: 36rpx;
font-weight: bold;
color: #212529;
}
.frequency-tags {
display: inline-block;
margin-top: 10rpx;
}
.sense-item {
margin-bottom: 20rpx;
padding: 15rpx;
background-color: white;
border-radius: 12rpx;
}
.signpost {
padding: 10rpx;
background-color: #e9ecef;
border-radius: 8rpx;
margin-bottom: 15rpx;
cursor: pointer;
}
.signpost-en {
font-weight: bold;
color: #495057;
margin-right: 10rpx;
}
.signpost-cn {
color: #6c757d;
}
.definitions {
padding: 10rpx 0;
cursor: pointer;
}
.definition-en {
font-size: 28rpx;
color: #212529;
margin-bottom: 8rpx;
display: block;
}
.definition-cn {
font-size: 26rpx;
color: #6c757d;
display: block;
}
.examples-section {
margin-top: 15rpx;
}
.examples-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
cursor: pointer;
}
.toggle-icon {
font-size: 24rpx;
color: #6c757d;
}
.examples-list {
margin-top: 15rpx;
padding: 10rpx;
background-color: #ffffff;
border-radius: 8rpx;
}
.example-item {
padding: 10rpx 0;
border-bottom: 1rpx solid #e9ecef;
}
.example-item:last-child {
border-bottom: none;
}
.example-en {
font-size: 26rpx;
color: #212529;
display: block;
margin-bottom: 4rpx;
}
.example-cn {
font-size: 24rpx;
color: #6c757d;
display: block;
}
.toggle-tip {
font-size: 22rpx;
color: #007AFF;
font-style: italic;
}
.dict-item {
margin-bottom: 40rpx;
border-radius: 16rpx;
width: 100%;
box-sizing: border-box;
}
.word-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #e9ecef;
}
.word-header-container {
display: flex;
align-items: center;
justify-content: flex-start;
}
.word-title {
font-size: 36rpx;
font-weight: bold;
color: #212529;
}
.frequency-tags {
display: inline-block;
margin-top: 10rpx;
}
.tag {
display: inline-block;
padding: 4rpx 12rpx;
font-size: 20rpx;
border-radius: 8rpx;
margin-right: 10rpx;
}
.level-tag {
background-color: #007AFF;
color: white;
}
.spoken-tag {
background-color: #28a745;
color: white;
}
.written-tag {
background-color: #ffc107;
color: #212529;
}
.level-tag {
background-color: #007AFF;
color: white;
}
.spoken-tag {
background-color: #28a745;
color: white;
}
.written-tag {
background-color: #ffc107;
color: #212529;
}
.pronunciations {
margin-top: 10rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.ipa {
font-family: arial, helvetica, sans-serif;
font-size: 24rpx;
color: #495057;
margin-left: 12rpx;
}
.ipa-audio {
padding: 12rpx;
}
.audio-btn {
background-color: #007AFF;
color: white;
border: none;
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 20rpx;
margin-left: 10rpx;
cursor: pointer;
}
.audio-btn.small {
padding: 4rpx 12rpx;
font-size: 18rpx;
}
.audio-btn:active {
background-color: #0056CC;
}
.senses-container {
padding: 10rpx 0;
}
.sense-item {
margin-bottom: 20rpx;
padding: 15rpx;
background-color: white;
border-radius: 12rpx;
}
.signpost {
padding: 10rpx;
background-color: #e9ecef;
border-radius: 8rpx;
margin-bottom: 15rpx;
cursor: pointer;
}
.signpost-en {
font-weight: bold;
color: #495057;
margin-right: 10rpx;
}
.signpost-cn {
color: #6c757d;
}
.definitions {
padding: 10rpx 0;
cursor: pointer;
}
.definition-en {
font-size: 28rpx;
color: #212529;
margin-bottom: 8rpx;
display: block;
}
.definition-cn {
font-size: 26rpx;
color: #6c757d;
display: block;
}
.examples-section {
margin-top: 15rpx;
}
.examples-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
cursor: pointer;
}
.toggle-icon {
font-size: 24rpx;
color: #6c757d;
}
.examples-list {
margin-top: 15rpx;
padding: 10rpx;
background-color: #ffffff;
border-radius: 8rpx;
}
.example-item {
padding: 10rpx 0;
border-bottom: 1rpx solid #e9ecef;
}
.example-item:last-child {
border-bottom: none;
}
.example-en {
font-size: 26rpx;
color: #212529;
display: block;
margin-bottom: 4rpx;
}
.example-cn {
font-size: 24rpx;
color: #6c757d;
display: block;
}
.toggle-tip {
font-size: 22rpx;
color: #007AFF;
font-style: italic;
}
.word-collapse {
--td-cell-vertical-padding: 8rpx;
--td-cell-horizontal-padding: 0rpx;
--td-collapse-horizontal-padding: 0rpx;
--td-collapse-content-padding: 8rpx;
--td-text-color-disabled: rgba(0,0,0,.9);
margin-bottom: 20rpx;
}
.word-collapse-content-icon {
width: 40rpx;
height: 40rpx;
display: inline-block;
vertical-align: middle;
padding: 0 6rpx;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #f3f3f3;
border-top: 4rpx solid #007AFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #666666;
}
/* 气泡列表 */
.bubble-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-bottom: 30rpx;
}
.bubble-item {
padding: 16rpx 30rpx;
background: #ffffff;
border-radius: 30rpx;
font-size: 28rpx;
color: #333;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.bubble-item:active {
transform: scale(0.95);
background: #f0f0f0;
}
/* 结果显示区域 */
.result-display-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
height: 60vh;
max-height: 60vh;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.1);
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1000;
}
.display-content {
flex: 1;
height: auto;
max-height: none;
box-sizing: border-box;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.content-text {
font-size: 28rpx;
color: #333333;
line-height: 1.6;
padding: 24rpx;
}
.content-area {
height: 100%;
width: 100%;
box-sizing: border-box;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* 单词详情样式 */
.word-detail-container {
flex: 1;
overflow-y: auto;
height: 100%;
width: 100%;
box-sizing: border-box;
padding: 24rpx;
}
.dict-item {
margin-bottom: 40rpx;
border-radius: 16rpx;
width: 100%;
box-sizing: border-box;
}
.word-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #e9ecef;
}
.word-header-container {
display: flex;
align-items: center;
justify-content: flex_start;
}
.word-title {
font-size: 36rpx;
font-weight: bold;
color: #212529;
}
.frequency-tags {
display: inline-block;
margin-top: 10rpx;
}
.pronunciations {
margin-top: 10rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.ipa {
font-family: arial, helvetica, sans-serif;
font-size: 24rpx;
color: #495057;
margin-left: 12rpx;
}
.ipa-audio {
padding: 12rpx;
}
.senses-container {
padding: 10rpx 0;
}
.word-collapse {
--td-cell-vertical-padding: 8rpx;
--td-cell-horizontal-padding: 0rpx;
--td-collapse-horizontal-padding: 0rpx;
--td-collapse-content-padding: 8rpx;
--td-text-color-disabled: rgba(0,0,0,.9);
}
.word-collapse-content-icon {
width: 40rpx;
height: 40rpx;
display: inline-block;
vertical-align: middle;
padding: 0 6rpx;
}
.microphone {
display: inline-block;
vertical-align: middle;
}
.text-primary {
margin-bottom: 20rpx;
}

View File

@@ -7,8 +7,19 @@ import { FILE_BASE_URL } from '../../utils/config'
const app = getApp<IAppOption>()
type IDayType = 'morning' | 'afternoon' | 'night'
const DayTypeMap: Record<IDayType, string> = {
morning: '早上好',
afternoon: '下午好',
night: '晚上好'
}
Page({
data: {
move_camera_pos_to: 'top' as 'top' | 'bottom',
DayTypeMap,
current_date: '',
day_type: 'morning' as IDayType,
isLoggedIn: false,
showLoginView: false,
userInfo: null as IUserInfo | null,
@@ -32,6 +43,7 @@ Page({
// 只有登录成功后才加载历史数据
if (this.data.isLoggedIn) {
this.loadDailySummary()
this.checkDayType()
}
}).catch((error) => {
console.error('登录检查失败:', error)
@@ -44,6 +56,18 @@ Page({
this.setData({ isProcessing: false }) // 清理处理状态
},
checkDayType() {
const currentHour = new Date().getHours();
const currentDate = new Date().toLocaleDateString('zh-CN');
const dayType = currentHour >= 6 && currentHour < 12 ? 'morning' :
currentHour >= 12 && currentHour < 18 ? 'afternoon' : 'night';
this.setData({
current_date: currentDate,
day_type: dayType as IDayType
});
},
// 加载每日摘要数据
async loadDailySummary(page: number = 1) {
try {
@@ -53,9 +77,10 @@ Page({
}
const result = await apiManager.getDailySummary(page, this.data.size);
const todaySummary = await apiManager.getTodaySummary(page, this.data.size);
// 处理数据,按年份分组
const processedItems = result.items.map(item => ({
const processedItems = [...result.items, ...todaySummary.items].map(item => ({
...item,
images: item.image_ids && item.thumbnail_ids ?
item.image_ids.map((imageId: string, index: number) => ({
@@ -105,6 +130,7 @@ Page({
hasMore: newData.length < result.total,
isLoading: false,
});
console.log('---lisa-groupedHistory', this.data.groupedHistory)
} catch (error) {
console.error('加载每日摘要失败:', error);
this.setData({ isLoading: false });
@@ -123,6 +149,19 @@ Page({
}
},
onPageScroll(e: any) {
const scrollTop = e.scrollTop;
if (scrollTop >= 30) {
this.setData({
move_camera_pos_to: 'top',
})
} else {
this.setData({
move_camera_pos_to: 'bottom',
})
}
},
// 下拉刷新
onPullDownRefresh() {
this.setData({
@@ -137,11 +176,29 @@ Page({
onImageTap(e: any) {
const { imageId } = e.currentTarget.dataset;
if (imageId) {
console.log('图片点击image_id:', imageId);
// 未来实现具体功能
wx.navigateTo({
url: `/pages/result_show/result_show?imageId=${imageId}`
})
}
},
onImageCardTap(e: any) {
const { imageItems } = e.currentTarget.dataset;
if (!imageItems?.images?.length) return;
const items = imageItems.images.map((item: any) => ({
image_id: item.image_id,
image: item.thumbnail_url,
}));
ActionSheet.show({
theme: ActionSheetTheme.Grid,
selector: '#t-images-sheet',
context: this,
items,
cancelText: '取消'
});
},
// 检查登录状态
async checkLoginStatus() {
try {
@@ -215,6 +272,18 @@ Page({
}
},
handleImageSelected(e: any) {
console.log('用户选择:', e)
if (e.detail.selected === 'cancel') return;
const { image_id } = e.detail.selected
if (image_id) {
wx.navigateTo({
url: `/pages/result_show/result_show?imageId=${image_id}`
})
}
},
// 拍照
async handleTakePhoto() {
try {

View File

@@ -1,19 +1,13 @@
<!--upload.wxml - 主功能页面-->
<view class="upload-container">
<!-- upload.wxml - 主功能页面 -->
<view class="{{['upload-container', day_type]}}">
<!-- 登录检查界面 -->
<view wx:if="{{showLoginView}}" class="login-check-view">
<view wx:if="{{showLoginView}}">
<view class="login-prompt card">
<text class="prompt-title">请先登录</text>
<text class="prompt-desc">使用图片识别功能需要先登录</text>
<button
class="goto-login-btn"
bindtap="checkLoginStatus"
>
去登录
</button>
<button class="goto-login-btn" bindtap="checkLoginStatus">去登录</button>
</view>
</view>
<!-- 主功能界面 -->
<view wx:else class="main-content">
<!-- 欢迎区域 -->
@@ -25,43 +19,66 @@
<text class="welcome-text">选择一张图片开始识别</text>
</view>
</view> -->
<!-- 功能按钮区域 -->
<view class="action-section">
<view class="action-buttons">
<t-icon name="image-add" size="140rpx" bind:click="handleImageSelect" />
<t-action-sheet id="t-action-sheet" bind:selected="handleSelected" />
<!-- 欢迎区域 -->
<view class="sunny-wrap" wx:if="{{ day_type !== 'night' }}">
<t-icon name="sunny-filled" class="sunny-icon" size="220rpx" />
<view class="face">•ᴗ•</view>
</view>
<view wx:else>
<view class="moon-wrap">
<t-icon name="moon-filled" class="moon-icon" size="160rpx" />
</view>
<div class="star-icon">★</div>
<div class="star-icon-2">★</div>
</view>
<t-icon name="cloud-filled" class="cloud-icon" size="220rpx" />
<view class="feature-section">
<view class="date">{{ current_date }}</view>
<view class="hello">{{DayTypeMap[day_type]}}</view>
<view class="begin-text">用一个新单词, 开启美好的一天</view>
<!-- 功能按钮区域 -->
<view class="{{['action-section welcome-card', move_camera_pos_to]}}">
<view class="action-buttons">
<t-icon name="image-add" size="140rpx" bind:click="handleImageSelect" />
<t-action-sheet id="t-action-sheet" bind:selected="handleSelected" />
</view>
</view>
</view>
<view class="history-wrap">
<view class="history-card" wx:for="{{groupedHistory}}" wx:key="year">
<p class="history-card-title">{{item.year}}年</p>
<view class="history-card-list">
<view class="history-card-item" catch:tap="onImageCardTap" data-image-items="{{historyItem}}" wx:for="{{item.items}}" wx:for-item="historyItem" wx:for-index="index" wx:key="index">
<p class="month-day">{{historyItem.monthDay}}</p>
<view class="images-list">
<view wx:for="{{historyItem.images}}" wx:for-item="image" wx:key="image_id" catch:tap="onImageTap" data-image-id="{{image.image_id}}">
<image class="image-item" src="{{image.thumbnail_url}}" mode="aspectFill" />
</view>
</view>
</view>
</view>
</view>
<t-action-sheet id="t-images-sheet" bind:selected="handleImageSelected" />
</view>
<!-- 历史记录区域 -->
<view>
<view wx:for="{{groupedHistory}}" wx:key="year" class="history-section">
<!-- 只有不是当前年份才显示年份标题 -->
<view wx:if="{{!item.isCurrentYear}}" class="history-year">{{item.year}}</view>
<!-- <view>
<view wx:for="{{groupedHistory}}" wx:key="year" class="history-section"> -->
<!-- 只有不是当前年份才显示年份标题 -->
<!-- <view wx:if="{{!item.isCurrentYear}}" class="history-year">{{item.year}}</view>
<t-steps layout="vertical" readonly theme="dot" current="{{groupedHistory.length-1}}">
<t-step-item wx:for="{{item.items}}" wx:for-item="historyItem" wx:for-index="index" wx:key="index">
<view slot="title">{{historyItem.monthDay}}</view>
<view slot="content">
<t-grid class="block" column="{{5}}">
<t-grid-item
wx:for="{{historyItem.images}}"
wx:for-item="image"
wx:key="image_id"
t-class-image="image"
image="{{image.thumbnail_url}}"
data-image-id="{{image.image_id}}"
bindtap="onImageTap"
/>
<t-grid-item wx:for="{{historyItem.images}}" wx:for-item="image" wx:key="image_id" t-class-image="image" image="{{image.thumbnail_url}}" data-image-id="{{image.image_id}}" bindtap="onImageTap" />
</t-grid>
</view>
</t-step-item>
</t-steps>
</view>
</view>
</view> -->
<!-- 仅在加载更多时显示骨架屏 -->
<t-skeleton wx:if="{{isLoading && page >= 1}}" theme="paragraph" animation="gradient" loading="{{true}}"></t-skeleton>
<!-- 加载状态显示 -->
<!-- <view wx:if="{{isProcessing}}" class="processing-section">
<view class="processing-card">
@@ -69,7 +86,6 @@
<text class="processing-text">正在跳转到识别页面...</text>
</view>
</view> -->
<!-- 使用提示 -->
<!-- <view class="tips-section">
<view class="tips-card">
@@ -81,5 +97,4 @@
</view>
</view> -->
</view>
</view>

View File

@@ -1,15 +1,24 @@
/* upload.wxss - 主功能页面样式(扁平化设计) */
.upload-container.morning,
.upload-container.afternoon {
background-color: #f4f4f4;
}
.morning .history-wrap,
.afternoon .history-wrap {
color: #333;
}
.upload-container {
min-height: 100vh;
background-color: #f5f5f5;
background-color: #010321e0;
padding: 0;
position: relative;
}
/* 主要内容区域 */
.main-content {
padding: 32rpx;
/* min-height: calc(100vh - 64rpx); */
display: flex;
flex-direction: column;
@@ -66,7 +75,172 @@
line-height: 88rpx;
}
/* 历史记录区域 */
.history-wrap {
padding: 40rpx 40rpx;
color: #ffffff;
}
.history-card-list {
width: 100%;
}
.history-card-item {
padding: 40rpx;
background-color: #ffffffb5;
border-radius: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
margin: 40rpx 0;
color: #333333;
}
.images-list {
margin-top: 20rpx;
width: 100%;
display: flex;
flex-wrap: nowrap;
overflow: hidden;
gap: 12rpx;
}
.image-item {
width: 100rpx;
height: 100rpx;
border-radius: 30rpx;
flex-shrink: 0;
box-shadow: 0 8rpx 16rpx 0 rgba(0, 0, 0, 0.04);
}
/* 欢迎区域 */
.moon-wrap {
opacity: 0;
color: #ffef22c7;
animation: slideInFromTopRight 3s forwards ease-in-out, fadeIn 3s forwards linear;
transform: translate(50%, -50%);
}
.moon-icon {
top: 100rpx;
opacity: 0.9;
}
.star-icon {
position: fixed;
top: 140px;
right: 40px;
font-size: 50rpx;
color: #FFD700;
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.7));
animation: scaleStar1 3s infinite ease-in-out;
}
.star-icon-2 {
position: fixed;
top: 100px;
left: 40px;
font-size: 40rpx;
color: #FFD700;
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.7));
animation: scaleStar2 3s infinite ease-in-out;
}
@keyframes scaleStar1 {
0%,
100% {
transform: scale(0.8);
}
50% {
transform: scale(1.2);
}
}
@keyframes scaleStar2 {
0%,
100% {
transform: scale(1.2);
}
50% {
transform: scale(0.8);
}
}
.sunny-wrap {
/* position: absolute;
top: -100px;
right: -100px;
color: #ffef22c7;
animation: appear 3s ease-out forwards; */
opacity: 0;
color: #f6c02f;
animation: slideInFromTopRight 3s forwards ease-in-out, fadeIn 3s forwards linear;
transform: translate(50%, -50%);
}
.sunny-icon {
opacity: 0.9;
animation: rotate 50s linear infinite 3s; /* 3秒后开始旋转 */
transform-origin: center center;
}
.face {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* 垂直居中 */
font-size: 50rpx;
font-weight: 500;
color:black;
opacity: 0.4;
}
@keyframes slideInFromTopRight {
to {
transform: translate(30%, 40%);
}
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
@keyframes appear {
0% {
top: -100px;
right: -100px;
opacity: 0;
transform: scale(0.2);
}
70% {
opacity: 0.6;
transform: scale(0.8);
}
100% {
top: 20px;
right: 60px;
opacity: 0.8;
transform: scale(1.1);
}
}
@keyframes rotate {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.cloud-icon {
position: fixed;
bottom: 440rpx;
left: -100rpx;
color: #fefefe;
/* opacity: 0.8; */
opacity: 0.7;
animation: moveLeftRight 4s infinite alternate linear;
}
@keyframes moveLeftRight {
0% {
transform: translateX(-20rpx);
}
100% {
transform: translateX(20rpx);
}
}
.welcome-section {
margin-bottom: 40rpx;
}
@@ -74,9 +248,10 @@
.welcome-card {
background: #ffffff;
padding: 32rpx;
border-radius: 16rpx;
border-radius: 50%;
text-align: center;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
border: 4rpx dashed #e91e637a;
}
.user-info {
@@ -106,13 +281,40 @@
opacity: 0.7;
}
/* 功能描述区域 */
.feature-section {
display: flex;
flex-direction: column;
align-items: center;
font-size: 28rpx;
color: #666666;
margin-top: 100rpx;
}
.hello {
margin-top: 80rpx;
font-size: 50rpx;
/* color: black; */
}
.begin-text {
margin-top: 10rpx;
font-size: 36rpx;
color: #666666;
}
/* 功能按钮区域 */
.action-section {
flex: 1;
width: 300rpx;
height: 300rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
margin-top: 100rpx;
opacity: 0.8;
}
.action-section .top {
}
.action-buttons {
@@ -123,6 +325,7 @@
gap: 32rpx;
width: 100%;
max-width: 400rpx;
color: #9c27b04f;
}
.action-btn {

105
miniprogram/types/app.ts Normal file
View File

@@ -0,0 +1,105 @@
// app.ts 的类型定义
import { ApiManager } from '../utils/api'
// 用户信息接口
export interface IUserInfo {
id: string
nickname?: string
avatar_url?: string
gender?: number
country?: string
province?: string
city?: string
language?: string
}
// 登录响应接口
export interface ILoginResponse {
access_token: string
access_token_expire_time: string
session_uuid: string
dict_level?: string
}
// API响应接口
export interface IApiResponse<T> {
code: number
message?: string
msg?: string
data: T
}
// 识别结果接口
export interface IRecognitionResult {
text: string
words: Array<{
word: string
start: number
end: number
}>
}
// 单词详情接口
export interface ExtendedWordDetail {
word: string
phonetic: string
definition: string
translation: string
pos: string
collins: number
oxford: number
tag: string
bnc: number
frq: number
exchange: Record<string, string[]>
detail: string
audio: string
}
// 审核历史记录接口
export interface IAuditHistoryResponse {
total: number
items: Array<{
id: string
image_url: string
text: string
created_at: string
}>
}
// 每日总结接口
export interface IDailySummaryResponse {
total: number
items: Array<{
date: string
count: number
images: Array<{
id: string
url: string
}>
}>
}
// 全局数据接口
export interface IGlobalData {
isLoggedIn: boolean
userInfo?: IUserInfo
token?: string
dictLevel?: string
apiManager: ApiManager
}
// 小程序选项接口
export interface IAppOption {
globalData: IGlobalData
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback
initLoginStatus: () => void
updateLoginStatus: (loginData: {
access_token: string
access_token_expire_time: string
session_uuid: string
userInfo?: IUserInfo
dict_level?: string
}) => void
clearLoginData: () => void
}

View File

@@ -571,7 +571,7 @@ class ApiManager {
}
// 上传文件第一步上传文件获取ID
private async uploadFile(filePath: string, retryCount: number = 0): Promise<string> {
async uploadFile(filePath: string, retryCount: number = 0): Promise<string> {
const maxRetries = 1 // 最多重试1次
return new Promise(async (resolve, reject) => {
@@ -722,6 +722,20 @@ class ApiManager {
return response.data
}
// 获取图片识别结果
async getRecognizeImage(fileId: string): Promise<IRecognitionResult & { file_id: string }> {
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'PRIMARY'
const response = await this.request<IRecognitionResult>(`/api/v1/image/${fileId}`, 'GET', {
dict_level: dictLevel
})
console.log('图片识别结果获取成功:', response.data)
return response.data
}
// 上传图片并识别(对外接口,整合两个步骤)
async uploadImage(filePath: string, type: string = 'word'): Promise<IRecognitionResult> {
try {
@@ -1210,39 +1224,144 @@ class ApiManager {
throw error;
}
}
async getTodaySummary(page: number = 1, size: number = 15): Promise<IDailySummaryResponse> {
try {
console.log('开始获取今日总结数据', { page, size });
const response = await this.request<IDailySummaryResponse>(`/api/v1/audit/today_summary?page=${page}&size=${size}`);
console.log('获取今日总结数据成功:', response.data);
return response.data;
} catch (error) {
console.error('获取今日总结数据失败:', error);
throw error;
}
}
// 获取口语评估结果
async getAssessmentResult(fileId: string, imageTextId: string): Promise<{
file_id: string
image_text_id: string
assessment_result: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number
Words: any
}
}
}
}> {
try {
console.log('开始获取口语评估结果', { fileId });
const response = await this.request<{
file_id: string
image_text_id: string
assessment_result: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number
Words: any
}
}
}
}>(`/api/v1/recording/assessment`, 'POST', {
file_id: fileId,
image_text_id: imageTextId
})
console.log('获取口语评估结果成功:', response.data);
return response.data;
} catch (error) {
console.error('获取口语评估结果失败:', error);
throw error;
}
}
// 获取图片文本和评分信息
async getImageTextInit(imageId: string): Promise<{
image_file_id: string,
assessments: Array<{
id: string
content: string,
details: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number,
Words: any
}
}
} | null
}>
}> {
try {
console.log('开始获取图片文本和评分信息', { imageId });
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'PRIMARY'
const response = await this.request<{
image_file_id: string,
assessments: Array<{
id: string
content: string,
details: {
assessment: {
code: number
final: number
message: string
voice_id: string
result: {
RefTextId: number,
SentenceId: number,
PronAccuracy: number,
PronFluency: number,
PronCompletion: number,
SuggestedScore: number,
Words: any
}
}
} | null
}>
}>(`/api/v1/image_text/init`, 'POST', {
dict_level: dictLevel,
image_id: imageId
})
console.log('获取图片文本和评分信息成功:', response.data);
return response.data;
} catch (error) {
console.error('获取图片文本和评分信息失败:', error);
throw error;
}
}
}
// 导出单例
const apiManager = new ApiManager()
export default apiManager
export { ApiManager }
export { ApiManager }

View File

@@ -6,7 +6,6 @@
"urlCheck": false,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": false,
@@ -20,5 +19,38 @@
"ignoreDevUnusedFiles": true
},
"libVersion": "3.9.3",
"condition": {}
"condition": {
"miniprogram": {
"list": [
{
"name": "",
"pathName": "pages/result/result",
"query": "imagePath=http://tmp/Jg3M9pJYPJdd9ab131e406bc48acb9ed4069809fd559.png",
"launchMode": "default",
"scene": null
},
{
"name": "",
"pathName": "pages/result_show/result_show",
"query": "imageId=2086560419278880768",
"launchMode": "default",
"scene": null
},
{
"name": "",
"pathName": "pages/assessment/assessment",
"query": "imageId=2086560419278880768&index=2",
"launchMode": "default",
"scene": null
},
{
"name": "",
"pathName": "pages/assessment/assessment",
"query": "imageId=2086529549784449024&index=1",
"launchMode": "default",
"scene": null
}
]
}
}
}