feat(mobile-app): prepare native app packaging

This commit is contained in:
2026-07-23 14:40:23 +08:00
parent f6609808df
commit 0f5c2e8667
62 changed files with 979 additions and 833 deletions
+3
View File
@@ -19,5 +19,8 @@ mobile/node_modules/
mobile/dist/
mobile-uni/node_modules/
mobile-uni/dist/
mobile-uni/unpackage/
mobile-uni/certificates/
mobile-uni/androidPrivacy.json
.tmp/
.worktrees/
+31
View File
@@ -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 壳、权限、原生插件或证书变更都必须重新云打包并重跑本清单。
+42
View File
@@ -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)不作为首个试点版本的发布方式;如后续启用,须有版本兼容、完整性校验、灰度与回滚方案。
+35
View File
@@ -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"
}
}
}
+178 -624
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -8,24 +8,37 @@
"dev": "uni -p h5",
"dev:h5": "uni -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",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-app": "3.0.0-alpha-5020120260710001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260710001",
"@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30"
},
"devDependencies": {
"@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",
"typescript": "~5.9.3",
"vite": "5.2.8",
"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": {
"node": ">=20.19.0",
"npm": ">=8.19.0"
+83
View File
@@ -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"
+107
View File
@@ -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).`);
+31 -44
View File
@@ -102,6 +102,8 @@
<script setup lang="ts">
import { ref } from 'vue';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
const props = withDefaults(defineProps<{
disabled?: boolean;
@@ -127,7 +129,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'send', text: string): void;
(e: 'voice-blob', blob: Blob): void;
(e: 'voice-capture', capture: SpeechCapture): void;
(e: 'pick-audio'): void;
(e: 'pick-media'): void;
(e: 'clear-media'): void;
@@ -141,14 +143,10 @@ const voiceMode = ref(true);
const panelOpen = ref(false);
const recording = ref(false);
let recorder: MediaRecorder | null = null;
let stream: MediaStream | null = null;
let captureSession: SpeechCaptureSession | null = null;
let holdGeneration = 0;
let discardRecording = false;
const supportsRecording = () =>
typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) && typeof MediaRecorder !== 'undefined';
const toggleVoiceMode = () => {
voiceMode.value = !voiceMode.value;
panelOpen.value = false;
@@ -174,69 +172,58 @@ const panelAction = (key: string) => {
emit('panel', key);
};
const stopTracks = () => {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
};
const startHold = async () => {
if (props.disabled || props.transcribing || recording.value) return;
if (!supportsRecording()) {
emit('pick-audio');
return;
}
const generation = ++holdGeneration;
discardRecording = false;
let fallbackRequested = false;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== holdGeneration) {
mediaStream.getTracks().forEach((track) => track.stop());
const session = await startSpeechCapture({
onCapture: (capture) => {
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;
}
stream = mediaStream;
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();
captureSession = session;
recording.value = true;
} catch {
stopTracks();
captureSession = null;
if (generation === holdGeneration) emit('pick-audio');
}
};
const endHold = () => {
if (!recording.value) return;
if (recorder && recorder.state !== 'inactive') recorder.stop();
captureSession?.stop();
};
const cancelHold = () => {
if (!recording.value) return;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') recorder.stop();
captureSession?.cancel();
captureSession = null;
recording.value = false;
};
const teardown = () => {
holdGeneration += 1;
discardRecording = true;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
recorder = null;
stopTracks();
captureSession?.cancel();
captureSession = null;
recording.value = false;
};
+97
View File
@@ -8,6 +8,103 @@
"uniStatistics": {
"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": {
"router": {
"base": "/h5/"
@@ -159,9 +159,11 @@ import {
createSpeechPlaybackController,
INTERVIEWER_VOICE_PROFILE,
normalizeOssId,
transcribeSpeechBlob,
transcribeSpeechCapture,
transcribeSpeechFile
} from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
const currentPath = '/pages/candidate/interview/index';
const records = ref<InterviewRecord[]>([]);
@@ -177,8 +179,7 @@ const message = ref('');
const transcribingQuestionId = ref('');
const recordingQuestionId = ref('');
const recordingQuestionStarting = ref(false);
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
let questionCapture: SpeechCaptureSession | null = null;
let questionCaptureGeneration = 0;
let questionTranscriptionGeneration = 0;
let abortQuestionTranscription: (() => void) | null = null;
@@ -298,11 +299,6 @@ const interviewResultSpeech = () => {
].filter(Boolean).join('。');
};
const stopQuestionMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelQuestionTranscription = () => {
questionTranscriptionGeneration += 1;
abortQuestionTranscription?.();
@@ -314,14 +310,8 @@ const cancelQuestionRecording = () => {
questionCaptureGeneration += 1;
cancelQuestionTranscription();
recordingQuestionStarting.value = false;
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
mediaRecorder.value = null;
stopQuestionMediaTracks();
questionCapture?.cancel();
questionCapture = null;
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;
const generation = ++questionTranscriptionGeneration;
transcribingQuestionId.value = questionId;
message.value = '语音转写中';
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) return;
@@ -385,42 +375,35 @@ const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
const startQuestionRecording = async (questionId: string) => {
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;
recordingQuestionStarting.value = true;
let fallbackRequested = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== questionCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
return;
}
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;
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== questionCaptureGeneration) return;
questionCapture = null;
recordingQuestionId.value = '';
stopQuestionMediaTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
message.value = '未录到音频,请改用文字输入';
void transcribeQuestionCapture(questionId, capture);
},
onError: () => {
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;
}
void transcribeQuestionBlob(questionId, blob);
};
recorder.start();
questionCapture = session;
recordingQuestionId.value = questionId;
message.value = '正在录音';
} catch {
recordingQuestionId.value = '';
stopQuestionMediaTracks();
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
} finally {
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
@@ -428,10 +411,9 @@ const startQuestionRecording = async (questionId: string) => {
};
const stopQuestionRecording = () => {
const recorder = mediaRecorder.value;
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
if (!recordingQuestionId.value || !questionCapture) return;
message.value = '录音结束,正在转写';
recorder.stop();
questionCapture.stop();
};
const toggleQuestionRecording = (questionId: string) => {
+37 -53
View File
@@ -306,7 +306,7 @@
:maxlength="500"
:panel-items="practicePanelItems"
@send="handleComposerSend"
@voice-blob="handleSessionVoiceBlob"
@voice-capture="handleSessionVoiceCapture"
@pick-audio="composerPickAudio"
@panel="handlePanelAction"
/>
@@ -397,7 +397,9 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
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 { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
@@ -473,8 +475,7 @@ const transcribing = ref(false);
const draftAudioUrl = ref('');
const draftAudioOssId = ref<string | undefined>();
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
let recordingCapture: SpeechCaptureSession | null = null;
let recordingCaptureGeneration = 0;
let transcriptionGeneration = 0;
let abortTranscription: (() => void) | null = null;
@@ -665,12 +666,12 @@ const submitTranscribed = async (text: string, audioUrl: string, ossId?: string)
await submit();
};
const handleSessionVoiceBlob = async (blob: Blob) => {
const handleSessionVoiceCapture = async (capture: SpeechCapture) => {
if (transcribing.value || busy.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
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) return;
@@ -907,11 +908,6 @@ const clearDraftAudio = () => {
draftAudioOssId.value = undefined;
};
const stopMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelTranscription = () => {
transcriptionGeneration += 1;
abortTranscription?.();
@@ -923,14 +919,8 @@ const cancelRecording = () => {
recordingCaptureGeneration += 1;
cancelTranscription();
recordingStarting.value = false;
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
mediaRecorder.value = null;
stopMediaTracks();
recordingCapture?.cancel();
recordingCapture = null;
recording.value = false;
recordingAssignmentId.value = null;
};
@@ -965,13 +955,13 @@ const applyDailyAsrResult = (assignmentId: number, result: { text?: string; audi
message.value = '每日一练语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob, assignmentId?: number) => {
const transcribeCapture = async (capture: SpeechCapture, assignmentId?: number) => {
if (transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
message.value = '语音转写中';
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) return;
@@ -1023,45 +1013,40 @@ const pickAudio = async (assignmentId?: number) => {
const startRecording = async (assignmentId?: number) => {
if (busy.value || recordingStarting.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio(assignmentId);
return;
}
const generation = ++recordingCaptureGeneration;
recordingStarting.value = true;
let fallbackRequested = false;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== recordingCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
return;
}
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 = () => {
const session = await startSpeechCapture({
onCapture: (capture) => {
if (generation !== recordingCaptureGeneration) return;
recordingCapture = null;
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;
recordingAssignmentId.value = null;
void transcribeBlob(blob, targetAssignmentId);
};
recorder.start();
void transcribeCapture(capture, targetAssignmentId);
},
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;
message.value = '正在录音';
} catch (error) {
recordingAssignmentId.value = null;
stopMediaTracks();
recordingCapture = null;
if (generation === recordingCaptureGeneration) await pickAudio(assignmentId);
} finally {
if (generation === recordingCaptureGeneration) recordingStarting.value = false;
@@ -1069,10 +1054,9 @@ const startRecording = async (assignmentId?: number) => {
};
const stopRecording = () => {
const recorder = mediaRecorder.value;
if (!recording.value || !recorder || recorder.state === 'inactive') return;
if (!recording.value || !recordingCapture) return;
message.value = '录音结束,正在转写';
recorder.stop();
recordingCapture.stop();
};
const toggleRecording = (assignmentId?: number) => {
+5 -4
View File
@@ -219,7 +219,7 @@
:maxlength="500"
:panel-items="composerPanelItems"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@pick-audio="pickVoiceAudio"
@panel="handlePanelAction"
/>
@@ -239,9 +239,10 @@ import {
chooseSpeechAudio,
createSpeechPlaybackController,
MENTOR_VOICE_PROFILE,
transcribeSpeechBlob,
transcribeSpeechCapture,
transcribeSpeechFile
} from '@/services/speech';
import type { SpeechCapture } from '@/services/speech-capture';
import type { SpeechPlaybackStatus } from '@/types/api';
import {
listMyWorkReports,
@@ -473,13 +474,13 @@ const cancelVoiceTranscription = () => {
voiceTranscribing.value = false;
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value || composerDisabled.value) return;
const generation = ++voiceTranscriptionGeneration;
const accountKey = currentAccountKey();
voiceTranscribing.value = true;
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 (!requestIsCurrent(generation, voiceTranscriptionGeneration, accountKey)) return;
+10 -4
View File
@@ -282,7 +282,7 @@
:tool-label="broadcastContext ? '' : defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况'"
:panel-items="composerPanelItems"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@pick-audio="pickVoiceAudio"
@pick-media="pickMedia"
@clear-media="clearMedia"
@@ -440,7 +440,8 @@ import {
import { resetPageScroll } from '@/services/navigation';
import { ApiError, authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
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 { getMemoryCandidates } from '@/services/personal-assistant';
import { currentAccountKey } from '@/services/request-scope';
@@ -1089,12 +1090,12 @@ const cancelVoiceTranscription = () => {
voiceTranscribing.value = false;
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
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) return;
@@ -1552,14 +1553,19 @@ onUnload(() => {
}
.broadcast-context-action {
display: flex;
min-height: 38px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 1px solid #e7aeb7;
border-radius: 10px;
background: #ffffff;
color: #b51c2b;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.broadcast-context-action.end {
+32 -41
View File
@@ -211,7 +211,9 @@ import { getMobileHome } from '@/services/home';
import { getPracticeAssignments } from '@/services/practice';
import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
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 { ensureEmployeePosition } from '@/services/position';
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
@@ -393,8 +395,7 @@ const loadBroadcastUnreadCount = async () => {
const recording = ref(false);
const cancelling = ref(false);
const transcribing = ref(false);
let recorder: MediaRecorder | null = null;
let recordStream: MediaStream | null = null;
let holdCapture: SpeechCaptureSession | null = null;
let holdGeneration = 0;
let discardRecording = false;
let holdStartY = 0;
@@ -411,57 +412,45 @@ const voiceSub = computed(() => {
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) => {
if (recording.value || transcribing.value) return;
if (!loggedIn.value) {
goLogin();
return;
}
if (!supportsRecording()) {
void pickAudioFallback();
return;
}
const generation = ++holdGeneration;
discardRecording = false;
cancelling.value = false;
let fallbackRequested = false;
if (event && 'touches' in event && event.touches[0]) {
holdStartY = event.touches[0].clientY;
}
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== holdGeneration) {
mediaStream.getTracks().forEach((track) => track.stop());
const session = await startSpeechCapture({
onCapture: (capture) => {
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;
}
recordStream = mediaStream;
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();
holdCapture = session;
recording.value = true;
} catch {
stopRecordTracks();
holdCapture = null;
if (generation === holdGeneration) void pickAudioFallback();
}
};
@@ -475,20 +464,22 @@ const moveHold = (event: TouchEvent) => {
const endHold = () => {
if (!recording.value) return;
discardRecording = cancelling.value;
if (recorder && recorder.state !== 'inactive') recorder.stop();
holdCapture?.stop();
};
const cancelHold = () => {
if (!recording.value) return;
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;
message.value = '';
try {
const result = await transcribeSpeechBlob(blob, 'today-ask.webm');
const result = await transcribeSpeechCapture(capture, 'today-ask.webm');
const text = result.text?.trim();
if (text) ask(text);
else message.value = '没听清,请再说一次';
+5 -4
View File
@@ -105,7 +105,7 @@
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
]"
@send="handleComposerSend"
@voice-blob="handleVoiceBlob"
@voice-capture="handleVoiceCapture"
@panel="handlePanelAction"
@clear-media="clearImage"
/>
@@ -120,7 +120,8 @@ import AskSourceHeader from '@/components/chat/AskSourceHeader.vue';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
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 { renderMarkdown } from '@/utils/markdown';
@@ -290,12 +291,12 @@ const handleComposerSend = (text: string) => {
void sendQuestion(text);
};
const handleVoiceBlob = async (blob: Blob) => {
const handleVoiceCapture = async (capture: SpeechCapture) => {
if (voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
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) return;
+9 -4
View File
@@ -1,7 +1,10 @@
import type { ApiPayload } from '@/types/api';
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';
@@ -31,7 +34,7 @@ export const isAuthApiError = (error: unknown): boolean =>
error instanceof ApiError
&& (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;
@@ -77,6 +80,8 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
export const readTextPayload = <T>(statusCode: number, text: string): T =>
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
const method = options.method || 'GET';
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> => {
if (typeof XMLHttpRequest !== 'undefined') {
if (shouldUseXhr()) {
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> => {
const fullUrl = normalizeUrl(url);
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
if (shouldUseXhr() && typeof URL !== 'undefined') {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', fullUrl);
+3 -1
View File
@@ -1,4 +1,5 @@
import { apiRequest } from './api';
import { isNativeAppRuntime } from './speech-capture';
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
@@ -122,7 +123,8 @@ export interface RealtimePracticeController {
}
const isSupported = () =>
typeof navigator !== 'undefined'
!isNativeAppRuntime()
&& typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof RTCPeerConnection !== 'undefined';
+170
View File
@@ -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;
+9
View File
@@ -1,5 +1,6 @@
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import type { SpeechCapture } from './speech-capture';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -91,6 +92,14 @@ export const transcribeSpeechBlob = (
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) =>
apiRequest<TtsResponse>({
url: '/api/ai/tts',
+15
View File
@@ -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;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+15
View File
@@ -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/);
});
+3 -2
View File
@@ -451,11 +451,12 @@ test('问师傅恢复旧版语音提问并在录音不可用时回退选择音
assert.match(composerSource, /按住 说话/);
assert.match(composerSource, /startHold/);
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, /const teardown = \(\) =>/);
assert.match(source, /pickVoiceAudio/);
assert.match(source, /transcribeSpeechBlob/);
assert.match(source, /transcribeSpeechCapture/);
assert.match(source, /transcribeSpeechFile/);
assert.match(speechService, /\/api\/ai\/asr/);
});
@@ -21,6 +21,7 @@ const realtimeVoiceRuntime = async () => {
module: runtimeModule,
require: (specifier) => {
if (specifier === './api') return { apiRequest: async () => ({}) };
if (specifier === './speech-capture') return { isNativeAppRuntime: () => false };
throw new Error(`Unexpected module: ${specifier}`);
}
});
+1 -1
View File
@@ -31,7 +31,7 @@ test('语音转写、文字和附件补充都进入无状态整理', async () =>
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /语音转文字/);
assert.match(page, /transcribeSpeechBlob/);
assert.match(page, /transcribeSpeechCapture/);
assert.match(page, /transcribeSpeechFile/);
assert.match(page, /organizeWorkReport\(/);
assert.match(page, /const previousDraft = draftPayload\(\)/);
+11
View File
@@ -1,3 +1,4 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
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';
// 代理到线上等已带 /dev-api 前缀的网关时,设 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 = {
'/dev-api': {
@@ -21,6 +29,9 @@ const proxy = {
export default defineConfig({
plugins: [uni()],
resolve: appVueRuntimeShim
? { alias: { vue: appVueRuntimeShim } }
: undefined,
server: {
host: devHost,
port: devPort,