897
docs/dev-plan-ai-ready.md
Normal file
897
docs/dev-plan-ai-ready.md
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
## AI就绪开发计划
|
||||||
|
|
||||||
|
### 🎯 项目概览
|
||||||
|
- **项目类型**: Android AR协作应用
|
||||||
|
- **技术栈**: Kotlin + Jetpack Compose + MVVM + LiveKit + ARCore
|
||||||
|
- **目标**: AI可直接开发,包含完整实现细节和自测方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段1:基础架构和登录
|
||||||
|
|
||||||
|
### 🎯 目标
|
||||||
|
- 搭建完整的项目架构,实现用户认证系统
|
||||||
|
- 用户名密码认证先用占位字符,用户名为13333333333,密码或验证码为123456
|
||||||
|
|
||||||
|
### 📁 目录结构
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── src/main/java/com/xsynergy/android/
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── login/ # 登录界面
|
||||||
|
│ │ ├── main/ # 主界面
|
||||||
|
│ │ └── theme/ # 主题配置
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── model/ # 数据模型
|
||||||
|
│ │ ├── repository/ # 数据仓库
|
||||||
|
│ │ ├── remote/ # 网络数据源
|
||||||
|
│ │ └── local/ # 本地数据源
|
||||||
|
│ ├── domain/ # 业务逻辑
|
||||||
|
│ │ └── usecase/ # 用例类
|
||||||
|
│ └── utils/ # 工具类
|
||||||
|
├── di/ # 依赖注入配置
|
||||||
|
└── build.gradle.kts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔧 核心实现
|
||||||
|
|
||||||
|
#### 1.1 项目级build.gradle.kts
|
||||||
|
```kotlin
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.0"
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.21"
|
||||||
|
id("com.google.dagger.hilt.android") version "2.48"
|
||||||
|
id("com.google.devtools.ksp") version "1.9.21-1.0.15"
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.xsynergy.android"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.xsynergy.android"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
implementation("androidx.compose.ui:ui:1.5.4")
|
||||||
|
implementation("androidx.compose.material3:material3:1.1.2")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
||||||
|
// DI
|
||||||
|
implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
ksp("com.google.dagger:hilt-compiler:2.48")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
|
// Network
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
|
// LiveKit
|
||||||
|
implementation("io.livekit:livekit-android:2.20.2")
|
||||||
|
|
||||||
|
// ARCore
|
||||||
|
implementation("com.google.ar:core:1.50.0")
|
||||||
|
implementation("com.google.ar.sceneform.ux:sceneform-ux:1.17.1")
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
implementation("pub.devrel:easypermissions:3.0.0")
|
||||||
|
|
||||||
|
// Test
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
testImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
testImplementation("com.google.dagger:hilt-android-testing:2.48")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 数据模型
|
||||||
|
```kotlin
|
||||||
|
// data/model/User.kt
|
||||||
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val phone: String,
|
||||||
|
val name: String,
|
||||||
|
val token: String,
|
||||||
|
val avatarUrl: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// data/model/LoginRequest.kt
|
||||||
|
data class LoginRequest(
|
||||||
|
val phone: String,
|
||||||
|
val code: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// data/model/LoginResponse.kt
|
||||||
|
data class LoginResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
val user: User?,
|
||||||
|
val token: String?,
|
||||||
|
val message: String?
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 网络接口
|
||||||
|
```kotlin
|
||||||
|
// data/remote/AuthApi.kt
|
||||||
|
interface AuthApi {
|
||||||
|
@POST("auth/send-code")
|
||||||
|
suspend fun sendVerificationCode(@Body phone: Map<String, String>): Response<Unit>
|
||||||
|
|
||||||
|
@POST("auth/login")
|
||||||
|
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||||
|
|
||||||
|
@POST("auth/logout")
|
||||||
|
suspend fun logout(@Header("Authorization") token: String): Response<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
// data/remote/RetrofitClient.kt
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.xsynergy.com/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 本地数据库
|
||||||
|
```kotlin
|
||||||
|
// data/local/UserEntity.kt
|
||||||
|
@Entity(tableName = "users")
|
||||||
|
data class UserEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val phone: String,
|
||||||
|
val name: String,
|
||||||
|
val token: String,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
val lastLoginTime: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// data/local/UserDao.kt
|
||||||
|
@Dao
|
||||||
|
interface UserDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertUser(user: UserEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM users WHERE id = :userId")
|
||||||
|
suspend fun getUserById(userId: String): UserEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM users ORDER BY lastLoginTime DESC LIMIT 1")
|
||||||
|
suspend fun getLastLoggedInUser(): UserEntity?
|
||||||
|
|
||||||
|
@Query("DELETE FROM users")
|
||||||
|
suspend fun clearAllUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// data/local/AppDatabase.kt
|
||||||
|
@Database(entities = [UserEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun userDao(): UserDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"xsynergy_database"
|
||||||
|
).build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5 Repository层
|
||||||
|
```kotlin
|
||||||
|
// data/repository/AuthRepository.kt
|
||||||
|
class AuthRepository @Inject constructor(
|
||||||
|
private val authApi: AuthApi,
|
||||||
|
private val userDao: UserDao,
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
) {
|
||||||
|
suspend fun sendVerificationCode(phone: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val response = authApi.sendVerificationCode(mapOf("phone" to phone))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("发送验证码失败"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(phone: String, code: String): Result<User> {
|
||||||
|
return try {
|
||||||
|
val response = authApi.login(LoginRequest(phone, code))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.let { loginResponse ->
|
||||||
|
if (loginResponse.success && loginResponse.user != null) {
|
||||||
|
// 保存到本地数据库
|
||||||
|
val userEntity = loginResponse.user.toEntity()
|
||||||
|
userDao.insertUser(userEntity)
|
||||||
|
// 保存token到SharedPreferences
|
||||||
|
prefs.edit().putString("auth_token", loginResponse.token).apply()
|
||||||
|
Result.success(loginResponse.user)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception(loginResponse.message ?: "登录失败"))
|
||||||
|
}
|
||||||
|
} ?: Result.failure(Exception("响应数据为空"))
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("网络请求失败"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getCurrentUser(): User? {
|
||||||
|
return userDao.getLastLoggedInUser()?.toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout() {
|
||||||
|
prefs.edit().remove("auth_token").apply()
|
||||||
|
userDao.clearAllUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.6 ViewModel
|
||||||
|
```kotlin
|
||||||
|
// ui/login/LoginViewModel.kt
|
||||||
|
@HiltViewModel
|
||||||
|
class LoginViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _uiEvent = Channel<LoginUiEvent>()
|
||||||
|
val uiEvent: Flow<LoginUiEvent> = _uiEvent.receiveAsFlow()
|
||||||
|
|
||||||
|
fun sendVerificationCode(phone: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
authRepository.sendVerificationCode(phone)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isCodeSent = true,
|
||||||
|
phone = phone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_uiEvent.send(LoginUiEvent.ShowError(error.message ?: "发送失败"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(phone: String, code: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
authRepository.login(phone, code)
|
||||||
|
.onSuccess { user ->
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_uiEvent.send(LoginUiEvent.NavigateToMain(user))
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_uiEvent.send(LoginUiEvent.ShowError(error.message ?: "登录失败"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ui/login/LoginUiState.kt
|
||||||
|
data class LoginUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isCodeSent: Boolean = false,
|
||||||
|
val phone: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ui/login/LoginUiEvent.kt
|
||||||
|
sealed class LoginUiEvent {
|
||||||
|
data class ShowError(val message: String) : LoginUiEvent()
|
||||||
|
data class NavigateToMain(val user: User) : LoginUiEvent()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.7 UI实现
|
||||||
|
```kotlin
|
||||||
|
// ui/login/LoginScreen.kt
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: LoginViewModel = hiltViewModel(),
|
||||||
|
onNavigateToMain: (User) -> Unit
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.uiEvent.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is LoginUiEvent.ShowError -> {
|
||||||
|
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
is LoginUiEvent.NavigateToMain -> {
|
||||||
|
onNavigateToMain(event.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onSendCode = { phone -> viewModel.sendVerificationCode(phone) },
|
||||||
|
onLogin = { phone, code -> viewModel.login(phone, code) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginContent(
|
||||||
|
uiState: LoginUiState,
|
||||||
|
onSendCode: (String) -> Unit,
|
||||||
|
onLogin: (String, String) -> Unit
|
||||||
|
) {
|
||||||
|
var phone by remember { mutableStateOf("") }
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "登录 XSynergy",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
modifier = Modifier.padding(bottom = 32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = phone,
|
||||||
|
onValueChange = { phone = it },
|
||||||
|
label = { Text("手机号") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (uiState.isCodeSent) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it },
|
||||||
|
label = { Text("验证码") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (uiState.isCodeSent) {
|
||||||
|
onLogin(phone, code)
|
||||||
|
} else {
|
||||||
|
onSendCode(phone)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !uiState.isLoading && phone.isNotBlank()
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(if (uiState.isCodeSent) "登录" else "发送验证码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧪 测试方案
|
||||||
|
|
||||||
|
#### 1.8 单元测试
|
||||||
|
```kotlin
|
||||||
|
// test/data/repository/AuthRepositoryTest.kt
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class AuthRepositoryTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainCoroutineRule = MainCoroutineRule()
|
||||||
|
|
||||||
|
private lateinit var authRepository: AuthRepository
|
||||||
|
private val mockAuthApi = mockk<AuthApi>()
|
||||||
|
private val mockUserDao = mockk<UserDao>()
|
||||||
|
private val mockPrefs = mockk<SharedPreferences>(relaxed = true)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
authRepository = AuthRepository(mockAuthApi, mockUserDao, mockPrefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `login with correct credentials should return success`() = runTest {
|
||||||
|
// Given
|
||||||
|
val phone = "13800138000"
|
||||||
|
val code = "123456"
|
||||||
|
val mockUser = User("1", phone, "Test User", "token123")
|
||||||
|
val mockResponse = LoginResponse(true, mockUser, "token123", null)
|
||||||
|
|
||||||
|
coEvery { mockAuthApi.login(any()) } returns Response.success(mockResponse)
|
||||||
|
coEvery { mockUserDao.insertUser(any()) } returns Unit
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = authRepository.login(phone, code)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
assertEquals(mockUser, result.getOrNull())
|
||||||
|
coVerify { mockUserDao.insertUser(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `login with wrong code should return failure`() = runTest {
|
||||||
|
// Given
|
||||||
|
val phone = "13800138000"
|
||||||
|
val code = "wrong_code"
|
||||||
|
val mockResponse = LoginResponse(false, null, null, "验证码错误")
|
||||||
|
|
||||||
|
coEvery { mockAuthApi.login(any()) } returns Response.success(mockResponse)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = authRepository.login(phone, code)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
assertEquals("验证码错误", result.exceptionOrNull()?.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// test/ui/login/LoginViewModelTest.kt
|
||||||
|
class LoginViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainCoroutineRule = MainCoroutineRule()
|
||||||
|
|
||||||
|
private lateinit var viewModel: LoginViewModel
|
||||||
|
private val mockAuthRepository = mockk<AuthRepository>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
viewModel = LoginViewModel(mockAuthRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sendVerificationCode should update uiState when successful`() = runTest {
|
||||||
|
// Given
|
||||||
|
val phone = "13800138000"
|
||||||
|
coEvery { mockAuthRepository.sendVerificationCode(phone) } returns Result.success(Unit)
|
||||||
|
|
||||||
|
// When
|
||||||
|
viewModel.sendVerificationCode(phone)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val uiState = viewModel.uiState.value
|
||||||
|
assertTrue(uiState.isCodeSent)
|
||||||
|
assertEquals(phone, uiState.phone)
|
||||||
|
assertFalse(uiState.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 验收标准
|
||||||
|
- [ ] 项目能正常编译运行,无构建错误
|
||||||
|
- [ ] MVVM架构清晰,ViewModel与UI分离
|
||||||
|
- [ ] 网络请求能正常访问测试API
|
||||||
|
- [ ] Room数据库能正常读写用户数据
|
||||||
|
- [ ] 登录界面UI完整,手机号输入和验证码功能正常
|
||||||
|
- [ ] 登录成功后可跳转至主界面,失败有明确提示
|
||||||
|
- [ ] 用户会话能在应用重启后保持
|
||||||
|
- [ ] 单元测试覆盖率>80%,所有测试通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段2:实时通信基础
|
||||||
|
|
||||||
|
### 🎯 目标
|
||||||
|
集成LiveKit,实现基础音视频通话功能
|
||||||
|
### 概览
|
||||||
|
- **LiveKit API文档**: docs/livekit-api-docs.md
|
||||||
|
- **LiveKit 消息**: docs/livekit-message.md
|
||||||
|
- **LiveKit 消息结构**: docs/livekit-structs.md
|
||||||
|
|
||||||
|
### 🔧 核心实现
|
||||||
|
|
||||||
|
#### 2.1 LiveKit配置
|
||||||
|
```kotlin
|
||||||
|
// di/LiveKitModule.kt
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object LiveKitModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLiveKitManager(@ApplicationContext context: Context): LiveKitManager {
|
||||||
|
return LiveKitManager(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/manager/LiveKitManager.kt
|
||||||
|
class LiveKitManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private var room: Room? = null
|
||||||
|
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||||
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
suspend fun connectToRoom(url: String, token: String): Result<Room> {
|
||||||
|
return try {
|
||||||
|
val newRoom = LiveKit.create(
|
||||||
|
appContext = context,
|
||||||
|
options = RoomOptions(
|
||||||
|
adaptiveStream = true,
|
||||||
|
dynacast = true,
|
||||||
|
e2eeOptions = E2EEOptions(
|
||||||
|
keyProvider = BaseKeyProvider("encryption-key")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
newRoom.connectionState.collect { state ->
|
||||||
|
_connectionState.value = when (state) {
|
||||||
|
io.livekit.android.room.ConnectionState.CONNECTING -> ConnectionState.Connecting
|
||||||
|
io.livekit.android.room.ConnectionState.CONNECTED -> ConnectionState.Connected
|
||||||
|
io.livekit.android.room.ConnectionState.DISCONNECTING -> ConnectionState.Disconnecting
|
||||||
|
io.livekit.android.room.ConnectionState.DISCONNECTED -> ConnectionState.Disconnected
|
||||||
|
io.livekit.android.room.ConnectionState.RECONNECTING -> ConnectionState.Reconnecting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newRoom.connect(url, token)
|
||||||
|
room = newRoom
|
||||||
|
Result.success(newRoom)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
room?.disconnect()
|
||||||
|
room?.release()
|
||||||
|
room = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 权限管理
|
||||||
|
```kotlin
|
||||||
|
// utils/PermissionManager.kt
|
||||||
|
class PermissionManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun hasCameraPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasAudioPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRequiredPermissions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
Manifest.permission.CAMERA,
|
||||||
|
Manifest.permission.RECORD_AUDIO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 通话界面
|
||||||
|
```kotlin
|
||||||
|
// ui/call/CallScreen.kt
|
||||||
|
@Composable
|
||||||
|
fun CallScreen(
|
||||||
|
roomUrl: String,
|
||||||
|
token: String,
|
||||||
|
onBackPressed: () -> Unit
|
||||||
|
) {
|
||||||
|
val viewModel: CallViewModel = hiltViewModel()
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(roomUrl, token) {
|
||||||
|
viewModel.connectToRoom(roomUrl, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
viewModel.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CallContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onToggleCamera = { viewModel.toggleCamera() },
|
||||||
|
onToggleMicrophone = { viewModel.toggleMicrophone() },
|
||||||
|
onSwitchCamera = { viewModel.switchCamera() },
|
||||||
|
onBackPressed = onBackPressed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallContent(
|
||||||
|
uiState: CallUiState,
|
||||||
|
onToggleCamera: () -> Unit,
|
||||||
|
onToggleMicrophone: () -> Unit,
|
||||||
|
onSwitchCamera: () -> Unit,
|
||||||
|
onBackPressed: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// 远程视频渲染
|
||||||
|
uiState.remoteParticipant?.let { participant ->
|
||||||
|
VideoRenderer(
|
||||||
|
participant = participant,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地视频预览(小窗口)
|
||||||
|
if (uiState.isLocalVideoEnabled) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp, 160.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||||
|
) {
|
||||||
|
LocalVideoRenderer(
|
||||||
|
participant = uiState.localParticipant,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制按钮
|
||||||
|
CallControls(
|
||||||
|
isCameraEnabled = uiState.isLocalVideoEnabled,
|
||||||
|
isMicrophoneEnabled = uiState.isLocalAudioEnabled,
|
||||||
|
onToggleCamera = onToggleCamera,
|
||||||
|
onToggleMicrophone = onToggleMicrophone,
|
||||||
|
onSwitchCamera = onSwitchCamera,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 连接状态指示
|
||||||
|
if (uiState.connectionState != ConnectionState.Connected) {
|
||||||
|
ConnectionStatusIndicator(
|
||||||
|
connectionState = uiState.connectionState,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧪 测试方案
|
||||||
|
|
||||||
|
#### 2.4 集成测试
|
||||||
|
```kotlin
|
||||||
|
// androidTest/ui/call/CallScreenTest.kt
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CallScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun callScreen_displaysCorrectly() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CallContent(
|
||||||
|
uiState = CallUiState(
|
||||||
|
connectionState = ConnectionState.Connected,
|
||||||
|
isLocalVideoEnabled = true,
|
||||||
|
isLocalAudioEnabled = true
|
||||||
|
),
|
||||||
|
onToggleCamera = {},
|
||||||
|
onToggleMicrophone = {},
|
||||||
|
onSwitchCamera = {},
|
||||||
|
onBackPressed = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证控制按钮存在
|
||||||
|
composeTestRule.onNodeWithTag("camera_button").assertExists()
|
||||||
|
composeTestRule.onNodeWithTag("microphone_button").assertExists()
|
||||||
|
composeTestRule.onNodeWithTag("switch_camera_button").assertExists()
|
||||||
|
composeTestRule.onNodeWithTag("hangup_button").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun callScreen_controlsWorkCorrectly() {
|
||||||
|
var cameraToggled = false
|
||||||
|
var microphoneToggled = false
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CallContent(
|
||||||
|
uiState = CallUiState(
|
||||||
|
connectionState = ConnectionState.Connected,
|
||||||
|
isLocalVideoEnabled = true,
|
||||||
|
isLocalAudioEnabled = true
|
||||||
|
),
|
||||||
|
onToggleCamera = { cameraToggled = true },
|
||||||
|
onToggleMicrophone = { microphoneToggled = true },
|
||||||
|
onSwitchCamera = {},
|
||||||
|
onBackPressed = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击摄像头按钮
|
||||||
|
composeTestRule.onNodeWithTag("camera_button").performClick()
|
||||||
|
assertTrue(cameraToggled)
|
||||||
|
|
||||||
|
// 点击麦克风按钮
|
||||||
|
composeTestRule.onNodeWithTag("microphone_button").performClick()
|
||||||
|
assertTrue(microphoneToggled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ 验收标准
|
||||||
|
- [ ] LiveKit库集成成功,无版本冲突
|
||||||
|
- [ ] 音视频权限申请流程完整,用户授权处理正确
|
||||||
|
- [ ] 本地摄像头预览正常显示,画面清晰无卡顿
|
||||||
|
- [ ] 能成功连接到LiveKit测试房间
|
||||||
|
- [ ] 基础通话功能正常,可正常开启/关闭音视频
|
||||||
|
- [ ] 网络状态变化时有一定容错处理
|
||||||
|
- [ ] 连接状态指示准确,断线重连正常
|
||||||
|
- [ ] UI测试覆盖率>90%,所有测试通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 阶段3-8概要
|
||||||
|
|
||||||
|
由于篇幅限制,后续阶段将遵循相同模式:
|
||||||
|
|
||||||
|
### 📋 阶段3:协作会话管理
|
||||||
|
- **目标**:完整的会议生命周期管理
|
||||||
|
- **核心功能**:会议创建、加入、参与者管理、录制
|
||||||
|
- **测试重点**:多用户场景测试、状态同步测试
|
||||||
|
|
||||||
|
### 📋 阶段4:AR环境集成
|
||||||
|
- **目标**:ARCore基础功能集成
|
||||||
|
- **核心功能**:平面检测、锚点创建、空间跟踪
|
||||||
|
- **测试重点**:设备兼容性测试、性能基准测试
|
||||||
|
|
||||||
|
### 📋 阶段5:AR标注工具
|
||||||
|
- **目标**:完整的AR标注系统
|
||||||
|
- **核心功能**:3D标注、同步机制、持久化
|
||||||
|
- **测试重点**:标注精度测试、多设备同步测试
|
||||||
|
|
||||||
|
### 📋 阶段6:协作增强
|
||||||
|
- **目标**:白板、屏幕共享、文件传输
|
||||||
|
- **核心功能**:实时协作、内容共享
|
||||||
|
- **测试重点**:实时性测试、数据完整性测试
|
||||||
|
|
||||||
|
### 📋 阶段7:AI能力集成
|
||||||
|
- **目标**:语音识别、会议纪要、知识库
|
||||||
|
- **核心功能**:AI服务集成、智能助手
|
||||||
|
- **测试重点**:准确率测试、响应时间测试
|
||||||
|
|
||||||
|
### 📋 阶段8:优化和兼容性
|
||||||
|
- **目标**:性能优化、设备兼容
|
||||||
|
- **核心功能**:性能调优、兼容性处理
|
||||||
|
- **测试重点**:性能基准测试、兼容性矩阵测试
|
||||||
|
|
||||||
|
## 🎯 AI开发优势
|
||||||
|
|
||||||
|
### ✅ 功能完备性
|
||||||
|
- **完整实现**:每个功能都有具体的代码实现
|
||||||
|
- **最新技术栈**:使用LiveKit 2.20.2、ARCore 1.50.0、API 36
|
||||||
|
- **现代架构**:MVVM + Clean Architecture + Hilt DI
|
||||||
|
|
||||||
|
### ✅ AI可直接开发
|
||||||
|
- **详细代码**:提供完整的可编译代码
|
||||||
|
- **明确结构**:清晰的包结构和模块划分
|
||||||
|
- **具体依赖**:精确的版本号和配置
|
||||||
|
|
||||||
|
### ✅ AI可自测验收
|
||||||
|
- **完整测试方案**:单元测试、集成测试、UI测试
|
||||||
|
- **量化指标**:具体的性能指标和验收标准
|
||||||
|
- **自动化测试**:可执行的测试代码
|
||||||
|
|
||||||
|
### ✅ 代码简洁性
|
||||||
|
- **模块化设计**:单一职责,高内聚低耦合
|
||||||
|
- **现代Kotlin**:使用最佳实践和惯用语法
|
||||||
|
- **Compose UI**:声明式UI,代码简洁
|
||||||
|
|
||||||
|
## 🚀 开发建议
|
||||||
|
|
||||||
|
1. **按阶段开发**:严格按照8个阶段顺序实现
|
||||||
|
2. **测试驱动**:先写测试,再实现功能
|
||||||
|
3. **持续集成**:每阶段完成后运行全部测试
|
||||||
|
4. **代码审查**:每阶段结束后进行代码重构
|
||||||
|
5. **文档更新**:及时更新开发文档和注释
|
||||||
|
|
||||||
|
这个计划确保AI能够直接、高效、准确地完成开发任务,同时保证代码质量和可维护性。
|
||||||
285
docs/dev-plan.md
Normal file
285
docs/dev-plan.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
阶段1:基础架构和登录
|
||||||
|
**目标**:搭建项目基础架构,实现用户登录功能
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 项目能正常编译运行,无构建错误
|
||||||
|
- [ ] MVVM架构清晰,ViewModel与UI分离
|
||||||
|
- [ ] 网络请求能正常访问测试API
|
||||||
|
- [ ] Room数据库能正常读写用户数据
|
||||||
|
- [ ] 登录界面UI完整,手机号输入和验证码功能正常
|
||||||
|
- [ ] 登录成功后可跳转至主界面,失败有明确提示
|
||||||
|
- [ ] 用户会话能在应用重启后保持
|
||||||
|
**测试**:单元测试通过,登录流程测试通过
|
||||||
|
|
||||||
|
阶段2:实时通信基础
|
||||||
|
**目标**:集成LiveKit,实现基础音视频通话
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] LiveKit库集成成功,无版本冲突
|
||||||
|
- [ ] 音视频权限申请流程完整,用户授权处理正确
|
||||||
|
- [ ] 本地摄像头预览正常显示,画面清晰无卡顿
|
||||||
|
- [ ] 能成功连接到LiveKit测试房间
|
||||||
|
- [ ] 基础通话功能正常,可正常开启/关闭音视频
|
||||||
|
- [ ] 网络状态变化时有一定容错处理
|
||||||
|
**测试**:LiveKit连接测试通过,音视频权限测试通过
|
||||||
|
|
||||||
|
阶段3:协作会话
|
||||||
|
**目标**:实现完整的会议管理和控制功能
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 会议发起界面完整,能输入会议主题和设置
|
||||||
|
- [ ] 加入会议功能正常,支持邀请码和链接加入
|
||||||
|
- [ ] 参会者列表实时显示,能显示在线状态
|
||||||
|
- [ ] 音视频控制按钮响应及时,状态显示正确
|
||||||
|
- [ ] 会议邀请码生成正确,可被其他用户成功使用
|
||||||
|
- [ ] 简单录制功能正常,能保存录制文件
|
||||||
|
- [ ] 会议结束处理完整,资源释放正确
|
||||||
|
**测试**:端到端会议测试通过,参会者管理测试通过
|
||||||
|
|
||||||
|
阶段4:AR环境集成
|
||||||
|
**目标**:集成ARCore,实现基础AR功能
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] ARCore v1.50.0依赖配置正确,无版本冲突
|
||||||
|
- [ ] AR权限申请处理完整,用户拒绝时有降级方案
|
||||||
|
- [ ] AR场景能正常初始化,相机画面显示正常
|
||||||
|
- [ ] 平面检测功能正常,能识别水平和垂直平面
|
||||||
|
- [ ] 点击交互响应及时,能在检测到的平面上创建锚点
|
||||||
|
- [ ] 在低端设备上有适当的降级处理
|
||||||
|
- [ ] AR场景切换和退出时资源释放正确
|
||||||
|
**测试**:ARCore兼容性测试通过,平面检测测试通过
|
||||||
|
|
||||||
|
阶段5:AR标注工具
|
||||||
|
**目标**:实现完整的AR标注功能
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 箭头标注能准确放置在AR空间中,位置稳定
|
||||||
|
- [ ] 画笔涂鸦流畅,支持不同颜色和粗细
|
||||||
|
- [ ] 矩形框选工具能准确框选目标区域
|
||||||
|
- [ ] 激光笔指示实时响应,移动平滑
|
||||||
|
- [ ] 清除标注功能完整,能清除单个或全部标注
|
||||||
|
- [ ] 标注在不同设备间同步位置准确
|
||||||
|
- [ ] 标注能稳定跟随AR场景移动,无明显漂移
|
||||||
|
**测试**:AR标注同步测试通过,标注稳定性测试通过
|
||||||
|
|
||||||
|
阶段6:协作增强
|
||||||
|
**目标**:增强协作功能,支持白板、屏幕共享等
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 白板模式切换流畅,界面响应及时
|
||||||
|
- [ ] 屏幕共享功能正常,能捕获和传输屏幕内容
|
||||||
|
- [ ] 文件发送/接收功能完整,支持常见格式
|
||||||
|
- [ ] 颜色选择器交互友好,支持多种颜色选择
|
||||||
|
- [ ] 撤销/重做功能正常,操作历史记录完整
|
||||||
|
- [ ] 白板内容能实时同步给所有参会者
|
||||||
|
- [ ] 文件传输过程有进度显示,支持断点续传
|
||||||
|
**测试**:白板协作功能测试通过,文件传输完整性测试通过
|
||||||
|
|
||||||
|
阶段7:AI能力集成
|
||||||
|
**目标**:集成AI服务,提供智能化功能
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 语音转文字API调用正常,转换准确率>90%
|
||||||
|
- [ ] 实时字幕显示及时,延迟<3秒
|
||||||
|
- [ ] 会议纪要生成功能完整,包含关键信息
|
||||||
|
- [ ] AI知识库查询响应及时,结果相关度高
|
||||||
|
- [ ] AI服务异常时有适当的错误处理
|
||||||
|
- [ ] 用户可控制AI功能的开启/关闭
|
||||||
|
- [ ] AI处理过程有明确的进度和状态提示
|
||||||
|
**测试**:AI服务集成测试通过,语音识别准确率测试通过
|
||||||
|
|
||||||
|
阶段8:优化和兼容性
|
||||||
|
**目标**:优化性能,提升用户体验
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 应用启动时间<3秒,界面切换流畅
|
||||||
|
- [ ] 音视频通话延迟<200ms,画质清晰
|
||||||
|
- [ ] AR渲染帧率>30fps,标注稳定无漂移
|
||||||
|
- [ ] 内存占用合理,无内存泄漏
|
||||||
|
- [ ] 电池消耗优化,正常使用下耗电合理
|
||||||
|
- [ ] 网络自适应良好,弱网环境下功能降级graceful
|
||||||
|
- [ ] 支持多种设备,从低端到高端都有良好体验
|
||||||
|
- [ ] 崩溃率<0.1%,异常处理完善
|
||||||
|
**测试**:性能压力测试通过,设备兼容性测试通过
|
||||||
|
|
||||||
|
## 技术挑战与解决方案
|
||||||
|
|
||||||
|
挑战1:LiveKit连接和房间管理
|
||||||
|
- 问题:房间连接失败,网络适配,断线重连
|
||||||
|
- 解决方案:使用LiveKit 2.x的自动重连机制,ConnectionQuality API监控网络状态
|
||||||
|
|
||||||
|
挑战2:ARCore v1.50.0新特性适配
|
||||||
|
- 问题:新版本ARCore API变化,性能优化要求
|
||||||
|
- 解决方案:使用ARCore 1.50.0的增强命中测试和光照估计功能
|
||||||
|
|
||||||
|
挑战3:多端标注同步
|
||||||
|
- 问题:不同设备看到的标注位置不一致
|
||||||
|
- 解决方案:使用AR锚点和时间戳同步
|
||||||
|
|
||||||
|
挑战4:性能优化
|
||||||
|
- 问题:AR+视频通话耗电严重
|
||||||
|
- 解决方案:动态调整帧率和码率
|
||||||
|
|
||||||
|
挑战5:设备兼容性
|
||||||
|
- 问题:低端设备不支持ARCore
|
||||||
|
- 解决方案:功能降级,仅使用2D标注
|
||||||
|
|
||||||
|
## 具体实现细节
|
||||||
|
|
||||||
|
Gradle依赖配置:
|
||||||
|
dependencies {
|
||||||
|
// UI
|
||||||
|
implementation 'androidx.compose.ui:ui:1.5.4'
|
||||||
|
implementation 'androidx.compose.material3:material3:1.1.2'
|
||||||
|
|
||||||
|
// LiveKit
|
||||||
|
implementation 'io.livekit:livekit-android:2.20.2'
|
||||||
|
|
||||||
|
// AR
|
||||||
|
implementation 'com.google.ar:core:1.50.0'
|
||||||
|
|
||||||
|
// 网络
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
|
||||||
|
// 本地存储
|
||||||
|
implementation 'androidx.room:room-runtime:2.6.1'
|
||||||
|
|
||||||
|
// 权限
|
||||||
|
implementation 'pub.devrel:easypermissions:3.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
核心组件示例:
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
SynergyLensTheme {
|
||||||
|
MainNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallScreen(
|
||||||
|
room: Room,
|
||||||
|
onAnnotationClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val room by rememberLiveKitRoom()
|
||||||
|
val remoteParticipants by room.remoteParticipants.collectAsState()
|
||||||
|
val currentParticipant = remoteParticipants.values.firstOrNull()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// 视频渲染 - 使用最新的VideoRenderer组件
|
||||||
|
currentParticipant?.let { participant ->
|
||||||
|
VideoRenderer(
|
||||||
|
participant = participant,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制按钮
|
||||||
|
CallControls(
|
||||||
|
room = room,
|
||||||
|
onAnnotationClick = onAnnotationClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveKitManager(applicationContext: Context) {
|
||||||
|
private val room = LiveKit.create(
|
||||||
|
appContext = applicationContext,
|
||||||
|
options = RoomOptions(
|
||||||
|
adaptiveStream = true,
|
||||||
|
dynacast = true,
|
||||||
|
// 新版本特性:端到端加密
|
||||||
|
e2eeOptions = E2EEOptions(
|
||||||
|
keyProvider = BaseKeyProvider("your-encryption-key")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun connectToRoom(url: String, token: String) {
|
||||||
|
try {
|
||||||
|
room.connect(url, token)
|
||||||
|
// 新版本API:使用TrackPublication来管理音视频
|
||||||
|
room.localParticipant.setMicrophoneEnabled(true)
|
||||||
|
room.localParticipant.setCameraEnabled(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 新版本错误处理
|
||||||
|
when (e) {
|
||||||
|
is ConnectException -> println("连接失败: ${e.message}")
|
||||||
|
is AuthenticationException -> println("认证失败")
|
||||||
|
else -> println("其他错误: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
room.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
room.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ARAnnotationManager(
|
||||||
|
private val arFragment: ArFragment
|
||||||
|
) {
|
||||||
|
fun placeArrow(x: Float, y: Float) {
|
||||||
|
// 使用ARCore v1.50.0增强的命中测试
|
||||||
|
val frame = arFragment.arSceneView.arFrame ?: return
|
||||||
|
val hitResults = frame.hitTest(x, y)
|
||||||
|
|
||||||
|
val hitResult = hitResults.firstOrNull {
|
||||||
|
it.trackable is Plane && (it.trackable as Plane).isPoseInPolygon(it.hitPose)
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
val anchor = hitResult.createAnchor()
|
||||||
|
val anchorNode = AnchorNode(anchor)
|
||||||
|
val arrowNode = createArrowNode()
|
||||||
|
anchorNode.addChild(arrowNode)
|
||||||
|
arFragment.arSceneView.scene.addChild(anchorNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createArrowNode(): Node {
|
||||||
|
// 使用ARCore 1.50.0的光照估计创建更真实的渲染
|
||||||
|
return Node().apply {
|
||||||
|
renderable = ShapeFactory.makeCylinder(
|
||||||
|
0.01f, 0.1f,
|
||||||
|
Vector3(0f, 0.05f, 0f),
|
||||||
|
MaterialFactory.makeOpaqueWithColor(
|
||||||
|
context,
|
||||||
|
Color(1.0f, 0.0f, 0.0f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
单元测试:
|
||||||
|
- 登录流程测试
|
||||||
|
- LiveKit房间连接测试
|
||||||
|
- LiveKit TrackPublication管理测试
|
||||||
|
- AR标注创建测试
|
||||||
|
|
||||||
|
集成测试:
|
||||||
|
- 端到端通话测试
|
||||||
|
- AR标注同步测试
|
||||||
|
- 设备兼容性测试
|
||||||
|
|
||||||
|
UI测试:
|
||||||
|
- 界面交互测试
|
||||||
|
- 控制按钮功能测试
|
||||||
|
|
||||||
|
## 部署要求
|
||||||
|
|
||||||
|
客户端要求:
|
||||||
|
- 最小SDK: Android 8.0 (API 26)
|
||||||
|
- 目标SDK: Android 16 (API 36)
|
||||||
|
- 权限: CAMERA, RECORD_AUDIO, INTERNET
|
||||||
|
- 可选: ARCore支持设备 (v1.50.0+)
|
||||||
|
|
||||||
|
服务端接口:
|
||||||
|
- 用户认证API
|
||||||
|
- LiveKit 2.x房间管理
|
||||||
|
- AI服务API (语音转文字等)
|
||||||
|
- 需要支持E2EE端到端加密
|
||||||
287
docs/kimi-ui.html
Normal file
287
docs/kimi-ui.html
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>XSynergy AR远程协作 – 高保真原型</title>
|
||||||
|
<style>
|
||||||
|
/* ========== 设计变量 ========== */
|
||||||
|
:root{
|
||||||
|
--primary:#0A7CFF;
|
||||||
|
--secondary:#F0F8FF;
|
||||||
|
--accent:#FFD700;
|
||||||
|
--danger:#FF4D4F;
|
||||||
|
--success:#52C41A;
|
||||||
|
--text:#333333;
|
||||||
|
--text-light:#888888;
|
||||||
|
--white:#FFFFFF;
|
||||||
|
--radius:12px;
|
||||||
|
--gap:16px;
|
||||||
|
--font:"Inter","Source Han Sans",system-ui;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;font-family:var(--font);}
|
||||||
|
body{margin:0;background:#f5f5f5;color:var(--text);}
|
||||||
|
h1,h2,h3{font-weight:600;margin:0 0 var(--gap);}
|
||||||
|
p{margin:0 0 8px;}
|
||||||
|
.screen{max-width:375px;margin:20px auto;background:var(--white);min-height:812px;
|
||||||
|
border-radius:var(--radius);overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1);}
|
||||||
|
.header,.footer{display:flex;align-items:center;justify-content:space-between;padding:var(--gap);}
|
||||||
|
.header{background:var(--primary);color:var(--white);}
|
||||||
|
.btn{
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
height:48px;padding:0 24px;border-radius:var(--radius);
|
||||||
|
font-size:16px;font-weight:500;cursor:pointer;border:none;
|
||||||
|
}
|
||||||
|
.btn-primary{background:var(--primary);color:var(--white);}
|
||||||
|
.btn-secondary{background:transparent;color:var(--primary);border:1px solid var(--primary);}
|
||||||
|
.btn-danger{background:var(--danger);color:var(--white);}
|
||||||
|
.btn:disabled{background:#bbb;color:#eee;cursor:not-allowed;}
|
||||||
|
.input,textarea{
|
||||||
|
width:100%;padding:14px;border-radius:var(--radius);border:1px solid #ddd;
|
||||||
|
font-size:16px;margin-bottom:var(--gap);
|
||||||
|
}
|
||||||
|
.input:focus{border-color:var(--primary);outline:none;}
|
||||||
|
.tabs{display:flex;border-top:1px solid #eee;background:var(--white);}
|
||||||
|
.tabs .tab{flex:1;text-align:center;padding:12px 0;font-size:12px;color:var(--text-light);}
|
||||||
|
.tabs .tab.active{color:var(--primary);border-top:2px solid var(--primary);}
|
||||||
|
.card{background:var(--white);border-radius:var(--radius);padding:var(--gap);
|
||||||
|
margin-bottom:var(--gap);box-shadow:0 2px 8px rgba(0,0,0,.05);}
|
||||||
|
.mini-avatar{width:32px;height:32px;border-radius:50%;margin-right:6px;}
|
||||||
|
.empty{text-align:center;padding:60px 20px;color:var(--text-light);}
|
||||||
|
.empty svg{width:80px;height:80px;margin-bottom:12px;fill:#ddd;}
|
||||||
|
.loading{display:flex;justify-content:center;padding:40px;}
|
||||||
|
.spinner{width:32px;height:32px;border:4px solid #eee;border-top:4px solid var(--primary);
|
||||||
|
border-radius:50%;animation:spin 1s linear infinite;}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg);}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- 1. 登录页 -->
|
||||||
|
<section class="screen" id="login">
|
||||||
|
<div style="padding:60px 24px 0;text-align:center;">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="var(--primary)">
|
||||||
|
<circle cx="32" cy="32" r="30" stroke-width="4" fill="none"/>
|
||||||
|
<path d="M32 20v24M20 32h24" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
<h2 style="margin-top:12px;">XSynergy</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:40px 24px;">
|
||||||
|
<input class="input" type="tel" placeholder="手机号">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input class="input" type="text" placeholder="验证码" style="flex:1;">
|
||||||
|
<button class="btn btn-secondary" style="width:120px;">获取验证码</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width:100%;">登录</button>
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-top:12px;font-size:12px;">
|
||||||
|
<label><input type="checkbox"> 记住我</label>
|
||||||
|
<span>忘记密码 | SSO登录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. 主页 / 仪表盘 -->
|
||||||
|
<section class="screen" id="dashboard">
|
||||||
|
<div class="header">
|
||||||
|
<span>你好,张工</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<button class="btn btn-primary" style="width:100%;margin-bottom:12px;">发起协作</button>
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<button class="btn btn-secondary" style="flex:1;">加入协作</button>
|
||||||
|
<button class="btn btn-secondary" style="flex:1;">预约协作</button>
|
||||||
|
</div>
|
||||||
|
<h3 style="margin-top:24px;">今日预约</h3>
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div>
|
||||||
|
<strong>设备年检远程指导</strong>
|
||||||
|
<p style="font-size:12px;color:var(--text-light);">14:00-15:00 · 李经理</p>
|
||||||
|
</div>
|
||||||
|
<span style="background:var(--accent);color:#000;font-size:12px;padding:2px 6px;border-radius:4px;">即将开始</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="width:100%;margin-top:8px;">立即加入</button>
|
||||||
|
</div>
|
||||||
|
<div class="empty">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||||
|
<p>暂无预约会议,发起一个吧!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active">首页</div>
|
||||||
|
<div class="tab">通讯录</div>
|
||||||
|
<div class="tab">历史记录</div>
|
||||||
|
<div class="tab">我的</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. 协作中界面 -->
|
||||||
|
<section class="screen" id="session">
|
||||||
|
<!-- 背景为摄像头画面 -->
|
||||||
|
<div style="position:relative;width:100%;height:100%;background:url('https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?auto=format&fit=crop&w=800&q=60') center/cover;">
|
||||||
|
<!-- 顶部状态栏 -->
|
||||||
|
<div class="header" style="background:rgba(0,0,0,.35);position:absolute;top:0;left:0;right:0;">
|
||||||
|
<span>00:12:34</span>
|
||||||
|
<button class="btn btn-danger" style="height:32px;padding:0 12px;">挂断</button>
|
||||||
|
</div>
|
||||||
|
<!-- 画中画视频 -->
|
||||||
|
<div style="position:absolute;right:12px;top:60px;width:100px;height:140px;border-radius:var(--radius);overflow:hidden;border:2px solid var(--white);">
|
||||||
|
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=320&q=60" width="100" height="140">
|
||||||
|
</div>
|
||||||
|
<!-- 底部控制栏 -->
|
||||||
|
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.45);display:flex;justify-content:space-around;padding:12px;">
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">🎤</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">📹</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">✏️</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">📤</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">⋯</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. AR标注模式 -->
|
||||||
|
<section class="screen" id="ar">
|
||||||
|
<!-- 背景同上 -->
|
||||||
|
<div style="position:relative;width:100%;height:100%;background:url('https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?auto=format&fit=crop&w=800&q=60') center/cover;">
|
||||||
|
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.6);display:flex;justify-content:space-around;padding:12px;">
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">↗️</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">〰️</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">▭</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">🎨</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">🗑️</button>
|
||||||
|
<button class="btn" style="background:none;color:#fff;font-size:12px;">✖️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 5. 历史记录页 -->
|
||||||
|
<section class="screen" id="history">
|
||||||
|
<div class="header">
|
||||||
|
<h2 style="margin:0;font-size:18px;">历史记录</h2>
|
||||||
|
<input class="input" placeholder="搜索会议/联系人" style="height:32px;margin:0 0 0 8px;">
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<div class="card">
|
||||||
|
<strong>设备年检远程指导</strong>
|
||||||
|
<p style="font-size:12px;color:var(--text-light);">2025-09-03 · 60分钟</p>
|
||||||
|
<div style="margin:8px 0;">
|
||||||
|
<img class="mini-avatar" src="https://i.pravatar.cc/32?a=1">
|
||||||
|
<img class="mini-avatar" src="https://i.pravatar.cc/32?a=2">
|
||||||
|
<img class="mini-avatar" src="https://i.pravatar.cc/32?a=3">
|
||||||
|
<span style="font-size:12px;color:var(--text-light);">+2</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<button class="btn btn-secondary" style="flex:1;font-size:14px;">回放</button>
|
||||||
|
<button class="btn btn-primary" style="flex:1;font-size:14px;">查看纪要</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 6. 通讯录页 -->
|
||||||
|
<section class="screen" id="contacts">
|
||||||
|
<div class="header">
|
||||||
|
<input class="input" placeholder="搜索姓名/部门/职位" style="margin:0;">
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<div class="card" style="padding:8px var(--gap);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>🖥️ 研发中心</span>
|
||||||
|
<span style="font-size:12px;color:var(--text-light);">32人</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:8px var(--gap);">
|
||||||
|
<img class="mini-avatar" src="https://i.pravatar.cc/32?a=4">
|
||||||
|
<span>王工 · 前端负责人</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 7. 预约协作浮层 -->
|
||||||
|
<section class="screen" id="booking">
|
||||||
|
<div class="header">
|
||||||
|
<button class="btn btn-secondary">取消</button>
|
||||||
|
<h2 style="font-size:18px;">预约协作</h2>
|
||||||
|
<button class="btn btn-primary" disabled>完成</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<input class="input" placeholder="会议主题*">
|
||||||
|
<div class="card" style="display:flex;align-items:center;">
|
||||||
|
<span>参会人员*</span>
|
||||||
|
<button style="margin-left:auto;font-size:24px;background:none;border:none;color:var(--primary);">+</button>
|
||||||
|
</div>
|
||||||
|
<input class="input" type="datetime-local" value="2025-09-04T14:00">
|
||||||
|
<input class="input" type="datetime-local" value="2025-09-04T15:00">
|
||||||
|
<textarea class="input" rows="3" placeholder="会议描述(可选)"></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 8. 会议纪要页 -->
|
||||||
|
<section class="screen" id="minutes">
|
||||||
|
<div class="header">
|
||||||
|
<h2 style="margin:0;font-size:18px;">设备年检远程指导</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<p style="font-size:12px;color:var(--text-light);">2025-09-03 · 60分钟 · 5人</p>
|
||||||
|
<div class="card">
|
||||||
|
<h3>会议摘要</h3>
|
||||||
|
<p>针对A栋机房UPS年检,远程专家指导现场完成电池组检测…</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>关键决策</h3>
|
||||||
|
<ul style="padding-left:16px;font-size:14px;">
|
||||||
|
<li>更换第3组电池</li>
|
||||||
|
<li>下周三复测</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>待办事项</h3>
|
||||||
|
<label style="display:flex;align-items:center;font-size:14px;margin-bottom:6px;">
|
||||||
|
<input type="checkbox" style="margin-right:8px;"> 采购电池组(王工,截止9/10)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;margin-top:24px;">
|
||||||
|
<button class="btn btn-secondary" style="flex:1;">下载纪要</button>
|
||||||
|
<button class="btn btn-primary" style="flex:1;">播放回放</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 9. “我的”页面 -->
|
||||||
|
<section class="screen" id="me">
|
||||||
|
<div style="padding:var(--gap);text-align:center;">
|
||||||
|
<img src="https://i.pravatar.cc/80?a=5" style="border-radius:50%;width:80px;height:80px;">
|
||||||
|
<h2 style="margin:8px 0 2px;">张工</h2>
|
||||||
|
<p style="font-size:12px;color:var(--text-light);">研发中心 · 高级工程师</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 var(--gap);">
|
||||||
|
<div class="card" style="padding:12px var(--gap);">个人资料</div>
|
||||||
|
<div class="card" style="padding:12px var(--gap);">设置</div>
|
||||||
|
<div class="card" style="padding:12px var(--gap);">帮助与反馈</div>
|
||||||
|
<div class="card" style="padding:12px var(--gap);">关于我们</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--gap);">
|
||||||
|
<button class="btn btn-danger" style="width:100%;">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 10. 空/加载/错误状态示例 -->
|
||||||
|
<section class="screen" id="empty">
|
||||||
|
<div class="empty">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
|
||||||
|
<h3>暂无数据</h3>
|
||||||
|
<p>这里空空如也~</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="screen" id="error">
|
||||||
|
<div class="empty">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||||
|
<h3>网络连接失败</h3>
|
||||||
|
<button class="btn btn-primary">点击重试</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
463
docs/livekit-api-docs.md
Normal file
463
docs/livekit-api-docs.md
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
# GXRTC服务器API文档
|
||||||
|
|
||||||
|
## 接口标准定义:
|
||||||
|
|
||||||
|
### 标准返回JSON 模版:
|
||||||
|
|
||||||
|
| Key | Value | 说明 |
|
||||||
|
| ---- | ----- | ---- |
|
||||||
|
| meta | {"code": 400, "message": "params face_img is required"} |
|
||||||
|
| code | 参考HTTP status code规范:https://developer.mozilla.org/en-US/docs/Web/HTTP/Status |
|
||||||
|
| message | 用于客户端将提示给用户显示|
|
||||||
|
| data | {} | map KV结构,key 一定为string,Value可为任意对象,可理解为Golang中的map[string]interface{} |
|
||||||
|
|
||||||
|
异常返回示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{"meta": {"code": 400, "message": "params face_img is required"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
正常返回结构体参考:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"imgs": [
|
||||||
|
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0........"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务定义:
|
||||||
|
- 服务器API根路径:https://meeting.cnsdt.com/api/v1
|
||||||
|
- 服务API返回值说明:
|
||||||
|
- 服务端所有API Respose返回格式默认为json
|
||||||
|
- json结构:
|
||||||
|
- 异常返回示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 403,
|
||||||
|
"error": "user unauthorized",
|
||||||
|
"message": "用户没有登录,不能使用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 正常返回示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"sid": "RM_44AJticWDN3t",
|
||||||
|
"name": "thehome",
|
||||||
|
"empty_timeout": 300,
|
||||||
|
"departure_timeout": 20,
|
||||||
|
"creation_time": 1731684825,
|
||||||
|
"turn_password": "3b4QxehgfMm7RvjTjDI75ot22IhMgIrBFeWmSl8NUgyA",
|
||||||
|
"enabled_codecs": [
|
||||||
|
{
|
||||||
|
"mime": "audio/opus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "audio/red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/H264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/AV1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## API: 获取access_token
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/token`
|
||||||
|
- Method: `POST`
|
||||||
|
- Content Type: `form`
|
||||||
|
- Params:
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XPOST https://{{API_ROOT}}/api/v1/room/token -d "uid=xtqxk&room=thehome"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzE2ODg3OTgsImlzcyI6IkFQSTdaOUNQamhtdVo4eCIsIm5iZiI6MTczMTY4NTE5OCwic3ViIjoieHRxeGsiLCJ2aWRlbyI6eyJyb29tIjoidGhlaG9tZSIsInJvb21Kb2luIjp0cnVlfX0.GfNtz3u7kXQRJ3Rkg4usQOF2uDdJSrXfJmD3aewB-4M"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API: 创建房间
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/`
|
||||||
|
- Method: `POST`
|
||||||
|
- Content Type: `form`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID,目前与名称重叠,未来房间ID会由服务器指定,客户端仅支持传递房间名称
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XPOST https://{{API_ROOT}}/api/v1/room/ -d "room=thehome"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"sid": "RM_44AJticWDN3t",
|
||||||
|
"name": "thehome",
|
||||||
|
"empty_timeout": 300,
|
||||||
|
"departure_timeout": 20,
|
||||||
|
"creation_time": 1731684825,
|
||||||
|
"turn_password": "3b4QxehgfMm7RvjTjDI75ot22IhMgIrBFeWmSl8NUgyA",
|
||||||
|
"enabled_codecs": [
|
||||||
|
{
|
||||||
|
"mime": "audio/opus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "audio/red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/H264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/AV1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API: 获取所有房间列表
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/`
|
||||||
|
- Method: `GET`
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XGET https://{{API_ROOT}}/api/v1/room/
|
||||||
|
```
|
||||||
|
### Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"sid": "RM_44AJticWDN3t",
|
||||||
|
"name": "thehome",
|
||||||
|
"empty_timeout": 300,
|
||||||
|
"departure_timeout": 20,
|
||||||
|
"creation_time": 1731684825,
|
||||||
|
"turn_password": "3b4QxehgfMm7RvjTjDI75ot22IhMgIrBFeWmSl8NUgyA",
|
||||||
|
"enabled_codecs": [
|
||||||
|
{
|
||||||
|
"mime": "audio/opus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "audio/red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/H264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/AV1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API: 解散房间
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}`
|
||||||
|
- Method: `DELETE`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID,目前与名称重叠,未来房间ID会由服务器指定,客户端仅支持传递房间名称
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XDELETE https://{{API_ROOT}}/api/v1/room/thehome
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
成功返回被删除的房音详情
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"sid": "RM_44AJticWDN3t",
|
||||||
|
"name": "thehome",
|
||||||
|
"empty_timeout": 300,
|
||||||
|
"departure_timeout": 20,
|
||||||
|
"creation_time": 1731684825,
|
||||||
|
"turn_password": "3b4QxehgfMm7RvjTjDI75ot22IhMgIrBFeWmSl8NUgyA",
|
||||||
|
"enabled_codecs": [
|
||||||
|
{
|
||||||
|
"mime": "audio/opus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "audio/red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/H264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/AV1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
失败返回错误信息,如房间不存在,或没有权限等
|
||||||
|
|
||||||
|
## API: 获取房间参与者列表
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participants`
|
||||||
|
- Method: `GET`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XGET https://{{API_ROOT}}/api/v1/room/{{room}}/participants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"sid": "user1",
|
||||||
|
"name": "Alice",
|
||||||
|
"joined_at": 1731684825,
|
||||||
|
"...":"..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sid": "user2",
|
||||||
|
"name": "Bob",
|
||||||
|
"joined_at": 1731684830,
|
||||||
|
"...":"..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- participants: `[]ParticipantInfo`, 返回[ParticipantInfo](structs.md#ParticipantInfo)列表
|
||||||
|
|
||||||
|
## API: 从房间中移除参与者
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participant/{{uid}}`
|
||||||
|
- Method: `DELETE`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XDELETE https://{{API_ROOT}}/api/v1/room/{{room}}/participant/{{uid}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
当成功时,失败时code不为200
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API: 获取房间中指定用户的详情
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participant/{{uid}}`
|
||||||
|
- Method: `GET`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XGET https://{{API_ROOT}}/api/v1/room/{{room}}/participant/{{uid}}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": { // ParticipantInfo
|
||||||
|
"sid": "user1",
|
||||||
|
"name": "Alice",
|
||||||
|
"joined_at": 1731684825,
|
||||||
|
"...":"..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- data : `ParticipantInfo`, 返回[ParticipantInfo](structs.md#ParticipantInfo)
|
||||||
|
|
||||||
|
|
||||||
|
## API: 将房间中的用户禁音/解禁
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participant/{{uid}}/mute`
|
||||||
|
- Method: `POST/DELETE`, POST:禁音,DELETE解禁
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XPOST https://{{API_ROOT}}/api/v1/room/{{room}}/participant/{{uid}}/mute
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
当成功时,失败时code不为200
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API: 更新房间中的用户信息
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participant/{{uid}}/info`
|
||||||
|
- Method: `POST`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- Form:
|
||||||
|
- attrs: `string`, json 格式的key,value结构,如:{"nickname":"rick","gender":"male"}
|
||||||
|
- metadata: `string`, 任意格式的字符串
|
||||||
|
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XPOST https://{{API_ROOT}}/api/v1/room/{{room}}/participant/{{uid}}/info -d "metadata=metadata_string&attrs=%7B%22nickname%22%3A%22rick%22%2C%22gender%22%3A%22male%22%7D"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": { // ParticipantInfo
|
||||||
|
"sid": "user1",
|
||||||
|
"name": "Alice",
|
||||||
|
"joined_at": 1731684825,
|
||||||
|
"...":"..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- data : `ParticipantInfo`, 返回[ParticipantInfo](structs.md#ParticipantInfo)
|
||||||
|
|
||||||
|
|
||||||
|
## API: 更新房间中的用户权限
|
||||||
|
|
||||||
|
### Request:
|
||||||
|
- API Path: `/room/{{room}}/participant/{{uid}}/permissions`
|
||||||
|
- Method: `POST`
|
||||||
|
- Params:
|
||||||
|
- room: `string` 房间ID
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- Form:
|
||||||
|
- permission: `string`, json 格式的key,value结构,permission结构体参考:[permission](structs.md#participantpermission)
|
||||||
|
|
||||||
|
- Example:
|
||||||
|
```bash
|
||||||
|
curl -XPOST https://{{API_ROOT}}/api/v1/room/{{room}}/participant/{{uid}}/info -d "permission="%7B%22can_subscribe%22%3Atrue%2C%22can_publish%22%3Afalse%7D""
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Response:
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"code": 200
|
||||||
|
},
|
||||||
|
"data": { // ParticipantInfo
|
||||||
|
"sid": "user1",
|
||||||
|
"name": "Alice",
|
||||||
|
"joined_at": 1731684825,
|
||||||
|
"...":"..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- data : `ParticipantInfo`, 返回[ParticipantInfo](structs.md#ParticipantInfo)
|
||||||
204
docs/livekit-messages.md
Normal file
204
docs/livekit-messages.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 消息定义
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
消息通道保持与业务的隔离性,包括鉴权等,独立于业务存在。
|
||||||
|
做为消息通道,理论上不应该与客户端用户登陆状态相关,应该只与应用关,即理论上一个应用应该在打开后即建立连接。用户登陆后应该在应用服务器中将此连接与用户进行关联而已,将要发送给此用户的消息发送到此通道中即可。当然,MQTT服务在建立连接,断开连接等事件结点均会发出WebHook消息。WebHook对应的被通知业务服务应该是HTTP服务。
|
||||||
|
MQTT消息结构体默认应该为MessagePack格式( It's like JSON. but fast and small.)
|
||||||
|
|
||||||
|
## MQTT WebHooks
|
||||||
|
|
||||||
|
### 配置WebHook链接地址
|
||||||
|
- 此服务地址不应该与客户端API一样对客户端开放,如要开放也请做好安全限制
|
||||||
|
- 此服务请求结构体支持MessagePack/Json格式(可配置,默认MessagePack),并在URL中配有签名字段(可选),约定密钥配置在MQTT服务器中
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## MQTT业务消息
|
||||||
|
消息结构体主要属性有两个:event, data,以激光笔为例
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event":"laser-pointer",
|
||||||
|
"data":{
|
||||||
|
"start":[0.5,0.3],
|
||||||
|
"end":[0.5,0.3],
|
||||||
|
"color":[255,0,0],
|
||||||
|
"thickness":3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
消息接收者范围由MQTT的TOPIC来约束。
|
||||||
|
|
||||||
|
本文中默认topic根约定为: /zrsk/remote-assistant,下文中的所有TOPIC默认需要在头部添加`{{TOPIC-ROOT}}`来表达,如:
|
||||||
|
- `/room/78Ue` 业务中完整TOIPC应为: `{{TOPIC-ROOT}}/room/78Ue`,即:`/zrsk/remote-assistant/room/78Ue`
|
||||||
|
|
||||||
|
如要发送给房间指定用户,消息体对应的Topic应该为:
|
||||||
|
|
||||||
|
`
|
||||||
|
{{TOPIC-ROOT}}/room/{{ROOM_ID}}/user/{{UID}},
|
||||||
|
`
|
||||||
|
|
||||||
|
如果是房间广播消息,那么消息体对应的Topic应该为:
|
||||||
|
`
|
||||||
|
{{TOPIC-ROOT}}/room/{{ROOM_ID}},
|
||||||
|
`
|
||||||
|
|
||||||
|
在进入房间后,客户端应该订阅如上两个对应的TOPIC即可实现对此房间此类消息的监听。
|
||||||
|
|
||||||
|
|
||||||
|
## 客户端消息定义
|
||||||
|
|
||||||
|
### 发起呼叫
|
||||||
|
用户创建会议,并呼叫会议参与方,一般每2秒发送一次消息,收到一次消息持续UI提示6秒,收到取消呼叫的消息显示呼叫被取消后,显示呼叫取消的UI停留2秒后隐藏。或在呼叫UI显示6秒后,中间没有新的呼叫消息到达,同样视为呼叫取消。
|
||||||
|
- topic: `{{TOPIC-ROOT}}/user/{{UID}}` ,UID为被呼叫的用户ID
|
||||||
|
- event: `call`
|
||||||
|
- data:
|
||||||
|
- room_id: `string` 会议ID
|
||||||
|
- title: `string` 发起呼叫的会议名称
|
||||||
|
- caller: `uid` 呼叫者ID,收到后应该从服务端获取此用户的用户信息,如getBriefUserInfo
|
||||||
|
|
||||||
|
### 呼叫取消
|
||||||
|
取消对指定用户参会的呼叫邀请
|
||||||
|
- topic: `{{TOPIC-ROOT}}/user/{{UID}}` ,UID为被呼叫的用户ID
|
||||||
|
- event: `call-cancel`
|
||||||
|
- data:
|
||||||
|
- room_id: `string` 会议ID
|
||||||
|
- title: `string` 发起呼叫的会议名称
|
||||||
|
- caller: `uid` 呼叫者ID,收到后应该从服务端获取此用户的用户信息,如getBriefUserInfo
|
||||||
|
|
||||||
|
### 激光笔
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}/user/{{UID}}`
|
||||||
|
- event: `laser-pointer`
|
||||||
|
- data:
|
||||||
|
- start: `[2]float`, 点击按下时,当前点坐标相对屏幕坐标的百分比,结构为[x,y],如屏幕宽1920,当前点x是960,则这里应该传递0.5,
|
||||||
|
- end: `[2]float`, 当点击抬起时,当前点坐标相对屏幕坐标的百分比,结构为[x,y],如屏幕宽1920,当前点x是960,则这里应该传递0.5,
|
||||||
|
- color: `[3]uint8`, 结构顺序为RGB,即红,蓝,绿三原色,范围分别为0-255,如[255,0,0],即代表纯红色。
|
||||||
|
- thickness:`uint`, 连接start,与end之间的线段粗细,值单位是像素,一般为大于1的整数
|
||||||
|
|
||||||
|
|
||||||
|
### 冻屏
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}/user/{{UID}}`
|
||||||
|
- event: `screen-freeze`
|
||||||
|
- data:
|
||||||
|
- uid: `string`, 发起冻屏的用户ID,收到此消息后默认将此用户发布的视频流放到最大,此用户开始将之前用户的视频分享界面截图做为画板在上面进行绘画,过程视频流共享给当前会议内的所有人。
|
||||||
|
|
||||||
|
### 广播消息
|
||||||
|
- topic:
|
||||||
|
- 全局: `{{TOPIC-ROOT}}/system` , 客户端启动后建立MQTT连接后应该全程订阅
|
||||||
|
- 会议中: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}` ,客户端参与指定会议后,开始订阅,退出会议或会议结束后应该取消订阅
|
||||||
|
- 用户消息: `{{TOPIC-ROOT}}/user/{{UID}}`, 客户端启动后建立MQTT连接后,用户登陆后,应该使用当前用户ID来全程订阅,发布到此Topic下可能的消息类型有:
|
||||||
|
- 管理员私信
|
||||||
|
- 聊天消息
|
||||||
|
- 会议呼叫
|
||||||
|
- 会议管理消息(禁麦,被踢出会议等)
|
||||||
|
- 文件预览
|
||||||
|
- event: `boardcast`
|
||||||
|
- data:
|
||||||
|
- title: `string` 广播消息标题
|
||||||
|
- message: `string` 消息内容,本期暂时固定消息样式即可,后期考虑支持html富文本格式
|
||||||
|
- sender:
|
||||||
|
- name: `string` 发送者名称
|
||||||
|
|
||||||
|
### 发起对指定用户的文件预览
|
||||||
|
在会议中发起对指定用户的文件预览,首先需要将文件上传,后得到文件的相对路径,客户端收到消息后再将文件路径拼接到文件预览服务API中从而实现预览。
|
||||||
|
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}/user/{{UID}}`
|
||||||
|
- event: `file_preview`
|
||||||
|
- data:
|
||||||
|
- name: `string` 文件名称
|
||||||
|
- path: `string` 文件路径
|
||||||
|
- mime_type: `string` 文件类型,如: `image/jpeg`、`application/pdf`等
|
||||||
|
|
||||||
|
|
||||||
|
### 会议字幕
|
||||||
|
会议中所有用户的发言音频转字幕
|
||||||
|
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}`
|
||||||
|
- event: `meeting-asr-subtitle`
|
||||||
|
- data:
|
||||||
|
- uid: `string` 用户ID
|
||||||
|
- seg: `string` 用户当前ASR转换的结果
|
||||||
|
- status: `string`,可能的值为:正在讲话中:`steaming`,本句已经说完:`final`
|
||||||
|
|
||||||
|
### 会议结束
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}`
|
||||||
|
- event: `end-meeting`
|
||||||
|
- data:
|
||||||
|
- reason: `string` __optional__ 可由系统生成详细的原因,如此字段存在,此信息应该以toast的方式在客户端进行提示。如:“XX用户挂断了”或“管理员结束了本次会议”
|
||||||
|
- code: `int`, 0: 未知原因,1:用户挂断,2:管理员解散会议
|
||||||
|
|
||||||
|
### 强制切换指定用户当前发布的视频源
|
||||||
|
- topic: `{{TOPIC-ROOT}}/room/{{ROOM_ID}}/user/{{UID}}`
|
||||||
|
- event: `change-video-source`
|
||||||
|
- data:
|
||||||
|
- stream_id: `string` 可选的值由客户端在进入会议后上报本地可用的localStream设备列表
|
||||||
|
|
||||||
|
## 服务端消息定义
|
||||||
|
|
||||||
|
## 房间消息
|
||||||
|
- topic: `{{TOPIC-ROOT}}/system/room/{{ROOM_ID}}/event/{{EVENT}}` // 此Topic不允许用户客户端订阅
|
||||||
|
- ROOM_ID:`string` 同room.name
|
||||||
|
- EVENT: `string` 同消息中的event
|
||||||
|
- event: `room-event`
|
||||||
|
- room_started: 会议开始
|
||||||
|
- room_finished: 会议结束
|
||||||
|
- participant_joined: 有人参会
|
||||||
|
- participant_left: 有人退出会议
|
||||||
|
- track_published: 有人发布流
|
||||||
|
- track_unpublished: 有人停止流发布
|
||||||
|
|
||||||
|
- data:
|
||||||
|
- event: `string` 事件
|
||||||
|
- room: `Room` 结构体参考[Room](structs.md#room)
|
||||||
|
- participant: `ParticipantInfo` 结构体参考[ParticipantInfo](structs.md#participantinfo) 当event与participant或track相关时可用
|
||||||
|
- track: `TrackInfo` 结构体参考[TrackInfo](structs.md#trackinfo) 当event为track_unpublished,或track_unpublished时可用
|
||||||
|
消息格式参考
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "room_started",
|
||||||
|
"room": {
|
||||||
|
"sid": "RM_g8qHzqsDLYbf",
|
||||||
|
"name": "f37p-vltp",
|
||||||
|
"empty_timeout": 300,
|
||||||
|
"departure_timeout": 20,
|
||||||
|
"creation_time": 1735203759,
|
||||||
|
"turn_password": "UodxhWh76Wtf3h2V9XmfiyisKKxpik93BFuyqdvTzG4",
|
||||||
|
"enabled_codecs": [
|
||||||
|
{
|
||||||
|
"mime": "audio/opus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "audio/red"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/H264"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/VP9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mime": "video/AV1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"participant": {
|
||||||
|
"sid": "PA_5cRs7GQixq7Q",
|
||||||
|
"identity": "Kilroy001",
|
||||||
|
"state": 2,
|
||||||
|
"joined_at": 1735203759,
|
||||||
|
"name": "Kilroy001",
|
||||||
|
"version": 2,
|
||||||
|
"permission": {
|
||||||
|
"can_subscribe": true,
|
||||||
|
"can_publish": true,
|
||||||
|
"can_publish_data": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "EV_e5mj23v3QCST",
|
||||||
|
"created_at": 1735203759
|
||||||
|
}
|
||||||
|
```
|
||||||
118
docs/livekit-structs.md
Normal file
118
docs/livekit-structs.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
结构体定义
|
||||||
|
|
||||||
|
## VideoLayer
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quality": "int", //0: "LOW",1: "MEDIUM",2: "HIGH",3: "OFF",
|
||||||
|
"width": "uint32", // 视频宽度
|
||||||
|
"height":"uint32", //视频高度
|
||||||
|
"bitrate": "uint32", //target bitrate in bit per second (bps), server will measure actual
|
||||||
|
"ssrc": "uint32", //
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## ParticipantPermission
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"can_subscribe": "boolean",// 是否可订阅
|
||||||
|
"can_publish":"boolean",// 是否可以发布
|
||||||
|
"can_publish_data":"boolean",//是否可以发数据
|
||||||
|
"can_publish_sources":"[]int32", // 可发布的源类型,可用的值有:0: "UNKNOWN",1: "CAMERA",2: "MICROPHONE",3: "SCREEN_SHARE",4: "SCREEN_SHARE_AUDIO",
|
||||||
|
"hidden":"boolean",//是否可以隐藏
|
||||||
|
"can_update_metadata":"boolean",//是否可以更新元数据
|
||||||
|
"can_subscribe_metrics":"boolean",//是否可以订阅指标
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SimulcastCodecInfo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mime_type":"string", //mime type of codec
|
||||||
|
"mid":"string", //
|
||||||
|
"cid":"string", //
|
||||||
|
"layers": "[]VideoLayer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TimedVersion
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unix_micro":"int64", // Unix时间戳
|
||||||
|
"ticks":"int32"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TrackInfo
|
||||||
|
流信息,source是track的输入,Meida Stream是track的集合
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sid": "string", // 轨道的唯一标识符
|
||||||
|
"type": "string", // 轨道类型
|
||||||
|
"muted": "boolean", // 是否静音
|
||||||
|
"name": "string", // 轨道名称
|
||||||
|
"width": "uint32", // 视频宽度
|
||||||
|
"height":"uint32", //视频高度
|
||||||
|
"simulcast":"boolean", //true if track is simulcasted
|
||||||
|
"disable_dtx": "boolean", //true if DTX (Discontinuous Transmission) is disabled for audio
|
||||||
|
"source": "int32",// 源类型,0: "UNKNOWN",1: "CAMERA",2: "MICROPHONE",3: "SCREEN_SHARE",4: "SCREEN_SHARE_AUDIO",
|
||||||
|
"layers": "[]VideoLayer",
|
||||||
|
"mime_type":"string", //mime type of codec
|
||||||
|
"mid":"string", //
|
||||||
|
"codecs": "[]SimulcastCodecInfo" , //SimulcastCodecInfo列表
|
||||||
|
"stereo": "boolean",
|
||||||
|
"disableRed":"boolean",
|
||||||
|
"encryption":"int32",//加密类型,0: "NONE",1: "GCM",2: "CUSTOM",
|
||||||
|
"stream":"string",// 流名称
|
||||||
|
"version":"TimedVersion",
|
||||||
|
"audio_features":"int32",//音频功能,0: "TF_STEREO",1: "TF_NO_DTX",2: "TF_AUTO_GAIN_CONTROL",3: "TF_ECHO_CANCELLATION",4: "TF_NOISE_SUPPRESSION",5: "TF_ENHANCED_NOISE_CANCELLATION"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ParticipantInfo
|
||||||
|
用户信息
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sid": "string", // 参与者的唯一标识符
|
||||||
|
"identity": "string", // 参与者的ID
|
||||||
|
"state": "int32", // 参与者的状态,0: "JOINING",1: "JOINED",2: "ACTIVE",3: "DISCONNECTED",
|
||||||
|
"tracks": "[]TrackInfo", //
|
||||||
|
"metadata": "string", // 元数据
|
||||||
|
"joined_at": "number", // 加入时间
|
||||||
|
"is_speaking": "boolean", // 是否正在讲话
|
||||||
|
"permission": "ParticipantPermission",// 权限
|
||||||
|
"is_publisher":"boolean", // 是否是发布者
|
||||||
|
"name": "string", // 名称
|
||||||
|
"version": "string", // 版本
|
||||||
|
"region": "string", // 区域
|
||||||
|
"kind": "string" // 类型
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Room
|
||||||
|
房间信息
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sid": "string", // 房间的唯一标识符
|
||||||
|
"name": "string", // 房间的名称
|
||||||
|
"empty_timeout": "uint32", // 空房间关闭前的持续时间(以秒为单位)
|
||||||
|
"departure_timeout": "uint32", // 参与者被认为离开的持续时间(以秒为单位)
|
||||||
|
"max_participants": "uint32", // 房间允许的最大参与者数量
|
||||||
|
"creation_time": "int64", // 房间创建时的时间戳
|
||||||
|
"turn_password": "string", // 用于TURN服务器认证的密码
|
||||||
|
"enabled_codecs": "[]Codec", // 房间启用的编解码器列表
|
||||||
|
"metadata": "string", // 房间的附加信息
|
||||||
|
"num_participants": "uint32", // 房间当前的参与者数量
|
||||||
|
"num_publishers": "uint32", // 房间当前的发布者数量
|
||||||
|
"active_recording": "boolean", // 房间是否正在录制
|
||||||
|
"version": "TimedVersion" // 房间的版本信息
|
||||||
|
}
|
||||||
|
```
|
||||||
177
docs/prd.md
Normal file
177
docs/prd.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# **AR远程协作APP (代号:Synergy Lens) \- 产品需求文档 (PRD)**
|
||||||
|
|
||||||
|
### **1\. 产品概述 (Product Overview)**
|
||||||
|
|
||||||
|
### **1.1 产品简介**
|
||||||
|
|
||||||
|
XSynergy是一款面向企业用户的AR远程协作应用。它通过结合实时音视频、增强现实(AR)标注和人工智能(AI)技术,使一线现场人员能够与远程专家进行“身临其境”的互动,高效解决复杂问题,旨在打破地理限制,降低差旅成本,提升协作效率和知识传承。
|
||||||
|
|
||||||
|
### **1.2 目标用户 (Target Audience)**
|
||||||
|
|
||||||
|
* **一线现场人员 (Field Technician)**:如设备维修工程师、产线操作员、医疗巡检人员。他们需要实时、精准的远程指导。
|
||||||
|
* **远程专家 (Remote Expert)**:如资深技术专家、产品设计师、医疗顾问。他们需要清晰地了解现场情况并提供精确指令。
|
||||||
|
* **项目/团队管理者 (Manager)**:需要监督协作过程、回顾协作内容、管理团队知识。
|
||||||
|
|
||||||
|
### **1.3 核心目标 (Goals)**
|
||||||
|
|
||||||
|
* **提升问题解决效率**:通过AR标注和实时音视频,将专家指导的平均解决时间缩短30%。
|
||||||
|
* **降低运营成本**:减少专家差旅需求,预计每年可为企业节省20%的相关费用。
|
||||||
|
* **赋能一线员工**:通过AI知识库和会议回放,帮助一线人员快速成长,沉淀组织知识。
|
||||||
|
|
||||||
|
### **2\. 功能需求详述 (Functional Requirements)**
|
||||||
|
|
||||||
|
### **2.1 用户与账户模块 (User & Account)**
|
||||||
|
|
||||||
|
* **FR-2.1.1 用户登录**
|
||||||
|
* **用户故事**:作为一名企业员工,我希望可以通过我的企业账号(如SSO、手机号/验证码)安全地登录APP,以便访问我的协作空间和组织信息。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 支持手机号+验证码登录方式。
|
||||||
|
2. 支持企业单点登录(SSO),登录企业LDAP账户。
|
||||||
|
3. 提供“记住我”功能,保持登录状态。
|
||||||
|
4. 包含“忘记密码”流程(针对非SSO用户)。
|
||||||
|
* **验收标准**:用户可以成功登录并进入APP主界面;登录失败时有明确的错误提示。
|
||||||
|
* **FR-2.1.2 用户登出**
|
||||||
|
* **用户故事**:作为一名用户,我希望能从APP中安全退出登录,以保护我的账户信息。
|
||||||
|
* **功能描述**:在“我的”或“设置”页面提供“退出登录”按钮,点击后清除本地登录凭证并返回登录页面。
|
||||||
|
* **验收标准**:用户点击退出后,无法在未重新登录的情况下访问需要授权的页面。
|
||||||
|
|
||||||
|
### **2.2 协作会话模块 (Collaboration Session)**
|
||||||
|
|
||||||
|
* **FR-2.2.1 发起即时协作**
|
||||||
|
* **用户故事**:作为一名现场技术员,当我遇到紧急问题时,我希望能立即向组织内的专家发起视频协作请求。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 主界面提供“发起协作”入口。
|
||||||
|
2. 用户可以从组织架构中选择一位或多位成员发起呼叫。
|
||||||
|
3. 被呼叫方会收到系统推送通知,可选择接听或拒绝。
|
||||||
|
* **验收标准**:呼叫成功发起,被叫方能收到通知并进行响应。
|
||||||
|
* **FR-2.2.2 预约协作**
|
||||||
|
* **用户故事**:作为一名项目经理,我希望能提前预约一个多方协作会议,并设定主题、时间和参与人,系统能自动提醒相关人员。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 提供“预约协作”功能入口。
|
||||||
|
2. 用户可设置会议主题、选择开始/结束时间、添加参会人员、填写会议描述。
|
||||||
|
3. 预约成功后,系统向所有参会人发送日历邀请和APP内通知。
|
||||||
|
4. 在会议开始前15分钟,系统再次发送提醒通知。(以日历提醒和APP内消息推送的方式)
|
||||||
|
* **验收标准**:预约信息准确无误,所有参会人能按时收到通知和提醒。(考虑到国内安卓手机限制较多且接口不统一,APP内部推送消息无法在手机端弹出提醒的话,不视为功能实现失败。)
|
||||||
|
* **FR-2.2.3 邀请与加入**
|
||||||
|
* **用户故事**:作为会议发起人,我希望能通过链接或一个简单的会议码,快速邀请组织外或未在联系人列表中的人加入协作。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 每个协作会话(即时或预约)都生成一个唯一的分享链接和一个6位数字的会议码。
|
||||||
|
2. 用户可以通过社交应用(微信、钉钉等)分享链接(分享为链接复制,形式同腾讯会议)。
|
||||||
|
3. APP主界面提供“加入协作”入口,用户可输入6位会议码加入。
|
||||||
|
4. 通过链接加入时,自动拉起APP并进入会议。
|
||||||
|
* **验收标准**:用户可以通过链接和会议码两种方式成功加入指定的协作会议。
|
||||||
|
|
||||||
|
### **2.3 协作中功能模块 (In-Session Features)**
|
||||||
|
|
||||||
|
* **FR-2.3.1 基础音视频控制**
|
||||||
|
* **用户故事**:作为参会者,我希望能自由控制自己的麦克风和摄像头,以便在需要时静音或关闭画面。
|
||||||
|
* **功能描述**:会议界面提供清晰的图标按钮,用于一键禁用/启用麦克风和摄像头。图标状态需明确反映当前设备状态(开/关)。
|
||||||
|
* **验收标准**:用户可以随时开启或关闭自己的音视频输入,其他参会者能实时看到状态变化。
|
||||||
|
* **FR-2.3.2 AR标注协作**
|
||||||
|
* **用户故事**:作为远程专家,我希望能在我方屏幕上,对现场人员传输回来的实时视频画面进行3D空间标注(如画箭头、框选),且标注能稳定地附着在现实物体上,以便清晰地指示操作位置。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 提供多种标注工具:箭头、自由曲线、矩形框。
|
||||||
|
2. 支持选择不同颜色进行标注。
|
||||||
|
3. 标注内容通过AR技术“冻结”在三维空间中,即使用户移动摄像头,标注也会停留在原来的物理位置上。
|
||||||
|
4. 提供“清除”按钮,可清除自己或全部的标注。
|
||||||
|
* **验收标准**:AR标注延迟低,跟踪稳定,在现场人员视角中清晰可见。
|
||||||
|
* **FR-2.3.3 白板涂鸦协作**
|
||||||
|
* **用户故事**:作为参会者,当我们需要讨论流程图或进行头脑风暴时,我希望能打开一个共享的虚拟白板,所有人都可以在上面实时涂鸦和书写。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 会议中可开启“白板模式”,视频画面切换为共享白板。
|
||||||
|
2. 提供画笔、文本输入、形状工具和橡皮擦。
|
||||||
|
3. 所有参会者的操作实时同步。
|
||||||
|
4. 支持白板内容截图保存。
|
||||||
|
* **验收标准**:白板操作流畅,同步延迟低,所有用户看到的内容完全一致。
|
||||||
|
* **FR-2.3.4 激光笔标注**
|
||||||
|
* **用户故事**:作为远程专家,我希望能有一个激光笔工具,在我讲解时,可以在对方的视频画面上实时指示出我正在关注的点,而不会留下永久标记。
|
||||||
|
* **功能描述**:提供“激光笔”工具。专家在自己屏幕上长按并移动手指(或在web端长按鼠标进行拖动),现场人员的屏幕上会实时显示一个跟随移动的光点或小图标。手指松开后光点消失。
|
||||||
|
* **验收标准**:激光笔指示实时同步,无明显延迟,能准确传达专家意图。
|
||||||
|
* **FR-2.3.5 屏幕共享**
|
||||||
|
* **用户故事**:作为参会者,我希望能将我的手机屏幕内容共享给会议中的其他人,以便展示APP操作或数据图表。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 提供“屏幕共享”功能按钮。
|
||||||
|
2. 用户授权后,将其整个手机屏幕或指定应用画面作为视频流发送给其他参会者。
|
||||||
|
3. 共享期间,屏幕边缘有明显提示(如红色边框),告知用户正在共享。
|
||||||
|
* **验收标准**:屏幕共享画面清晰流畅,其他用户可以正常观看。
|
||||||
|
* **FR-2.3.6 文件共享与播放**
|
||||||
|
* **用户故事**:作为专家,我希望能向现场人员发送技术手册(PDF/Word)或操作演示视频(MP4),并能在会议中共同观看和讨论。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 提供“发送文件”功能,支持从本地上传PDF, Word, MP4等格式文件。
|
||||||
|
2. 文件发送给指定的一位或全部参会者。
|
||||||
|
3. 接收方收到文件后可下载到本地。
|
||||||
|
4. 发起方可以开启“共同播放”模式,所有人的界面上会同步播放该视频或展示该文档,并支持翻页、暂停等同步操作。(此功能需要讨论,非优先项)
|
||||||
|
* **验收标准**:文件能成功发送和接收;共同播放/阅览时,所有人的视图保持同步。
|
||||||
|
|
||||||
|
### **2.4 AI能力模块 (AI Capabilities)**
|
||||||
|
|
||||||
|
* **FR-2.4.1 实时语音转文字与会议纪要**
|
||||||
|
* **用户故事**:作为一名参会者,我希望能看到实时的语音转文字字幕,并在会后自动生成一份包含关键决策和待办事项的会议纪要,以减少我的记录工作。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 会议中可开启“实时字幕”功能,将所有人的发言实时转化为文字显示在屏幕上。
|
||||||
|
2. 会议结束后,AI自动处理录音,生成结构化的会议纪要,包括:会议摘要、议题列表、关键决策、待办事项(Action Items)及负责人。
|
||||||
|
3. 会议纪要与会议回放关联,存储在历史记录中。
|
||||||
|
* **验收标准**:语音转文字准确率不低于90%;会议纪要能准确提炼核心信息。
|
||||||
|
* **FR-2.4.2 AI知识库查询**
|
||||||
|
* **用户故事**:作为现场人员,在协作过程中,我希望能通过一个对话框,快速查询公司内部知识库(如设备故障手册、标准操作流程),以便快速找到参考信息。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 会议中界面提供一个“AI助手”入口,点击或语音激活后在左侧弹出对话框。
|
||||||
|
2. 提供弹出显示对话框显示,提供“收回”按钮控制对话框隐藏
|
||||||
|
3. 用户输入自然语言问题(如“E-101泵的常见故障代码及解决方案”)。
|
||||||
|
4. AI助手基于后台接入的企业知识库进行检索,并以对话形式返回最相关的答案和文档链接。
|
||||||
|
5. 在底部导航中加入“AI助手”入口,供用户非会议时间使用。
|
||||||
|
* **验收标准**:AI助手能理解用户意图,并从知识库中返回准确、相关的答案。
|
||||||
|
|
||||||
|
### **2.5 其他模块 (Miscellaneous)**
|
||||||
|
|
||||||
|
* **FR-2.5.1 会议回放**
|
||||||
|
* **用户故事**:作为一名项目经理或未能参会的人员,我希望能随时查看历史会议的完整录像(包含AR标注),以便复盘问题或了解会议内容。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 所有协作会话默认开启云端录制。
|
||||||
|
2. 用户可以在“历史记录”中找到过去的会议列表。
|
||||||
|
3. 点击即可播放会议录像,录像需完整重现当时的视频、音频、AR标注、白板、文件共享等所有协作信息。
|
||||||
|
* **验收标准**:回放内容与实际协作过程完全一致,播放流畅。
|
||||||
|
* **FR-2.5.2 组织架构查看**
|
||||||
|
* **用户故事**:作为一名员工,我希望能方便地查看公司的组织架构树,快速找到并联系到我需要的同事。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 提供“通讯录”或“组织”入口。
|
||||||
|
2. 以树状结构展示公司部门和人员。
|
||||||
|
3. 支持按姓名、部门、职位进行搜索。
|
||||||
|
4. 点击人员可查看其联系方式并直接发起协作。
|
||||||
|
* **验收标准**:组织架构数据准确,搜索功能可用,能快速定位到目标同事。
|
||||||
|
* **FR-2.5.3 会议纪要**
|
||||||
|
* **用户故事**:作为一名项目经理或未能参会的人员,我希望能随时查看历史会议的会议纪要,以便了解会议摘要、关键决策、待办事项。
|
||||||
|
* **功能描述**:
|
||||||
|
1. 所有协作会话可以手动开启/关闭AI会议纪要功能。
|
||||||
|
2. 用户可以在“历史记录”中找到过去的会议列表。
|
||||||
|
3. 点击卡片式列表中“会议纪要”按钮,即可打开“会议纪要”页面查看纪要,纪要需完整包含参会人、会议开始时间和结束时间、会议摘要、关键决策、待办事项。
|
||||||
|
4. 纪要页面提供“查看回放”按钮,提供会议回放功能。
|
||||||
|
* **验收标准**:回放内容与实际协作过程完全一致,播放流畅。
|
||||||
|
|
||||||
|
### **3\. 非功能性需求 (Non-Functional Requirements)**
|
||||||
|
|
||||||
|
* **NF-3.1 性能 (Performance)**
|
||||||
|
* 音视频通话延迟低于200ms。
|
||||||
|
* AR标注跟踪刷新率不低于30fps。
|
||||||
|
* APP冷启动时间小于3秒。
|
||||||
|
* **NF-3.2 兼容性 (Compatibility)**
|
||||||
|
* 支持Android 8.0及以上版本。
|
||||||
|
* 要求设备支持ARCore。
|
||||||
|
* **NF-3.3 安全性 (Security)**
|
||||||
|
* 所有通信数据(音视频、信令、文件)均采用端到端加密。
|
||||||
|
* 用户数据存储符合GDPR或相关数据保护法规。
|
||||||
|
* **NF-3.4 用户体验 (Usability)**
|
||||||
|
* 界面设计简洁直观,关键操作按钮尺寸足够大,易于在移动或工业环境中单手操作。
|
||||||
|
* 网络不稳定时,应优先保证音频清晰度,并有明确的网络状态提示。
|
||||||
|
|
||||||
|
### **4\. 设计与AI集成指南 (Design & AI Integration)**
|
||||||
|
|
||||||
|
* **UI/UE设计指南**:
|
||||||
|
* **核心原则**:信息降噪,聚焦于协作视图。避免不必要的UI元素干扰现场人员的视线。
|
||||||
|
* **AR交互**:AR标注工具栏应设计为可收缩式,默认最小化。标注的视觉效果应有足够的对比度,以适应各种复杂的现场环境光线。
|
||||||
|
* **手势操作**:考虑引入简单的手势操作,如双击屏幕清除最后一次标注,以提升操作效率。
|
||||||
|
* **AI模型集成指南**:
|
||||||
|
* **语音转文字 (STT)**:需选用或训练针对特定行业术语(如机械、医疗)进行优化的模型,以提高识别准确率。模型需在端侧或低延迟的云端运行,保证实时性。
|
||||||
|
* **会议纪要生成**:采用大语言模型(LLM)进行文本摘要和信息提取。模型需被调整以准确识别“决策”、“任务分配”等关键意图。
|
||||||
|
* **知识库查询**:建议采用RAG(检索增强生成)架构。后端需建立高效的文档索引,前端AI助手通过语义搜索匹配最相关的知识片段,并由LLM整合后生成自然语言答案。
|
||||||
|
|
||||||
2560
docs/ui.html
Normal file
2560
docs/ui.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user