From 0b0582572d37f12ee16806b8ccba239fb564b8b1 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 19 Dec 2025 17:16:24 +0800 Subject: [PATCH] add sentence --- miniprogram/app.json | 3 +- miniprogram/app.ts | 4 +- .../components/score-modal/score-modal.json | 9 + .../components/score-modal/score-modal.ts | 37 + .../components/score-modal/score-modal.wxml | 75 ++ .../components/score-modal/score-modal.wxss | 52 ++ .../word-dictionary/word-dictionary.json | 12 + .../word-dictionary/word-dictionary.ts | 131 +++ .../word-dictionary/word-dictionary.wxml | 136 +++ .../word-dictionary/word-dictionary.wxss | 197 ++++ miniprogram/pages/assessment/assessment.json | 8 +- miniprogram/pages/assessment/assessment.ts | 22 +- miniprogram/pages/assessment/assessment.wxml | 269 +----- miniprogram/pages/profile/profile.ts | 8 +- .../pages/scene_sentence/scene_sentence.json | 15 + .../pages/scene_sentence/scene_sentence.ts | 840 ++++++++++++++++++ .../pages/scene_sentence/scene_sentence.wxml | 151 ++++ .../pages/scene_sentence/scene_sentence.wxss | 447 ++++++++++ miniprogram/pages/upload/upload.ts | 158 +++- miniprogram/pages/upload/upload.wxml | 48 +- miniprogram/pages/upload/upload.wxss | 107 ++- miniprogram/utils/api.ts | 41 +- 22 files changed, 2499 insertions(+), 271 deletions(-) create mode 100644 miniprogram/components/score-modal/score-modal.json create mode 100644 miniprogram/components/score-modal/score-modal.ts create mode 100644 miniprogram/components/score-modal/score-modal.wxml create mode 100644 miniprogram/components/score-modal/score-modal.wxss create mode 100644 miniprogram/components/word-dictionary/word-dictionary.json create mode 100644 miniprogram/components/word-dictionary/word-dictionary.ts create mode 100644 miniprogram/components/word-dictionary/word-dictionary.wxml create mode 100644 miniprogram/components/word-dictionary/word-dictionary.wxss create mode 100644 miniprogram/pages/scene_sentence/scene_sentence.json create mode 100644 miniprogram/pages/scene_sentence/scene_sentence.ts create mode 100644 miniprogram/pages/scene_sentence/scene_sentence.wxml create mode 100644 miniprogram/pages/scene_sentence/scene_sentence.wxss diff --git a/miniprogram/app.json b/miniprogram/app.json index bbeccc8..d3ec5fd 100755 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 3d08779..458a8c8 100755 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -78,7 +78,7 @@ App({ 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({ 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) diff --git a/miniprogram/components/score-modal/score-modal.json b/miniprogram/components/score-modal/score-modal.json new file mode 100644 index 0000000..d80005c --- /dev/null +++ b/miniprogram/components/score-modal/score-modal.json @@ -0,0 +1,9 @@ +{ + "component": true, + "styleIsolation": "apply-shared", + "usingComponents": { + "t-icon": "tdesign-miniprogram/icon/icon", + "t-tag": "tdesign-miniprogram/tag/tag" + } +} + diff --git a/miniprogram/components/score-modal/score-modal.ts b/miniprogram/components/score-modal/score-modal.ts new file mode 100644 index 0000000..4bc9c0d --- /dev/null +++ b/miniprogram/components/score-modal/score-modal.ts @@ -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') + } + } +}) diff --git a/miniprogram/components/score-modal/score-modal.wxml b/miniprogram/components/score-modal/score-modal.wxml new file mode 100644 index 0000000..30a4fa4 --- /dev/null +++ b/miniprogram/components/score-modal/score-modal.wxml @@ -0,0 +1,75 @@ + + + + + + + + + + {{sentence.content}} + + + + + + {{totalScore}} + 总分 + + + + + {{accuracyScore}} + 准确性 + + + + + {{completenessScore}} + 完整性 + + + + + {{fluencyScore}} + 流利度 + + + + + + + + + + {{item.description}} + + + + + + + + {{item.word}} + + + 准确性 + {{item.pronAccuracy}} + + + 流利度 + {{item.pronFluency}} + + + + + + [{{phoneInfo.phone}}] + {{phoneInfo.pronAccuracy}} + + + + + + + diff --git a/miniprogram/components/score-modal/score-modal.wxss b/miniprogram/components/score-modal/score-modal.wxss new file mode 100644 index 0000000..ed786a0 --- /dev/null +++ b/miniprogram/components/score-modal/score-modal.wxss @@ -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; } + diff --git a/miniprogram/components/word-dictionary/word-dictionary.json b/miniprogram/components/word-dictionary/word-dictionary.json new file mode 100644 index 0000000..9aec202 --- /dev/null +++ b/miniprogram/components/word-dictionary/word-dictionary.json @@ -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" + } +} diff --git a/miniprogram/components/word-dictionary/word-dictionary.ts b/miniprogram/components/word-dictionary/word-dictionary.ts new file mode 100644 index 0000000..647e45c --- /dev/null +++ b/miniprogram/components/word-dictionary/word-dictionary.ts @@ -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 }) + } + } +}) diff --git a/miniprogram/components/word-dictionary/word-dictionary.wxml b/miniprogram/components/word-dictionary/word-dictionary.wxml new file mode 100644 index 0000000..fb57d64 --- /dev/null +++ b/miniprogram/components/word-dictionary/word-dictionary.wxml @@ -0,0 +1,136 @@ + + + + + {{item}} + + + + + + + + + + + {{wordDict.simple.query}} + + 词源: {{prototypeWord}} + + More + + + + + + + UK:[{{wordDict.simple.word[0].ukphone}}] + + + + + + US:[{{wordDict.simple.word[0].usphone}}] + + + + + + + + + + {{item}} + + + + + + + + + + + + + + {{item.pos_entry.pos}} {{item.pos_entry.pos_tips}} + + + + + {{item.text}} + {{item.text}} + + + {{item.tran}} + + + {{item.eng_sent}} + {{item.chn_sent}} + + + + + + + + + + + + + + + {{item.en}} + {{item.zh}} + --摘自《{{item.source}}》 + + + + + + 同义词 + + + + {{item.syno.pos}} + + {{item.syno.tran}} + + + {{item.w}} + + + + + 相关单词 + + + + {{item.rel.pos}} + + + + {{item.tran}} + {{item.word}} + + + + + 区分单词 + + + {{item.usage}} + {{item.headword}} + + + + + + 暂无相关单词信息 + + + + 没有更多内容 + diff --git a/miniprogram/components/word-dictionary/word-dictionary.wxss b/miniprogram/components/word-dictionary/word-dictionary.wxss new file mode 100644 index 0000000..5879f4b --- /dev/null +++ b/miniprogram/components/word-dictionary/word-dictionary.wxss @@ -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; +} diff --git a/miniprogram/pages/assessment/assessment.json b/miniprogram/pages/assessment/assessment.json index 6748048..362ec26 100644 --- a/miniprogram/pages/assessment/assessment.json +++ b/miniprogram/pages/assessment/assessment.json @@ -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" } -} \ No newline at end of file +} diff --git a/miniprogram/pages/assessment/assessment.ts b/miniprogram/pages/assessment/assessment.ts index f75102c..ea736ca 100644 --- a/miniprogram/pages/assessment/assessment.ts +++ b/miniprogram/pages/assessment/assessment.ts @@ -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({ data: { imagePath: '', // 图片路径 + imageId: '', imageSmall: false, // 图片是否缩小 imageMode: 'aspectFill', // 初始完整展示模式 imageScale: 1, @@ -1145,6 +1152,7 @@ Page({ 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({ 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() {}, diff --git a/miniprogram/pages/assessment/assessment.wxml b/miniprogram/pages/assessment/assessment.wxml index 9abaa63..994cf1f 100644 --- a/miniprogram/pages/assessment/assessment.wxml +++ b/miniprogram/pages/assessment/assessment.wxml @@ -69,239 +69,50 @@ - 更多功能 + 场景句型 - - - - - - - - - - - - - - - {{currentSentence.content}} - - - - - - {{displayTotalScore}} - 总分 - - - - - {{displayAccuracyScore}} - 准确性 - - - - - {{displayCompletenessScore}} - 完整性 - - - - - {{displayFluencyScore}} - 流利度 - - - - - - - - - - - - {{item.description}} - - - - - - - - - {{item.word}} - - - 准确性 - {{item.pronAccuracy}} - - - 流利度 - {{item.pronFluency}} - - - - - - - [{{phoneInfo.phone}}] - {{phoneInfo.pronAccuracy}} - - - - - - - - - - - - - - - {{item}} - - - - - - - - - - - {{wordDict.simple.query}} - - 词源: {{prototypeWord}} - - More - - - - - - - - - - UK:[{{wordDict.simple.word[0].ukphone}}] - - - - - - US:[{{wordDict.simple.word[0].usphone}}] - - - - - - - - - - - {{item}} - - - - - - - - - - - - - - - {{item.pos_entry.pos}} {{item.pos_entry.pos_tips}} - - - - - {{item.text}} - {{item.text}} - - - {{item.tran}} - - - {{item.eng_sent}} - {{item.chn_sent}} - - - - - - - - - - - - - - - {{item.en}} - {{item.zh}} - --摘自《{{item.source}}》 - - - - - - 同义词 - - - - {{item.syno.pos}} - - {{item.syno.tran}} - - - {{item.w}} - - - - - 相关单词 - - - - {{item.rel.pos}} - - - - {{item.tran}} - {{item.word}} - - - - - 区分单词 - - - {{item.usage}} - {{item.headword}} - - - - - - 暂无相关单词信息 - - - - 没有更多内容 - + + diff --git a/miniprogram/pages/profile/profile.ts b/miniprogram/pages/profile/profile.ts index 69299c0..90fa7f2 100755 --- a/miniprogram/pages/profile/profile.ts +++ b/miniprogram/pages/profile/profile.ts @@ -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() } }) diff --git a/miniprogram/pages/scene_sentence/scene_sentence.json b/miniprogram/pages/scene_sentence/scene_sentence.json new file mode 100644 index 0000000..d737b96 --- /dev/null +++ b/miniprogram/pages/scene_sentence/scene_sentence.json @@ -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" + } +} diff --git a/miniprogram/pages/scene_sentence/scene_sentence.ts b/miniprogram/pages/scene_sentence/scene_sentence.ts new file mode 100644 index 0000000..0bb99fc --- /dev/null +++ b/miniprogram/pages/scene_sentence/scene_sentence.ts @@ -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 + englishWords?: Array + showDictPopup?: boolean + showDictExtended?: boolean + dictLoading?: boolean + wordDict?: any + isWordEmptyResult?: boolean + dictDefaultTabValue?: string + standardAudioMap?: Record + standardAudioLocalMap?: Record + wordAudioPlaying?: boolean + wordAudioIconName?: string + activeWordAudioType?: string + buttonsVisible?: boolean + commonMistakes?: string[] + pragmaticAlternative?: string[] + responsePairs?: string[] + recordStartTime?: number + recordDuration?: number + remainingTime?: number + overlayVisible?: boolean + highlightWords?: Array + highlightShow?: boolean + highlightZoom?: boolean + analizing?: boolean + cachedHighlightWords?: Array + cachedSentenceIndex?: number +} + +interface IPageInstance { + pollTimer?: number + audioCtx?: any + wordAudioContext?: WechatMiniprogram.InnerAudioContext + wordAudioIconTimer?: number + recordTimer?: number + fetchSceneSentence: (imageId: string) => Promise + 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 + noop: () => void + startRecording: () => void + stopRecording: () => void + onMicHighlight: () => void + computeHighlightLayout: () => void + fetchRecordResultForSentence: (textId: string) => Promise +} + +Page({ + 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) { + 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>)/g).filter(Boolean) + const processedParts: Array<{ text: string; bold: boolean }> = [] + parts.forEach((part: string) => { + if (part.startsWith('') && part.endsWith('')) { + 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 + } + } +}) diff --git a/miniprogram/pages/scene_sentence/scene_sentence.wxml b/miniprogram/pages/scene_sentence/scene_sentence.wxml new file mode 100644 index 0000000..177e1dc --- /dev/null +++ b/miniprogram/pages/scene_sentence/scene_sentence.wxml @@ -0,0 +1,151 @@ + + + + + + + + 场景分析中... + + + + + + {{w}} + + {{scene.list[currentIndex].sentenceZh}} + + + + + #{{item}} + + + + + + 发音提示 + + + {{scene.list[currentIndex].pronunciationTip}} + + + + + 技巧 + + + {{scene.list[currentIndex].fluencyHacks}} + + + + + + + + 常见错误 + + + {{item}} + + + + + + 相似句型 + + + + {{item}} + + + + + 搭话 + + + + {{item}} + + + + + 核心词汇 + {{scene.list[currentIndex].coreVocab.length}} words + + + {{item}} {{scene.list[currentIndex].coreVocabDesc[i]}} + + + + 搭配用法 + + + {{item}} + + + + + + + + + + {{item.text}} + + + + + + + + + + + + + + + + + + + diff --git a/miniprogram/pages/scene_sentence/scene_sentence.wxss b/miniprogram/pages/scene_sentence/scene_sentence.wxss new file mode 100644 index 0000000..2dab944 --- /dev/null +++ b/miniprogram/pages/scene_sentence/scene_sentence.wxss @@ -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); + } +} diff --git a/miniprogram/pages/upload/upload.ts b/miniprogram/pages/upload/upload.ts index 2213030..5bea0f1 100755 --- a/miniprogram/pages/upload/upload.ts +++ b/miniprogram/pages/upload/upload.ts @@ -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) {} }, diff --git a/miniprogram/pages/upload/upload.wxml b/miniprogram/pages/upload/upload.wxml index ddc21ce..39739f1 100755 --- a/miniprogram/pages/upload/upload.wxml +++ b/miniprogram/pages/upload/upload.wxml @@ -64,13 +64,13 @@ - + - + {{item.weekday}} {{item.day}} - + {{item.year}} {{item.month}} @@ -85,7 +85,19 @@ - + + + + + + + + + + + 场景句型 + + @@ -94,13 +106,37 @@ - + + + + + + + + + + + 场景句型 + + - + + + + + + + + + + + 场景句型 + + diff --git a/miniprogram/pages/upload/upload.wxss b/miniprogram/pages/upload/upload.wxss index 43c4eb0..b91ee5d 100755 --- a/miniprogram/pages/upload/upload.wxss +++ b/miniprogram/pages/upload/upload.wxss @@ -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; diff --git a/miniprogram/utils/api.ts b/miniprogram/utils/api.ts index f5ba24f..e669217 100755 --- a/miniprogram/utils/api.ts +++ b/miniprogram/utils/api.ts @@ -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 { if (USE_CLOUD) { const app = getApp() - 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() - 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() - 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 { + const resp = await this.request(`/api/v1/scene/image/${imageId}/sentence`, 'GET') + return resp.data + } + + async getRecordResult(textId: string | number): Promise { + const resp = await this.request(`/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 + } + } // 导出单例