This commit is contained in:
李志鹏
2025-10-21 13:42:51 +08:00
19 changed files with 1902 additions and 197 deletions

View File

@@ -27,5 +27,6 @@ rules: {
],
parserOptions: {
ecmaVersion: 'latest'
}
},
globals: { defineOptions: 'readonly' }
}

966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@
"typescript": "~4.8.4",
"unplugin-auto-import": "^0.15.3",
"unplugin-vue-components": "^0.24.1",
"unplugin-vue-define-options": "^3.1.1",
"vite": "^4.1.4",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^1.2.0"

View File

@@ -1,7 +1,11 @@
<template>
<RouterView />
<RouteCache />
</template>
<script setup lang="ts">
import RouteCache from '@/components/RouteCache.vue'
</script>
<style lang="less">
#app{

View File

@@ -21,7 +21,7 @@
<div class="main">
<div class="return" @click="handleClickReturn"><SvgIcon name="return" size="34" /></div>
<span class="title">{{ title }}</span>
<div class="setting" v-if="hasSetting"><SvgIcon name="setting" size="34" /></div>
<div class="setting" v-if="hasSetting"><SvgIcon name="setting" size="44" /></div>
</div>
<div class="placeholder"></div>
</div>

View File

@@ -0,0 +1,59 @@
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 缓存的组件名称列表
const cachedViews = ref<string[]>([])
// 监听路由变化,管理缓存
watch(
() => route,
(newRoute) => {
const routeName = newRoute.name as string
const shouldCache = newRoute.meta?.cache === true
// console.log('🔄 路由变化:', { routeName, shouldCache, currentCache: cachedViews.value })
if (shouldCache && routeName && !cachedViews.value.includes(routeName)) {
cachedViews.value.push(routeName)
// console.log('✅ 添加到缓存:', routeName)
} else if (!shouldCache && routeName && cachedViews.value.includes(routeName)) {
// 从缓存列表中移除
const index = cachedViews.value.indexOf(routeName)
if (index > -1) {
cachedViews.value.splice(index, 1)
// console.log('❌ 从缓存移除:', routeName)
}
}
},
{ immediate: true, deep: true }
)
// 提供清除缓存的方法
const clearCache = (routeName?: string) => {
if (routeName) {
const index = cachedViews.value.indexOf(routeName)
if (index > -1) {
cachedViews.value.splice(index, 1)
}
} else {
cachedViews.value = []
}
}
// 暴露方法供外部使用
defineExpose({
clearCache,
cachedViews
})
</script>

View File

@@ -1,4 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
/**
* 路由缓存机制:
* 1. 设置路由的meta属性为{ cache: true },表示需要缓存
* 2. App.vue中使用RouteCache组件通过路由的name来进行匹配
* 3. 路由的name默认是文件名,如果文件名与name不一致,通过defineOptions({ name: 'componentName' })来设置
*/
const router = createRouter({
history: createWebHistory('/'),
// history: createWebHistory(import.meta.env.VITE_APP_URL),
@@ -25,7 +34,7 @@ const router = createRouter({
{
path: '/stylist',
name: 'StylistPage',
redirect:'/stylist/index',
redirect: '/stylist/index',
component: () => import('@/views/stylist/container.vue'),
children: [
{
@@ -53,7 +62,8 @@ const router = createRouter({
{
path: '/asistant',
name: 'asistant',
component: () => import('../views/asistant/index.vue')
component: () => import('../views/asistant/index.vue'),
meta: { cache: true }
},
{
path: '/workshop',
@@ -61,8 +71,8 @@ const router = createRouter({
component: () => import('../views/Workshop/index.vue'),
children: [
{
path: '/workshop',
redirect: "/workshop/selectStyle",
path: '/workshop',
redirect: '/workshop/selectStyle'
},
{
path: '/workshop/selectStyle',
@@ -95,20 +105,20 @@ const router = createRouter({
// 自定义创作
path: '/workshop/customize',
name: 'customize',
component: () => import('../views/Workshop/customize.vue'),
component: () => import('../views/Workshop/customize.vue')
},
{
// library
path: '/workshop/library',
name: 'library',
component: () => import('../views/Workshop/library.vue'),
name: 'library',
component: () => import('../views/Workshop/library.vue')
},
{
// creation
path: '/workshop/creation',
name: 'creation',
component: () => import('../views/Workshop/creation/index.vue')
},
{
// creation
path: '/workshop/creation',
name: 'creation',
component: () => import('../views/Workshop/creation/index.vue'),
},
{
// 完成创建
path: '/workshop/end',

View File

@@ -4,4 +4,5 @@ import { createPersistedState } from 'pinia-persistedstate-plugin'
const store = createPinia()
// 使用持久化插件(全局持久化)
store.use(createPersistedState())
export default store
export default store
export * from './modules/generate'

View File

@@ -1,7 +1,7 @@
// 每一个存储的模块命名规则use开头store结尾
import { defineStore } from 'pinia'
export const useWorkshopStore = defineStore({
id: 'workshop', // 必须指明唯一的pinia仓库的id
export const useGenerateStore = defineStore({
id: 'generate', // 必须指明唯一的pinia仓库的id
state: () => {
return {
userData:{

View File

@@ -1,26 +1,42 @@
// 每一个存储的模块命名规则use开头store结尾
import { defineStore } from 'pinia'
export const useUserInfoStore = defineStore({
id: 'userInfo', // 必须指明唯一的pinia仓库的id
state: () => {
return {
num: 0,
name: '张三',
token: ''
}
},
getters: {
doubleCount: (state) => state.num * 2
},
actions: {
changeNum() {
this.num++
},
loginOut() {
// 处理退出登录的一些逻辑
return new Promise((rez) => {
rez('111')
})
}
import { ref, computed } from 'vue'
export const useUserInfoStore = defineStore('userInfo', () => {
// state
const num = ref(0)
const name = ref('张三')
const token = ref('')
// getters
const getUserInfo = computed(() => ({
num: num.value,
name: name.value,
token: token.value
}))
// actions
const setUserInfo = (data: any) => {
name.value = data.name
token.value = data.token
}
const loginOut = () => {
// 处理退出登录的一些逻辑
return new Promise((rez) => {
rez('111')
})
}
return {
// state
num,
name,
token,
// getters
getUserInfo,
// actions
setUserInfo,
loginOut
}
})

5
src/types/define-options.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare global {
const defineOptions: <T = Record<string, any>>(options: T) => T
}
export {}

231
src/utils/audioUtils.ts Normal file
View File

@@ -0,0 +1,231 @@
/**
* 音频处理工具函数
* 为后续集成第三方TTS方案做准备
*/
// 音频文件类型定义
export interface AudioFile {
blob: Blob
name: string
size: number
type: string
duration?: number
}
// 音频格式转换选项
export interface AudioConvertOptions {
format: 'webm' | 'mp3' | 'wav' | 'ogg'
quality?: number
bitrate?: number
}
/**
* 将Blob转换为指定格式的音频文件
* @param audioBlob 原始音频Blob
* @param options 转换选项
* @returns Promise<Blob> 转换后的音频Blob
*/
export const convertAudioFormat = async (
audioBlob: Blob,
options: AudioConvertOptions
): Promise<Blob> => {
return new Promise((resolve, reject) => {
try {
const audio = new Audio()
const url = URL.createObjectURL(audioBlob)
audio.onloadedmetadata = () => {
// 这里可以添加格式转换逻辑
// 目前直接返回原始Blob实际项目中可以使用Web Audio API或第三方库
URL.revokeObjectURL(url)
resolve(audioBlob)
}
audio.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('音频加载失败'))
}
audio.src = url
} catch (error) {
reject(error)
}
})
}
/**
* 获取音频文件信息
* @param audioBlob 音频Blob
* @returns Promise<AudioFile> 音频文件信息
*/
export const getAudioFileInfo = async (audioBlob: Blob): Promise<AudioFile> => {
return new Promise((resolve, reject) => {
const audio = new Audio()
const url = URL.createObjectURL(audioBlob)
audio.onloadedmetadata = () => {
const audioFile: AudioFile = {
blob: audioBlob,
name: `audio-${Date.now()}.webm`,
size: audioBlob.size,
type: audioBlob.type,
duration: audio.duration
}
URL.revokeObjectURL(url)
resolve(audioFile)
}
audio.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('无法获取音频信息'))
}
audio.src = url
})
}
/**
* 压缩音频文件
* @param audioBlob 原始音频Blob
* @param quality 压缩质量 (0-1)
* @returns Promise<Blob> 压缩后的音频Blob
*/
export const compressAudio = async (
audioBlob: Blob,
quality: number = 0.8
): Promise<Blob> => {
// 这里可以实现音频压缩逻辑
// 目前直接返回原始Blob实际项目中可以使用Web Audio API
return audioBlob
}
/**
* 上传音频文件到服务器
* @param audioFile 音频文件
* @param uploadUrl 上传地址
* @returns Promise<string> 服务器返回的文件ID或URL
*/
export const uploadAudioFile = async (
audioFile: AudioFile,
uploadUrl: string
): Promise<string> => {
const formData = new FormData()
formData.append('audio', audioFile.blob, audioFile.name)
formData.append('duration', audioFile.duration?.toString() || '0')
try {
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`上传失败: ${response.statusText}`)
}
const result = await response.json()
return result.fileId || result.url
} catch (error) {
console.error('音频上传失败:', error)
throw error
}
}
/**
* 为第三方TTS服务准备音频数据
* @param audioBlob 音频Blob
* @param serviceType TTS服务类型
* @returns Promise<any> 准备好的音频数据
*/
export const prepareAudioForTTS = async (
audioBlob: Blob,
serviceType: 'openai' | 'azure' | 'aws' | 'google'
): Promise<any> => {
const audioFile = await getAudioFileInfo(audioBlob)
switch (serviceType) {
case 'openai':
return {
file: audioFile.blob,
model: 'whisper-1',
language: 'zh',
response_format: 'json'
}
case 'azure':
return {
audio: audioFile.blob,
language: 'zh-CN',
format: 'json'
}
case 'aws':
return {
Audio: audioFile.blob,
LanguageCode: 'zh-CN',
MediaFormat: 'webm'
}
case 'google':
return {
audio: {
content: await blobToBase64(audioFile.blob)
},
config: {
encoding: 'WEBM_OPUS',
languageCode: 'zh-CN',
sampleRateHertz: 16000
}
}
default:
throw new Error(`不支持的TTS服务类型: ${serviceType}`)
}
}
/**
* 将Blob转换为Base64字符串
* @param blob Blob对象
* @returns Promise<string> Base64字符串
*/
export const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
/**
* 验证音频文件格式
* @param blob 音频Blob
* @returns boolean 是否为有效的音频格式
*/
export const validateAudioFormat = (blob: Blob): boolean => {
const validTypes = [
'audio/webm',
'audio/mp3',
'audio/wav',
'audio/ogg',
'audio/mpeg'
]
return validTypes.includes(blob.type)
}
/**
* 获取音频录制权限
* @returns Promise<boolean> 是否获得权限
*/
export const requestAudioPermission = async (): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach(track => track.stop())
return true
} catch (error) {
console.error('获取音频权限失败:', error)
return false
}
}

View File

@@ -7,6 +7,7 @@ import editProfile1_1 from '@/assets/images/workshop/selectStyle/selectStyle1-1.
import editProfile1_2 from '@/assets/images/workshop/selectStyle/selectStyle1-2.png'
import editProfile1_3 from '@/assets/images/workshop/selectStyle/selectStyle1-3.png'
import editProfile1_4 from '@/assets/images/workshop/selectStyle/selectStyle1-4.png'
import { useGenerateStore } from '@/stores'
const router = useRouter()
//const props = defineProps({
@@ -66,11 +67,15 @@ let data = reactive({
}
})
const generateStore = useGenerateStore()
const toSelectModelContinue = ()=>{
router.push({ path: 'selectModelContinue' })
}
onMounted(()=>{
emit('view-type', 1)
console.log(useGenerateStore())
})
onUnmounted(()=>{
})

View File

@@ -0,0 +1,557 @@
<template>
<div class="audio-recorder">
<!-- 录制状态显示 -->
<div class="recorder-status" v-if="isRecording || hasRecording">
<div class="status-info">
<div class="status-icon">
<div v-if="isRecording" class="recording-dot"></div>
<div v-else-if="hasRecording" class="playback-icon"></div>
</div>
<div class="status-text">
<span v-if="isRecording">正在录制...</span>
<span v-else-if="hasRecording">录制完成</span>
</div>
<div class="recording-time" v-if="isRecording || hasRecording">
{{ formatTime(recordingTime) }}
</div>
</div>
</div>
<!-- 音频可视化 -->
<div class="audio-visualizer" v-if="isRecording || hasRecording">
<div class="visualizer-container">
<div
v-for="(bar, index) in audioBars"
:key="index"
class="audio-bar"
:style="{ height: bar.height + 'px' }"
></div>
</div>
</div>
<!-- 控制按钮 -->
<div class="recorder-controls">
<button
v-if="!isRecording && !hasRecording"
@click="startRecording"
class="record-btn start-btn"
:disabled="!isSupported"
>
<div class="btn-icon">🎤</div>
<span>开始录制</span>
</button>
<button
v-if="isRecording"
@click="stopRecording"
class="record-btn stop-btn"
>
<div class="btn-icon"></div>
<span>停止录制</span>
</button>
<div v-if="hasRecording" class="playback-controls">
<button @click="playRecording" class="control-btn play-btn" :disabled="isPlaying">
<div class="btn-icon">{{ isPlaying ? '⏸' : '▶' }}</div>
</button>
<button @click="pauseRecording" class="control-btn pause-btn" :disabled="!isPlaying">
<div class="btn-icon"></div>
</button>
<button @click="downloadRecording" class="control-btn download-btn">
<div class="btn-icon">💾</div>
</button>
<button @click="deleteRecording" class="control-btn delete-btn">
<div class="btn-icon">🗑</div>
</button>
</div>
</div>
<!-- 音频播放器 -->
<audio
ref="audioPlayer"
@ended="onPlaybackEnded"
@timeupdate="onTimeUpdate"
style="display: none"
></audio>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
// 定义事件
const emit = defineEmits<{
'recording-started': []
'recording-stopped': [audioBlob: Blob]
'recording-deleted': []
}>()
// 响应式数据
const isRecording = ref(false)
const hasRecording = ref(false)
const isPlaying = ref(false)
const recordingTime = ref(0)
const error = ref('')
const isSupported = ref(false)
// 音频相关
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let audioBlob: Blob | null = null
let recordingStartTime = 0
let recordingTimer: number | null = null
// 音频可视化
const audioBars = ref<Array<{ height: number }>>([])
let animationFrame: number | null = null
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let microphone: MediaStreamAudioSourceNode | null = null
let dataArray: Uint8Array | null = null
// DOM 引用
const audioPlayer = ref<HTMLAudioElement>()
// 计算属性
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 检查浏览器支持
const checkSupport = () => {
isSupported.value = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
if (!isSupported.value) {
error.value = '您的浏览器不支持音频录制功能'
}
}
// 初始化音频可视化
const initAudioVisualizer = async () => {
if (!isRecording.value) return
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
analyser = audioContext.createAnalyser()
analyser.fftSize = 256
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
microphone = audioContext.createMediaStreamSource(stream)
microphone.connect(analyser)
dataArray = new Uint8Array(analyser.frequencyBinCount)
updateVisualizer()
} catch (err) {
console.error('初始化音频可视化失败:', err)
}
}
// 更新音频可视化
const updateVisualizer = () => {
if (!analyser || !dataArray || !isRecording.value) return
analyser.getByteFrequencyData(dataArray)
// 创建音频条
const barCount = 20
const newBars: Array<{ height: number }> = []
for (let i = 0; i < barCount; i++) {
const dataIndex = Math.floor((i / barCount) * dataArray.length)
const value = dataArray[dataIndex]
const height = Math.max(4, (value / 255) * 60) // 最小高度4px最大60px
newBars.push({ height })
}
audioBars.value = newBars
animationFrame = requestAnimationFrame(updateVisualizer)
}
// 停止音频可视化
const stopAudioVisualizer = () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
}
if (microphone) {
microphone.disconnect()
microphone = null
}
if (audioContext) {
audioContext.close()
audioContext = null
}
audioBars.value = []
}
// 开始录制
const startRecording = async () => {
try {
error.value = ''
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
hasRecording.value = true
emit('recording-stopped', audioBlob)
// 停止所有音频流
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start(100) // 每100ms收集一次数据
isRecording.value = true
recordingStartTime = Date.now()
recordingTime.value = 0
// 开始计时
recordingTimer = window.setInterval(() => {
recordingTime.value = Math.floor((Date.now() - recordingStartTime) / 1000)
}, 1000)
// 初始化音频可视化
await initAudioVisualizer()
emit('recording-started')
} catch (err) {
console.error('开始录制失败:', err)
error.value = '无法访问麦克风,请检查权限设置'
}
}
// 停止录制
const stopRecording = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop()
isRecording.value = false
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
stopAudioVisualizer()
}
}
// 播放录制
const playRecording = () => {
if (audioBlob && audioPlayer.value) {
const audioUrl = URL.createObjectURL(audioBlob)
audioPlayer.value.src = audioUrl
audioPlayer.value.play()
isPlaying.value = true
}
}
// 暂停播放
const pauseRecording = () => {
if (audioPlayer.value) {
audioPlayer.value.pause()
isPlaying.value = false
}
}
// 播放结束
const onPlaybackEnded = () => {
isPlaying.value = false
}
// 时间更新
const onTimeUpdate = () => {
// 可以在这里添加播放进度更新逻辑
}
// 下载录制
const downloadRecording = () => {
if (audioBlob) {
const url = URL.createObjectURL(audioBlob)
const a = document.createElement('a')
a.href = url
a.download = `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
// 删除录制
const deleteRecording = () => {
hasRecording.value = false
audioBlob = null
isPlaying.value = false
if (audioPlayer.value) {
audioPlayer.value.src = ''
}
emit('recording-deleted')
}
// 组件挂载
onMounted(() => {
checkSupport()
})
// 组件卸载
onUnmounted(() => {
if (isRecording.value) {
stopRecording()
}
stopAudioVisualizer()
if (recordingTimer) {
clearInterval(recordingTimer)
}
})
// 暴露方法给父组件
defineExpose({
startRecording,
stopRecording,
playRecording,
pauseRecording,
downloadRecording,
deleteRecording,
hasRecording: computed(() => hasRecording.value),
isRecording: computed(() => isRecording.value),
audioBlob: computed(() => audioBlob)
})
</script>
<style scoped>
.audio-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e9ecef;
}
.recorder-status {
width: 100%;
max-width: 300px;
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.recording-dot {
width: 12px;
height: 12px;
background: #ff4757;
border-radius: 50%;
animation: pulse 1s infinite;
}
.playback-icon {
color: #2ed573;
font-size: 16px;
}
.status-text {
flex: 1;
font-weight: 500;
color: #2c3e50;
}
.recording-time {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #7f8c8d;
background: #ecf0f1;
padding: 4px 8px;
border-radius: 4px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.audio-visualizer {
width: 100%;
max-width: 300px;
height: 80px;
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.visualizer-container {
display: flex;
align-items: end;
justify-content: center;
gap: 2px;
height: 100%;
}
.audio-bar {
width: 8px;
background: linear-gradient(to top, #ff6b6b, #ffa726);
border-radius: 4px;
transition: height 0.1s ease;
min-height: 4px;
}
.recorder-controls {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.record-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
justify-content: center;
}
.start-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.start-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.stop-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
}
.stop-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
}
.record-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.playback-controls {
display: flex;
gap: 8px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
.play-btn {
background: linear-gradient(135deg, #2ed573 0%, #1e90ff 100%);
color: white;
}
.pause-btn {
background: linear-gradient(135deg, #ffa726 0%, #ff6b6b 100%);
color: white;
}
.download-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.delete-btn {
background: linear-gradient(135deg, #ff4757 0%, #c44569 100%);
color: white;
}
.control-btn:hover:not(:disabled) {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.control-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
font-size: 16px;
}
.error-message {
color: #e74c3c;
background: #fdf2f2;
border: 1px solid #fecaca;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
text-align: center;
max-width: 300px;
}
</style>

View File

@@ -11,16 +11,13 @@
</div>
</div>
<div class="input-container">
<!-- 加号图标 -->
<div class="icon-wrapper">
<SvgIcon v-if="!isRecording" name="plus" size="40" />
<SvgIcon v-else name="pause" size="60" @click="stopRecording" />
</div>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- 正常状态显示输入框 -->
<div class="input-wrapper">
<textarea
id="textarea"
@@ -34,18 +31,28 @@
ref="textareaRef"
></textarea>
<div v-show="isRecording" class="recording-wrapper">
<!-- <div class="recording-animation"> -->
<AudioVisualizer ref="audioVisualizerRef" />
<!-- </div> -->
</div>
<div v-show="showAudioRecorder" class="audio-recorder-wrapper">
<AudioRecorder
ref="audioRecorderRef"
@recording-started="onRecordingStarted"
@recording-stopped="onRecordingStopped"
@recording-deleted="onRecordingDeleted"
/>
</div>
</div>
<!-- 语音图标 -->
<div class="icon-wrapper" v-show="!isRecording" @click="handleClickAudio">
<SvgIcon name="audio" size="52" />
</div>
<!-- 发送图标 -->
<!-- 音频录制图标 -->
<!-- <div class="icon-wrapper" v-show="!isRecording" @click="toggleAudioRecorder">
<SvgIcon name="download" size="52" />
</div> -->
<div class="icon-wrapper send-icon" @click="handleSend">
<SvgIcon name="send" size="46" />
</div>
@@ -56,13 +63,17 @@
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue'
import AudioVisualizer from './AudioVisualizer.vue'
import AudioRecorder from './AudioRecorder.vue'
import { showToast } from 'vant'
import { getAudioFileInfo, uploadAudioFile, prepareAudioForTTS } from '@/utils/audioUtils'
const emit = defineEmits(['send-message'])
const inputValue = ref<string>('')
const isRecording = ref<boolean>(false)
const showAudioRecorder = ref<boolean>(false)
const audioVisualizerRef = ref<InstanceType<typeof AudioVisualizer> | null>(null)
const audioRecorderRef = ref<InstanceType<typeof AudioRecorder> | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const shortcutList: string[] = [
'Suggest shoe styles',
@@ -97,14 +108,7 @@ const handleKeyDown = (event: KeyboardEvent): void => {
const handleInput = (): void => {
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
// const lineHeight = 4.8
// const maxLines = 3
// const maxHeight = lineHeight * maxLines
const scrollHeight = textareaRef.value.scrollHeight
// const newHeight = Math.min(scrollHeight, maxHeight * 10)
textareaRef.value.style.height = `${scrollHeight}px`
}
}
@@ -145,6 +149,48 @@ const handleClickAudio = async (): Promise<void> => {
}
}
// 切换音频录制器显示
const toggleAudioRecorder = () => {
showAudioRecorder.value = !showAudioRecorder.value
if (showAudioRecorder.value) {
showToast('音频录制功能已启用')
}
}
// 音频录制事件处理
const onRecordingStarted = () => {
console.log('音频录制开始')
showToast('开始录制音频')
}
const onRecordingStopped = async (audioBlob: Blob) => {
console.log('音频录制结束', audioBlob)
showToast('音频录制完成')
try {
// 获取音频文件信息
const audioFile = await getAudioFileInfo(audioBlob)
console.log('音频文件信息:', audioFile)
// 为第三方TTS服务准备数据示例
const ttsData = await prepareAudioForTTS(audioBlob, 'openai')
console.log('TTS服务数据:', ttsData)
// 这里可以将音频文件发送到服务器或进行其他处理
// 例如await uploadAudioFile(audioFile, '/api/upload-audio')
showToast(`音频文件大小: ${(audioFile.size / 1024).toFixed(2)}KB`)
} catch (error) {
console.error('处理音频文件失败:', error)
showToast('处理音频文件失败')
}
}
const onRecordingDeleted = () => {
console.log('音频录制已删除')
showToast('音频录制已删除')
}
// 开始语音识别
const startRecording = () => {
if (!speechRecognition) {
@@ -162,7 +208,7 @@ const startRecording = () => {
// 配置参数
speechRecognition.continuous = true
speechRecognition.interimResults = true
speechRecognition.lang = 'zh-CN' // 设置为
speechRecognition.lang = 'en-US' // 设置为
}
// 识别开始
@@ -336,6 +382,15 @@ const stopRecording = () => {
min-height: 4.8rem;
}
// 音频录制器样式
.audio-recorder-wrapper {
flex: 1;
display: flex;
align-items: center;
min-height: 4.8rem;
padding: 10px 0;
}
.recording-animation {
width: 100%;
height: 100%;

View File

@@ -24,10 +24,14 @@ import HeaderTitle from '@/components/HeaderTitle.vue'
import NoticeList from './components/NoticeList.vue'
import InputArea from './components/InputArea.vue'
import GenerateLoading from './components/GenerateLoading.vue'
import { ref, onMounted } from 'vue'
import { ref, onMounted, onActivated } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
defineOptions({
name:'asistant'
})
// 定义NoticeList组件引用类型
interface NoticeListRef {
simulateSendMessage: () => void
@@ -77,9 +81,15 @@ const messageList = ref<ChatMessage[]>([
])
onMounted(() => {
console.log('🚀 组件挂载 - onMounted 触发')
// handleSendMessage('123')
})
onActivated(() => {
console.log('🔄 缓存页面激活 - onActivated 触发')
console.log('当前消息数量:', messageList.value.length)
})
const handleSendMessage = (message: string): void => {
console.log('收到消息:', message)
messageList.value.push({
@@ -98,7 +108,7 @@ const handleContinue = () => {
isLoading.value = true
setTimeout(() => {
router.push('/workshop/selectStyle')
// isLoading.value = false
isLoading.value = false
}, 1000)
}
</script>

View File

@@ -15,14 +15,13 @@
</div>
<div class="login-container">
<form @submit.prevent="handleLogin" class="login-form">
<form @submit.prevent="handleLogin" class="login-form" novalidate>
<div class="input-group">
<input
type="email"
v-model="formData.email"
placeholder="Email"
class="input-field"
required
/>
</div>
<div class="input-group pwd">
@@ -31,7 +30,6 @@
v-model="formData.password"
placeholder="Your Password"
class="input-field"
required
/>
</div>
@@ -69,11 +67,7 @@ const formData = reactive<Record<string, string>>({
password: ''
})
// 表单验证状态
const formErrors = reactive<Record<string, string>>({
email: '',
password: ''
})
// 移除表单错误状态管理,改用 toast 提示
// 加载状态
const isLoading = ref(false)
@@ -90,31 +84,25 @@ const validatePassword = (password: string) => {
// 验证表单
const validateForm = () => {
let isValid = true
// 重置错误信息
formErrors.email = ''
formErrors.password = ''
// 验证邮箱
if (!formData.email) {
formErrors.email = '请输入邮箱地址'
isValid = false
showToast('请输入邮箱地址')
return false
} else if (!validateEmail(formData.email)) {
formErrors.email = '请输入有效的邮箱地址'
isValid = false
showToast('请输入有效的邮箱地址')
return false
}
// 验证密码
if (!formData.password) {
formErrors.password = '请输入密码'
isValid = false
showToast('请输入密码')
return false
} else if (!validatePassword(formData.password)) {
formErrors.password = '密码至少需要6位字符'
isValid = false
showToast('密码至少需要6位字符')
return false
}
return isValid
return true
}
// 返回上一页
@@ -125,7 +113,6 @@ const goBack = () => {
// 处理登录
const handleLogin = async () => {
if (!validateForm()) {
showToast('请检查输入信息')
return
}
@@ -179,22 +166,7 @@ const handleSignup = () => {
router.push('/signup')
}
// 实时验证输入
const handleEmailInput = () => {
if (formData.email && !validateEmail(formData.email)) {
formErrors.email = '请输入有效的邮箱地址'
} else {
formErrors.email = ''
}
}
const handlePasswordInput = () => {
if (formData.password && !validatePassword(formData.password)) {
formErrors.password = '密码至少需要6位字符'
} else {
formErrors.password = ''
}
}
// 移除实时验证输入的错误显示逻辑,改用表单提交时统一验证
</script>
<style scoped lang="less">

View File

@@ -1,11 +1,11 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/**/*.d.ts"],
"compilerOptions": {
"forceConsistentCasingInFileNames": false, // ⚠️ 禁用大小写检查
"allowJs": true,
"forceConsistentCasingInFileNames": false, // ⚠️ 禁用大小写检查
"allowJs": true,
"baseUrl": ".",
"types": ["node"],
"types": ["node", "unplugin-vue-define-options/macros-global"],
"paths": {
"@/*": ["./src/*"],
"_c/*": ["./src/components/*"]

View File

@@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import DefineOptions from 'unplugin-vue-define-options/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import path from 'path'
import { VantResolver } from '@vant/auto-import-resolver'
@@ -16,6 +17,7 @@ import { VantResolver } from '@vant/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
DefineOptions(),
// ...
AutoImport({
resolvers: [VantResolver()],