add sentence

This commit is contained in:
Felix
2025-12-19 17:16:24 +08:00
parent c82070c3b7
commit 0b0582572d
22 changed files with 2499 additions and 271 deletions

View File

@@ -10,7 +10,8 @@
"pages/privacy/privacy",
"pages/analyze/analyze",
"pages/coupon/coupon",
"pages/order/order"
"pages/order/order",
"pages/scene_sentence/scene_sentence"
],
"window": {
"navigationBarTextStyle": "black",

View File

@@ -78,7 +78,7 @@ App<IAppOption>({
this.globalData.token = authInfo.token
this.globalData.userInfo = authInfo.userInfo
// 初始化词典等级
this.globalData.dictLevel = authInfo.dictLevel || 'LEVEL1'
this.globalData.dictLevel = authInfo.dictLevel || 'level1'
console.log('登录状态有效,自动登录')
} else {
console.log('Token 已过期,清理本地数据')
@@ -101,7 +101,7 @@ App<IAppOption>({
this.globalData.token = loginData.access_token
this.globalData.userInfo = loginData.userInfo
// 更新词典等级
this.globalData.dictLevel = loginData.dict_level || 'LEVEL1'
this.globalData.dictLevel = loginData.dict_level || 'level1'
// 存储到本地
wx.setStorageSync('token', loginData.access_token)

View File

@@ -0,0 +1,9 @@
{
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}

View File

@@ -0,0 +1,37 @@
Component({
properties: {
visible: { type: Boolean, value: false },
hasScoreInfo: { type: Boolean, value: false },
sentence: { type: Object, value: null },
totalScore: { type: Number, value: 0 },
accuracyScore: { type: Number, value: 0 },
completenessScore: { type: Number, value: 0 },
fluencyScore: { type: Number, value: 0 },
circleProgressStyle: { type: String, value: '' },
accuracyCircleStyle: { type: String, value: '' },
completenessCircleStyle: { type: String, value: '' },
fluencyCircleStyle: { type: String, value: '' },
wordScores: { type: Array, value: [] },
matchTagLegend: {
type: Array,
value: [
{ tag: 0, description: '匹配', color: '#ffffff' },
{ tag: 1, description: '新增', color: '#e6f0ff' },
{ tag: 2, description: '缺少', color: '#eef7ff' },
{ tag: 3, description: '错读', color: '#eaf3ff' },
{ tag: 4, description: '未录入', color: '#f5f7fa' }
]
},
playIconName: { type: String, value: 'sound-low' }
},
methods: {
onClose() {
const self = this as any
self.triggerEvent('close')
},
onPlay() {
const self = this as any
self.triggerEvent('play')
}
}
})

View File

@@ -0,0 +1,75 @@
<view wx:if="{{hasScoreInfo}}">
<view class="score-modal-content {{visible ? 'show' : ''}}">
<view class="score-modal-header">
<view class="score-modal-title"></view>
<t-icon name="close" class="score-modal-close" size="40rpx" bindtap="onClose" />
</view>
<view class="score-container">
<view class="score-image-container">
<t-icon wx:if="{{sentence && sentence.file_id}}" name="{{playIconName}}" class="score-modal-play" size="60rpx" bindtap="onPlay"></t-icon>
<view class="score-text">{{sentence.content}}</view>
</view>
<view class="score-overview">
<view class="score-circles">
<view class="circle-item">
<view class="circle-progress" style="{{circleProgressStyle}}">
<text class="total-score-value">{{totalScore}}</text>
<text class="total-score-label">总分</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{accuracyCircleStyle}}">
<text class="total-score-value">{{accuracyScore}}</text>
<text class="total-score-label">准确性</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{completenessCircleStyle}}">
<text class="total-score-value">{{completenessScore}}</text>
<text class="total-score-label">完整性</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{fluencyCircleStyle}}">
<text class="total-score-value">{{fluencyScore}}</text>
<text class="total-score-label">流利度</text>
</view>
</view>
</view>
</view>
<view class="match-tag-legend">
<view class="legend-header">
<view class="legend-items">
<view class="legend-item" wx:for="{{matchTagLegend}}" wx:key="tag">
<view class="color-box" style="background-color: {{item.color}}"></view>
<text class="legend-text">{{item.description}}</text>
</view>
</view>
</view>
</view>
<scroll-view scroll-y class="word-scores-list">
<view class="word-score-item" wx:for="{{wordScores}}" wx:key="word" style="background-color: {{matchTagLegend[item.matchTag || 0].color}}">
<view class="word-header">
<text class="word-text">{{item.word}}</text>
<view class="word-score-details">
<view class="word-score-row">
<text class="word-score-label">准确性</text>
<text class="word-score-value">{{item.pronAccuracy}}</text>
</view>
<view class="word-score-row">
<text class="word-score-label">流利度</text>
<text class="word-score-value">{{item.pronFluency}}</text>
</view>
</view>
</view>
<view class="phone-infos" wx:if="{{item.phoneInfos && item.phoneInfos.length > 0}}">
<view class="phone-info-item" wx:for="{{item.phoneInfos}}" wx:for-item="phoneInfo" wx:key="phone" style="background-color: {{matchTagLegend[phoneInfo.matchTag || 0].color}}">
<text class="phone-text">[{{phoneInfo.phone}}]</text>
<text class="phone-score">{{phoneInfo.pronAccuracy}}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>

View File

@@ -0,0 +1,52 @@
.score-modal-content {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f8fafc;
z-index: 999;
transform: translateY(100%);
transition: transform 0ms ease-out;
}
.score-modal-content.show { transform: translateY(0); }
.score-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
height: 48rpx;
}
.score-modal-title { display: flex; align-items: center; gap: 16rpx; }
.score-modal-close { position: absolute; top: 12rpx; right: 12rpx; color: #666; }
.score-container { overflow-y: auto; }
.score-image-container { display: flex; align-items: center; padding: 0 0 0 12rpx; min-height: 120rpx; }
.score-text { flex: 1; font-size: 30rpx; line-height: 42rpx; color: #001858; font-weight: 600; word-break: break-word; }
.score-modal-play { flex-shrink: 0; margin-right: 20rpx; }
.score-overview {}
.score-circles { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 24rpx; }
.circle-item { display: flex; justify-content: center; background-color: #eee; border-radius: 50%; }
.circle-progress { width: 120rpx; height: 120rpx; border-radius: 50%; background: #f7f7f7; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
.circle-progress::before { content: ''; position: absolute; width: 100rpx; height: 100rpx; top: 10rpx; left: 10rpx; border-radius: 50%; background: #ffffff; }
.total-score-value { font-size: 28rpx; color: #001858; font-weight: 600; position: relative; z-index: 1; line-height: 28rpx; }
.total-score-label { font-size: 20rpx; color: #666666; position: relative; z-index: 1; margin-top: 8rpx; line-height: 20rpx; }
.match-tag-legend { padding: 24rpx; }
.legend-header { display: flex; align-items: center; gap: 16rpx; }
.legend-items { display: flex; flex-wrap: wrap; gap: 16rpx; }
.legend-item { display: flex; align-items: center; gap: 8rpx; }
.color-box { width: 20rpx; height: 20rpx; border: 1rpx solid #e0e0e0; border-radius: 4rpx; }
.legend-text { font-size: 22rpx; color: #666; }
.word-scores-list { display: flex; flex-direction: column; gap: 16rpx; overflow-y: auto; border-radius: 24rpx; }
.word-score-item { display: flex; flex-direction: column; gap: 12rpx; padding: 20rpx; border-bottom: 2rpx solid #eee; }
.word-score-item:last-child { border-bottom: none; }
.word-header { display: flex; justify-content: space-between; align-items: center; }
.phone-infos { display: flex; flex-wrap: wrap; gap: 8rpx; }
.phone-info-item { display: flex; align-items: center; gap: 8rpx; padding: 6rpx 10rpx; border-radius: 8rpx; border: 1rpx solid rgba(0,0,0,0.06); }
.phone-text { font-size: 24rpx; line-height: 24rpx; color: #333; }
.phone-score { font-size: 24rpx; line-height: 24rpx; color: #666; }
.word-text { font-size: 28rpx; color: #333; font-weight: 500; }
.word-score-details { display: flex; gap: 24rpx; }
.word-score-row { display: flex; align-items: center; gap: 8rpx; justify-content: flex-end; }
.word-score-label { font-size: 22rpx; color: #666; }
.word-score-value { font-size: 22rpx; color: #333; font-weight: 500; text-align: right; }

View File

@@ -0,0 +1,12 @@
{
"component": true,
"styleIsolation": "apply-shared",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-cell": "tdesign-miniprogram/cell/cell",
"t-tabs": "tdesign-miniprogram/tabs/tabs",
"t-tab-panel": "tdesign-miniprogram/tab-panel/tab-panel",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton"
}
}

View File

@@ -0,0 +1,131 @@
Component({
properties: {
visible: { type: Boolean, value: false },
expanded: { type: Boolean, value: false },
loading: { type: Boolean, value: false },
wordDict: { type: Object, value: {} },
showBackIcon: { type: Boolean, value: false },
prototypeWord: { type: String, value: '' },
isWordEmptyResult: { type: Boolean, value: false },
dictDefaultTabValue: { type: String, value: '0' },
activeWordAudioType: { type: String, value: '' },
wordAudioPlaying: { type: Boolean, value: false },
wordAudioIconName: { type: String, value: 'sound' }
},
methods: {
onClose() {
const self = this as any
try { self.wordAudioContext && self.wordAudioContext.stop() } catch (e) {}
try {
if (self.wordAudioContext) {
self.wordAudioContext.offPlay()
self.wordAudioContext.offEnded()
self.wordAudioContext.offError()
self.wordAudioContext.destroy()
self.wordAudioContext = undefined
}
} catch (e) {}
if (self.wordAudioIconTimer) {
clearInterval(self.wordAudioIconTimer)
self.wordAudioIconTimer = undefined
}
self.setData({ wordAudioPlaying: false, wordAudioIconName: 'sound', activeWordAudioType: '' })
self.triggerEvent('close')
},
onMore() {
const self = this as any
self.triggerEvent('more')
},
onTabsChange(e: any) {
const self = this as any
self.triggerEvent('tabsChange', { value: e?.detail?.value })
},
onTabsClick(e: any) {
const self = this as any
self.triggerEvent('tabsClick', { value: e?.detail?.value })
},
onPlayWordAudio(e: any) {
const dataset = e?.currentTarget?.dataset || {}
const type = dataset.type
const audio = dataset.audio
const self = this as any
if (!audio) return
if (self.data.wordAudioPlaying && self.data.activeWordAudioType === type) {
try { self.wordAudioContext && self.wordAudioContext.stop() } catch (err) {}
try {
if (self.wordAudioContext) {
self.wordAudioContext.offPlay()
self.wordAudioContext.offEnded()
self.wordAudioContext.offError()
self.wordAudioContext.destroy()
self.wordAudioContext = undefined
}
} catch (err) {}
if (self.wordAudioIconTimer) {
clearInterval(self.wordAudioIconTimer)
self.wordAudioIconTimer = undefined
}
self.setData({ wordAudioPlaying: false, wordAudioIconName: 'sound', activeWordAudioType: '' })
return
}
try { self.wordAudioContext && self.wordAudioContext.stop() } catch (err) {}
try {
if (self.wordAudioContext) {
self.wordAudioContext.offPlay()
self.wordAudioContext.offEnded()
self.wordAudioContext.offError()
self.wordAudioContext.destroy()
self.wordAudioContext = undefined
}
} catch (err) {}
if (self.wordAudioIconTimer) {
clearInterval(self.wordAudioIconTimer)
self.wordAudioIconTimer = undefined
}
const audioUrl = `https://dict.youdao.com/dictvoice?audio=${audio}`
self.wordAudioContext = wx.createInnerAudioContext()
try { (self.wordAudioContext as any).autoplay = false } catch (err) {}
self.wordAudioContext.onPlay(() => {
const seq = ['sound', 'sound-low']
let i = 0
self.setData({ wordAudioPlaying: true, activeWordAudioType: type, wordAudioIconName: seq[0] })
self.wordAudioIconTimer = setInterval(() => {
i = (i + 1) % seq.length
self.setData({ wordAudioIconName: seq[i] })
}, 400) as any
})
const finalize = () => {
if (self.wordAudioIconTimer) {
clearInterval(self.wordAudioIconTimer)
self.wordAudioIconTimer = undefined
}
self.setData({ wordAudioPlaying: false, wordAudioIconName: 'sound', activeWordAudioType: '' })
try {
if (self.wordAudioContext) {
self.wordAudioContext.offPlay()
self.wordAudioContext.offEnded()
self.wordAudioContext.offError()
self.wordAudioContext.destroy()
self.wordAudioContext = undefined
}
} catch (err) {}
}
self.wordAudioContext.onEnded(() => { finalize() })
self.wordAudioContext.onError(() => {
wx.showToast({ title: '播放失败', icon: 'none' })
finalize()
})
self.wordAudioContext.src = audioUrl
self.wordAudioContext.play()
},
onBack() {
const self = this as any
self.triggerEvent('back')
},
onWordTap() {
const self = this as any
const word = (self.data && self.data.prototypeWord) ? self.data.prototypeWord : ''
self.triggerEvent('wordTap', { word })
}
}
})

View File

@@ -0,0 +1,136 @@
<view class="word-popup {{expanded ? 'expanded' : ''}}" wx:if="{{visible}}">
<view class="popup-header">
<view class="frequency-tags">
<t-tag wx:if="{{wordDict.ec.exam_type && wordDict.ec.exam_type.length > 0}}" wx:for="{{wordDict.ec.exam_type}}" wx:key="index" variant="light" theme="success">
{{item}}
</t-tag>
</view>
<view class="close-btn" bindtap="onClose">
<t-icon name="close" size="36rpx" />
</view>
</view>
<t-skeleton wx:if="{{loading}}" theme="paragraph" animation="gradient" loading="{{true}}"></t-skeleton>
<view wx:if="{{!loading}}" class="popup-header">
<view wx:if="{{wordDict.simple.query}}" class="word-title">
<t-icon wx:if="{{showBackIcon}}" name="chevron-left" size="40rpx" bindtap="onBack"></t-icon>
{{wordDict.simple.query}}
</view>
<view class="word-source" wx:if="{{prototypeWord}}">词源: <t-tag variant="light" theme="primary" data-word="{{prototypeWord}}" bindtap="onWordTap">{{prototypeWord}}</t-tag></view>
<view class="more-btn" wx:if="{{!isWordEmptyResult}}" bindtap="onMore">
<text wx:if="{{!expanded}}">More</text>
<t-icon wx:if="{{!expanded}}" name="chevron-up" size="48rpx"></t-icon>
</view>
</view>
<view class="pronounce" wx:if="{{!loading}}">
<view class="pron-item" wx:if="{{wordDict.simple && wordDict.simple.word && wordDict.simple.word.length > 0}}">
<span class="pron-item-text" wx:if="{{wordDict.simple.word[0].ukphone}}">
UK:[{{wordDict.simple.word[0].ukphone}}]
</span>
<t-icon class="ipa-audio" wx:if="{{wordDict.simple.word[0].ukspeech}}" bind:tap="onPlayWordAudio" data-type="uk" data-audio="{{wordDict.simple.word[0].ukspeech}}" name="{{(activeWordAudioType === 'uk' && wordAudioPlaying) ? wordAudioIconName : 'sound'}}" size="30rpx" />
</view>
<view class="pron-item" wx:if="{{wordDict.simple && wordDict.simple.word && wordDict.simple.word.length > 0}}">
<span class="pron-item-text" wx:if="{{wordDict.simple.word[0].usphone}}">
US:[{{wordDict.simple.word[0].usphone}}]
</span>
<t-icon class="ipa-audio" wx:if="{{wordDict.simple.word[0].usspeech}}" bind:tap="onPlayWordAudio" data-type="us" data-audio="{{wordDict.simple.word[0].usspeech}}" name="{{(activeWordAudioType === 'us' && wordAudioPlaying) ? wordAudioIconName : 'sound'}}" size="30rpx" />
</view>
</view>
<view wx:if="{{!loading}}" class="word-meanings">
<block wx:if="{{wordDict.ec && wordDict.ec.word && wordDict.ec.word.length > 0}}">
<block wx:for="{{wordDict.ec.word[0].trs}}" wx:key="index">
<block wx:for="{{item.tr}}" wx:key="index">
<block wx:for="{{item.l.i}}" wx:key="index">
<text>{{item}}</text>
</block>
</block>
</block>
</block>
</view>
<t-tabs class="t-tabs" animation="{{ { duration: 0.1 } }}" defaultValue="{{dictDefaultTabValue}}" bind:change="onTabsChange" bind:click="onTabsClick" wx:if="{{expanded}}">
<t-tab-panel label="使用" value="0" wx:if="{{wordDict.collins && wordDict.collins.collins_entries && wordDict.collins.collins_entries.length > 0 && wordDict.collins.collins_entries[0].entries && wordDict.collins.collins_entries[0].entries.entry}}">
<view class="dictionary">
<block wx:for="{{wordDict.collins.collins_entries[0].entries.entry}}" wx:key="index">
<block wx:if="{{item.tran_entry && item.tran_entry.length > 0}}">
<view class="dictionary-content" wx:for="{{item.tran_entry}}" wx:key="tindex">
<view class="dictionary-pos" wx:if="{{item.pos_entry && item.pos_entry.pos && item.pos_entry.pos_tips}}">
<t-tag variant="light" theme="primary">
{{item.pos_entry.pos}} {{item.pos_entry.pos_tips}}
</t-tag>
</view>
<view class="dictionary-tran" wx:if="{{item.tranParts && item.tranParts.length > 0}}">
<block wx:for="{{item.tranParts}}" wx:key="pindex">
<text wx:if="{{!item.bold}}" class="normal-text">{{item.text}}</text>
<text wx:if="{{item.bold}}" class="dictionary-tran-bold">{{item.text}}</text>
</block>
</view>
<view class="dictionary-tran" wx:elif="{{item.tran}}">{{item.tran}}</view>
<view class="dictionary-list" wx:if="{{item.exam_sents && item.exam_sents.sent && item.exam_sents.sent.length > 0}}">
<block wx:for="{{item.exam_sents.sent}}" wx:key="index">
<view class="word-sent-en" wx:if="{{item.eng_sent}}">{{item.eng_sent}}</view>
<view class="word-sent-zh" wx:if="{{item.chn_sent}}">{{item.chn_sent}}</view>
</block>
</view>
</view>
</block>
</block>
</view>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.phrs && wordDict.phrs.phrs && wordDict.phrs.phrs.length > 0}}" label="短语" value="1">
<block wx:for="{{wordDict.phrs.phrs}}" wx:key="index">
<t-cell title="{{item.phr.headword.l.i}}" description="{{item.phr.trs[0].tr.l.i}}" />
</block>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.individual && wordDict.individual.pastExamSents && wordDict.individual.pastExamSents.length > 0}}" label="真题" value="2">
<view wx:for="{{wordDict.individual.pastExamSents}}" wx:key="index" class="exam-sent">
<view class="exam-sent-en">{{item.en}}</view>
<view class="exam-sent-zh">{{item.zh}}</view>
<view class="exam-sent-source">--摘自《{{item.source}}》</view>
</view>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0}}" label="相关" value="3">
<view wx:if="{{(wordDict.syno && wordDict.syno.synos && wordDict.syno.synos.length > 0) || (wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0) || (wordDict.discriminate && wordDict.discriminate.data && wordDict.discriminate.data.length > 0)}}">
<view class="syno" wx:if="{{wordDict.syno && wordDict.syno.synos && wordDict.syno.synos.length > 0}}">
<view class="syno-title">同义词</view>
<view class="syno-list" wx:for="{{wordDict.syno.synos}}" wx:key="index">
<view class="syno-pos">
<t-tag wx:if="{{item.syno.pos}}" variant="light" theme="primary">
{{item.syno.pos}}
</t-tag>
<view class="syno-tran">{{item.syno.tran}}</view>
</view>
<t-tag class="syno-item" wx:for="{{item.syno.ws}}" wx:key="w" variant="light">
{{item.w}}
</t-tag>
</view>
</view>
<view class="rel-word" wx:if="{{wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0}}">
<view class="rel-word-title">相关单词</view>
<view class="rel-word-list" wx:for="{{wordDict.relWord.rels}}" wx:key="index">
<view class="rel-word-pos">
<t-tag wx:if="{{item.rel.pos}}" variant="light" theme="primary">
{{item.rel.pos}}
</t-tag>
</view>
<view class="rel-word-item" wx:for="{{item.rel.words}}" wx:key="word">
<view class="rel-word-tran">{{item.tran}}</view>
<view class="rel-word-word">{{item.word}}</view>
</view>
</view>
</view>
<view class="discriminate" wx:if="{{wordDict.discriminate && wordDict.discriminate.data && wordDict.discriminate.data.length > 0}}">
<view class="discriminate-title">区分单词</view>
<view class="discriminate-list" wx:for="{{wordDict.discriminate.data}}" wx:key="index">
<view class="discriminate-item" wx:for="{{item.usages}}" wx:key="headword">
<view class="discriminate-tran">{{item.usage}}</view>
<view class="discriminate-headword">{{item.headword}}</view>
</view>
</view>
</view>
</view>
<view wx:else class="no-related-words">
<text class="no-related-words-text">暂无相关单词信息</text>
</view>
</t-tab-panel>
</t-tabs>
<view class="no-word" wx:if="{{isWordEmptyResult}}">没有更多内容</view>
</view>

View File

@@ -0,0 +1,197 @@
.word-popup {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top-left-radius: 12rpx;
border-top-right-radius: 12rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
padding: 0 20rpx;
box-sizing: border-box;
transition: all 0.3s ease;
z-index: 100;
overflow-y: scroll;
min-height: 600rpx;
}
.word-popup.expanded {
height: 100vh;
border-top-left-radius: 0rpx;
border-top-right-radius: 0rpx;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20rpx;
position: relative;
}
.word-title {
display: flex;
align-items: center;
font-size: 40rpx;
font-weight: bold;
}
.word-source {
font-size: 24rpx;
}
.frequency-tags {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
min-height: 48rpx;
}
.close-btn {
flex-shrink: 0;
margin-left: 20rpx;
}
.more-btn {
display: flex;
font-size: 30rpx;
}
.pronounce {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 20rpx;
}
.pron-item {
display: flex;
align-items: center;
margin-right: 20rpx;
font-size: 24rpx;
line-height: 30rpx;
}
.pron-item-text {
padding-right: 8rpx;
}
.word-meanings {
display: flex;
flex-direction: column;
margin-bottom: 20rpx;
font-size: 24rpx;
}
.dictionary {
padding: 20rpx 0;
}
.dictionary-content {
margin-bottom: 20rpx;
font-size: 28rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.dictionary-pos {
display: inline;
color: #33272a;
}
.dictionary-tran {
display: inline;
padding-left: 16rpx;
vertical-align: middle;
}
.dictionary-tran-bold {
font-weight: bold;
}
.dictionary-list {
padding: 16rpx 0;
margin-left: 16rpx;
}
.exam-sent {
padding: 10rpx 0;
border-bottom: 1rpx solid #e0e0e0;
}
.exam-sent-en {
font-size: 32rpx;
color: #333;
margin-bottom: 6rpx;
}
.exam-sent-zh {
font-size: 28rpx;
color: #909090;
margin-bottom: 6rpx;
}
.exam-sent-source {
font-size: 24rpx;
color: #ccc;
text-align: right;
}
.syno {
margin-bottom: 20rpx;
}
.syno-title {
font-size: 32rpx;
line-height: 32rpx;
font-weight: bold;
color: #333;
padding: 20rpx 0;
}
.syno-list {
padding-bottom: 20rpx;
border-bottom: 1rpx solid #e0e0e0;
margin-bottom: 20rpx;
}
.syno-pos {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
}
.syno-tran {
font-size: 28rpx;
color: #333;
margin: 12rpx 0;
display: inline;
vertical-align: middle;
padding-left: 16rpx;
}
.syno-item {
display: inline-block;
margin-right: 15rpx;
}
.rel-word {
margin-bottom: 30rpx;
}
.rel-word-title {
font-size: 32rpx;
line-height: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.rel-word-list {
padding: 20rpx;
background: #f9f9f9;
border-radius: 24rpx;
margin-bottom: 24rpx;
}
.rel-word-pos {
font-size: 28rpx;
color: #666;
margin-bottom: 10rpx;
}
.rel-word-item {
display: flex;
justify-content: space-between;
padding: 15rpx 0;
border-bottom: 1rpx solid #eee;
}
.rel-word-item:last-child {
border-bottom: none;
}
.rel-word-tran {
font-size: 28rpx;
color: #333;
flex: 1;
}
.rel-word-word {
font-size: 28rpx;
}
.no-related-words-text {
font-size: 28rpx;
color: #909090;
}
.no-word {
text-align: center;
font-size: 28rpx;
color: #909090;
padding: 40rpx 0;
}

View File

@@ -6,12 +6,14 @@
"backgroundTextStyle": "light",
"enablePullDownRefresh": false,
"onReachBottomDistance": 50,
"usingComponents": {
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-cell": "tdesign-miniprogram/cell/cell",
"t-tabs": "tdesign-miniprogram/tabs/tabs",
"t-tab-panel": "tdesign-miniprogram/tab-panel/tab-panel",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton"
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
"score-modal": "/components/score-modal/score-modal",
"word-dictionary": "/components/word-dictionary/word-dictionary"
}
}
}

View File

@@ -6,8 +6,11 @@ let recorderHandlersBound = false
interface IPageData {
imagePath: string
imageId?: string
imageSmall: boolean
imageMode: 'widthFix' | 'aspectFit'
imageMode: 'widthFix' | 'aspectFit' | 'aspectFill'
imageScale: number
imageTranslateY: number
currentSentence: any
sentences: any[]
currentIndex: number
@@ -136,6 +139,7 @@ interface IPageData {
sentenceMarginTop: number,
standardAudioLocalMap: { [key: string]: string }, // 标准语音文件ID映射
assessmentAudioLocalMap: { [key: string]: string } // 评估语音文件ID映射
[key: string]: any
}
type IPageMethods = {
@@ -167,9 +171,11 @@ type IPageMethods = {
onPageScroll: (e: any) => void
processCollinsData: (collinsData: any) => any // Add this line
computeHighlightLayout:() => void
computeDynamicLayout: () => void
onMicHighlight: () => void
ensureRecordPermission: () => void
onMoreTap: () => void
onSceneSentenceTap: () => void
noop: () => void
}
@@ -192,6 +198,7 @@ interface IPageInstance extends IPageMethods {
Page<IPageData, IPageInstance>({
data: {
imagePath: '', // 图片路径
imageId: '',
imageSmall: false, // 图片是否缩小
imageMode: 'aspectFill', // 初始完整展示模式
imageScale: 1,
@@ -1145,6 +1152,7 @@ Page<IPageData, IPageInstance>({
this.setData({ currentIndex: Number(options.index) })
}
if (options.imageId) {
this.setData({ imageId: options.imageId })
// wx.showLoading({ title: '加载中...' })
this.setData({ loadingMaskVisible: true })
apiManager.getImageTextInit(options.imageId)
@@ -1396,6 +1404,18 @@ Page<IPageData, IPageInstance>({
recorderHandlersBound = true
}
},
onSceneSentenceTap() {
const imageId = this.data.imageId || ''
if (!imageId) {
wx.showToast({ title: '缺少图片ID', icon: 'none' })
return
}
this.setData({ isMoreMenuOpen: false, isMoreMenuClosing: false })
wx.navigateTo({
url: `/pages/scene_sentence/scene_sentence?image_id=${encodeURIComponent(imageId)}`
})
},
noop() {},

View File

@@ -69,239 +69,50 @@
</view>
<view class="bottom-more-area {{isMoreMenuOpen ? 'open' : (isMoreMenuClosing ? 'close' : '')}}" wx:if="{{isMoreMenuOpen || isMoreMenuClosing}}">
<view class="more-items">
<view class="more-item">更多功能</view>
<view class="more-item" bindtap="onSceneSentenceTap">场景句型</view>
<!-- <view class="more-item">功能2</view> -->
<!-- <view class="more-item">功能3</view> -->
</view>
</view>
<view class="bottom-mask"></view>
<!-- 评分结果弹窗 -->
<view wx:if="{{isScoreModalOpen && hasScoreInfo}}">
<!-- <view class="score-modal-mask" bindtap="onCloseScoreModal"></view> -->
<view class="score-modal-content {{scoreModalVisible ? 'show' : ''}}">
<view class="score-modal-header">
<view class="score-modal-title">
<!-- <span>口语评分结果</span> -->
</view>
<t-icon name="close" class="score-modal-close" size="40rpx" bindtap="onCloseScoreModal" />
</view>
<view class="score-container" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop">
<view class="score-image-container">
<t-icon wx:if="{{currentSentence && currentSentence.file_id}}" name="{{playIconName}}" class="score-modal-play" size="60rpx" bindtap="playAssessmentVoice"></t-icon>
<!-- <image wx:if="{{currentSentence && currentSentence.file_id}}" src="{{playIconSrc}}" class="score-modal-play" bind:tap="playAssessmentVoice" mode="aspectFit" /> -->
<view class="score-text">{{currentSentence.content}}</view>
</view>
<view class="score-overview">
<view class="score-circles">
<view class="circle-item">
<view class="circle-progress" style="{{circleProgressStyle}}">
<text class="total-score-value">{{displayTotalScore}}</text>
<text class="total-score-label">总分</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{accuracyCircleStyle}}">
<text class="total-score-value">{{displayAccuracyScore}}</text>
<text class="total-score-label">准确性</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{completenessCircleStyle}}">
<text class="total-score-value">{{displayCompletenessScore}}</text>
<text class="total-score-label">完整性</text>
</view>
</view>
<view class="circle-item">
<view class="circle-progress" style="{{fluencyCircleStyle}}">
<text class="total-score-value">{{displayFluencyScore}}</text>
<text class="total-score-label">流利度</text>
</view>
</view>
</view>
</view>
<!-- MatchTag 说明 -->
<view class="match-tag-legend">
<view class="legend-header">
<!-- <text class="legend-title">匹配说明:</text> -->
<view class="legend-items">
<view class="legend-item" wx:for="{{matchTagLegend}}" wx:key="tag">
<view class="color-box" style="background-color: {{item.color}}"></view>
<text class="legend-text">{{item.description}}</text>
</view>
</view>
</view>
</view>
<!-- 单词评分列表 -->
<scroll-view scroll-y class="word-scores-list">
<view class="word-score-item" wx:for="{{wordScores}}" wx:key="word" style="background-color: {{matchTagLegend[item.matchTag || 0].color}}">
<view class="word-header">
<text class="word-text">{{item.word}}</text>
<view class="word-score-details">
<view class="word-score-row">
<text class="word-score-label">准确性</text>
<text class="word-score-value">{{item.pronAccuracy}}</text>
</view>
<view class="word-score-row">
<text class="word-score-label">流利度</text>
<text class="word-score-value">{{item.pronFluency}}</text>
</view>
</view>
</view>
<!-- 音标信息 -->
<view class="phone-infos" wx:if="{{item.phoneInfos && item.phoneInfos.length > 0}}">
<view class="phone-info-item" wx:for="{{item.phoneInfos}}" wx:for-item="phoneInfo" wx:key="phone" style="background-color: {{matchTagLegend[phoneInfo.matchTag || 0].color}}">
<text class="phone-text">[{{phoneInfo.phone}}]</text>
<text class="phone-score">{{phoneInfo.pronAccuracy}}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
<!-- 单词查询展示区域 -->
<view class="word-popup {{showDictExtended ? 'expanded' : ''}}" wx:if="{{showDictPopup}}">
<!-- 弹窗头部:单词 + more 按钮 -->
<view class="popup-header">
<!-- 频率标签 -->
<view class="frequency-tags">
<t-tag wx:if="{{wordDict.ec.exam_type && wordDict.ec.exam_type.length > 0}}" wx:for="{{wordDict.ec.exam_type}}" wx:key="index" variant="light" theme="success">
{{item}}
</t-tag>
</view>
<view class="close-btn" bindtap="handleDictClose">
<t-icon name="close" size="36rpx" />
</view>
</view>
<t-skeleton wx:if="{{dictLoading}}" theme="paragraph" animation="gradient" loading="{{true}}"></t-skeleton>
<view wx:if="{{!dictLoading}}" class="popup-header">
<view wx:if="{{wordDict.simple.query}}" class="word-title">
<t-icon wx:if="{{showBackIcon}}" name="chevron-left" size="40rpx" bindtap="handleBackToPreviousWord"></t-icon>
{{wordDict.simple.query}}
</view>
<view class="word-source" wx:if="{{prototypeWord}}">词源: <t-tag variant="light" theme="primary" data-word="{{prototypeWord}}" bindtap="handleWordClick">{{prototypeWord}}</t-tag></view>
<view class="more-btn" wx:if="{{!isWordEmptyResult}}" bindtap="handleDictMore">
<text wx:if="{{!showDictExtended}}">More</text>
<!-- <text wx:if="{{!showDictExtended}}">Less</text> -->
<t-icon wx:if="{{!showDictExtended}}" name="chevron-up" size="48rpx"></t-icon>
<!-- <t-icon wx:else name="chevron-down" size="32rpx"></t-icon> -->
</view>
</view>
<!-- 发音区域 -->
<view class="pronounce" wx:if="{{!dictLoading}}">
<view class="pron-item" wx:if="{{wordDict.simple && wordDict.simple.word && wordDict.simple.word.length > 0}}">
<span class="pron-item-text" wx:if="{{wordDict.simple.word[0].ukphone}}">
UK:[{{wordDict.simple.word[0].ukphone}}]
</span>
<t-icon class="ipa-audio" wx:if="{{wordDict.simple.word[0].ukspeech}}" bind:click="playWordAudio" data-type="uk" data-audio="{{wordDict.simple.word[0].ukspeech}}" name="{{(activeWordAudioType === 'uk' && wordAudioPlaying) ? wordAudioIconName : 'sound'}}" size="30rpx" />
</view>
<view class="pron-item" wx:if="{{wordDict.simple && wordDict.simple.word && wordDict.simple.word.length > 0}}">
<span class="pron-item-text" wx:if="{{wordDict.simple.word[0].usphone}}">
US:[{{wordDict.simple.word[0].usphone}}]
</span>
<t-icon class="ipa-audio" wx:if="{{wordDict.simple.word[0].usspeech}}" bind:click="playWordAudio" data-type="us" data-audio="{{wordDict.simple.word[0].usspeech}}" name="{{(activeWordAudioType === 'us' && wordAudioPlaying) ? wordAudioIconName : 'sound'}}" size="30rpx" />
</view>
</view>
<!-- 基础词性释义 -->
<view wx:if="{{!dictLoading}}" class="word-meanings">
<block wx:if="{{wordDict.ec && wordDict.ec.word && wordDict.ec.word.length > 0}}">
<block wx:for="{{wordDict.ec.word[0].trs}}" wx:key="index">
<block wx:for="{{item.tr}}" wx:key="index">
<block wx:for="{{item.l.i}}" wx:key="index">
<text>{{item}}</text>
</block>
</block>
</block>
</block>
</view>
<!-- 扩展内容(默认隐藏,点击 more 后显示) -->
<t-tabs class="t-tabs" animation="{{ { duration: 0.1 } }}" defaultValue="{{dictDefaultTabValue}}" bind:change="onTabsChange" bind:click="onTabsClick" wx:if="{{showDictExtended}}">
<t-tab-panel label="使用" value="0" wx:if="{{wordDict.collins && wordDict.collins.collins_entries && wordDict.collins.collins_entries.length > 0 && wordDict.collins.collins_entries[0].entries && wordDict.collins.collins_entries[0].entries.entry}}">
<view class="dictionary">
<block wx:for="{{wordDict.collins.collins_entries[0].entries.entry}}" wx:key="index">
<block wx:if="{{item.tran_entry && item.tran_entry.length > 0}}">
<view class="dictionary-content" wx:for="{{item.tran_entry}}" wx:key="tindex">
<view class="dictionary-pos" wx:if="{{item.pos_entry && item.pos_entry.pos && item.pos_entry.pos_tips}}">
<t-tag variant="light" theme="primary">
{{item.pos_entry.pos}} {{item.pos_entry.pos_tips}}
</t-tag>
</view>
<view class="dictionary-tran" wx:if="{{item.tranParts && item.tranParts.length > 0}}">
<block wx:for="{{item.tranParts}}" wx:key="pindex">
<text wx:if="{{!item.bold}}" class="normal-text">{{item.text}}</text>
<text wx:if="{{item.bold}}" class="dictionary-tran-bold">{{item.text}}</text>
</block>
</view>
<view class="dictionary-tran" wx:elif="{{item.tran}}">{{item.tran}}</view>
<view class="dictionary-list" wx:if="{{item.exam_sents && item.exam_sents.sent && item.exam_sents.sent.length > 0}}">
<block wx:for="{{item.exam_sents.sent}}" wx:key="index">
<view class="word-sent-en" wx:if="{{item.eng_sent}}">{{item.eng_sent}}</view>
<view class="word-sent-zh" wx:if="{{item.chn_sent}}">{{item.chn_sent}}</view>
</block>
</view>
</view>
</block>
</block>
</view>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.phrs && wordDict.phrs.phrs && wordDict.phrs.phrs.length > 0}}" label="短语" value="1">
<block wx:for="{{wordDict.phrs.phrs}}" wx:key="index">
<t-cell title="{{item.phr.headword.l.i}}" description="{{item.phr.trs[0].tr.l.i}}" />
</block>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.individual && wordDict.individual.pastExamSents && wordDict.individual.pastExamSents.length > 0}}" label="真题" value="2">
<view wx:for="{{wordDict.individual.pastExamSents}}" wx:key="index" class="exam-sent">
<view class="exam-sent-en">{{item.en}}</view>
<view class="exam-sent-zh">{{item.zh}}</view>
<view class="exam-sent-source">--摘自《{{item.source}}》</view>
</view>
</t-tab-panel>
<t-tab-panel wx:if="{{wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0}}" label="相关" value="3">
<view wx:if="{{(wordDict.syno && wordDict.syno.synos && wordDict.syno.synos.length > 0) || (wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0) || (wordDict.discriminate && wordDict.discriminate.data && wordDict.discriminate.data.length > 0)}}">
<view class="syno" wx:if="{{wordDict.syno && wordDict.syno.synos && wordDict.syno.synos.length > 0}}">
<view class="syno-title">同义词</view>
<view class="syno-list" wx:for="{{wordDict.syno.synos}}" wx:key="index">
<view class="syno-pos">
<t-tag wx:if="{{item.syno.pos}}" variant="light" theme="primary">
{{item.syno.pos}}
</t-tag>
<view class="syno-tran">{{item.syno.tran}}</view>
</view>
<t-tag class="syno-item" wx:for="{{item.syno.ws}}" wx:key="w" variant="light">
{{item.w}}
</t-tag>
</view>
</view>
<view class="rel-word" wx:if="{{wordDict.relWord && wordDict.relWord.rels && wordDict.relWord.rels.length > 0}}">
<view class="rel-word-title">相关单词</view>
<view class="rel-word-list" wx:for="{{wordDict.relWord.rels}}" wx:key="index">
<view class="rel-word-pos">
<t-tag wx:if="{{item.rel.pos}}" variant="light" theme="primary">
{{item.rel.pos}}
</t-tag>
</view>
<view class="rel-word-item" wx:for="{{item.rel.words}}" wx:key="word">
<view class="rel-word-tran">{{item.tran}}</view>
<view class="rel-word-word">{{item.word}}</view>
</view>
</view>
</view>
<view class="discriminate" wx:if="{{wordDict.discriminate && wordDict.discriminate.data && wordDict.discriminate.data.length > 0}}">
<view class="discriminate-title">区分单词</view>
<view class="discriminate-list" wx:for="{{wordDict.discriminate.data}}" wx:key="index">
<view class="discriminate-item" wx:for="{{item.usages}}" wx:key="headword">
<view class="discriminate-tran">{{item.usage}}</view>
<view class="discriminate-headword">{{item.headword}}</view>
</view>
</view>
</view>
</view>
<view wx:else class="no-related-words">
<text class="no-related-words-text">暂无相关单词信息</text>
</view>
</t-tab-panel>
</t-tabs>
<view class="no-word" wx:if="{{isWordEmptyResult}}">没有更多内容</view>
</view>
<score-modal
wx:if="{{isScoreModalOpen && hasScoreInfo}}"
visible="{{scoreModalVisible}}"
hasScoreInfo="{{hasScoreInfo}}"
sentence="{{currentSentence}}"
totalScore="{{displayTotalScore}}"
accuracyScore="{{displayAccuracyScore}}"
completenessScore="{{displayCompletenessScore}}"
fluencyScore="{{displayFluencyScore}}"
circleProgressStyle="{{circleProgressStyle}}"
accuracyCircleStyle="{{accuracyCircleStyle}}"
completenessCircleStyle="{{completenessCircleStyle}}"
fluencyCircleStyle="{{fluencyCircleStyle}}"
wordScores="{{wordScores}}"
matchTagLegend="{{matchTagLegend}}"
playIconName="{{playIconName}}"
bind:close="onCloseScoreModal"
bind:play="playAssessmentVoice"
/>
<word-dictionary
visible="{{showDictPopup}}"
expanded="{{showDictExtended}}"
loading="{{dictLoading}}"
wordDict="{{wordDict}}"
showBackIcon="{{showBackIcon}}"
prototypeWord="{{prototypeWord}}"
isWordEmptyResult="{{isWordEmptyResult}}"
dictDefaultTabValue="{{dictDefaultTabValue}}"
activeWordAudioType="{{activeWordAudioType}}"
wordAudioPlaying="{{wordAudioPlaying}}"
wordAudioIconName="{{wordAudioIconName}}"
bind:close="handleDictClose"
bind:more="handleDictMore"
bind:tabsChange="onTabsChange"
bind:tabsClick="onTabsClick"
bind:play="playWordAudio"
bind:back="handleBackToPreviousWord"
bind:wordTap="handleWordClick"
/>
<view class="word-popup-mask" wx:if="{{showDictPopup}}" bindtap="handleDictClose"></view>
</view>

View File

@@ -133,7 +133,7 @@ Page({
const basicData = {
isLoggedIn: app.globalData.isLoggedIn || false,
userInfo: app.globalData.userInfo || null,
dictLevel: app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'LEVEL1'
dictLevel: app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
}
// 设置基础统计数据
@@ -218,7 +218,7 @@ Page({
const userData = {
isLoggedIn: app.globalData.isLoggedIn || false,
userInfo: app.globalData.userInfo || null,
dictLevel: app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'LEVEL1'
dictLevel: app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
}
logger.info('更新用户信息:', userData)
@@ -265,7 +265,7 @@ Page({
logger.info('开始加载词典等级配置')
const startTime = Date.now()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'LEVEL1'
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
const endTime = Date.now()
logger.info('词典等级配置加载完成,耗时:', endTime - startTime, 'ms', dictLevel)
@@ -274,7 +274,7 @@ Page({
} catch (error) {
logger.error('加载词典等级配置失败:', error)
// 使用默认设置
this.setData({ dictLevel: 'LEVEL1' })
this.setData({ dictLevel: 'level1' })
resolve()
}
})

View File

@@ -0,0 +1,15 @@
{
"navigationBarTitleText": "场景句子",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",
"backgroundTextStyle": "light",
"enablePullDownRefresh": false,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-cell": "tdesign-miniprogram/cell/cell",
"score-modal": "/components/score-modal/score-modal",
"word-dictionary": "/components/word-dictionary/word-dictionary"
}
}

View File

@@ -0,0 +1,840 @@
import apiManager from '../../utils/api'
import logger from '../../utils/logger'
const recorderManager = wx.getRecorderManager()
let recorderHandlersBound = false
interface IData {
loadingMaskVisible: boolean
imageId: string
taskId?: string
statusText: string
scene?: {
id: string
image_id: string
list: Array<{
collocations: string[]
coreVocab: string[]
coreVocabDesc?: string[]
functionTags: string[]
pronunciationTip: string
pronunciationUrl?: string | null
imageTextId?: string
sceneExplanation: string
sentenceEn: string
sentenceZh: string
avoidScenarios?: string
}>
}
currentIndex: number
transDisplayMode: 'en' | 'en_zh'
contentVisible: boolean
isPlaying: boolean
isRecording: boolean
hasScoreInfo: boolean
isScoreModalOpen: boolean
scoreModalVisible: boolean
currentSentence?: {
content: string
file_id?: string
id?: string
}
totalScore: number
accuracyScore: number
completenessScore: number
fluencyScore: number
circleProgressStyle: string
accuracyCircleStyle: string
completenessCircleStyle: string
fluencyCircleStyle: string
wordScores: Array<any>
englishWords?: Array<string>
showDictPopup?: boolean
showDictExtended?: boolean
dictLoading?: boolean
wordDict?: any
isWordEmptyResult?: boolean
dictDefaultTabValue?: string
standardAudioMap?: Record<string, string>
standardAudioLocalMap?: Record<string, string>
wordAudioPlaying?: boolean
wordAudioIconName?: string
activeWordAudioType?: string
buttonsVisible?: boolean
commonMistakes?: string[]
pragmaticAlternative?: string[]
responsePairs?: string[]
recordStartTime?: number
recordDuration?: number
remainingTime?: number
overlayVisible?: boolean
highlightWords?: Array<any>
highlightShow?: boolean
highlightZoom?: boolean
analizing?: boolean
cachedHighlightWords?: Array<any>
cachedSentenceIndex?: number
}
interface IPageInstance {
pollTimer?: number
audioCtx?: any
wordAudioContext?: WechatMiniprogram.InnerAudioContext
wordAudioIconTimer?: number
recordTimer?: number
fetchSceneSentence: (imageId: string) => Promise<void>
startPolling: (taskId: string, imageId: string) => void
onTransTap: () => void
onPrevTap: () => void
onNextTap: () => void
switchSentence: (delta: number) => void
playStandardVoice: () => void
handleRecordStart: () => void
handleRecordEnd: () => void
onScoreTap: () => void
onCloseScoreModal: () => void
onWordTap: (e: any) => void
handleDictClose: () => void
handleDictMore: () => void
processCollinsData: (collinsData: any) => any
getStandardVoice: (sentenceId: string) => Promise<void>
noop: () => void
startRecording: () => void
stopRecording: () => void
onMicHighlight: () => void
computeHighlightLayout: () => void
fetchRecordResultForSentence: (textId: string) => Promise<void>
}
Page<IData, IPageInstance>({
data: {
loadingMaskVisible: false,
imageId: '',
statusText: '加载中...',
scene: undefined,
currentIndex: 0,
transDisplayMode: 'en_zh',
contentVisible: false,
isPlaying: false,
isRecording: false,
hasScoreInfo: false,
isScoreModalOpen: false,
scoreModalVisible: false,
currentSentence: undefined,
totalScore: 0,
accuracyScore: 0,
completenessScore: 0,
fluencyScore: 0,
circleProgressStyle: '',
accuracyCircleStyle: '',
completenessCircleStyle: '',
fluencyCircleStyle: '',
wordScores: [],
englishWords: [],
showDictPopup: false,
showDictExtended: false,
dictLoading: false,
wordDict: {},
isWordEmptyResult: false,
dictDefaultTabValue: '0',
standardAudioMap: {},
standardAudioLocalMap: {},
wordAudioPlaying: false,
wordAudioIconName: 'sound',
activeWordAudioType: '',
buttonsVisible: false,
commonMistakes: [],
pragmaticAlternative: [],
responsePairs: [],
recordStartTime: 0,
recordDuration: 0,
remainingTime: 30,
overlayVisible: false,
highlightWords: ["this", "dish"],
highlightShow: false,
highlightZoom: false,
analizing: false,
cachedHighlightWords: [],
cachedSentenceIndex: -1
},
noop() {},
async onLoad(options: Record<string, string>) {
const imageId = options.image_id || options.imageId || ''
this.setData({ imageId })
if (!imageId) {
wx.showToast({ title: '缺少 image_id', icon: 'none' })
return
}
this.setData({ loadingMaskVisible: true, statusText: '加载中...', currentIndex: 0, transDisplayMode: 'en_zh', contentVisible: false, isPlaying: false, isRecording: false, hasScoreInfo: false, isScoreModalOpen: false, scoreModalVisible: false })
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
}
if (!recorderHandlersBound) {
recorderManager.onStop((res) => {
const ms = Date.now() - (this.data.recordStartTime || 0)
if (ms >= 3000) {
wx.showModal({
title: '提示',
content: '录音完成,是否确认提交?',
success: (r) => {
if (r.confirm) {
this.setData({ analizing: true })
apiManager.uploadFile(res.tempFilePath).then((fileId) => {
const cur = this.data.scene?.list?.[this.data.currentIndex]
const imageTextId = cur && cur.imageTextId ? String(cur.imageTextId) : ''
if (!imageTextId) {
this.setData({ analizing: false })
wx.showToast({ title: '缺少句子ID', icon: 'none' })
return
}
apiManager.getAssessmentResult(fileId, imageTextId).then((result) => {
const assessmentResult = result.assessment_result?.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((s: number) => s < 0)
const wordScores = (assessmentResult.Words || []).map((w: any) => ({
word: w.Word,
pronAccuracy: Number((w.PronAccuracy || 0).toFixed(2)),
pronFluency: Number((w.PronFluency || 0).toFixed(2)),
matchTag: w.MatchTag || 0,
phoneInfos: (w.PhoneInfos || []).map((p: any) => ({
phone: p.Phone,
pronAccuracy: Number((p.PronAccuracy || 0).toFixed(2)),
matchTag: p.MatchTag || 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,
wordScores,
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
this.onScoreTap()
}).catch(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
wx.showToast({ title: '评估失败', icon: 'none' })
}).finally(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
})
}).catch(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
wx.showToast({ title: '上传失败', icon: 'none' })
})
} else {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
}
}
})
} else {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
wx.showToast({ title: '说话时间太短', icon: 'none' })
}
})
recorderManager.onError(() => {
wx.showToast({ title: '录音失败', icon: 'none' })
// this.setData({ overlayVisible: false, isRecording: false })
const timer = setTimeout(() => {
// this.setData({ highlightShow: false, highlightZoom: false, highlightWords: [], analizing: false })
clearTimeout(timer)
}, 320)
})
recorderHandlersBound = true
}
await this.fetchSceneSentence(imageId)
},
async fetchSceneSentence(imageId: string) {
try {
const resp = await apiManager.getSceneSentence(imageId)
if (resp && Object.keys(resp || {}).length > 0) {
logger.info('场景句子数据:', resp)
const scene = resp.data || resp
const idx = 0
const cur = scene && scene.list && scene.list[idx] ? scene.list[idx] : undefined
const currentSentence = cur ? { content: cur.sentenceEn, file_id: cur.pronunciationUrl || undefined, id: cur.imageTextId } : undefined
const englishWords = cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : []
const commonMistakes = (cur as any)?.commonMistakes || [
'1. 漏冠词a×bowl mint leaves',
'2. refreshing误读为/ˈrefrʌʃɪŋ/'
]
const pragmaticAlternative = (cur as any)?.pragmaticAlternative || [
'This bowl of mint leaves appears very refreshing.(更书面)',
'These mint leaves look crisp and fresh.(强调口感)'
]
const responsePairs = (cur as any)?.responsePairs || []
this.setData({
scene,
loadingMaskVisible: false,
statusText: '已获取数据',
contentVisible: true,
currentIndex: idx,
currentSentence,
englishWords,
buttonsVisible: true,
commonMistakes,
pragmaticAlternative,
responsePairs,
hasScoreInfo: false
})
const sid = cur && cur.imageTextId
if (sid) {
try { this.fetchRecordResultForSentence(String(sid)) } catch (e) {}
}
} else {
const { task_id } = await apiManager.createScene(imageId, 'scene_sentence')
this.setData({ taskId: task_id, statusText: '解析中...' })
this.startPolling(task_id, imageId)
}
} catch (e) {
logger.error('获取场景句子失败:', e)
wx.showToast({ title: '加载失败', icon: 'none' })
this.setData({ loadingMaskVisible: false })
}
},
startPolling(taskId: string, imageId: string) {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
this.setData({ loadingMaskVisible: true })
this.pollTimer = setInterval(async () => {
try {
const res = await apiManager.getSceneTask(taskId)
if (res.status === 'completed') {
clearInterval(this.pollTimer!)
this.pollTimer = undefined
await this.fetchSceneSentence(imageId)
} else if (res.status === 'failed') {
clearInterval(this.pollTimer!)
this.pollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
}
} catch (err) {
logger.error('轮询任务状态失败:', err)
}
}, 3000) as any
},
onTransTap() {
const mode = this.data.transDisplayMode === 'en' ? 'en_zh' : 'en'
this.setData({ transDisplayMode: mode })
},
onPrevTap() {
this.switchSentence(-1)
},
onNextTap() {
this.switchSentence(1)
},
switchSentence(delta: number) {
const list = this.data.scene?.list || []
const count = list.length
if (count === 0) return
const nextIndex = this.data.currentIndex + delta
if (nextIndex < 0 || nextIndex >= count) return
if (this.data.isPlaying) {
this.setData({ isPlaying: false })
}
this.setData({ contentVisible: false })
setTimeout(() => {
const list = this.data.scene?.list || []
const cur = list && list[nextIndex] ? list[nextIndex] : undefined
const currentSentence = cur ? { content: cur.sentenceEn, file_id: cur.pronunciationUrl || undefined, id: cur.imageTextId } : undefined
const englishWords = cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : []
const commonMistakes = (cur as any)?.commonMistakes || [
'1. 漏冠词a×bowl mint leaves',
'2. refreshing误读为/ˈrefrʌʃɪŋ/'
]
const pragmaticAlternative = (cur as any)?.pragmaticAlternative || []
const responsePairs = (cur as any)?.responsePairs || []
this.setData({
currentIndex: nextIndex,
currentSentence,
englishWords,
commonMistakes,
pragmaticAlternative,
responsePairs,
contentVisible: true,
hasScoreInfo: false
})
const sid = cur && cur.imageTextId
if (sid) {
wx.nextTick(() => { this.getStandardVoice(String(sid)) })
wx.nextTick(() => { this.fetchRecordResultForSentence(String(sid)) })
}
}, 200)
},
async fetchRecordResultForSentence(textId: string) {
try {
const rec: any = await apiManager.getRecordResult(textId)
const hasDetails = !!(rec && rec.details && Object.keys(rec.details || {}).length > 0)
if (!hasDetails) {
this.setData({ hasScoreInfo: false })
return
}
const assessment = rec.details?.assessment || {}
const result = assessment?.result || {}
const suggestedScore = result?.SuggestedScore ?? 0
const pronAccuracy = result?.PronAccuracy ?? 0
const pronCompletion = result?.PronCompletion ?? 0
const pronFluency = result?.PronFluency ?? 0
const wordScores = (result?.Words || []).map((w: any) => ({
word: w.Word,
pronAccuracy: Number((w.PronAccuracy || 0).toFixed(2)),
pronFluency: Number((w.PronFluency || 0).toFixed(2)),
matchTag: w.MatchTag || 0,
phoneInfos: (w.PhoneInfos || []).map((p: any) => ({
phone: p.Phone,
pronAccuracy: Number((p.PronAccuracy || 0).toFixed(2)),
matchTag: p.MatchTag || 0
}))
}))
const curSentence = this.data.currentSentence || { content: '' }
this.setData({
hasScoreInfo: true,
totalScore: Number((suggestedScore || 0).toFixed(2)),
accuracyScore: Number((pronAccuracy || 0).toFixed(2)),
completenessScore: Number((pronCompletion || 0).toFixed(2)),
fluencyScore: Number((pronFluency || 0).toFixed(2)),
wordScores,
currentSentence: { ...curSentence, file_id: rec?.file_id || curSentence.file_id }
})
} catch (e) {
this.setData({ hasScoreInfo: false })
}
},
playStandardVoice() {
const sid = this.data.currentSentence?.id
if (!sid) { wx.showToast({ title: '缺少 imageTextId', icon: 'none' }); return }
const audioUrl = (this.data.standardAudioMap || {})[sid]
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioCtx as any).autoplay = false } catch (e) {}
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
}
if (!audioUrl) {
wx.showLoading({ title: '正在获取...' })
this.getStandardVoice(sid)
.then(() => {
wx.hideLoading()
this.playStandardVoice()
})
.catch(() => {
wx.hideLoading()
})
return
}
const cachedLocal = (this.data.standardAudioLocalMap || {})[sid]
const playWithPath = (filePath: string) => {
if (this.data.isPlaying && this.audioCtx?.src === filePath) {
try { this.audioCtx.pause() } catch (e) {}
try { this.audioCtx.seek(0) } catch (e) {}
this.setData({ isPlaying: false })
return
}
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioCtx as any).autoplay = false } catch (e) {}
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
}
if (this.audioCtx.src !== filePath) {
try { this.audioCtx.pause() } catch (e) {}
try { this.audioCtx.seek(0) } catch (e) {}
this.audioCtx.src = filePath
} else {
try { this.audioCtx.seek(0) } catch (e) {}
}
try {
this.audioCtx.play()
this.setData({ isPlaying: true })
} catch (error) {
wx.showToast({ title: '音频播放失败', icon: 'none' })
}
}
if (cachedLocal) {
playWithPath(cachedLocal)
} else {
apiManager.downloadFile(audioUrl).then((filePath) => {
const map = this.data.standardAudioLocalMap || {}
this.setData({ standardAudioLocalMap: { ...map, [sid]: filePath } })
playWithPath(filePath)
}).catch((error) => {
logger.error('下载音频失败:', error)
wx.showToast({ title: '音频下载失败', icon: 'none' })
})
}
},
handleRecordStart() {
if (this.data.isRecording) return
this.startRecording()
try { this.onMicHighlight() } catch (e) {}
},
handleRecordEnd() {
this.stopRecording()
},
onScoreTap() {
if (!this.data.hasScoreInfo) return
this.setData({ isScoreModalOpen: true })
setTimeout(() => this.setData({ scoreModalVisible: true }), 50)
},
onCloseScoreModal() {
this.setData({ isScoreModalOpen: false, scoreModalVisible: false })
},
async onWordTap(e: any) {
const rawWord = (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.word)
|| (e && e.target && e.target.dataset && e.target.dataset.word)
|| (e && e.detail && e.detail.word)
|| ''
console.log('rawWord:', rawWord)
if (!rawWord) return
const cleanedWord = String(rawWord).replace(/[.,?!*;:'"()]/g, '').trim()
if (!cleanedWord) return
try {
this.setData({ dictLoading: true, showDictPopup: true })
const wordDetail: any = await apiManager.getWordDetail(cleanedWord)
const processedCollins = wordDetail['collins'] ? this.processCollinsData(wordDetail['collins']) : wordDetail['collins']
const rawRelWord = wordDetail['rel_word']
let sanitizedRelWord = rawRelWord
if (rawRelWord && rawRelWord.rels && Array.isArray(rawRelWord.rels)) {
sanitizedRelWord = {
...rawRelWord,
rels: rawRelWord.rels.map((entry: any) => {
const rel = entry.rel || {}
const pos = rel.pos
const cleanPos = typeof pos === 'string' ? pos.replace(/\[|\]/g, '') : pos
return { ...entry, rel: { ...rel, pos: cleanPos } }
})
}
}
const hasEE = !!wordDetail['ee']
const hasEC = !!(wordDetail['ec'] && wordDetail['ec'].word && wordDetail['ec'].word.length > 0)
const hasCollins = !!(processedCollins && processedCollins.collins_entries && processedCollins.collins_entries.length > 0)
const isWordEmptyResult = !(hasEE || hasEC || hasCollins)
const hasPhrs = !!(wordDetail['phrs'] && wordDetail['phrs'].phrs && wordDetail['phrs'].phrs.length > 0)
const hasPastExam = !!(wordDetail['individual'] && wordDetail['individual'].pastExamSents && wordDetail['individual'].pastExamSents.length > 0)
const dictDefaultTabValue = hasCollins ? '0' : (hasPhrs ? '1' : (hasPastExam ? '2' : '3'))
this.setData({
showDictPopup: true,
wordDict: {
ee: wordDetail['ee'], ec: wordDetail['ec'], expandEc: wordDetail['expand_ec'],
simple: wordDetail['simple'], phrs: wordDetail['phrs'], etym: wordDetail['etym'],
individual: wordDetail['individual'], collins: processedCollins,
relWord: sanitizedRelWord, syno: wordDetail['syno'],
discriminate: wordDetail['discriminate']
},
isWordEmptyResult,
dictDefaultTabValue
})
this.setData({ dictLoading: false })
} catch (err) {
logger.error('获取单词详情失败:', err)
wx.showToast({ title: '获取单词详情失败', icon: 'none' })
this.setData({ dictLoading: false })
}
},
handleDictClose() {
this.setData({ showDictPopup: false, showDictExtended: false })
try { this.wordAudioContext && this.wordAudioContext.stop() } catch (e) {}
try {
if (this.wordAudioContext) {
this.wordAudioContext.offPlay()
this.wordAudioContext.offEnded()
this.wordAudioContext.offError()
this.wordAudioContext.destroy()
this.wordAudioContext = undefined
}
} catch (e) {}
if (this.wordAudioIconTimer) {
clearInterval(this.wordAudioIconTimer)
this.wordAudioIconTimer = undefined
}
this.setData({ wordAudioPlaying: false, wordAudioIconName: 'sound', activeWordAudioType: '' })
},
handleDictMore() {
this.setData({ showDictExtended: !this.data.showDictExtended })
},
processCollinsData(collinsData: any) {
if (!collinsData || !collinsData.collins_entries) return collinsData
const processedData = JSON.parse(JSON.stringify(collinsData))
processedData.collins_entries.forEach((entry: any) => {
if (entry.entries && entry.entries.entry) {
entry.entries.entry.forEach((entryItem: any) => {
if (entryItem.tran_entry) {
entryItem.tran_entry.forEach((tranEntry: any) => {
if (tranEntry.tran) {
const parts = tranEntry.tran.split(/(<b>.*?<\/b>)/g).filter(Boolean)
const processedParts: Array<{ text: string; bold: boolean }> = []
parts.forEach((part: string) => {
if (part.startsWith('<b>') && part.endsWith('</b>')) {
const text = part.substring(3, part.length - 4)
processedParts.push({ text, bold: true })
} else {
processedParts.push({ text: part, bold: false })
}
})
tranEntry.tranParts = processedParts
tranEntry.originalTran = tranEntry.tran
}
})
}
})
}
})
return processedData
},
startRecording() {
const options: WechatMiniprogram.RecorderManagerStartOption = {
duration: 30000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3' as 'mp3'
}
this.recordTimer && clearInterval(this.recordTimer)
this.setData({ recordDuration: 0, remainingTime: 30 })
recorderManager.start(options)
this.setData({ isRecording: true, recordStartTime: Date.now() })
this.recordTimer = setInterval(() => {
const duration = Date.now() - (this.data.recordStartTime || 0)
const remaining = Math.max(0, 30 - Math.floor(duration / 1000))
this.setData({ recordDuration: duration, remainingTime: remaining })
if (remaining === 0) {
this.stopRecording()
}
}, 100) as any
},
stopRecording() {
if (!this.data.isRecording) return
const duration = Date.now() - (this.data.recordStartTime || 0)
if (this.recordTimer) { clearInterval(this.recordTimer) }
if (duration < 3000) {
try { recorderManager.stop() } catch (e) {}
this.setData({
isRecording: false,
remainingTime: 30,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
cachedHighlightWords: [],
cachedSentenceIndex: -1
})
return
}
try { recorderManager.stop() } catch (e) {}
},
onMicHighlight() {
this.computeHighlightLayout()
const startAnim = () => {
const words = this.data.cachedHighlightWords || []
if (!words || words.length === 0) return
this.setData({
overlayVisible: true,
highlightWords: words.map((w: any) => ({ ...w, transform: 'translate(0px, 0px) scale(1)' })),
highlightShow: false,
highlightZoom: false
})
setTimeout(() => {
this.setData({ highlightShow: true })
setTimeout(() => {
this.setData({ highlightZoom: true })
try {
wx.nextTick(() => {
const updated = (this.data.highlightWords || []).map((w: any) => ({ ...w, transform: w.targetTransform }))
this.setData({ highlightWords: updated })
})
} catch (e) {
setTimeout(() => {
const updated = (this.data.highlightWords || []).map((w: any) => ({ ...w, transform: w.targetTransform }))
this.setData({ highlightWords: updated })
}, 0)
}
}, 500)
}, 50)
}
try {
wx.nextTick(() => {
setTimeout(() => startAnim(), 80)
})
} catch (e) {
setTimeout(() => {
setTimeout(() => startAnim(), 80)
}, 0)
}
},
computeHighlightLayout() {
const list = this.data.scene?.list || []
const cur = list[this.data.currentIndex]
const words = (cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : [])
if (!words || words.length === 0) return
const sys = wx.getSystemInfoSync()
const windowWidth = sys.windowWidth || 375
const windowHeight = sys.windowHeight || 667
const bottomOffset = 120
const scale = 1.6
const centerX = windowWidth / 2
const centerY = (windowHeight - bottomOffset) / 2
const query = wx.createSelectorQuery().in(this as any)
query.selectAll('.sentence-en .sentence-word').boundingClientRect()
query.exec((res: any) => {
const rects = (res && res[0]) || []
if (!rects || rects.length === 0) return
const sidePadding = 24
const wordSpacing = 12
const rowSpacing = 16
const availWidth = Math.max(windowWidth - sidePadding * 2, 100)
const scaledHeights = rects.map((r: any) => r.height * scale)
const rowHeight = Math.max(...scaledHeights)
const scaledWidths = rects.map((r: any) => Math.max(r.width * scale, 10))
const rows: { idxs: number[], width: number }[] = []
let current: { idxs: number[], width: number } = { idxs: [], width: 0 }
scaledWidths.forEach((w: number, i: number) => {
const extra = current.idxs.length > 0 ? wordSpacing : 0
if (current.width + extra + w <= availWidth) {
current.idxs.push(i)
current.width += extra + w
} else {
if (current.idxs.length > 0) rows.push(current)
current = { idxs: [i], width: w }
}
})
if (current.idxs.length > 0) rows.push(current)
const totalHeight = rows.length * rowHeight + Math.max(rows.length - 1, 0) * rowSpacing
const firstRowCenterY = centerY - totalHeight / 2 + rowHeight / 2
const targetWords = rects.map((r: any, idx: number) => {
const rcx = r.left + r.width / 2
const rcy = r.top + r.height / 2
let rowIndex = 0
let y = firstRowCenterY
for (let ri = 0; ri < rows.length; ri++) {
if (rows[ri].idxs.includes(idx)) { rowIndex = ri; break }
y += rowHeight + rowSpacing
}
const row = rows[rowIndex]
const rowStartX = centerX - row.width / 2
let cumX = 0
for (const j of row.idxs) {
if (j === idx) break
cumX += scaledWidths[j] + wordSpacing
}
const targetCx = rowStartX + cumX + scaledWidths[idx] / 2
const targetCy = firstRowCenterY + rowIndex * (rowHeight + rowSpacing)
const dx = targetCx - rcx
const dy = targetCy - rcy
const transform = `translate(${dx}px, ${dy}px) scale(${scale})`
return {
text: words[idx] || '',
left: r.left,
top: r.top,
width: r.width,
height: r.height,
targetTransform: transform,
transform: 'translate(0px, 0px) scale(1)'
}
})
this.setData({ cachedHighlightWords: targetWords, cachedSentenceIndex: this.data.currentIndex })
})
},
async getStandardVoice(sentenceId: string) {
try {
const map = this.data.standardAudioMap || {}
if (map[sentenceId]) {
if (this.audioCtx && !this.audioCtx.src) {
this.audioCtx.src = map[sentenceId]
}
return
}
const { audio_id } = await apiManager.getStandardVoice(sentenceId)
if (audio_id) {
const fileUrl = String(audio_id)
this.setData({ standardAudioMap: { ...map, [sentenceId]: fileUrl } })
if (this.audioCtx && !this.audioCtx.src) {
this.audioCtx.src = fileUrl
}
}
} catch (err) {
logger.error('获取标准语音失败:', err)
wx.showToast({ title: '获取语音失败', icon: 'none' })
}
},
onUnload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
if (this.audioCtx) {
try {
this.audioCtx.destroy()
} catch {}
this.audioCtx = undefined
}
}
})

View File

@@ -0,0 +1,151 @@
<view class="scene-sentence-container">
<view wx:if="{{loadingMaskVisible}}" class="page-loading-mask">
<view class="scanner scanner-visible">
<view class="star star1"></view>
<view class="star star2"></view>
<view class="star star3"></view>
</view>
<view class="status-text">场景分析中...</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{scene && scene.list && scene.list.length > 0}}">
<block wx:if="{{scene.list[currentIndex]}}">
<view class="sentence-header">
<view class="sentence-en sentence-content-wrapper">
<view wx:for="{{englishWords}}" wx:for-item="w" wx:for-index="i" wx:key="i" class="sentence-word" style="{{i === 0 ? 'text-transform: capitalize;' : ''}}" data-word="{{w}}" data-index="{{i}}" bindtap="onWordTap">{{w}}</view>
</view>
<text class="sentence-zh">{{scene.list[currentIndex].sentenceZh}}</text>
</view>
<scroll-view class="sentence-body" scroll-y="true" scroll-with-animation="true">
<view class="tags-wrap">
<t-tag wx:for="{{scene.list[currentIndex].functionTags}}" wx:key="idx" variant="light"><text>#</text>{{item}}</t-tag>
</view>
<view class="tip-card">
<view class="tip-title">
<t-icon name="info-circle" size="40rpx" />
<text>发音提示</text>
</view>
<view class="tip-content">
<text>{{scene.list[currentIndex].pronunciationTip}}</text>
</view>
<view class="fluency-hack" wx:if="{{scene.list[currentIndex].fluencyHacks}}">
<view class="hack-title">
<t-icon name="chat-bubble-smile" size="32rpx" />
<text>技巧</text>
</view>
<view class="hack-content">
<text>{{scene.list[currentIndex].fluencyHacks}}</text>
</view>
</view>
</view>
<view class="tip-card">
<view class="tip-title">
<t-icon name="chat-bubble-error" size="40rpx" />
<text>常见错误</text>
</view>
<view class="tip-content">
<text wx:for="{{commonMistakes}}" wx:for-index="i" wx:key="idx" class="mistake-item ">{{item}}</text>
</view>
<!-- <view class="fluency-hack" wx:if="{{scene.list[currentIndex].fluencyHacks}}">
<view class="hack-title">
<t-icon name="notification-error" size="32rpx" />
<text>注意事项</text>
</view>
<view class="hack-content">
<text>{{scene.list[currentIndex].avoidScenarios}}</text>
</view>
</view> -->
</view>
<view class="section-title">
<text>相似句型</text>
</view>
<view class="mistake-card" wx:if="{{pragmaticAlternative && pragmaticAlternative.length > 0}}">
<view class="mistake-list">
<text wx:for="{{pragmaticAlternative}}" wx:for-index="i" wx:key="idx" class="mistake-item ">{{item}}</text>
</view>
</view>
<view class="section-title">
<text>搭话</text>
</view>
<view class="mistake-card" wx:if="{{responsePairs && responsePairs.length > 0}}">
<view class="mistake-list">
<text wx:for="{{responsePairs}}" wx:for-index="i" wx:key="idx" class="mistake-item ">{{item}}</text>
</view>
</view>
<view class="section-title">
<text>核心词汇</text>
<text class="section-count">{{scene.list[currentIndex].coreVocab.length}} words</text>
</view>
<view class="core-vocab">
<t-tag class="core-vocab-tag" wx:for="{{scene.list[currentIndex].coreVocab}}" wx:for-index="i" wx:key="word" variant="light" theme="primary" data-word="{{item}}" bindtap="onWordTap">{{item}} {{scene.list[currentIndex].coreVocabDesc[i]}}</t-tag>
</view>
<view class="section-title">
<text>搭配用法</text>
</view>
<view class="collocations">
<t-tag wx:for="{{scene.list[currentIndex].collocations}}" wx:key="idx" variant="light" theme="primary">{{item}}</t-tag>
</view>
<!-- <view class="scene-explain">
<text>{{scene.list[currentIndex].sceneExplanation}}</text>
</view> -->
</scroll-view>
</block>
</view>
<view class="highlight-area {{overlayVisible ? 'show' : ''}}" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop">
<view wx:for="{{highlightWords}}" wx:key="index"
class="overlay-word {{highlightShow ? 'show' : ''}} {{highlightZoom ? 'zoom' : ''}}"
style="left: {{item.left}}px; top: {{item.top}}px; width: {{item.width}}px; height: {{item.height}}px; {{item.transform ? ('transform: ' + item.transform) : ''}}">
<view class="overlay-text">{{item.text}}</view>
</view>
</view>
<view wx:if="{{isRecording}}" class="recording-mask" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop"></view>
<view class="bottom-bar {{buttonsVisible ? 'show' : ''}}">
<t-icon name="chevron-left" class="bottom-btn {{currentIndex <= 0 ? 'disabled' : ''}}" size="48rpx" bind:tap="onPrevTap" />
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-btn" size="48rpx" bind:tap="playStandardVoice" />
<view class="mic-wrap" bind:longpress="handleRecordStart" bind:touchend="handleRecordEnd" bind:touchcancel="handleRecordEnd">
<t-icon name="microphone-1" class="microphone {{isRecording ? 'recording' : 'bottom-btn'}}" size="48rpx" />
<view wx:if="{{isRecording}}" class="mic">
<view class="mic-shadow"></view>
</view>
</view>
<t-icon name="fact-check" class="bottom-btn {{(hasScoreInfo && !isRecording) ? '' : 'disabled'}}" size="48rpx" bind:tap="onScoreTap" />
<t-icon name="chevron-right" class="bottom-btn {{(scene.list && (currentIndex >= scene.list.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
</view>
<score-modal
wx:if="{{isScoreModalOpen && hasScoreInfo}}"
visible="{{scoreModalVisible}}"
hasScoreInfo="{{hasScoreInfo}}"
sentence="{{currentSentence}}"
totalScore="{{totalScore}}"
accuracyScore="{{accuracyScore}}"
completenessScore="{{completenessScore}}"
fluencyScore="{{fluencyScore}}"
circleProgressStyle="{{circleProgressStyle}}"
accuracyCircleStyle="{{accuracyCircleStyle}}"
completenessCircleStyle="{{completenessCircleStyle}}"
fluencyCircleStyle="{{fluencyCircleStyle}}"
wordScores="{{wordScores}}"
bind:close="onCloseScoreModal"
bind:play="playStandardVoice"
/>
<word-dictionary
visible="{{showDictPopup}}"
expanded="{{showDictExtended}}"
loading="{{dictLoading}}"
wordDict="{{wordDict}}"
isWordEmptyResult="{{isWordEmptyResult}}"
dictDefaultTabValue="{{dictDefaultTabValue}}"
bind:close="handleDictClose"
bind:more="handleDictMore"
bind:wordTap="onWordTap"
/>
<view class="word-popup-mask" wx:if="{{showDictPopup}}" bindtap="handleDictClose"></view>
</view>

View File

@@ -0,0 +1,447 @@
.scene-sentence-container {
min-height: 100vh;
background: #ffffff;
/* padding-bottom: calc(220rpx + env(safe-area-inset-bottom)); */
}
.status-text {
font-size: 28rpx;
color: #666666;
}
.page-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 1000;
}
/* 遮罩层样式 */
.word-popup-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99;
}
.container {
width: 100%;
max-width: 750rpx;
padding: 32rpx;
box-sizing: border-box;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.fade-in { animation: fadeInUp 260ms ease forwards; }
.fade-out { animation: fadeOutDown 200ms ease forwards; }
@keyframes fadeInUp {
from { opacity: 0; transform: translate3d(0, 12rpx, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes fadeOutDown {
from { opacity: 1; transform: translate3d(0, 0, 0); }
to { opacity: 0; transform: translate3d(0, 12rpx, 0); }
}
.sentence-header {
display: flex;
flex-direction: column;
gap: 16rpx;
align-items: center;
}
.sentence-body {
flex: 1;
margin-top: 24rpx;
box-sizing: border-box;
overflow: hidden;
margin-bottom: calc(110rpx + env(safe-area-inset-bottom)) ;
}
.sentence-content-wrapper {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
justify-content: center;
}
.sentence-word {
font-size: 40rpx;
line-height: 56rpx;
color: #001858;
font-weight: 600;
}
.sentence-en {
font-size: 40rpx;
font-weight: 700;
color: #001858;
line-height: 56rpx;
}
.sentence-zh {
font-size: 28rpx;
color: #666;
text-align: center;
}
/* 高亮遮罩与单词浮层 */
.highlight-area {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(120rpx + env(safe-area-inset-bottom));
background: rgba(0,0,0,0.6);
z-index: 99;
display: none;
opacity: 0;
transition: opacity 300ms ease;
}
.highlight-area.show { display: block; opacity: 1; }
.overlay-word {
position: fixed;
color: #ffffff;
font-size: 40rpx;
line-height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
opacity: 0;
transition: opacity 500ms ease;
font-weight: 600;
}
.overlay-word .overlay-text {
color: #ffffff;
}
.overlay-word.show { opacity: 1; }
.overlay-word.zoom { transform-origin: center center; transition: transform 500ms ease; }
.recording-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 99;
}
.mic-wrap {
position: relative;
}
.microphone {
transition: all 0.3s ease;
z-index: 1;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.microphone.recording {
background: transparent;
color: #fff;
}
.mic {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
pointer-events: none;
}
.mic::before,
.mic::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 100%;
z-index: 2;
box-shadow: 0 0 4.8px 4.8px #1c084f;
}
.mic::before {
width: 72rpx;
height: 72rpx;
background-color: #1a084e;
}
.mic::after {
width: 48rpx;
height: 48rpx;
background-color: #2f1e5f;
animation: mic-circle-size 0.8s linear infinite alternate;
}
.mic-shadow {
width: 72rpx;
height: 72rpx;
position: absolute;
top: 50%;
left: 50%;
border-radius: 100%;
z-index: 1;
box-shadow: 3.6rpx -14.4rpx 7.2rpx 3.6rpx #823ca6,
9.6rpx -2.4rpx 14.4rpx 3.6rpx #aab3d2,
9.6rpx 7.2rpx 26.4rpx 3.6rpx #5acee3,
19.2rpx 2.4rpx 7.2rpx 3.6rpx #1b7d8f,
2.4rpx 1.2rpx 21.6rpx 3.6rpx #f30bf5;
transform: translate(-50%, -50%);
transform-origin: 0% 0%;
animation: mic-shadow-rotate 2s linear infinite;
}
@keyframes mic-circle-size {
from {
width: 48rpx;
height: 48rpx;
}
to {
width: 57.6rpx;
height: 57.6rpx;
}
}
@keyframes mic-shadow-rotate {
from { rotate: 0deg; }
to { rotate: 360deg; }
}
.tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 24rpx;
justify-content: center;
align-items: center;
}
.tip-card {
border-radius: 20rpx;
background: #fff;
border: 2rpx solid #e6eef9;
position: relative;
margin-bottom: 24rpx;
overflow: hidden;
}
.tip-card::after {
content: "";
position: absolute;
top: -60rpx;
right: -60rpx;
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background-color: #eef3fb;
opacity: 0.6;
}
.tip-title {
position: relative;
padding: 24rpx;
display: flex;
align-items: center;
gap: 12rpx;
font-size: 28rpx;
color: #001858;
font-weight: 600;
}
.tip-content {
position: relative;
padding: 0 24rpx;
padding-bottom: 24rpx;
font-size: 28rpx;
color: #333;
line-height: 42rpx;
z-index: 1;
}
.fluency-hack {
position: relative;
display: block;
margin-top: 16rpx;
padding: 24rpx 24rpx 28rpx;
border-top: 2rpx solid #e6eef9;
background: #eef3fb;
border-radius: 0 0 20rpx 20rpx;
}
.hack-title {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 26rpx;
color: #001858;
font-weight: 600;
}
.hack-content {
margin-top: 10rpx;
font-size: 28rpx;
color: #333;
line-height: 42rpx;
}
.section-title {
display: flex;
align-items: baseline;
gap: 10rpx;
font-size: 30rpx;
color: #001858;
font-weight: 600;
margin: 24rpx 0 12rpx;
}
.section-count {
font-size: 24rpx;
color: #999;
font-weight: 400;
}
.core-vocab {
border-radius: 16rpx;
overflow: hidden;
padding: 12rpx;
}
.core-vocab-tag {
margin: 8rpx 12rpx 8rpx 0;
}
.collocations {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 24rpx;
}
.mistake-card {
position: relative;
padding: 24rpx;
border-radius: 20rpx;
background: #fff;
border: 2rpx solid #e6eef9;
margin: 12rpx 0 24rpx;
overflow: hidden;
}
.mistake-card::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8rpx;
border-top-left-radius: 20rpx;
border-bottom-left-radius: 20rpx;
background: #eef3fb;
}
.mistake-title {
font-size: 28rpx;
color: #001858;
font-weight: 600;
margin-bottom: 12rpx;
}
.mistake-item {
display: block;
font-size: 28rpx;
color: #333;
line-height: 42rpx;
}
.scene-explain {
font-size: 26rpx;
color: #666;
line-height: 40rpx;
background: #fafafa;
border-radius: 16rpx;
padding: 20rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
box-shadow: 0 -8rpx 24rpx rgba(0,0,0,0.08);
opacity: 0;
transition: opacity 300ms ease;
}
.bottom-bar.show { opacity: 1; }
.bottom-btn {
padding: 20rpx;
border-radius: 50%;
background: #f5f5f5;
color: #666;
}
.bottom-btn.disabled {
opacity: 0.4;
pointer-events: none;
}
.score-btn {
padding: 16rpx 24rpx;
border-radius: 28rpx;
background: #f5f5f5;
color: #001858;
font-size: 26rpx;
}
.scanner {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
pointer-events: none;
border-radius: 12rpx;
background-color: rgba(255, 255, 255, 0.35);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.scanner-visible { animation: scannerFadeIn 1s 1000ms ease forwards; }
@keyframes scannerFadeIn { from { opacity: 0 } to { opacity: 1 } }
.star { position: relative; opacity: 0; transform-origin: center center; will-change: transform, opacity; }
.star::after {
content:"";
position:absolute;
width: 36rpx;
height: 40rpx;
border-radius: 8rpx;
background: linear-gradient(337deg, #DE9EFC 7.88%, #31DAFF 107.03%);
transform:rotate(45deg) skewX(22.5deg) skewY(22.5deg);
}
.star::before{
content:"";
position:absolute;
width: 36rpx;
height: 40rpx;
border-radius: 8rpx;
background: linear-gradient(78.35deg, #DE9EFC 7.88%, #31DAFF 107.03%);
transform:rotate(-45deg) skewX(22.5deg) skewY(22.5deg);
}
.scanner-visible .star { animation: starCycle 3000ms ease-in-out infinite; }
.scanner-visible .star1 { animation-delay: 1000ms; }
.scanner-visible .star2 { animation-delay: 2000ms; }
.scanner-visible .star3 { animation-delay: 3000ms; }
@keyframes starCycle {
0% {
opacity: 0;
transform: translate(-24rpx, -24rpx) scale(0.5);
}
34% {
opacity: 1;
transform: translate(-24rpx, -24rpx) scale(0.5);
}
67% {
opacity: 1;
transform: translate(0rpx, 0rpx) scale(1);
}
100% {
opacity: 0;
transform: translate(36rpx, 36rpx) scale(0.5);
}
}

View File

@@ -71,6 +71,8 @@ Page({
dateStripScrollLeft: 0,
dateStripTransform: '',
isSwitchingDate: false,
dateItemWidthRpx: 120,
dateItemGapRpx: 24,
},
onLoad() {
@@ -820,6 +822,8 @@ Page({
}
},
noop() {},
// 跳转到个人主页
goProfile() {
wx.navigateTo({
@@ -860,7 +864,10 @@ Page({
const gridCols = 1;
const monthTitle = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}`;
const stripCentered = true;
this.setData({ dateStripItems: strip, imagesByDate, selectedDateKey: selectedKey, selectedDateImages: selectedImages, gridCols, selectedMonthTitle: monthTitle, useWaterfall: false, waterfallLeft: [], waterfallRight: [], stripCentered, todayKey: selectedKey });
this.setData({ dateStripItems: strip, imagesByDate, selectedDateKey: selectedKey, selectedDateImages: selectedImages, gridCols, selectedMonthTitle: monthTitle, useWaterfall: false, waterfallLeft: [], waterfallRight: [], stripCentered, todayKey: selectedKey }, () => {
this.computeDateItemDimensions();
this.centerDateStrip();
});
return;
}
records.sort((a, b) => a.date.getTime() - b.date.getTime());
@@ -903,6 +910,7 @@ Page({
}
const stripCentered = strip.length <= 3;
this.setData({ dateStripItems: strip, imagesByDate, selectedDateKey: selectedKey, selectedDateImages: selectedImages, gridCols, selectedMonthTitle: monthTitle, useWaterfall, waterfallLeft, waterfallRight, stripCentered, todayKey, animateWaterfall: false }, () => {
this.computeDateItemDimensions();
this.centerDateStrip();
});
if (useWaterfall) {
@@ -964,15 +972,18 @@ Page({
if (cached) {
img.thumbnail_url = typeof cached === 'string' ? cached.trim() : cached;
img.thumbnail_loading = false;
img.thumb_loaded = false;
return;
}
try {
img.thumbnail_url = (await apiManager.getFileDisplayUrl(img.thumbnail_file_id)).trim();
this.cacheThumbnail(img.thumbnail_file_id, img.thumbnail_url);
img.thumbnail_loading = false;
img.thumb_loaded = false;
} catch {
img.thumbnail_url = '/static/sun-2.png';
img.thumbnail_loading = false;
img.thumb_loaded = false;
}
});
}
@@ -987,6 +998,95 @@ Page({
} catch (e) {}
},
onThumbLoaded(e: any) {
try {
const imageId = e?.currentTarget?.dataset?.imageId;
if (!imageId) return;
const list = (this.data.selectedDateImages || []).map((img: any) => {
if (img && img.image_id === imageId) {
return { ...img, thumb_loaded: true };
}
return img;
});
this.setData({ selectedDateImages: list }, () => {
if (this.data.useWaterfall) {
this.balanceWaterfall(this.data.selectedDateImages || []);
}
});
} catch (err) {}
},
onWfThumbLoaded(e: any) {
try {
const imageId = e?.currentTarget?.dataset?.imageId;
if (!imageId) return;
const list = (this.data.selectedDateImages || []).map((img: any) => {
if (img && img.image_id === imageId) {
return { ...img, thumb_loaded: true };
}
return img;
});
this.setData({ selectedDateImages: list }, () => {
if (this.data.useWaterfall) {
this.balanceWaterfall(this.data.selectedDateImages || []);
}
});
} catch (err) {}
},
onImageMoreTap(e: any) {
try {
const imageId = e?.currentTarget?.dataset?.imageId;
if (!imageId) return;
const list = (this.data.selectedDateImages || []).map((img: any) => {
if (img && img.image_id === imageId) {
const open = !!img.more_open;
if (open) {
return { ...img, menu_closing: true, more_open: false };
}
return { ...img, more_open: true, menu_closing: false };
}
return { ...img, more_open: false, menu_closing: false };
});
this.setData({ selectedDateImages: list }, () => {
if (this.data.useWaterfall) {
this.balanceWaterfall(this.data.selectedDateImages || []);
}
});
// 如果是关闭,延迟移除 closing 状态以便播放淡出动画
setTimeout(() => {
const after = (this.data.selectedDateImages || []).map((img: any) => {
if (img && img.image_id === imageId) {
return { ...img, menu_closing: false };
}
return img;
});
this.setData({ selectedDateImages: after }, () => {
if (this.data.useWaterfall) {
this.balanceWaterfall(this.data.selectedDateImages || []);
}
});
}, 260);
} catch (err) {}
},
onImageSceneSentenceTap(e: any) {
try {
const imageId = e?.currentTarget?.dataset?.imageId;
if (!imageId) return;
// 关闭所有更多菜单
const list = (this.data.selectedDateImages || []).map((img: any) => ({ ...img, more_open: false, menu_closing: false }));
this.setData({ selectedDateImages: list }, () => {
if (this.data.useWaterfall) {
this.balanceWaterfall(this.data.selectedDateImages || []);
}
});
wx.navigateTo({
url: `/pages/scene_sentence/scene_sentence?image_id=${encodeURIComponent(imageId)}`
});
} catch (err) {}
},
async ensureThumbSize(img: any): Promise<{ w: number; h: number } | null> {
if (img && typeof img._thumbW === 'number' && typeof img._thumbH === 'number') {
return { w: img._thumbW, h: img._thumbH };
@@ -1009,7 +1109,7 @@ Page({
},
async computeWaterfallColumns(images: any[]): Promise<{ left: any[]; right: any[] }> {
const windowInfo = wx.getWindowInfo();
const windowInfo = (wx as any).getWindowInfo();
const ww = windowInfo.windowWidth;
const rpx = ww / 750;
const padPx = Math.floor(24 * rpx);
@@ -1046,29 +1146,65 @@ Page({
} catch (e) {}
},
computeDateItemDimensions() {
try {
const items = this.data.dateStripItems || [];
if (!items || items.length === 0) return;
const windowInfo = (wx as any).getWindowInfo();
const ww = windowInfo.windowWidth;
const rpx = ww / 750;
const containerW = ww - Math.floor(40 * rpx);
const itemCount = items.length;
const minWidthRpx = 96;
const maxWidthRpx = 160;
const minGapRpx = 12;
const maxGapRpx = 24;
const tryCompute = (visible: number) => {
let gapRpx = maxGapRpx;
let gapPx = Math.floor(gapRpx * rpx);
let widthPx = Math.floor((containerW - (visible - 1) * gapPx) / visible);
let widthRpx = Math.floor(widthPx / rpx);
if (widthRpx < minWidthRpx) {
gapRpx = minGapRpx;
gapPx = Math.floor(gapRpx * rpx);
widthPx = Math.floor((containerW - (visible - 1) * gapPx) / visible);
widthRpx = Math.floor(widthPx / rpx);
}
return { widthRpx, gapRpx };
};
let visible = itemCount >= 6 ? 6 : Math.min(5, itemCount);
let { widthRpx, gapRpx } = tryCompute(visible);
if (widthRpx < minWidthRpx && visible > 5) {
visible = 5;
({ widthRpx, gapRpx } = tryCompute(visible));
}
widthRpx = Math.max(minWidthRpx, Math.min(widthRpx, maxWidthRpx));
this.setData({ dateItemWidthRpx: widthRpx, dateItemGapRpx: gapRpx });
} catch (e) {}
},
centerDateStrip() {
try {
const items = this.data.dateStripItems || [];
if (!items || items.length < 2) return;
const idx = items.findIndex((it: any) => it && it.key === this.data.selectedDateKey);
if (idx < 0) return;
const windowInfo = wx.getWindowInfo();
const windowInfo = (wx as any).getWindowInfo();
const ww = windowInfo.windowWidth;
const rpx = ww / 750;
const itemW = Math.floor(120 * rpx);
const gap = Math.floor(24 * rpx);
const widthRpx = (this.data as any).dateItemWidthRpx || 120;
const gapRpx = (this.data as any).dateItemGapRpx || 24;
const itemW = Math.floor(widthRpx * rpx);
const gap = Math.floor(gapRpx * rpx);
const n = items.length;
const contentW = n * itemW + (n - 1) * gap;
const containerW = ww;
const containerW = ww - Math.floor(40 * rpx);
if (contentW > containerW) {
let target = Math.floor(idx * (itemW + gap) - (containerW - itemW) / 2);
target = Math.max(0, Math.min(contentW - containerW, target));
this.setData({ dateStripScrollLeft: target, dateStripTransform: '' });
this.setData({ dateStripScrollLeft: target, dateStripTransform: '', stripCentered: false });
} else {
const selectedX = Math.floor(idx * (itemW + gap));
const centerX = Math.floor((containerW - itemW) / 2);
const shift = centerX - selectedX;
this.setData({ dateStripTransform: `transform: translateX(${shift}px);` });
this.setData({ dateStripScrollLeft: 0, dateStripTransform: '', stripCentered: true });
}
} catch (e) {}
},

View File

@@ -64,13 +64,13 @@
<!-- <t-icon name="calendar" size="40rpx" /> -->
</view>
<scroll-view class="date-strip" scroll-x scroll-with-animation="{{true}}" scroll-left="{{dateStripScrollLeft}}">
<view class="date-strip-inner {{stripCentered ? 'strip-center' : 'strip-default'}}" style="{{dateStripTransform}}">
<view class="date-strip-inner {{stripCentered ? 'strip-center' : 'strip-default'}}" style="gap: {{dateItemGapRpx}}rpx; {{dateStripTransform}}">
<block wx:for="{{dateStripItems}}" wx:key="index">
<view wx:if="{{item.type === 'day'}}" id="date-item-{{item.key}}" class="date-item {{item.key === selectedDateKey ? 'active' : ''}}" data-key="{{item.key}}" bindtap="onDateTap">
<view wx:if="{{item.type === 'day'}}" id="date-item-{{item.key}}" class="date-item {{item.key === selectedDateKey ? 'active' : ''}}" data-key="{{item.key}}" bindtap="onDateTap" style="width: {{dateItemWidthRpx}}rpx; flex: 0 0 {{dateItemWidthRpx}}rpx;">
<view class="weekday">{{item.weekday}}</view>
<view class="day">{{item.day}}</view>
</view>
<view wx:elif="{{item.type === 'ym'}}" id="date-item-{{item.key}}" class="date-item label">
<view wx:elif="{{item.type === 'ym'}}" id="date-item-{{item.key}}" class="date-item label" style="width: {{dateItemWidthRpx}}rpx; flex: 0 0 {{dateItemWidthRpx}}rpx;">
<view class="weekday">{{item.year}}</view>
<view class="day">{{item.month}}</view>
</view>
@@ -85,7 +85,19 @@
<view class="image-list">
<view class="list-item {{animateWaterfall ? 'fade-in' : ''}}" wx:for="{{selectedDateImages}}" wx:for-item="image" wx:for-index="k" wx:key="image_id" catch:tap="onImageTap" data-image-id="{{image.image_id}}" style="animation-delay: {{k * 0.15}}s;">
<t-skeleton wx:if="{{image.thumbnail_loading}}" class="full-image" row-col="{{[1]}}" animation="gradient" loading></t-skeleton>
<image wx:else class="full-image" src="{{image.thumbnail_url}}" mode="widthFix"/>
<image wx:else class="full-image" src="{{image.thumbnail_url}}" mode="widthFix" bindload="onThumbLoaded" data-image-id="{{image.image_id}}"/>
<view wx:if="{{image.thumb_loaded}}" class="image-more-wrap" catchtap="noop">
<view class="mini-button" bindtap="onImageMoreTap" data-image-id="{{image.image_id}}">
<view class="ul-mini {{image.more_open ? 'active' : ''}}">
<view class="dot1"></view>
<view class="dot2"></view>
<view class="dot3"></view>
</view>
</view>
<view wx:if="{{image.more_open || image.menu_closing}}" class="image-more-menu {{image.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{image.image_id}}">场景句型</view>
</view>
</view>
</view>
</view>
</block>
@@ -94,13 +106,37 @@
<view class="wf-col">
<view class="wf-item {{animateWaterfall ? 'fade-in' : ''}}" wx:for="{{waterfallLeft}}" wx:for-index="i" wx:key="image_id" catch:tap="onImageTap" data-image-id="{{item.image_id}}" style="animation-delay: {{i * 0.15}}s;">
<t-skeleton wx:if="{{item.thumbnail_loading}}" class="wf-image" row-col="{{[1]}}" animation="gradient" loading></t-skeleton>
<image wx:else class="wf-image" src="{{item.thumbnail_url}}" mode="widthFix" />
<image wx:else class="wf-image" src="{{item.thumbnail_url}}" mode="widthFix" bindload="onWfThumbLoaded" data-image-id="{{item.image_id}}"/>
<view wx:if="{{item.thumb_loaded}}" class="image-more-wrap" catchtap="noop">
<view class="mini-button" bindtap="onImageMoreTap" data-image-id="{{item.image_id}}">
<view class="ul-mini {{item.more_open ? 'active' : ''}}">
<view class="dot1"></view>
<view class="dot2"></view>
<view class="dot3"></view>
</view>
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
</view>
</view>
</view>
</view>
<view class="wf-col">
<view class="wf-item {{animateWaterfall ? 'fade-in' : ''}}" wx:for="{{waterfallRight}}" wx:for-index="j" wx:key="image_id" catch:tap="onImageTap" data-image-id="{{item.image_id}}" style="animation-delay: {{j * 0.15 + 0.15}}s;">
<t-skeleton wx:if="{{item.thumbnail_loading}}" class="wf-image" row-col="{{[1]}}" animation="gradient" loading></t-skeleton>
<image wx:else class="wf-image" src="{{item.thumbnail_url}}" mode="widthFix"/>
<image wx:else class="wf-image" src="{{item.thumbnail_url}}" mode="widthFix" bindload="onWfThumbLoaded" data-image-id="{{item.image_id}}"/>
<view wx:if="{{item.thumb_loaded}}" class="image-more-wrap" catchtap="noop">
<view class="mini-button" bindtap="onImageMoreTap" data-image-id="{{item.image_id}}">
<view class="ul-mini {{item.more_open ? 'active' : ''}}">
<view class="dot1"></view>
<view class="dot2"></view>
<view class="dot3"></view>
</view>
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
</view>
</view>
</view>
</view>
</view>

View File

@@ -113,13 +113,109 @@
/* 新:单列全幅图片列表 */
.image-list { padding: 24rpx; box-sizing: border-box; }
.list-item { margin-bottom: 24rpx; border-radius: 24rpx; overflow: hidden; background: #fff; }
.list-item { margin-bottom: 24rpx; border-radius: 24rpx; overflow: hidden; background: #fff; position: relative; }
.full-image { width: 100%; height: auto; display: block; --td-skeleton-text-height: 550rpx;}
/* 新:两列瀑布布局 */
.waterfall { display: flex; gap: 12rpx; padding: 12rpx; box-sizing: border-box; }
/* 图片右下角更多按钮 */
.image-more-wrap {
position: absolute;
right: 16rpx;
bottom: 16rpx;
z-index: 2;
}
.mini-button {
padding: 6rpx;
border-radius: 50%;
background: rgba(255,255,255,0.95);
box-shadow: 0 4rpx 10rpx rgba(0,0,0,0.1);
}
.ul-mini {
position: relative;
width: 40rpx;
height: 40rpx;
background: transparent;
}
.ul-mini .dot1,
.ul-mini .dot2,
.ul-mini .dot3 {
display: inline-block;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10rpx;
height: 10rpx;
background: #666;
border-radius: 50%;
border: none;
transition: 0.6s;
}
.ul-mini .dot1 { left: 10%; z-index: 2; }
.ul-mini .dot2 { left: 50%; transition: .6s; }
.ul-mini .dot3 { left: 90%; z-index: 2; }
.ul-mini.active .dot1,
.ul-mini.active .dot3 {
top: 50%;
left: 50%;
border-radius: 0;
width: 6rpx;
height: 38rpx;
border-radius: 6rpx;
transition-delay: 0.2s;
}
.ul-mini.active .dot1 {
transform: translate(-50%, -50%) rotate(405deg);
}
.ul-mini.active .dot3 {
transform: translate(-50%, -50%) rotate(-405deg);
}
.ul-mini.active .dot2 {
top: 50%;
left: 50%;
width: 56rpx;
height: 56rpx;
background: #f5f5f5;
border: 2rpx solid #e0e0e0;
}
.image-more-menu {
position: absolute;
right: 0rpx;
bottom: 70rpx;
background: #fff;
color: #666;
border-radius: 12rpx;
padding: 8rpx 12rpx;
box-shadow: 0 4rpx 8rpx rgba(0,0,0,0.08);
display: inline-flex;
align-items: center;
gap: 12rpx;
opacity: 1;
transform: translateY(0) scale(1);
}
.image-more-menu.opening {
animation: menuFadeIn 240ms ease forwards;
}
.image-more-menu.closing {
animation: menuFadeOut 240ms ease forwards;
}
.more-item-mini {
font-size: 26rpx;
white-space: nowrap;
line-height: 32rpx;
}
@keyframes menuFadeIn {
0% { opacity: 0; transform: translateY(8rpx) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes menuFadeOut {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(8rpx) scale(0.98); }
}
.wf-col { width: calc(50% - 6rpx); }
.wf-item { margin-bottom: 12rpx; border-radius: 24rpx; overflow: hidden; background: #fff; }
.wf-item { margin-bottom: 12rpx; border-radius: 24rpx; overflow: hidden; background: #fff; position: relative; }
.wf-image { width: 100%; height: auto; display: block; --td-skeleton-text-height: 330rpx; }
/* 瀑布流淡入动画 */
@@ -371,15 +467,16 @@
.month-title { font-size: 36rpx; }
.date-strip { white-space: nowrap; height: 100rpx;}
.date-strip-inner { display: flex; gap: 24rpx; transition: transform 0.3s ease; will-change: transform; }
.strip-center { justify-content: center; min-width: 100%; }
.strip-center { justify-content: center; }
.strip-default { justify-content: flex-start; }
.date-strip::-webkit-scrollbar { display: none; }
.date-item {
display: inline-flex;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120rpx;
flex: 0 0 100rpx;
width: 100rpx;
height: 100rpx;
border-radius: 24rpx;
background: #ffffff;

View File

@@ -131,9 +131,9 @@ initPersistentCacheAsync()
// 词典等级映射
const DICT_LEVEL_OPTIONS = {
LEVEL1: "初级",
LEVEL2: "中级",
LEVEL3: "高级"
LEVEL1: "level1",
LEVEL2: "level2",
LEVEL3: "level3"
}
class ApiManager {
@@ -479,7 +479,7 @@ class ApiManager {
async smartLogin(forceRefresh: boolean = false): Promise<ILoginResponse | null> {
if (USE_CLOUD) {
const app = getApp<IAppOption>()
let dictLevel = wx.getStorageSync('dictLevel') || 'LEVEL1'
let dictLevel = wx.getStorageSync('dictLevel') || 'level1'
try {
const resp = await this.request<{ user: IUserInfo; dict_level?: string; session_uuid?: string; settings?: { dict_level?: string }; points?: { balance: number; expired_time: string } }>(
'/api/v1/wx/user',
@@ -492,7 +492,7 @@ class ApiManager {
app.globalData.userInfo = data.user
wx.setStorageSync('userInfo', data.user)
}
const dl = data.dict_level || 'LEVEL1'
const dl = data.dict_level || 'level1'
if (dl) {
dictLevel = dl
wx.setStorageSync('dictLevel', dictLevel)
@@ -521,12 +521,12 @@ class ApiManager {
if (!isExpired) {
app.globalData.isLoggedIn = true
app.globalData.token = authInfo.token
app.globalData.dictLevel = authInfo.dictLevel || 'LEVEL1'
app.globalData.dictLevel = authInfo.dictLevel || 'level1'
return {
access_token: authInfo.token,
access_token_expire_time: new Date(authInfo.tokenExpiry).toISOString(),
session_uuid: authInfo.sessionUuid || '',
dict_level: authInfo.dictLevel || 'LEVEL1'
dict_level: authInfo.dictLevel || 'level1'
}
} else {
this.clearAuthData()
@@ -936,7 +936,7 @@ class ApiManager {
// 获取当前的词典等级配置
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'LEVEL1'
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
console.log('开始图片识别请求:', { fileId, type, dictLevel })
const response = await this.request<{task_id: string, status: string, message: string}>('/api/v1/image/recognize/async', 'POST', {
@@ -1862,7 +1862,7 @@ class ApiManager {
try {
console.log('开始获取图片文本和评分信息', { imageId });
const app = getApp<IAppOption>()
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'LEVEL1'
const dictLevel = app.globalData.dictLevel || wx.getStorageSync('dictLevel') || 'level1'
const response = await this.request<{
image_file_id: string,
assessments: Array<{
@@ -2024,6 +2024,29 @@ class ApiManager {
return response.data
}
async getSceneSentence(imageId: string): Promise<any> {
const resp = await this.request<any>(`/api/v1/scene/image/${imageId}/sentence`, 'GET')
return resp.data
}
async getRecordResult(textId: string | number): Promise<any> {
const resp = await this.request<any>(`/api/v1/image_text/${textId}/record_result`, 'GET')
return resp.data
}
async createScene(imageId: string, scene_type: string = 'scene_sentence'): Promise<{ task_id: string; status?: string }> {
const resp = await this.request<{ task_id: string; status?: string }>(`/api/v1/scene/create`, 'POST', {
image_id: imageId,
scene_type
})
return resp.data
}
async getSceneTask(taskId: string | number): Promise<{ status: string; message?: string }> {
const resp = await this.request<{ status: string; message?: string }>(`/api/v1/scene/task/${taskId}`, 'GET')
return resp.data
}
}
// 导出单例