This commit is contained in:
Felix
2025-12-30 20:37:59 +08:00
parent 9b83e83f3b
commit a99bf39dfb
16 changed files with 1971 additions and 190 deletions

View File

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

View File

@@ -1,3 +1,5 @@
import apiManager from '../../utils/api'
Component({
properties: {
visible: { type: Boolean, value: false },
@@ -6,6 +8,7 @@ Component({
wordDict: { type: Object, value: {} },
showBackIcon: { type: Boolean, value: false },
prototypeWord: { type: String, value: '' },
forceHidePrototype: { type: Boolean, value: false },
isWordEmptyResult: { type: Boolean, value: false },
dictDefaultTabValue: { type: String, value: '0' },
activeWordAudioType: { type: String, value: '' },
@@ -13,6 +16,85 @@ Component({
wordAudioIconName: { type: String, value: 'sound' }
},
methods: {
async queryWord(word: string) {
const self = this as any
const raw = String(word || '')
const cleaned = raw.replace(/[.,?!*;:'"()]/g, '').trim()
if (!cleaned) return
self.setData({ visible: true, loading: true })
try {
const detail: any = await apiManager.getWordDetail(cleaned)
const collins = detail['collins']
let processedCollins = collins
if (collins && collins.collins_entries) {
const cloned = JSON.parse(JSON.stringify(collins))
cloned.collins_entries.forEach((entry: any) => {
if (entry.entries && entry.entries.entry) {
entry.entries.entry.forEach((entryItem: any) => {
if (entryItem.tran_entry) {
entryItem.tran_entry.forEach((tranEntry: any) => {
if (tranEntry.tran) {
const parts = tranEntry.tran.split(/(<b>.*?<\/b>)/g).filter(Boolean)
const processed: Array<{ text: string; bold: boolean }> = []
parts.forEach((part: string) => {
if (part.startsWith('<b>') && part.endsWith('</b>')) {
const text = part.substring(3, part.length - 4)
processed.push({ text, bold: true })
} else {
processed.push({ text: part, bold: false })
}
})
tranEntry.tranParts = processed
tranEntry.originalTran = tranEntry.tran
}
})
}
})
}
})
processedCollins = cloned
}
const rawRelWord = detail['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 = !!detail['ee']
const hasEC = !!(detail['ec'] && detail['ec'].word && detail['ec'].word.length > 0)
const hasCollins = !!(processedCollins && processedCollins.collins_entries && processedCollins.collins_entries.length > 0)
const isEmpty = !(hasEE || hasEC || hasCollins)
const hasPhrs = !!(detail['phrs'] && detail['phrs'].phrs && detail['phrs'].phrs.length > 0)
const hasPastExam = !!(detail['individual'] && detail['individual'].pastExamSents && detail['individual'].pastExamSents.length > 0)
const defaultTab = hasCollins ? '0' : (hasPhrs ? '1' : (hasPastExam ? '2' : '3'))
const proto = (detail.ee?.word?.['return-phrase']?.['l']?.['i']) || (detail?.ec?.word?.[0]?.prototype)
const hideProto = !!self.data.forceHidePrototype
self.setData({
wordDict: {
ee: detail['ee'], ec: detail['ec'], expandEc: detail['expand_ec'],
simple: detail['simple'], phrs: detail['phrs'], etym: detail['etym'],
individual: detail['individual'], collins: processedCollins,
relWord: sanitizedRelWord, syno: detail['syno'],
discriminate: detail['discriminate']
},
dictDefaultTabValue: defaultTab,
isWordEmptyResult: isEmpty,
prototypeWord: hideProto ? '' : proto,
loading: false,
visible: true
})
} catch (err) {
self.setData({ loading: false })
self.triggerEvent('error', { message: 'queryFailed' })
}
},
onClose() {
const self = this as any
try { self.wordAudioContext && self.wordAudioContext.stop() } catch (e) {}
@@ -128,7 +210,8 @@ Component({
const propWord = (self.data && self.data.prototypeWord) ? self.data.prototypeWord : ''
const word = dsWord || propWord || ''
if (!word) return
self.triggerEvent('wordTap', { word })
const prev = (self.data && self.data.wordDict && self.data.wordDict.simple && self.data.wordDict.simple.query) ? self.data.wordDict.simple.query : ''
self.triggerEvent('wordTap', { word, previousWord: prev })
}
}
})

View File

@@ -15,7 +15,7 @@
<t-icon wx:if="{{showBackIcon}}" name="chevron-left" size="40rpx" bindtap="onBack"></t-icon>
{{wordDict.simple.query}}
</view>
<view class="word-source" wx:if="{{prototypeWord}}">词源: <t-tag variant="light" theme="primary" data-word="{{prototypeWord}}" bindtap="onWordTap">{{prototypeWord}}</t-tag></view>
<view class="word-source" wx:if="{{prototypeWord && !prototypeWord !== ''}}">词源: <t-tag variant="light" theme="primary" data-word="{{prototypeWord}}" bindtap="onWordTap">{{prototypeWord}}</t-tag></view>
<view class="more-btn" wx:if="{{!isWordEmptyResult}}" bindtap="onMore">
<text wx:if="{{!expanded}}">More</text>
<t-icon wx:if="{{!expanded}}" name="chevron-up" size="48rpx"></t-icon>

View File

@@ -287,6 +287,7 @@ Page<IPageData, IPageInstance>({
showBackIcon: false, // 是否显示返回图标
previousWord: '', // 上一个单词
isReturningFromPrevious: false, // 是否正在从上一个单词返回
forceHidePrototype: false,
isWordEmptyResult: false,
overlayVisible: false,
highlightWords: [],
@@ -967,7 +968,10 @@ Page<IPageData, IPageInstance>({
},
async handleWordClick(e: any) {
const { word, index } = e.currentTarget.dataset
const ds = (e && e.currentTarget && e.currentTarget.dataset) ? e.currentTarget.dataset : {}
const dt = (e && e.detail) ? e.detail : {}
const word: string = String(ds.word || dt.word || '')
const index = typeof ds.index === 'number' ? ds.index : (typeof dt.index === 'number' ? dt.index : undefined)
// 若例句未被选中,优先选中例句并阻止单词选择
const { selectedSentenceIndex, justSelectedByWord } = this.data
if (typeof index === 'number' && index !== selectedSentenceIndex) {
@@ -1017,76 +1021,33 @@ Page<IPageData, IPageInstance>({
// 只有通过 word-source 标签点击且不是从返回功能调用时才保存当前单词作为上一个单词,并显示返回图标
let showBackIcon = false;
let previousWord = '';
if (isFromWordSource && !isReturningFromPrevious) {
// 保存当前单词作为上一个单词,并显示返回图标
previousWord = this.data.wordDict?.simple?.query || '';
previousWord = String(dt.previousWord || '');
showBackIcon = !!previousWord;
} else if (isReturningFromPrevious) {
showBackIcon = false;
previousWord = '';
}
// 注意:这里不立即设置 showBackIcon 和 previousWord而是在获取单词详情后再设置
// 重置返回标记位
this.setData({
prototypeWord: '',
isReturningFromPrevious: false
isReturningFromPrevious: false,
forceHidePrototype: (isFromWordSource && !isReturningFromPrevious)
});
this.setData({ showBackIcon, previousWord })
try {
// 调用API获取单词详情
this.setData({ dictLoading: true, showDictPopup: true })
const wordDetail: any = await apiManager.getWordDetail(cleanedWord);
logger.info('获取到单词详情:', wordDetail);
// 处理 Collins 数据中的 HTML 标签
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 } }
})
}
this.setData({ showDictPopup: true })
const comp = this.selectComponent('#wordDict') as any
if (comp && typeof comp.queryWord === 'function') {
try {
wx.nextTick(() => {
comp.queryWord(cleanedWord)
})
} catch (e) {
setTimeout(() => comp.queryWord(cleanedWord), 0)
}
// 这里可以添加处理单词详情的逻辑,比如显示在页面上
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 hasRelated = !!((wordDetail['syno'] && wordDetail['syno'].synos && wordDetail['syno'].synos.length > 0) || (sanitizedRelWord && sanitizedRelWord.rels && sanitizedRelWord.rels.length > 0) || (wordDetail['discriminate'] && wordDetail['discriminate'].data && wordDetail['discriminate'].data.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']
},
prototypeWord: (wordDetail?.ec?.word?.[0]?.prototype) || '',
showBackIcon: showBackIcon, // 只有通过 word-source 点击且不是从返回功能调用时才显示返回图标
previousWord: previousWord, // 只有通过 word-source 点击且不是从返回功能调用时才保存上一个单词
isWordEmptyResult,
dictDefaultTabValue
});
this.setData({ dictLoading: false })
} catch (error) {
logger.error('获取单词详情失败:', error)
wx.showToast({
title: '获取单词详情失败',
icon: 'none'
})
this.setData({ dictLoading: false })
}
},
@@ -2016,7 +1977,8 @@ Page<IPageData, IPageInstance>({
// 设置返回标记位
this.setData({
isReturningFromPrevious: true
isReturningFromPrevious: true,
forceHidePrototype: false
});
// 调用 handleWordClick 方法查询上一个单词

View File

@@ -96,6 +96,7 @@
bind:play="playAssessmentVoice"
/>
<word-dictionary
id="wordDict"
visible="{{showDictPopup}}"
expanded="{{showDictExtended}}"
loading="{{dictLoading}}"
@@ -103,6 +104,7 @@
showBackIcon="{{showBackIcon}}"
prototypeWord="{{prototypeWord}}"
isWordEmptyResult="{{isWordEmptyResult}}"
forceHidePrototype="{{forceHidePrototype}}"
dictDefaultTabValue="{{dictDefaultTabValue}}"
activeWordAudioType="{{activeWordAudioType}}"
wordAudioPlaying="{{wordAudioPlaying}}"

View File

@@ -0,0 +1,13 @@
{
"navigationBarTitleText": "问答练习",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",
"backgroundTextStyle": "light",
"enablePullDownRefresh": false,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
"word-dictionary": "../../components/word-dictionary/word-dictionary"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
<view class="qa-exercise-container">
<view wx:if="{{!contentReady}}" class="page-loading-mask">
<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">{{statusText}}</view>
</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0}}">
<block wx:for="{{qaList}}" wx:key="index">
<view class="process-dot {{processDotClasses[index]}} {{index === currentIndex ? 'current' : ''}}"></view>
</block>
</view>
<view class="image-card">
<image wx:if="{{imageLocalUrl}}" class="image" src="{{imageLocalUrl}}" mode="aspectFill" bindtap="previewImage" bindload="onImageLoad" binderror="onImageError"></image>
<view class="view-full" wx:if="{{imageLocalUrl}}" bindtap="previewImage">
<t-icon name="zoom-in" size="32rpx" />
<!-- <text>View Full</text> -->
</view>
</view>
<view class="question-title">
<text wx:for="{{questionWords}}" wx:key="index" class="word-item" data-word="{{item}}" bindtap="handleWordClick">{{item}}</text>
</view>
<!-- <view class="progress-text">{{progressText}}</view> -->
<scroll-view class="question-scroll" scroll-y="true">
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'choice'}}">
<view class="choice-title">Select the correct answer ({{selectedCount}}/{{choiceRequiredCount}})</view>
<view class="option-list">
<view wx:for="{{choiceOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{(!choiceSubmitted && !selectedFlags[index] && selectedCount >= choiceRequiredCount) ? 'disabled' : ''}}" data-index="{{index}}" data-word="{{item.content}}" bindtap="selectOption" bindlongpress="onOptionLongPress">
<view class="option-radio">
<view class="radio-dot {{selectedFlags[index] ? 'on' : ''}} {{(evalClasses[index] === 'opt-incorrect' && selectedFlags[index]) ? 'red' : ''}}"></view>
</view>
<text class="option-text">{{item.content}}</text>
</view>
</view>
</view>
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'cloze'}}">
<view class="choice-title">Select the correct word to complete the sentence:</view>
<view class="cloze-sentence">
<text class="cloze-text">{{clozeParts[0]}}</text>
<text class="cloze-fill">_____</text>
<text class="cloze-text">{{clozeParts[1]}}</text>
</view>
<view class="option-list">
<view wx:for="{{clozeOptions}}" wx:key="index" class="option-item {{evalClasses[index]}}" data-index="{{index}}" data-word="{{item}}" bindtap="selectClozeOption" bindlongpress="onOptionLongPress">
<view class="option-radio">
<view class="radio-dot {{selectedClozeIndex === index ? 'on' : ''}} {{(evalClasses[index] === 'opt-incorrect' && selectedClozeIndex === index) ? 'red' : ''}}"></view>
</view>
<text class="option-text">{{item}}</text>
</view>
</view>
</view>
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'free_text'}}">
<view class="tip-row">
<t-icon name="info-circle" size="32rpx" />
<text class="tip-text">Tip: Be specific about the object type.</text>
</view>
<view class="answer-row">
<text class="answer-label">Your Answer</text>
<text class="answer-hint" bindtap="onHintTap">Need a hint?</text>
</view>
<view class="input-card">
<textarea class="answer-input" placeholder="Type your answer here..." maxlength="200" bindinput="inputChange" value="{{freeTextInput}}" />
<text class="input-type">English input</text>
</view>
</view>
<view class="submit-row">
<button class="submit-btn" bindtap="onSubmitTap" disabled="{{submitDisabled}}" wx:if="{{retryDisabled}}">提交</button>
<button class="submit-btn" bindtap="onRetryTap" disabled="{{retryDisabled}}" wx:else>重试</button>
</view>
</scroll-view>
</view>
<view class="bottom-bar {{contentVisible ? 'show' : ''}}">
<t-icon name="chevron-left" class="bottom-btn {{currentIndex <= 0 ? 'disabled' : ''}}" size="48rpx" bind:tap="onPrevTap" />
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-btn" size="48rpx" bind:tap="playStandardVoice" />
<t-icon name="swap" class="bottom-btn" size="48rpx" bind:tap="toggleMode" />
<t-icon name="fact-check" class="bottom-btn {{resultDisplayed ? '' : 'disabled'}}" size="48rpx" bind:tap="onScoreTap" />
<t-icon name="chevron-right" class="bottom-btn {{(qaList && (currentIndex >= qaList.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
</view>
<word-dictionary
id="wordDict"
visible="{{showDictPopup}}"
expanded="{{showDictExtended}}"
loading="{{dictLoading}}"
wordDict="{{wordDict}}"
showBackIcon="{{showBackIcon}}"
prototypeWord="{{prototypeWord}}"
forceHidePrototype="{{forceHidePrototype}}"
isWordEmptyResult="{{isWordEmptyResult}}"
dictDefaultTabValue="{{dictDefaultTabValue}}"
activeWordAudioType="{{activeWordAudioType}}"
wordAudioPlaying="{{wordAudioPlaying}}"
wordAudioIconName="{{wordAudioIconName}}"
bind:close="handleDictClose"
bind:more="handleDictMore"
bind:tabsChange="onTabsChange"
bind:tabsClick="onTabsClick"
bind:back="handleBackToPreviousWord"
bind:wordTap="handleWordClick"
/>
<view class="word-popup-mask" wx:if="{{showDictPopup}}" bindtap="handleDictClose"></view>
<view class="qa-detail-overlay" wx:if="{{qaDetailVisible}}" bindtap="onCloseDetailModal"></view>
<view class="qa-detail-modal {{qaDetailVisible ? 'show' : ''}}" wx:if="{{qaDetailVisible}}">
<view class="modal-header">
<text class="modal-title">题目解析</text>
<t-icon name="close" class="modal-close" size="40rpx" bind:tap="onCloseDetailModal" />
</view>
<scroll-view class="detail-body" scroll-y="true">
<view class="section">
<text class="question-text">{{qaDetailQuestionText}}</text>
</view>
<view class="section">
<view class="overview-card {{qaDetailResultStatus}}">
<view class="overview-icon">
<t-icon name="{{qaDetailIconName}}" size="40rpx" color="#fff" />
</view>
<view class="overview-content">
<text class="overview-label">评估详情 (EVALUATION DETAIL)</text>
<text class="overview-text">{{qaDetailOverviewText}}</text>
</view>
</view>
</view>
<block wx:for="{{qaDetailBlocks}}" wx:key="index">
<view class="section" wx:if="{{item.items && item.items.length}}">
<!-- <text class="section-title">{{item.title}}</text> -->
<view wx:if="{{item.variant === 'incorrect'}}" class="detail-row incorrect">
<view class="detail-icon">
<t-icon name="{{item.iconName || 'data-error' }}" size="40rpx" color="#fff" />
</view>
<view class="detail-content">
<text class="detail-label">错误选项</text>
<view class="detail-items incorrect-list">
<view class="incorrect-item" wx:for="{{item.items}}" wx:key="index">
<view class="incorrect-head">
<view class="chip incorrect">{{item.content}}</view>
<text class="error-type">{{item.error_type}}</text>
</view>
<text class="error-reason">{{item.error_reason}}</text>
</view>
</view>
</view>
</view>
<view wx:if="{{item.variant === 'info'}}" class="detail-row your-choice {{qaDetailResultStatus}}">
<view class="detail-icon">
<t-icon name="{{item.iconName || 'info-circle' }}" size="40rpx" color="#fff" />
</view>
<view class="detail-content">
<text class="detail-label">{{item.title}}</text>
<view class="detail-items">
<text class="detail-item" wx:for="{{item.items}}" wx:key="index">{{item}}</text>
</view>
</view>
</view>
<view wx:if="{{item.variant !== 'incorrect' && item.variant !== 'info'}}" class="detail-row {{item.variant}}">
<view class="detail-icon">
<t-icon name="{{item.iconName || 'info-circle' }}" size="40rpx" color="#fff" />
</view>
<view class="detail-content">
<text class="detail-label">{{item.title}}</text>
<view class="detail-items">
<text class="detail-item" wx:for="{{item.items}}" wx:key="index">{{item}}</text>
</view>
</view>
</view>
</view>
</block>
</scroll-view>
</view>
</view>

View File

@@ -0,0 +1,292 @@
.qa-exercise-container { min-height: 100vh; background: #ffffff; }
.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; }
.loading-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -100%); display: flex; flex-direction: column; align-items: center; gap: 16rpx; }
.container { width: 100%; height: 100%; padding: 32rpx; box-sizing: border-box; margin: 0 auto; display: flex; flex-direction: column; gap: 24rpx; }
.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) } }
.question-scroll { padding-bottom: calc(110rpx + env(safe-area-inset-bottom));}
.loading-container { padding: 32rpx; }
.process-container { width: 100%; display: flex; gap: 16rpx; align-items: center; padding: 8rpx 0; }
.process-dot { flex: 1; height: 10rpx; border-radius: 8rpx; background: #e6eef9; transition: all 0.3s ease; border: 3rpx solid transparent; box-sizing: border-box; }
.process-dot.dot-0 { background: #d9e6f2; }
.process-dot.dot-1 { background: #c9f3e4; }
.process-dot.dot-2 { background: #7be6b2; }
.process-dot.dot-3 { background: #21cc80; }
.process-dot.current { border-color: #21cc80; transform: scaleY(1.2); }
.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; }
.scanner { position: relative; width: 220rpx; height: 220rpx; 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) } }
.word-popup-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99;
}
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; }
.image { width: 100%; height: 360rpx; border-radius: 24rpx; background: #f5f5f5; }
.view-full { position: absolute; right: 16rpx; bottom: 16rpx; display: flex; align-items: center; gap: 8rpx; padding: 10rpx 16rpx; border-radius: 24rpx; background: rgba(0,0,0,0.4); color: #fff; }
.question-title { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; width: 100%; display: flex; flex-wrap: wrap; justify-content: flex-start;}
.progress-text { font-size: 26rpx; color: #666; }
.word-item { display: inline; font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; margin-right: 12rpx; }
.choice-title { font-size: 28rpx; color: #666; margin: 12rpx 0 16rpx; }
.option-list { display: flex; flex-direction: column; gap: 16rpx; }
.option-item { display: flex; align-items: center; gap: 16rpx; padding: 24rpx; border-radius: 20rpx; background: #fff; border: 2rpx solid #e6eef9; }
.option-item.selected { border-color: #21cc80; background: #eafaf2; }
.option-item.opt-correct { border-color: #21cc80; background: #eafaf2; }
.option-item.opt-incorrect { border-color: #e74c3c; background: #fdecea; }
.option-item.opt-missing { border-color: #21cc80; background: #eafaf2; }
.option-item.disabled { opacity: 0.6; }
.option-radio { width: 36rpx; height: 36rpx; border-radius: 50%; border: 2rpx solid #cfd8e3; display: flex; align-items: center; justify-content: center; }
.radio-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: transparent; }
.radio-dot.on { background: #21cc80; }
.radio-dot.red { background: #e74c3c; }
.option-text { font-size: 30rpx; color: #001858; line-height: 44rpx; }
.cloze-sentence { display: flex; flex-wrap: wrap; gap: 8rpx; align-items: baseline; }
.cloze-text { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; }
.cloze-fill { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; }
.cloze-fill.selected { color: #21cc80; border-bottom-color: #21cc80; }
.tip-row { display: flex; align-items: center; gap: 8rpx; color: #21cc80; margin: 8rpx 0 16rpx; }
.tip-text { font-size: 24rpx; color: #21cc80; }
.answer-row { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8rpx; }
.answer-label { font-size: 28rpx; color: #001858; font-weight: 600; }
.answer-hint { font-size: 26rpx; color: #21cc80; }
.input-card { position: relative; border-radius: 24rpx; border: 4rpx solid #21cc80; background: #ffffff; padding: 12rpx; }
.answer-input { width: 100%; min-height: 200rpx; font-size: 30rpx; color: #001858; line-height: 44rpx; }
.input-type { position: absolute; right: 16rpx; bottom: 12rpx; font-size: 22rpx; color: #999; }
.submit-row { display: flex; justify-content: center; width: 100%; margin-top: 48rpx; padding-bottom: 24rpx; }
.submit-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
background: #21cc80;
color: #fff;
font-size: 32rpx;
font-weight: 700;
border: none;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn::after { border: none; }
.submit-btn:active { transform: translateY(8rpx); box-shadow: none; }
.submit-btn[disabled] {
background: #e5e5e5;
color: #afafaf;
box-shadow: none;
transform: none;
pointer-events: none;
}
/* 结果详情弹窗样式 */
.qa-detail-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.45);
z-index: 999;
}
.qa-detail-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
max-height: 80vh;
background: #fff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
box-shadow: 0 -12rpx 36rpx rgba(0,0,0,0.12);
z-index: 1000;
transform: translateY(100%);
transition: transform 250ms ease;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.qa-detail-modal.show {
transform: translateY(0);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 2rpx solid #f0f4f8;
}
.modal-title {
font-size: 32rpx;
color: #001858;
font-weight: 700;
}
.modal-close {
color: #666;
}
.detail-body {
padding: 12rpx 24rpx 24rpx;
flex: 1;
min-height: 0;
box-sizing: border-box;
}
.section { margin: 12rpx 0; width: 100%; box-sizing: border-box; }
.section-title { font-size: 28rpx; color: #001858; font-weight: 600; margin-bottom: 8rpx; }
.question-text { font-size: 30rpx; color: #001858; line-height: 44rpx; }
/* New Overview Card Styles */
.overview-card {
display: flex;
align-items: center;
gap: 24rpx;
padding: 12rpx;
border-radius: 24rpx;
background: #f8f8f8;
}
.overview-card.partial { background: #fff5e6; }
.overview-card.correct { background: #eafaf2; }
.overview-card.incorrect { background: #fff7f6; }
.overview-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #ddd;
}
.overview-card.partial .overview-icon { background: #ff6b00; }
.overview-card.correct .overview-icon { background: #21cc80; }
.overview-card.incorrect .overview-icon { background: #e74c3c; }
.overview-content {
display: flex;
flex-direction: column;
gap: 8rpx;
flex: 1;
}
.overview-label {
font-size: 26rpx;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1rpx;
color: #666;
}
.overview-card.partial .overview-label { color: #ff6b00; }
.overview-card.correct .overview-label { color: #21cc80; }
.overview-card.incorrect .overview-label { color: #e74c3c; }
.overview-text {
font-size: 28rpx;
font-weight: 800;
color: #333;
}
.overview-card.partial .overview-text { color: #4a2b0f; }
.overview-card.correct .overview-text { color: #004d2c; }
.overview-card.incorrect .overview-text { color: #5a120c; }
.chip-list { display: flex; flex-wrap: wrap; gap: 8rpx; }
.incorrect-list { display: flex; flex-direction: column; gap: 8rpx; }
.incorrect-title { font-size: 26rpx; color: #e74c3c; font-weight: 700; margin-bottom: 4rpx; }
.incorrect-item { display: flex; flex-direction: column; gap: 4rpx; padding: 12rpx; }
.incorrect-head { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; padding: 8rpx 16rpx; border-radius: 20rpx; font-size: 26rpx; }
.chip.correct { background: #eafaf2; color: #0b9e62; border: 2rpx solid #21cc80; }
.chip.incorrect { background: #fdecea; color: #e74c3c; }
.chip.missing { background: #eaf2ff; color: #2f80ed; border: 2rpx solid #2f80ed; }
.error-type { font-size: 24rpx; color: #e74c3c; }
.error-reason { font-size: 24rpx; color: #666; }
.info-list { display: flex; flex-direction: column; gap: 8rpx; }
.info-item { font-size: 28rpx; color: #001858; line-height: 40rpx; }
/* 你的选择高亮(参考概览卡配色) */
.info-list.your-choice.correct .info-item {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 20rpx;
background: #eafaf2;
color: #0b9e62;
border: 2rpx solid #21cc80;
}
.info-list.your-choice.incorrect .info-item {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 20rpx;
background: #fdecea;
color: #e74c3c;
border: 2rpx solid #e74c3c;
}
/* 统一的结果行样式,参考概览卡结构 */
.detail-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx;
border-radius: 16rpx;
background: #f8f9fb;
}
.detail-row.correct { background: #eafaf2; }
.detail-row.incorrect { background: #fff7f6; }
.detail-row.missing { background: #eaf2ff; }
.detail-row.your-choice.correct { background: #eafaf2; }
.detail-row.your-choice.incorrect { background: #fff7f6; }
.detail-row.your-choice.partial { background: #eafaf2; }
.detail-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #ccc;
}
.detail-row.correct .detail-icon { background: #21cc80; }
.detail-row.incorrect .detail-icon { background: #e74c3c; }
.detail-row.missing .detail-icon { background: #2f80ed; }
.detail-row.your-choice.correct .detail-icon { background: #21cc80; }
.detail-row.your-choice.incorrect .detail-icon { background: #e74c3c; }
.detail-row.your-choice.partial .detail-icon { background: #21cc80; }
.detail-content { display: flex; flex-direction: column; gap: 6rpx; flex: 1; }
.detail-label { font-size: 26rpx; font-weight: 700; color: #001858; }
.detail-items { display: flex; flex-wrap: wrap; gap: 12rpx; }
.detail-item { font-size: 28rpx; color: #001858; }
.detail-row.correct .detail-item { color: #0b9e62; }
.detail-row.incorrect .detail-item { color: #e74c3c; }
.detail-row.missing .detail-item { color: #2f80ed; }
.detail-row.your-choice.partial .detail-item { color: #0b9e62; }

View File

@@ -90,6 +90,11 @@ interface IData {
cachedSentenceIndex?: number
loadingDots?: string
loadingLabel?: string
showBackIcon?: boolean
previousWord?: string
isReturningFromPrevious?: boolean
prototypeWord?: string
forceHidePrototype?: boolean
}
interface IPageInstance {
@@ -127,6 +132,7 @@ interface IPageInstance {
fetchRecordResultForSentence: (textId: string) => Promise<void>
startLoadingDots: () => void
stopLoadingDots: () => void
handleBackToPreviousWord: () => void
}
Page<IData, IPageInstance>({
@@ -186,7 +192,12 @@ Page<IData, IPageInstance>({
cachedHighlightWords: [],
cachedSentenceIndex: -1,
loadingDots: '',
loadingLabel: '场景分析中'
loadingLabel: '场景分析中',
showBackIcon: false,
previousWord: '',
isReturningFromPrevious: false,
prototypeWord: '',
forceHidePrototype: false
},
noop() {},
@@ -801,56 +812,43 @@ Page<IData, IPageInstance>({
},
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)
const dsWord = (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.word) || (e && e.target && e.target.dataset && e.target.dataset.word) || ''
const dtWord = (e && e.detail && e.detail.word) || ''
const rawWord = dtWord || dsWord || ''
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 })
const isFromWordSource = !!dtWord
const { isReturningFromPrevious } = this.data
let showBackIcon = false
let previousWord = ''
if (isFromWordSource && !isReturningFromPrevious) {
previousWord = String((e && e.detail && e.detail.previousWord) || '')
showBackIcon = !!previousWord
} else if (isReturningFromPrevious) {
showBackIcon = false
previousWord = ''
}
this.setData({ prototypeWord: '', isReturningFromPrevious: false, forceHidePrototype: (isFromWordSource && !isReturningFromPrevious) })
this.setData({ showBackIcon, previousWord })
this.setData({ showDictPopup: true })
const comp = this.selectComponent('#wordDict') as any
if (comp && typeof comp.queryWord === 'function') {
try {
wx.nextTick(() => {
comp.queryWord(cleanedWord)
})
} catch (e) {
setTimeout(() => comp.queryWord(cleanedWord), 0)
}
}
},
handleBackToPreviousWord() {
const w = String(this.data.previousWord || '')
if (!w) return
this.setData({ isReturningFromPrevious: true, forceHidePrototype: false })
const event = { currentTarget: { dataset: { word: w } } }
this.onWordTap(event as any)
},
updateCircleProgress() {
const { totalScore, accuracyScore, completenessScore, fluencyScore, hasScoreInfo } = this.data

View File

@@ -144,10 +144,15 @@
expanded="{{showDictExtended}}"
loading="{{dictLoading}}"
wordDict="{{wordDict}}"
showBackIcon="{{showBackIcon}}"
prototypeWord="{{prototypeWord}}"
forceHidePrototype="{{forceHidePrototype}}"
isWordEmptyResult="{{isWordEmptyResult}}"
id="wordDict"
dictDefaultTabValue="{{dictDefaultTabValue}}"
bind:close="handleDictClose"
bind:more="handleDictMore"
bind:back="handleBackToPreviousWord"
bind:wordTap="onWordTap"
/>
<view class="word-popup-mask" wx:if="{{showDictPopup}}" bindtap="handleDictClose"></view>

View File

@@ -833,40 +833,40 @@ Page({
const deltaRpx = (expandBottomRpx - expandTopRpx - (photoBottomRpx - photoTopRpx)) / 2
const pxPerRpx = (win.windowWidth || vw) / 750
dy += deltaRpx * pxPerRpx
const startAtPhoto = `transform: translate(${dx}px, ${dy}px) scale(${scale});`
const targetHeight = Math.round(rect.height / scale)
this.setData({
photoExpandTransform: startAtPhoto,
photoExpandTransition: 'transition: transform 0ms',
photoExpandCurrentWidth: targetWidth,
photoExpandCurrentHeight: targetHeight,
showExpandLayer: true,
expandBorderStyle: 'opacity: 0;'
})
setTimeout(() => {
this.setData({ photoExpandTransition: 'transition: transform 900ms ease-in-out' })
this.setData({ photoExpandTransform: 'transform: translate(0px, 0px) scale(1);', expandBorderStyle: 'opacity: 1; transition: opacity 600ms ease-out;' })
setTimeout(() => { this.setData({ scannerVisible: true }) }, 100)
}, 50)
const startAtPhoto = `transform: translate(${dx}px, ${dy}px) scale(${scale});`
const targetHeight = Math.round(rect.height / scale)
this.setData({
photoExpandTransform: startAtPhoto,
photoExpandTransition: 'transition: transform 0ms',
photoExpandCurrentWidth: targetWidth,
photoExpandCurrentHeight: targetHeight,
showExpandLayer: true,
expandBorderStyle: 'opacity: 0;'
})
setTimeout(() => {
this.setData({ photoExpandTransition: 'transition: transform 900ms ease-in-out' })
this.setData({ photoExpandTransform: 'transform: translate(0px, 0px) scale(1);', expandBorderStyle: 'opacity: 1; transition: opacity 600ms ease-out;' })
setTimeout(() => { this.setData({ scannerVisible: true }) }, 100)
}, 50)
})
}, waitMs)
} catch (e) {}
},
resetPageState() {
this.setData({
takePhoto: false,
photoPath: '',
photoImageLoaded: false,
photoExpandLoaded: false,
showExpandLayer: false,
photoExpandTransform: '',
photoExpandTransition: '',
photoExpandCurrentWidth: 0,
photoExpandCurrentHeight: 0,
expandBorderStyle: ''
})
},
resetPageState() {
this.setData({
takePhoto: false,
photoPath: '',
photoImageLoaded: false,
photoExpandLoaded: false,
showExpandLayer: false,
photoExpandTransform: '',
photoExpandTransition: '',
photoExpandCurrentWidth: 0,
photoExpandCurrentHeight: 0,
expandBorderStyle: ''
})
},
// 跳转到结果页面
async navigateToResult() {
@@ -1190,6 +1190,23 @@ Page({
} catch (err) {}
},
onImageQaExerciseTap(e: any) {
try {
const imageId = e?.currentTarget?.dataset?.imageId;
const thumbnailId = e?.currentTarget?.dataset?.thumbnailId;
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/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${thumbnailId ? ('&thumbnail_id=' + encodeURIComponent(thumbnailId)) : ''}`
});
} 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 };

View File

@@ -42,8 +42,8 @@
</view>
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}">
<!-- <view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}} {{photoExpandCurrentHeight ? ('height: ' + photoExpandCurrentHeight + 'px;') : ''}}"> -->
<view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}}">
<image class="photo-expand-image" src="{{takePhoto ? photoExpandSrc : photoSvgData}}" mode="widthFix" bindload="onPhotoExpandLoaded" binderror="onPhotoExpandError"></image>
<view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}} {{photoExpandCurrentHeight ? ('height: ' + photoExpandCurrentHeight + 'px;') : ''}}">
<image class="photo-expand-image {{photoExpandLoaded ? 'visible' : 'hidden'}}" src="{{takePhoto ? photoExpandSrc : 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>
@@ -53,10 +53,6 @@
</view>
<t-action-sheet id="t-action-sheet" bind:selected="handleSelected" />
</view>
<!-- <view class='button-wrapper'>
<view class='button'>123</view>
<view class='button-bg'></view>
</view> -->
</view>
<view wx:if="{{!takePhoto && dateStripItems && dateStripItems.length > 0}}" class="date-section">
<view class="date-strip-header">
@@ -96,6 +92,7 @@
</view>
<view wx:if="{{image.more_open || image.menu_closing}}" class="image-more-menu {{image.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{image.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}">场景练习</view>
</view>
</view>
</view>
@@ -117,6 +114,7 @@
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}">场景练习</view>
</view>
</view>
</view>
@@ -135,6 +133,7 @@
</view>
<view wx:if="{{item.more_open || item.menu_closing}}" class="image-more-menu {{item.menu_closing ? 'closing' : 'opening'}}">
<view class="more-item-mini" bindtap="onImageSceneSentenceTap" data-image-id="{{item.image_id}}">场景句型</view>
<view class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}">场景练习</view>
</view>
</view>
</view>

View File

@@ -183,13 +183,10 @@
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;
flex-direction: column;
align-items: stretch;
gap: 12rpx;
opacity: 1;
transform: translateY(0) scale(1);
@@ -204,6 +201,9 @@
font-size: 26rpx;
white-space: nowrap;
line-height: 32rpx;
padding: 10rpx 16rpx;
border-radius: 8rpx;
background: #f5f5f5;
}
@keyframes menuFadeIn {
@@ -1196,44 +1196,47 @@
height: auto;
}
.photo-expand-layer {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 99;
transition: transform 900ms ease-in-out;
will-change: transform;
transform-origin: center center;
backface-visibility: hidden;
}
.photo-expand-inner {
display: inline-block;
/* 初始与小卡片重叠,无额外外边距 */
background-color: #ffffff;
border: 24rpx solid #ffffff;
border-bottom: 48rpx solid #fff;
box-shadow: 0 12rpx 32rpx rgba(0,0,0,0.35);
border-radius: 12rpx;
position: relative;
box-sizing: border-box;
transition: width 900ms ease-in-out;
will-change: width, transform;
transform-origin: center center;
backface-visibility: hidden;
}
.photo-expand-image {
display: block;
width: 100%;
height: auto;
max-width: 100%;
position: relative;
border-radius: 12rpx;
backface-visibility: hidden;
}
.photo-expand-layer {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 99;
transition: transform 900ms ease-in-out;
will-change: transform;
transform-origin: center center;
backface-visibility: hidden;
}
.photo-expand-inner {
display: inline-block;
/* 初始与小卡片重叠,无额外外边距 */
background-color: #ffffff;
border: 24rpx solid #ffffff;
border-bottom: 48rpx solid #fff;
box-shadow: 0 12rpx 32rpx rgba(0,0,0,0.35);
border-radius: 12rpx;
position: relative;
box-sizing: border-box;
transition: width 900ms ease-in-out;
will-change: width, transform;
transform-origin: center center;
backface-visibility: hidden;
overflow: hidden;
}
.photo-expand-image {
display: block;
width: 100%;
height: auto;
max-width: 100%;
position: relative;
border-radius: 12rpx;
backface-visibility: hidden;
}
.photo-expand-image.hidden { opacity: 0; }
.photo-expand-image.visible { opacity: 1; transition: opacity 180ms ease; }
.scanner {
position: absolute;

View File

@@ -48,6 +48,100 @@ export interface IRecognitionResponseResult {
level3: IRecognitionResponseResultLevel,
}
export interface IQaExerciseCreateAccepted {
task_id: string
status: 'accepted'
}
export interface IQaExerciseTaskStatus {
task_id: string
image_id: string
ref_type: 'qa_exercise'
ref_id: string
status: string
error_message?: string
}
export interface IQaExerciseItem {
id: number
image_id: number
title?: string
description?: string
status: string
question_count: number
created_by: number
created_time?: string
}
export type IQaExerciseListResponse = IQaExerciseItem[]
export type QaAttemptMode = 'choice' | 'free_text' | 'audio'
export interface IQaExerciseQuestion {
id: number
exercise_id: number
image_id: number
question: string
user_id: number
ext: any
created_time?: string
}
export interface IQaExerciseQueryResponse {
exercise: IQaExerciseItem
session: IQaExerciseSession
questions: IQaExerciseQuestion[]
}
export interface IQaQuestionAttemptAccepted {
attempt_id: string
task_id: string
status: 'accepted'
}
export interface IQaQuestionTaskStatus {
task_id: string
ref_type: 'qa_question_attempt'
ref_id: string
status: string
error_message?: string
}
export type QaCorrectness = 'correct' | 'partial' | 'incorrect'
export interface IIncorrectSelectionItem {
content: string
error_type?: string
error_reason?: string
}
export interface ISelectedDetail {
correct: string[]
incorrect: IIncorrectSelectionItem[]
}
export interface IEvaluationSchema {
type?: string
result?: string
detail?: string
selected?: ISelectedDetail
missing_correct?: string[]
feedback?: string
}
export interface IQaResult {
attempt_id: string
status: string
is_correct?: QaCorrectness
evaluation?: IEvaluationSchema
mode: QaAttemptMode
selected_option_ids?: number[]
input_text?: string
recording_id?: string
stt_text?: string
updated_time?: string
}
// 单词详情接口
export interface ExtendedWordDetail {
word: string
@@ -75,6 +169,18 @@ export interface IPointsData {
expired_time: string
}
export interface IQaExerciseSession {
id: number
exercise_id: number
starter_user_id: number
status: string
started_at?: string
completed_at?: string
progress: number
score?: number
ext?: any
}
export interface YdWordDetail {
ee: {}
ec: {}

View File

@@ -7,7 +7,13 @@ import {
IAuditHistoryResponse,
IUserInfo,
IDailySummaryResponse,
YdWordDetail
YdWordDetail,
IQaExerciseCreateAccepted,
IQaExerciseTaskStatus,
IQaExerciseQueryResponse,
IQaQuestionAttemptAccepted,
IQaQuestionTaskStatus,
IQaResult
} from '../types/app';
import { BASE_URL, USE_CLOUD } from './config';
import { cloudConfig } from './cloud.config';
@@ -2055,6 +2061,52 @@ class ApiManager {
return resp.data
}
async createQaExerciseTask(imageId: number | string, title?: string, description?: string): Promise<IQaExerciseCreateAccepted> {
const payload: Record<string, any> = { image_id: imageId }
if (title !== undefined) payload.title = title
if (description !== undefined) payload.description = description
const resp = await this.request<IQaExerciseCreateAccepted>(`/api/v1/qa/exercises/tasks`, 'POST', payload)
return resp.data
}
async getQaExerciseTaskStatus(taskId: string): Promise<IQaExerciseTaskStatus> {
const resp = await this.request<IQaExerciseTaskStatus>(`/api/v1/qa/exercises/tasks/${taskId}/status`, 'GET')
return resp.data
}
async listQaExercisesByImage(imageId: string): Promise<IQaExerciseQueryResponse | null> {
const resp = await this.request<IQaExerciseQueryResponse | null>(`/api/v1/qa/${imageId}/exercises`, 'GET')
return resp.data
}
async createQaQuestionAttempt(
questionId: string,
payload: {
exercise_id: string
mode: 'choice' | 'free_text' | 'cloze'
selected_options?: string[]
input_text?: string
file_id?: string
}
): Promise<IQaQuestionAttemptAccepted> {
const resp = await this.request<IQaQuestionAttemptAccepted>(`/api/v1/qa/questions/${questionId}/attempts`, 'POST', payload)
return resp.data
}
async getQaQuestionTaskStatus(taskId: string): Promise<IQaQuestionTaskStatus> {
const resp = await this.request<IQaQuestionTaskStatus>(`/api/v1/qa/question-tasks/${taskId}/status`, 'GET')
return resp.data
}
async getQaResult(questionId: string): Promise<IQaResult> {
const resp = await this.request<IQaResult>(`/api/v1/qa/questions/${questionId}/result`, 'GET')
return resp.data
}
async getQaQuestionAudio(questionId: string | number): Promise<{ file_id: string | number }> {
const resp = await this.request<{ file_id: string | number }>(`/api/v1/qa/questions/${questionId}/audio`, 'GET')
return resp.data
}
}
// 导出单例