feat: init

This commit is contained in:
2025-11-07 20:54:09 +08:00
parent d45e556c20
commit 17570c7821
11 changed files with 3682 additions and 1071 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"WebFetch(domain:x.ant.design)",
"WebSearch",
"WebFetch(domain:antd-design-x-vue.netlify.app)",
"Bash(npm install:*)"
],
"deny": [],
"ask": []
}
}

221
CLAUDE.md Normal file
View File

@@ -0,0 +1,221 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **小智AI (Xiaozhi AI)** voice assistant front-end application built with **uni-app (Vue 3)** targeting multiple platforms (H5, WeChat Mini Program, and other mini-program platforms). The app provides AI conversation, voice interaction, script management, voice cloning, and IoT device configuration capabilities.
**Core Technologies:**
- uni-app 3.x with Vue 3 Composition API
- Vite 5.2.8 for build tooling
- Pinia 2.1.7 for state management
- vue-i18n 9.1.9 for internationalization
**Backend Integration:**
- Java Spring Boot backend at `http://localhost:8091` (configurable in `src/utils/api.js`)
- Alibaba Cloud voice services for TTS/ASR/voice cloning
- WeChat OAuth 2.0 for user authentication
## Development Commands
```bash
# Install dependencies
npm install
# H5 development (web browser)
npm run dev:h5
# WeChat Mini Program development
npm run dev:mp-weixin
# Build for H5 production
npm run build:h5
# Build for WeChat Mini Program
npm run build:mp-weixin
# Other platforms (Alipay, Baidu, etc.)
npm run dev:mp-[platform] # development
npm run build:mp-[platform] # production
```
Build outputs go to `dist/build/h5/` or `dist/build/mp-weixin/` respectively.
## Architecture & Code Structure
### Entry Points
- **`src/main.js`**: Application bootstrap, creates SSR app and registers Pinia
- **`src/App.vue`**: Root component with global lifecycle hooks
- **`src/pages.json`**: Page routing, tabBar configuration, and navigation styles (all pages use custom navigation)
- **`src/manifest.json`**: Platform-specific configurations (WeChat AppID: `wxff56c34ef9aceb62`)
### State Management (Pinia)
- **`src/stores/user.js`**: User authentication state
- Manages `token`, `nickName`, `avatarUrl`, `openid`, `isLoggedIn`, `hasAgreedToTerms`, `hasVisitedMinePage`
- Provides `login()`, `logout()`, `setUserInfo()`, `clearFakeLoginData()` methods
- Persists state to uni-app local storage (`userInfo`, `custom_token`, `user_token`)
- **Important**: Token is checked from multiple sources (store, `custom_token`, `user_token`, `userInfo.token`)
### API Layer (`src/utils/api.js`)
- **Request interceptor**: Automatically adds `Authorization: Bearer <token>` header from multiple token sources
- **Response handling**: Smart extraction of backend responses from various nested structures
- **Text cleaning**: `cleanText()` function strips HTML/Markdown and keeps only text and punctuation
**API Modules:**
- **`chatAPI`**:
- `syncChat()` - Synchronous chat with fallback for anonymous users
- `asyncChat()` - Asynchronous chat (if needed)
- `getChatHistory()`, `createConversation()` - Conversation management
- **Note**: When user is not logged in, returns `isAnonymous: true` to trigger local simulation
- **`voiceAPI`**:
- `textToSpeech()` - TTS (Text-to-Speech)
- `chatWithTTS()` - AI chat + voice synthesis
- `voiceChat()` - Complete voice interaction (uploads AAC, backend converts to WAV)
- `uploadVoiceChat()` - Audio file upload for voice chat
- **Important**: Frontend sends AAC format, backend must convert to WAV for processing
- Response parsing handles complex nested structures: `data.llmResult.response`, `data.sttResult.text`, `data.ttsResult.audioPath`
- **`rechargeAPI`**: `getUserBalance()`, `createRechargeOrder()`, `getOrderStatus()`, `getRechargeHistory()`
- **`roleAPI`**: `getRoles()`, `getRoleById()` - AI character management
- **`configAPI`**: `getAllConfigs()`, `getModels()`, `getSTTConfigs()`, `getTemplates()`, `getTTSConfigs()`
### Page Structure
Pages are organized by feature under `src/pages/`:
**Core Pages:**
- `splash/splash` - Launch screen with agreement flow
- `index/index` - Homepage with drama characters and AI voices (has tabBar)
- `mine/mine` - User profile and settings (has tabBar)
- `chat/chat` - AI conversation with text and voice input
- `create/create` - Script creation entry
- `script/editor` - Script editing interface
- `drama/index` - Drama character details
- `voice/clone` - Voice cloning feature
- `agreement/agreement` - User agreement display
- `recharge/recharge` - Recharge/payment
- `recharge/history` - Recharge history (with pull-down refresh enabled)
All pages use `navigationStyle: "custom"` for custom navigation bars.
### Global Styles
- **`src/uni.scss`**: Global SCSS variables and mixins
- **`src/styles/`**: Common stylesheets
- **`src/static/`**: Static assets (images, icons, etc.)
### Components
- **`src/components/UserAgreement.vue`**: User agreement component
- **`src/components/UserAuth.vue`**: User authentication component
## Key Architectural Patterns
### Authentication Flow
1. User triggers login from UI
2. `useUserStore.login()` orchestrates:
- Call `uni.getUserProfile()` for WeChat user info
- Call `uni.login()` to get WeChat `code`
- POST to `/app/login` with `code`
- Save `token`, `nickName`, `avatarUrl`, `openid` to store and local storage
3. All subsequent API calls auto-inject token from store/storage via request interceptor
### Anonymous User Handling
- When not logged in, `chatAPI.syncChat()` returns `isAnonymous: true`
- UI layer should implement local simulation/fallback responses
- Voice APIs skip backend calls and use degradation handling
### Voice Interaction Flow
1. **Record**: User records voice (AAC format) using `uni.getRecorderManager()`
2. **Upload**: Frontend uploads AAC to backend via `voiceAPI.voiceChat()` or `uploadVoiceChat()`
3. **Backend Processing**:
- Convert AAC to WAV
- STT (Speech-to-Text) via Alibaba Cloud ASR
- LLM processing for AI response
- TTS (Text-to-Speech) via Alibaba Cloud TTS
4. **Response**: Backend returns structured response with `sttResult.text`, `llmResult.response`, `ttsResult.audioPath`
5. **Playback**: Frontend plays audio using `uni.createInnerAudioContext()`
### Response Data Extraction Pattern
The API layer uses a flexible extraction strategy to handle varying backend response structures:
- Try multiple field names: `response`, `message`, `content`, `reply`, `answer`, `text`
- Check nested paths: `data.data.response`, `data.response`, root-level `response`
- For voice APIs, specifically look for: `data.llmResult.response`, `data.sttResult.text`, `data.ttsResult.audioPath`
- Always clean responses with `cleanText()` to remove markup
## Important Configuration Files
### `src/pages.json`
Defines all pages, tabBar, and global styles. Key settings:
- All pages have `navigationStyle: "custom"` (custom nav bars)
- TabBar: Homepage (`pages/index/index`) and Mine (`pages/mine/mine`)
- `lazyCodeLoading: "requiredComponents"` for performance
- Splash and agreement pages have `disableScroll: true` and `popGesture: "none"` (prevent back gesture)
### `src/manifest.json`
Platform-specific configurations:
- WeChat Mini Program AppID: `wxff56c34ef9aceb62`
- App permissions and capabilities
- Platform-specific optimizations
### `vite.config.js`
Minimal Vite config using `@dcloudio/vite-plugin-uni` plugin.
## Documentation References
Refer to these files for detailed specifications:
- **`API接口文档.md`**: Complete backend API documentation with request/response examples
- **`产品设计需求文档.md`**: Product requirements, functional specifications, AI service integration details, IoT device configuration requirements
- **`前端交接文档.md`**: Project handoff document with module descriptions and development notes
## Development Best Practices
### Working with uni-app
- Use `uni.*` APIs for cross-platform compatibility (e.g., `uni.request`, `uni.showToast`, `uni.navigateTo`)
- Test on both H5 and WeChat Mini Program to ensure compatibility
- WeChat Mini Program has stricter restrictions (e.g., requires HTTPS for production, domain whitelist)
### State Management
- Always use Pinia store for shared state
- Persist critical state (user info, tokens) to local storage via `uni.setStorageSync()`
- Initialize stores on app launch by calling store's `init()` method
### API Integration
- Never hardcode API endpoints; use `BASE_URL` in `src/utils/api.js`
- Handle both logged-in and anonymous states gracefully
- Always check for multiple possible response structures when parsing backend data
- Use `cleanText()` for AI-generated text to strip formatting
### Audio Handling
- Record audio using `uni.getRecorderManager()` with AAC format
- Play audio using `uni.createInnerAudioContext()`
- Always inform backend that AAC will be sent and needs WAV conversion
- Cache frequently used audio responses for better performance
### Error Handling
- Display user-friendly error messages using `uni.showToast()`
- Log errors to console for debugging
- Implement fallback mechanisms for non-critical features when backend is unavailable
### Performance
- Use `lazyCodeLoading: "requiredComponents"` (already configured)
- Optimize images and static assets
- Implement code splitting for large pages
- Cache API responses where appropriate (e.g., config data, role lists)
## Platform-Specific Notes
### WeChat Mini Program
- Requires domain whitelist configuration in WeChat Developer Console
- OAuth login flow uses `uni.login({ provider: 'weixin' })`
- Recording and playback permissions must be requested from user
- Payment integration uses WeChat Pay API
### H5 (Web)
- Can be deployed to any web server
- Use browser DevTools for debugging
- May need CORS configuration on backend for local development
## Git Workflow
Main branch: `master`
Current branch: `test`
When creating PRs, target the `master` branch.

View File

@@ -52,6 +52,8 @@
"@dcloudio/uni-mp-weixin": "3.0.0-4060420250429001",
"@dcloudio/uni-mp-xhs": "3.0.0-4060420250429001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4060420250429001",
"ant-design-vue": "^4.2.6",
"ant-design-x-vue": "^1.3.2",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"

View File

@@ -2,18 +2,26 @@
export default {
onLaunch: function () {
console.log('App Launch');
// 应用启动时的全局初始化逻辑
// 启动页会自动处理倒计时和页面跳转
// 可以在这里添加其他全局初始化逻辑
// 例如:设置全局变量、注册事件监听器等
// 检查是否是首次启动或需要显示启动页
const hasShownSplash = uni.getStorageSync('hasShownSplash');
const hasAgreedToTerms = uni.getStorageSync('hasAgreedToTerms');
// 如果未显示过启动页或未同意协议,则跳转到启动页
if (!hasShownSplash || hasAgreedToTerms !== 'true') {
// 延迟一下确保页面已加载
setTimeout(() => {
uni.redirectTo({
url: '/pages/splash/splash'
});
}, 100);
}
},
onShow: function () {
console.log('App Show')
},
onHide: function () {
console.log('App Hide')
}

View File

@@ -1,6 +1,29 @@
{
"lazyCodeLoading": "requiredComponents",
"pages": [
"pages": [
{
"path": "pages/drama/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/device/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/chat",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/splash/splash",
"style": {
@@ -19,12 +42,6 @@
"disableScroll": false
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/create/create",
"style": {
@@ -37,12 +54,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/drama/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/voice/clone",
"style": {
@@ -55,16 +66,10 @@
"navigationStyle": "custom",
"disableScroll": true,
"app-plus": {
"popGesture": "none"
"popGesture": "none"
}
}
},
{
"path": "pages/chat/chat",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/recharge/recharge",
"style": {
@@ -92,8 +97,16 @@
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
"pagePath": "pages/drama/index",
"text": "发现"
},
{
"pagePath": "pages/device/index",
"text": "设备"
},
{
"pagePath": "pages/chat/chat",
"text": "专属"
},
{
"pagePath": "pages/mine/mine",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

584
src/pages/device/index.vue Normal file
View File

@@ -0,0 +1,584 @@
<template>
<view class="floral-container">
<!-- 夜空装饰背景 -->
<view class="night-sky-decoration">
<view class="star star-1"></view>
<view class="star star-2"></view>
<view class="star star-3"></view>
<view class="star star-4"></view>
<view class="star star-5"></view>
<view class="star star-6"></view>
<view class="star star-7"></view>
<view class="star star-8"></view>
</view>
<!-- 顶部自定义导航栏 -->
<view class="custom-navbar fixed-navbar">
<view class="navbar-stars">
<view class="navbar-star star-left"></view>
<view class="navbar-star star-right"></view>
</view>
<text class="navbar-title">我的设备</text>
</view>
<!-- 设备信息卡片 -->
<view class="device-card">
<view class="device-header">
<view class="device-icon" :class="{'connected': deviceConnected}">
{{ deviceConnected ? '🌟' : '⭕' }}
</view>
<view class="device-status">
<text class="status-text">{{ deviceConnected ? '已连接' : '未连接' }}</text>
<text class="status-indicator" :class="{'active': deviceConnected}"></text>
</view>
</view>
<view class="device-info">
<view class="info-row">
<text class="info-label">设备名称</text>
<text class="info-value">{{ deviceName }}</text>
</view>
<view class="info-row">
<text class="info-label">设备ID</text>
<text class="info-value">{{ deviceId }}</text>
</view>
<view class="info-row">
<text class="info-label">设备SN</text>
<text class="info-value">{{ deviceSN }}</text>
</view>
<view class="info-row" v-if="deviceConnected">
<text class="info-label">连接时间</text>
<text class="info-value">{{ connectTime }}</text>
</view>
</view>
<view class="device-actions">
<button
class="floral-btn"
:class="{'disconnect-btn': deviceConnected}"
@click="toggleConnection"
>
{{ deviceConnected ? '断开连接' : '连接设备' }}
</button>
<button
class="floral-btn outline refresh-btn"
@click="refreshDeviceStatus"
>
🔄 刷新状态
</button>
</view>
</view>
<!-- 未登录提示 -->
<view class="login-tip" v-if="!userStore.isLoggedIn">
<view class="tip-icon">🔐</view>
<text class="tip-text">请先登录以查看设备信息</text>
<button class="floral-btn small-btn" @click="goToLogin">去登录</button>
</view>
<!-- 设备功能列表 -->
<view class="feature-list" v-if="deviceConnected && userStore.isLoggedIn">
<view class="feature-title">设备功能</view>
<view class="feature-item">
<view class="feature-icon">📡</view>
<view class="feature-content">
<text class="feature-name">远程控制</text>
<text class="feature-desc">通过小程序远程控制设备</text>
</view>
</view>
<view class="feature-item">
<view class="feature-icon">🎤</view>
<view class="feature-content">
<text class="feature-name">语音交互</text>
<text class="feature-desc">与AI语音助手对话</text>
</view>
</view>
<view class="feature-item">
<view class="feature-icon">🔔</view>
<view class="feature-content">
<text class="feature-name">消息通知</text>
<text class="feature-desc">接收设备状态通知</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useUserStore } from '@/stores/user.js';
// 状态管理
const userStore = useUserStore();
const deviceConnected = ref(false);
const deviceName = ref('小智AI设备');
const deviceSN = ref('未连接');
const deviceId = ref('30:ed:a0:12:99:60');
const connectTime = ref('');
// 生命周期
onMounted(() => {
userStore.init();
// refreshDeviceStatus(); // API已注释不需要自动刷新
});
// 刷新设备状态
const refreshDeviceStatus = () => {
if (!userStore.isLoggedIn) {
uni.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
// 暂时注释掉API请求使用本地模拟数据
uni.showToast({
title: '刷新成功(本地模拟)',
icon: 'success'
});
/* API请求已注释
uni.showLoading({
title: '获取设备状态...'
});
uni.request({
url: 'https://www.aixsy.com.cn/api/device/status',
method: 'GET',
header: {
'Authorization': userStore.token ? (userStore.token.startsWith('Bearer ') ? userStore.token : 'Bearer ' + userStore.token) : ''
},
success: (res) => {
console.log('设备状态响应:', res);
if (res.statusCode === 200 && res.data.data) {
const data = res.data.data;
deviceConnected.value = data.connected || false;
deviceSN.value = data.sn || '未知SN';
if (deviceConnected.value) {
connectTime.value = data.connectTime || new Date().toLocaleString();
}
uni.showToast({
title: '刷新成功',
icon: 'success'
});
}
},
fail: (err) => {
console.error('获取设备状态失败:', err);
uni.showToast({
title: '刷新失败',
icon: 'none'
});
},
complete: () => {
uni.hideLoading();
}
});
*/
};
// 连接/断开设备
const toggleConnection = () => {
if (!userStore.isLoggedIn) {
uni.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
// 暂时注释掉API请求使用本地模拟数据
deviceConnected.value = !deviceConnected.value;
if (deviceConnected.value) {
connectTime.value = new Date().toLocaleString();
deviceSN.value = 'XZ' + Math.random().toString(36).substr(2, 8).toUpperCase();
uni.showToast({
title: '连接成功(本地模拟)',
icon: 'success'
});
} else {
connectTime.value = '';
deviceSN.value = '未连接';
uni.showToast({
title: '已断开连接(本地模拟)',
icon: 'success'
});
}
/* API请求已注释
uni.showLoading({
title: deviceConnected.value ? '断开连接中...' : '连接设备中...'
});
const token = userStore.token;
const requestData = {
deviceId: deviceId.value
};
uni.request({
url: `https://www.aixsy.com.cn/api/device/${deviceConnected.value ? 'disconnect' : 'connect'}`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': token ? (token.startsWith('Bearer ') ? token : 'Bearer ' + token) : ''
},
data: requestData,
success: (res) => {
console.log('设备操作成功:', res);
if (res.statusCode === 200) {
deviceConnected.value = !deviceConnected.value;
if (deviceConnected.value) {
connectTime.value = new Date().toLocaleString();
deviceSN.value = res.data.data?.sn || 'XZ' + Math.random().toString(36).substr(2, 8).toUpperCase();
uni.showToast({
title: '连接成功',
icon: 'success'
});
} else {
connectTime.value = '';
deviceSN.value = '未连接';
uni.showToast({
title: '已断开连接',
icon: 'success'
});
}
}
},
fail: (err) => {
console.error('设备操作失败:', err);
uni.showToast({
title: deviceConnected.value ? '断开失败' : '连接失败',
icon: 'none'
});
},
complete: () => {
uni.hideLoading();
}
});
*/
};
// 去登录
const goToLogin = () => {
uni.switchTab({
url: '/pages/mine/mine'
});
};
</script>
<style lang="scss" scoped>
.floral-container {
min-height: 100vh;
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
padding-bottom: 100rpx;
position: relative;
overflow: hidden;
}
/* 夜空装饰 */
.night-sky-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.star {
position: absolute;
width: 6rpx;
height: 6rpx;
background: #f9e076;
border-radius: 50%;
animation: twinkle 2s infinite;
box-shadow: 0 0 10rpx #f9e076;
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.star-1 { top: 10%; left: 15%; animation-delay: 0s; }
.star-2 { top: 20%; left: 80%; animation-delay: 0.3s; }
.star-3 { top: 30%; left: 45%; animation-delay: 0.6s; }
.star-4 { top: 50%; left: 25%; animation-delay: 0.9s; }
.star-5 { top: 60%; left: 70%; animation-delay: 1.2s; }
.star-6 { top: 70%; left: 40%; animation-delay: 1.5s; }
.star-7 { top: 15%; left: 60%; animation-delay: 0.4s; }
.star-8 { top: 85%; left: 55%; animation-delay: 1.8s; }
/* 导航栏 */
.custom-navbar {
position: sticky;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: rgba(26, 11, 46, 0.95);
backdrop-filter: blur(20rpx);
display: flex;
align-items: center;
justify-content: center;
padding: 0 30rpx;
z-index: 1000;
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
}
.navbar-stars {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
}
.navbar-star {
position: absolute;
width: 8rpx;
height: 8rpx;
background: #f9e076;
border-radius: 50%;
box-shadow: 0 0 15rpx #f9e076;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
.star-left {
top: 50%;
left: 30rpx;
transform: translateY(-50%);
}
.star-right {
top: 50%;
right: 30rpx;
transform: translateY(-50%);
}
.navbar-title {
font-size: 36rpx;
font-weight: bold;
color: #f9e076;
text-shadow: 0 0 20rpx rgba(249, 224, 118, 0.5);
}
/* 设备卡片 */
.device-card {
margin: 40rpx 30rpx;
background: linear-gradient(135deg, rgba(249, 224, 118, 0.1) 0%, rgba(249, 224, 118, 0.05) 100%);
border-radius: 30rpx;
padding: 40rpx;
border: 2rpx solid rgba(249, 224, 118, 0.2);
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.3);
position: relative;
z-index: 1;
}
.device-header {
display: flex;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 30rpx;
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
}
.device-icon {
font-size: 80rpx;
margin-right: 30rpx;
transition: all 0.3s;
}
.device-icon.connected {
animation: rotate 2s infinite linear;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.device-status {
display: flex;
align-items: center;
gap: 20rpx;
}
.status-text {
font-size: 32rpx;
font-weight: bold;
color: #f9e076;
}
.status-indicator {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: rgba(249, 224, 118, 0.3);
}
.status-indicator.active {
background: #4caf50;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.device-info {
margin-bottom: 30rpx;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba(249, 224, 118, 0.05);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 28rpx;
color: rgba(249, 224, 118, 0.6);
}
.info-value {
font-size: 28rpx;
color: #f9e076;
font-weight: 500;
}
.device-actions {
display: flex;
gap: 20rpx;
}
.floral-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
color: #1a0b2e;
font-weight: bold;
font-size: 28rpx;
border-radius: 40rpx;
border: none;
box-shadow: 0 8rpx 20rpx rgba(249, 224, 118, 0.3);
transition: all 0.3s;
}
.floral-btn:active {
transform: scale(0.95);
box-shadow: 0 4rpx 10rpx rgba(249, 224, 118, 0.3);
}
.disconnect-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 8rpx 20rpx rgba(255, 107, 107, 0.3);
}
.outline {
background: transparent;
color: #f9e076;
border: 2rpx solid #f9e076;
box-shadow: none;
}
.refresh-btn {
flex: 0 0 auto;
width: 180rpx;
}
/* 未登录提示 */
.login-tip {
margin: 40rpx 30rpx;
padding: 60rpx 40rpx;
background: rgba(249, 224, 118, 0.05);
border-radius: 30rpx;
border: 2rpx dashed rgba(249, 224, 118, 0.3);
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
position: relative;
z-index: 1;
}
.tip-icon {
font-size: 80rpx;
}
.tip-text {
font-size: 28rpx;
color: rgba(249, 224, 118, 0.8);
}
.small-btn {
width: 200rpx;
margin-top: 20rpx;
}
/* 功能列表 */
.feature-list {
margin: 40rpx 30rpx;
position: relative;
z-index: 1;
}
.feature-title {
font-size: 32rpx;
font-weight: bold;
color: #f9e076;
margin-bottom: 30rpx;
padding-left: 20rpx;
border-left: 6rpx solid #f9e076;
}
.feature-item {
display: flex;
align-items: center;
gap: 30rpx;
padding: 30rpx;
margin-bottom: 20rpx;
background: linear-gradient(135deg, rgba(249, 224, 118, 0.08) 0%, rgba(249, 224, 118, 0.03) 100%);
border-radius: 20rpx;
border: 1rpx solid rgba(249, 224, 118, 0.1);
}
.feature-icon {
font-size: 60rpx;
}
.feature-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.feature-name {
font-size: 30rpx;
font-weight: bold;
color: #f9e076;
}
.feature-desc {
font-size: 24rpx;
color: rgba(249, 224, 118, 0.6);
}
</style>

View File

@@ -1,14 +1,16 @@
<template>
<view class="floral-container">
<!-- 顶部自定义导航栏 -->
<view class="custom-navbar fixed-navbar">
<view class="navbar-left" @tap="goBack"> 返回</view>
<text class="navbar-title">选择剧情</text>
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="navbar-title">剧情角色</view>
</view>
</view>
<!-- 可滚动的两列卡片区 -->
<scroll-view
class="scroll-container"
:style="{ marginTop: navBarHeight + 'px' }"
scroll-y="true"
:show-scrollbar="false"
>
@@ -94,6 +96,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useUserStore } from '@/stores/user.js';
import { roleAPI } from '@/utils/api.js';
const userStore = useUserStore();
const showLoginModal = ref(false);
@@ -102,84 +105,283 @@ const showLoginModal = ref(false);
const showDetailModal = ref(false);
const selectedItem = ref(null);
// 数据:剧情列表
// 状态栏高度适配
const statusBarHeight = ref(0);
const navBarHeight = ref(0);
// 获取系统状态栏高度
const getSystemInfo = () => {
const systemInfo = uni.getSystemInfoSync();
statusBarHeight.value = systemInfo.statusBarHeight || 0;
// 导航栏总高度 = 状态栏高度 + 导航栏内容高度44px
navBarHeight.value = statusBarHeight.value + 44;
console.log('状态栏高度:', statusBarHeight.value, '导航栏总高度:', navBarHeight.value);
};
// 数据剧情列表使用API请求获取真实数据
const dramaList = ref([]);
/* ====== 以下为假数据(已注释,保留作为参考) ======
const dramaList = ref([
{
id: 1,
cover: '/static/bailing.jpg',
tag: '浪漫',
title: '白领女友的温柔早安',
{
id: 1,
roleId: 1,
roleName: '小何',
roleDesc: '台湾女孩,高情商智能助手',
avatar: '/static/bailing.jpg',
greeting: '你好~我是小何,一个高情商的台湾女孩,很高兴认识你!有什么想聊的吗?'
cover: '/static/characters/xiaozhi.png',
avatar: '/static/characters/xiaozhi.png',
tag: '浪漫',
title: '小智AI助手',
roleName: '小智AI助手',
roleDesc: '你好我是小智你的专属AI助手。我可以帮你解答问题、提供建议、陪你聊天。',
greeting: '你好!我是小智,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-1',
ttsId: 'tts-1',
sttId: 'stt-1',
temperature: 0.7,
topP: 0.9
},
{
id: 2,
cover: '/static/haomen.jpg',
tag: '优雅',
title: '豪门女主的秘密生活',
{
id: 2,
roleId: 2,
roleName: '小于',
roleDesc: '咖啡店兼职店员,热情友好',
avatar: '/static/haomen.jpg',
greeting: '你好!我是小于,咖啡店的新店员,今天想喝点什么吗?'
cover: '/static/characters/sakura.png',
avatar: '/static/characters/sakura.png',
tag: '优雅',
title: '元气少女小樱',
roleName: '元气少女小樱',
roleDesc: '嘿!我是小樱,超级喜欢和大家聊天的元气少女!每天都充满活力。',
greeting: '你好!我是小樱,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-2',
ttsId: 'tts-2',
sttId: 'stt-1',
temperature: 0.8,
topP: 0.9
},
{
id: 3,
cover: '/static/waimai.jpg',
tag: '活泼',
title: '外卖小姐姐的贴心问候',
{
id: 3,
roleId: 3,
roleName: '李老师',
roleDesc: '中学图书馆管理员,温文尔雅',
avatar: '/static/waimai.jpg',
greeting: '你好,我是李老师,图书馆管理员。有什么想了解的书吗?'
cover: '/static/characters/lina.png',
avatar: '/static/characters/lina.png',
tag: '活泼',
title: '温柔大姐姐琳娜',
roleName: '温柔大姐姐琳娜',
roleDesc: '你好呀,我是琳娜。如果你需要倾诉或者想要一些温暖的陪伴,随时都可以找我。',
greeting: '你好!我是琳娜,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-3',
ttsId: 'tts-3',
sttId: 'stt-1',
temperature: 0.7,
topP: 0.9
},
{
id: 4,
cover: '/static/tunvlang.jpg',
tag: '深情',
title: '因爱执著的少女',
{
id: 4,
roleId: 4,
roleName: '小何',
roleDesc: '另一个小何角色',
avatar: '/static/tunvlang.jpg',
greeting: '你好~我是小何,很高兴为你服务!'
cover: '/static/characters/allen.png',
avatar: '/static/characters/allen.png',
tag: '深情',
title: '知识博士艾伦',
roleName: '知识博士艾伦',
roleDesc: '大家好,我是艾伦博士。拥有丰富的知识储备,擅长科学、历史、文学等各个领域。',
greeting: '你好!我是艾伦博士,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-4',
ttsId: 'tts-4',
sttId: 'stt-1',
temperature: 0.6,
topP: 0.9
},
// {
// id: 5,
// cover: '/static/logo.png',
// tag: '温暖',
// title: '邻家女孩的暖心故事',
// roleId: 5,
// roleName: '温衡',
// roleDesc: '职场女上司,成熟强势',
// avatar: '/static/logo.png',
// greeting: '我是温衡,你的上司。今天的工作完成了吗?'
// },
// {
// id: 6,
// cover: '/static/logo.png',
// tag: '梦幻',
// title: '公主的浪漫邂逅',
// roleId: 6,
// roleName: '职场上司',
// roleDesc: '另一个温衡角色',
// avatar: '/static/logo.png',
// greeting: '我是你的上司,有什么需要汇报的吗?'
// },
// { id: 7, cover: '/static/logo.png', tag: '治愈', title: '咖啡店的午后时光' },
// { id: 8, cover: '/static/logo.png', tag: '青春', title: '校园里的美好回忆' },
{
id: 5,
roleId: 5,
cover: '/static/characters/ajie.png',
avatar: '/static/characters/ajie.png',
tag: '温暖',
title: '搞笑达人阿杰',
roleName: '搞笑达人阿杰',
roleDesc: '哈喽我是阿杰专业逗乐20年生活已经够累了让我用段子和笑话给你的一天增添点乐趣吧。',
greeting: '你好!我是阿杰,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-5',
ttsId: 'tts-5',
sttId: 'stt-1',
temperature: 0.9,
topP: 0.95
},
{
id: 6,
roleId: 6,
cover: '/static/characters/mike.png',
avatar: '/static/characters/mike.png',
tag: '梦幻',
title: '运动教练米克',
roleName: '运动教练米克',
roleDesc: '嘿!我是米克,你的私人运动教练。想要保持健康、塑造好身材吗?',
greeting: '你好!我是米克,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-6',
ttsId: 'tts-6',
sttId: 'stt-1',
temperature: 0.7,
topP: 0.9
},
{
id: 7,
roleId: 7,
cover: '/static/characters/lily.png',
avatar: '/static/characters/lily.png',
tag: '治愈',
title: '心理咨询师莉莉',
roleName: '心理咨询师莉莉',
roleDesc: '你好,我是莉莉。作为一名心理咨询师,我擅长倾听和理解你的内心世界。',
greeting: '你好!我是莉莉,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-7',
ttsId: 'tts-7',
sttId: 'stt-1',
temperature: 0.7,
topP: 0.9
},
{
id: 8,
roleId: 8,
cover: '/static/characters/meimei.png',
avatar: '/static/characters/meimei.png',
tag: '青春',
title: '美食家小美',
roleName: '美食家小美',
roleDesc: '嗨!我是小美,一个热爱美食的吃货!从家常菜到高级料理,从中餐到西餐,我都很在行哦。',
greeting: '你好!我是小美,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-8',
ttsId: 'tts-8',
sttId: 'stt-1',
temperature: 0.8,
topP: 0.9
},
{
id: 9,
roleId: 9,
cover: '/static/characters/andy.png',
avatar: '/static/characters/andy.png',
tag: '浪漫',
title: '旅行达人安迪',
roleName: '旅行达人安迪',
roleDesc: '你好!我是安迪,走遍世界各地的旅行达人。我可以为你推荐最美的风景。',
greeting: '你好!我是安迪,很高兴认识你!',
modelId: 'gpt-3.5-turbo',
templateId: 'template-9',
ttsId: 'tts-9',
sttId: 'stt-1',
temperature: 0.7,
topP: 0.9
},
{
id: 10,
roleId: 10,
cover: '/static/characters/code.png',
avatar: '/static/characters/code.png',
tag: '优雅',
title: '程序员小码',
roleName: '程序员小码',
roleDesc: 'Hello World我是小码一名资深程序员。擅长各种编程语言和技术栈。',
greeting: 'Hello我是小码很高兴认识你',
modelId: 'gpt-3.5-turbo',
templateId: 'template-10',
ttsId: 'tts-10',
sttId: 'stt-1',
temperature: 0.6,
topP: 0.9
}
]);
====== 假数据结束 ====== */
// 列数据拆分
const leftColumnItems = computed(() => dramaList.value.filter((_, index) => index % 2 === 0));
const rightColumnItems = computed(() => dramaList.value.filter((_, index) => index % 2 === 1));
// 加载角色列表数据
const loadDramaList = async () => {
try {
uni.showLoading({ title: '加载中...' });
console.log('开始加载角色列表...');
const result = await roleAPI.getRoles();
console.log('API返回结果:', result);
if (result.success) {
// 处理响应数据,兼容多种返回格式
let roles = [];
// 尝试从不同的路径提取角色列表数据
if (result.data && result.data.data && result.data.data.list) {
// 格式:{ data: { data: { list: [...] } } }
roles = result.data.data.list;
} else if (result.data && result.data.list) {
// 格式:{ data: { list: [...] } }
roles = result.data.list;
} else if (Array.isArray(result.data)) {
// 格式:{ data: [...] }
roles = result.data;
} else if (result.data && result.data.data && Array.isArray(result.data.data)) {
// 格式:{ data: { data: [...] } }
roles = result.data.data;
}
console.log('提取的角色数据:', roles);
// 映射数据到前端需要的格式
dramaList.value = roles.map(role => ({
id: role.roleId,
roleId: role.roleId,
cover: role.avatar || '/static/default-avatar.png',
avatar: role.avatar || '/static/default-avatar.png',
tag: role.tag || '角色',
title: role.roleName || '未命名角色',
roleName: role.roleName || '未命名角色',
roleDesc: role.roleDesc || '',
greeting: role.greeting || '你好!很高兴认识你!',
modelId: role.modelId || 'gpt-3.5-turbo',
modelName: role.modelName || '',
modelProvider: role.modelProvider || '',
templateId: role.templateId || role.roleId,
ttsId: role.ttsId || '',
ttsProvider: role.ttsProvider || '',
sttId: role.sttId || '',
temperature: role.temperature || 0.7,
topP: role.topP || 0.9,
voiceName: role.voiceName || '',
vadEnergyTh: role.vadEnergyTh || 0.01,
vadSilenceMs: role.vadSilenceMs || 1200,
vadSilenceTh: role.vadSilenceTh || 0.3,
vadSpeechTh: role.vadSpeechTh || 0.5
}));
console.log('角色列表加载成功,共', dramaList.value.length, '个角色');
} else {
console.error('获取角色列表失败:', result.error);
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
});
}
} catch (error) {
console.error('加载角色列表异常:', error);
uni.showToast({
title: '加载异常,请重试',
icon: 'none'
});
} finally {
uni.hideLoading();
}
};
onMounted(() => {
getSystemInfo(); // 获取系统信息
userStore.init();
// 调用 API 加载角色列表
loadDramaList();
});
// 方法
@@ -189,22 +391,29 @@ const handleUse = (item) => {
return;
}
uni.showLoading({ title: '正在设置角色...' });
if (item.roleId) {
uni.hideLoading();
const params = {
characterId: item.id,
roleId: item.roleId,
roleName: item.roleName || item.title,
roleDesc: item.roleDesc,
avatar: item.avatar || item.cover,
greeting: item.greeting
};
const queryString = Object.keys(params).map(key => `${key}=${encodeURIComponent(params[key] || '')}`).join('&');
uni.navigateTo({ url: `/pages/chat/chat?${queryString}` });
} else {
uni.hideLoading();
uni.navigateTo({ url: `/pages/chat/chat?characterId=${item.id}` });
}
// 构建完整的角色参数,包括模型和模板信息
const params = {
characterId: item.id,
roleId: item.roleId,
roleName: item.roleName || item.title,
roleDesc: item.roleDesc,
avatar: item.avatar || item.cover,
greeting: item.greeting,
modelId: item.modelId || '', // 模型ID
templateId: item.templateId || '', // 模板ID使用roleId作为templateId
ttsId: item.ttsId || '', // TTS服务ID
sttId: item.sttId || '', // STT服务ID
temperature: item.temperature || '',
topP: item.topP || ''
};
const queryString = Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key] || '')}`)
.join('&');
uni.hideLoading();
uni.navigateTo({ url: `/pages/chat/chat?${queryString}` });
};
const showLoginTip = () => { showLoginModal.value = true; };
@@ -212,7 +421,6 @@ const showDetail = (item) => { selectedItem.value = item; showDetailModal.value
const closeDetail = () => { showDetailModal.value = false; selectedItem.value = null; };
const useFromDetail = () => { if (selectedItem.value) { const v = selectedItem.value; closeDetail(); handleUse(v); } };
const goToLogin = () => { showLoginModal.value = false; uni.switchTab({ url: '/pages/mine/mine' }); };
const goBack = () => { uni.navigateBack(); };
</script>
<style scoped>
@@ -223,47 +431,41 @@ const goBack = () => { uni.navigateBack(); };
position: relative;
overflow-x: hidden;
padding-bottom: 120rpx;
padding-top: 100rpx;
display: flex;
flex-direction: column;
}
.fixed-navbar {
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: 1002;
width: 100%;
background: linear-gradient(135deg, rgba(26, 11, 46, 0.95) 0%, rgba(74, 30, 109, 0.95) 25%, rgba(107, 44, 156, 0.95) 50%, rgba(138, 43, 226, 0.95) 75%, rgba(75, 0, 130, 0.95) 100%);
backdrop-filter: blur(10px);
border-bottom: 1rpx solid rgba(251, 191, 36, 0.3);
z-index: 1000;
}
.custom-navbar {
height: 100rpx;
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(26, 11, 46, 0.95) 0%, rgba(74, 30, 109, 0.95) 25%, rgba(107, 44, 156, 0.95) 50%, rgba(138, 43, 226, 0.95) 75%, rgba(75, 0, 130, 0.95) 100%);
border-bottom: 1rpx solid rgba(251, 191, 36, 0.3);
font-weight: bold;
font-size: 32rpx;
position: relative;
color: #fff;
}
.navbar-left {
position: absolute;
left: 24rpx;
color: #f9e076;
font-size: 28rpx;
padding: 0 24rpx;
}
.navbar-title {
color: #ffffff;
font-weight: bold;
font-size: 32rpx;
text-shadow: 0 0 10px rgba(249, 224, 118, 0.5);
}
.scroll-container {
flex: 1;
min-height: 0;
margin: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
}

View File

@@ -110,20 +110,17 @@ const skipToHome = () => {
if (timer) {
clearInterval(timer);
}
// 尝试使用reLaunch跳转
uni.reLaunch({
url: '/pages/index/index',
// 标记已显示过启动页
uni.setStorageSync('hasShownSplash', 'true');
// 跳转到 tabBar 首页(发现页)
uni.switchTab({
url: '/pages/drama/index',
fail: () => {
// 如果reLaunch失败尝试switchTab
uni.switchTab({
url: '/pages/index/index',
fail: () => {
// 如果switchTab也失败使用redirectTo
uni.redirectTo({
url: '/pages/index/index'
});
}
// 如果 switchTab 失败,使用 reLaunch
uni.reLaunch({
url: '/pages/drama/index'
});
}
});

View File

@@ -42,14 +42,14 @@ export const cleanText = (text) => {
// 去除行首行尾空格
.trim();
// 去除特殊字符(保留中文、英文、数字、标点符号)
cleaned = cleaned.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\.,;:!?()()【】""''""''、,。!?;:]/g, '');
// 去除特殊字符(保留中文、英文、数字、标点符号,以及分隔符 &
cleaned = cleaned.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\.,;:!?()()【】""''""''、,。!?;:&]/g, '');
return cleaned;
};
// 基础配置
const BASE_URL = 'http://localhost:8091'; // 根据后端地址调整
const BASE_URL = 'http://192.168.3.243:8091'; // 根据后端地址调整
// 检查用户登录状态
const checkLoginStatus = () => {
@@ -813,7 +813,7 @@ export const roleAPI = {
getRoles: async () => {
try {
const response = await request({
url: '/api/role/query',
url: '/app/role/query',
method: 'GET'
});