feat(mobile-app): prepare native app packaging
@@ -19,5 +19,8 @@ mobile/node_modules/
|
|||||||
mobile/dist/
|
mobile/dist/
|
||||||
mobile-uni/node_modules/
|
mobile-uni/node_modules/
|
||||||
mobile-uni/dist/
|
mobile-uni/dist/
|
||||||
|
mobile-uni/unpackage/
|
||||||
|
mobile-uni/certificates/
|
||||||
|
mobile-uni/androidPrivacy.json
|
||||||
.tmp/
|
.tmp/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 原生 App 真机验收清单
|
||||||
|
|
||||||
|
此清单用于 Android/iOS 自定义基座或签名包验收。`npm --prefix mobile-uni run verify:app-assets` 只校验静态资源和 manifest 映射,不能替代本清单。
|
||||||
|
|
||||||
|
## 预条件
|
||||||
|
|
||||||
|
1. 已执行 `npm --prefix mobile-uni run build:app`,并使用 HBuilderX 的**自定义运行基座**运行当前资源。
|
||||||
|
2. Android 首次上机前,复制 `mobile-uni/androidPrivacy.json.example` 为 `mobile-uni/androidPrivacy.json`,替换两个法务确认的 HTTPS 链接;再执行 `npm --prefix mobile-uni run verify:app-release`。
|
||||||
|
3. 使用有效的移动端测试账号;不得把管理员 Web 登录态当作 App 登录验证。
|
||||||
|
4. 每台设备保留系统版本、App `versionName/versionCode`、基座/包哈希、录屏或截图和失败日志。
|
||||||
|
|
||||||
|
## Android(最低 Android 10 + 一台当前主流机型)
|
||||||
|
|
||||||
|
- [ ] 首次启动先显示原生隐私弹窗;两个协议链接可打开,拒绝后退出,同意后才进入登录。
|
||||||
|
- [ ] 手机号登录、短信验证码错误、登录失效重登均符合预期。
|
||||||
|
- [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。
|
||||||
|
- [ ] 图片、视频、文档和音频可选择并上传;受控附件下载后可打开,不泄露 OSS 地址。
|
||||||
|
- [ ] 前后台切换、网络断开/恢复、窄屏和底部手势区无白屏、遮挡或重复提交。
|
||||||
|
- [ ] 再次安装同版本后,桌面图标、启动图和 Android 导航栏不闪白。
|
||||||
|
|
||||||
|
## iPhone(iOS 15 + 一台当前刘海或灵动岛机型)
|
||||||
|
|
||||||
|
- [ ] 初次访问录音时,系统麦克风说明与功能一致;拒绝后可选择音频且不崩溃。
|
||||||
|
- [ ] 选择图片/视频/文件时,系统照片/文件访问说明与功能一致;取消选择不会残留上传状态。
|
||||||
|
- [ ] 登录、录音转写、TTS 播放、上传、受控下载、前后台切换均通过。
|
||||||
|
- [ ] 启动图全屏,无黑边;状态栏、刘海、底部安全区和键盘不遮挡主要操作。
|
||||||
|
- [ ] App Store 图标无透明通道、未预制圆角;真机图标更新后重启设备复核 iOS 缓存。
|
||||||
|
|
||||||
|
## 通过口径
|
||||||
|
|
||||||
|
每个平台的必测项全部通过,且录音拒绝、媒体取消、登录失效和离线恢复四种反例均留有证据,才可进入签名包或 TestFlight/企业 APK 测试。任何 App 壳、权限、原生插件或证书变更都必须重新云打包并重跑本清单。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# mobile-uni 原生 App 打包
|
||||||
|
|
||||||
|
`mobile-uni/` 采用 uni-app Vue 3 的 App-Plus 目标,同时保留 H5。App 不是将线上 H5 包进 WebView;前端资源通过 `npm --prefix mobile-uni run build:app` 编译,再由 HBuilderX 的自定义基座或云端打包生成 Android/iOS 原生安装包。
|
||||||
|
|
||||||
|
## 当前约定
|
||||||
|
|
||||||
|
- Android applicationId 与 iOS Bundle ID 预设为 `com.yincheng.wygj`。在首次签名或注册 App ID 前,发布负责人必须确认企业实际拥有该标识;变更时 Android、iOS 必须同步修改。
|
||||||
|
- App 直接请求 `https://peilian.njzhmj.top/dev-api`。不依赖 H5 开发服务器的 `/dev-api` proxy;地址是公开配置,不得在 App 包中放入短信、模型、签名或任何服务端密钥。
|
||||||
|
- `manifest.json` 只保留不含秘密的发布配置。`.keystore`、`.p12`、`.mobileprovision` 与证书密码只能由发布负责人在 HBuilderX 云打包界面或受控 CI 秘密变量中提供,绝不能提交 Git。
|
||||||
|
- Android 最低试点设备为 Android 10,iOS 最低试点设备为 iOS 15;每次发版提升 `versionCode`,再更新 `versionName`。
|
||||||
|
|
||||||
|
## 本地验证与出包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix mobile-uni run generate:app-assets
|
||||||
|
npm --prefix mobile-uni run verify:app-assets
|
||||||
|
npm --prefix mobile-uni run typecheck
|
||||||
|
npm --prefix mobile-uni run build:h5
|
||||||
|
npm --prefix mobile-uni run build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 用 HBuilderX 打开 `mobile-uni`,先生成 Android/iOS **自定义运行基座**。标准基座不会加载当前的包名、权限、启动图或原生配置。
|
||||||
|
2. 在真实 Android 与 iPhone 上验证手机号登录、录音并转写、TTS 播放、图片/视频/文件上传、附件下载、拒绝权限、前后台切换、刘海和底部安全区。
|
||||||
|
3. Android 试点使用企业签名的正式 APK;上架渠道按渠道要求导出相应制品。iOS 使用企业 Apple Developer 账号的 Distribution 证书和 provisioning profile,先走 TestFlight。
|
||||||
|
|
||||||
|
## 发布前尚需提供的资产
|
||||||
|
|
||||||
|
- 经法务确认并已可公开访问的《服务协议》《隐私政策》;Android 必须用该实际链接配置原生 template 隐私弹窗,iOS 需提供相应的麦克风、相机、相册/文件用途说明。
|
||||||
|
- 无透明通道的 1024×1024 App Store 图标,以及 Android 自适应图标/启动图。
|
||||||
|
- 企业持有的 Android 签名证书、Apple Team、Bundle ID 注册与 iOS 发布证书。上述资产缺失时可以完成代码与真机调试,不能声称已具备商店上架条件。
|
||||||
|
|
||||||
|
## 已接入的资产与隐私模板
|
||||||
|
|
||||||
|
- 图标和启动图由 `scripts/generate-app-assets.sh` 从现有 `src/static/brand-logo.png` 生成,输出位于 `src/static/app/`,并由 `manifest.json` 显式关联。iOS App Store 图标为 1024×1024、无 alpha、未预制圆角;生成后可用 `npm --prefix mobile-uni run verify:app-assets` 校验尺寸和映射。
|
||||||
|
- 现有品牌源图仅为 64×64,因此这里生成的是**可用于自定义基座和测试包的派生资产**。正式提交商店前应替换为品牌方提供的高分辨率母版,再重新执行生成和预检。
|
||||||
|
- `androidPrivacy.json.example` 是**未启用的法务模板**,其中 `__TERMS_URL__` 与 `__PRIVACY_URL__` 必须替换为法务确认的 HTTPS 地址。复制为被 Git 忽略的 `androidPrivacy.json` 后,`npm --prefix mobile-uni run verify:app-release` 才会通过。
|
||||||
|
- iOS 已按实际功能填写麦克风和照片库用途说明;不申请相机、定位、通讯录、蓝牙或广告追踪权限。
|
||||||
|
- 真机步骤和证据口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。资源预检通过不等于 Android/iOS 真机或应用商店验收通过。
|
||||||
|
|
||||||
|
## 更新策略
|
||||||
|
|
||||||
|
涉及原生权限、插件、证书或 App 壳的改动必须重新云打包并通过应用商店/TestFlight 发布。仅 JavaScript/CSS/静态资源的热更新(WGT)不作为首个试点版本的发布方式;如后续启用,须有版本兼容、完整性校验、灰度与回滚方案。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"prompt": "template",
|
||||||
|
"title": "服务协议和隐私政策",
|
||||||
|
"message": " 请你审慎阅读并充分理解《服务协议》和《隐私政策》。为了提供登录、录音转写、图片/视频/文件上传和故障排查服务,本应用会在你使用相应功能时请求必要的设备和文件访问权限。你可阅读<a href=\"__TERMS_URL__\">《服务协议》</a>和<a href=\"__PRIVACY_URL__\">《隐私政策》</a>了解详细信息。点击“同意并继续”即表示你同意上述协议。",
|
||||||
|
"buttonAccept": "同意并继续",
|
||||||
|
"buttonRefuse": "暂不同意",
|
||||||
|
"hrefLoader": "system",
|
||||||
|
"backToExit": "false",
|
||||||
|
"second": {
|
||||||
|
"title": "确认提示",
|
||||||
|
"message": " 进入应用前,请先阅读并同意<a href=\"__TERMS_URL__\">《服务协议》</a>和<a href=\"__PRIVACY_URL__\">《隐私政策》</a>。",
|
||||||
|
"buttonAccept": "同意并继续",
|
||||||
|
"buttonRefuse": "退出应用"
|
||||||
|
},
|
||||||
|
"disagreeMode": {
|
||||||
|
"support": false,
|
||||||
|
"loadNativePlugins": false,
|
||||||
|
"visitorEntry": false,
|
||||||
|
"showAlways": false
|
||||||
|
},
|
||||||
|
"styles": {
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"borderRadius": "12px",
|
||||||
|
"title": {
|
||||||
|
"color": "#1F2329"
|
||||||
|
},
|
||||||
|
"buttonAccept": {
|
||||||
|
"color": "#E60012"
|
||||||
|
},
|
||||||
|
"buttonRefuse": {
|
||||||
|
"color": "#6B7280"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,24 +8,37 @@
|
|||||||
"dev": "uni -p h5",
|
"dev": "uni -p h5",
|
||||||
"dev:h5": "uni -p h5",
|
"dev:h5": "uni -p h5",
|
||||||
"build:h5": "uni build -p h5",
|
"build:h5": "uni build -p h5",
|
||||||
|
"build:app": "uni build -p app",
|
||||||
|
"generate:app-assets": "sh scripts/generate-app-assets.sh",
|
||||||
|
"verify:app-assets": "node scripts/verify-app-assets.mjs",
|
||||||
|
"verify:app-release": "node scripts/verify-app-assets.mjs --require-privacy",
|
||||||
"test:unit": "node --test tests/*.test.mjs",
|
"test:unit": "node --test tests/*.test.mjs",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
|
"@dcloudio/uni-app": "3.0.0-alpha-5020120260710001",
|
||||||
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
|
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260710001",
|
||||||
"@dcloudio/uni-ui": "^1.5.12",
|
"@dcloudio/uni-ui": "^1.5.12",
|
||||||
"lxgw-wenkai-tc-webfont": "^1.0.0",
|
"lxgw-wenkai-tc-webfont": "^1.0.0",
|
||||||
"vue": "3.5.30"
|
"vue": "3.5.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dcloudio/types": "3.4.31",
|
"@dcloudio/types": "3.4.31",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020120260706001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020120260710001",
|
||||||
"sass": "^1.101.0",
|
"sass": "^1.101.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "5.2.8",
|
"vite": "5.2.8",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.5"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@vue/compiler-core": "3.5.30",
|
||||||
|
"@vue/compiler-dom": "3.5.30",
|
||||||
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
|
"@vue/runtime-core": "3.5.30",
|
||||||
|
"@vue/runtime-dom": "3.5.30",
|
||||||
|
"@vue/server-renderer": "3.5.30",
|
||||||
|
"@vue/shared": "3.5.30"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0",
|
"node": ">=20.19.0",
|
||||||
"npm": ">=8.19.0"
|
"npm": ">=8.19.0"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
brand_logo="$project_root/src/static/brand-logo.png"
|
||||||
|
icons="$project_root/src/static/app/icons"
|
||||||
|
splash="$project_root/src/static/app/splash"
|
||||||
|
|
||||||
|
if ! command -v magick >/dev/null 2>&1; then
|
||||||
|
echo "ImageMagick (magick) is required to generate App assets." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$brand_logo" ]; then
|
||||||
|
echo "Brand logo not found: $brand_logo" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$icons" "$splash"
|
||||||
|
|
||||||
|
make_icon() {
|
||||||
|
size=$1
|
||||||
|
logo_size=$2
|
||||||
|
output=$3
|
||||||
|
magick -size "${size}x${size}" xc:'#FFFFFF' \
|
||||||
|
\( "$brand_logo" -resize "${logo_size}x${logo_size}" \) \
|
||||||
|
-gravity center -compose over -composite -alpha off -strip "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_splash() {
|
||||||
|
canvas=$1
|
||||||
|
logo_size=$2
|
||||||
|
output=$3
|
||||||
|
magick -size "$canvas" xc:'#F7F8FA' \
|
||||||
|
\( "$brand_logo" -resize "${logo_size}x${logo_size}" \) \
|
||||||
|
-gravity center -compose over -composite -alpha off -strip "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# All launch and App Store icons are opaque PNGs. iOS applies its own rounded
|
||||||
|
# mask; source artwork must remain a square without alpha information.
|
||||||
|
make_icon 1024 620 "$icons/ios-appstore.png"
|
||||||
|
make_icon 192 118 "$icons/android-xxxhdpi.png"
|
||||||
|
make_icon 144 88 "$icons/android-xxhdpi.png"
|
||||||
|
make_icon 96 60 "$icons/android-xhdpi.png"
|
||||||
|
make_icon 72 44 "$icons/android-hdpi.png"
|
||||||
|
|
||||||
|
make_icon 180 110 "$icons/ios-iphone-app-3x.png"
|
||||||
|
make_icon 120 74 "$icons/ios-iphone-app-2x.png"
|
||||||
|
make_icon 120 74 "$icons/ios-iphone-spotlight-3x.png"
|
||||||
|
make_icon 80 50 "$icons/ios-iphone-spotlight-2x.png"
|
||||||
|
make_icon 87 54 "$icons/ios-iphone-settings-3x.png"
|
||||||
|
make_icon 58 36 "$icons/ios-iphone-settings-2x.png"
|
||||||
|
make_icon 60 38 "$icons/ios-iphone-notification-3x.png"
|
||||||
|
make_icon 40 26 "$icons/ios-iphone-notification-2x.png"
|
||||||
|
|
||||||
|
make_icon 167 104 "$icons/ios-ipad-pro-app-2x.png"
|
||||||
|
make_icon 152 94 "$icons/ios-ipad-app-2x.png"
|
||||||
|
make_icon 76 48 "$icons/ios-ipad-app.png"
|
||||||
|
make_icon 80 50 "$icons/ios-ipad-spotlight-2x.png"
|
||||||
|
make_icon 40 26 "$icons/ios-ipad-spotlight.png"
|
||||||
|
make_icon 58 36 "$icons/ios-ipad-settings-2x.png"
|
||||||
|
make_icon 29 18 "$icons/ios-ipad-settings.png"
|
||||||
|
make_icon 40 26 "$icons/ios-ipad-notification-2x.png"
|
||||||
|
make_icon 20 14 "$icons/ios-ipad-notification.png"
|
||||||
|
|
||||||
|
make_splash '480x762' 160 "$splash/android-hdpi.png"
|
||||||
|
make_splash '720x1242' 240 "$splash/android-xhdpi.png"
|
||||||
|
make_splash '1080x1882' 320 "$splash/android-xxhdpi.png"
|
||||||
|
|
||||||
|
make_splash '640x1136' 200 "$splash/ios-iphone-retina40.png"
|
||||||
|
make_splash '750x1334' 230 "$splash/ios-iphone-retina47.png"
|
||||||
|
make_splash '1242x2208' 360 "$splash/ios-iphone-retina55.png"
|
||||||
|
make_splash '1125x2436' 330 "$splash/ios-iphone-x.png"
|
||||||
|
make_splash '828x1792' 250 "$splash/ios-iphone-896h-2x.png"
|
||||||
|
make_splash '1242x2688' 370 "$splash/ios-iphone-896h-3x.png"
|
||||||
|
|
||||||
|
make_splash '768x1024' 230 "$splash/ios-ipad-portrait7.png"
|
||||||
|
make_splash '1536x2048' 460 "$splash/ios-ipad-portrait-retina7.png"
|
||||||
|
make_splash '1668x2224' 500 "$splash/ios-ipad-1112h-2x.png"
|
||||||
|
make_splash '1668x2388' 500 "$splash/ios-ipad-1194h-2x.png"
|
||||||
|
make_splash '2048x2732' 610 "$splash/ios-ipad-1366h-2x.png"
|
||||||
|
|
||||||
|
echo "Generated App icons and portrait splash images from src/static/brand-logo.png"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const projectRoot = resolve(import.meta.dirname, '..');
|
||||||
|
const manifestPath = resolve(projectRoot, 'src/manifest.json');
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||||
|
const requirePrivacy = process.argv.includes('--require-privacy');
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
const fail = (message) => failures.push(message);
|
||||||
|
const readPng = (relativePath) => {
|
||||||
|
if (typeof relativePath !== 'string' || !relativePath) {
|
||||||
|
fail('manifest is missing an App asset path');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const path = resolve(projectRoot, 'src', relativePath);
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
fail(`missing asset: ${relativePath}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const header = readFileSync(path).subarray(0, 29);
|
||||||
|
const isPng = header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||||
|
if (!isPng || header.toString('ascii', 12, 16) !== 'IHDR') {
|
||||||
|
fail(`invalid PNG: ${relativePath}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: header.readUInt32BE(16),
|
||||||
|
height: header.readUInt32BE(20),
|
||||||
|
colorType: header[25]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectPng = (relativePath, width, height, opaque = false) => {
|
||||||
|
const png = readPng(relativePath);
|
||||||
|
if (!png) return;
|
||||||
|
if (png.width !== width || png.height !== height) {
|
||||||
|
fail(`unexpected dimensions for ${relativePath}: got ${png.width}x${png.height}, expected ${width}x${height}`);
|
||||||
|
}
|
||||||
|
if (opaque && (png.colorType === 4 || png.colorType === 6)) {
|
||||||
|
fail(`iOS App icon must not contain alpha: ${relativePath}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = manifest['app-plus'];
|
||||||
|
const distribute = app?.distribute;
|
||||||
|
const icons = distribute?.icons;
|
||||||
|
const splashscreen = distribute?.splashscreen;
|
||||||
|
|
||||||
|
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
|
||||||
|
fail('app-plus.screenOrientation must include portrait-primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidIcons = [
|
||||||
|
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
|
||||||
|
];
|
||||||
|
for (const [density, size] of androidIcons) {
|
||||||
|
expectPng(icons?.android?.[density], size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iosIconPaths = [
|
||||||
|
[icons?.ios?.appstore, 1024],
|
||||||
|
[icons?.ios?.iphone?.['app@2x'], 120], [icons?.ios?.iphone?.['app@3x'], 180],
|
||||||
|
[icons?.ios?.iphone?.['spotlight@2x'], 80], [icons?.ios?.iphone?.['spotlight@3x'], 120],
|
||||||
|
[icons?.ios?.iphone?.['settings@2x'], 58], [icons?.ios?.iphone?.['settings@3x'], 87],
|
||||||
|
[icons?.ios?.iphone?.['notification@2x'], 40], [icons?.ios?.iphone?.['notification@3x'], 60],
|
||||||
|
[icons?.ios?.ipad?.app, 76], [icons?.ios?.ipad?.['app@2x'], 152], [icons?.ios?.ipad?.['proapp@2x'], 167],
|
||||||
|
[icons?.ios?.ipad?.spotlight, 40], [icons?.ios?.ipad?.['spotlight@2x'], 80],
|
||||||
|
[icons?.ios?.ipad?.settings, 29], [icons?.ios?.ipad?.['settings@2x'], 58],
|
||||||
|
[icons?.ios?.ipad?.notification, 20], [icons?.ios?.ipad?.['notification@2x'], 40]
|
||||||
|
];
|
||||||
|
for (const [relativePath, size] of iosIconPaths) expectPng(relativePath, size, size, true);
|
||||||
|
|
||||||
|
const splashPaths = [
|
||||||
|
[splashscreen?.android?.hdpi, 480, 762], [splashscreen?.android?.xhdpi, 720, 1242], [splashscreen?.android?.xxhdpi, 1080, 1882],
|
||||||
|
[splashscreen?.ios?.iphone?.retina40, 640, 1136], [splashscreen?.ios?.iphone?.retina47, 750, 1334],
|
||||||
|
[splashscreen?.ios?.iphone?.retina55, 1242, 2208], [splashscreen?.ios?.iphone?.iphonex, 1125, 2436],
|
||||||
|
[splashscreen?.ios?.iphone?.['portrait-896h@2x'], 828, 1792], [splashscreen?.ios?.iphone?.['portrait-896h@3x'], 1242, 2688],
|
||||||
|
[splashscreen?.ios?.ipad?.portrait7, 768, 1024], [splashscreen?.ios?.ipad?.['portrait-retina7'], 1536, 2048],
|
||||||
|
[splashscreen?.ios?.ipad?.['portrait-1112h@2x'], 1668, 2224], [splashscreen?.ios?.ipad?.['portrait-1194h@2x'], 1668, 2388],
|
||||||
|
[splashscreen?.ios?.ipad?.['portrait-1366h@2x'], 2048, 2732]
|
||||||
|
];
|
||||||
|
for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height);
|
||||||
|
|
||||||
|
const privacyPath = resolve(projectRoot, 'androidPrivacy.json');
|
||||||
|
if (requirePrivacy) {
|
||||||
|
if (!existsSync(privacyPath)) {
|
||||||
|
fail('androidPrivacy.json is required for release; copy androidPrivacy.json.example and replace both legal links');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
|
||||||
|
const links = [...String(privacy.message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
if (privacy.prompt !== 'template' || links.length < 2 || links.some((link) => !/^https:\/\//.test(link) || link.includes('__'))) {
|
||||||
|
fail('androidPrivacy.json must use prompt=template and two final HTTPS legal links');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fail('androidPrivacy.json must be valid JSON without comments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error(`App asset verification failed:\n- ${failures.join('\n- ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`App asset verification passed (${androidIcons.length + iosIconPaths.length} icons, ${splashPaths.length} splash images).`);
|
||||||
@@ -102,6 +102,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { startSpeechCapture } from '@/services/speech-capture';
|
||||||
|
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -127,7 +129,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'send', text: string): void;
|
(e: 'send', text: string): void;
|
||||||
(e: 'voice-blob', blob: Blob): void;
|
(e: 'voice-capture', capture: SpeechCapture): void;
|
||||||
(e: 'pick-audio'): void;
|
(e: 'pick-audio'): void;
|
||||||
(e: 'pick-media'): void;
|
(e: 'pick-media'): void;
|
||||||
(e: 'clear-media'): void;
|
(e: 'clear-media'): void;
|
||||||
@@ -141,14 +143,10 @@ const voiceMode = ref(true);
|
|||||||
const panelOpen = ref(false);
|
const panelOpen = ref(false);
|
||||||
const recording = ref(false);
|
const recording = ref(false);
|
||||||
|
|
||||||
let recorder: MediaRecorder | null = null;
|
let captureSession: SpeechCaptureSession | null = null;
|
||||||
let stream: MediaStream | null = null;
|
|
||||||
let holdGeneration = 0;
|
let holdGeneration = 0;
|
||||||
let discardRecording = false;
|
let discardRecording = false;
|
||||||
|
|
||||||
const supportsRecording = () =>
|
|
||||||
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) && typeof MediaRecorder !== 'undefined';
|
|
||||||
|
|
||||||
const toggleVoiceMode = () => {
|
const toggleVoiceMode = () => {
|
||||||
voiceMode.value = !voiceMode.value;
|
voiceMode.value = !voiceMode.value;
|
||||||
panelOpen.value = false;
|
panelOpen.value = false;
|
||||||
@@ -174,69 +172,58 @@ const panelAction = (key: string) => {
|
|||||||
emit('panel', key);
|
emit('panel', key);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopTracks = () => {
|
|
||||||
stream?.getTracks().forEach((track) => track.stop());
|
|
||||||
stream = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startHold = async () => {
|
const startHold = async () => {
|
||||||
if (props.disabled || props.transcribing || recording.value) return;
|
if (props.disabled || props.transcribing || recording.value) return;
|
||||||
if (!supportsRecording()) {
|
|
||||||
emit('pick-audio');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generation = ++holdGeneration;
|
const generation = ++holdGeneration;
|
||||||
discardRecording = false;
|
discardRecording = false;
|
||||||
|
let fallbackRequested = false;
|
||||||
try {
|
try {
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const session = await startSpeechCapture({
|
||||||
if (generation !== holdGeneration) {
|
onCapture: (capture) => {
|
||||||
mediaStream.getTracks().forEach((track) => track.stop());
|
if (generation !== holdGeneration) return;
|
||||||
|
captureSession = null;
|
||||||
|
recording.value = false;
|
||||||
|
if (!discardRecording) emit('voice-capture', capture);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (generation !== holdGeneration) return;
|
||||||
|
fallbackRequested = true;
|
||||||
|
captureSession = null;
|
||||||
|
recording.value = false;
|
||||||
|
emit('pick-audio');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session || generation !== holdGeneration) {
|
||||||
|
session?.cancel();
|
||||||
|
if (generation === holdGeneration && !fallbackRequested) emit('pick-audio');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream = mediaStream;
|
captureSession = session;
|
||||||
const mediaRecorder = new MediaRecorder(mediaStream);
|
|
||||||
const chunks: Blob[] = [];
|
|
||||||
recorder = mediaRecorder;
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data?.size) chunks.push(event.data);
|
|
||||||
};
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
recording.value = false;
|
|
||||||
recorder = null;
|
|
||||||
stopTracks();
|
|
||||||
if (discardRecording) return;
|
|
||||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
|
||||||
if (blob.size) emit('voice-blob', blob);
|
|
||||||
};
|
|
||||||
mediaRecorder.start();
|
|
||||||
recording.value = true;
|
recording.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
stopTracks();
|
captureSession = null;
|
||||||
if (generation === holdGeneration) emit('pick-audio');
|
if (generation === holdGeneration) emit('pick-audio');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const endHold = () => {
|
const endHold = () => {
|
||||||
if (!recording.value) return;
|
if (!recording.value) return;
|
||||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
captureSession?.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelHold = () => {
|
const cancelHold = () => {
|
||||||
if (!recording.value) return;
|
if (!recording.value) return;
|
||||||
discardRecording = true;
|
discardRecording = true;
|
||||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
captureSession?.cancel();
|
||||||
|
captureSession = null;
|
||||||
|
recording.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const teardown = () => {
|
const teardown = () => {
|
||||||
holdGeneration += 1;
|
holdGeneration += 1;
|
||||||
discardRecording = true;
|
discardRecording = true;
|
||||||
if (recorder && recorder.state !== 'inactive') {
|
captureSession?.cancel();
|
||||||
recorder.ondataavailable = null;
|
captureSession = null;
|
||||||
recorder.onstop = null;
|
|
||||||
recorder.stop();
|
|
||||||
}
|
|
||||||
recorder = null;
|
|
||||||
stopTracks();
|
|
||||||
recording.value = false;
|
recording.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,103 @@
|
|||||||
"uniStatistics": {
|
"uniStatistics": {
|
||||||
"enable": false
|
"enable": false
|
||||||
},
|
},
|
||||||
|
"app-plus": {
|
||||||
|
"checkPermissionDenied": true,
|
||||||
|
"screenOrientation": [
|
||||||
|
"portrait-primary"
|
||||||
|
],
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": false
|
||||||
|
},
|
||||||
|
"safearea": {
|
||||||
|
"background": "#f7f8fa",
|
||||||
|
"bottom": {
|
||||||
|
"offset": "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ssl": {
|
||||||
|
"untrustedca": "refuse"
|
||||||
|
},
|
||||||
|
"distribute": {
|
||||||
|
"icons": {
|
||||||
|
"android": {
|
||||||
|
"hdpi": "static/app/icons/android-hdpi.png",
|
||||||
|
"xhdpi": "static/app/icons/android-xhdpi.png",
|
||||||
|
"xxhdpi": "static/app/icons/android-xxhdpi.png",
|
||||||
|
"xxxhdpi": "static/app/icons/android-xxxhdpi.png"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"appstore": "static/app/icons/ios-appstore.png",
|
||||||
|
"iphone": {
|
||||||
|
"app@2x": "static/app/icons/ios-iphone-app-2x.png",
|
||||||
|
"app@3x": "static/app/icons/ios-iphone-app-3x.png",
|
||||||
|
"spotlight@2x": "static/app/icons/ios-iphone-spotlight-2x.png",
|
||||||
|
"spotlight@3x": "static/app/icons/ios-iphone-spotlight-3x.png",
|
||||||
|
"settings@2x": "static/app/icons/ios-iphone-settings-2x.png",
|
||||||
|
"settings@3x": "static/app/icons/ios-iphone-settings-3x.png",
|
||||||
|
"notification@2x": "static/app/icons/ios-iphone-notification-2x.png",
|
||||||
|
"notification@3x": "static/app/icons/ios-iphone-notification-3x.png"
|
||||||
|
},
|
||||||
|
"ipad": {
|
||||||
|
"app": "static/app/icons/ios-ipad-app.png",
|
||||||
|
"app@2x": "static/app/icons/ios-ipad-app-2x.png",
|
||||||
|
"proapp@2x": "static/app/icons/ios-ipad-pro-app-2x.png",
|
||||||
|
"spotlight": "static/app/icons/ios-ipad-spotlight.png",
|
||||||
|
"spotlight@2x": "static/app/icons/ios-ipad-spotlight-2x.png",
|
||||||
|
"settings": "static/app/icons/ios-ipad-settings.png",
|
||||||
|
"settings@2x": "static/app/icons/ios-ipad-settings-2x.png",
|
||||||
|
"notification": "static/app/icons/ios-ipad-notification.png",
|
||||||
|
"notification@2x": "static/app/icons/ios-ipad-notification-2x.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"splashscreen": {
|
||||||
|
"iosStyle": "default",
|
||||||
|
"ios": {
|
||||||
|
"iphone": {
|
||||||
|
"retina40": "static/app/splash/ios-iphone-retina40.png",
|
||||||
|
"retina47": "static/app/splash/ios-iphone-retina47.png",
|
||||||
|
"retina55": "static/app/splash/ios-iphone-retina55.png",
|
||||||
|
"iphonex": "static/app/splash/ios-iphone-x.png",
|
||||||
|
"portrait-896h@2x": "static/app/splash/ios-iphone-896h-2x.png",
|
||||||
|
"portrait-896h@3x": "static/app/splash/ios-iphone-896h-3x.png"
|
||||||
|
},
|
||||||
|
"ipad": {
|
||||||
|
"portrait7": "static/app/splash/ios-ipad-portrait7.png",
|
||||||
|
"portrait-retina7": "static/app/splash/ios-ipad-portrait-retina7.png",
|
||||||
|
"portrait-1112h@2x": "static/app/splash/ios-ipad-1112h-2x.png",
|
||||||
|
"portrait-1194h@2x": "static/app/splash/ios-ipad-1194h-2x.png",
|
||||||
|
"portrait-1366h@2x": "static/app/splash/ios-ipad-1366h-2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"androidStyle": "default",
|
||||||
|
"android": {
|
||||||
|
"hdpi": "static/app/splash/android-hdpi.png",
|
||||||
|
"xhdpi": "static/app/splash/android-xhdpi.png",
|
||||||
|
"xxhdpi": "static/app/splash/android-xxhdpi.png"
|
||||||
|
},
|
||||||
|
"androidTranslucent": false,
|
||||||
|
"androidNavigationBarColor": "#F7F8FA"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"packagename": "com.yincheng.wygj",
|
||||||
|
"abiFilters": [
|
||||||
|
"armeabi-v7a",
|
||||||
|
"arm64-v8a"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"appid": "com.yincheng.wygj",
|
||||||
|
"devices": "universal",
|
||||||
|
"deploymentTarget": "15.0",
|
||||||
|
"privacyDescription": {
|
||||||
|
"NSMicrophoneUsageDescription": "用于录制训练、提问和面试回答,并将音频转写为文本。",
|
||||||
|
"NSPhotoLibraryUsageDescription": "用于选择图片或视频作为本次问题、资料或成果投稿的附件。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"h5": {
|
"h5": {
|
||||||
"router": {
|
"router": {
|
||||||
"base": "/h5/"
|
"base": "/h5/"
|
||||||
|
|||||||
@@ -159,9 +159,11 @@ import {
|
|||||||
createSpeechPlaybackController,
|
createSpeechPlaybackController,
|
||||||
INTERVIEWER_VOICE_PROFILE,
|
INTERVIEWER_VOICE_PROFILE,
|
||||||
normalizeOssId,
|
normalizeOssId,
|
||||||
transcribeSpeechBlob,
|
transcribeSpeechCapture,
|
||||||
transcribeSpeechFile
|
transcribeSpeechFile
|
||||||
} from '@/services/speech';
|
} from '@/services/speech';
|
||||||
|
import { startSpeechCapture } from '@/services/speech-capture';
|
||||||
|
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||||
|
|
||||||
const currentPath = '/pages/candidate/interview/index';
|
const currentPath = '/pages/candidate/interview/index';
|
||||||
const records = ref<InterviewRecord[]>([]);
|
const records = ref<InterviewRecord[]>([]);
|
||||||
@@ -177,8 +179,7 @@ const message = ref('');
|
|||||||
const transcribingQuestionId = ref('');
|
const transcribingQuestionId = ref('');
|
||||||
const recordingQuestionId = ref('');
|
const recordingQuestionId = ref('');
|
||||||
const recordingQuestionStarting = ref(false);
|
const recordingQuestionStarting = ref(false);
|
||||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
let questionCapture: SpeechCaptureSession | null = null;
|
||||||
const mediaStream = ref<MediaStream | null>(null);
|
|
||||||
let questionCaptureGeneration = 0;
|
let questionCaptureGeneration = 0;
|
||||||
let questionTranscriptionGeneration = 0;
|
let questionTranscriptionGeneration = 0;
|
||||||
let abortQuestionTranscription: (() => void) | null = null;
|
let abortQuestionTranscription: (() => void) | null = null;
|
||||||
@@ -298,11 +299,6 @@ const interviewResultSpeech = () => {
|
|||||||
].filter(Boolean).join('。');
|
].filter(Boolean).join('。');
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopQuestionMediaTracks = () => {
|
|
||||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
|
||||||
mediaStream.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelQuestionTranscription = () => {
|
const cancelQuestionTranscription = () => {
|
||||||
questionTranscriptionGeneration += 1;
|
questionTranscriptionGeneration += 1;
|
||||||
abortQuestionTranscription?.();
|
abortQuestionTranscription?.();
|
||||||
@@ -314,14 +310,8 @@ const cancelQuestionRecording = () => {
|
|||||||
questionCaptureGeneration += 1;
|
questionCaptureGeneration += 1;
|
||||||
cancelQuestionTranscription();
|
cancelQuestionTranscription();
|
||||||
recordingQuestionStarting.value = false;
|
recordingQuestionStarting.value = false;
|
||||||
const recorder = mediaRecorder.value;
|
questionCapture?.cancel();
|
||||||
if (recorder && recorder.state !== 'inactive') {
|
questionCapture = null;
|
||||||
recorder.ondataavailable = null;
|
|
||||||
recorder.onstop = null;
|
|
||||||
recorder.stop();
|
|
||||||
}
|
|
||||||
mediaRecorder.value = null;
|
|
||||||
stopQuestionMediaTracks();
|
|
||||||
recordingQuestionId.value = '';
|
recordingQuestionId.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,13 +351,13 @@ const transcribeQuestionFile = async (questionId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
const transcribeQuestionCapture = async (questionId: string, capture: SpeechCapture) => {
|
||||||
if (transcribingQuestionId.value) return;
|
if (transcribingQuestionId.value) return;
|
||||||
const generation = ++questionTranscriptionGeneration;
|
const generation = ++questionTranscriptionGeneration;
|
||||||
transcribingQuestionId.value = questionId;
|
transcribingQuestionId.value = questionId;
|
||||||
message.value = '语音转写中';
|
message.value = '语音转写中';
|
||||||
try {
|
try {
|
||||||
const result = await transcribeSpeechBlob(blob, 'candidate-answer.webm', (abort) => {
|
const result = await transcribeSpeechCapture(capture, 'candidate-answer.webm', (abort) => {
|
||||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||||
});
|
});
|
||||||
if (generation !== questionTranscriptionGeneration) return;
|
if (generation !== questionTranscriptionGeneration) return;
|
||||||
@@ -385,42 +375,35 @@ const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
|||||||
|
|
||||||
const startQuestionRecording = async (questionId: string) => {
|
const startQuestionRecording = async (questionId: string) => {
|
||||||
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
|
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
|
||||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
||||||
await transcribeQuestionFile(questionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generation = ++questionCaptureGeneration;
|
const generation = ++questionCaptureGeneration;
|
||||||
recordingQuestionStarting.value = true;
|
recordingQuestionStarting.value = true;
|
||||||
|
let fallbackRequested = false;
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const session = await startSpeechCapture({
|
||||||
if (generation !== questionCaptureGeneration) {
|
onCapture: (capture) => {
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
if (generation !== questionCaptureGeneration) return;
|
||||||
return;
|
questionCapture = null;
|
||||||
}
|
|
||||||
mediaStream.value = stream;
|
|
||||||
const recorder = new MediaRecorder(stream);
|
|
||||||
const chunks: Blob[] = [];
|
|
||||||
mediaRecorder.value = recorder;
|
|
||||||
recordingQuestionId.value = questionId;
|
|
||||||
recorder.ondataavailable = (event) => {
|
|
||||||
if (event.data?.size) chunks.push(event.data);
|
|
||||||
};
|
|
||||||
recorder.onstop = () => {
|
|
||||||
mediaRecorder.value = null;
|
|
||||||
recordingQuestionId.value = '';
|
recordingQuestionId.value = '';
|
||||||
stopQuestionMediaTracks();
|
void transcribeQuestionCapture(questionId, capture);
|
||||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
},
|
||||||
if (!blob.size) {
|
onError: () => {
|
||||||
message.value = '未录到音频,请改用文字输入';
|
if (generation !== questionCaptureGeneration) return;
|
||||||
|
fallbackRequested = true;
|
||||||
|
questionCapture = null;
|
||||||
|
recordingQuestionId.value = '';
|
||||||
|
void transcribeQuestionFile(questionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session || generation !== questionCaptureGeneration) {
|
||||||
|
session?.cancel();
|
||||||
|
if (generation === questionCaptureGeneration && !fallbackRequested) await transcribeQuestionFile(questionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void transcribeQuestionBlob(questionId, blob);
|
questionCapture = session;
|
||||||
};
|
recordingQuestionId.value = questionId;
|
||||||
recorder.start();
|
|
||||||
message.value = '正在录音';
|
message.value = '正在录音';
|
||||||
} catch {
|
} catch {
|
||||||
recordingQuestionId.value = '';
|
recordingQuestionId.value = '';
|
||||||
stopQuestionMediaTracks();
|
|
||||||
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
|
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
|
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
|
||||||
@@ -428,10 +411,9 @@ const startQuestionRecording = async (questionId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopQuestionRecording = () => {
|
const stopQuestionRecording = () => {
|
||||||
const recorder = mediaRecorder.value;
|
if (!recordingQuestionId.value || !questionCapture) return;
|
||||||
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
|
|
||||||
message.value = '录音结束,正在转写';
|
message.value = '录音结束,正在转写';
|
||||||
recorder.stop();
|
questionCapture.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleQuestionRecording = (questionId: string) => {
|
const toggleQuestionRecording = (questionId: string) => {
|
||||||
|
|||||||
@@ -306,7 +306,7 @@
|
|||||||
:maxlength="500"
|
:maxlength="500"
|
||||||
:panel-items="practicePanelItems"
|
:panel-items="practicePanelItems"
|
||||||
@send="handleComposerSend"
|
@send="handleComposerSend"
|
||||||
@voice-blob="handleSessionVoiceBlob"
|
@voice-capture="handleSessionVoiceCapture"
|
||||||
@pick-audio="composerPickAudio"
|
@pick-audio="composerPickAudio"
|
||||||
@panel="handlePanelAction"
|
@panel="handlePanelAction"
|
||||||
/>
|
/>
|
||||||
@@ -397,7 +397,9 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
|
|||||||
import { searchKnowledge } from '@/services/knowledge';
|
import { searchKnowledge } from '@/services/knowledge';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
import { ensureEmployeePosition } from '@/services/position';
|
import { ensureEmployeePosition } from '@/services/position';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||||
|
import { startSpeechCapture } from '@/services/speech-capture';
|
||||||
|
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||||
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
|
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
|
||||||
import { stripInternalCodes } from '@/services/text';
|
import { stripInternalCodes } from '@/services/text';
|
||||||
@@ -473,8 +475,7 @@ const transcribing = ref(false);
|
|||||||
const draftAudioUrl = ref('');
|
const draftAudioUrl = ref('');
|
||||||
const draftAudioOssId = ref<string | undefined>();
|
const draftAudioOssId = ref<string | undefined>();
|
||||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
let recordingCapture: SpeechCaptureSession | null = null;
|
||||||
const mediaStream = ref<MediaStream | null>(null);
|
|
||||||
let recordingCaptureGeneration = 0;
|
let recordingCaptureGeneration = 0;
|
||||||
let transcriptionGeneration = 0;
|
let transcriptionGeneration = 0;
|
||||||
let abortTranscription: (() => void) | null = null;
|
let abortTranscription: (() => void) | null = null;
|
||||||
@@ -665,12 +666,12 @@ const submitTranscribed = async (text: string, audioUrl: string, ossId?: string)
|
|||||||
await submit();
|
await submit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionVoiceBlob = async (blob: Blob) => {
|
const handleSessionVoiceCapture = async (capture: SpeechCapture) => {
|
||||||
if (transcribing.value || busy.value) return;
|
if (transcribing.value || busy.value) return;
|
||||||
const generation = ++transcriptionGeneration;
|
const generation = ++transcriptionGeneration;
|
||||||
transcribing.value = true;
|
transcribing.value = true;
|
||||||
try {
|
try {
|
||||||
const asr = await transcribeSpeechBlob(blob, 'practice-turn.webm', (abort) => {
|
const asr = await transcribeSpeechCapture(capture, 'practice-turn.webm', (abort) => {
|
||||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||||
});
|
});
|
||||||
if (generation !== transcriptionGeneration) return;
|
if (generation !== transcriptionGeneration) return;
|
||||||
@@ -907,11 +908,6 @@ const clearDraftAudio = () => {
|
|||||||
draftAudioOssId.value = undefined;
|
draftAudioOssId.value = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopMediaTracks = () => {
|
|
||||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
|
||||||
mediaStream.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelTranscription = () => {
|
const cancelTranscription = () => {
|
||||||
transcriptionGeneration += 1;
|
transcriptionGeneration += 1;
|
||||||
abortTranscription?.();
|
abortTranscription?.();
|
||||||
@@ -923,14 +919,8 @@ const cancelRecording = () => {
|
|||||||
recordingCaptureGeneration += 1;
|
recordingCaptureGeneration += 1;
|
||||||
cancelTranscription();
|
cancelTranscription();
|
||||||
recordingStarting.value = false;
|
recordingStarting.value = false;
|
||||||
const recorder = mediaRecorder.value;
|
recordingCapture?.cancel();
|
||||||
if (recorder && recorder.state !== 'inactive') {
|
recordingCapture = null;
|
||||||
recorder.ondataavailable = null;
|
|
||||||
recorder.onstop = null;
|
|
||||||
recorder.stop();
|
|
||||||
}
|
|
||||||
mediaRecorder.value = null;
|
|
||||||
stopMediaTracks();
|
|
||||||
recording.value = false;
|
recording.value = false;
|
||||||
recordingAssignmentId.value = null;
|
recordingAssignmentId.value = null;
|
||||||
};
|
};
|
||||||
@@ -965,13 +955,13 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
|
|||||||
message.value = '每日一练语音已转成文字,请确认后提交';
|
message.value = '每日一练语音已转成文字,请确认后提交';
|
||||||
};
|
};
|
||||||
|
|
||||||
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
|
const transcribeCapture = async (capture: SpeechCapture, assignmentId?: number) => {
|
||||||
if (transcribing.value) return;
|
if (transcribing.value) return;
|
||||||
const generation = ++transcriptionGeneration;
|
const generation = ++transcriptionGeneration;
|
||||||
transcribing.value = true;
|
transcribing.value = true;
|
||||||
message.value = '语音转写中';
|
message.value = '语音转写中';
|
||||||
try {
|
try {
|
||||||
const result = await transcribeSpeechBlob(blob, 'practice-audio.webm', (abort) => {
|
const result = await transcribeSpeechCapture(capture, 'practice-audio.webm', (abort) => {
|
||||||
if (generation === transcriptionGeneration) abortTranscription = abort;
|
if (generation === transcriptionGeneration) abortTranscription = abort;
|
||||||
});
|
});
|
||||||
if (generation !== transcriptionGeneration) return;
|
if (generation !== transcriptionGeneration) return;
|
||||||
@@ -1023,45 +1013,40 @@ const pickAudio = async (assignmentId?: number) => {
|
|||||||
const startRecording = async (assignmentId?: number) => {
|
const startRecording = async (assignmentId?: number) => {
|
||||||
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
|
||||||
if (!requireLogin()) return;
|
if (!requireLogin()) return;
|
||||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
||||||
await pickAudio(assignmentId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generation = ++recordingCaptureGeneration;
|
const generation = ++recordingCaptureGeneration;
|
||||||
recordingStarting.value = true;
|
recordingStarting.value = true;
|
||||||
|
let fallbackRequested = false;
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const session = await startSpeechCapture({
|
||||||
if (generation !== recordingCaptureGeneration) {
|
onCapture: (capture) => {
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
if (generation !== recordingCaptureGeneration) return;
|
||||||
return;
|
recordingCapture = null;
|
||||||
}
|
|
||||||
mediaStream.value = stream;
|
|
||||||
const recorder = new MediaRecorder(stream);
|
|
||||||
const chunks: Blob[] = [];
|
|
||||||
mediaRecorder.value = recorder;
|
|
||||||
recordingAssignmentId.value = assignmentId ?? null;
|
|
||||||
recorder.ondataavailable = (event) => {
|
|
||||||
if (event.data?.size) chunks.push(event.data);
|
|
||||||
};
|
|
||||||
recorder.onstop = () => {
|
|
||||||
recording.value = false;
|
recording.value = false;
|
||||||
mediaRecorder.value = null;
|
|
||||||
stopMediaTracks();
|
|
||||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
|
||||||
if (!blob.size) {
|
|
||||||
message.value = '未录到音频,请改用文字输入';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetAssignmentId = assignmentId;
|
const targetAssignmentId = assignmentId;
|
||||||
recordingAssignmentId.value = null;
|
recordingAssignmentId.value = null;
|
||||||
void transcribeBlob(blob, targetAssignmentId);
|
void transcribeCapture(capture, targetAssignmentId);
|
||||||
};
|
},
|
||||||
recorder.start();
|
onError: () => {
|
||||||
|
if (generation !== recordingCaptureGeneration) return;
|
||||||
|
fallbackRequested = true;
|
||||||
|
recordingCapture = null;
|
||||||
|
recording.value = false;
|
||||||
|
recordingAssignmentId.value = null;
|
||||||
|
void pickAudio(assignmentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session || generation !== recordingCaptureGeneration) {
|
||||||
|
session?.cancel();
|
||||||
|
if (generation === recordingCaptureGeneration && !fallbackRequested) await pickAudio(assignmentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordingCapture = session;
|
||||||
|
recordingAssignmentId.value = assignmentId ?? null;
|
||||||
recording.value = true;
|
recording.value = true;
|
||||||
message.value = '正在录音';
|
message.value = '正在录音';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordingAssignmentId.value = null;
|
recordingAssignmentId.value = null;
|
||||||
stopMediaTracks();
|
recordingCapture = null;
|
||||||
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
|
||||||
@@ -1069,10 +1054,9 @@ const startRecording = async (assignmentId?: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopRecording = () => {
|
const stopRecording = () => {
|
||||||
const recorder = mediaRecorder.value;
|
if (!recording.value || !recordingCapture) return;
|
||||||
if (!recording.value || !recorder || recorder.state === 'inactive') return;
|
|
||||||
message.value = '录音结束,正在转写';
|
message.value = '录音结束,正在转写';
|
||||||
recorder.stop();
|
recordingCapture.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRecording = (assignmentId?: number) => {
|
const toggleRecording = (assignmentId?: number) => {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@
|
|||||||
:maxlength="500"
|
:maxlength="500"
|
||||||
:panel-items="composerPanelItems"
|
:panel-items="composerPanelItems"
|
||||||
@send="handleComposerSend"
|
@send="handleComposerSend"
|
||||||
@voice-blob="handleVoiceBlob"
|
@voice-capture="handleVoiceCapture"
|
||||||
@pick-audio="pickVoiceAudio"
|
@pick-audio="pickVoiceAudio"
|
||||||
@panel="handlePanelAction"
|
@panel="handlePanelAction"
|
||||||
/>
|
/>
|
||||||
@@ -239,9 +239,10 @@ import {
|
|||||||
chooseSpeechAudio,
|
chooseSpeechAudio,
|
||||||
createSpeechPlaybackController,
|
createSpeechPlaybackController,
|
||||||
MENTOR_VOICE_PROFILE,
|
MENTOR_VOICE_PROFILE,
|
||||||
transcribeSpeechBlob,
|
transcribeSpeechCapture,
|
||||||
transcribeSpeechFile
|
transcribeSpeechFile
|
||||||
} from '@/services/speech';
|
} from '@/services/speech';
|
||||||
|
import type { SpeechCapture } from '@/services/speech-capture';
|
||||||
import type { SpeechPlaybackStatus } from '@/types/api';
|
import type { SpeechPlaybackStatus } from '@/types/api';
|
||||||
import {
|
import {
|
||||||
listMyWorkReports,
|
listMyWorkReports,
|
||||||
@@ -473,13 +474,13 @@ const cancelVoiceTranscription = () => {
|
|||||||
voiceTranscribing.value = false;
|
voiceTranscribing.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceBlob = async (blob: Blob) => {
|
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||||
if (voiceTranscribing.value || composerDisabled.value) return;
|
if (voiceTranscribing.value || composerDisabled.value) return;
|
||||||
const generation = ++voiceTranscriptionGeneration;
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
const accountKey = currentAccountKey();
|
const accountKey = currentAccountKey();
|
||||||
voiceTranscribing.value = true;
|
voiceTranscribing.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await transcribeSpeechBlob(blob, 'work-report.webm', (abort) => {
|
const result = await transcribeSpeechCapture(capture, 'work-report.webm', (abort) => {
|
||||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||||
});
|
});
|
||||||
if (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
|
if (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
|
||||||
:panel-items="composerPanelItems"
|
:panel-items="composerPanelItems"
|
||||||
@send="handleComposerSend"
|
@send="handleComposerSend"
|
||||||
@voice-blob="handleVoiceBlob"
|
@voice-capture="handleVoiceCapture"
|
||||||
@pick-audio="pickVoiceAudio"
|
@pick-audio="pickVoiceAudio"
|
||||||
@pick-media="pickMedia"
|
@pick-media="pickMedia"
|
||||||
@clear-media="clearMedia"
|
@clear-media="clearMedia"
|
||||||
@@ -440,7 +440,8 @@ import {
|
|||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
|
||||||
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
|
import { ensureEmployeePosition, fetchMobileIdentity } from '@/services/position';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||||
|
import type { SpeechCapture } from '@/services/speech-capture';
|
||||||
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
import { downloadSummaryCardImage } from '@/services/summary-card-image';
|
||||||
import { getMemoryCandidates } from '@/services/personal-assistant';
|
import { getMemoryCandidates } from '@/services/personal-assistant';
|
||||||
import { currentAccountKey } from '@/services/request-scope';
|
import { currentAccountKey } from '@/services/request-scope';
|
||||||
@@ -1089,12 +1090,12 @@ const cancelVoiceTranscription = () => {
|
|||||||
voiceTranscribing.value = false;
|
voiceTranscribing.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceBlob = async (blob: Blob) => {
|
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||||
if (voiceTranscribing.value) return;
|
if (voiceTranscribing.value) return;
|
||||||
const generation = ++voiceTranscriptionGeneration;
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
voiceTranscribing.value = true;
|
voiceTranscribing.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
|
const result = await transcribeSpeechCapture(capture, 'sop-question.webm', (abort) => {
|
||||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||||
});
|
});
|
||||||
if (generation !== voiceTranscriptionGeneration) return;
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
@@ -1552,14 +1553,19 @@ onUnload(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.broadcast-context-action {
|
.broadcast-context-action {
|
||||||
|
display: flex;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
border: 1px solid #e7aeb7;
|
border: 1px solid #e7aeb7;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #b51c2b;
|
color: #b51c2b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.broadcast-context-action.end {
|
.broadcast-context-action.end {
|
||||||
|
|||||||
@@ -211,7 +211,9 @@ import { getMobileHome } from '@/services/home';
|
|||||||
import { getPracticeAssignments } from '@/services/practice';
|
import { getPracticeAssignments } from '@/services/practice';
|
||||||
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { resetPageScroll } from '@/services/navigation';
|
import { resetPageScroll } from '@/services/navigation';
|
||||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||||
|
import { startSpeechCapture } from '@/services/speech-capture';
|
||||||
|
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
|
||||||
import { stripInternalCodes } from '@/services/text';
|
import { stripInternalCodes } from '@/services/text';
|
||||||
import { ensureEmployeePosition } from '@/services/position';
|
import { ensureEmployeePosition } from '@/services/position';
|
||||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||||
@@ -393,8 +395,7 @@ const loadBroadcastUnreadCount = async () => {
|
|||||||
const recording = ref(false);
|
const recording = ref(false);
|
||||||
const cancelling = ref(false);
|
const cancelling = ref(false);
|
||||||
const transcribing = ref(false);
|
const transcribing = ref(false);
|
||||||
let recorder: MediaRecorder | null = null;
|
let holdCapture: SpeechCaptureSession | null = null;
|
||||||
let recordStream: MediaStream | null = null;
|
|
||||||
let holdGeneration = 0;
|
let holdGeneration = 0;
|
||||||
let discardRecording = false;
|
let discardRecording = false;
|
||||||
let holdStartY = 0;
|
let holdStartY = 0;
|
||||||
@@ -411,57 +412,45 @@ const voiceSub = computed(() => {
|
|||||||
return '松开发送,上滑取消';
|
return '松开发送,上滑取消';
|
||||||
});
|
});
|
||||||
|
|
||||||
const supportsRecording = () =>
|
|
||||||
typeof navigator !== 'undefined'
|
|
||||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
|
||||||
&& typeof MediaRecorder !== 'undefined';
|
|
||||||
|
|
||||||
const stopRecordTracks = () => {
|
|
||||||
recordStream?.getTracks().forEach((track) => track.stop());
|
|
||||||
recordStream = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startHold = async (event?: TouchEvent | MouseEvent) => {
|
const startHold = async (event?: TouchEvent | MouseEvent) => {
|
||||||
if (recording.value || transcribing.value) return;
|
if (recording.value || transcribing.value) return;
|
||||||
if (!loggedIn.value) {
|
if (!loggedIn.value) {
|
||||||
goLogin();
|
goLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!supportsRecording()) {
|
|
||||||
void pickAudioFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generation = ++holdGeneration;
|
const generation = ++holdGeneration;
|
||||||
discardRecording = false;
|
discardRecording = false;
|
||||||
cancelling.value = false;
|
cancelling.value = false;
|
||||||
|
let fallbackRequested = false;
|
||||||
if (event && 'touches' in event && event.touches[0]) {
|
if (event && 'touches' in event && event.touches[0]) {
|
||||||
holdStartY = event.touches[0].clientY;
|
holdStartY = event.touches[0].clientY;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const session = await startSpeechCapture({
|
||||||
if (generation !== holdGeneration) {
|
onCapture: (capture) => {
|
||||||
mediaStream.getTracks().forEach((track) => track.stop());
|
if (generation !== holdGeneration) return;
|
||||||
|
holdCapture = null;
|
||||||
|
recording.value = false;
|
||||||
|
if (!discardRecording) void handleVoiceCapture(capture);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (generation !== holdGeneration) return;
|
||||||
|
fallbackRequested = true;
|
||||||
|
holdCapture = null;
|
||||||
|
recording.value = false;
|
||||||
|
message.value = error.message;
|
||||||
|
void pickAudioFallback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session || generation !== holdGeneration) {
|
||||||
|
session?.cancel();
|
||||||
|
if (generation === holdGeneration && !fallbackRequested) void pickAudioFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recordStream = mediaStream;
|
holdCapture = session;
|
||||||
const mediaRecorder = new MediaRecorder(mediaStream);
|
|
||||||
const chunks: Blob[] = [];
|
|
||||||
recorder = mediaRecorder;
|
|
||||||
mediaRecorder.ondataavailable = (e) => {
|
|
||||||
if (e.data?.size) chunks.push(e.data);
|
|
||||||
};
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
recording.value = false;
|
|
||||||
recorder = null;
|
|
||||||
stopRecordTracks();
|
|
||||||
if (discardRecording) return;
|
|
||||||
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
|
||||||
if (blob.size) void handleVoiceBlob(blob);
|
|
||||||
};
|
|
||||||
mediaRecorder.start();
|
|
||||||
recording.value = true;
|
recording.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
stopRecordTracks();
|
holdCapture = null;
|
||||||
if (generation === holdGeneration) void pickAudioFallback();
|
if (generation === holdGeneration) void pickAudioFallback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -475,20 +464,22 @@ const moveHold = (event: TouchEvent) => {
|
|||||||
const endHold = () => {
|
const endHold = () => {
|
||||||
if (!recording.value) return;
|
if (!recording.value) return;
|
||||||
discardRecording = cancelling.value;
|
discardRecording = cancelling.value;
|
||||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
holdCapture?.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelHold = () => {
|
const cancelHold = () => {
|
||||||
if (!recording.value) return;
|
if (!recording.value) return;
|
||||||
discardRecording = true;
|
discardRecording = true;
|
||||||
if (recorder && recorder.state !== 'inactive') recorder.stop();
|
holdCapture?.cancel();
|
||||||
|
holdCapture = null;
|
||||||
|
recording.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceBlob = async (blob: Blob) => {
|
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||||
transcribing.value = true;
|
transcribing.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const result = await transcribeSpeechBlob(blob, 'today-ask.webm');
|
const result = await transcribeSpeechCapture(capture, 'today-ask.webm');
|
||||||
const text = result.text?.trim();
|
const text = result.text?.trim();
|
||||||
if (text) ask(text);
|
if (text) ask(text);
|
||||||
else message.value = '没听清,请再说一次';
|
else message.value = '没听清,请再说一次';
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
|
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
|
||||||
]"
|
]"
|
||||||
@send="handleComposerSend"
|
@send="handleComposerSend"
|
||||||
@voice-blob="handleVoiceBlob"
|
@voice-capture="handleVoiceCapture"
|
||||||
@panel="handlePanelAction"
|
@panel="handlePanelAction"
|
||||||
@clear-media="clearImage"
|
@clear-media="clearImage"
|
||||||
/>
|
/>
|
||||||
@@ -120,7 +120,8 @@ import AskSourceHeader from '@/components/chat/AskSourceHeader.vue';
|
|||||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
|
||||||
|
import type { SpeechCapture } from '@/services/speech-capture';
|
||||||
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
|
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
|
||||||
import { renderMarkdown } from '@/utils/markdown';
|
import { renderMarkdown } from '@/utils/markdown';
|
||||||
|
|
||||||
@@ -290,12 +291,12 @@ const handleComposerSend = (text: string) => {
|
|||||||
void sendQuestion(text);
|
void sendQuestion(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceBlob = async (blob: Blob) => {
|
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||||
if (voiceTranscribing.value) return;
|
if (voiceTranscribing.value) return;
|
||||||
const generation = ++voiceTranscriptionGeneration;
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
voiceTranscribing.value = true;
|
voiceTranscribing.value = true;
|
||||||
try {
|
try {
|
||||||
const asr = await transcribeSpeechBlob(blob, 'web-ai-question.webm', (abort) => {
|
const asr = await transcribeSpeechCapture(capture, 'web-ai-question.webm', (abort) => {
|
||||||
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
|
||||||
});
|
});
|
||||||
if (generation !== voiceTranscriptionGeneration) return;
|
if (generation !== voiceTranscriptionGeneration) return;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { ApiPayload } from '@/types/api';
|
import type { ApiPayload } from '@/types/api';
|
||||||
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
||||||
|
import { isNativeAppRuntime } from './speech-capture';
|
||||||
|
|
||||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
const configuredApiBase = import.meta.env.VITE_API_BASE;
|
||||||
|
const currentApiBase = () => configuredApiBase
|
||||||
|
|| (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api');
|
||||||
|
|
||||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ export const isAuthApiError = (error: unknown): boolean =>
|
|||||||
error instanceof ApiError
|
error instanceof ApiError
|
||||||
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
|
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
|
||||||
|
|
||||||
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`;
|
||||||
|
|
||||||
export const apiUrl = normalizeUrl;
|
export const apiUrl = normalizeUrl;
|
||||||
|
|
||||||
@@ -77,6 +80,8 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
|||||||
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
||||||
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
||||||
|
|
||||||
|
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
|
||||||
|
|
||||||
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||||
const method = options.method || 'GET';
|
const method = options.method || 'GET';
|
||||||
const hasBody = method !== 'GET' && options.data !== undefined;
|
const hasBody = method !== 'GET' && options.data !== undefined;
|
||||||
@@ -109,7 +114,7 @@ const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||||
if (typeof XMLHttpRequest !== 'undefined') {
|
if (shouldUseXhr()) {
|
||||||
return requestWithXhr<T>(options);
|
return requestWithXhr<T>(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
|||||||
|
|
||||||
export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
||||||
const fullUrl = normalizeUrl(url);
|
const fullUrl = normalizeUrl(url);
|
||||||
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
|
if (shouldUseXhr() && typeof URL !== 'undefined') {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', fullUrl);
|
xhr.open('GET', fullUrl);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiRequest } from './api';
|
import { apiRequest } from './api';
|
||||||
|
import { isNativeAppRuntime } from './speech-capture';
|
||||||
|
|
||||||
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@ export interface RealtimePracticeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSupported = () =>
|
const isSupported = () =>
|
||||||
typeof navigator !== 'undefined'
|
!isNativeAppRuntime()
|
||||||
|
&& typeof navigator !== 'undefined'
|
||||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
&& typeof RTCPeerConnection !== 'undefined';
|
&& typeof RTCPeerConnection !== 'undefined';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { SpeechSelectedFile } from '@/types/api';
|
||||||
|
|
||||||
|
export type SpeechCapture =
|
||||||
|
| { kind: 'blob'; blob: Blob; filename: string }
|
||||||
|
| { kind: 'file'; file: SpeechSelectedFile };
|
||||||
|
|
||||||
|
export interface SpeechCaptureSession {
|
||||||
|
stop: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRecorderResult {
|
||||||
|
tempFilePath?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRecorderError {
|
||||||
|
errMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRecorderManager {
|
||||||
|
start: (options?: { duration?: number; sampleRate?: number; format?: 'mp3' }) => void;
|
||||||
|
stop: () => void;
|
||||||
|
onStop: (callback: (result: AppRecorderResult) => void) => void;
|
||||||
|
onError: (callback: (error: AppRecorderError) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UniWithRecorder = typeof uni & {
|
||||||
|
getRecorderManager?: () => AppRecorderManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SpeechCaptureHandlers {
|
||||||
|
onCapture: (capture: SpeechCapture) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAppRuntime = () => Boolean((globalThis as typeof globalThis & { plus?: unknown }).plus);
|
||||||
|
const supportsBrowserRecording = () =>
|
||||||
|
typeof navigator !== 'undefined'
|
||||||
|
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
|
&& typeof MediaRecorder !== 'undefined';
|
||||||
|
|
||||||
|
let activeNativeSession: SpeechCaptureSession | null = null;
|
||||||
|
|
||||||
|
const startNativeCapture = (handlers: SpeechCaptureHandlers): SpeechCaptureSession | null => {
|
||||||
|
const manager = (uni as UniWithRecorder).getRecorderManager?.();
|
||||||
|
if (!manager) return null;
|
||||||
|
|
||||||
|
activeNativeSession?.cancel();
|
||||||
|
let active = true;
|
||||||
|
let discarded = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
active = false;
|
||||||
|
if (activeNativeSession === session) activeNativeSession = null;
|
||||||
|
};
|
||||||
|
const finishWithError = (error: Error) => {
|
||||||
|
if (!active) return;
|
||||||
|
cleanup();
|
||||||
|
if (!discarded) handlers.onError?.(error);
|
||||||
|
};
|
||||||
|
const session: SpeechCaptureSession = {
|
||||||
|
stop: () => {
|
||||||
|
if (!active) return;
|
||||||
|
try {
|
||||||
|
manager.stop();
|
||||||
|
} catch (error) {
|
||||||
|
finishWithError(error instanceof Error ? error : new Error('停止录音失败'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
if (!active) return;
|
||||||
|
discarded = true;
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
manager.stop();
|
||||||
|
} catch {
|
||||||
|
// The capture has already been discarded; there is nothing to surface.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose: () => session.cancel()
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.onStop((result) => {
|
||||||
|
if (!active) return;
|
||||||
|
const path = String(result.tempFilePath || '');
|
||||||
|
cleanup();
|
||||||
|
if (discarded) return;
|
||||||
|
if (!path) {
|
||||||
|
handlers.onError?.(new Error('未获取到录音文件,请改用选择音频'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onCapture({
|
||||||
|
kind: 'file',
|
||||||
|
file: {
|
||||||
|
path,
|
||||||
|
name: `recording-${Date.now()}.mp3`,
|
||||||
|
size: Number(result.fileSize || 0)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
manager.onError((error) => finishWithError(new Error(error.errMsg || '录音失败,请检查麦克风权限')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.start({ duration: 600000, sampleRate: 16000, format: 'mp3' });
|
||||||
|
activeNativeSession = session;
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
finishWithError(error instanceof Error ? error : new Error('录音启动失败'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||||
|
if (!supportsBrowserRecording()) return null;
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
let active = true;
|
||||||
|
let discarded = false;
|
||||||
|
const releaseStream = () => stream.getTracks().forEach((track) => track.stop());
|
||||||
|
const session: SpeechCaptureSession = {
|
||||||
|
stop: () => {
|
||||||
|
if (active && recorder.state !== 'inactive') recorder.stop();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
if (!active) return;
|
||||||
|
discarded = true;
|
||||||
|
active = false;
|
||||||
|
if (recorder.state !== 'inactive') recorder.stop();
|
||||||
|
releaseStream();
|
||||||
|
},
|
||||||
|
dispose: () => session.cancel()
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data?.size) chunks.push(event.data);
|
||||||
|
};
|
||||||
|
recorder.onerror = () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
releaseStream();
|
||||||
|
handlers.onError?.(new Error('录音失败,请检查麦克风权限'));
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
releaseStream();
|
||||||
|
if (discarded) return;
|
||||||
|
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||||
|
if (!blob.size) {
|
||||||
|
handlers.onError?.(new Error('未录到音频,请改用选择音频'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onCapture({ kind: 'blob', blob, filename: 'recording.webm' });
|
||||||
|
};
|
||||||
|
recorder.start();
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a microphone capture on the current platform. App-Plus produces a
|
||||||
|
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
|
||||||
|
*/
|
||||||
|
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||||
|
if (isAppRuntime()) return startNativeCapture(handlers);
|
||||||
|
return startBrowserCapture(handlers);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isNativeAppRuntime = isAppRuntime;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
|
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
|
||||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||||
|
import type { SpeechCapture } from './speech-capture';
|
||||||
|
|
||||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||||
|
|
||||||
@@ -91,6 +92,14 @@ export const transcribeSpeechBlob = (
|
|||||||
xhr.send(form);
|
xhr.send(form);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const transcribeSpeechCapture = (
|
||||||
|
capture: SpeechCapture,
|
||||||
|
filename: string,
|
||||||
|
registerAbort?: SpeechAbortRegistrar
|
||||||
|
) => capture.kind === 'blob'
|
||||||
|
? transcribeSpeechBlob(capture.blob, filename, registerAbort)
|
||||||
|
: transcribeSpeechFile(capture.file, registerAbort);
|
||||||
|
|
||||||
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
|
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
|
||||||
apiRequest<TtsResponse>({
|
apiRequest<TtsResponse>({
|
||||||
url: '/api/ai/tts',
|
url: '/api/ai/tts',
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// The uni-app App compiler currently imports these two runtime-core internals
|
||||||
|
// from the public `vue` entry point. Keep this App-only shim minimal and do
|
||||||
|
// not use it from product code.
|
||||||
|
export * from '../../node_modules/vue/dist/vue.runtime.esm-bundler.js';
|
||||||
|
|
||||||
|
export const isInSSRComponentSetup = false;
|
||||||
|
|
||||||
|
export const injectHook = (type, hook, target, prepend = false) => {
|
||||||
|
if (!target) return hook;
|
||||||
|
const hooks = target[type] || [];
|
||||||
|
if (prepend) hooks.unshift(hook);
|
||||||
|
else hooks.push(hook);
|
||||||
|
target[type] = hooks;
|
||||||
|
return hook;
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 945 B |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 143 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
test('App 图标与启动图映射均可由预检脚本验证', () => {
|
||||||
|
const output = execFileSync(process.execPath, ['scripts/verify-app-assets.mjs'], {
|
||||||
|
cwd: mobileRoot,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
assert.match(output, /App asset verification passed/);
|
||||||
|
});
|
||||||
@@ -451,11 +451,12 @@ test('问师傅恢复旧版语音提问并在录音不可用时回退选择音
|
|||||||
assert.match(composerSource, /按住 说话/);
|
assert.match(composerSource, /按住 说话/);
|
||||||
assert.match(composerSource, /startHold/);
|
assert.match(composerSource, /startHold/);
|
||||||
assert.match(composerSource, /endHold/);
|
assert.match(composerSource, /endHold/);
|
||||||
assert.match(composerSource, /navigator\.mediaDevices\?\.getUserMedia/);
|
assert.match(composerSource, /startSpeechCapture/);
|
||||||
|
assert.match(composerSource, /emit\('voice-capture'/);
|
||||||
assert.match(composerSource, /emit\('pick-audio'\)/);
|
assert.match(composerSource, /emit\('pick-audio'\)/);
|
||||||
assert.match(composerSource, /const teardown = \(\) =>/);
|
assert.match(composerSource, /const teardown = \(\) =>/);
|
||||||
assert.match(source, /pickVoiceAudio/);
|
assert.match(source, /pickVoiceAudio/);
|
||||||
assert.match(source, /transcribeSpeechBlob/);
|
assert.match(source, /transcribeSpeechCapture/);
|
||||||
assert.match(source, /transcribeSpeechFile/);
|
assert.match(source, /transcribeSpeechFile/);
|
||||||
assert.match(speechService, /\/api\/ai\/asr/);
|
assert.match(speechService, /\/api\/ai\/asr/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const realtimeVoiceRuntime = async () => {
|
|||||||
module: runtimeModule,
|
module: runtimeModule,
|
||||||
require: (specifier) => {
|
require: (specifier) => {
|
||||||
if (specifier === './api') return { apiRequest: async () => ({}) };
|
if (specifier === './api') return { apiRequest: async () => ({}) };
|
||||||
|
if (specifier === './speech-capture') return { isNativeAppRuntime: () => false };
|
||||||
throw new Error(`Unexpected module: ${specifier}`);
|
throw new Error(`Unexpected module: ${specifier}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test('语音转写、文字和附件补充都进入无状态整理', async () =>
|
|||||||
const page = await source('../src/pages/user/report/index.vue');
|
const page = await source('../src/pages/user/report/index.vue');
|
||||||
|
|
||||||
assert.match(page, /语音转文字/);
|
assert.match(page, /语音转文字/);
|
||||||
assert.match(page, /transcribeSpeechBlob/);
|
assert.match(page, /transcribeSpeechCapture/);
|
||||||
assert.match(page, /transcribeSpeechFile/);
|
assert.match(page, /transcribeSpeechFile/);
|
||||||
assert.match(page, /organizeWorkReport\(/);
|
assert.match(page, /organizeWorkReport\(/);
|
||||||
assert.match(page, /const previousDraft = draftPayload\(\)/);
|
assert.match(page, /const previousDraft = draftPayload\(\)/);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import uniPlugin from '@dcloudio/vite-plugin-uni';
|
import uniPlugin from '@dcloudio/vite-plugin-uni';
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ const devHost = process.env.HOST || '0.0.0.0';
|
|||||||
const proxyTarget = process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:8080';
|
const proxyTarget = process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:8080';
|
||||||
// 代理到线上等已带 /dev-api 前缀的网关时,设 VITE_PROXY_KEEP_PREFIX=1 保留前缀
|
// 代理到线上等已带 /dev-api 前缀的网关时,设 VITE_PROXY_KEEP_PREFIX=1 保留前缀
|
||||||
const keepProxyPrefix = process.env.VITE_PROXY_KEEP_PREFIX === '1';
|
const keepProxyPrefix = process.env.VITE_PROXY_KEEP_PREFIX === '1';
|
||||||
|
// DCloud's current App compiler imports two internal lifecycle helpers from
|
||||||
|
// `vue`. Public Vue packages intentionally do not export them, so provide a
|
||||||
|
// narrow App-only compatibility module. H5 continues to resolve the normal
|
||||||
|
// Vue package.
|
||||||
|
const appVueRuntimeShim = process.env.UNI_PLATFORM === 'app'
|
||||||
|
? fileURLToPath(new URL('./src/shims/vue-app-runtime.js', import.meta.url))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const proxy = {
|
const proxy = {
|
||||||
'/dev-api': {
|
'/dev-api': {
|
||||||
@@ -21,6 +29,9 @@ const proxy = {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [uni()],
|
plugins: [uni()],
|
||||||
|
resolve: appVueRuntimeShim
|
||||||
|
? { alias: { vue: appVueRuntimeShim } }
|
||||||
|
: undefined,
|
||||||
server: {
|
server: {
|
||||||
host: devHost,
|
host: devHost,
|
||||||
port: devPort,
|
port: devPort,
|
||||||
|
|||||||