Compare commits

...

10 Commits

Author SHA1 Message Date
Felix
96b0a20fa0 fix code 2026-01-26 17:52:22 +08:00
Felix
1bffa69aea fix code 2026-01-25 22:12:29 +08:00
Felix
4607db2721 fix code 2026-01-23 16:26:34 +08:00
Felix
107283a69a fix code 2026-01-23 15:51:01 +08:00
Felix
2ae39a4eaa fix code 2026-01-23 12:52:31 +08:00
Felix
0a36bebde5 fix code 2026-01-23 12:02:07 +08:00
Felix
9d8f6d73ef add conversation 2026-01-21 13:29:15 +08:00
Felix
751b2ae087 add subscribe 2026-01-18 11:48:03 +08:00
Felix
90057c8ddb add variation 2026-01-13 20:50:02 +08:00
Felix
78b7964860 add confetti 2026-01-09 12:29:07 +08:00
25 changed files with 3363 additions and 223 deletions

View File

@@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"t-image": "tdesign-miniprogram/image/image",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton"
}
}

View File

@@ -0,0 +1,98 @@
import apiManager from '../../utils/api';
// Global memory cache for image URLs
const urlCache = new Map<string, string>();
// Track pending requests to avoid duplicate fetches for the same fileId
const pendingRequests = new Map<string, Promise<string>>();
Component({
virtualHost: true,
properties: {
fileId: {
type: String,
observer: 'loadUrl'
},
width: {
type: String,
value: '100%'
},
height: {
type: String,
value: '100%'
},
mode: {
type: String,
value: 'widthFix'
},
radius: {
type: String,
value: '0'
}
},
data: {
imageUrl: '',
isLoading: true,
isError: false
},
methods: {
async loadUrl(this: any, fileId: string) {
if (!fileId) {
this.setData({ imageUrl: '', isLoading: false });
return;
}
// Check cache first
if (urlCache.has(fileId)) {
this.setData({
imageUrl: urlCache.get(fileId),
isLoading: false,
isError: false
});
return;
}
this.setData({ isLoading: true, isError: false });
try {
let promise = pendingRequests.get(fileId);
if (!promise) {
promise = apiManager.getFileDisplayUrl(fileId);
pendingRequests.set(fileId, promise);
}
const url = await promise;
urlCache.set(fileId, url);
pendingRequests.delete(fileId);
// Ensure the fileId hasn't changed while we were fetching
if (this.data.fileId === fileId) {
console.log('[CloudImage] Loaded url for', fileId, url)
this.setData({
imageUrl: url,
isLoading: false
});
}
} catch (e) {
console.error('Failed to load image url for fileId:', fileId, e);
if (this.data.fileId === fileId) {
this.setData({
isLoading: false,
isError: true
});
}
pendingRequests.delete(fileId);
}
},
onLoad(this: any, e: any) {
this.triggerEvent('load', e);
},
onError(this: any, e: any) {
this.setData({ isError: true, isLoading: false });
this.triggerEvent('error', e);
}
}
});

View File

@@ -0,0 +1,24 @@
<view class="cloud-image" style="width: {{width}}; height: {{height}}; border-radius: {{radius}}; overflow: hidden; position: relative;">
<t-skeleton
wx:if="{{isLoading}}"
row-col="{{[{ width: '100%', height: '100%', borderRadius: radius }]}}"
loading
class="skeleton"
style="width: 100%; height: 100%; display: block;"
></t-skeleton>
<t-image
wx:if="{{!isLoading && imageUrl}}"
src="{{imageUrl}}"
mode="{{mode}}"
class="image"
t-class="image"
style="width: 100%; height: {{mode === 'widthFix' ? 'auto' : '100%'}}; display: block;"
bind:error="onError"
bind:load="onLoad"
/>
<view wx:if="{{isError}}" class="error-placeholder" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #f5f5f5; color: #999;">
<text style="font-size: 24rpx;">加载失败</text>
</view>
</view>

View File

@@ -0,0 +1,34 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.cloud-image {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.skeleton {
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
display: block;
}
.error-placeholder {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 24rpx;
}

View File

@@ -0,0 +1,448 @@
// canvas-confetti.js - 微信小程序兼容版本
// 基于 https://github.com/catdad/canvas-confetti
Component({
properties: {
width: {
type: Number,
value: 300
},
height: {
type: Number,
value: 300
},
id: {
type: String,
value: 'confettiCanvas'
}
},
data: {
canvasId: '',
isCanvasReady: false,
canvasInitPromise: null,
animationFrameId: null,
animatingFettis: [],
defaults: {
particleCount: 50,
angle: 90,
spread: 45,
startVelocity: 45,
decay: 0.9,
gravity: 1,
drift: 0,
ticks: 200,
x: 0.5,
y: 0.5,
shapes: ['square', 'circle'],
colors: [
'#26ccff',
'#a25afd',
'#ff5e7e',
'#88ff5a',
'#fcff42',
'#ffa62d',
'#ff36ff'
],
scalar: 1,
flat: false
}
},
lifetimes: {
attached() {
this.setData({
canvasId: this.properties.id
});
// 创建初始化Promise
this.data.canvasInitPromise = new Promise((resolve) => {
this._canvasReadyResolver = resolve;
});
},
ready() {
// 在组件准备好后初始化canvas
this.initCanvas();
},
detached() {
this.reset();
}
},
methods: {
initCanvas() {
const query = this.createSelectorQuery();
query.select(`#${this.data.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
if (res && res[0]) {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
// 设置canvas大小
canvas.width = this.properties.width;
canvas.height = this.properties.height;
// 优化真机显示 - 考虑设备像素比
try {
const deviceInfo = wx.getDeviceInfo();
const dpr = deviceInfo.pixelRatio || 1;
canvas.width = this.properties.width * dpr;
canvas.height = this.properties.height * dpr;
ctx.scale(dpr, dpr);
} catch (e) {
console.error('设置DPR失败', e);
}
this.canvas = canvas;
this.ctx = ctx;
// 标记canvas已准备好
this.setData({ isCanvasReady: true });
// 解析初始化Promise
if (this._canvasReadyResolver) {
this._canvasReadyResolver();
}
console.log('Canvas 已初始化完成', this.canvas.width, this.canvas.height);
} else {
console.error('Canvas 节点未找到', this.data.canvasId);
// 如果找不到canvas200ms后重试
setTimeout(() => {
this.initCanvas();
}, 200);
}
});
},
// 等待canvas初始化完成
async waitForCanvasReady() {
if (this.data.isCanvasReady && this.canvas && this.ctx) {
return true;
}
try {
await this.data.canvasInitPromise;
return true;
} catch (err) {
console.error('Canvas 初始化失败', err);
return false;
}
},
// 核心方法,触发五彩纸屑效果
async fire(options = {}) {
// 等待canvas初始化完成
const isReady = await this.waitForCanvasReady();
if (!isReady || !this.canvas || !this.ctx) {
console.error('Canvas 未初始化');
return Promise.reject('Canvas 未初始化');
}
return this.fireConfetti(options);
},
// 重置,停止当前动画
reset() {
this.cancelAnimation();
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.setData({
animatingFettis: []
});
},
// 取消动画帧
cancelAnimation() {
if (this.data.animationFrameId) {
if (wx.canIUse('cancelAnimationFrame')) {
cancelAnimationFrame(this.data.animationFrameId);
} else {
clearTimeout(this.data.animationFrameId);
}
this.setData({
animationFrameId: null
});
}
},
// 请求动画帧
requestFrame(callback) {
if (wx.canIUse('requestAnimationFrame')) {
return requestAnimationFrame(callback);
} else {
return setTimeout(callback, 1000 / 60);
}
},
// 转换属性
convert(val, transform) {
return transform ? transform(val) : val;
},
// 检查值是否有效
isOk(val) {
return !(val === null || val === undefined);
},
// 获取配置属性
prop(options, name, transform) {
return this.convert(
options && this.isOk(options[name]) ? options[name] : this.data.defaults[name],
transform
);
},
// 将十六进制颜色转为RGB
hexToRgb(str) {
const val = String(str).replace(/[^0-9a-f]/gi, '');
const hex = val.length < 6
? val[0] + val[0] + val[1] + val[1] + val[2] + val[2]
: val;
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
};
},
// 颜色数组转RGB
colorsToRgb(colors) {
return colors.map(color => this.hexToRgb(color));
},
// 只接受正整数
onlyPositiveInt(number) {
return number < 0 ? 0 : Math.floor(number);
},
// 生成随机整数
randomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
},
// 获取原点配置
getOrigin(options) {
const origin = this.prop(options, 'origin', Object) || {};
origin.x = this.isOk(origin.x) ? origin.x : this.data.defaults.x;
origin.y = this.isOk(origin.y) ? origin.y : this.data.defaults.y;
return origin;
},
// 创建随机物理属性
randomPhysics(opts) {
const radAngle = opts.angle * (Math.PI / 180);
const radSpread = opts.spread * (Math.PI / 180);
return {
x: opts.x,
y: opts.y,
wobble: Math.random() * 10,
wobbleSpeed: Math.min(0.11, Math.random() * 0.1 + 0.05),
velocity: (opts.startVelocity * 0.5) + (Math.random() * opts.startVelocity),
angle2D: -radAngle + ((0.5 * radSpread) - (Math.random() * radSpread)),
tiltAngle: (Math.random() * (0.75 - 0.25) + 0.25) * Math.PI,
color: opts.color,
shape: opts.shape,
tick: 0,
totalTicks: opts.ticks,
decay: opts.decay,
drift: opts.drift,
random: Math.random() + 2,
tiltSin: 0,
tiltCos: 0,
wobbleX: 0,
wobbleY: 0,
gravity: opts.gravity * 3,
ovalScalar: 0.6,
scalar: opts.scalar,
flat: opts.flat
};
},
// 微信小程序不支持Path2D和标准的ellipse创建一个ellipse方法
ellipse(context, x, y, radiusX, radiusY, rotation, startAngle, endAngle) {
context.save();
context.translate(x, y);
context.rotate(rotation);
context.scale(radiusX, radiusY);
context.arc(0, 0, 1, startAngle, endAngle, false);
context.restore();
},
// 更新单个五彩纸屑
updateFetti(context, fetti) {
fetti.x += Math.cos(fetti.angle2D) * fetti.velocity + fetti.drift;
fetti.y += Math.sin(fetti.angle2D) * fetti.velocity + fetti.gravity;
fetti.velocity *= fetti.decay;
if (fetti.flat) {
fetti.wobble = 0;
fetti.wobbleX = fetti.x + (10 * fetti.scalar);
fetti.wobbleY = fetti.y + (10 * fetti.scalar);
fetti.tiltSin = 0;
fetti.tiltCos = 0;
fetti.random = 1;
} else {
fetti.wobble += fetti.wobbleSpeed;
fetti.wobbleX = fetti.x + ((10 * fetti.scalar) * Math.cos(fetti.wobble));
fetti.wobbleY = fetti.y + ((10 * fetti.scalar) * Math.sin(fetti.wobble));
fetti.tiltAngle += 0.1;
fetti.tiltSin = Math.sin(fetti.tiltAngle);
fetti.tiltCos = Math.cos(fetti.tiltAngle);
fetti.random = Math.random() + 2;
}
const progress = (fetti.tick++) / fetti.totalTicks;
const x1 = fetti.x + (fetti.random * fetti.tiltCos);
const y1 = fetti.y + (fetti.random * fetti.tiltSin);
const x2 = fetti.wobbleX + (fetti.random * fetti.tiltCos);
const y2 = fetti.wobbleY + (fetti.random * fetti.tiltSin);
context.fillStyle = `rgba(${fetti.color.r}, ${fetti.color.g}, ${fetti.color.b}, ${1 - progress})`;
context.beginPath();
if (fetti.shape === 'circle') {
this.ellipse(
context,
fetti.x,
fetti.y,
Math.abs(x2 - x1) * fetti.ovalScalar,
Math.abs(y2 - y1) * fetti.ovalScalar,
Math.PI / 10 * fetti.wobble,
0,
2 * Math.PI
);
} else if (fetti.shape === 'star') {
let rot = Math.PI / 2 * 3;
const innerRadius = 4 * fetti.scalar;
const outerRadius = 8 * fetti.scalar;
const x = fetti.x;
const y = fetti.y;
let spikes = 5;
const step = Math.PI / spikes;
while (spikes--) {
let xTemp = x + Math.cos(rot) * outerRadius;
let yTemp = y + Math.sin(rot) * outerRadius;
context.lineTo(xTemp, yTemp);
rot += step;
xTemp = x + Math.cos(rot) * innerRadius;
yTemp = y + Math.sin(rot) * innerRadius;
context.lineTo(xTemp, yTemp);
rot += step;
}
} else {
// square (default)
context.moveTo(Math.floor(fetti.x), Math.floor(fetti.y));
context.lineTo(Math.floor(fetti.wobbleX), Math.floor(y1));
context.lineTo(Math.floor(x2), Math.floor(y2));
context.lineTo(Math.floor(x1), Math.floor(fetti.wobbleY));
}
context.closePath();
context.fill();
return fetti.tick < fetti.totalTicks;
},
// 动画函数
animate(fettis) {
const animatingFettis = [...fettis];
const context = this.ctx;
const canvas = this.canvas;
const update = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
const stillAlive = [];
for (let i = 0; i < animatingFettis.length; i++) {
if (this.updateFetti(context, animatingFettis[i])) {
stillAlive.push(animatingFettis[i]);
}
}
if (stillAlive.length) {
this.setData({
animatingFettis: stillAlive,
animationFrameId: this.requestFrame(() => update())
});
} else {
this.setData({
animatingFettis: [],
animationFrameId: null
});
}
};
this.setData({
animationFrameId: this.requestFrame(() => update())
});
},
// 发射五彩纸屑
fireConfetti(options) {
return new Promise((resolve) => {
const particleCount = this.prop(options, 'particleCount', this.onlyPositiveInt.bind(this));
const angle = this.prop(options, 'angle', Number);
const spread = this.prop(options, 'spread', Number);
const startVelocity = this.prop(options, 'startVelocity', Number);
const decay = this.prop(options, 'decay', Number);
const gravity = this.prop(options, 'gravity', Number);
const drift = this.prop(options, 'drift', Number);
const colors = this.prop(options, 'colors', this.colorsToRgb.bind(this));
const ticks = this.prop(options, 'ticks', Number);
const shapes = this.prop(options, 'shapes');
const scalar = this.prop(options, 'scalar');
const flat = !!this.prop(options, 'flat');
const origin = this.getOrigin(options);
let temp = particleCount;
const fettis = [];
const startX = this.canvas.width * origin.x;
const startY = this.canvas.height * origin.y;
while (temp--) {
fettis.push(
this.randomPhysics({
x: startX,
y: startY,
angle: angle,
spread: spread,
startVelocity: startVelocity,
color: colors[temp % colors.length],
shape: shapes[this.randomInt(0, shapes.length)],
ticks: ticks,
decay: decay,
gravity: gravity,
drift: drift,
scalar: scalar,
flat: flat
})
);
}
// 合并已有和新的五彩纸屑
const allFettis = [...this.data.animatingFettis, ...fettis];
this.setData({
animatingFettis: allFettis
}, () => {
// 如果已经有动画在运行,不需要再次启动
if (!this.data.animationFrameId) {
this.animate(allFettis);
}
resolve();
});
});
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<canvas type="2d" id="{{canvasId}}" style="width: {{width}}px; height: {{height}}px;"></canvas>

View File

@@ -21,7 +21,14 @@ Component({
const raw = String(word || '')
const cleaned = raw.replace(/[.,?!*;:'"()]/g, '').trim()
if (!cleaned) return
self.setData({ visible: true, loading: true })
// Clear previous word data before showing loading state
self.setData({
visible: true,
loading: true,
wordDict: {},
prototypeWord: '',
isWordEmptyResult: false
})
try {
const detail: any = await apiManager.getWordDetail(cleaned)
const collins = detail['collins']
@@ -75,7 +82,7 @@ Component({
const hasPastExam = !!(detail['individual'] && detail['individual'].pastExamSents && detail['individual'].pastExamSents.length > 0)
const defaultTab = hasCollins ? '0' : (hasPhrs ? '1' : (hasPastExam ? '2' : '3'))
const protoTemp = detail.ee?.word?.['return-phrase']?.['l']?.['i'] || ''
const proto = protoTemp != '' && protoTemp != word ? protoTemp : (detail?.ec?.word?.[0]?.prototype || '')
const proto = protoTemp != '' && protoTemp.toLowerCase() != word.toLowerCase() ? protoTemp : (detail?.ec?.word?.[0]?.prototype || '')
const hideProto = !!self.data.forceHidePrototype
self.setData({
wordDict: {

View File

@@ -178,7 +178,7 @@ type IPageMethods = {
ensureRecordPermission: () => void
onMoreTap: () => void
onSceneSentenceTap: () => void
onImageQaExerciseTap: () => void
onImageQaExerciseTap: (e: any) => void
onSentenceTouchStart: (e: any) => void
onSentenceTouchMove: (e: any) => void
onSentenceTouchEnd: () => void
@@ -1387,7 +1387,9 @@ Page<IPageData, IPageInstance>({
})
recorderManager.onError((res) => {
wx.showToast({ title: '录音失败', icon: 'none' })
logger.error('录音失败', res)
const msg = (res as any)?.errMsg || '录音失败'
wx.showToast({ title: msg, icon: 'none' })
// 录音出错时淡出高亮层
this.setData({ overlayVisible: false, isRecording: false })
const timer = setTimeout(() => {
@@ -1412,15 +1414,16 @@ Page<IPageData, IPageInstance>({
})
},
onImageQaExerciseTap() {
onImageQaExerciseTap(e: any) {
const imageId = this.data.imageId || ''
const type = e?.currentTarget?.dataset?.type;
if (!imageId) {
wx.showToast({ title: '缺少图片ID', icon: 'none' })
return
}
this.setData({ isMoreMenuOpen: false, isMoreMenuClosing: false })
wx.navigateTo({
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}`
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${type ? ('&type=' + type) : ''}`
})
},

View File

@@ -71,7 +71,7 @@
<view class="bottom-more-area {{isMoreMenuOpen ? 'open' : (isMoreMenuClosing ? 'close' : '')}}" wx:if="{{isMoreMenuOpen || isMoreMenuClosing}}">
<view class="more-items">
<view class="more-item" bindtap="onSceneSentenceTap">场景句型</view>
<view class="more-item" bindtap="onImageQaExerciseTap">场景练习</view>
<view class="more-item" bindtap="onImageQaExerciseTap" data-type="cloze">场景练习</view>
<!-- <view class="more-item">功能3</view> -->
</view>
</view>

View File

@@ -4,6 +4,7 @@ import logger from '../../utils/logger'
Page({
data: {
products: [] as Array<any>,
plans: [] as Array<any>,
userPoints: 0,
displayUserPoints: '0',
vipLevel: 0
@@ -11,11 +12,20 @@ Page({
async onLoad(options: Record<string, string>) {
logger.info('Coupon page loaded')
try {
const list = await apiManager.getProductList()
const products = (list || []).map((p: any) => ({
const [productList, planList] = await Promise.all([
apiManager.getProductList(),
apiManager.getSubscriptionPlans()
])
const products = (productList || []).map((p: any) => ({
...p,
amountYuan: ((p.amount_cents || 0) / 100).toFixed(2)
}))
const plans = (planList || []).map((p: any) => ({
...p,
priceYuan: ((p.price || 0) / 100).toFixed(2)
}))
const ptsStr = options?.points || '0'
const lvlStr = options?.vipLevel || '0'
const ptsNum = Number(ptsStr) || 0
@@ -26,6 +36,7 @@ Page({
}
this.setData({
products,
plans,
userPoints: ptsNum,
displayUserPoints: fmt(ptsNum),
vipLevel: lvlNum
@@ -35,6 +46,59 @@ Page({
wx.showToast({ title: '加载失败', icon: 'none' })
}
},
async handlePlanTap(e: any) {
try {
const productId = e?.currentTarget?.dataset?.id
if (!productId) {
wx.showToast({ title: '订阅商品信息错误', icon: 'none' })
return
}
const order = await apiManager.createSubscriptionOrder(String(productId))
const prepayId = order?.prepay_id
const outTradeNo = order?.out_trade_no
if (!prepayId) {
wx.showToast({ title: '下单失败', icon: 'none' })
return
}
const pkg = `prepay_id=${prepayId}`
const timeStamp = order?.timeStamp
const nonceStr = order?.nonceStr
const paySign = order?.paySign
const signType = (order?.signType || 'RSA') as any
if (timeStamp && nonceStr && paySign) {
wx.requestPayment({
timeStamp,
nonceStr,
package: pkg,
signType,
paySign,
success: async () => {
if (outTradeNo) {
await this.queryPaymentWithRetry(outTradeNo)
} else {
wx.showToast({ title: '订阅成功', icon: 'success' })
}
},
fail: async (err) => {
logger.error('订阅支付失败', err)
if (outTradeNo) {
await this.queryPaymentWithRetry(outTradeNo)
} else {
wx.showToast({ title: '订阅支付失败', icon: 'none' })
}
}
})
} else {
wx.showToast({ title: '缺少支付签名参数', icon: 'none' })
}
} catch (error) {
logger.error('发起订阅支付失败', error)
wx.showToast({ title: '发起订阅支付失败', icon: 'none' })
}
},
async handleCouponTap(e: any) {
try {
const productId = e?.currentTarget?.dataset?.id
@@ -55,7 +119,7 @@ Page({
const timeStamp = order?.timeStamp
const nonceStr = order?.nonceStr
const paySign = order?.paySign
const signType = order?.signType || 'RSA'
const signType = (order?.signType || 'RSA') as any
if (timeStamp && nonceStr && paySign) {
wx.requestPayment({

View File

@@ -1,16 +1,19 @@
<!--coupon.wxml-->
<view class="coupon-container">
<view class="coupon_title">
订阅计划
</view>
<view class="card-box">
<view class="card" wx:for="{{plans}}" wx:key="id" wx:for-item="plan" bindtap="handlePlanTap" data-id="{{plan.id}}">
<view class="card-title">{{plan.name}}</view>
<view class="card-points">{{plan.features && plan.features.label ? plan.features.label : ''}}</view>
<view class="card-credits">{{plan.features && plan.features.extra ? plan.features.extra : ''}}</view>
<view class="card-price">¥{{plan.priceYuan}}</view>
</view>
</view>
<view class="coupon_title">
获取更多积分
</view>
<!-- <view class='coupon_box {{item.one_time ? "one_time" : ""}}' wx:for="{{products}}" wx:key="id" wx:for-item="item">
<view class='content' bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class='title'>{{item.title}}</view>
<view class='how_much'>{{item.points}}</view>
</view>
<view class='btn'> ¥{{item.amountYuan}}</view>
</view> -->
<view class="card-box">
<view class="card" wx:for="{{products}}" wx:key="id" wx:for-item="item" bindtap="handleCouponTap" data-id="{{item.id}}" data-points="{{item.points}}">
<view class="card-title">{{item.title}}</view>
@@ -26,6 +29,7 @@
<view class="tips-title">积分说明</view>
</view>
<view class="tips-list">
<view class="tips-item">订阅期间不消耗积分。</view>
<view class="tips-item">积分购买后永久有效。</view>
<!-- <view class="tips-item"></view> -->
</view>

View File

@@ -23,7 +23,9 @@ Page({
frozen_balance: 0,
total_purchased: 0,
total_refunded: 0,
expired_time: ''
expired_time: '',
is_subscribed: false,
subscription_expires_at: null as string | null
},
// 兑换码弹窗显示控制
@@ -316,7 +318,7 @@ Page({
const pointsData = await apiManager.getPointsData()
// 如果返回的数据为null则设置默认值
const finalPointsData = pointsData || { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '' }
const finalPointsData = pointsData || { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '', is_subscribed: false, subscription_expires_at: null }
const endTime = Date.now()
logger.info('积分数据加载完成,耗时:', endTime - startTime, 'ms', finalPointsData)
@@ -331,12 +333,31 @@ Page({
frozen_balance: 0,
total_purchased: 0,
total_refunded: 0,
expired_time: ''
expired_time: '',
is_subscribed: false,
subscription_expires_at: null as string | null
}
})
}
},
formatDate(dateStr: string | null) {
if (!dateStr) {
return ''
}
const date = new Date(dateStr)
const time = date.getTime()
if (isNaN(time)) {
return dateStr || ''
}
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const mm = month < 10 ? '0' + month : '' + month
const dd = day < 10 ? '0' + day : '' + day
return `${year}-${mm}-${dd}`
},
// 加载缓存统计信息
loadCacheStats() {
return new Promise<void>((resolve) => {

View File

@@ -69,11 +69,11 @@
maxcharacter="{{6}}"
/>
</t-dialog>
<navigator url="/pages/coupon/coupon?points={{points.available_balance}}" class="cell-navigator">
<t-cell title="积分" hover note="{{points.available_balance}}">
<t-icon slot="left-icon" name="star" size="44rpx"></t-icon>
</t-cell>
</navigator>
<navigator url="/pages/coupon/coupon?points={{points.available_balance}}" class="cell-navigator">
<t-cell title="{{points.is_subscribed ? '订阅到期时间' : '积分'}}" hover note="{{points.is_subscribed ? points.subscription_expires_at : points.available_balance}}">
<t-icon slot="left-icon" name="star" size="44rpx"></t-icon>
</t-cell>
</navigator>
<navigator url="/pages/order/order" class="cell-navigator">
<t-cell title="订单记录" hover arrow>
<t-icon slot="left-icon" name="shop" size="44rpx"></t-icon>

View File

@@ -1,5 +1,5 @@
{
"navigationBarTitleText": "问答练习",
"navigationBarTitleText": "场景练习",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff",
@@ -8,6 +8,16 @@
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
"word-dictionary": "../../components/word-dictionary/word-dictionary"
"t-cell": "tdesign-miniprogram/cell/cell",
"word-dictionary": "../../components/word-dictionary/word-dictionary",
"vx-confetti": "/components/vx-confetti/vx-confetti",
"cloud-image": "../../components/cloud-image/cloud-image",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-check-tag": "tdesign-miniprogram/check-tag/check-tag",
"t-drawer": "tdesign-miniprogram/drawer/drawer",
"t-chat": "tdesign-miniprogram/chat-list/chat-list",
"t-chat-sender": "tdesign-miniprogram/chat-sender/chat-sender",
"t-chat-loading": "tdesign-miniprogram/chat-loading/chat-loading",
"t-chat-message": "tdesign-miniprogram/chat-message/chat-message"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<view class="qa-exercise-container">
<view wx:if="{{!contentReady}}" class="page-loading-mask">
<view wx:if="{{!contentReady || loadingMaskVisible}}" class="page-loading-mask">
<view class="loading-center">
<view class="scanner scanner-visible">
<view class="star star1"></view>
@@ -10,79 +11,349 @@
</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0}}">
<view class="type-container" wx:if="{{fixedMode}}">
<view class="type-item {{fixedMode === 'cloze' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="cloze">填空</view>
<view class="type-item {{fixedMode === 'choice' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="choice">问答</view>
<view class="type-item {{fixedMode === 'variation' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="variation">识图</view>
<view class="type-item {{fixedMode === 'conversation' ? 'active' : ''}}" hover-class="type-item-hover" bindtap="switchMode" data-mode="conversation">对话</view>
</view>
<view class="container {{contentVisible ? 'fade-in' : 'fade-out'}}" wx:if="{{contentReady && !loadingMaskVisible}}">
<view class="process-container" wx:if="{{qaList && qaList.length > 0 && questionMode !== 'conversation'}}">
<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 class="question-scroll-wrapper {{questionMode === 'conversation' && conversationViewMode === 'chat' && isChatInputVisible ? 'chat-input-mode-scroll' : 'chat-mode-scroll'}}">
<scroll-view class="inner-scroll" scroll-y >
<view class="image-card" wx:if="{{questionMode !== 'variation'}}">
<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" />
</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 class="question-title" wx:if="{{questionMode !== 'conversation'}}">
<text wx:for="{{questionWords}}" wx:key="index" class="word-item" data-word="{{item}}" bindtap="handleWordClick">{{item}}</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 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' : ''}} {{resultDisplayed ? '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>
<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 class="question-content {{modeAnim}}" wx:if="{{questionMode === 'cloze'}}">
<view class="cloze-sentence">
<text wx:for="{{clozeSentenceTokens}}" wx:key="index" class="{{item.isBlank ? 'cloze-fill' : 'cloze-text'}}" data-word="{{item.word}}" bindtap="handleWordClick">{{item.text}}</text>
</view>
<view class="option-list">
<view wx:for="{{clozeOptions}}" wx:key="index" class="option-item {{evalClasses[index]}} {{resultDisplayed ? 'disabled' : ''}}" 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="answer-row">
<text class="answer-label">Your Answer</text>
<text class="answer-hint" bindtap="onHintTap">Need a hint?</text>
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'variation'}}">
<view class="variation-container">
<view class="variation-grid">
<view class="variation-item" wx:for="{{variationQaList}}" wx:key="index">
<view class="variation-image-wrapper {{index === variationSelectedIndex ? 'selected' : ''}}" data-index="{{index}}" bindtap="selectVariationOption">
<cloud-image
file-id="{{item.file_id}}"
mode="widthFix"
height="auto"
radius="24rpx"
bind:load="onVariationImageLoad"
data-fileid="{{item.file_id}}"
/>
<view class="view-full" wx:if="{{variationImageLoaded[item.file_id]}}" data-fileid="{{item.file_id}}" catchtap="previewVariationImage">
<t-icon name="zoom-in" size="32rpx" />
</view>
<view wx:if="{{variationImageLoaded[item.file_id]}}" class="selection-badge {{variationResultStatus === 'incorrect' && index === variationSelectedIndex ? 'incorrect' : (index === variationSelectedIndex ? 'selected' : 'unselected')}}">
<t-icon name="{{variationResultStatus === 'incorrect' && index === variationSelectedIndex ? 'close-circle' : 'check-circle'}}" size="36rpx" color="#fff" />
</view>
<view wx:if="{{variationResultStatus && index === variationSelectedIndex}}" class="variation-border {{variationResultStatus}}"></view>
</view>
<!-- <view class="variation-text">{{item.question}}</view> -->
</view>
</view>
</view>
</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="conversation-content {{modeAnim}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'setup'}}">
<view class="conversation-section">
<view class="conversation-label">难度选择</view>
<view class="conversation-tags">
<t-check-tag
wx:for="{{difficultyOptions}}"
wx:key="value"
variant="outline"
size="medium"
data-level="{{item.value}}"
checked="{{conversationDifficulty === item.value}}"
bindtap="selectConversationDifficulty"
content="{{ [conversationSceneLang === 'zh' ? item.label_zh : item.label_en] }}"
/>
</view>
</view>
<view class="conversation-section">
<view class="conversation-label">场景标签</view>
<view class="conversation-tags" wx:if="{{conversationSetting && conversationSetting.all_possible_scenes && conversationSetting.all_possible_scenes.length}}">
<t-check-tag
wx:for="{{conversationSetting.all_possible_scenes}}"
wx:key="scene_en"
variant="outline"
size="medium"
data-scene="{{item.scene_en}}"
checked="{{conversationSelectedScenesMap && conversationSelectedScenesMap[item.scene_en]}}"
bindtap="toggleConversationScene"
>
{{conversationSceneLang === 'zh' ? item.scene_zh : item.scene_en}}
</t-check-tag>
<t-check-tag
wx:for="{{conversationCustomScenes}}"
wx:key="key"
variant="outline"
size="medium"
data-scene="{{item.key}}"
checked="{{conversationSelectedScenesMap && conversationSelectedScenesMap[item.key]}}"
bindtap="toggleConversationScene"
>
<view class="custom-scene-tag">
<text class="custom-scene-text">{{item.text}}</text>
<t-icon name="close" size="24rpx" data-key="{{item.key}}" catchtap="onConversationCustomSceneDelete" />
</view>
</t-check-tag>
<view wx:if="{{conversationCustomSceneEditing}}" class="conversation-custom-scene-input-wrapper">
<input
class="conversation-custom-scene-input {{conversationCustomSceneOverLimit ? 'conversation-custom-scene-input-error' : ''}}"
value="{{conversationCustomSceneText}}"
placeholder="自定义场景"
maxlength="60"
focus="{{true}}"
bindinput="onConversationCustomSceneInput"
bindblur="onConversationCustomSceneBlur"
/>
</view>
<t-check-tag
wx:if="{{!conversationCustomSceneEditing}}"
variant="outline"
size="medium"
bindtap="onConversationCustomSceneAdd"
>
+
</t-check-tag>
</view>
</view>
<view class="conversation-section">
<view class="conversation-label">事件标签</view>
<view class="conversation-tags" wx:if="{{conversationSetting && conversationSetting.all_possible_events && conversationSetting.all_possible_events.length}}">
<t-check-tag
wx:for="{{conversationSetting.all_possible_events}}"
wx:key="event_en"
variant="outline"
size="medium"
data-event="{{item.event_en}}"
checked="{{conversationSelectedEventsMap && conversationSelectedEventsMap[item.event_en]}}"
bindtap="toggleConversationEvent"
>
{{conversationSceneLang === 'zh' ? item.event_zh : item.event_en}}
</t-check-tag>
<t-check-tag
wx:for="{{conversationCustomEvents}}"
wx:key="key"
variant="outline"
size="medium"
data-event="{{item.key}}"
checked="{{conversationSelectedEventsMap && conversationSelectedEventsMap[item.key]}}"
bindtap="toggleConversationEvent"
>
<view class="custom-scene-tag">
<text class="custom-scene-text">{{item.text}}</text>
<t-icon name="close" size="24rpx" data-key="{{item.key}}" catchtap="onConversationCustomEventDelete" />
</view>
</t-check-tag>
<view wx:if="{{conversationCustomEventEditing}}" class="conversation-custom-scene-input-wrapper">
<input
class="conversation-custom-scene-input {{conversationCustomEventOverLimit ? 'conversation-custom-scene-input-error' : ''}}"
value="{{conversationCustomEventText}}"
placeholder="自定义事件"
maxlength="60"
focus="{{true}}"
bindinput="onConversationCustomEventInput"
bindblur="onConversationCustomEventBlur"
/>
</view>
<t-check-tag
wx:if="{{!conversationCustomEventEditing}}"
variant="outline"
size="medium"
bindtap="onConversationCustomEventAdd"
>
+
</t-check-tag>
</view>
</view>
<view class="conversation-section" wx:if="{{conversationSuggestedRoles && conversationSuggestedRoles.length}}">
<view class="conversation-label">角色扮演</view>
<view class="conversation-tags">
<block wx:for="{{conversationSuggestedRoles}}" wx:key="key" wx:for-index="idx">
<view style="display: flex; align-items: center;">
<t-check-tag
checked="{{selectedRole && selectedRole.roleIndex === idx && selectedRole.roleSide === 1}}"
bind:change="onRoleSelect"
data-index="{{idx}}"
data-side="{{1}}"
variant="outline"
size="medium"
>{{conversationSceneLang === 'zh' ? item.role1_zh : item.role1_en}}</t-check-tag>
<t-check-tag
checked="{{selectedRole && selectedRole.roleIndex === idx && selectedRole.roleSide === 2}}"
bind:change="onRoleSelect"
data-index="{{idx}}"
data-side="{{2}}"
variant="outline"
size="medium"
>{{conversationSceneLang === 'zh' ? item.role2_zh : item.role2_en}}</t-check-tag>
</view>
</block>
</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 class="conversation-section">
<view class="conversation-label-row">
<text class="conversation-label">额外说明</text>
<text class="conversation-count">{{(conversationExtraNote && conversationExtraNote.length) || 0}}/200</text>
</view>
<view class="conversation-note-card">
<textarea
class="conversation-note-input"
placeholder="例如:描述一下图片中的人物关系或具体发生的事件,这将帮助 AI 更好地生成对话内容..."
maxlength="200"
value="{{conversationExtraNote}}"
bindinput="onConversationNoteInput"
/>
</view>
</view>
<view class="conversation-start-row">
<button class="conversation-start-btn" bindtap="onStartConversationTap">
开始对话
<t-icon name="chat" size="32rpx" style="margin-left: 12rpx;" />
</button>
</view>
</view>
<view class="conversation-content {{modeAnim}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
<block wx:if="{{conversationMessages && conversationMessages.length}}">
<view class="conversation-block {{item.role === 'user' ? 'user' : 'default'}}" wx:for="{{conversationMessages}}" wx:key="index"
>
<t-chat-message
role="{{item.role}}"
placement="{{item.role === 'user' ? 'right' : 'left'}}"
content="{{item.content}}"
/>
</view>
</block>
<view class="conversation-block" wx:if="{{replyLoading}}">
<t-chat-loading animation="dots" />
</view>
<view id="bottom-anchor" style="height: 1rpx;"></view>
</view>
<view class="submit-row" wx:if="{{questionMode !== 'conversation'}}">
<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>
</scroll-view>
</view>
<view class="bottom-bar {{contentVisible ? 'show' : ''}}">
<view class="bottom-bar {{contentVisible ? 'show' : ''}}" wx:if="{{questionMode !== 'conversation'}}">
<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="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" />
<t-icon name="{{nextButtonIcon || 'chevron-right'}}" class="bottom-btn {{(qaList && (currentIndex >= qaList.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
</view>
<t-drawer visible="{{historyVisible}}" placement="left" bind:visible-change="onHistoryDrawerClose" close-btn="{{true}}">
<scroll-view scroll-y class="history-drawer-scroll" bindscrolltolower="onHistoryScrollBottom">
<view class="history-list">
<t-cell
wx:for="{{historyList}}"
wx:key="session_id"
title="{{item.created_at}}"
hover
class="history-item {{conversationLatestSession && conversationLatestSession.session_id === item.session_id ? 'active' : ''}}"
bind:click="chatItemClick"
data-item="{{item}}"
>
<view slot="description" class="history-tags">
<block wx:for="{{item.scene}}" wx:for-item="scene" wx:key="index">
<view class="history-tag" wx:if="{{scene.en || scene.zh}}">
{{conversationSceneLang === 'zh' ? (scene.zh || scene.en) : (scene.en || scene.zh)}}
</view>
</block>
<block wx:for="{{item.event}}" wx:for-item="ev" wx:key="index">
<view class="history-tag" wx:if="{{ev.en || ev.zh}}">
{{conversationSceneLang === 'zh' ? (ev.zh || ev.en) : (ev.en || ev.zh)}}
</view>
</block>
</view>
</t-cell>
<view class="history-loading" wx:if="{{historyLoading}}">
<t-loading theme="spinner" size="40rpx" text="加载中..." />
</view>
<view class="history-no-more" wx:if="{{!historyHasMore && historyList.length > 0}}">没有更多内容</view>
<view class="history-no-more" wx:if="{{!historyHasMore && historyList.length === 0}}">暂无历史记录</view>
</view>
</scroll-view>
</t-drawer>
<view class="chat-sender-wrapper {{isChatInputVisible ? 'show' : ''}}" style="bottom: {{inputBottom}}px" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
<view class="suggestion-bar {{showChatSuggestions ? 'show' : ''}}"
wx:if="{{chatSuggestions.length > 0}}"
bind:touchstart="handleSuggestionTouchStart"
bind:touchend="handleSuggestionTouchEnd">
<scroll-view scroll-x class="suggestion-scroll" enable-flex>
<view class="suggestion-item" wx:for="{{chatSuggestions}}" wx:key="index" bindtap="handleSuggestionTap" data-text="{{item.text}}">
{{item.label}}
</view>
</scroll-view>
</view>
<t-chat-sender
value="{{chatInputValue}}"
placeholder="{{chatPlaceholder}}"
loading="{{replyLoading}}"
focus="{{isChatInputVisible}}"
renderPresets="{{renderPresets}}"
bind:send="onSendMessage"
bind:input="onChatInput"
bind:blur="onChatBlur"
>
<view slot="footer-prefix" class="footer-prefix">
<view class="chat-icon-block" bind:tap="onChatCloseTap" style="margin-right: 16rpx;">
<t-icon name="close-circle" size="64rpx" color="#dcdcdc"/>
</view>
<view class="chat-icon-block" bind:touchstart="handleRecordStart" bind:touchend="handleRecordEnd">
<t-icon name="microphone-1" size="64rpx" color="{{isRecording ? '#0052d9' : '#dcdcdc'}}"/>
</view>
</view>
</t-chat-sender>
</view>
<view class="bottom-bar {{contentVisible && !isChatInputVisible ? 'show' : ''}}" wx:if="{{questionMode === 'conversation'}}">
<view class="bottom-btn bottom-button-img-wrap" bind:tap="toggleConversationSceneLang">
<t-icon name="translate" class="trans-button left-half {{conversationSceneLang === 'en' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
<t-icon name="translate" class="trans-button right-half {{conversationSceneLang === 'zh' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
</view>
<t-icon name="keyboard" class="bottom-btn {{conversationLatestSession && conversationViewMode !== 'setup' ? '' : 'disabled'}}" size="48rpx" bind:tap="showChatInput" />
<t-icon name="{{conversationLatestSession && conversationViewMode === 'chat' ? 'chat-bubble-add' : 'chat-bubble-1'}}" class="bottom-btn {{conversationLatestSession ? '' : 'disabled'}}" size="48rpx" bind:tap="toggleConversationView" />
<t-icon name="fact-check" class="bottom-btn {{resultDisplayed ? '' : 'disabled'}}" size="48rpx" bind:tap="" />
<t-icon name="chat-bubble-history" class="bottom-btn" size="48rpx" bind:tap="onHistoryTap" />
</view>
<word-dictionary
@@ -113,7 +384,7 @@
<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">
<scroll-view class="detail-body" scroll-y="{{true}}">
<view class="section">
<text class="question-text">{{qaDetailQuestionText}}</text>
</view>
@@ -196,4 +467,5 @@
</button>
</view>
</view>
<vx-confetti id="confetti" class="confetti-canvas" width="{{canvasWidth}}" height="{{canvasHeight}}"></vx-confetti>
</view>

View File

@@ -1,16 +1,94 @@
.qa-exercise-container { min-height: 100vh; background: #ffffff; }
.qa-exercise-container {
height: 100vh;
background: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.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; }
.container {
width: 100%;
flex: 1;
height: 0;
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));}
.question-scroll-wrapper {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
/* padding-bottom: calc(110rpx + env(safe-area-inset-bottom)); */
/* transition: padding-bottom 0.3s ease; */
transition: margin-bottom 0.3s ease;
}
.inner-scroll {
position: absolute;
top: 0;
left: 0;
width: 100%;
/* height: 100%; */
height: 500rpx;
}
.chat-mode-scroll {
/* padding-bottom: calc(110rpx + env(safe-area-inset-bottom)); */
margin-bottom: calc(110rpx + env(safe-area-inset-bottom));
}
.chat-input-mode-scroll {
/* padding-bottom: calc(240rpx + env(safe-area-inset-bottom)); */
margin-bottom: calc(240rpx + env(safe-area-inset-bottom));
}
.loading-container { padding: 32rpx; }
.chat-sender-wrapper {
position: fixed;
left: 0;
right: 0;
bottom: 0;
/* bottom: calc(120rpx + env(safe-area-inset-bottom)); */
z-index: 100;
background: #fff;
/* padding: 16rpx 32rpx; */
border-top: 1rpx solid #f0f4f8;
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.04);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
pointer-events: none;
}
.chat-sender-wrapper.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.footer-prefix {
position: absolute;
right: 80rpx;
display: flex;
align-items: center;
}
.chat-icon-block {
color: var(--td-text-color-primary);
border-radius: 200rpx;
border: 2rpx solid var(--td-component-border);
display: flex;
justify-content: center;
align-items: center;
}
.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; }
@@ -46,12 +124,12 @@
z-index: 99;
}
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; }
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; box-shadow: none; }
.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;}
.view-full { position: absolute; right: 16rpx; bottom: 16rpx; display: flex; align-items: center; gap: 8rpx; padding: 8rpx; border-radius: 24rpx; background: rgba(0,0,0,0.4); color: #fff; }
.question-title { font-size: 40rpx; 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; }
.word-item { display: inline; font-size: 40rpx; 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; }
@@ -60,12 +138,12 @@
.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; }
.option-radio { width: 36rpx; height: 36rpx; min-width: 36rpx; min-height: 36rpx; max-width: 36rpx; max-height: 36rpx; flex: 0 0 36rpx; flex-shrink: 0; align-self: center; box-sizing: border-box; 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; }
.option-text { flex: 1; min-width: 0; font-size: 30rpx; color: #001858; line-height: 44rpx; }
.cloze-sentence { display: flex; flex-wrap: wrap; gap: 8rpx; align-items: baseline; margin-bottom: 24rpx; }
.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; }
@@ -258,6 +336,133 @@
border-radius: 16rpx;
background: #f8f9fb;
}
.conversation-content {
display: flex;
flex-direction: column;
gap: 32rpx;
padding-top: 16rpx;
}
.conversation-block.user {
--td-bg-color-secondarycontainer: #f5f5f5;
--td-spacer-2: 0rpx;
}
.conversation-block.default {
--td-spacer-2: 12rpx;
width: 85%;
background-color: #e0e0e0;
padding: 12rpx 20rpx 0 20rpx;
border-radius: 0 24rpx 24rpx 24rpx;
}
.conversation-section {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.conversation-label {
font-size: 30rpx;
font-weight: 700;
color: #001858;
}
.conversation-segment {
display: flex;
background: #f5f7fb;
border-radius: 999rpx;
padding: 6rpx;
}
.segment-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
border-radius: 999rpx;
}
.segment-item.active {
background: #ffffff;
color: #00b578;
font-weight: 700;
}
.conversation-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.conversation-custom-scene-input-wrapper {
min-width: 160rpx;
}
.conversation-custom-scene-input {
padding: 0rpx 15rpx;
border-radius: 8rpx;
border: 2rpx dashed #00b578;
background: #f5f7fb;
font-size: 24rpx;
color: #001858;
}
.conversation-custom-scene-input-error {
border-color: #ff4d4f;
}
.custom-scene-tag {
display: inline-flex;
align-items: center;
gap: 8rpx;
}
.custom-scene-text {
font-size: 26rpx;
}
.tag-item {
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #333;
background: #f5f7fb;
}
.tag-item.active {
background: #e6fff4;
color: #00b578;
}
.conversation-label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.conversation-count {
font-size: 24rpx;
color: #999;
}
.conversation-note-card {
border-radius: 24rpx;
background: #f5f7fb;
padding: 20rpx;
}
.conversation-note-input {
width: 100%;
min-height: 200rpx;
font-size: 28rpx;
color: #001858;
}
.conversation-start-row {
margin-top: 24rpx;
}
.conversation-start-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
background: #00b578;
color: #fff;
font-size: 32rpx;
font-weight: 700;
border: none;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.conversation-start-btn::after {
border: none;
}
.detail-row.correct { background: #eafaf2; }
.detail-row.incorrect { background: #fff7f6; }
.detail-row.missing { background: #eaf2ff; }
@@ -348,6 +553,83 @@
border-radius: 4rpx;
}
/* Variation Mode Styles */
.variation-container {
width: 100%;
}
.variation-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
width: 100%;
}
.variation-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.variation-image-wrapper {
position: relative;
width: 100%;
border-radius: 24rpx;
overflow: hidden;
background-color: #f5f5f5;
min-height: 200rpx;
}
.variation-image-wrapper.selected {
}
.variation-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 24rpx;
border-width: 4rpx;
border-style: solid;
box-sizing: border-box;
pointer-events: none;
}
.variation-border.correct {
border-color: #21cc80;
}
.variation-border.incorrect {
border-color: #e34d59;
}
.selection-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.selection-badge.unselected {
background-color: rgba(0,0,0,0.25);
}
.selection-badge.selected {
background-color: #21cc80;
box-shadow: 0 4rpx 12rpx rgba(33, 204, 128, 0.4);
}
.selection-badge.incorrect {
background-color: #e34d59;
box-shadow: 0 4rpx 12rpx rgba(227, 77, 89, 0.3);
}
.variation-text {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.confetti.c1 { background: #FFD700; top: -10rpx; left: 10rpx; transform: rotate(-15deg); }
.confetti.c2 { background: #4facfe; top: 20rpx; right: -10rpx; transform: rotate(25deg); }
.confetti.c3 { background: #a18cd1; bottom: 0; left: -20rpx; transform: rotate(-45deg); }
@@ -397,3 +679,186 @@
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.confetti-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: 9999;
pointer-events: none; /* 确保canvas不会阻挡点击事件 */
}
.type-container {
display: flex;
justify-content: center;
background: #f5f5f5;
border-radius: 40rpx;
padding: 8rpx;
margin-top: 12rpx;
width: fit-content;
position: relative;
left: 50%;
transform: translateX(-50%);
z-index: 100;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.type-item {
padding: 12rpx 48rpx;
border-radius: 32rpx;
font-size: 28rpx;
color: #666;
transition: all 0.3s;
}
.type-item.active {
background: #fff;
color: #333;
font-weight: bold;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1);
}
.type-item-hover {
opacity: 0.7;
background-color: rgba(0,0,0,0.05);
}
.history-drawer-scroll {
height: 100vh;
width: 100%;
box-sizing: border-box;
}
.history-list {
--td-cell-vertical-padding: 12rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
.history-item {
background: #f9f9f9;
}
.history-item:active {
background: #f0f0f0;
}
.history-item.active {
background: #eafaf2;
border-color: #21cc80;
}
.history-title {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
font-weight: 600;
}
.history-item.active .history-title {
color: #0052d9; /* Highlight active session title in blue */
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.history-tag {
font-size: 24rpx;
color: #666;
background: #fff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
border: 1rpx solid #e0e0e0;
}
.history-loading {
display: flex;
justify-content: center;
padding: 24rpx 0;
}
.history-no-more {
text-align: center;
font-size: 24rpx;
color: #999;
padding: 24rpx 0;
}
.suggestion-bar {
height: 0;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
overflow: hidden;
background: #f9f9f9;
}
.suggestion-bar.show {
height: 60rpx;
opacity: 1;
visibility: visible;
padding: 12rpx 0;
}
.suggestion-scroll {
display: flex;
white-space: nowrap;
padding: 0 24rpx;
box-sizing: border-box;
width: 100%;
}
.suggestion-item {
display: block;
background: #fff;
border: 1rpx solid #e7e7e7;
border-radius: 12rpx;
padding: 0 12rpx;
font-size: 26rpx;
color: #333;
margin-right: 16rpx;
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
height: 56rpx;
line-height: 54rpx;
flex-shrink: 0;
box-sizing: border-box;
}
.bottom-button-img-wrap {
width: 46rpx;
height: 46rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.trans-button {
position: absolute;
top: 20rpx;
width: 50%;
height: 100%;
}
.trans-button.left-half {
left: 20rpx;
clip-path: inset(0 50% 0 0);
}
.trans-button.right-half {
right: 20rpx;
clip-path: inset(0 0 0 50%);
}
.trans-button.trans-active {
color: #0096fa;
z-index: 1;
}
.trans-button.trans-deactive {
color: #666;
}

View File

@@ -18,7 +18,7 @@
<text class="sentence-zh">{{scene.list[currentIndex].sentenceZh}}</text>
</view>
<scroll-view class="sentence-body" scroll-y="true" scroll-with-animation="true">
<scroll-view class="sentence-body" scroll-y="{{true}}" scroll-with-animation="true">
<view class="tags-wrap">
<t-tag wx:for="{{scene.list[currentIndex].functionTags}}" wx:key="idx" variant="light"><text>#</text>{{item}}</t-tag>
</view>

View File

@@ -642,6 +642,9 @@ Page({
try {
this.setData({ isProcessing: true })
wx.showLoading({ title: '准备拍照...' })
try {
wx.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (e) {}
// const imagePath = await imageManager.takePhoto({
// quality: 80,
@@ -679,6 +682,9 @@ Page({
try {
this.setData({ isProcessing: true })
wx.showLoading({ title: '准备选择图片...' })
try {
wx.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (e) {}
// const imagePath = await imageManager.chooseFromAlbum({
// quality: 80,
@@ -767,7 +773,6 @@ Page({
}
} catch {}
},
async ensurePhotoPersistent() {
try {
const p = String(this.data.photoPath || '').trim()
@@ -1194,6 +1199,7 @@ Page({
try {
const imageId = e?.currentTarget?.dataset?.imageId;
const thumbnailId = e?.currentTarget?.dataset?.thumbnailId;
const type = e?.currentTarget?.dataset?.type;
if (!imageId) return;
const list = (this.data.selectedDateImages || []).map((img: any) => ({ ...img, more_open: false, menu_closing: false }));
this.setData({ selectedDateImages: list }, () => {
@@ -1202,7 +1208,7 @@ Page({
}
});
wx.navigateTo({
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${thumbnailId ? ('&thumbnail_id=' + encodeURIComponent(thumbnailId)) : ''}`
url: `/pages/qa_exercise/qa_exercise?image_id=${encodeURIComponent(imageId)}${thumbnailId ? ('&thumbnail_id=' + encodeURIComponent(thumbnailId)) : ''}${type ? ('&type=' + type) : ''}`
});
} catch (err) {}
},

View File

@@ -40,7 +40,7 @@
</view>
</view>
</view>
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}">
<view wx:if="{{takePhoto && showExpandLayer }}" class="photo-expand-layer" style="{{photoExpandTransform}} {{photoExpandTransition}}" catchtouchmove="noop">
<!-- <view class="photo-expand-inner" style="{{photoExpandCurrentWidth ? ('width: ' + photoExpandCurrentWidth + 'px;') : ''}} {{photoExpandCurrentHeight ? ('height: ' + photoExpandCurrentHeight + 'px;') : ''}}"> -->
<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>
@@ -92,7 +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 class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{image.image_id}}" data-thumbnail-id="{{image.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>
@@ -114,7 +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 class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>
@@ -133,7 +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 class="more-item-mini" bindtap="onImageQaExerciseTap" data-image-id="{{item.image_id}}" data-thumbnail-id="{{item.thumbnail_file_id}}" data-type="cloze">场景练习</view>
</view>
</view>
</view>

View File

@@ -143,6 +143,54 @@ export interface IQaResult {
updated_time?: string
}
export interface IQaConversationCoreObject {
object_en: string
object_zh: string
[key: string]: any
}
export interface IQaConversationEvent {
event_en: string
event_zh: string
[key: string]: any
}
export interface IQaConversationScene {
scene_en: string
scene_zh: string
[key: string]: any
}
export interface IQaConversationSettingPayload {
all_possible_events: IQaConversationEvent[]
all_possible_scenes: IQaConversationScene[]
core_objects: IQaConversationCoreObject[]
}
export interface IQaConversationSession {
id: string | number
status: string
created_at?: string
updated_at?: string
ext?: any
}
export interface IQaConversationSettingResponse {
image_id: string
latest_session: IQaConversationSession | null
setting: IQaConversationSettingPayload
}
export interface IQaConversationDetail {
id: string | number
status: string
created_at?: string
updated_at?: string
ext?: any
messages?: any[]
[key: string]: any
}
// 单词详情接口
export interface ExtendedWordDetail {
word: string
@@ -168,6 +216,8 @@ export interface IPointsData {
total_purchased: number
total_refunded: number
expired_time: string
is_subscribed: boolean
subscription_expires_at: string | null
}
export interface IQaExerciseSession {

View File

@@ -13,7 +13,9 @@ import {
IQaExerciseQueryResponse,
IQaQuestionAttemptAccepted,
IQaQuestionTaskStatus,
IQaResult
IQaResult,
IQaConversationSettingResponse,
IQaConversationDetail
} from '../types/app';
import { BASE_URL, USE_CLOUD } from './config';
import { cloudConfig } from './cloud.config';
@@ -685,6 +687,15 @@ class ApiManager {
})
}
// 语音识别
async recognizeQaAudio(sessionId: string, fileId: string): Promise<IApiResponse<any>> {
return this.request(
`/api/v1/qa/conversations/${sessionId}/recognize_audio`,
'POST',
{ file_id: fileId }
)
}
// 上传文件第一步上传文件获取ID
async uploadFile(filePath: string, retryCount: number = 0): Promise<string> {
if (USE_CLOUD) {
@@ -1961,7 +1972,7 @@ class ApiManager {
// 如果返回的数据为null则返回默认值
if (!response.data) {
return { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '' };
return { balance: 0, available_balance: 0, frozen_balance: 0, total_purchased: 0, total_refunded: 0, expired_time: '', is_subscribed: false, subscription_expires_at: null as any };
}
const d: any = response.data as any
const normalized = {
@@ -1970,7 +1981,9 @@ class ApiManager {
frozen_balance: Number(d.frozen_balance || 0),
total_purchased: Number(d.total_purchased || 0),
total_refunded: Number(d.total_refunded || 0),
expired_time: String(d.expired_time || '')
expired_time: String(d.expired_time || ''),
is_subscribed: !!d.is_subscribed,
subscription_expires_at: d.subscription_expires_at || null
}
return normalized as import("../types/app").IPointsData;
} catch (error) {
@@ -2017,6 +2030,29 @@ class ApiManager {
return response.data
}
async createSubscriptionOrder(product_id: string): Promise<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}> {
const response = await this.request<{
out_trade_no: string
prepay_id: string
trade_state: string
timeStamp?: string
nonceStr?: string
paySign?: string
signType?: string
}>('/api/v1/wxpay/order/jsapi/subscription', 'POST', {
product_id
})
return response.data
}
async getProductList(): Promise<Array<{
id: number
title: string
@@ -2036,6 +2072,33 @@ class ApiManager {
return response.data
}
async getSubscriptionPlans(): Promise<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>> {
const response = await this.request<Array<{
id: string
name: string
price: number
cycle_type: string
cycle_length: number
max_cycle_usage: number
features?: {
label?: string
extra?: string
}
}>>('/api/v1/product/plan', 'GET')
return response.data
}
async getWxpayOrder(out_trade_no: string): Promise<{
out_trade_no: string
trade_state: string
@@ -2090,10 +2153,9 @@ class ApiManager {
return resp.data
}
async createQaExerciseTask(imageId: number | string, title?: string, description?: string): Promise<IQaExerciseCreateAccepted> {
async createQaExerciseTask(imageId: number | string, type?: string): Promise<IQaExerciseCreateAccepted> {
const payload: Record<string, any> = { image_id: imageId }
if (title !== undefined) payload.title = title
if (description !== undefined) payload.description = description
if (type !== undefined) payload.type = type
const resp = await this.request<IQaExerciseCreateAccepted>(`/api/v1/qa/exercises/tasks`, 'POST', payload)
return resp.data
}
@@ -2103,8 +2165,55 @@ class ApiManager {
return resp.data
}
async listQaExercisesByImage(imageId: string): Promise<IQaExerciseQueryResponse | null> {
const resp = await this.request<IQaExerciseQueryResponse | null>(`/api/v1/qa/${imageId}/exercises`, 'GET')
async listQaExercisesByImage(imageId: string, type?: string): Promise<IQaExerciseQueryResponse | null> {
let url = `/api/v1/qa/${imageId}/exercises`
if (type) {
url += `?type=${type}`
}
const resp = await this.request<IQaExerciseQueryResponse | null>(url, 'GET')
return resp.data
}
async getQaConversationSetting(imageId: string): Promise<IQaConversationSettingResponse> {
const resp = await this.request<IQaConversationSettingResponse>('/api/v1/qa/conversations/setting', 'POST', {
image_id: imageId
})
return resp.data
}
async startQaConversation(payload: {
image_id: string | number
scene?: Array<{ en: string; zh: string }>
event?: Array<{ en: string; zh: string }>
user_role?: { en: string; zh: string }
assistant_role?: { en: string; zh: string }
level?: string
info?: string
style?: { en: string; zh: string }
}): Promise<any> {
const resp = await this.request<any>('/api/v1/qa/conversations/start', 'POST', payload)
return resp.data
}
async getQaConversationDetail(sessionId: string | number): Promise<IQaConversationDetail> {
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}`, 'GET')
return resp.data
}
async getQaConversationLatest(sessionId: string | number): Promise<IQaConversationDetail> {
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}/latest`, 'GET')
return resp.data
}
async listQaConversations(imageId: string | number, page: number = 1, pageSize: number = 10): Promise<any> {
const resp = await this.request<any>(`/api/v1/qa/conversations/${imageId}/list?page=${page}&page_size=${pageSize}`, 'GET')
return resp.data
}
async replyQaConversation(sessionId: string | number, content: string): Promise<any> {
const resp = await this.request<any>(`/api/v1/qa/conversations/${sessionId}/reply`, 'POST', {
content
})
return resp.data
}

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "25.9.10",
"dependencies": {
"cos-wx-sdk-v5": "^1.8.0",
"tdesign-miniprogram": "^1.11.2"
"tdesign-miniprogram": "^1.12.1"
},
"devDependencies": {
"miniprogram-api-typings": "^2.8.3-1"
@@ -80,9 +80,9 @@
"license": "MIT"
},
"node_modules/tdesign-miniprogram": {
"version": "1.11.2",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/tdesign-miniprogram/-/tdesign-miniprogram-1.11.2.tgz",
"integrity": "sha512-lXcry3vRa9jHzjpOdIxuIAh7F85kImym82VwLbCqr/TkMhycOsOepx+r1S9fum7u2nsWiYRTV+HuvDHN3KlIuA==",
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/tdesign-miniprogram/-/tdesign-miniprogram-1.12.1.tgz",
"integrity": "sha512-L2w/6lqVXSCtptvlPGlY91dutMb3dJlx9rFpmpLRtG7c7v1l89rIhvgLRgsaBcwTA5btdB8VXGXpTVj621bv5w==",
"license": "MIT"
}
}

View File

@@ -11,6 +11,6 @@
},
"dependencies": {
"cos-wx-sdk-v5": "^1.8.0",
"tdesign-miniprogram": "^1.11.2"
"tdesign-miniprogram": "^1.12.1"
}
}