This commit is contained in:
Felix
2025-12-21 16:17:36 +08:00
parent 0b0582572d
commit c006a2c4c7
13 changed files with 516 additions and 161 deletions

View File

@@ -16,15 +16,16 @@ Component({
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' }
{ tag: 1, description: '新增', color: '#ffebee' },
{ tag: 2, description: '缺少', color: '#e3f2fd' },
{ tag: 3, description: '错读', color: '#fff3e0' },
{ tag: 4, description: '未录入', color: '#f5f5f5' }
]
},
playIconName: { type: String, value: 'sound-low' }
},
methods: {
noop() {},
onClose() {
const self = this as any
self.triggerEvent('close')

View File

@@ -1,10 +1,10 @@
<view wx:if="{{hasScoreInfo}}">
<view class="score-modal-content {{visible ? 'show' : ''}}">
<view class="score-modal-content {{visible ? 'show' : ''}}" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop" catchtap="noop">
<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">
<scroll-view class="score-container" scroll-y="true">
<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>
@@ -47,7 +47,7 @@
</view>
</view>
</view>
<scroll-view scroll-y class="word-scores-list">
<view 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>
@@ -69,7 +69,7 @@
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</scroll-view>
</view>
</view>

View File

@@ -19,7 +19,7 @@
}
.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-container { height: 100vh; }
.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; }
@@ -49,4 +49,3 @@
.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

@@ -122,9 +122,12 @@ Component({
const self = this as any
self.triggerEvent('back')
},
onWordTap() {
onWordTap(e: any) {
const self = this as any
const word = (self.data && self.data.prototypeWord) ? self.data.prototypeWord : ''
const dsWord = e && e.currentTarget && e.currentTarget.dataset ? e.currentTarget.dataset.word : ''
const propWord = (self.data && self.data.prototypeWord) ? self.data.prototypeWord : ''
const word = dsWord || propWord || ''
if (!word) return
self.triggerEvent('wordTap', { word })
}
}

View File

@@ -37,6 +37,7 @@
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 {{analizing ? 'loading-shimmer' : ''}}" style="animation-delay: {{index * 200}}ms;">{{item.text}}</view>
<view class="overlay-text {{analizing ? 'loading-shimmer' : ''}}" style="animation-delay: {{index * 200}}ms; {{index === 0 ? 'text-transform: capitalize;' : ''}}">{{item.text}}</view>
</view>
</view>
<view wx:if="{{isRecording}}" class="recording-mask" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop"></view>

View File

@@ -1,32 +1,25 @@
<!--coupon.wxml-->
<view class="coupon-container">
<!-- <view class="points_box">
<view class="points-title">
<t-icon class="points-label" name="filter-3" size="28rpx"></t-icon>
当前积分
</view>
<view class="points-row">
<view class="points-value">{{displayUserPoints}}</view>
</view>
<view class="vip-pill" wx:if="{{vipLevel > 0}}">
<text class="vip-star">★</text>
<text class="vip-text">VIP Member Level {{vipLevel}}</text>
</view>
<view class="vip-pill" wx:else>
<text class="vip-star">★</text>
<text class="vip-text">Member</text>
</view>
</view> -->
<view class="coupon_title">
获取更多积分
</view>
<view class='coupon_box {{item.one_time ? "one_time" : ""}}' wx:for="{{products}}" wx:key="id" wx:for-item="item">
<!-- <view class='coupon_box {{item.one_time ? "one_time" : ""}}' wx:for="{{products}}" wx:key="id" wx:for-item="item">
<view class='content' bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class='title'>{{item.title}}</view>
<view class='how_much'>{{item.points}}</view>
</view>
<view class='btn'> ¥{{item.amountYuan}}</view>
</view> -->
<view class="card-box">
<view class="card" wx:for="{{products}}" wx:key="id" wx:for-item="item" bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class="card-title">{{item.title}}</view>
<view class="card-points">{{item.points}}</view>
<view class="card-credits">{{item.description ? item.description : ' '}}</view>
<view class="card-price">¥{{item.amountYuan}}</view>
</view>
</view>
<view class="tips_box">
<view class="tips-header">
<view class="tips-icon"><t-icon name="info-circle" size="32rpx"></t-icon></view>

View File

@@ -55,95 +55,65 @@
line-height: 38rpx;
}
.points_box {
width: 100%;
margin: 40rpx 2%;
padding: 36rpx 24rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #6a5af9 0%, #a855f7 60%, #9333ea 100%);
box-shadow: 0 12rpx 30rpx rgba(123, 67, 255, 0.35);
position: relative;
color: #ffffff;
}
.points_box::before,
.points_box::after {
content: '';
position: absolute;
width: 240rpx;
height: 240rpx;
border-radius: 50%;
filter: blur(12rpx);
pointer-events: none;
}
.points_box::before {
bottom: -80rpx;
left: -80rpx;
background: radial-gradient(closest-side, rgba(255,255,255,0.20), rgba(255,255,255,0));
}
.points_box::after {
top: -80rpx;
right: -80rpx;
background: radial-gradient(closest-side, rgba(255,255,255,0.16), rgba(255,255,255,0));
}
.points-title {
font-size: 26rpx;
opacity: 0.92;
display: flex;
align-items: center;
gap: 10rpx;
}
.points-title .points-label {
display: inline-flex;
align-items: center;
}
.points-row {
display: flex;
align-items: baseline;
gap: 16rpx;
margin-top: 24rpx;
}
.points-value {
font-size: 96rpx;
line-height: 96rpx;
font-weight: 800;
text-shadow: 0 8rpx 22rpx rgba(0,0,0,0.18);
}
.points-label {
font-size: 28rpx;
opacity: 0.9;
}
.vip-pill {
margin-top: 18rpx;
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: rgba(255,255,255,0.18);
box-shadow: inset 0 0 0 2rpx rgba(255,255,255,0.15);
}
.vip-star {
font-size: 24rpx;
}
.vip-text {
font-size: 26rpx;
}
.coupon_title {
width: 100%;
padding: 0 2%;
padding: 24rpx 2% 0 24rpx;
font-weight: 700;
}
.card-box {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 24rpx;
padding: 24rpx 0 8rpx 0;
}
.card {
width: 26%;
background: #ffffff;
border-radius: 24rpx;
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
align-items: center;
padding: 28rpx 2% 24rpx 2%;
}
.card-title {
font-size: 26rpx;
font-weight: 600;
color: #4b5565;
margin-bottom: 12rpx;
text-align: center;
}
.card-points {
font-size: 48rpx;
font-weight: 800;
color: #111827;
line-height: 60rpx;
}
.card-credits {
margin-top: 4rpx;
font-size: 22rpx;
letter-spacing: 2rpx;
color: #6b7280;
min-height: 32rpx;
}
.card-price {
margin-top: 18rpx;
background: #eef2f7;
color: #111827;
font-size: 26rpx;
font-weight: 700;
padding: 12rpx 24rpx;
border-radius: 9999rpx;
}
.coupon_box{
background: linear-gradient(to right, #5da0f9, #3071ee);
width: 46%;

View File

@@ -1,5 +1,5 @@
{
"navigationBarTitleText": "场景句",
"navigationBarTitleText": "场景句",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",

View File

@@ -42,6 +42,10 @@ interface IData {
accuracyScore: number
completenessScore: number
fluencyScore: number
displayTotalScore: number
displayAccuracyScore: number
displayCompletenessScore: number
displayFluencyScore: number
circleProgressStyle: string
accuracyCircleStyle: string
completenessCircleStyle: string
@@ -56,6 +60,17 @@ interface IData {
dictDefaultTabValue?: string
standardAudioMap?: Record<string, string>
standardAudioLocalMap?: Record<string, string>
assessmentAudioLocalMap?: Record<string, string>
assessmentCache?: Record<string, {
hasScoreInfo: boolean
totalScore: number
accuracyScore: number
completenessScore: number
fluencyScore: number
wordScores: Array<any>
file_id?: string
content?: string
}>
wordAudioPlaying?: boolean
wordAudioIconName?: string
activeWordAudioType?: string
@@ -73,6 +88,8 @@ interface IData {
analizing?: boolean
cachedHighlightWords?: Array<any>
cachedSentenceIndex?: number
loadingDots?: string
loadingLabel?: string
}
interface IPageInstance {
@@ -80,7 +97,9 @@ interface IPageInstance {
audioCtx?: any
wordAudioContext?: WechatMiniprogram.InnerAudioContext
wordAudioIconTimer?: number
circleAnimTimer?: number
recordTimer?: number
loadingDotsTimer?: number
fetchSceneSentence: (imageId: string) => Promise<void>
startPolling: (taskId: string, imageId: string) => void
onTransTap: () => void
@@ -97,12 +116,16 @@ interface IPageInstance {
handleDictMore: () => void
processCollinsData: (collinsData: any) => any
getStandardVoice: (sentenceId: string) => Promise<void>
updateCircleProgress: () => void
playAssessmentVoice: () => void
noop: () => void
startRecording: () => void
stopRecording: () => void
onMicHighlight: () => void
computeHighlightLayout: () => void
fetchRecordResultForSentence: (textId: string) => Promise<void>
startLoadingDots: () => void
stopLoadingDots: () => void
}
Page<IData, IPageInstance>({
@@ -124,6 +147,10 @@ Page<IData, IPageInstance>({
accuracyScore: 0,
completenessScore: 0,
fluencyScore: 0,
displayTotalScore: 0,
displayAccuracyScore: 0,
displayCompletenessScore: 0,
displayFluencyScore: 0,
circleProgressStyle: '',
accuracyCircleStyle: '',
completenessCircleStyle: '',
@@ -138,6 +165,8 @@ Page<IData, IPageInstance>({
dictDefaultTabValue: '0',
standardAudioMap: {},
standardAudioLocalMap: {},
assessmentAudioLocalMap: {},
assessmentCache: {},
wordAudioPlaying: false,
wordAudioIconName: 'sound',
activeWordAudioType: '',
@@ -154,7 +183,9 @@ Page<IData, IPageInstance>({
highlightZoom: false,
analizing: false,
cachedHighlightWords: [],
cachedSentenceIndex: -1
cachedSentenceIndex: -1,
loadingDots: '',
loadingLabel: '场景分析中'
},
noop() {},
@@ -166,6 +197,7 @@ Page<IData, IPageInstance>({
return
}
this.setData({ loadingMaskVisible: true, statusText: '加载中...', currentIndex: 0, transDisplayMode: 'en_zh', contentVisible: false, isPlaying: false, isRecording: false, hasScoreInfo: false, isScoreModalOpen: false, scoreModalVisible: false })
this.startLoadingDots()
if (!this.audioCtx) {
this.audioCtx = wx.createInnerAudioContext()
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
@@ -215,16 +247,37 @@ Page<IData, IPageInstance>({
completenessScore: pronCompletion >= 0 ? Number(pronCompletion.toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number(pronFluency.toFixed(2)) : 0,
wordScores,
currentSentence: {
content: String(this.data.currentSentence?.content || ''),
id: this.data.currentSentence?.id,
file_id: fileId
},
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
highlightWords: [],
analizing: false
})
this.onScoreTap()
}).catch(() => {
this.setData({
analizing: false
})
const cache = this.data.assessmentCache || {}
this.setData({
assessmentCache: {
...cache,
[imageTextId]: {
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,
file_id: fileId,
content: String(this.data.currentSentence?.content || '')
}
}
})
this.onScoreTap()
}).catch(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
@@ -310,20 +363,21 @@ Page<IData, IPageInstance>({
'These mint leaves look crisp and fresh.(强调口感)'
]
const responsePairs = (cur as any)?.responsePairs || []
this.setData({
scene,
loadingMaskVisible: false,
statusText: '已获取数据',
contentVisible: true,
this.setData({
scene,
loadingMaskVisible: false,
statusText: '已获取数据',
contentVisible: true,
currentIndex: idx,
currentSentence,
englishWords,
buttonsVisible: true,
commonMistakes,
pragmaticAlternative,
responsePairs,
hasScoreInfo: false
})
responsePairs,
hasScoreInfo: false
})
this.stopLoadingDots()
const sid = cur && cur.imageTextId
if (sid) {
try { this.fetchRecordResultForSentence(String(sid)) } catch (e) {}
@@ -331,12 +385,34 @@ Page<IData, IPageInstance>({
} else {
const { task_id } = await apiManager.createScene(imageId, 'scene_sentence')
this.setData({ taskId: task_id, statusText: '解析中...' })
this.startLoadingDots()
this.startPolling(task_id, imageId)
}
} catch (e) {
logger.error('获取场景句子失败:', e)
wx.showToast({ title: '加载失败', icon: 'none' })
const msg = (e as any)?.message || ''
if (typeof msg === 'string' && msg.indexOf('积分') !== -1) {
wx.showModal({
title: '积分不足',
content: '您的积分不足,是否前往购买?',
confirmText: '获取',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.redirectTo({ url: '/pages/coupon/coupon' })
} else {
wx.navigateBack({ delta: 1 })
}
}
})
} else {
wx.showToast({
title: msg || '上传失败',
icon: 'none'
})
}
this.setData({ loadingMaskVisible: false })
this.stopLoadingDots()
}
},
@@ -346,6 +422,7 @@ Page<IData, IPageInstance>({
this.pollTimer = undefined
}
this.setData({ loadingMaskVisible: true })
this.startLoadingDots()
this.pollTimer = setInterval(async () => {
try {
const res = await apiManager.getSceneTask(taskId)
@@ -358,12 +435,29 @@ Page<IData, IPageInstance>({
this.pollTimer = undefined
wx.showToast({ title: '任务失败', icon: 'none' })
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
this.stopLoadingDots()
}
} catch (err) {
logger.error('轮询任务状态失败:', err)
}
}, 3000) as any
},
startLoadingDots() {
if (this.loadingDotsTimer) { clearInterval(this.loadingDotsTimer) }
let n = 0
this.loadingDotsTimer = setInterval(() => {
n = (n % 3) + 1
const dots = '.'.repeat(n)
this.setData({ loadingDots: dots })
}, 500) as any
},
stopLoadingDots() {
if (this.loadingDotsTimer) {
clearInterval(this.loadingDotsTimer)
this.loadingDotsTimer = undefined
}
this.setData({ loadingDots: '' })
},
onTransTap() {
const mode = this.data.transDisplayMode === 'en' ? 'en_zh' : 'en'
@@ -411,14 +505,53 @@ Page<IData, IPageInstance>({
})
const sid = cur && cur.imageTextId
if (sid) {
wx.nextTick(() => { this.getStandardVoice(String(sid)) })
wx.nextTick(() => { this.fetchRecordResultForSentence(String(sid)) })
const sidStr = String(sid)
const cachedAssess = (this.data.assessmentCache || {})[sidStr]
if (cachedAssess && cachedAssess.hasScoreInfo) {
this.setData({
hasScoreInfo: true,
totalScore: cachedAssess.totalScore,
accuracyScore: cachedAssess.accuracyScore,
completenessScore: cachedAssess.completenessScore,
fluencyScore: cachedAssess.fluencyScore,
wordScores: cachedAssess.wordScores || [],
currentSentence: {
content: String(currentSentence?.content || cachedAssess.content || ''),
id: sidStr,
file_id: cachedAssess.file_id || currentSentence?.file_id
}
})
} else {
wx.nextTick(() => { this.fetchRecordResultForSentence(sidStr) })
}
const hasStd = !!((this.data.standardAudioMap || {})[sidStr])
if (!hasStd) {
wx.nextTick(() => { this.getStandardVoice(sidStr) })
}
}
}, 200)
},
async fetchRecordResultForSentence(textId: string) {
try {
const cached = (this.data.assessmentCache || {})[textId]
if (cached && cached.hasScoreInfo) {
const curSentence = this.data.currentSentence || { content: '' }
this.setData({
hasScoreInfo: true,
totalScore: cached.totalScore,
accuracyScore: cached.accuracyScore,
completenessScore: cached.completenessScore,
fluencyScore: cached.fluencyScore,
wordScores: cached.wordScores || [],
currentSentence: {
content: String(curSentence.content || cached.content || ''),
id: curSentence.id || textId,
file_id: cached.file_id || curSentence.file_id
}
})
return
}
const rec: any = await apiManager.getRecordResult(textId)
const hasDetails = !!(rec && rec.details && Object.keys(rec.details || {}).length > 0)
if (!hasDetails) {
@@ -452,6 +585,22 @@ Page<IData, IPageInstance>({
wordScores,
currentSentence: { ...curSentence, file_id: rec?.file_id || curSentence.file_id }
})
const cache = this.data.assessmentCache || {}
this.setData({
assessmentCache: {
...cache,
[textId]: {
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,
file_id: rec?.file_id || curSentence.file_id,
content: String(curSentence.content || '')
}
}
})
} catch (e) {
this.setData({ hasScoreInfo: false })
}
@@ -523,6 +672,56 @@ Page<IData, IPageInstance>({
})
}
},
playAssessmentVoice() {
if (this.data.isRecording) return
const fileId = this.data.currentSentence?.file_id
if (!fileId) {
wx.showToast({ title: '暂无评分音频', icon: 'none' })
return
}
const cachedLocal = (this.data.assessmentAudioLocalMap || {})[fileId]
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.onStop(() => { 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(fileId).then((filePath) => {
const map = this.data.assessmentAudioLocalMap || {}
this.setData({ assessmentAudioLocalMap: { ...map, [fileId]: filePath } })
playWithPath(filePath)
}).catch((error) => {
logger.error('下载音频失败:', error)
wx.showToast({ title: '音频下载失败', icon: 'none' })
})
}
},
handleRecordStart() {
if (this.data.isRecording) return
@@ -536,12 +735,39 @@ Page<IData, IPageInstance>({
onScoreTap() {
if (!this.data.hasScoreInfo) return
this.setData({ isScoreModalOpen: true })
setTimeout(() => this.setData({ scoreModalVisible: true }), 50)
this.setData({
isScoreModalOpen: true,
scoreModalVisible: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
analizing: false
})
setTimeout(() => {
this.setData({ scoreModalVisible: true })
setTimeout(() => {
try { this.updateCircleProgress() } catch (e) {}
}, 100)
}, 0)
},
onCloseScoreModal() {
this.setData({ isScoreModalOpen: false, scoreModalVisible: false })
if (this.circleAnimTimer) {
clearInterval(this.circleAnimTimer)
this.circleAnimTimer = undefined
}
this.setData({ scoreModalVisible: false })
this.setData({
circleProgressStyle: 'background: conic-gradient(transparent 0)',
accuracyCircleStyle: 'background: conic-gradient(transparent 0)',
completenessCircleStyle: 'background: conic-gradient(transparent 0)',
fluencyCircleStyle: 'background: conic-gradient(transparent 0)',
displayTotalScore: 0,
displayAccuracyScore: 0,
displayCompletenessScore: 0,
displayFluencyScore: 0,
isScoreModalOpen: false
})
},
async onWordTap(e: any) {
@@ -596,6 +822,77 @@ Page<IData, IPageInstance>({
this.setData({ dictLoading: false })
}
},
updateCircleProgress() {
const { totalScore, accuracyScore, completenessScore, fluencyScore, hasScoreInfo } = this.data
const normalize = (v: number) => {
if (v < 0) return 0
if (v <= 1) return Math.round(v * 100)
if (v > 100) return 100
return Math.round(v)
}
const totalPct = normalize(totalScore)
const accPct = normalize(accuracyScore)
const compPct = normalize(completenessScore)
const fluPct = normalize(fluencyScore)
const pickColor = (pct: number) => {
if (pct >= 70) return 'rgb(39, 174, 96)'
if (pct >= 40) return 'rgb(241, 196, 15)'
return 'rgb(231, 76, 60)'
}
const totalColor = pickColor(totalPct)
const accColor = pickColor(accPct)
const compColor = pickColor(compPct)
const fluColor = pickColor(fluPct)
if (this.circleAnimTimer) {
clearInterval(this.circleAnimTimer)
this.circleAnimTimer = undefined
}
const duration = 800
const start = Date.now()
const compose = (pct: number, color: string) => {
if (!hasScoreInfo || pct <= 0) {
return 'background: conic-gradient(transparent 0)'
}
return `background: conic-gradient(${color} ${pct}%, transparent 0)`
}
const tick = () => {
const elapsed = Date.now() - start
const progressPct = Math.min(100, Math.round((elapsed / duration) * 100))
const curTotal = Math.min(progressPct, totalPct)
const curAcc = Math.min(progressPct, accPct)
const curComp = Math.min(progressPct, compPct)
const curFlu = Math.min(progressPct, fluPct)
const ratio = (target: number, current: number) => (target === 0 ? 0 : (current / target))
const round2 = (v: number) => Math.round(v * 100) / 100
this.setData({
circleProgressStyle: compose(curTotal, totalColor),
accuracyCircleStyle: compose(curAcc, accColor),
completenessCircleStyle: compose(curComp, compColor),
fluencyCircleStyle: compose(curFlu, fluColor),
displayTotalScore: round2(totalScore * ratio(totalPct, curTotal)),
displayAccuracyScore: round2(accuracyScore * ratio(accPct, curAcc)),
displayCompletenessScore: round2(completenessScore * ratio(compPct, curComp)),
displayFluencyScore: round2(fluencyScore * ratio(fluPct, curFlu)),
})
if (elapsed >= duration) {
if (this.circleAnimTimer) {
clearInterval(this.circleAnimTimer)
this.circleAnimTimer = undefined
}
}
}
this.setData({
circleProgressStyle: compose(0, totalColor),
accuracyCircleStyle: compose(0, accColor),
completenessCircleStyle: compose(0, compColor),
fluencyCircleStyle: compose(0, fluColor),
displayTotalScore: 0,
displayAccuracyScore: 0,
displayCompletenessScore: 0,
displayFluencyScore: 0,
})
this.circleAnimTimer = setInterval(tick, 16) as any
},
handleDictClose() {
this.setData({ showDictPopup: false, showDictExtended: false })

View File

@@ -1,11 +1,13 @@
<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 class="loading-center">
<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">{{loadingLabel}}{{loadingDots}}</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]}}">
@@ -103,7 +105,7 @@
<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 class="overlay-text {{analizing ? 'loading-shimmer' : ''}}" style="animation-delay: {{index * 200}}ms; {{index === 0 ? 'text-transform: capitalize;' : ''}}">{{item.text}}</view>
</view>
</view>
<view wx:if="{{isRecording}}" class="recording-mask" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop"></view>
@@ -124,17 +126,17 @@
visible="{{scoreModalVisible}}"
hasScoreInfo="{{hasScoreInfo}}"
sentence="{{currentSentence}}"
totalScore="{{totalScore}}"
accuracyScore="{{accuracyScore}}"
completenessScore="{{completenessScore}}"
fluencyScore="{{fluencyScore}}"
totalScore="{{displayTotalScore}}"
accuracyScore="{{displayAccuracyScore}}"
completenessScore="{{displayCompletenessScore}}"
fluencyScore="{{displayFluencyScore}}"
circleProgressStyle="{{circleProgressStyle}}"
accuracyCircleStyle="{{accuracyCircleStyle}}"
completenessCircleStyle="{{completenessCircleStyle}}"
fluencyCircleStyle="{{fluencyCircleStyle}}"
wordScores="{{wordScores}}"
bind:close="onCloseScoreModal"
bind:play="playStandardVoice"
bind:play="playAssessmentVoice"
/>
<word-dictionary
visible="{{showDictPopup}}"

View File

@@ -18,6 +18,16 @@
background: transparent;
z-index: 1000;
}
.loading-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
/* 遮罩层样式 */
.word-popup-mask {
position: fixed;
@@ -117,6 +127,24 @@
}
.overlay-word.show { opacity: 1; }
.overlay-word.zoom { transform-origin: center center; transition: transform 500ms ease; }
.loading-shimmer {
animation: loading-shimmer 3s ease infinite;
}
@keyframes loading-shimmer {
0% {
transform: translate(0, 0) scale(1);
color: #ffffff;
}
15% {
transform: translate(0, -2rpx) scale(1.2);
color: #eee;
}
30%, 100% {
transform: translate(0, 0) scale(1);
color: #fff;
opacity: 1;
}
}
.recording-mask {
position: fixed;
top: 0;
@@ -387,12 +415,9 @@ background-color: #eef3fb;
}
.scanner {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
position: relative;
width: 220rpx;
height: 220rpx;
pointer-events: none;
border-radius: 12rpx;
background-color: rgba(255, 255, 255, 0.35);

View File

@@ -49,6 +49,8 @@ Page({
scrollTop: 0,
takePhoto: false,
photoPath: '',
photoImageLoaded: false,
photoExpandLoaded: false,
photoExpandTransform: '',
photoExpandTransition: '',
showExpandLayer: false,
@@ -648,7 +650,7 @@ Page({
const imagePath = await imageManager.takePhoto({})
wx.hideLoading()
this.setData({ photoPath: imagePath, takePhoto: true })
this.setData({ photoPath: String(imagePath || '').trim(), takePhoto: true })
logger.info('拍照成功:', imagePath)
// try { wx.nextTick(() => { this.scheduleExpandTransform() }) } catch (err) {}
@@ -683,7 +685,7 @@ Page({
const imagePath = await imageManager.chooseFromAlbum()
wx.hideLoading()
this.setData({ photoPath: imagePath, takePhoto: true })
this.setData({ photoPath: String(imagePath || '').trim(), takePhoto: true })
logger.info('选择图片成功:', imagePath)
// try { wx.nextTick(() => { this.scheduleExpandTransform() }) } catch (err) {}
@@ -704,8 +706,68 @@ Page({
}
}
},
async onPhotoImageLoad() {
try {
this.setData({ photoImageLoaded: true })
if (this.data.takePhoto && !this.data.showExpandLayer) {
try { wx.nextTick(() => { this.scheduleExpandTransform() }) } catch {}
}
} catch {}
},
async onPhotoImageError() {
try {
const p = String(this.data.photoPath || '').trim()
if (!p) return
const fs = wx.getFileSystemManager()
let persistent = ''
try {
persistent = fs.saveFileSync(p)
} catch (e) {}
if (persistent) {
this.setData({ photoPath: persistent })
} else {
try {
await wx.getImageInfo({ src: p } as any)
} catch {
wx.showToast({ title: '图片加载失败', icon: 'none' })
}
}
} catch {}
},
async onPhotoExpandLoaded() {
try {
this.setData({ photoExpandLoaded: true })
} catch {}
},
async onPhotoExpandError() {
try {
const p = String(this.data.photoPath || '').trim()
if (!p) return
const fs = wx.getFileSystemManager()
let persistent = ''
try {
persistent = fs.saveFileSync(p)
} catch (e) {}
if (persistent) {
this.setData({ photoPath: persistent })
} else {
try {
await wx.getImageInfo({ src: p } as any)
} catch {
wx.showToast({ title: '图片加载失败', icon: 'none' })
}
}
} catch {}
},
scheduleExpandTransform() {
if (!this.data.photoImageLoaded) {
const timer = setTimeout(() => {
clearTimeout(timer)
if (this.data.photoImageLoaded) this.scheduleExpandTransform()
}, 200)
return
}
try {
const sys = wx.getSystemInfoSync()
const win: any = (wx as any).getWindowInfo ? (wx as any).getWindowInfo() : sys
@@ -759,6 +821,8 @@ Page({
this.setData({
takePhoto: false,
photoPath: '',
photoImageLoaded: false,
photoExpandLoaded: false,
showExpandLayer: false,
photoExpandTransform: '',
photoExpandTransition: '',
@@ -803,13 +867,13 @@ Page({
wx.showModal({
title: '积分不足',
content: '您的积分不足,是否前往购买?',
confirmText: '购买',
confirmText: '获取',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.navigateTo({ url: '/pages/coupon/coupon' })
wx.redirectTo({ url: '/pages/coupon/coupon' })
} else {
// wx.showToast({ title: '已取消', icon: 'none' })
// wx.navigateBack({ delta: 1 })
}
}
})
@@ -932,7 +996,7 @@ Page({
const y = Number(parts[0]);
const m = Number(parts[1]);
const gridCols = images.length <= 1 ? 1 : images.length <= 4 ? 2 : 3;
const useWaterfall = images.length > 3;
const useWaterfall = images.length > 2;
let waterfallLeft: any[] = [], waterfallRight: any[] = [];
if (useWaterfall) {
images.forEach((img, idx) => { (idx % 2 === 0 ? waterfallLeft : waterfallRight).push(img); });

View File

@@ -36,14 +36,14 @@
<view class="photo-wrapper" wx:if="{{(!isLoading && todaySummary.length == 0 && groupedHistory.length == 0) || takePhoto}}">
<view class="photo">
<view class="photo-inner">
<image class="photo-image" src="{{takePhoto ? photoPath : photoSvgData}}" mode="{{takePhoto ? 'widthFix' : 'aspectFit'}}"></image>
<image class="photo-image" src="{{takePhoto ? photoPath : photoSvgData}}" mode="{{takePhoto ? 'widthFix' : 'aspectFit'}}" bindload="onPhotoImageLoad" binderror="onPhotoImageError"></image>
</view>
</view>
</view>
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}">
<view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}}">
<!-- <view class="expand-border-bg" style="{{expandBorderStyle}}"></view> -->
<image class="photo-expand-image" src="{{takePhoto ? photoPath : photoSvgData}}" mode="widthFix"></image>
<image class="photo-expand-image" src="{{takePhoto ? photoPath : photoSvgData}}" mode="widthFix" bindload="onPhotoExpandLoaded" binderror="onPhotoExpandError"></image>
<view class="scanner {{scannerVisible ? 'scanner-visible' : 'scanner-hidden'}}">
<view class="star star1"></view>
<view class="star star2"></view>