Compare commits
10 Commits
bdf0112046
...
96b0a20fa0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b0a20fa0 | ||
|
|
1bffa69aea | ||
|
|
4607db2721 | ||
|
|
107283a69a | ||
|
|
2ae39a4eaa | ||
|
|
0a36bebde5 | ||
|
|
9d8f6d73ef | ||
|
|
751b2ae087 | ||
|
|
90057c8ddb | ||
|
|
78b7964860 |
7
miniprogram/components/cloud-image/cloud-image.json
Normal file
7
miniprogram/components/cloud-image/cloud-image.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"t-image": "tdesign-miniprogram/image/image",
|
||||
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton"
|
||||
}
|
||||
}
|
||||
98
miniprogram/components/cloud-image/cloud-image.ts
Normal file
98
miniprogram/components/cloud-image/cloud-image.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
24
miniprogram/components/cloud-image/cloud-image.wxml
Normal file
24
miniprogram/components/cloud-image/cloud-image.wxml
Normal 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>
|
||||
34
miniprogram/components/cloud-image/cloud-image.wxss
Normal file
34
miniprogram/components/cloud-image/cloud-image.wxss
Normal 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;
|
||||
}
|
||||
448
miniprogram/components/vx-confetti/vx-confetti.js
Normal file
448
miniprogram/components/vx-confetti/vx-confetti.js
Normal 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);
|
||||
// 如果找不到canvas,200ms后重试
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
miniprogram/components/vx-confetti/vx-confetti.json
Normal file
4
miniprogram/components/vx-confetti/vx-confetti.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
1
miniprogram/components/vx-confetti/vx-confetti.wxml
Normal file
1
miniprogram/components/vx-confetti/vx-confetti.wxml
Normal file
@@ -0,0 +1 @@
|
||||
<canvas type="2d" id="{{canvasId}}" style="width: {{width}}px; height: {{height}}px;"></canvas>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) : ''}`
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
8
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cos-wx-sdk-v5": "^1.8.0",
|
||||
"tdesign-miniprogram": "^1.11.2"
|
||||
"tdesign-miniprogram": "^1.12.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user