更新记录
1.0.1(2026-10-28)
支持ios、鸿蒙
1.0.0(2026-08-14)
阿里云跟读测评
平台兼容性
uni-app(4.75)
| Vue2 |
Vue2插件版本 |
Vue3 |
Vue3插件版本 |
Chrome |
Safari |
app-vue |
app-vue插件版本 |
app-nvue |
app-nvue插件版本 |
Android |
Android插件版本 |
iOS |
iOS插件版本 |
鸿蒙 |
鸿蒙插件版本 |
| √ |
1.0.0 |
√ |
1.0.0 |
× |
× |
√ |
1.0.0 |
√ |
1.0.0 |
10.0 |
1.0.0 |
13 |
1.0.1 |
5.0 |
1.0.1 |
| 微信小程序 |
支付宝小程序 |
抖音小程序 |
百度小程序 |
快手小程序 |
京东小程序 |
鸿蒙元服务 |
QQ小程序 |
飞书小程序 |
小红书小程序 |
快应用-华为 |
快应用-联盟 |
| × |
× |
× |
× |
× |
× |
× |
× |
× |
× |
× |
× |
uni-app x(4.75)
| Chrome |
Safari |
Android |
Android插件版本 |
iOS |
iOS插件版本 |
鸿蒙 |
鸿蒙插件版本 |
微信小程序 |
| × |
× |
10.0 |
1.0.0 |
13 |
1.0.1 |
16 |
1.0.1 |
× |
yao-oral-eval
基于先声智能(SingSound)云评测服务的口语测评 / 跟读测评 UTS 插件。鉴权、录音、评测协议与先声官方 ssound-android 3.0.2 SDK 对齐,无需集成官方 SDK,纯 UTS 实现。
支持平台
| 平台 |
状态 |
| Android |
✔ 已启用 |
| HarmonyOS |
✔ 已启用 |
| iOS |
✔ 已启用 |
功能特性
- 初始化时自动申请录音权限(Android,仅 Android 端)
- 两种评测模式:
- 实时评测:WebSocket 推流,
onRealTimeEval 实时返回逐词反馈,onResult 返回最终总分
- 录音评测:本地录音,
stop() 后上传 WAV 走 HTTP 评测
- 独立录音 API(可脱离评测单独录音,逐帧回调 PCM 数据)
- 音频文件评测(对已有 WAV 文件直接发起 HTTP 评测)
快速开始
import { initSDK, start, stop, cancel, destroy } from "@/uni_modules/yao-oral-eval";
export default {
onLoad() {
initSDK(
'your_appKey', // appKey
'your_secretKey', // secretKey
'test_user_001', // userId
'12.12.13.15', // 用户客户端 IP(鉴权签名用)
() => { /* onReady 引擎就绪 */ },
() => { /* 开始录音 */ },
(result) => { /* onResult 最终评测结果(JSON 字符串) */ },
(result) => { /* onRealTimeEval 实时评测结果 */ },
(code, msg) => { /* onEnd code=0 正常结束 */ },
(volume) => { /* onVolume 音量 0-100 */ },
() => { /* onFrontVadTimeout 前端点超时:未检测到说话 */ },
() => { /* onBackVadTimeout 后端点超时:说话后静音 */ },
(code, msg) => { /* onError 错误,见下方错误码表 */ }
);
},
onUnload() {
destroy();
},
methods: {
// 开始评测:实时模式
onStartRealTime() {
start('en.sent.score', 'Hello, how are you today?', 100, true);
},
// 开始评测:录音模式(stop 后出结果)
onStartRecord() {
start('cn.sent.score', '今天天气真不错', 100, false);
},
onStop() {
stop();
},
onCancel() {
cancel();
}
}
}
API 参考
initSDK(appKey, secretKey, userId, ip, onReady, , onResult, onRealTimeEval, onEnd, onVolume, onFrontVadTimeout, onBackVadTimeout, onError)
初始化引擎并鉴权。
- Android 端会先弹出录音权限申请,授权通过后才发起鉴权;权限被拒绝时通过
onError(-11) 报错,不会发起鉴权。
- 鉴权流程:先请求正式环境,失败自动回退试用环境;成功后回调
onReady,失败回调 onError(-6)。
- 所有回调为独立函数参数(UTS 不支持对象接口作为参数),建议对
initSDK 调用方页面使用 @UTSJS.keepAlive 语义的持久回调(插件已内置处理)。
| 参数 |
类型 |
说明 |
| appKey |
string |
先声平台 appid |
| secretKey |
string |
先声平台 app_secret |
| userId |
string |
用户唯一标识 |
| ip |
string |
用户客户端 IP(参与鉴权签名) |
评测控制
| 函数 |
说明 |
start(coreType, refText, rank, realTime) |
开始评测。realTime=true 实时推流模式;false 录音结束后 HTTP 上传。rank 传 100 |
stop() |
结束评测。实时模式:发 end 包等最终结果;录音模式:停录并上传 WAV |
cancel() |
取消当前评测并停止录音 |
destroy() |
销毁引擎,释放录音与网络资源(页面 onUnload 时务必调用) |
其他
| 函数 |
说明 |
setAuth(warrantId, expireAt) |
设置第三方鉴权凭证。warrant_id 为一次性凭证,每次评测前插件内部会自动重新鉴权;服务端模式下可由业务层直接下发 |
startPCMFileEval(coreType, refText, rank, realTime, filePath, sampleRate, channel, id) |
文件评测:对已有 WAV/PCM 文件(16k/单声道/16bit 推荐)发起 HTTP 评测。id 任意类型,会原样透传到结果 JSON 的 itemId 字段。realTime 参数忽略,统一走 HTTP |
playback() |
播放最近一次评测录音 |
getRecordPath() |
返回最近一次评测录音的 WAV 文件路径,无录音返回空字符串 |
md5(input) |
MD5 摘要,返回 32 位小写十六进制字符串 |
独立录音 API(Android / iOS)
与评测解耦的通用录音能力,逐帧回调 PCM 数据(行为对齐鸿蒙端 yao-recorded-frame)。
| 函数 |
说明 |
recordStart(options, callback) |
开始录音。options(UTSJSONObject):sampleRate(默认 16000)、numberOfChannels(默认 1)、frameSize(默认 1024,字节)、audioSource。callback(prompt) 返回 "success" 表示成功,否则为错误信息 |
recordStop(callback) |
停止录音并落盘 WAV,callback(path) 返回文件路径 |
recordPause() / recordResume() |
暂停 / 恢复录音 |
onFrameRecorded(callback) |
注册逐帧回调,callback(frame) 返回 PCM 字节数据 |
getBufferStatus() |
返回 BufferStatus | null,未录音时为 null;availableRead 为已累计 PCM 字节数 |
回调说明
| 回调 |
触发时机 |
| onReady |
引擎初始化(鉴权)成功 |
| onBegin |
开始评测录音 |
| onResult |
最终评测结果(JSON 字符串),formatJson 后可解析总分 |
| onRealTimeEval |
实时模式的逐词实时反馈 |
| onEnd(code, msg) |
评测结束。code === 0 正常,非 0 为异常结束原因 |
| onVolume(volume) |
录音音量,0-100 |
| onFrontVadTimeout |
前端点超时:开始录音后一直未检测到说话 |
| onBackVadTimeout |
后端点超时:说话后持续静音 |
| onError(code, msg) |
错误,见错误码表 |
错误码
| 错误码 |
含义 |
| -2 |
未鉴权(请先调用 initSDK)/ 已有评测进行中 / 录音启动失败 |
| -3 |
鉴权成功但 warrant_id 为空 |
| -4 |
无音频数据 / 音频文件路径为空 |
| -5 |
没有录音文件 |
| -6 |
鉴权失败 / 鉴权异常 / warrant_id 失效 |
| -7 |
评测失败(响应解析失败或服务端返回错误) |
| -9 |
评测异常(音频文件读取失败等) |
| -10 |
WebSocket 连接失败 / 错误 |
| -11 |
录音权限被拒绝(Android 初始化时申请,勾选"不再询问"需到系统设置手动开启) |
支持的测评类型(coreType)
| coreType |
说明 |
en.sent.score |
英文句子 |
en.word.score |
英文单词 |
en.pred.score |
英文段落 |
cn.sent.score |
中文句子 |
cn.word.score |
中文单词 |
生产环境鉴权
插件默认在 initSDK 时用 appKey + secretKey 自动鉴权(正式环境失败自动回退试用环境),试用环境仅供调试。
生产环境推荐两种方式:
- 插件自动鉴权:继续使用
initSDK 传入正式凭证,插件每次评测前自动重新鉴权(warrant_id 为一次性凭证)。
- 服务端下发:由自有后台调用先声鉴权接口获取
warrant_id,业务层调用 setAuth(warrantId, expireAt) 直接下发,避免 secretKey 出现在客户端。
权限说明
Android 端插件已在 AndroidManifest.xml 中声明以下权限,云打包时自动合并:
RECORD_AUDIO(录音,initSDK 时动态申请)
INTERNET / ACCESS_NETWORK_STATE / ACCESS_WIFI_STATE(网络访问)
试用鉴权接口走 HTTP,插件已声明 usesCleartextTraffic=true 允许明文流量。
完整示例页面
即项目首页 pages/index/index.vue,测评类型切换、实时评分逐词高亮、录音回放等完整功能,可直接复制使用。
<template>
<view class="container">
<!-- 标题 -->
<view class="header">
<text class="title">口语测评 Demo</text>
<text class="subtitle">yao-oral-eval</text>
</view>
<!-- 凭证配置 -->
<view class="card">
<view class="card-header" @click="showCredentials = !showCredentials">
<text class="card-title">凭证配置(试用)</text>
<text class="arrow">{{ showCredentials ? '▼' : '▶' }}</text>
</view>
<view v-if="showCredentials" class="card-body">
<view class="form-item">
<text class="label">AppKey</text>
<input class="input" v-model="appKey" placeholder="appKey" />
</view>
<view class="form-item">
<text class="label">SecretKey</text>
<input class="input" v-model="secretKey" placeholder="secretKey" />
</view>
<view class="form-item">
<text class="label">UserID</text>
<input class="input" v-model="userId" placeholder="userId" />
</view>
<button class="btn btn-primary" @click="onInit" :disabled="engineReady || initing">
{{ initing ? '初始化中...' : (engineReady ? '引擎已就绪 ✓' : '初始化引擎') }}
</button>
</view>
</view>
<!-- 测评配置 -->
<view class="card">
<view class="card-header"><text class="card-title">测评配置</text></view>
<view class="card-body">
<view class="form-item">
<text class="label">测评类型</text>
<picker :range="coreTypes" range-key="name" @change="onCoreTypeChange">
<view class="picker-value">{{ coreTypes[coreTypeIndex].name }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">参考文本</text>
<textarea class="textarea" v-model="refText" placeholder="请输入参考文本" />
</view>
<view class="form-item form-row">
<text class="label">实时评测</text>
<switch :checked="realTime" @change="onRealTimeChange" />
</view>
</view>
</view>
<!-- 录音控制 -->
<view class="card">
<view class="card-header"><text class="card-title">录音测评</text></view>
<view class="card-body">
<view class="status-bar">
<text class="status-label">状态:</text>
<text class="status-value" :class="'status-' + statusLevel">{{ statusText }}</text>
</view>
<view class="volume-bar" v-if="recording">
<view class="volume-fill" :style="{ width: volume + '%' }"></view>
</view>
<view class="btn-group">
<button class="btn btn-record" @click="onStart" :disabled="!engineReady || recording">开始测评</button>
<button class="btn btn-stop" @click="onStop" :disabled="!recording">停止</button>
</view>
<view class="btn-group">
<button class="btn btn-secondary" @click="onCancel" :disabled="!recording">取消</button>
<button class="btn btn-secondary" @click="onPlayback" :disabled="!engineReady">播放录音</button>
</view>
</view>
</view>
<!-- 评分 -->
<view class="card" v-if="score && score.overall !== null">
<view class="card-header"><text class="card-title">评分结果</text></view>
<view class="card-body">
<view class="score-main">
<text class="score-value" :class="'score-c-' + scoreLevel">{{ score.overall }}</text>
<text class="score-label">{{ recording ? '实时评分 · 说完自动出总分' : '总评分' }}</text>
</view>
<view class="score-grid">
<view class="score-item" v-if="score.accuracy !== null">
<text class="score-num">{{ score.accuracy }}</text>
<text class="score-name">准确度</text>
</view>
<view class="score-item" v-if="score.fluency !== null">
<text class="score-num">{{ score.fluency }}</text>
<text class="score-name">流利度</text>
</view>
<view class="score-item" v-if="score.integrity !== null">
<text class="score-num">{{ score.integrity }}</text>
<text class="score-name">完整度</text>
</view>
<view class="score-item" v-if="score.pron !== null">
<text class="score-num">{{ score.pron }}</text>
<text class="score-name">发音</text>
</view>
<view class="score-item" v-if="score.rhythm !== null">
<text class="score-num">{{ score.rhythm }}</text>
<text class="score-name">韵律</text>
</view>
</view>
<view class="words-row" v-if="realtimeWords.length">
<view class="word-chip" v-for="(w, i) in realtimeWords" :key="i">
<text class="word-char">{{ w.char }}</text>
<text class="word-score" v-if="w.score !== null"
:class="w.score >= 80 ? 'w-good' : (w.score >= 60 ? 'w-mid' : 'w-bad')">{{ w.score }}</text>
<text class="word-score w-pending" v-else>·</text>
</view>
</view>
</view>
</view>
<!-- 实时结果 -->
<view class="card" v-if="realTimeResult">
<view class="card-header"><text class="card-title">实时结果</text></view>
<view class="card-body">
<text class="result-text" selectable>{{ realTimeResult }}</text>
</view>
</view>
<!-- 最终结果 -->
<view class="card" v-if="finalResult">
<view class="card-header"><text class="card-title">最终结果</text></view>
<view class="card-body">
<text class="result-text" selectable>{{ finalResult }}</text>
</view>
</view>
<!-- 错误信息 -->
<view class="card card-error" v-if="errorMsg">
<view class="card-body">
<text class="error-text">[{{ errorCode }}] {{ errorMsg }}</text>
</view>
</view>
</view>
</template>
<script>
import {
initSDK,
start,
stop,
cancel,
destroy,
getRecordPath
} from "@/uni_modules/yao-oral-eval";
export default {
data() {
return {
// 试用凭证(先声测评平台测试账号)
showCredentials: true,
appKey: 'your_appKey',
secretKey: 'your_secretKey',
userId: 'test_user_001',
// 引擎状态
initing: false,
engineReady: false,
// 测评配置
coreTypes: [
{ name: '英文句子', value: 'en.sent.score' },
{ name: '英文单词', value: 'en.word.score' },
{ name: '英文段落', value: 'en.pred.score' },
{ name: '中文句子', value: 'cn.sent.score' },
{ name: '中文单词', value: 'cn.word.score' },
],
coreTypeIndex: 0,
refText: "Hello, how are you?",
realTime: false,
// 录音状态
recording: false,
volume: 0,
// 状态显示
statusText: '未初始化',
statusLevel: 'idle',
// 结果
realTimeResult: '',
finalResult: '',
score: null,
realtimeWords: [],
// 错误
errorCode: 0,
errorMsg: '',
ip: '12.12.13.15'
}
},
computed: {
scoreLevel() {
const v = this.score && this.score.overall;
if (v == null) return 'mid';
return v >= 80 ? 'good' : (v >= 60 ? 'mid' : 'bad');
}
},
onUnload() {
destroy();
},
methods: {
onCoreTypeChange(e) {
this.coreTypeIndex = e.detail.value;
},
onRealTimeChange(e) {
this.realTime = e.detail.value;
},
updateStatus(text, level) {
this.statusText = text;
this.statusLevel = level;
},
onInit() {
this.doInit();
},
doInit() {
this.initing = true;
this.errorMsg = '';
this.updateStatus('初始化中...', 'initing');
initSDK(
this.appKey,
this.secretKey,
this.userId || '111',
this.ip,
() => {
// onReady
this.initing = false;
this.engineReady = true;
this.updateStatus('引擎就绪,可以开始测评', 'ready');
uni.showToast({ title: '引擎初始化成功', icon: 'success' });
},
() => {
//
this.recording = true;
this.finalResult = '';
this.realTimeResult = '';
this.score = null;
this.realtimeWords = [];
this.updateStatus('正在录音测评...', 'recording');
},
(result) => {
// onResult
this.finalResult = this.formatJson(result);
this.parseScore(result);
this.updateStatus('测评完成', 'done');
},
(result) => {
// onRealTimeEval
this.realTimeResult = this.formatJson(result);
this.parseRealtimeScore(result);
},
(code, msg) => {
// onEnd
this.recording = false;
this.volume = 0;
if (code === 0) {
this.updateStatus('测评结束', 'done');
} else {
this.updateStatus('结束: ' + msg + ' (' + code + ')', 'error');
}
},
(vol) => {
// onVolume
this.volume = Math.min(100, vol);
},
() => {
// onFrontVadTimeout
this.recording = false;
this.volume = 0;
this.updateStatus('前端超时:未检测到说话', 'warn');
uni.showToast({ title: '未检测到说话', icon: 'none' });
},
() => {
// onBackVadTimeout
this.recording = false;
this.volume = 0;
this.updateStatus('后端超时:说话后静音', 'warn');
},
(code, msg) => {
// onError
this.initing = false;
this.recording = false;
this.volume = 0;
this.errorCode = code;
this.errorMsg = msg;
this.updateStatus('错误: ' + msg, 'error');
uni.showToast({ title: msg, icon: 'none' });
console.log(code, msg)
}
);
},
onStart() {
if (!this.engineReady) {
uni.showToast({ title: '请先初始化引擎', icon: 'none' });
return;
}
const coreType = this.coreTypes[this.coreTypeIndex].value;
start(coreType, this.refText, 100, this.realTime);
this.updateStatus('正在录音...', 'recording');
},
onStop() {
stop();
this.updateStatus('正在获取结果...', 'processing');
},
onCancel() {
cancel();
this.recording = false;
this.volume = 0;
this.updateStatus('已取消', 'idle');
},
onPlayback() {
const path = getRecordPath();
if (!path) {
uni.showToast({ title: '没有录音文件', icon: 'none' });
return;
}
const audio = uni.createInnerAudioContext();
audio.src = path;
audio.play();
uni.showToast({ title: '播放录音中', icon: 'none' });
},
parseScore(result) {
try {
const data = JSON.parse(result);
const r = data.result || data;
const r1 = (v) => (typeof v === 'number' ? Math.round(v * 10) / 10 : null);
this.score = {
overall: r1(r.overall),
accuracy: r1(r.accuracy),
fluency: r1(r.fluency ? r.fluency.overall : null),
integrity: r1(r.integrity),
pron: r1(r.pron),
rhythm: r1(r.rhythm ? r.rhythm.overall : null)
};
} catch (e) {
this.score = null;
}
},
parseRealtimeScore(result) {
try {
const data = JSON.parse(result);
const r = data.result;
if (!r || !r.realtime_details) return;
const words = [];
let sum = 0;
let count = 0;
r.realtime_details.forEach((d) => {
// dp_type=0 是已说出的单词(有实时分),dp_type=1 为未说(占位)
words.push({
char: d.char,
score: d.dp_type === 0 ? d.score : null
});
if (d.dp_type === 0 && typeof d.score === 'number' && d.dur > 0) {
sum += d.score;
count += 1;
}
});
this.realtimeWords = words;
if (count > 0) {
const live = Math.round((sum / count) * 10) / 10;
if (this.score && this.score.accuracy === null) {
this.score.overall = live;
} else if (!this.score) {
this.score = {
overall: live,
accuracy: null,
fluency: null,
integrity: null,
pron: null,
rhythm: null
};
}
}
} catch (e) {
// 忽略实时包解析失败
}
},
formatJson(str) {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch (e) {
return str;
}
},
}
}
</script>
<style>
.container {
padding: 20rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
text-align: center;
padding: 30rpx 0;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #333;
display: block;
}
.subtitle {
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
display: block;
}
.card {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.card-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.arrow {
font-size: 24rpx;
color: #ccc;
}
.card-body {
padding: 20rpx 30rpx;
}
.form-item {
margin-bottom: 20rpx;
}
.form-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.form-row .label {
margin-bottom: 0;
}
.input {
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
background: #fafafa;
}
.textarea {
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
background: #fafafa;
width: 100%;
height: 120rpx;
box-sizing: border-box;
}
.picker-value {
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
background: #fafafa;
color: #333;
}
.btn {
border-radius: 12rpx;
font-size: 30rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
margin: 0;
}
.btn::after {
border: none;
}
.btn[disabled] {
opacity: 0.5;
}
.btn-primary {
background: #007aff;
color: #fff;
width: 100%;
margin-top: 10rpx;
}
.btn-record {
background: #ff4d4f;
color: #fff;
flex: 1;
}
.btn-stop {
background: #52c41a;
color: #fff;
flex: 1;
margin-left: 20rpx;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
flex: 1;
font-size: 28rpx;
}
.btn-secondary:last-child {
margin-left: 20rpx;
}
.btn-group {
display: flex;
margin-top: 20rpx;
}
.status-bar {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.status-label {
font-size: 26rpx;
color: #999;
}
.status-value {
font-size: 28rpx;
font-weight: bold;
margin-left: 10rpx;
}
.status-idle { color: #999; }
.status-initing { color: #faad14; }
.status-ready { color: #52c41a; }
.status-recording { color: #ff4d4f; }
.status-processing { color: #1677ff; }
.status-done { color: #52c41a; }
.status-warn { color: #faad14; }
.status-error { color: #ff4d4f; }
.volume-bar {
height: 12rpx;
background: #f0f0f0;
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 20rpx;
}
.volume-fill {
height: 100%;
background: #52c41a;
}
.result-text {
font-size: 24rpx;
color: #333;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
.card-error {
border: 2rpx solid #ff4d4f;
}
.score-main {
text-align: center;
margin-bottom: 20rpx;
}
.score-value {
font-size: 100rpx;
font-weight: bold;
line-height: 1.2;
display: block;
}
.score-c-good { color: #52c41a; }
.score-c-mid { color: #faad14; }
.score-c-bad { color: #ff4d4f; }
.score-label {
font-size: 26rpx;
color: #999;
display: block;
}
.score-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.score-item {
width: 33.33%;
text-align: center;
padding: 14rpx 0;
}
.score-num {
font-size: 38rpx;
font-weight: bold;
color: #1677ff;
display: block;
}
.score-name {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 6rpx;
}
.words-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 16rpx;
justify-content: center;
}
.word-chip {
display: flex;
flex-direction: column;
align-items: center;
margin: 8rpx 14rpx;
}
.word-char {
font-size: 26rpx;
color: #333;
}
.word-score {
font-size: 24rpx;
margin-top: 4rpx;
font-weight: bold;
}
.w-good { color: #52c41a; }
.w-mid { color: #faad14; }
.w-bad { color: #ff4d4f; }
.w-pending { color: #ccc; }
.error-text {
font-size: 26rpx;
color: #ff4d4f;
}
</style>