From 0314acc18e870affd95aca7044f6b17aaa89cf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Fri, 27 Mar 2026 18:45:21 +0800 Subject: [PATCH] init --- .gitignore | 2 + AndroidLibs/README.md | 9 +- .../commonsdk-compose/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 10 +- .../compose/activity/WebViewPageActivity.kt | 42 + .../components/accordion/AccordionPanel.kt | 97 +++ .../compose/components/image/AdaptiveImage.kt | 103 +++ .../refresh/SwipeRefreshContainer.kt | 85 ++ .../components/swipe/SwipeActionItem.kt | 112 +++ .../components/webview/CommonWebView.kt | 114 +++ AndroidLibs/commonsdk-core/build.gradle.kts | 1 + .../src/main/java/com/xuqm/sdk/CoreSDK.kt | 63 +- .../java/com/xuqm/sdk/cache/GlobalCache.kt | 72 ++ .../xuqm/sdk/communication/PluginMessenger.kt | 56 ++ .../com/xuqm/sdk/network/RetrofitManager.kt | 5 +- .../xuqm/sdk/permission/PermissionManager.kt | 81 ++ .../main/java/com/xuqm/sdk/ui/DialogCenter.kt | 102 +++ .../main/java/com/xuqm/sdk/ui/ToastCenter.kt | 2 +- .../com/xuqm/sdk/update/DownloadManager.kt | 20 +- .../java/com/xuqm/sdk/utils/DateTimeUtils.kt | 35 +- .../java/com/xuqm/sdk/utils/FileHelper.kt | 109 +++ .../main/java/com/xuqm/sdk/utils/Logger.kt | 49 ++ AndroidLibs/commonsdk-update/build.gradle.kts | 31 + .../commonsdk-update/consumer-rules.pro | 1 + .../xuqm/sdk/plugin/PluginPackageManager.kt | 36 +- .../java/com/xuqm/sdk/update/AppUpdater.kt | 3 +- .../com/xuqm/sdk/update/VersionComparator.kt | 0 .../java/com/xuqm/sdk/updatekit/UpdateSDK.kt | 14 + AndroidLibs/gradle/libs.versions.toml | 6 + .../com/xuqm/szyx/login/SzyxLoginActivity.kt | 1 - AndroidLibs/sample-app/build.gradle.kts | 3 +- .../main/java/com/xuqm/sample/MainActivity.kt | 20 +- AndroidLibs/settings.gradle.kts | 1 + doc/01-project-overview.md | 35 + doc/02-architecture.md | 37 + doc/03-frontend.md | 65 ++ doc/04-backend.md | 56 ++ doc/05-infrastructure.md | 60 ++ doc/06-version-management.md | 55 ++ doc/07-development-workflow.md | 42 + doc/08-android-sdk.md | 54 ++ doc/README.md | 20 + frontend/README.md | 26 +- frontend/admin-platform/package.json | 1 + frontend/ops-platform/package.json | 1 + frontend/ops-platform/src/App.vue | 19 +- frontend/ops-platform/src/api/client.ts | 104 ++- frontend/ops-platform/src/router/index.ts | 21 +- frontend/ops-platform/src/styles.css | 39 + .../src/views/AppManagementView.vue | 297 +++++++ frontend/ops-platform/src/views/LoginView.vue | 72 ++ .../src/views/ModulePlaceholderView.vue | 11 + .../ops-platform/src/views/OpsShellView.vue | 23 + frontend/package.json | 14 + frontend/yarn.lock | 738 ++++++++++++++++++ server/pom.xml | 28 + server/version-management-service/pom.xml | 5 + .../config/DataInitializer.java | 40 +- .../CompatibilityUpdateController.java | 16 +- .../controller/OpsVersionController.java | 169 +++- .../controller/PublicAccountController.java | 19 +- .../versionmanagement/model/PlatformData.java | 120 +++ .../persistence/entity/AccountEntity.java | 11 + .../persistence/entity/ApplicationEntity.java | 11 + .../persistence/entity/PluginEntity.java | 100 +++ .../persistence/entity/ReleaseEntity.java | 32 + .../repository/AccountRepository.java | 2 + .../repository/PluginRepository.java | 10 + .../repository/ReleaseRepository.java | 2 + .../service/AccountService.java | 15 +- .../service/PlatformMapper.java | 31 +- .../service/VersionManagementService.java | 249 +++++- .../src/main/resources/application.yml | 30 +- server/version-service/README.md | 68 -- .../version-service/data/version-config.json | 22 - server/version-service/package.json | 12 - server/version-service/src/index.js | 141 ---- 77 files changed, 3812 insertions(+), 399 deletions(-) create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/activity/WebViewPageActivity.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/accordion/AccordionPanel.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/image/AdaptiveImage.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/refresh/SwipeRefreshContainer.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/swipe/SwipeActionItem.kt create mode 100644 AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/webview/CommonWebView.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/GlobalCache.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/PluginMessenger.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/permission/PermissionManager.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/DialogCenter.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/FileHelper.kt create mode 100644 AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/Logger.kt create mode 100644 AndroidLibs/commonsdk-update/build.gradle.kts create mode 100644 AndroidLibs/commonsdk-update/consumer-rules.pro rename AndroidLibs/{commonsdk-core => commonsdk-update}/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt (86%) rename AndroidLibs/{commonsdk-core => commonsdk-update}/src/main/java/com/xuqm/sdk/update/AppUpdater.kt (97%) rename AndroidLibs/{commonsdk-core => commonsdk-update}/src/main/java/com/xuqm/sdk/update/VersionComparator.kt (100%) create mode 100644 AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/updatekit/UpdateSDK.kt create mode 100644 doc/01-project-overview.md create mode 100644 doc/02-architecture.md create mode 100644 doc/03-frontend.md create mode 100644 doc/04-backend.md create mode 100644 doc/05-infrastructure.md create mode 100644 doc/06-version-management.md create mode 100644 doc/07-development-workflow.md create mode 100644 doc/08-android-sdk.md create mode 100644 doc/README.md create mode 100644 frontend/ops-platform/src/views/AppManagementView.vue create mode 100644 frontend/ops-platform/src/views/LoginView.vue create mode 100644 frontend/ops-platform/src/views/ModulePlaceholderView.vue create mode 100644 frontend/ops-platform/src/views/OpsShellView.vue create mode 100644 frontend/package.json create mode 100644 frontend/yarn.lock create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/entity/PluginEntity.java create mode 100644 server/version-management-service/src/main/java/com/xuqm/versionmanagement/persistence/repository/PluginRepository.java delete mode 100644 server/version-service/README.md delete mode 100644 server/version-service/data/version-config.json delete mode 100644 server/version-service/package.json delete mode 100644 server/version-service/src/index.js diff --git a/.gitignore b/.gitignore index cfbd199..9baf0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .idea/ .m2/ +.yarn/ .gradle/ .kotlin/ build/ @@ -8,6 +9,7 @@ target/ node_modules/ dist/ coverage/ +.pnp.* *.iml *.log AndroidLibs/.gradle-home/ diff --git a/AndroidLibs/README.md b/AndroidLibs/README.md index b2df95e..3a06971 100644 --- a/AndroidLibs/README.md +++ b/AndroidLibs/README.md @@ -4,8 +4,9 @@ ## 模块结构 -- `commonsdk-core`: SDK 核心,承载网络、共享缓存、插件管理、App 更新、设备信息与时间工具。 -- `commonsdk-compose`: Compose 扩展组件。 +- `commonsdk-core`: SDK 核心,承载网络、下载、文件、权限、缓存、日志、全局弹窗、设备与时间等基础能力。 +- `commonsdk-compose`: Compose 扩展组件,提供下拉刷新、折叠面板、滑动操作、WebView、图片等业务可复用组件。 +- `commonsdk-update`: 更新 SDK,独立承载 APK 更新、插件更新、版本比较与安装编排。 - `lib-szyx`: 项目专属 SDK,承载真实登录接口、签名、业务 Header 与会话管理。 - `sample-app`: 示例宿主应用。 - `plugins/plugin-ui`: UI 演示插件,可独立运行,也可被宿主拉起。 @@ -48,6 +49,10 @@ nexus.password=your-password - `commonsdk-core` 提供: - `HttpManager / RetrofitManager` - `SharedCacheManager / SharedCacheProvider` + - `DownloadManager / FileHelper` + - `PermissionManager / GlobalCache / Logger / DialogCenter` +- `commonsdk-update` 提供: + - `UpdateSDK` - `PluginPackageManager` - `AppUpdater` - `lib-szyx` 提供: diff --git a/AndroidLibs/commonsdk-compose/build.gradle.kts b/AndroidLibs/commonsdk-compose/build.gradle.kts index 9cee32a..19da5a1 100644 --- a/AndroidLibs/commonsdk-compose/build.gradle.kts +++ b/AndroidLibs/commonsdk-compose/build.gradle.kts @@ -32,8 +32,11 @@ dependencies { api(project(":commonsdk-core")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.webkit) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) + implementation(libs.coil.compose) debugImplementation(libs.bundles.compose.debug) } diff --git a/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml index 2d10029..ed6c531 100644 --- a/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml +++ b/AndroidLibs/commonsdk-compose/src/main/AndroidManifest.xml @@ -1,3 +1,11 @@ - + + + + + + diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/activity/WebViewPageActivity.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/activity/WebViewPageActivity.kt new file mode 100644 index 0000000..9acc85a --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/activity/WebViewPageActivity.kt @@ -0,0 +1,42 @@ +package com.xuqm.sdk.compose.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.xuqm.sdk.compose.components.webview.CommonWebView +import com.xuqm.sdk.compose.components.webview.WebViewConfig +import androidx.compose.material3.MaterialTheme + +class WebViewPageActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(EXTRA_URL).orEmpty() + val title = intent.getStringExtra(EXTRA_TITLE) + setContent { + MaterialTheme { + CommonWebView( + url = url, + config = WebViewConfig(title = title), + ) + } + } + } + + companion object { + private const val EXTRA_URL = "extra_url" + private const val EXTRA_TITLE = "extra_title" + + fun createIntent( + context: Context, + url: String, + title: String? = null, + ): Intent { + return Intent(context, WebViewPageActivity::class.java).apply { + putExtra(EXTRA_URL, url) + putExtra(EXTRA_TITLE, title) + } + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/accordion/AccordionPanel.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/accordion/AccordionPanel.kt new file mode 100644 index 0000000..6d2a66a --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/accordion/AccordionPanel.kt @@ -0,0 +1,97 @@ +package com.xuqm.sdk.compose.components.accordion + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.xuqm.sdk.compose.components.refresh.RefreshableLazyColumn + +data class AccordionItem( + val id: String, + val title: String, + val data: T, + val initiallyExpanded: Boolean = false, +) + +@Composable +fun AccordionPanel( + title: String, + modifier: Modifier = Modifier, + initiallyExpanded: Boolean = false, + headerBackground: Color = MaterialTheme.colorScheme.surfaceVariant, + content: @Composable () -> Unit, +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + Card(modifier = modifier.fillMaxWidth()) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .background(headerBackground) + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Icon( + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = null, + ) + } + AnimatedVisibility(expanded) { + Box(modifier = Modifier.padding(16.dp)) { + content() + } + } + } + } +} + +@Composable +fun RefreshableAccordionList( + items: List>, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + itemContent: @Composable (AccordionItem) -> Unit, +) { + RefreshableLazyColumn( + items = items, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier, + listState = rememberLazyListState(), + itemContent = { _, item -> + AccordionPanel( + title = item.title, + initiallyExpanded = item.initiallyExpanded, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + itemContent(item) + } + }, + ) +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/image/AdaptiveImage.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/image/AdaptiveImage.kt new file mode 100644 index 0000000..0e937e7 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/image/AdaptiveImage.kt @@ -0,0 +1,103 @@ +package com.xuqm.sdk.compose.components.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest + +enum class ImageAdaptMode { + WIDTH_FIXED, + HEIGHT_FIXED, + BOTH_FIXED, + FILL_WIDTH, +} + +@Composable +fun AdaptiveImage( + model: Any?, + modifier: Modifier = Modifier, + adaptMode: ImageAdaptMode = ImageAdaptMode.FILL_WIDTH, + width: Dp? = null, + height: Dp? = null, + aspectRatio: Float = 1f, + shape: Shape = RoundedCornerShape(16.dp), + contentScale: ContentScale = ContentScale.Crop, + contentDescription: String? = null, +) { + val imageModifier = when (adaptMode) { + ImageAdaptMode.WIDTH_FIXED -> Modifier.width(width ?: 160.dp).aspectRatio(aspectRatio) + ImageAdaptMode.HEIGHT_FIXED -> Modifier.height(height ?: 160.dp).aspectRatio(aspectRatio) + ImageAdaptMode.BOTH_FIXED -> Modifier.width(width ?: 160.dp).height(height ?: 160.dp) + ImageAdaptMode.FILL_WIDTH -> Modifier.fillMaxWidth().aspectRatio(aspectRatio) + } + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(model).crossfade(true).build(), + contentDescription = contentDescription, + modifier = modifier.then(imageModifier).clip(shape), + contentScale = contentScale, + ) +} + +@Composable +fun CircleImage( + model: Any?, + modifier: Modifier = Modifier, + size: Dp = 72.dp, + borderWidth: Dp = 0.dp, + borderColor: Color = Color.Transparent, + maskColor: Color = Color.Transparent, + overlayBrush: Brush? = null, + contentDescription: String? = null, +) { + Box( + modifier = modifier + .width(size) + .height(size) + .clip(CircleShape) + .border(borderWidth, borderColor, CircleShape) + .background(borderColor), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(model).crossfade(true).build(), + contentDescription = contentDescription, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentScale = ContentScale.Crop, + ) + if (maskColor != Color.Transparent || overlayBrush != null) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(overlayBrush ?: Brush.verticalGradient(listOf(maskColor, maskColor))), + ) + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/refresh/SwipeRefreshContainer.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/refresh/SwipeRefreshContainer.kt new file mode 100644 index 0000000..b4b68ee --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/refresh/SwipeRefreshContainer.kt @@ -0,0 +1,85 @@ +package com.xuqm.sdk.compose.components.refresh + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeRefreshContainer( + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val refreshState = rememberPullToRefreshState() + PullToRefreshBox( + state = refreshState, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier.fillMaxSize(), + content = content, + ) +} + +@Composable +fun RefreshableLazyColumn( + items: List, + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), + enableLoadMore: Boolean = false, + isLoadingMore: Boolean = false, + hasMore: Boolean = true, + onLoadMore: () -> Unit = {}, + emptyContent: (@Composable BoxScope.() -> Unit)? = null, + itemContent: @Composable (index: Int, item: T) -> Unit, +) { + val shouldLoadMore by remember(items, enableLoadMore, isLoadingMore, hasMore, listState) { + derivedStateOf { + if (!enableLoadMore || isLoadingMore || !hasMore) return@derivedStateOf false + val layoutInfo = listState.layoutInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible >= items.lastIndex - 1 && items.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + + SwipeRefreshContainer( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier, + ) { + if (items.isEmpty() && emptyContent != null) { + Box(modifier = Modifier.fillMaxSize(), content = emptyContent) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + itemsIndexed(items) { index, item -> + itemContent(index, item) + } + } + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/swipe/SwipeActionItem.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/swipe/SwipeActionItem.kt new file mode 100644 index 0000000..2bffaab --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/swipe/SwipeActionItem.kt @@ -0,0 +1,112 @@ +package com.xuqm.sdk.compose.components.swipe + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +data class SwipeAction( + val label: String, + val backgroundColor: Color, + val contentColor: Color = Color.White, + val onClick: () -> Unit, + val content: (@Composable () -> Unit)? = null, +) + +@Composable +fun SwipeActionItem( + modifier: Modifier = Modifier, + actions: List, + actionWidthDp: Int = 88, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + var offsetX by remember { mutableFloatStateOf(0f) } + val maxOffset = with(density) { -(actions.size * actionWidthDp.dp.toPx()) } + + Box( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + horizontalArrangement = Arrangement.End, + ) { + actions.forEach { action -> + Box( + modifier = Modifier + .width(actionWidthDp.dp) + .fillMaxHeight() + .background(action.backgroundColor) + .clickable { + scope.launch { offsetX = 0f } + action.onClick() + }, + contentAlignment = Alignment.Center, + ) { + if (action.content != null) { + action.content.invoke() + } else { + Text( + text = action.label, + color = action.contentColor, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(offsetX.roundToInt(), 0) } + .pointerInput(actions) { + detectHorizontalDragGestures( + onHorizontalDrag = { _, dragAmount -> + offsetX = (offsetX + dragAmount).coerceIn(maxOffset, 0f) + }, + onDragEnd = { + scope.launch { + offsetX = if (offsetX < maxOffset / 2) maxOffset else 0f + } + }, + ) + } + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)), + ) { + content() + } + } +} diff --git a/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/webview/CommonWebView.kt b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/webview/CommonWebView.kt new file mode 100644 index 0000000..ac4a674 --- /dev/null +++ b/AndroidLibs/commonsdk-compose/src/main/java/com/xuqm/sdk/compose/components/webview/CommonWebView.kt @@ -0,0 +1,114 @@ +package com.xuqm.sdk.compose.components.webview + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +data class WebViewConfig( + val title: String? = null, + val javaScriptEnabled: Boolean = true, + val domStorageEnabled: Boolean = true, + val userAgentString: String? = null, +) + +interface WebViewCallback { + fun onPageStarted(url: String?) {} + fun onPageFinished(url: String?) {} + fun onProgressChanged(progress: Int) {} + fun onReceivedTitle(title: String?) {} + fun shouldOverrideUrlLoading(url: String?): Boolean = false +} + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommonWebView( + url: String, + modifier: Modifier = Modifier, + config: WebViewConfig = WebViewConfig(), + callback: WebViewCallback? = null, + onClose: (() -> Unit)? = null, +) { + var webView: WebView? by remember { mutableStateOf(null) } + var title by remember { mutableStateOf(config.title ?: "") } + var progress by remember { mutableIntStateOf(0) } + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } + + DisposableEffect(Unit) { + onDispose { webView?.destroy() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (title.isBlank()) url else title) }, + ) + }, + ) { padding -> + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = config.javaScriptEnabled + settings.domStorageEnabled = config.domStorageEnabled + settings.cacheMode = WebSettings.LOAD_DEFAULT + config.userAgentString?.let { settings.userAgentString = it } + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + callback?.onPageStarted(url) + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + return callback?.shouldOverrideUrlLoading(request?.url?.toString()) ?: false + } + + override fun onPageFinished(view: WebView?, url: String?) { + callback?.onPageFinished(url) + } + } + webChromeClient = object : WebChromeClient() { + override fun onReceivedTitle(view: WebView?, newTitle: String?) { + title = newTitle ?: config.title.orEmpty() + callback?.onReceivedTitle(newTitle) + } + + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + callback?.onProgressChanged(newProgress) + } + } + loadUrl(url) + webView = this + } + }, + modifier = modifier + .fillMaxSize(), + update = { view -> + if (view.url != url) { + view.loadUrl(url) + } + }, + ) + } +} diff --git a/AndroidLibs/commonsdk-core/build.gradle.kts b/AndroidLibs/commonsdk-core/build.gradle.kts index f031854..b9ed73e 100644 --- a/AndroidLibs/commonsdk-core/build.gradle.kts +++ b/AndroidLibs/commonsdk-core/build.gradle.kts @@ -25,6 +25,7 @@ kotlin { dependencies { api(libs.androidx.core.ktx) + api(libs.androidx.activity.ktx) api(libs.bundles.network) api(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt index 333a259..b4c935e 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/CoreSDK.kt @@ -1,17 +1,24 @@ package com.xuqm.sdk +import android.app.Activity import android.app.Application import android.content.Context +import android.os.Bundle +import com.xuqm.sdk.cache.GlobalCache import com.xuqm.sdk.network.HttpConfig import com.xuqm.sdk.network.HttpManager -import com.xuqm.sdk.plugin.PluginPackageManager -import com.xuqm.sdk.update.AppUpdater +import com.xuqm.sdk.ui.DialogCenter +import com.xuqm.sdk.ui.ToastCenter import com.xuqm.sdk.update.DownloadManager import com.xuqm.sdk.utils.DeviceUtils +import com.xuqm.sdk.utils.FileHelper +import com.xuqm.sdk.utils.Logger +import java.lang.ref.WeakReference object CoreSDK { private var appContext: Context? = null private var config: SDKConfig = SDKConfig() + private var topActivityRef: WeakReference? = null data class SDKConfig( val debugMode: Boolean = false, @@ -20,20 +27,66 @@ object CoreSDK { fun init(context: Context, config: SDKConfig = SDKConfig()) { if (appContext != null) return - appContext = context.applicationContext + val applicationContext = context.applicationContext + appContext = applicationContext this.config = config HttpManager.init(HttpConfig(debugMode = config.debugMode)) + Logger.init(config.debugMode) + GlobalCache.init(applicationContext) + ToastCenter.init(applicationContext) + DialogCenter.init(applicationContext) + FileHelper.init(applicationContext) + registerLifecycleCallbacks(applicationContext) } fun context(): Context = requireNotNull(appContext) { "CoreSDK not initialized" } - fun pluginPackageManager(): PluginPackageManager = PluginPackageManager.getInstance(context()) + fun config(): SDKConfig = config + + fun currentActivity(): Activity? = topActivityRef?.get() fun downloadManager(): DownloadManager = DownloadManager.getInstance(context()) - fun appUpdater(): AppUpdater = AppUpdater.getInstance(context()) + fun fileHelper(): FileHelper = FileHelper + + fun cache(): GlobalCache = GlobalCache + + fun logger(): Logger = Logger + + fun dialogCenter(): DialogCenter = DialogCenter fun deviceId(): String = DeviceUtils.getDeviceId(context()) fun deviceInfo() = DeviceUtils.getDeviceInfo(context()) + + private fun registerLifecycleCallbacks(context: Context) { + val application = context as? Application ?: return + application.registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + topActivityRef = WeakReference(activity) + } + + override fun onActivityStarted(activity: Activity) { + topActivityRef = WeakReference(activity) + } + + override fun onActivityResumed(activity: Activity) { + topActivityRef = WeakReference(activity) + } + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) { + if (topActivityRef?.get() === activity) { + topActivityRef = null + } + } + }, + ) + } } diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/GlobalCache.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/GlobalCache.kt new file mode 100644 index 0000000..e475491 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/cache/GlobalCache.kt @@ -0,0 +1,72 @@ +package com.xuqm.sdk.cache + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +object GlobalCache { + private const val DEFAULT_TTL = 24 * 60 * 60 * 1000L + private var cacheManager: SharedCacheManager? = null + + internal fun init(context: Context) { + if (cacheManager == null) { + cacheManager = SharedCacheManager.getInstance(context.applicationContext) + } + } + + fun putString(key: String, value: String, ttl: Long = DEFAULT_TTL) { + requireManager().put(key, value, ttl) + } + + fun getString(key: String, defaultValue: String? = null): String? { + return requireManager().getSync(key) ?: defaultValue + } + + fun putBoolean(key: String, value: Boolean, ttl: Long = DEFAULT_TTL) { + putString(key, value.toString(), ttl) + } + + fun getBoolean(key: String, defaultValue: Boolean = false): Boolean { + return getString(key)?.toBooleanStrictOrNull() ?: defaultValue + } + + fun putInt(key: String, value: Int, ttl: Long = DEFAULT_TTL) { + putString(key, value.toString(), ttl) + } + + fun getInt(key: String, defaultValue: Int = 0): Int { + return getString(key)?.toIntOrNull() ?: defaultValue + } + + fun putLong(key: String, value: Long, ttl: Long = DEFAULT_TTL) { + putString(key, value.toString(), ttl) + } + + fun getLong(key: String, defaultValue: Long = 0L): Long { + return getString(key)?.toLongOrNull() ?: defaultValue + } + + fun putJson(key: String, value: JSONObject, ttl: Long = DEFAULT_TTL) { + putString(key, value.toString(), ttl) + } + + fun getJson(key: String): JSONObject? { + return getString(key)?.let { runCatching { JSONObject(it) }.getOrNull() } + } + + fun putJsonArray(key: String, value: JSONArray, ttl: Long = DEFAULT_TTL) { + putString(key, value.toString(), ttl) + } + + fun getJsonArray(key: String): JSONArray? { + return getString(key)?.let { runCatching { JSONArray(it) }.getOrNull() } + } + + fun remove(key: String) { + requireManager().remove(key) + } + + private fun requireManager(): SharedCacheManager { + return requireNotNull(cacheManager) { "GlobalCache not initialized. Call CoreSDK.init() first." } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/PluginMessenger.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/PluginMessenger.kt new file mode 100644 index 0000000..bcbdcb0 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/communication/PluginMessenger.kt @@ -0,0 +1,56 @@ +package com.xuqm.sdk.communication + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.UUID + +data class PluginMessage( + val id: String = UUID.randomUUID().toString(), + val from: String, + val to: String? = null, + val topic: String, + val payload: String? = null, + val timestamp: Long = System.currentTimeMillis(), +) + +object PluginMessenger { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val bus = MutableSharedFlow(extraBufferCapacity = 64) + + fun send( + from: String, + topic: String, + payload: String? = null, + to: String? = null, + ) { + bus.tryEmit( + PluginMessage( + from = from, + to = to, + topic = topic, + payload = payload, + ), + ) + } + + fun subscribe( + owner: String, + topic: String? = null, + onReceive: (PluginMessage) -> Unit, + ): Job { + return bus + .filter { message -> + val topicMatches = topic == null || topic == message.topic + val targetMatches = message.to.isNullOrBlank() || message.to == owner + topicMatches && targetMatches + } + .onEach(onReceive) + .launchIn(scope) + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt index 4db33d5..f9a12c0 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/network/RetrofitManager.kt @@ -29,7 +29,7 @@ class RetrofitManager private constructor() { private val serviceCache = ConcurrentHashMap() private var globalConfig: HttpConfig = HttpConfig() - fun init(config: HttpConfig = HttpConfig()) { + internal fun init(config: HttpConfig = HttpConfig()) { globalConfig = config } @@ -75,7 +75,7 @@ class RetrofitManager private constructor() { } object HttpManager { - fun init(config: HttpConfig = HttpConfig()) { + internal fun init(config: HttpConfig = HttpConfig()) { RetrofitManager.getInstance().init(config) } @@ -83,4 +83,3 @@ object HttpManager { return RetrofitManager.getInstance().getService(baseUrl, serviceClass, config) } } - diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/permission/PermissionManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/permission/PermissionManager.kt new file mode 100644 index 0000000..94c69b7 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/permission/PermissionManager.kt @@ -0,0 +1,81 @@ +package com.xuqm.sdk.permission + +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.xuqm.sdk.ui.DialogCenter + +data class PermissionRequest( + val permission: String, + val title: String, + val description: String, +) + +data class PermissionResult( + val granted: List, + val denied: List, +) { + fun allGranted(): Boolean = denied.isEmpty() +} + +class PermissionManager private constructor( + private val launcher: ActivityResultLauncher>, + private val callback: (PermissionResult) -> Unit, +) { + private var pendingRequests: List = emptyList() + + fun request( + requests: List, + confirmTitle: String = "权限申请说明", + confirmButton: String = "继续申请", + cancelButton: String = "取消", + onCancelled: (() -> Unit)? = null, + ) { + if (requests.isEmpty()) { + callback(PermissionResult(emptyList(), emptyList())) + return + } + pendingRequests = requests + val message = buildString { + appendLine("应用将申请以下权限:") + requests.forEach { + appendLine() + append("• ") + append(it.title) + append(":") + append(it.description) + } + }.trim() + val shown = DialogCenter.showConfirm( + title = confirmTitle, + message = message, + confirmText = confirmButton, + cancelText = cancelButton, + cancelable = true, + canceledOnTouchOutside = false, + onConfirm = { + launcher.launch(requests.map { it.permission }.toTypedArray()) + }, + onCancel = onCancelled, + ) + if (!shown) { + onCancelled?.invoke() + } + } + + companion object { + fun register( + caller: ActivityResultCaller, + onResult: (PermissionResult) -> Unit, + ): PermissionManager { + lateinit var manager: PermissionManager + val launcher = caller.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val granted = manager.pendingRequests.map { it.permission }.filter { result[it] == true } + val denied = manager.pendingRequests.map { it.permission }.filterNot { result[it] == true } + onResult(PermissionResult(granted = granted, denied = denied)) + } + manager = PermissionManager(launcher = launcher, callback = onResult) + return manager + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/DialogCenter.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/DialogCenter.kt new file mode 100644 index 0000000..35fe2db --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/DialogCenter.kt @@ -0,0 +1,102 @@ +package com.xuqm.sdk.ui + +import android.app.Activity +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import com.xuqm.sdk.CoreSDK + +object DialogCenter { + data class LoadingConfig( + val message: String = "加载中...", + val cancelable: Boolean = false, + val canceledOnTouchOutside: Boolean = false, + val onUserCancel: (() -> Unit)? = null, + ) + + private var appContext: Context? = null + private var currentDialog: AlertDialog? = null + private var loadingDialog: Dialog? = null + + internal fun init(context: Context) { + appContext = context.applicationContext + } + + fun showConfirm( + title: String, + message: String, + confirmText: String = "确定", + cancelText: String = "取消", + cancelable: Boolean = true, + canceledOnTouchOutside: Boolean = true, + onConfirm: () -> Unit, + onCancel: (() -> Unit)? = null, + ): Boolean { + val activity = currentActivity() ?: return false + activity.runOnUiThread { + dismissCurrentDialog() + currentDialog = AlertDialog.Builder(activity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(confirmText) { dialog, _ -> + dialog.dismiss() + onConfirm() + } + .setNegativeButton(cancelText) { dialog, _ -> + dialog.dismiss() + onCancel?.invoke() + } + .create() + .apply { + setCancelable(cancelable) + setCanceledOnTouchOutside(canceledOnTouchOutside) + show() + } + } + return true + } + + fun showLoading(config: LoadingConfig = LoadingConfig()): Boolean { + val activity = currentActivity() ?: return false + activity.runOnUiThread { + dismissLoading() + loadingDialog = Dialog(activity).apply { + setCancelable(config.cancelable) + setCanceledOnTouchOutside(config.canceledOnTouchOutside) + setOnCancelListener { config.onUserCancel?.invoke() } + setContentView( + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + val padding = 48 + setPadding(padding, padding, padding, padding) + addView(ProgressBar(activity)) + addView( + TextView(activity).apply { + text = config.message + textSize = 16f + setPadding(24, 0, 0, 0) + }, + ) + }, + ) + show() + } + } + return true + } + + fun dismissLoading() { + loadingDialog?.dismiss() + loadingDialog = null + } + + fun dismissCurrentDialog() { + currentDialog?.dismiss() + currentDialog = null + } + + private fun currentActivity(): Activity? = CoreSDK.currentActivity() +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt index 021ff2d..c634caa 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/ui/ToastCenter.kt @@ -9,7 +9,7 @@ object ToastCenter { private val handler = Handler(Looper.getMainLooper()) private var appContext: Context? = null - fun init(context: Context) { + internal fun init(context: Context) { appContext = context.applicationContext } diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt index 9aba442..59214e7 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/DownloadManager.kt @@ -1,11 +1,8 @@ package com.xuqm.sdk.update import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build import android.os.Environment -import androidx.core.content.FileProvider +import com.xuqm.sdk.utils.FileHelper import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -159,20 +156,7 @@ class DownloadManager private constructor(private val context: Context) { } fun installApk(file: File): Boolean { - return runCatching { - val authority = "${context.packageName}.sdk.fileprovider" - val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - FileProvider.getUriForFile(context, authority, file) - } else { - Uri.fromFile(file) - } - context.startActivity( - Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) - setDataAndType(uri, "application/vnd.android.package-archive") - }, - ) - }.isSuccess + return FileHelper.installApk(file) } private fun resolvePath(storagePath: StoragePath, customPath: String?): String { diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt index cbaea7d..d49f549 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/DateTimeUtils.kt @@ -16,5 +16,38 @@ object DateTimeUtils { } fun now(pattern: String = "yyyy-MM-dd HH:mm:ss"): String = format(System.currentTimeMillis(), pattern) -} + fun nowMillis(): Long = System.currentTimeMillis() + + fun parse( + value: String, + pattern: String = "yyyy-MM-dd HH:mm:ss", + timeZone: TimeZone = TimeZone.getDefault(), + locale: Locale = Locale.getDefault(), + ): Long? { + return runCatching { + SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.parse(value)?.time + }.getOrNull() + } + + fun isToday(timeMillis: Long): Boolean { + val today = format(System.currentTimeMillis(), "yyyy-MM-dd") + return format(timeMillis, "yyyy-MM-dd") == today + } + + fun addDays(timeMillis: Long, days: Int): Long = timeMillis + days * 24L * 60L * 60L * 1000L + + fun addHours(timeMillis: Long, hours: Int): Long = timeMillis + hours * 60L * 60L * 1000L + + fun between(startMillis: Long, endMillis: Long): Long = endMillis - startMillis + + fun toFriendlyText(timeMillis: Long): String { + val delta = System.currentTimeMillis() - timeMillis + return when { + delta < 60_000L -> "刚刚" + delta < 60L * 60L * 1000L -> "${delta / 60_000L}分钟前" + delta < 24L * 60L * 60L * 1000L -> "${delta / (60L * 60L * 1000L)}小时前" + else -> format(timeMillis, "yyyy-MM-dd") + } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/FileHelper.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/FileHelper.kt new file mode 100644 index 0000000..5a3b75b --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/FileHelper.kt @@ -0,0 +1,109 @@ +package com.xuqm.sdk.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object FileHelper { + private var appContext: Context? = null + + internal fun init(context: Context) { + appContext = context.applicationContext + } + + fun installApk(file: File): Boolean { + val context = requireContext() + return runCatching { + val uri = toUri(file) + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(uri, "application/vnd.android.package-archive") + }, + ) + }.isSuccess + } + + fun openFile(file: File, mimeType: String = "*/*"): Boolean { + val context = requireContext() + return runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(toUri(file), mimeType) + }, + ) + }.isSuccess + } + + fun createTempImageFile(prefix: String = "IMG"): File { + val context = requireContext() + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.cacheDir + return File(directory, "${prefix}_${timeStamp}.jpg").apply { + parentFile?.mkdirs() + } + } + + fun toUri(file: File): Uri { + val context = requireContext() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile(context, "${context.packageName}.sdk.fileprovider", file) + } else { + Uri.fromFile(file) + } + } + + fun registerFilePicker( + caller: ActivityResultCaller, + onResult: (Uri?) -> Unit, + ): ActivityResultLauncher> { + return caller.registerForActivityResult(ActivityResultContracts.OpenDocument(), onResult) + } + + fun registerImagePicker( + caller: ActivityResultCaller, + onResult: (Uri?) -> Unit, + ): ActivityResultLauncher { + return caller.registerForActivityResult(ActivityResultContracts.PickVisualMedia(), onResult) + } + + fun registerCameraCapture( + caller: ActivityResultCaller, + outputFileProvider: () -> File = { createTempImageFile() }, + onResult: (Boolean, Uri?) -> Unit, + ): Pair, () -> Uri> { + var pendingUri: Uri? = null + val launcher = caller.registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> + onResult(success, pendingUri) + } + val uriProvider = { + pendingUri = toUri(outputFileProvider()) + requireNotNull(pendingUri) + } + return launcher to uriProvider + } + + fun createImageCaptureIntent(outputUri: Uri): Intent { + return Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, outputUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + } + + private fun requireContext(): Context { + return requireNotNull(appContext) { "FileHelper not initialized. Call CoreSDK.init() first." } + } +} diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/Logger.kt b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/Logger.kt new file mode 100644 index 0000000..02b7fa4 --- /dev/null +++ b/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/utils/Logger.kt @@ -0,0 +1,49 @@ +package com.xuqm.sdk.utils + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject + +object Logger { + private var debugEnabled: Boolean = false + private const val DEFAULT_TAG = "CoreSDK" + + internal fun init(debug: Boolean) { + debugEnabled = debug + } + + fun d(message: Any?, tag: String = DEFAULT_TAG) { + if (debugEnabled) { + Log.d(tag, stringify(message)) + } + } + + fun i(message: Any?, tag: String = DEFAULT_TAG) { + Log.i(tag, stringify(message)) + } + + fun w(message: Any?, tag: String = DEFAULT_TAG) { + Log.w(tag, stringify(message)) + } + + fun e(message: Any?, throwable: Throwable? = null, tag: String = DEFAULT_TAG) { + Log.e(tag, stringify(message), throwable) + } + + fun json(json: String, tag: String = DEFAULT_TAG) { + val pretty = runCatching { + when { + json.trim().startsWith("{") -> JSONObject(json).toString(2) + json.trim().startsWith("[") -> JSONArray(json).toString(2) + else -> json + } + }.getOrDefault(json) + Log.d(tag, pretty) + } + + private fun stringify(message: Any?): String = when (message) { + null -> "null" + is Throwable -> message.stackTraceToString() + else -> message.toString() + } +} diff --git a/AndroidLibs/commonsdk-update/build.gradle.kts b/AndroidLibs/commonsdk-update/build.gradle.kts new file mode 100644 index 0000000..ab9b73a --- /dev/null +++ b/AndroidLibs/commonsdk-update/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + alias(libs.plugins.android.library) +} + +apply(from = rootProject.file("gradle/publishing.gradle.kts")) + +android { + namespace = "com.xuqm.sdk.updatekit" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + api(project(":commonsdk-core")) + implementation(libs.kotlinx.coroutines.android) + + testImplementation(libs.junit4) +} diff --git a/AndroidLibs/commonsdk-update/consumer-rules.pro b/AndroidLibs/commonsdk-update/consumer-rules.pro new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/AndroidLibs/commonsdk-update/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt similarity index 86% rename from AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt rename to AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt index 6e212c3..21c1ea8 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt +++ b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/plugin/PluginPackageManager.kt @@ -3,11 +3,9 @@ package com.xuqm.sdk.plugin import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.os.Build -import androidx.core.content.FileProvider import com.xuqm.sdk.cache.CacheKeys -import com.xuqm.sdk.cache.SharedCacheManager +import com.xuqm.sdk.cache.GlobalCache import com.xuqm.sdk.update.DownloadDecision import com.xuqm.sdk.update.DownloadManager import com.xuqm.sdk.update.DownloadRequest @@ -16,6 +14,7 @@ import com.xuqm.sdk.update.VersionCheckResult import com.xuqm.sdk.update.VersionCheckStrategy import com.xuqm.sdk.update.VersionComparator import com.xuqm.sdk.update.VersionInfo +import com.xuqm.sdk.utils.FileHelper import org.json.JSONObject import java.io.File @@ -41,7 +40,6 @@ class PluginPackageManager private constructor(private val context: Context) { } } - private val cacheManager = SharedCacheManager.getInstance(context) private val downloadManager = DownloadManager.getInstance(context) fun cacheCurrentUser( @@ -57,11 +55,15 @@ class PluginPackageManager private constructor(private val context: Context) { put("timestamp", System.currentTimeMillis()) extraData.forEach { (key, value) -> put(key, value) } } - cacheManager.put(CacheKeys.CURRENT_USER, json.toString(), 10 * 60 * 1000) + GlobalCache.putString(CacheKeys.CURRENT_USER, json.toString(), 10 * 60 * 1000) } fun getCachedUser(appPackageName: String? = null): String? { - return cacheManager.getSync(CacheKeys.CURRENT_USER, appPackageName) + return if (appPackageName.isNullOrBlank() || appPackageName == context.packageName) { + GlobalCache.getString(CacheKeys.CURRENT_USER) + } else { + com.xuqm.sdk.cache.SharedCacheManager.getInstance(context).getSync(CacheKeys.CURRENT_USER, appPackageName) + } } fun isPluginInstalled(packageName: String): Boolean { @@ -92,11 +94,6 @@ class PluginPackageManager private constructor(private val context: Context) { }.getOrNull() } - fun compareVersion(packageName: String, remoteVersionCode: Long): Int { - val local = getLocalPluginInfo(packageName) ?: return 1 - return remoteVersionCode.compareTo(local.versionCode) - } - fun shouldDownloadPlugin( packageName: String, remoteVersionCode: Long? = null, @@ -207,22 +204,7 @@ class PluginPackageManager private constructor(private val context: Context) { return runCatching { context.startActivity(launchIntent) }.isSuccess } - fun installPlugin(apkFile: File): Boolean { - return runCatching { - val authority = "${context.packageName}.sdk.fileprovider" - val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - FileProvider.getUriForFile(context, authority, apkFile) - } else { - Uri.fromFile(apkFile) - } - context.startActivity( - Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) - }, - ) - }.isSuccess - } + fun installPlugin(apkFile: File): Boolean = FileHelper.installApk(apkFile) fun loadPlugin(apkFile: File): Boolean = installPlugin(apkFile) diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/update/AppUpdater.kt similarity index 97% rename from AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt rename to AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/update/AppUpdater.kt index 6e4ee8f..0838231 100644 --- a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/AppUpdater.kt +++ b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/update/AppUpdater.kt @@ -3,6 +3,7 @@ package com.xuqm.sdk.update import android.content.Context import android.content.pm.PackageManager import android.os.Build +import com.xuqm.sdk.utils.FileHelper import kotlinx.coroutines.flow.StateFlow data class UpdateInfo( @@ -119,7 +120,7 @@ class AppUpdater private constructor(private val context: Context) { fun clear(taskId: String): Boolean = downloadManager.clear(taskId) - fun installApk(file: java.io.File): Boolean = downloadManager.installApk(file) + fun installApk(file: java.io.File): Boolean = FileHelper.installApk(file) fun installFromTask(taskId: String): Boolean { val file = downloadManager.getDownloadedFile(taskId) ?: return false diff --git a/AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/update/VersionComparator.kt similarity index 100% rename from AndroidLibs/commonsdk-core/src/main/java/com/xuqm/sdk/update/VersionComparator.kt rename to AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/update/VersionComparator.kt diff --git a/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/updatekit/UpdateSDK.kt b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/updatekit/UpdateSDK.kt new file mode 100644 index 0000000..3420910 --- /dev/null +++ b/AndroidLibs/commonsdk-update/src/main/java/com/xuqm/sdk/updatekit/UpdateSDK.kt @@ -0,0 +1,14 @@ +package com.xuqm.sdk.updatekit + +import android.content.Context +import com.xuqm.sdk.CoreSDK +import com.xuqm.sdk.plugin.PluginPackageManager +import com.xuqm.sdk.update.AppUpdater + +object UpdateSDK { + fun appUpdater(context: Context = CoreSDK.context()): AppUpdater = AppUpdater.getInstance(context) + + fun pluginPackageManager(context: Context = CoreSDK.context()): PluginPackageManager { + return PluginPackageManager.getInstance(context) + } +} diff --git a/AndroidLibs/gradle/libs.versions.toml b/AndroidLibs/gradle/libs.versions.toml index 20ba47c..aa89ea2 100644 --- a/AndroidLibs/gradle/libs.versions.toml +++ b/AndroidLibs/gradle/libs.versions.toml @@ -7,6 +7,7 @@ minSdk = "24" coreKtx = "1.18.0" lifecycle = "2.10.0" activityCompose = "1.13.0" +activityKtx = "1.13.0" composeBom = "2026.03.00" coroutines = "1.10.2" datastore = "1.1.7" @@ -14,6 +15,8 @@ retrofit = "3.0.0" okhttp = "5.3.2" gson = "2.13.2" jserialization = "1.9.0" +webkit = "1.14.0" +coil = "2.7.0" junit4 = "4.13.2" androidxJunit = "1.3.0" espresso = "3.7.0" @@ -23,6 +26,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -34,6 +38,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jserialization" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } diff --git a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt index 6eb11cc..085e7d4 100644 --- a/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt +++ b/AndroidLibs/lib-szyx/src/main/java/com/xuqm/szyx/login/SzyxLoginActivity.kt @@ -31,7 +31,6 @@ class SzyxLoginActivity : ComponentActivity() { if (!SzyxSDK.isInitialized()) { SzyxSDK.init(this) } - ToastCenter.init(this) setContent { MaterialTheme { LoginScreen { finish() } } } } } diff --git a/AndroidLibs/sample-app/build.gradle.kts b/AndroidLibs/sample-app/build.gradle.kts index 09d3055..a7e954d 100644 --- a/AndroidLibs/sample-app/build.gradle.kts +++ b/AndroidLibs/sample-app/build.gradle.kts @@ -17,7 +17,7 @@ android { manifestPlaceholders["authProviderAuthority"] = "com.xuqm.sample.auth" manifestPlaceholders["sharedCacheAuthority"] = "com.xuqm.sample.sdk.cache.provider" manifestPlaceholders["coreFileProviderAuthority"] = "com.xuqm.sample.sdk.fileprovider" - buildConfigField("String", "UPDATE_SERVER_BASE_URL", "\"http://192.168.116.9:3000/\"") + buildConfigField("String", "UPDATE_SERVER_BASE_URL", "\"http://192.168.113.162:8080/\"") } buildTypes { @@ -48,6 +48,7 @@ kotlin { dependencies { implementation(project(":commonsdk-core")) implementation(project(":commonsdk-compose")) + implementation(project(":commonsdk-update")) implementation(project(":lib-szyx")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt index e440525..e055e2a 100644 --- a/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt +++ b/AndroidLibs/sample-app/src/main/java/com/xuqm/sample/MainActivity.kt @@ -49,6 +49,7 @@ import com.xuqm.sdk.update.StoragePath import com.xuqm.sdk.update.UpdateInfo import com.xuqm.sdk.update.VersionCheckResult import com.xuqm.sdk.update.VersionCheckStrategy +import com.xuqm.sdk.updatekit.UpdateSDK import com.xuqm.sdk.utils.DateTimeUtils import com.xuqm.sample.update.UpdateRepository import com.xuqm.szyx.SzyxSDK @@ -89,7 +90,7 @@ class MainActivity : ComponentActivity() { if (intent.action == Intent.ACTION_PACKAGE_ADDED && reloadPluginAfterInstall) { reloadPluginAfterInstall = false val pluginUpdateInfo = currentPluginUpdateInfo - CoreSDK.pluginPackageManager().reloadPlugin( + UpdateSDK.pluginPackageManager().reloadPlugin( packageName = PLUGIN_PACKAGE_NAME, entryActivity = pluginUpdateInfo?.entryActivity ?: PLUGIN_ENTRY_ACTIVITY, extras = pluginUpdateInfo?.extras ?: mapOf("hostPackageName" to this@MainActivity.packageName), @@ -117,7 +118,6 @@ class MainActivity : ComponentActivity() { debugMode = true, ), ) - ToastCenter.init(this) refreshState() ensureLoginOnLaunch() registerPackageChangeReceiver() @@ -170,7 +170,7 @@ class MainActivity : ComponentActivity() { private fun refreshState() { refreshSession() - pluginInstalledState.value = CoreSDK.pluginPackageManager().isPluginInstalled(PLUGIN_PACKAGE_NAME) + pluginInstalledState.value = UpdateSDK.pluginPackageManager().isPluginInstalled(PLUGIN_PACKAGE_NAME) } private fun openLogin() { @@ -199,7 +199,7 @@ class MainActivity : ComponentActivity() { return } - val pluginManager = CoreSDK.pluginPackageManager() + val pluginManager = UpdateSDK.pluginPackageManager() pluginManager.cacheCurrentUser( userId = session.loginModel.userId, sessionId = session.loginModel.sessionId, @@ -224,7 +224,7 @@ class MainActivity : ComponentActivity() { entryActivity = remoteUpdate.entryActivity ?: PLUGIN_ENTRY_ACTIVITY, extras = mapOf("hostPackageName" to packageName), ) - val checkResult = CoreSDK.pluginPackageManager().checkPluginUpdate( + val checkResult = UpdateSDK.pluginPackageManager().checkPluginUpdate( packageName = pluginUpdateInfo.packageName, remoteVersionCode = pluginUpdateInfo.versionCode, remoteVersionName = pluginUpdateInfo.versionName, @@ -235,7 +235,7 @@ class MainActivity : ComponentActivity() { return@onSuccess } currentPluginUpdateInfo = pluginUpdateInfo - val taskId = CoreSDK.pluginPackageManager().downloadPlugin( + val taskId = UpdateSDK.pluginPackageManager().downloadPlugin( updateInfo = pluginUpdateInfo, fileName = "plugin-ui-release.apk", storagePath = StoragePath.EXTERNAL_FILES, @@ -259,7 +259,7 @@ class MainActivity : ComponentActivity() { updateRepository.fetchLatestAppUpdate(packageName) .onSuccess { updateInfo -> when ( - val checkResult = CoreSDK.appUpdater().checkUpdate( + val checkResult = UpdateSDK.appUpdater().checkUpdate( updateInfo = updateInfo, strategy = VersionCheckStrategy.VERSION_CODE_OR_NAME, ) @@ -281,7 +281,7 @@ class MainActivity : ComponentActivity() { private fun confirmDownloadAppUpdate() { val updateInfo = pendingAppUpdateState.value ?: return pendingAppUpdateState.value = null - val taskId = CoreSDK.appUpdater().downloadUpdate( + val taskId = UpdateSDK.appUpdater().downloadUpdate( updateInfo = updateInfo, fileName = "sample-app-update.apk", storagePath = StoragePath.EXTERNAL_FILES, @@ -308,7 +308,7 @@ class MainActivity : ComponentActivity() { is DownloadState.Success -> { ToastCenter.show("插件下载完成,准备重新加载") reloadPluginAfterInstall = true - if (!CoreSDK.pluginPackageManager().loadPlugin(state.file)) { + if (!UpdateSDK.pluginPackageManager().loadPlugin(state.file)) { reloadPluginAfterInstall = false ToastCenter.show("插件加载拉起失败") } @@ -343,7 +343,7 @@ class MainActivity : ComponentActivity() { when (state) { is DownloadState.Success -> { ToastCenter.show("安装包下载完成,准备安装") - if (!CoreSDK.appUpdater().installApk(state.file)) { + if (!UpdateSDK.appUpdater().installApk(state.file)) { ToastCenter.show("应用安装拉起失败") } CoreSDK.downloadManager().clear(taskId) diff --git a/AndroidLibs/settings.gradle.kts b/AndroidLibs/settings.gradle.kts index 4b8eb89..1e1e6cf 100644 --- a/AndroidLibs/settings.gradle.kts +++ b/AndroidLibs/settings.gradle.kts @@ -20,6 +20,7 @@ rootProject.name = "AndroidLibs" include(":commonsdk-core") include(":commonsdk-compose") +include(":commonsdk-update") include(":lib-szyx") include(":sample-app") include(":plugins:plugin-ui") diff --git a/doc/01-project-overview.md b/doc/01-project-overview.md new file mode 100644 index 0000000..ea85531 --- /dev/null +++ b/doc/01-project-overview.md @@ -0,0 +1,35 @@ +# 01 项目概览 + +## 项目目标 + +本项目用于承载 Android SDK、示例宿主应用、插件能力、运营平台、管理平台以及配套的版本管理服务端。 + +当前目标重点: + +- 保持 Android 端 `sample-app` 与 `plugin-ui` 的版本查询能力可继续使用 +- 增加运营平台与管理平台两个 Vue 3 前端 +- 服务端升级为 Spring Boot 微服务形态,便于后续继续扩展即时通讯、推送、版本管理等模块 +- 首期优先落地版本管理、插件化开关、灰度发布与用户钩子能力 + +## 目录结构 + +```text +AndroidLibsGroup/ +├── AndroidLibs/ Android SDK、示例宿主、插件工程 +├── frontend/ Yarn workspace 前端工程 +│ ├── ops-platform/ 运营平台 +│ └── admin-platform/ 管理平台 +├── server/ Spring Boot 服务端与旧版 Node 示例服务 +│ ├── version-management-service/ +│ └── version-service/ +└── doc/ 项目文档 +``` + +## 当前已实现内容 + +- Android SDK 与示例应用基础工程 +- Vue 3 运营平台与管理平台基础页面 +- Spring Boot 版本管理服务 +- MySQL 持久化 +- Redis 缓存灰度用户列表 +- Android 更新兼容接口 diff --git a/doc/02-architecture.md b/doc/02-architecture.md new file mode 100644 index 0000000..8215747 --- /dev/null +++ b/doc/02-architecture.md @@ -0,0 +1,37 @@ +# 02 架构设计 + +## 总体分层 + +### Android 侧 + +- `commonsdk-core`:网络、下载、文件、权限、缓存、日志、全局弹窗、时间等基础能力 +- `commonsdk-compose`:下拉刷新、折叠面板、左滑操作、WebView、图片等 Compose 组件扩展 +- `commonsdk-update`:独立更新 SDK,负责宿主 APK / 插件版本比较、下载编排与安装拉起 +- `lib-szyx`:业务登录与会话管理 +- `sample-app`:宿主示例 +- `plugins/plugin-ui`:插件示例 + +### 前端侧 + +- `ops-platform`:运营使用,开放注册、版本上传、发布、灰度圈选 +- `admin-platform`:平台治理使用,审核主账户、禁用账户、管理子账户权限 + +### 服务端 + +- `version-management-service`:当前主服务 +- 后续可继续拆分为账号中心、推送中心、IM 服务、文件服务等独立微服务 + +## 当前服务端结构 + +- `controller`:接口层 +- `service`:业务编排层 +- `persistence/entity`:JPA 实体 +- `persistence/repository`:JPA 仓储 +- `config`:CORS、Redis、初始化数据等配置 + +## 演进方向 + +- 账号体系独立成 `account-service` +- 版本包上传独立成文件存储服务,对接 MinIO 或对象存储 +- 灰度用户钩子改为对接真实用户平台 API,而不是初始化数据 +- 增加统一鉴权与租户隔离 diff --git a/doc/03-frontend.md b/doc/03-frontend.md new file mode 100644 index 0000000..31eec19 --- /dev/null +++ b/doc/03-frontend.md @@ -0,0 +1,65 @@ +# 03 前端说明 + +## 技术栈 + +- Vue 3 +- Vue Router 4 +- Pinia +- Vite 6 +- TypeScript 5 +- Yarn workspace + +## 包管理 + +前端统一使用 Yarn,不再使用 npm。 + +根目录: + +```bash +cd frontend +yarn install +``` + +开发命令: + +```bash +yarn dev:ops +yarn dev:admin +``` + +构建命令: + +```bash +yarn build +``` + +## 项目职责 + +### 运营平台 `ops-platform` + +- 主账户开放注册 +- 版本管理 +- 插件化开关 +- 版本包上传 +- 全量发布 +- 灰度发布 +- 灰度用户圈选 + +### 管理平台 `admin-platform` + +- 查看运营主账户 +- 审核主账户 +- 禁用账户 +- 查看子账户 +- 管理子账户权限 + +## 环境变量 + +- `VITE_API_BASE_URL`:服务端接口地址,默认 `http://127.0.0.1:8080` + +## 后续规划 + +- 引入统一 UI 组件体系 +- 增加登录鉴权与路由守卫 +- 加入页面级权限控制 +- 加入文件上传组件和上传进度状态 diff --git a/doc/04-backend.md b/doc/04-backend.md new file mode 100644 index 0000000..749b5b4 --- /dev/null +++ b/doc/04-backend.md @@ -0,0 +1,56 @@ +# 04 服务端说明 + +## 当前主服务 + +`server/version-management-service` + +## 技术基线 + +- JDK 21 +- Spring Boot 3.4.4 +- Spring Web +- Spring Data JPA +- Spring Data Redis +- MySQL 8+ + +## 已实现接口能力 + +### 公开接口 + +- 运营平台主账户注册 + +### 管理平台接口 + +- 账户列表 +- 账户审核/禁用 +- 子账户权限更新 + +### 运营平台接口 + +- 应用列表 +- 插件化开关 +- 版本上传 +- 版本发布 +- 灰度用户列表 +- 分组列表 +- 快速选择列表 + +### Android 兼容接口 + +- `GET /api/v1/updates/app/latest` +- `GET /api/v1/updates/plugin/latest` + +## 启动 + +```bash +cd server +mvn -pl version-management-service spring-boot:run +``` + +## 后续演进建议 + +- 增加 Spring Security + JWT +- 引入 Flyway 管理表结构 +- 加入 OpenAPI/Swagger +- 增加单元测试与集成测试 +- 版本包下载地址改为文件服务生成 diff --git a/doc/05-infrastructure.md b/doc/05-infrastructure.md new file mode 100644 index 0000000..d221d58 --- /dev/null +++ b/doc/05-infrastructure.md @@ -0,0 +1,60 @@ +# 05 数据库与缓存 + +## MySQL + +- Host: `xuqinmin.com` +- Port: `3306` +- Database: `androidLibsServer` +- Username: `androidLibsServer` + +当前通过 Spring JPA 自动建表,后续建议切换到 Flyway 管理。 + +当前 `version-management-service` 的数据库与 Redis 配置风格参考了 +`/Users/xuqinmin/Projects/TrustProjects/AppManager/AppManagerWeb/RuoYi-Vue` +项目,重点对齐了: + +- MySQL JDBC 参数格式 +- Redis 基础连接项 +- Redis Lettuce 连接池参数 +- 数据库连接池的超时与空闲参数 + +支持通过环境变量覆盖: + +- `DB_URL` +- `DB_USERNAME` +- `DB_PASSWORD` + +## Redis + +- Host: `redisdev.xuqinmin.com` +- Port: `6379` +- Database: `0` + +支持通过环境变量覆盖: + +- `REDIS_HOST` +- `REDIS_PORT` +- `REDIS_DATABASE` +- `REDIS_PASSWORD` + +## 当前数据用途 + +### MySQL + +- 运营主账户 +- 子账户 +- 应用配置 +- 版本记录 +- 灰度用户基础数据 +- 用户分组 +- 快速选择配置 + +### Redis + +- 灰度用户列表查询缓存 +- 分组与快速选择组合查询结果缓存 + +## 注意事项 + +- 当前数据库与 Redis 密码已直接写入配置文件,仅适合当前内部开发阶段 +- 后续应迁移到环境变量、配置中心或密钥管理服务 diff --git a/doc/06-version-management.md b/doc/06-version-management.md new file mode 100644 index 0000000..2e428b5 --- /dev/null +++ b/doc/06-version-management.md @@ -0,0 +1,55 @@ +# 06 版本管理与灰度发布 + +## 业务范围 + +当前版本管理覆盖: + +- App 版本管理 +- 插件版本管理 +- 插件化开关 +- 版本包上传登记 +- 全量发布 +- 灰度发布 + +## 灰度发布规则 + +灰度发布基于用户平台钩子能力,当前支持三种圈选方式: + +- 分组选择 +- 快速选择 +- 单选用户 + +系统返回的用户字段包含: + +- 用户 ID +- 昵称 +- 手机号 +- 邮箱 +- 地区 +- 分组信息 + +其中 ID、昵称、手机号、邮箱在前台展示时进行脱敏。 + +## Android 拉取版本逻辑 + +### 全量版本 + +所有用户都可拉取到最新已发布版本。 + +### 灰度版本 + +只有命中灰度规则的用户才能拉取到灰度版本。 + +支持命中条件: + +- 命中指定用户 ID +- 命中指定用户分组 +- 命中指定快速选择集合 + +## 后续升级项 + +- 支持灰度比例发布 +- 支持按渠道、设备、地区、版本范围灰度 +- 支持灰度回滚 +- 支持发布时间窗口 +- 支持上传真实 APK/AAB/插件包并持久化元数据 diff --git a/doc/07-development-workflow.md b/doc/07-development-workflow.md new file mode 100644 index 0000000..7a5c221 --- /dev/null +++ b/doc/07-development-workflow.md @@ -0,0 +1,42 @@ +# 07 开发与交付流程 + +## 本地开发 + +### Android + +在 `AndroidLibs/` 下使用 Gradle 进行开发与构建。 + +```bash +cd AndroidLibs +./gradlew :sample-app:assembleDebug :plugins:plugin-ui:assembleDebug +``` + +### 前端 + +```bash +cd frontend +yarn install +yarn dev:ops +yarn dev:admin +``` + +### 服务端 + +```bash +cd server +mvn -pl version-management-service spring-boot:run +``` + +## 提交流程 + +- 功能开发完成后,同步更新 `doc/` 文档 +- 变更接口时,同时更新前端调用与服务端说明 +- 变更基础设施配置时,同时更新 `05-infrastructure.md` +- 变更版本管理逻辑时,同时更新 `06-version-management.md` +- 变更 Android SDK 模块结构或公共组件时,同时更新 `08-android-sdk.md` + +## 持续维护要求 + +- `doc/` 为项目正式文档目录,后续新增能力必须持续补充 +- 若引入新微服务,需要新增单独文档章节 +- 若引入 CI/CD、容器化部署、测试基线,需要补充新的专题文档 diff --git a/doc/08-android-sdk.md b/doc/08-android-sdk.md new file mode 100644 index 0000000..4b4dfc0 --- /dev/null +++ b/doc/08-android-sdk.md @@ -0,0 +1,54 @@ +# 08 Android SDK 说明 + +## 当前模块拆分 + +- `commonsdk-core` + - 网络能力:`HttpManager`、`RetrofitManager` + - 下载能力:`DownloadManager` + - 文件能力:`FileHelper` + - 权限能力:`PermissionManager` + - 缓存能力:`GlobalCache`、`SharedCacheManager` + - 通信能力:`PluginMessenger` + - 通用 UI 能力:`ToastCenter`、`DialogCenter` + - 工具能力:`Logger`、`DateTimeUtils` +- `commonsdk-compose` + - 下拉刷新:`SwipeRefreshContainer`、`RefreshableLazyColumn` + - 折叠面板:`AccordionPanel`、`RefreshableAccordionList` + - 左滑操作:`SwipeActionItem` + - WebView:`CommonWebView`、`WebViewPageActivity` + - 图片:`AdaptiveImage`、`CircleImage` +- `commonsdk-update` + - 宿主更新:`AppUpdater` + - 插件更新:`PluginPackageManager` + - SDK 门面:`UpdateSDK` + - 版本比较:`VersionComparator` + +## 设计原则 + +- 下载与文件安装归 `core`,保证后续任意业务 SDK 都能复用。 +- 宿主更新和插件更新归 `commonsdk-update`,避免核心模块承担业务升级策略。 +- Compose 组件保持业务无关,App 可直接引用或二次封装。 + +## 接入方式 + +### 宿主应用 + +- 初始化基础 SDK:`CoreSDK.init(context, CoreSDK.SDKConfig(...))` +- 使用更新能力:`UpdateSDK.appUpdater()`、`UpdateSDK.pluginPackageManager()` +- 使用公共能力:`CoreSDK.fileHelper()`、`CoreSDK.cache()`、`CoreSDK.dialogCenter()` + +### WebView 页面 + +- 内嵌组件:直接使用 `CommonWebView(url = "...")` +- 独立页面:通过 `WebViewPageActivity.createIntent(context, url, title)` 跳转 + +### 权限申请 + +- 通过 `PermissionManager.register(...)` 注册权限请求器 +- 请求时传入权限标题和描述,框架会先弹出确认说明,再真正调用系统权限弹窗 + +## 当前验证 + +- 2026-03-27 已执行: + - `./gradlew --no-daemon --no-configuration-cache :sample-app:assembleDebug :plugins:plugin-ui:assembleDebug` +- 结果:构建通过 diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..063bcf8 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,20 @@ +# AndroidLibsGroup 文档 + +当前目录用于沉淀项目的长期文档,后续新增模块、接口、部署说明、发布流程都持续更新在这里。 + +## 文档索引 + +- [01-项目概览](./01-project-overview.md) +- [02-架构设计](./02-architecture.md) +- [03-前端说明](./03-frontend.md) +- [04-服务端说明](./04-backend.md) +- [05-数据库与缓存](./05-infrastructure.md) +- [06-版本管理与灰度发布](./06-version-management.md) +- [07-开发与交付流程](./07-development-workflow.md) +- [08-Android SDK 说明](./08-android-sdk.md) + +## 维护约定 + +- 新增模块时,同步更新对应章节。 +- 接口或数据结构发生变化时,同步更新服务端、前端和版本管理文档。 +- 部署地址、账号、依赖版本变更时,优先更新 `04-05` 章节。 diff --git a/frontend/README.md b/frontend/README.md index e03a8d4..4d5eb39 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,10 +1,17 @@ # frontend -当前目录新增两个 Vue 3 前端项目: +当前目录新增两个 Vue 3 前端项目,并统一使用 Yarn workspace 管理: - `ops-platform`:运营平台,提供开放注册、版本管理、插件化开关、全量/灰度发布。 - `admin-platform`:管理平台,负责审核运营账户、禁用账户、管理子账户权限。 +## 安装依赖 + +```bash +cd frontend +yarn install +``` + ## 启动方式 先启动后端: @@ -17,15 +24,20 @@ mvn -pl version-management-service spring-boot:run 再分别启动前端: ```bash -cd frontend/ops-platform -npm install -npm run dev +cd frontend +yarn dev:ops ``` ```bash -cd frontend/admin-platform -npm install -npm run dev +cd frontend +yarn dev:admin +``` + +## 构建 + +```bash +cd frontend +yarn build ``` 前端默认请求 `http://127.0.0.1:8080`,如需调整可通过 `VITE_API_BASE_URL` 覆盖。 diff --git a/frontend/admin-platform/package.json b/frontend/admin-platform/package.json index 71706d7..e4787a2 100644 --- a/frontend/admin-platform/package.json +++ b/frontend/admin-platform/package.json @@ -2,6 +2,7 @@ "name": "admin-platform", "version": "0.1.0", "private": true, + "packageManager": "yarn@1.22.22", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/ops-platform/package.json b/frontend/ops-platform/package.json index 1f757e8..e45409c 100644 --- a/frontend/ops-platform/package.json +++ b/frontend/ops-platform/package.json @@ -2,6 +2,7 @@ "name": "ops-platform", "version": "0.1.0", "private": true, + "packageManager": "yarn@1.22.22", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/ops-platform/src/App.vue b/frontend/ops-platform/src/App.vue index eb81c62..a43d49c 100644 --- a/frontend/ops-platform/src/App.vue +++ b/frontend/ops-platform/src/App.vue @@ -1,22 +1,7 @@ diff --git a/frontend/ops-platform/src/api/client.ts b/frontend/ops-platform/src/api/client.ts index 5423f36..c1725fa 100644 --- a/frontend/ops-platform/src/api/client.ts +++ b/frontend/ops-platform/src/api/client.ts @@ -18,15 +18,36 @@ export interface Account { createdAt: string } +export interface LoginResult { + account: Account + tenantAccountId: string +} + +export interface PluginConfig { + id: string + appId: string + name: string + packageName: string + entryActivity?: string | null + description?: string | null + enabled: boolean +} + export interface ReleaseRecord { id: string + appId: string + pluginId?: string | null packageType: 'APP' | 'PLUGIN' + packageName: string versionCode: number versionName: string title: string changelog: string downloadUrl: string uploadedFileName: string + entryActivity?: string | null + minHostVersionCode?: number | null + minHostVersionName?: string | null status: 'DRAFT' | 'PUBLISHED' | 'GRAYSCALE' publishStrategy: string grayRule?: { @@ -42,10 +63,12 @@ export interface ApplicationDetail { id: string name: string packageName: string - pluginPackageName: string + pluginPackageName?: string | null + description?: string | null pluginManagementEnabled: boolean businessModules: string[] } + plugins: PluginConfig[] releases: ReleaseRecord[] } @@ -92,29 +115,80 @@ async function request(path: string, init?: RequestInit): Promise { } export const api = { - registerAccount(payload: Pick) { + registerAccount(payload: { + accountName: string + contactName: string + email: string + phone: string + password: string + }) { return request('/api/v1/open/accounts/register', { method: 'POST', body: JSON.stringify(payload), }) }, - listApplications() { - return request('/api/v1/ops/version/applications') - }, - togglePluginManagement(appId: string, enabled: boolean) { - return request(`/api/v1/ops/version/applications/${appId}/plugin-management`, { - method: 'PUT', - body: JSON.stringify({ enabled }), - }) - }, - uploadRelease(appId: string, payload: Record) { - return request(`/api/v1/ops/version/applications/${appId}/releases/upload`, { + login(payload: { email: string; password: string }) { + return request('/api/v1/open/accounts/login', { method: 'POST', body: JSON.stringify(payload), }) }, - publishRelease(appId: string, releaseId: string, payload: Record) { - return request(`/api/v1/ops/version/applications/${appId}/releases/${releaseId}/publish`, { + listApplications() { + return request('/api/v1/ops/apps') + }, + createApplication(payload: Record) { + return request('/api/v1/ops/apps', { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + updateApplication(appId: string, payload: Record) { + return request(`/api/v1/ops/apps/${appId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) + }, + togglePluginManagement(appId: string, enabled: boolean) { + return request(`/api/v1/ops/apps/${appId}/plugin-management`, { + method: 'PUT', + body: JSON.stringify({ enabled }), + }) + }, + listAppPackages(appId: string) { + return request(`/api/v1/ops/apps/${appId}/packages`) + }, + uploadAppPackage(appId: string, payload: Record) { + return request(`/api/v1/ops/apps/${appId}/packages`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + listPlugins(appId: string) { + return request(`/api/v1/ops/apps/${appId}/plugins`) + }, + createPlugin(appId: string, payload: Record) { + return request(`/api/v1/ops/apps/${appId}/plugins`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + updatePlugin(pluginId: string, payload: Record) { + return request(`/api/v1/ops/plugins/${pluginId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) + }, + listPluginPackages(pluginId: string) { + return request(`/api/v1/ops/plugins/${pluginId}/packages`) + }, + uploadPluginPackage(pluginId: string, payload: Record) { + return request(`/api/v1/ops/plugins/${pluginId}/packages`, { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + publishPackage(releaseId: string, payload: Record) { + return request(`/api/v1/ops/packages/${releaseId}/publish`, { method: 'POST', body: JSON.stringify(payload), }) diff --git a/frontend/ops-platform/src/router/index.ts b/frontend/ops-platform/src/router/index.ts index a1fd13b..ed8bce6 100644 --- a/frontend/ops-platform/src/router/index.ts +++ b/frontend/ops-platform/src/router/index.ts @@ -1,12 +1,23 @@ import { createRouter, createWebHistory } from 'vue-router' -import RegisterView from '../views/RegisterView.vue' -import VersionManagementView from '../views/VersionManagementView.vue' +import LoginView from '../views/LoginView.vue' +import OpsShellView from '../views/OpsShellView.vue' +import ModulePlaceholderView from '../views/ModulePlaceholderView.vue' +import AppManagementView from '../views/AppManagementView.vue' export default createRouter({ history: createWebHistory(), routes: [ - { path: '/', redirect: '/register' }, - { path: '/register', component: RegisterView }, - { path: '/versions', component: VersionManagementView }, + { path: '/', redirect: '/login' }, + { path: '/login', component: LoginView }, + { + path: '/console', + component: OpsShellView, + children: [ + { path: '', redirect: '/console/apps' }, + { path: 'im', component: ModulePlaceholderView, props: { title: 'IM 模块', description: '预留即时通讯管理控制台。' } }, + { path: 'push', component: ModulePlaceholderView, props: { title: 'Push 模块', description: '预留推送策略、模板与发布能力。' } }, + { path: 'apps', component: AppManagementView }, + ], + }, ], }) diff --git a/frontend/ops-platform/src/styles.css b/frontend/ops-platform/src/styles.css index 4e0d01a..8b17225 100644 --- a/frontend/ops-platform/src/styles.css +++ b/frontend/ops-platform/src/styles.css @@ -66,6 +66,13 @@ select { gap: 20px; } +.auth-layout { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + .stack { grid-template-columns: 1.25fr 1fr; align-items: start; @@ -167,6 +174,38 @@ button { flex-wrap: wrap; } +.tab-row, +.list-grid { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; +} + +.list-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.list-card { + text-align: left; + display: grid; + gap: 6px; + background: #eef5fb; + color: #163454; +} + +.list-card[data-active='true'] { + background: linear-gradient(135deg, #0d72ff, #11b8a5); + color: white; +} + +.sub-panel { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid rgba(16, 35, 61, 0.08); +} + .chips span, .chip-button { padding: 6px 10px; diff --git a/frontend/ops-platform/src/views/AppManagementView.vue b/frontend/ops-platform/src/views/AppManagementView.vue new file mode 100644 index 0000000..b9553a9 --- /dev/null +++ b/frontend/ops-platform/src/views/AppManagementView.vue @@ -0,0 +1,297 @@ +