This commit is contained in:
阿猫
2026-09-03 12:38:16 +08:00
parent 09669fe4a2
commit e96e92d7ab
68 changed files with 2810 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 采集占位M4+M5 数据采集S2/S3 挂载扫描与采集页)。 */
@AndroidEntryPoint
class CollectFragment : PlaceholderFragment()

View File

@@ -0,0 +1,41 @@
package com.stec.cmd
/**
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
*
* S1+ 挂载业务页面的方式:把对应 [MountItem.fragmentClass] 从占位 Fragment
* 换成业务 Fragment 即可导航壳MainActivity零改动新增 Tab 时扩展 [Tab]。
* 挂载映射依据《安卓客户端功能明细表》:
* 首页=M2、任务=M3、采集=M4+M5、统计=M6、我的=M1。
*/
object FeatureMount {
/** 底部导航 TabitemId 与 res/menu/bottom_nav_menu.xml 一一对应)。 */
enum class Tab(val itemId: Int, val labelRes: Int) {
HOME(R.id.nav_home, R.string.tab_home),
TASK(R.id.nav_task, R.string.tab_task),
COLLECT(R.id.nav_collect, R.string.tab_collect),
STATS(R.id.nav_stats, R.string.tab_stats),
MINE(R.id.nav_mine, R.string.tab_mine),
}
/** 单个 Tab 的挂载项。 */
data class MountItem(
val tab: Tab,
val fragmentClass: Class<out androidx.fragment.app.Fragment>,
val moduleTag: String,
)
/** 挂载注册表S1+ 替换 fragmentClass 即完成业务页挂载。 */
val mounts: List<MountItem> = listOf(
MountItem(Tab.HOME, HomeFragment::class.java, "M2 项目工作台"),
MountItem(Tab.TASK, TaskFragment::class.java, "M3 任务管理"),
MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"),
MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"),
MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),
)
/** 按 menu itemId 取挂载项;未知 id 返回 null容错。 */
fun byItemId(itemId: Int): MountItem? =
mounts.firstOrNull { it.tab.itemId == itemId }
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 首页占位M2 项目工作台S1 挂载项目列表)。 */
@AndroidEntryPoint
class HomeFragment : PlaceholderFragment()

View File

@@ -0,0 +1,59 @@
package com.stec.cmd
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.stec.cmd.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
/**
* 单 Activity 导航壳BottomNavigationView 5 Tab 切换 Fragment。
* show/hide 复用实例以保留各 Tab 视图状态S1 可迁移 Navigation 组件。
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.bottomNav.setOnItemSelectedListener(::selectTab)
if (savedInstanceState == null) {
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
}
}
private fun selectTab(item: MenuItem): Boolean {
val mount = FeatureMount.byItemId(item.itemId) ?: return false
switchTo(mount)
return true
}
private fun switchTo(mount: FeatureMount.MountItem) {
val tag = mount.tab.name
val existing = supportFragmentManager.findFragmentByTag(tag)
val target: Fragment = existing ?: createFragment(mount)
supportFragmentManager.commit {
setReorderingAllowed(true)
supportFragmentManager.fragments.forEach { if (it !== target) hide(it) }
if (existing == null) add(R.id.fragment_container, target, tag)
show(target)
}
supportActionBar?.title = getString(mount.tab.labelRes)
}
/** 实例化挂载项声明的 Fragment占位 Fragment 支持模块名参数。 */
private fun createFragment(mount: FeatureMount.MountItem): Fragment {
val fragment = mount.fragmentClass.getDeclaredConstructor().newInstance()
if (fragment is PlaceholderFragment) {
fragment.arguments = PlaceholderFragment.args(mount.moduleTag)
}
return fragment
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 我的占位M1 登录与配置S1 挂载登录/配置页)。 */
@AndroidEntryPoint
class MineFragment : PlaceholderFragment()

View File

@@ -0,0 +1,45 @@
package com.stec.cmd
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.stec.cmd.databinding.FragmentPlaceholderBinding
/**
* S0 占位页基类显示模块编号与挂载说明S1+ 由各业务 Fragment 替换。
*/
open class PlaceholderFragment : Fragment() {
private var _binding: FragmentPlaceholderBinding? = null
private val binding get() = checkNotNull(_binding)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentPlaceholderBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val moduleTag = arguments?.getString(ARG_MODULE_TAG).orEmpty()
binding.moduleTag.text = moduleTag
binding.hint.text = getString(R.string.placeholder_hint)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val ARG_MODULE_TAG = "module_tag"
fun args(moduleTag: String): Bundle = Bundle().apply {
putString(ARG_MODULE_TAG, moduleTag)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 统计占位M6 统计与上传S4 挂载统计表与文件上传)。 */
@AndroidEntryPoint
class StatsFragment : PlaceholderFragment()

View File

@@ -0,0 +1,31 @@
package com.stec.cmd
import android.app.Application
import android.util.Log
import com.stec.cmd.core.common.AppLog
import dagger.hilt.android.HiltAndroidApp
/**
* 应用入口:装配 Hilt 图并安装日志实现。
* core:common 的 AppLog 门面不依赖 android.util.LogLogCat sink 在壳层注入。
*/
@HiltAndroidApp
class SteCmdApplication : Application() {
override fun onCreate() {
super.onCreate()
AppLog.install { level, tag, message, throwable ->
when (level) {
AppLog.Level.DEBUG -> Log.d(tag, message, throwable)
AppLog.Level.INFO -> Log.i(tag, message, throwable)
AppLog.Level.WARN -> Log.w(tag, message, throwable)
AppLog.Level.ERROR -> Log.e(tag, message, throwable)
}
}
AppLog.i(TAG, "监测数据不落地平台客户端启动S0 骨架)")
}
private companion object {
const val TAG = "SteCmd"
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 任务占位M3 任务管理S2 挂载任务列表)。 */
@AndroidEntryPoint
class TaskFragment : PlaceholderFragment()

View File

@@ -0,0 +1,49 @@
package com.stec.cmd.config
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.stec.cmd.core.network.NetworkConfig
import com.stec.cmd.core.network.TokenProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(
name = "stec_cmd_config",
)
/**
* app 壳 Hilt 装配:
* - DataStore 实例;
* - [ConfigRepository] 绑定为 core:network 的 [NetworkConfig] / [TokenProvider]
* 补全 core:network NetworkModule 的依赖图(密钥值由用户配置注入,不入库)。
*/
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideConfigDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.configDataStore
}
@Module
@InstallIn(SingletonComponent::class)
abstract class ConfigBindModule {
@Binds
@Singleton
abstract fun bindNetworkConfig(impl: ConfigRepository): NetworkConfig
@Binds
@Singleton
abstract fun bindTokenProvider(impl: ConfigRepository): TokenProvider
}

View File

@@ -0,0 +1,127 @@
package com.stec.cmd.config
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.stec.cmd.core.common.AppLog
import com.stec.cmd.core.network.NetworkConfig
import com.stec.cmd.core.network.TokenProvider
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import javax.inject.Singleton
/**
* 平台配置仓储M1-06 前置、M7-04 落地):
* baseUrl / SecretKey / SystemCode / Token 经 DataStore 持久化 + 内存快照。
*
* **默认值全空,密钥不进代码库**S1 在「我的」页面提供配置 UI
* 未配置时 [NetworkConfig] 各字段为空串AuthInterceptor 将带空三要素,
* 业务请求由 S1 的配置检查(未配置 → 跳转配置页)拦截。
*
* 拦截器在 OkHttp 后台线程同步读快照runBlocking 只在冷启动首读时短暂阻塞,
* 之后 [snapshotStateFlow] 已被预热,直接返回缓存值)。
*/
@Singleton
class ConfigRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : NetworkConfig, TokenProvider {
data class Snapshot(
val baseUrl: String = "",
val secretKey: String = "",
val systemCode: String = "",
val token: String = "",
) {
/** 三要素 + 地址是否已配置完整。 */
val isReady: Boolean
get() = baseUrl.isNotBlank() && secretKey.isNotBlank() && systemCode.isNotBlank()
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val snapshotFlow = MutableStateFlow(Snapshot())
/** 配置快照流S1 配置页/业务层订阅。 */
val snapshotStateFlow: StateFlow<Snapshot> = snapshotFlow.asStateFlow()
init {
scope.launch {
dataStore.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}
.collect { prefs ->
snapshotFlow.value = Snapshot(
baseUrl = prefs[KEY_BASE_URL].orEmpty(),
secretKey = prefs[KEY_SECRET_KEY].orEmpty(),
systemCode = prefs[KEY_SYSTEM_CODE].orEmpty(),
token = prefs[KEY_TOKEN].orEmpty(),
)
}
}
}
// ---- NetworkConfig ----
override val baseUrl: String get() = snapshot().baseUrl
override val secretKey: String get() = snapshot().secretKey
override val systemCode: String get() = snapshot().systemCode
override val tokenProvider: TokenProvider get() = this
// ---- TokenProvider ----
override fun currentToken(): String = snapshot().token
// ---- 写入S1 配置页 / 登录流程调用)----
/** 保存三要素与服务器地址。 */
suspend fun updateConnection(baseUrl: String, secretKey: String, systemCode: String) {
dataStore.edit { prefs ->
prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
prefs[KEY_SECRET_KEY] = secretKey.trim()
prefs[KEY_SYSTEM_CODE] = systemCode.trim()
}
AppLog.i(TAG, "连接配置已更新")
}
/** 登录成功保存 Token登出传空串。 */
suspend fun updateToken(token: String) {
dataStore.edit { it[KEY_TOKEN] = token }
}
/** 阻塞读取当前快照(拦截器线程安全:值来自内存缓存)。 */
private fun snapshot(): Snapshot =
if (snapshotFlow.value != Snapshot()) {
snapshotFlow.value
} else {
// 冷启动首读:预热缓存
runBlocking { snapshotFlow.first() }
}
private companion object {
const val TAG = "ConfigRepository"
val KEY_BASE_URL = stringPreferencesKey("base_url")
val KEY_SECRET_KEY = stringPreferencesKey("secret_key")
val KEY_SYSTEM_CODE = stringPreferencesKey("system_code")
val KEY_TOKEN = stringPreferencesKey("token")
}
}
/** 只读 Token 快照接口的窄化视图(给不需要完整 NetworkConfig 的组件用)。 */
val ConfigRepository.tokenFlow: Flow<Snapshot> get() = snapshotStateFlow